iOS ARKit 教程:用手指在空中繪畫

已發表: 2022-03-11

最近,Apple 宣布了其名為 ARKit 的新增強現實 (AR) 庫。 對許多人來說,它看起來只是另一個優秀的 AR 庫,而不是需要關心的技術顛覆者。 但是,如果你看看過去幾年 AR 的進展,應該不會很快得出這樣的結論。

ARKit 教程插圖:在 iOS ARKit 應用程序中與虛擬對象交互

在這篇文章中,我們將使用 iOS ARKit 創建一個有趣的 ARKit 示例項目。 用戶將手指放在桌子上,就像他們拿著筆一樣,點擊縮略圖並開始繪圖。 完成後,用戶將能夠將他們的繪圖轉換為 3D 對象,如下面的動畫所示。 GitHub 上提供了我們 iOS ARKit 示例的完整源代碼。

演示我們正在使用的 iOS ARKit 示例增強現實應用程序

為什麼我們現在應該關心 iOS ARKit?

每個有經驗的開發人員都可能意識到 AR 是一個古老的概念。 我們可以將 AR 的第一次嚴肅開發確定為開發人員從網絡攝像頭訪問單個幀的時間。 當時的應用程序通常被用來改變你的臉。 然而,人類很快就意識到將面孔變成兔子並不是他們最迫切的需求之一,很快這種炒作就消失了!

我相信 AR 一直缺少使其有用的兩個關鍵技術飛躍:可用性和沈浸感。 如果您追踪其他 AR 炒作,您會注意到這一點。 例如,當開發人員可以訪問移動攝像頭的單個幀時,AR 炒作再次開始。 除了大兔子變形金剛的強勢回歸之外,我們還看到了一波將 3D 對象放在打印的二維碼上的應用程序。 但他們從來沒有作為一個概念起飛。 它們不是增強現實,而是增強的二維碼。

然後谷歌用科幻小說谷歌眼鏡讓我們大吃一驚。 兩年過去了,當這個神奇的產品被期待復活時,它已經死了! 許多批評者分析了谷歌眼鏡失敗的原因,將責任歸咎於從社交方面到谷歌在推出產品時的沉悶方法等各種問題。 然而,我們在這篇文章中確實關心一個特殊的原因——沉浸在環境中。 雖然 Google Glass 解決了可用性問題,但它仍然只是在空中繪製的 2D 圖像。

微軟、Facebook 和蘋果等科技巨頭牢記這一慘痛教訓。 2017 年 6 月,Apple 發布了其精美的 iOS ARKit 庫,將沉浸感作為重中之重。 拿著手機仍然是一個很大的用戶體驗障礙,但谷歌眼鏡的教訓告訴我們,硬件不是問題。

我相信我們很快就會走向一個新的 AR 炒作高峰,隨著這個新的重要轉折點,它最終可能會找到自己的本土市場,讓更多的 AR 應用程序開發成為主流。 這也意味著每個增強現實應用程序開發公司都將能夠利用蘋果的生態系統和用戶群。

但是足夠的歷史,讓我們用代碼弄髒我們的手,看看蘋果增強現實的實際應用吧!

ARKit 沉浸式功能

ARKit 提供了兩個主要功能; 第一個是 3D 空間中的相機位置,第二個是水平面檢測。 為了實現前者,ARKit 假設你的手機是一個在真實 3D 空間中移動的相機,因此在任何點放置一些 3D 虛擬對像都會被錨定到真實 3D 空間中的那個點。 對於後者,ARKit 可以檢測水平面,例如桌子,以便您可以在上面放置對象。

那麼 ARKit 是如何實現這一點的呢? 這是通過稱為視覺慣性里程計 (VIO) 的技術完成的。 別擔心,就像企業家發現他們在弄清楚他們的初創公司名稱背後的來源時咯咯笑的次數感到高興一樣,研究人員發現他們的樂趣在於你試圖破譯他們提出的任何術語時的頭部抓撓次數命名他們的發明 - 所以讓我們讓他們玩得開心,然後繼續前進。

VIO 是一種將相機幀與運動傳感器融合以跟踪設備在 3D 空間中的位置的技術。 跟踪攝像機幀的運動是通過檢測特徵來完成的,換句話說,就是圖像中具有高對比度的邊緣點——比如藍色花瓶和白色桌子之間的邊緣。 通過檢測這些點從一幀到另一幀的相對移動量,可以估計設備在 3D 空間中的位置。 這就是為什麼 ARKit 在面對毫無特色的白牆或設備快速移動導致圖像模糊時無法正常工作的原因。

在 iOS 中開始使用 ARKit

在撰寫本文時,ARKit 是 iOS 11 的一部分,目前仍處於測試階段。 因此,要開始使用,您需要在 iPhone 6s 或更高版本上下載 iOS 11 Beta,以及新的 Xcode Beta。 我們可以從New > Project > Augmented Reality App開始一個新的 ARKit 項目。 但是,我發現使用官方 Apple ARKit 示例開始這個增強現實教程更方便,它提供了一些基本代碼塊,對平面檢測特別有幫助。 所以,讓我們從這個示例代碼開始,先解釋其中的要點,然後為我們的項目修改它。

首先,我們應該確定我們將使用哪個引擎。 ARKit 可以與 Sprite SceneKit 或 Metal 一起使用。 在 Apple ARKit 示例中,我們使用的是 Apple 提供的 3D 引擎 iOS SceneKit。 接下來,我們需要設置一個視圖來渲染我們的 3D 對象。 這是通過添加ARSCNView類型的視圖來完成的。

ARSCNView是名為SCNView的 SceneKit 主視圖的子類,但它通過幾個有用的功能擴展了視圖。 它將來自設備攝像頭的實時視頻饋送渲染為場景背景,同時它會自動將 SceneKit 空間與現實世界匹配,假設設備是這個世界中的移動攝像頭。

ARSCNView本身不進行 AR 處理,但它需要一個 AR 會話對象來管理設備攝像頭和運動處理。 因此,首先,我們需要分配一個新會話:

 self.session = ARSession() sceneView.session = session sceneView.delegate = self setupFocusSquare()

上面的最後一行添加了一個視覺指示器,幫助用戶直觀地描述平面檢測的狀態。 Focus Square 是由示例代碼而不是 ARKit 庫提供的,這也是我們開始使用此示例代碼的主要原因之一。 您可以在示例代碼中包含的自述文件中找到有關它的更多信息。 下圖顯示了投影在桌子上的焦點方塊:

使用 Apple ARKit 在桌子上投影的焦點方塊

下一步是啟動 ARKit 會話。 每次視圖出現時重新啟動會話是有意義的,因為如果我們不再跟踪用戶,我們就無法使用以前的會話信息。 因此,我們將在 viewDidAppear 中啟動會話:

 override func viewDidAppear(_ animated: Bool) { let configuration = ARWorldTrackingSessionConfiguration() configuration.planeDetection = .horizontal session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) }

在上面的代碼中,我們首先設置 ARKit 會話配置來檢測水平面。 在撰寫本文時,Apple 不提供除此之外的選項。 但顯然,它暗示未來會檢測到更複雜的物體。 然後,我們開始運行會話並確保我們重置跟踪。

最後,我們需要在相機位置(即實際設備方向或位置)發生變化時更新 Focus Square。 這可以在 SCNView 的渲染器委託函數中完成,每次要渲染 3D 引擎的新幀時都會調用該函數:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() }

至此,如果您運行該應用程序,您應該會在相機流上看到正在搜索水平面的焦點方塊。 在下一節中,我們將解釋如何檢測平面,以及如何相應地定位焦點方塊。

在 ARKit 中檢測平面

ARKit 可以檢測新平面、更新現有平面或刪除它們。 為了方便地處理平面,我們將創建一些虛擬的 SceneKit 節點來保存平面位置信息和對焦點方塊的引用。 平面在 X 和 Z 方向上定義,其中 Y 是表面的法線,也就是說,如果我們想讓它看起來好像打印在平面上,我們應該始終保持我們的繪圖節點位置在平面的相同 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() } ... }

回調函數為我們提供了兩個參數, anchornodenode是一個普通的 SceneKit 節點,放置在平面的確切位置和方向上。 它沒有幾何形狀,所以它是不可見的。 我們使用它來添加我們自己的平面節點,該節點也是不可見的,但在anchor中保存有關平面方向和位置的信息。

那麼在ARPlaneAnchor中是如何保存位置和方向的呢? 位置、方向和比例都編碼在一個 4x4 矩陣中。 如果我有機會選擇一個數學概念讓你學習,那無疑是矩陣。 無論如何,我們可以通過如下描述這個 4x4 矩陣來規避這個問題: 一個包含 4x4 浮點數的出色二維數組。 通過以某種方式將這些數字乘以其局部空間中的 3D 頂點 v1,得到一個新的 3D 頂點 v2,它表示世界空間中的 v1。 因此,如果 v1 = (1, 0, 0) 在其局部空間中,並且我們希望將其放置在世界空間中的 x = 100 處,則相對於世界空間,v2 將等於 (101, 0, 0)。 當然,當我們添加關於軸的旋轉時,其背後的數學變得更加複雜,但好消息是我們可以不理解它(我強烈建議查看這篇優秀文章的相關部分,以深入解釋這個概念)。

checkIfObjectShouldMoveOntoPlane檢查我們是否已經繪製了對象,並檢查所有這些對象的 y 軸是否與新檢測到的平面的 y 軸匹配。

現在,回到上一節中描述的updateFocusSquare() 。 我們希望將焦點方塊保持在屏幕的中心,但投影在最近的檢測平面上。 下面的代碼演示了這一點:

 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 矩陣,包含檢測到的平面的所有變換信息,而result.worldTransform.translation是一個方便的函數,它只返回位置。

現在,給定屏幕上的 2D 點,我們擁有了在檢測到的表面上放置 3D 對象所需的所有信息。 那麼,讓我們開始畫畫吧。

繪畫

讓我們首先解釋在計算機視覺中按照人類手指繪製形狀的方法。 繪製形狀是通過檢測移動手指的每個新位置、在該位置放置一個頂點並將每個頂點與前一個頂點連接來完成的。 頂點可以通過一條簡單的線連接,如果我們需要平滑的輸出,也可以通過貝塞爾曲線連接。

為簡單起見,我們將遵循一些簡單的繪圖方法。 對於手指的每個新位置,我們將在檢測到的平面上放置一個帶有圓角且高度幾乎為零的非常小的盒子。 它看起來好像是一個點。 一旦用戶完成繪圖並選擇 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) // Setup the material of the point 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 的渲染器回調函數中,我們將使用相同的PointNode類繪製一些像筆尖一樣的指示器。 如果啟用繪圖,我們將在該位置放置一個點,如果啟用 3D 模式,我們將把繪圖提升為 3D 結構:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() // Setup a dot that represents the virtual pen's tippoint if (self.virtualPenTip == nil) { self.virtualPenTip = PointNode(color: UIColor.red) self.sceneView.scene.rootNode.addChildNode(self.virtualPenTip!) } // Draw if let screenCenterInWorld = worldPositionFromScreenPosition(self.screenCenter, self.sceneView) { // Update virtual pen position self.virtualPenTip?.isHidden = false self.virtualPenTip?.simdPosition = screenCenterInWorld // Draw new point if (self.inDrawMode && !self.virtualObjectManager.pointNodeExistAt(pos: screenCenterInWorld)){ let newPoint = PointNode() self.sceneView.scene.rootNode.addChildNode(newPoint) self.virtualObjectManager.loadVirtualObject(newPoint, to: screenCenterInWorld) } // Convert drawing to 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 } } }

virtualObjectManager是一個管理繪製點的類。 在 3D 模式下,我們估計與最後一個位置的差異,並使用該值增加/減少所有點的高度。

到目前為止,假設虛擬筆位於屏幕中心,我們正在檢測到的表面上繪圖。 現在是有趣的部分——檢測用戶的手指並使用它而不是屏幕中心。

檢測用戶的指尖

Apple 在 iOS 11 中引入的很酷的庫之一是 Vision Framework。 它以非常方便和有效的方式提供了一些計算機視覺技術。 特別是,我們將在我們的增強現實教程中使用對象跟踪技術。 對象跟踪的工作原理如下:首先,我們為它提供圖像和我們要跟踪的對像在圖像邊界內的正方形坐標。 之後我們調用一些函數來初始化跟踪。 最後,我們輸入一個新圖像,其中該對象的位置發生了變化,以及之前操作的分析結果。 鑑於此,它將為我們返回對象的新位置。

我們將使用一個小技巧。 我們將要求用戶將手放在桌子上,就像他們拿著筆一樣,並確保他們的縮略圖朝向相機,然後他們應該在屏幕上點擊他們的縮略圖。 這裡有兩點需要說明。 首先,縮略圖應該有足夠的獨特特徵,可以通過白色縮略圖、皮膚和表格之間的對比來追踪。 這意味著較深的皮膚色素將導致更可靠的跟踪。 其次,由於用戶將手放在桌子上,並且由於我們已經將桌子檢測為平面,因此將縮略圖的位置從 2D 視圖投影到 3D 環境將導致手指在桌子。

下圖顯示了 Vision 庫可以檢測到的特徵點:

視覺庫檢測到的 iOS ARKit 特徵點

我們將在點擊手勢中初始化縮略圖跟踪,如下所示:

 // MARK: Object tracking 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) // Set up the rect in the image in view coordinate space that we will track 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) // Transfrom the rect from view space to image space 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 為我們提供了從圖像坐標空間轉換為視口坐標空間的displayTransform矩陣,但反之則不行。 那麼我們如何做相反的事情呢? 通過使用矩陣的逆。 我真的試圖在這篇文章中盡量減少對數學的使用,但在 3D 世界中有時是不可避免的。

接下來,在渲染器中,我們將輸入一個新圖像來跟踪手指的新位置:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { // Track the thumbnail 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 // Transfrom the rect from image space to view space 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 // Get the projection if the location of the tracked image from image space to the nearest detected plane 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 環境識別技術相結合。 您可以等著看誰將贏得這場比賽,或者您現在可以通過開發真正的沉浸式增強現實應用程序來參與其中! 但是,請幫人類一個忙,不要把活的物體變成兔子。