原文
本文翻譯自 iOS ARKit Tutorial: Drawing in the Air with Bare Fingers,原作者是 Osama AbdelKarim AboulHassan。
最近,Apple 發布了名為 ARKit 的全新增強現實(AR)庫。在許多人看來,這只是另一個好的 AR 庫而已,而不是什麼值得關注的革命性技術。但如果你了解過去幾年 AR 的發展,就不會如此草率地下結論。
在本文中會用 iOS ARKit 創建一個好玩的項目。用戶把手指放在桌子上,就好像握著一只筆,點擊拇指甲就可以開始繪畫。完成後,用戶還可以把畫作轉成 3D 對象,就像下面的動圖展示的那樣。此項目的完整源碼可以在 GitHub 上下載。
動圖
為何現在要關注 ARKit?
每個有經驗的開發者應該都知道 AR 不是什麼新概念了。AR 的第一次大規模開發要追溯到網絡攝像頭剛開始應用的時期。那時的 app 通常用於對臉做一些變化。然而,人們很快就發現把臉變成兔子並不是什麼迫切的需求,很快這波勢頭就降下去了!
我相信 AR 一直以來都有兩個關鍵技術沒有實現,導致它沒那麼實用:可用性和沉浸性。如果你觀察過其它有關 AR 的不實鼓吹,就會發現這兩點。舉個例子,當開發者可以訪問手機攝像頭的時候,就出現了一波對 AR 的鼓吹。除了強勢回歸的偉大的變兔子工具之外,還有一波 app 可以把 3D 對象放到打印的二維碼上。但這個概念從來從來都沒有火過。這並不是增強現實,只是增強的二維碼而已。
然後 Google 用一次科技神話震驚了我們,Google Glass。兩年過去,這個神奇的產品本應來到了我們的生活,但現實卻是已經死掉了!許多批評家分析 Google Glass 失敗的原因,歸咎於從社會角度到 Google 發布產品時的無聊方式等等方面。但在本文中,我們只關心一個原因 —— 在環境中的沉浸性。雖然 Google Glass 解決了可用性問題,但它仍然只是在空氣中繪制 2D 圖像而已。
像微軟、Facebook 和 Apple 這樣的科技泰斗都從這次深刻的教訓中吸取了經驗。2017 年七月,Apple 發布了美妙的 iOS ARKit 庫,制造沉浸性成為了它的優先任務。需要舉著手機使用對用戶體驗仍然有很大的影響,但 Google Glass 的教訓告訴我們,硬件不是問題。
我相信很快就要進入一波新的 AR 熱潮,並在在這個關鍵節點上,它可能會最終找到的合適的市場。歷史課就上到這裡,下面開始寫代碼,實際了解 Apple 的增強現實!
ARKit 的沉浸功能
ARKit 提供了兩個主要功能;第一個是 3D 空間裡的相機位置,第二個是水平面檢測。前者的意思是,ARKit 假定用戶的手機是在真實的 3D 空間裡移動的攝像機,所以在任意位置放置 3D 虛擬對象都會錨定在真實 3D 空間中對應的點上。對於後者來說,ARKit 可以檢測諸如桌子這樣的水平面,然後就可以在上面放置對象。
那麼 ARKit 是怎麼做到的呢?這是一項叫做視覺慣性裡程計(VIO)的技術。不要擔心,就像創業者樂於人們發現他們的創業公司名稱背後的秘密一樣,研究人員也會樂於人們破譯他們命名的發明中的所有術語——所以讓他們開心吧,我們繼續往前看。
VIO 這項技術融合了攝像頭幀畫面和運動傳感器來追蹤設備在 3D 空間裡的位置。從攝像頭幀畫面中追蹤運動是通過檢測特征點實現的,也可以說是高對比度圖像中的邊緣點——就像藍色花瓶和白色桌子之間的邊緣。通過檢測兩幀畫面間特征點的相對移動距離,就可以估算出設備在 3D 空間裡的位置。所以如果用戶面對一面缺少特征點的白牆,或者設備移動過快導致畫面模糊,ARKit 都會無法正常工作。
上手 iOS 中的 ARKit
寫作本文時,ARKit 是 iOS 11 的一部分,仍然在 beta 版本。所以要上手的話,你需要在 iPhone 6s 或更新的設備上下載 iOS 11 Beta,當然還有新的 Xcode Beta。我們可以用 New > Project > Augmented Reality App 來新建一個 ARKit 項目。但是我發現使用官方 Apple ARKit 示例開始會更方便,它提供了一些必要的代碼塊,尤其對於平面檢測很有幫助。所以,從這個示例代碼開始吧,我會首先解析裡面的關鍵點,然後將其修改為我們自己的項目。
首先,我們要確定使用哪個引擎。ARKit 可用於 Sprite SceneKit 或 Meta。在 Apple ARKit 示例裡,我們是用的是 iOS SceneKit,由 Apple 提供的 3D 引擎。接下來,我們需要設置用於渲染 3D 對象的視圖。添加一個 ARSCNView 類型的視圖即可。
ARSCNView 是 SceneKit 主視圖 SCNView 的子類,但它擴展了一些有用的功能。它會將設備攝像頭的實時視頻流渲染為場景背景,並會自動匹配 SceneKit 空間和真實世界,假定設備是這個世界裡的移動 camera。
ARSCNView 本身不會做 AR 處理,但它需要 AR session 對象來管理設備攝像頭和運動處理。所以,從賦值一個新的 session 開始:
self.session = ARSession() sceneView.session = session sceneView.delegate = self setupFocusSquare()
上面的最後一行代碼添加了一個視覺指示,讓用戶直觀地了解平面檢測狀態。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]) }
在上面的代碼裡,設置了 ARKit session configuration 來檢測平面。寫作本文時,Apple 沒有提供除此以外的選項。但很明顯,這暗示了未來可以檢測到更復雜的對象。然後,開始運行 session 並確保重置了追蹤。
最後,我們需要在攝像頭位置(即實際的設備角度和位置)改變時更新 Focus Square。可以在 SCNView 的 renderer delegate 函數裡實現,每次 3D 引擎將要渲染新的幀時都會調用:
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() }
此時運行 app,就可以看見攝像頭視頻流中位於檢測到的水平面上的 focus square 了。在下一個部分,我們解釋平面是如何被檢測到的,以及如何對應放置 focus square。
平面檢測
ARKit 可以檢測新平面,更新現有平面,或是移除它們。為了便於處理平面,我們會創建一些虛擬的 SceneKit node 來管理平面的位置信息以及對 focus square 的引用。平面是定義在 X 和 Z 方向上的,Y 則是表面的法線,也就是說,如果想在平面上繪制一個 node 的話,應保持該 node 的 Y 值與平面相同。
平面檢測是通過 ARKit 提供的回調函數來完成的。舉個例子,下面的回調函數會在每次檢測到新平面時調用:
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() } ... }
回調函數給我們提供了兩個參數,anchor 和 node。node 是一個普通的 SceneKit node,角度和位置與平面完全相同。它沒有幾何體,所以是可不見的。我們用它來添加自己的平面 node,同樣也是不可見的,但會管理 anchor 裡有關平面角度和位置的信息。
所以位置和角度是如何存儲在 ARPlaneAnchor 中的呢?位置、角度和比例都被編碼在 4x4 矩陣中。如果我可以讓你學會一個數學概念的話,毫無疑問就是矩陣了。不過沒關系,可以把 4x4 矩陣想象為:一個包含 4x4 浮點數字的 2D 智能 2D 數組。用某種特定的方式將這些數字乘以它在局部空間中的 3D 頂點 v1 就會得到新的 3D 頂點 v2,即 v1 在世界空間中的表示。所以,如果局部空間裡的 v1 = (1, 0, 0),並且希望把它放在世界空間中 x = 100 的位置,相對於世界空間的 v2 就會等於 (101, 0, 0)。當然,如果還要添加繞軸旋轉,背後的數學就會變得更加復雜,但好消息是我們沒必要理解這背後的原理(我強烈建議看看這篇文章中的相關部分,裡面有關於此概念的深入解釋)。
checkIfObjectShouldMoveOntoPlane 會檢查是否已經繪制了對象,以及有沒有對象的 y 坐標匹配新檢測到的平面。
現在,回到上一部分描述的 updateFocusSquare()。我們想要保證 focus square 在屏幕中心,並映射到檢測到的距離最近的平面上。使用如下代碼實現:
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 會搜索對應屏幕上的 2D 點的真實世界平面,方式是映射這個 2D 點到下方最近的平面上。result.worldTransform 是一個 4x4 矩陣,具有檢測到的平面的所有 transform 信息,而 result.worldTransform.translation 則用於只取出位置。
現在我們已經具備所需的全部信息,以便根據屏幕上的 2D 點向檢測到的平面上放置 3D 對象。所以下面開始繪制吧。
繪圖
首先解釋一下如何利用計算機視覺跟隨人的手指來繪制圖形。繪制是通過檢測手指移動的每個位置完成的,在對應的位置放置一個頂點,並將每個頂點與前面的頂點相連。頂點可以通過一條簡單的線連接,如果需要平滑的輸出的話,則可以通過 Bezier 曲線完成。
為了簡單起見,我們會使用一些原生的繪圖方法。對於手指的新位置,我們會在被檢測到的平面上放置一個非常小的圓角 box,高度幾乎為零。看起來就像一個點一樣。用戶完成繪制並點擊 3D 按鈕後,則會根據用戶手指的移動改變放置對象的高度。
下面的代碼展示了用於表示點的 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) // 設置點的材質 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 回調函數中,使用 PointNode 類繪制一個指示來表示筆尖。如果開啟了繪圖的話,就會在那個位置放一個點下去,如果開啟的是 3D 模式,則會將繪圖抬高,變成 3D 結構體:
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() // 設置表示虛擬筆尖的點 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) } // 將繪圖轉為 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 發布的另一個牛逼閃閃的庫是 Vision 框架。它以一種相當方便和有效的方式提供可一些計算機視覺技術。我們會使用其中的對象追蹤技術。對象追蹤的工作原理如下:首先需要提供一張圖像,以及圖像中被追蹤的對象的正方形邊界坐標。然後調用幾個函數來初始化追蹤。最後,為其提供一個新的圖像以及之前操作獲得的分析結果,在新圖像裡該對象的位置發生了改變。如果我們給定了這些信息,它就會返回對象的新位置。
下面采用一種巧妙的方式。讓用戶把手放在桌上,就像在握著一支筆,然後確保指甲蓋面向攝像頭,然後點擊屏幕上的指甲蓋。這裡需要說明兩點。第一,指甲蓋應該具有足夠的獨特性,以便在白色指甲蓋、皮膚和桌子之間實現追蹤。也就是說深色皮膚會讓追蹤更加可靠。第二,因為用戶是把手放在桌上的,再加上我們已經檢測到了桌子的平面,所以將指甲蓋的位置從 2D 視圖映射到 3D 環境中的話,位置就會和手指在桌子上的位置極為接近。
下面這張圖顯示了 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) // 用視圖坐標空間設置 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 從視圖坐標控件轉換為圖片空間 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 坐標控件轉換到圖片坐標空間。ARKit 只為我們提供了從圖像坐標空間轉換為 viewport 坐標控件的 displayTransform 矩陣。所以如何實現相反的操作呢?只要使用逆矩陣即可。我在這篇文章裡已經嘗試盡量少用數學,但在 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) } . . . }
對象追蹤完成後,會調用一個回調函數,用它來更新指甲蓋的位置。基本就是上面在觸摸手勢裡相反的代碼:
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 // 從圖像空間轉換到視圖空間 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 而不是屏幕中心,這樣就全部結束了。
談一談 ARKit 和未來
在這篇文章裡,我們感受到了 AR 如何通過與用戶的手指和現實生活中的桌子交互來實現沉浸式體驗。隨著計算機視覺的發展,以及新增加的對 AR 友好的硬件(如深度攝像頭),我們可以就可以更多地獲取身邊對象的 3D 結構。
盡管微軟的 Hololens 設備還沒有向大眾發布,但微軟已經決心要贏得這場 AR 競賽,這個設備組合了 AR 定制的硬件並帶有高級 3D 環境識別技術。你可以靜靜看著誰會贏得這場比賽,也可以現在就加入開發沉浸式 AR app 的大軍!但是一定要做點對人類有意義的事,而不是把我們變成兔子。
附錄
Apple 的 ARKit 為開發者提供了哪些功能?
ARKit 可以讓開發者在 iPhone 和 iPad 上構建沉浸式增強現實 app,通過分析攝像頭視圖展示的場景並找出房間裡的水平面。
如何用 Apple 的 Vision 庫來追蹤對象?
Apple 的 Vision 庫可以讓開發者追蹤視頻流中的對象。開發者提供初始圖像幀中待追蹤對象的矩形坐標,然後提供視頻幀,這個庫就會返回該對象的最新位置。
如何上手 Apple 的 ARKit?
要上手 Apple 的 ARKit,在 iPhone 6s 或更高的設備上下載 iOS 11 並用 New > Project > Augmented Reality App 創建一個新的 ARKit 項目。同時也可以看看蘋果在這裡提供的 AR 示例代碼:https://developer.apple.com/arkit/
作者:張嘉夫
鏈接:http://www.jianshu.com/p/4cbe6b6b8ea2
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。