iOS ARKit 教程:不觸摸屏幕够挂,用空氣中的手勢作畫
本文翻譯自 iOS ARKit Tutorial: Drawing in the Air with Bare Fingers覆获,原作者是 Osama AbdelKarim AboulHassan倍谜。
最近,Apple 發(fā)布了名為 ARKit 的全新增強現(xiàn)實(AR)庫禁荒。在許多人看來猬膨,這只是另一個好的 AR 庫而已,而不是什么值得關(guān)注的革命性技術(shù)呛伴。但如果你了解過去幾年 AR 的發(fā)展勃痴,就不會如此草率地下結(jié)論。
在本文中會用 iOS ARKit 創(chuàng)建一個好玩的項目热康。用戶把手指放在桌子上沛申,就好像握著一只筆,點擊拇指甲就可以開始繪畫姐军。完成后铁材,用戶還可以把畫作轉(zhuǎn)成 3D 對象,就像下面的動圖展示的那樣奕锌。此項目的完整源碼可以在 GitHub 上下載著觉。
動圖
為何現(xiàn)在要關(guān)注 ARKit?
每個有經(jīng)驗的開發(fā)者應(yīng)該都知道 AR 不是什么新概念了惊暴。AR 的第一次大規(guī)模開發(fā)要追溯到網(wǎng)絡(luò)攝像頭剛開始應(yīng)用的時期饼丘。那時的 app 通常用于對臉做一些變化。然而辽话,人們很快就發(fā)現(xiàn)把臉變成兔子并不是什么迫切的需求肄鸽,很快這波勢頭就降下去了!
我相信 AR 一直以來都有兩個關(guān)鍵技術(shù)沒有實現(xiàn)油啤,導(dǎo)致它沒那么實用:可用性和沉浸性典徘。如果你觀察過其它有關(guān) AR 的不實鼓吹,就會發(fā)現(xiàn)這兩點益咬。舉個例子逮诲,當開發(fā)者可以訪問手機攝像頭的時候,就出現(xiàn)了一波對 AR 的鼓吹础废。除了強勢回歸的偉大的變兔子工具之外汛骂,還有一波 app 可以把 3D 對象放到打印的二維碼上。但這個概念從來從來都沒有火過评腺。這并不是增強現(xiàn)實帘瞭,只是增強的二維碼而已。
然后 Google 用一次科技神話震驚了我們蒿讥,Google Glass蝶念。兩年過去抛腕,這個神奇的產(chǎn)品本應(yīng)來到了我們的生活,但現(xiàn)實卻是已經(jīng)死掉了媒殉!許多批評家分析 Google Glass 失敗的原因担敌,歸咎于從社會角度到 Google 發(fā)布產(chǎn)品時的無聊方式等等方面。但在本文中廷蓉,我們只關(guān)心一個原因 —— 在環(huán)境中的沉浸性全封。雖然 Google Glass 解決了可用性問題,但它仍然只是在空氣中繪制 2D 圖像而已桃犬。
像微軟刹悴、Facebook 和 Apple 這樣的科技泰斗都從這次深刻的教訓(xùn)中吸取了經(jīng)驗。2017 年七月攒暇,Apple 發(fā)布了美妙的 iOS ARKit 庫土匀,制造沉浸性成為了它的優(yōu)先任務(wù)。需要舉著手機使用對用戶體驗仍然有很大的影響形用,但 Google Glass 的教訓(xùn)告訴我們就轧,硬件不是問題。
我相信很快就要進入一波新的 AR 熱潮田度,并在在這個關(guān)鍵節(jié)點上妒御,它可能會最終找到的合適的市場。歷史課就上到這里每币,下面開始寫代碼携丁,實際了解 Apple 的增強現(xiàn)實琢歇!
ARKit 的沉浸功能
ARKit 提供了兩個主要功能兰怠;第一個是 3D 空間里的相機位置,第二個是水平面檢測李茫。前者的意思是揭保,ARKit 假定用戶的手機是在真實的 3D 空間里移動的攝像機,所以在任意位置放置 3D 虛擬對象都會錨定在真實 3D 空間中對應(yīng)的點上魄宏。對于后者來說秸侣,ARKit 可以檢測諸如桌子這樣的水平面,然后就可以在上面放置對象宠互。
那么 ARKit 是怎么做到的呢味榛?這是一項叫做視覺慣性里程計(VIO)的技術(shù)。不要擔心予跌,就像創(chuàng)業(yè)者樂于人們發(fā)現(xiàn)他們的創(chuàng)業(yè)公司名稱背后的秘密一樣搏色,研究人員也會樂于人們破譯他們命名的發(fā)明中的所有術(shù)語——所以讓他們開心吧,我們繼續(xù)往前看券册。
VIO 這項技術(shù)融合了攝像頭幀畫面和運動傳感器來追蹤設(shè)備在 3D 空間里的位置频轿。從攝像頭幀畫面中追蹤運動是通過檢測特征點實現(xiàn)的垂涯,也可以說是高對比度圖像中的邊緣點——就像藍色花瓶和白色桌子之間的邊緣。通過檢測兩幀畫面間特征點的相對移動距離航邢,就可以估算出設(shè)備在 3D 空間里的位置耕赘。所以如果用戶面對一面缺少特征點的白墻,或者設(shè)備移動過快導(dǎo)致畫面模糊膳殷,ARKit 都會無法正常工作操骡。
上手 iOS 中的 ARKit
寫作本文時,ARKit 是 iOS 11 的一部分赚窃,仍然在 beta 版本当娱。所以要上手的話,你需要在 iPhone 6s 或更新的設(shè)備上下載 iOS 11 Beta考榨,當然還有新的 Xcode Beta跨细。我們可以用 New > Project > Augmented Reality App 來新建一個 ARKit 項目。但是我發(fā)現(xiàn)使用官方 Apple ARKit 示例開始會更方便河质,它提供了一些必要的代碼塊冀惭,尤其對于平面檢測很有幫助。所以掀鹅,從這個示例代碼開始吧散休,我會首先解析里面的關(guān)鍵點,然后將其修改為我們自己的項目乐尊。
首先戚丸,我們要確定使用哪個引擎。ARKit 可用于 Sprite SceneKit 或 Meta扔嵌。在 Apple ARKit 示例里限府,我們是用的是 iOS SceneKit,由 Apple 提供的 3D 引擎痢缎。接下來胁勺,我們需要設(shè)置用于渲染 3D 對象的視圖。添加一個 ARSCNView 類型的視圖即可独旷。
ARSCNView 是 SceneKit 主視圖 SCNView 的子類署穗,但它擴展了一些有用的功能。它會將設(shè)備攝像頭的實時視頻流渲染為場景背景嵌洼,并會自動匹配 SceneKit 空間和真實世界案疲,假定設(shè)備是這個世界里的移動 camera。
ARSCNView 本身不會做 AR 處理麻养,但它需要 AR session 對象來管理設(shè)備攝像頭和運動處理褐啡。所以,從賦值一個新的 session 開始:
self.session = ARSession()
sceneView.session = session
sceneView.delegate = self
setupFocusSquare()
上面的最后一行代碼添加了一個視覺指示回溺,讓用戶直觀地了解平面檢測狀態(tài)春贸。Focus Square 是示例代碼提供的混萝,而不是 ARKit 庫,這也是我們用示例代碼上手的重要原因之一萍恕。在示例代碼里的 readme 文件里可以找到更多信息逸嘀。下面這張圖顯示了映射在桌子上的 focus square:
下一步是啟動 ARKit session。每次 view appears 時都要重啟 session允粤,因為停止追蹤用戶后崭倘,之前的 session 信息就沒有價值了。所以类垫,在 viewDidAppear 里啟動 session:
override func viewDidAppear(_ animated: Bool) {
let configuration = ARWorldTrackingSessionConfiguration()
configuration.planeDetection = .horizontal
session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
}
在上面的代碼里司光,設(shè)置了 ARKit session configuration 來檢測平面。寫作本文時悉患,Apple 沒有提供除此以外的選項残家。但很明顯,這暗示了未來可以檢測到更復(fù)雜的對象售躁。然后坞淮,開始運行 session 并確保重置了追蹤。
最后陪捷,我們需要在攝像頭位置(即實際的設(shè)備角度和位置)改變時更新 Focus Square回窘。可以在 SCNView 的 renderer delegate 函數(shù)里實現(xiàn)市袖,每次 3D 引擎將要渲染新的幀時都會調(diào)用:
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
updateFocusSquare()
}
此時運行 app啡直,就可以看見攝像頭視頻流中位于檢測到的水平面上的 focus square 了。在下一個部分苍碟,我們解釋平面是如何被檢測到的酒觅,以及如何對應(yīng)放置 focus square。
平面檢測
ARKit 可以檢測新平面驰怎,更新現(xiàn)有平面阐滩,或是移除它們二打。為了便于處理平面县忌,我們會創(chuàng)建一些虛擬的 SceneKit node 來管理平面的位置信息以及對 focus square 的引用。平面是定義在 X 和 Z 方向上的继效,Y 則是表面的法線症杏,也就是說,如果想在平面上繪制一個 node 的話瑞信,應(yīng)保持該 node 的 Y 值與平面相同厉颤。
平面檢測是通過 ARKit 提供的回調(diào)函數(shù)來完成的。舉個例子凡简,下面的回調(diào)函數(shù)會在每次檢測到新平面時調(diào)用:
var planes = [ARPlaneAnchor: Plane]()
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
if let planeAnchor = anchor as? ARPlaneAnchor {
serialQueue.async {
self.addPlane(node: node, anchor: planeAnchor)
self.virtualObjectManager.checkIfObjectShouldMoveOntoPlane(anchor: planeAnchor, planeAnchorNode: node)
}
}
}
func addPlane(node: SCNNode, anchor: ARPlaneAnchor) {
let plane = Plane(anchor)
planes[anchor] = plane
node.addChildNode(plane)
}
...
class Plane: SCNNode {
var anchor: ARPlaneAnchor
var focusSquare: FocusSquare?
init(_ anchor: ARPlaneAnchor) {
self.anchor = anchor
super.init()
}
...
}
回調(diào)函數(shù)給我們提供了兩個參數(shù)逼友,anchor 和 node精肃。node 是一個普通的 SceneKit node,角度和位置與平面完全相同帜乞。它沒有幾何體司抱,所以是可不見的。我們用它來添加自己的平面 node黎烈,同樣也是不可見的习柠,但會管理 anchor 里有關(guān)平面角度和位置的信息。
所以位置和角度是如何存儲在 ARPlaneAnchor 中的呢照棋?位置资溃、角度和比例都被編碼在 4x4 矩陣中。如果我可以讓你學(xué)會一個數(shù)學(xué)概念的話烈炭,毫無疑問就是矩陣了溶锭。不過沒關(guān)系,可以把 4x4 矩陣想象為:一個包含 4x4 浮點數(shù)字的 2D 智能 2D 數(shù)組符隙。用某種特定的方式將這些數(shù)字乘以它在局部空間中的 3D 頂點 v1 就會得到新的 3D 頂點 v2暖途,即 v1 在世界空間中的表示。所以膏执,如果局部空間里的 v1 = (1, 0, 0)驻售,并且希望把它放在世界空間中 x = 100 的位置,相對于世界空間的 v2 就會等于 (101, 0, 0)更米。當然欺栗,如果還要添加繞軸旋轉(zhuǎn),背后的數(shù)學(xué)就會變得更加復(fù)雜征峦,但好消息是我們沒必要理解這背后的原理(我強烈建議看看這篇文章中的相關(guān)部分迟几,里面有關(guān)于此概念的深入解釋)。
checkIfObjectShouldMoveOntoPlane 會檢查是否已經(jīng)繪制了對象栏笆,以及有沒有對象的 y 坐標匹配新檢測到的平面类腮。
現(xiàn)在,回到上一部分描述的 updateFocusSquare()蛉加。我們想要保證 focus square 在屏幕中心蚜枢,并映射到檢測到的距離最近的平面上。使用如下代碼實現(xiàn):
func updateFocusSquare() {
let worldPos = worldPositionFromScreenPosition(screenCenter, self.sceneView)
self.focusSquare?.simdPosition = worldPos
}
func worldPositionFromScreenPosition(_ position: CGPoint, in sceneView: ARSCNView) -> float3? {
let planeHitTestResults = sceneView.hitTest(position, types: .existingPlaneUsingExtent)
if let result = planeHitTestResults.first {
return result.worldTransform.translation
}
return nil
}
sceneView.hitTest 會搜索對應(yīng)屏幕上的 2D 點的真實世界平面针饥,方式是映射這個 2D 點到下方最近的平面上厂抽。result.worldTransform 是一個 4x4 矩陣,具有檢測到的平面的所有 transform 信息丁眼,而 result.worldTransform.translation 則用于只取出位置筷凤。
現(xiàn)在我們已經(jīng)具備所需的全部信息,以便根據(jù)屏幕上的 2D 點向檢測到的平面上放置 3D 對象苞七。所以下面開始繪制吧藐守。
繪圖
首先解釋一下如何利用計算機視覺跟隨人的手指來繪制圖形挪丢。繪制是通過檢測手指移動的每個位置完成的,在對應(yīng)的位置放置一個頂點卢厂,并將每個頂點與前面的頂點相連吃靠。頂點可以通過一條簡單的線連接,如果需要平滑的輸出的話足淆,則可以通過 Bezier 曲線完成巢块。
為了簡單起見,我們會使用一些原生的繪圖方法巧号。對于手指的新位置族奢,我們會在被檢測到的平面上放置一個非常小的圓角 box,高度幾乎為零丹鸿≡阶撸看起來就像一個點一樣。用戶完成繪制并點擊 3D 按鈕后靠欢,則會根據(jù)用戶手指的移動改變放置對象的高度廊敌。
下面的代碼展示了用于表示點的 PointNode 類:
let POINT_SIZE = CGFloat(0.003)
let POINT_HEIGHT = CGFloat(0.00001)
class PointNode: SCNNode {
static var boxGeo: SCNBox?
override init() {
super.init()
if PointNode.boxGeo == nil {
PointNode.boxGeo = SCNBox(width: POINT_SIZE, height: POINT_HEIGHT, length: POINT_SIZE, chamferRadius: 0.001)
// 設(shè)置點的材質(zhì)
let material = PointNode.boxGeo!.firstMaterial
material?.lightingModel = SCNMaterial.LightingModel.blinn
material?.diffuse.contents? = UIImage(named: "wood-diffuse.jpg")
material?.normal.contents? = UIImage(named: "wood-normal.png")
material?.specular.contents = UIImage(named: "wood-specular.jpg")
}
let object = SCNNode(geometry: PointNode.boxGeo!)
object.transform = SCNMatrix4MakeTranslation(0.0, Float(POINT_HEIGHT) / 2.0, 0.0)
self.addChildNode(object)
}
. . .
在上面的代碼把幾何體沿 y 軸移動了高度的一半。這樣做是為了確保對象的底部總是處于 y = 0 的位置门怪,這樣看起來就像在平面上一樣骡澈。
下面,在 SceneKit 的 renderer 回調(diào)函數(shù)中掷空,使用 PointNode 類繪制一個指示來表示筆尖肋殴。如果開啟了繪圖的話,就會在那個位置放一個點下去坦弟,如果開啟的是 3D 模式护锤,則會將繪圖抬高,變成 3D 結(jié)構(gòu)體:
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
updateFocusSquare()
// 設(shè)置表示虛擬筆尖的點
if (self.virtualPenTip == nil) {
self.virtualPenTip = PointNode(color: UIColor.red)
self.sceneView.scene.rootNode.addChildNode(self.virtualPenTip!)
}
// 繪圖
if let screenCenterInWorld = worldPositionFromScreenPosition(self.screenCenter, self.sceneView) {
// 更新虛擬筆尖位置
self.virtualPenTip?.isHidden = false
self.virtualPenTip?.simdPosition = screenCenterInWorld
// 繪制新的點
if (self.inDrawMode && !self.virtualObjectManager.pointNodeExistAt(pos: screenCenterInWorld)){
let newPoint = PointNode()
self.sceneView.scene.rootNode.addChildNode(newPoint)
self.virtualObjectManager.loadVirtualObject(newPoint, to: screenCenterInWorld)
}
// 將繪圖轉(zhuǎn)為 3D
if (self.in3DMode ) {
if self.trackImageInitialOrigin != nil {
DispatchQueue.main.async {
let newH = 0.4 *? (self.trackImageInitialOrigin!.y - screenCenterInWorld.y) / self.sceneView.frame.height
self.virtualObjectManager.setNewHeight(newHeight: newH)
}
}
else {
self.trackImageInitialOrigin = screenCenterInWorld
}
}
}
檢測用戶指尖
Apple 在 iOS 11 發(fā)布的另一個牛逼閃閃的庫是 Vision 框架酿傍。它以一種相當方便和有效的方式提供可一些計算機視覺技術(shù)烙懦。我們會使用其中的對象追蹤技術(shù)。對象追蹤的工作原理如下:首先需要提供一張圖像赤炒,以及圖像中被追蹤的對象的正方形邊界坐標氯析。然后調(diào)用幾個函數(shù)來初始化追蹤。最后,為其提供一個新的圖像以及之前操作獲得的分析結(jié)果,在新圖像里該對象的位置發(fā)生了改變前酿。如果我們給定了這些信息蜗元,它就會返回對象的新位置。
下面采用一種巧妙的方式旺罢。讓用戶把手放在桌上旷余,就像在握著一支筆绢记,然后確保指甲蓋面向攝像頭,然后點擊屏幕上的指甲蓋正卧。這里需要說明兩點蠢熄。第一,指甲蓋應(yīng)該具有足夠的獨特性炉旷,以便在白色指甲蓋签孔、皮膚和桌子之間實現(xiàn)追蹤。也就是說深色皮膚會讓追蹤更加可靠窘行。第二饥追,因為用戶是把手放在桌上的,再加上我們已經(jīng)檢測到了桌子的平面罐盔,所以將指甲蓋的位置從 2D 視圖映射到 3D 環(huán)境中的話但绕,位置就會和手指在桌子上的位置極為接近。
下面這張圖顯示了 Vision 庫檢測到的特征點:
然后用一個觸摸手勢來初始化指甲蓋追蹤:
// MARK: 對象追蹤
fileprivate var lastObservation: VNDetectedObjectObservation?
var trackImageBoundingBox: CGRect?
let trackImageSize = CGFloat(20)
@objc private func tapAction(recognizer: UITapGestureRecognizer) {
lastObservation = nil
let tapLocation = recognizer.location(in: view)
// 用視圖坐標空間設(shè)置 image 中的 rect 以便用于追蹤
let trackImageBoundingBoxOrigin = CGPoint(x: tapLocation.x - trackImageSize / 2, y: tapLocation.y - trackImageSize / 2)
trackImageBoundingBox = CGRect(origin: trackImageBoundingBoxOrigin, size: CGSize(width: trackImageSize, height: trackImageSize))
let t = CGAffineTransform(scaleX: 1.0 / self.view.frame.size.width, y: 1.0 / self.view.frame.size.height)
let normalizedTrackImageBoundingBox = trackImageBoundingBox!.applying(t)
// 將 rect 從視圖坐標控件轉(zhuǎn)換為圖片空間
guard let fromViewToCameraImageTransform = self.sceneView.session.currentFrame?.displayTransform(withViewportSize: self.sceneView.frame.size, orientation: UIInterfaceOrientation.portrait).inverted() else {
return
}
var trackImageBoundingBoxInImage =? normalizedTrackImageBoundingBox.applying(fromViewToCameraImageTransform)
trackImageBoundingBoxInImage.origin.y = 1 - trackImageBoundingBoxInImage.origin.y? // Image space uses bottom left as origin while view space uses top left
lastObservation = VNDetectedObjectObservation(boundingBox: trackImageBoundingBoxInImage)
}
上面最棘手的部分就是如何把點擊位置從 UIView 坐標控件轉(zhuǎn)換到圖片坐標空間惶看。ARKit 只為我們提供了從圖像坐標空間轉(zhuǎn)換為 viewport 坐標控件的 displayTransform 矩陣捏顺。所以如何實現(xiàn)相反的操作呢?只要使用逆矩陣即可纬黎。我在這篇文章里已經(jīng)嘗試盡量少用數(shù)學(xué)幅骄,但在 3D 世界里有時就是難以避免。
下面本今。在 renderer 中提供一個新圖像來追蹤手指的新位置:
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
// 追蹤指甲蓋
guard let pixelBuffer = self.sceneView.session.currentFrame?.capturedImage,
let observation = self.lastObservation else {
return
}
let request = VNTrackObjectRequest(detectedObjectObservation: observation) { [unowned self] request, error in
self.handle(request, error: error)
}
request.trackingLevel = .accurate
do {
try self.handler.perform([request], on: pixelBuffer)
}
catch {
print(error)
}
. . .
}
對象追蹤完成后昌执,會調(diào)用一個回調(diào)函數(shù),用它來更新指甲蓋的位置诈泼《埃基本就是上面在觸摸手勢里相反的代碼:
fileprivate func handle(_ request: VNRequest, error: Error?) {
DispatchQueue.main.async {
guard let newObservation = request.results?.first as? VNDetectedObjectObservation else {
return
}
self.lastObservation = newObservation
var trackImageBoundingBoxInImage = newObservation.boundingBox
// 從圖像空間轉(zhuǎn)換到視圖空間
trackImageBoundingBoxInImage.origin.y = 1 - trackImageBoundingBoxInImage.origin.y
guard let fromCameraImageToViewTransform = self.sceneView.session.currentFrame?.displayTransform(withViewportSize: self.sceneView.frame.size, orientation: UIInterfaceOrientation.portrait) else {
return
}
let normalizedTrackImageBoundingBox = trackImageBoundingBoxInImage.applying(fromCameraImageToViewTransform)
let t = CGAffineTransform(scaleX: self.view.frame.size.width, y: self.view.frame.size.height)
let unnormalizedTrackImageBoundingBox = normalizedTrackImageBoundingBox.applying(t)
self.trackImageBoundingBox = unnormalizedTrackImageBoundingBox
// 獲取追蹤的圖像在圖像空間的位置在距離最近的檢測到的平面上的映射
if let trackImageOrigin = self.trackImageBoundingBox?.origin {
self.lastFingerWorldPos = self.virtualObjectManager.worldPositionFromScreenPosition(CGPoint(x: trackImageOrigin.x - 20.0, y: trackImageOrigin.y + 40.0), in: self.sceneView)
}
}
}
最后,繪圖時使用 self.lastFingerWorldPos 而不是屏幕中心铐达,這樣就全部結(jié)束了岖赋。
談一談 ARKit 和未來
在這篇文章里,我們感受到了 AR 如何通過與用戶的手指和現(xiàn)實生活中的桌子交互來實現(xiàn)沉浸式體驗瓮孙。隨著計算機視覺的發(fā)展唐断,以及新增加的對 AR 友好的硬件(如深度攝像頭),我們可以就可以更多地獲取身邊對象的 3D 結(jié)構(gòu)杭抠。
盡管微軟的 Hololens 設(shè)備還沒有向大眾發(fā)布脸甘,但微軟已經(jīng)決心要贏得這場 AR 競賽,這個設(shè)備組合了 AR 定制的硬件并帶有高級 3D 環(huán)境識別技術(shù)偏灿。你可以靜靜看著誰會贏得這場比賽丹诀,也可以現(xiàn)在就加入開發(fā)沉浸式 AR app 的大軍!但是一定要做點對人類有意義的事,而不是把我們變成兔子铆遭。
附錄
Apple 的 ARKit 為開發(fā)者提供了哪些功能硝桩?
ARKit 可以讓開發(fā)者在 iPhone 和 iPad 上構(gòu)建沉浸式增強現(xiàn)實 app,通過分析攝像頭視圖展示的場景并找出房間里的水平面枚荣。
如何用 Apple 的 Vision 庫來追蹤對象碗脊?
Apple 的 Vision 庫可以讓開發(fā)者追蹤視頻流中的對象。開發(fā)者提供初始圖像幀中待追蹤對象的矩形坐標橄妆,然后提供視頻幀衙伶,這個庫就會返回該對象的最新位置。
如何上手 Apple 的 ARKit害碾?
要上手 Apple 的 ARKit矢劲,在 iPhone 6s 或更高的設(shè)備上下載 iOS 11 并用 New > Project > Augmented Reality App 創(chuàng)建一個新的 ARKit 項目。同時也可以看看蘋果在這里提供的 AR 示例代碼:https://developer.apple.com/arkit/