動かして学ぶARKit ~長さ測定~

ARKitで現実の空間の長さ測定する実装を紹介します。
2020.09.09

ARKitでの長さ測定の方法とその実装を前提として知っておくべきオブジェクトについて説明しながら紹介します。

空間認識を用いた長さ測定

まずは準備です。計算した距離を表示するUILabelをstoryboardの部品とIBOutletで接続します。その他プロパティも予め初期化しておきます。長さ計測の始点と終点の位置の更新のためにプロパティを宣言しています。centerPositionはsceneViewの中央の座標を渡して更新しますが、hitTest(_:types:)の第一引数に使います。ヒットテストは当たり判定のことです。

@IBOutlet private weak var distanceLabel: UILabel!
@IBOutlet private var sceneView: ARSCNView!
    
private var centerPosition = CGPoint(x: 0, y: 0)
private var tapCount = 0
private var startPosition: SIMD3<Float> = SIMD3(0, 0, 0)
private var currentPosition: SIMD3<Float> = SIMD3(0, 0, 0)

viewDidLoadメソッドsceneViewの初期設定及びセッションの開始と画面の中央座標の保存を行います。

sceneView.delegate = self
sceneView.scene = SCNScene()
centerPosition = sceneView.center
let config = ARWorldTrackingConfiguration()
sceneView.session.run(config)

touchesBegan(_:with:)で初回に始点を保存、初回かどうかを判定するための変数のインクリメントと始点を表わすノードの追加します。初回でない時に直線の描画を行ってノードを追加します。

putSphere(at: currentPosition)
guard tapCount != 0 else {
    startPosition = currentPosition
    tapCount = 1
    return
}
tapCount = 0
let lineNode = drawLine(from: SCNVector3(startPosition), to: SCNVector3(currentPosition))
sceneView.scene.rootNode.addChildNode(lineNode)

各種ノードの配置の実装は以下です。

private extension ViewController {
    private func drawLine(from: SCNVector3, to: SCNVector3) -> SCNNode {
        let source = SCNGeometrySource(vertices: [from, to])
        let element = SCNGeometryElement(data: Data([0, 1]), primitiveType: .line, primitiveCount: 1, bytesPerIndex: 1)
        let geometry = SCNGeometry(sources: , elements: [element])
        let node = SCNNode()
        node.geometry = geometry
        node.geometry?.materials.first?.diffuse.contents = UIColor.red
        return node
    }
    
    private func putSphere(at position: SIMD3<Float>) {
        let node = SCNNode()
        node.geometry = SCNSphere(radius: 0.0003)
        node.position = SCNVector3(position.x, position.y, position.z)
        sceneView.scene.rootNode.addChildNode(node)
    }
}

リアルタイムな距離計算を行うためにrenderer(_: updateAtTime:)で距離計算を行います。

transformプロパティについて

ヒットテストの結果を表すオブジェクトはARHitTestResultですが、このクラスのインスタンスプロパティにtransformがあります。計算にはこれを利用します。

このプロパティはmatrix_float4x4型で、4×4の行列がワールド座標系におけるカメラの変位と回転、いわば位置と向きを表しています。当たり判定からこの座標を取り出して計算を行います。

ドキュメントは以下です。

コードは以下です。

func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
    let hitResults = sceneView.hitTest(centerPosition, types: [.featurePoint])
    guard !hitResults.isEmpty else { return }
    guard let hitResult = hitResults.first else { return }
    currentPosition = SIMD3(hitResult.worldTransform.columns.3.x, hitResult.worldTransform.columns.3.y, hitResult.worldTransform.columns.3.z)
    if tapCount == 1 {
        let length = distance(startPosition, currentPosition)
        DispatchQueue.main.async {
            self.distanceLabel.text = String(format: "%.1f", length * 100)
        }
    }
}

中央の座標を利用して当たり判定を行って、その結果からそれぞれのワールド座標系における座標を取り出し距離の計算を行っています。そしてその当たり判定には特徴点検出を使っているという実装でした。

平面認識を用いた長さ測定

ARKitの平面認識機能を用いて3D空間における座標に落とし込み現実の空間の長さを推定します。当たり判定の方法が違います。typesに渡しているのがexistingPlaneになっています。existingPlaneUsingExtentではなくこちらを利用しているのは認識した平面よりも広い範囲で計測できるようにしたいからです。

let hitResults = sceneView.hitTest(centerPosition, types: [.existingPlane])

変更点は以上のみですが、平面の検出を行った時に平面のノードを描画するようにしたコード全文を載せます。これまでに説明した部分のコードもそのままにしてあります。

//
//  ViewController.swift
//  arkit_lazer
//
//  Created by tanabe.nobuyuki on 2020/09/06.
//  Copyright © 2020 tanabe.nobuyuki. All rights reserved.
//

import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController {
    
    @IBOutlet private weak var distanceLabel: UILabel!
    @IBOutlet private var sceneView: ARSCNView!
    
    private var centerPosition = CGPoint(x: 0, y: 0)
    private var tapCount = 0
    private var startPosition: SIMD3<Float> = SIMD3(0, 0, 0)
    private var currentPosition: SIMD3<Float> = SIMD3(0, 0, 0)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        sceneView.delegate = self
        sceneView.scene = SCNScene()
        centerPosition = sceneView.center
        let config = ARWorldTrackingConfiguration()
        config.planeDetection = .horizontal
        
        sceneView.session.run(config)
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        putSphere(at: currentPosition)
        guard tapCount != 0 else {
            startPosition = currentPosition
            tapCount = 1
            return
        }
        tapCount = 0
        let lineNode = drawLine(from: SCNVector3(startPosition), to: SCNVector3(currentPosition))
        sceneView.scene.rootNode.addChildNode(lineNode)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        // Pause the view's session
        sceneView.session.pause()
    }
}

extension ViewController: ARSCNViewDelegate {
    
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { fatalError() }
        let planeNode = SCNNode()
        let geometry = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
        geometry.materials.first?.diffuse.contents = UIColor.white.withAlphaComponent(0.5)
        planeNode.geometry = geometry
        planeNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2.0, 1, 0, 0)
        node.addChildNode(planeNode)
    }
    
    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else { fatalError() }
        guard let geometryPlaneNode = node.childNodes.first, let planeGeometry = geometryPlaneNode.geometry as? SCNPlane else { fatalError() }
        planeGeometry.width = CGFloat(planeAnchor.extent.x)
        planeGeometry.height = CGFloat(planeAnchor.extent.z)
        geometryPlaneNode.simdPosition = SIMD3(planeAnchor.center.x, 0, planeAnchor.center.z)
    }
    
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        let hitResults = sceneView.hitTest(centerPosition, types: [.existingPlaneUsingExtent])
        guard !hitResults.isEmpty else { return }
        guard let hitResult = hitResults.first else { return }
        currentPosition = SIMD3(hitResult.worldTransform.columns.3.x, hitResult.worldTransform.columns.3.y, hitResult.worldTransform.columns.3.z)
        if tapCount == 1 {
            let length = distance(startPosition, currentPosition)
            DispatchQueue.main.async {
                self.distanceLabel.text = String(format: "%.1f", length * 100)
            }
        }
    }
}

private extension ViewController {
    private func drawLine(from: SCNVector3, to: SCNVector3) -> SCNNode {
        let source = SCNGeometrySource(vertices: [from, to])
        let element = SCNGeometryElement(data: Data([0, 1]), primitiveType: .line, primitiveCount: 1, bytesPerIndex: 1)
        let geometry = SCNGeometry(sources: , elements: [element])
        let node = SCNNode()
        node.geometry = geometry
        node.geometry?.materials.first?.diffuse.contents = UIColor.red
        return node
    }
    
    private func putSphere(at position: SIMD3<Float>) {
        let node = SCNNode()
        node.geometry = SCNSphere(radius: 0.0003)
        node.position = SCNVector3(position.x, position.y, position.z)
        sceneView.scene.rootNode.addChildNode(node)
    }
}

平面認識と特徴点認識の比較

平面認識による距離計算は平面が検出できることが前提になりますが、特徴点認識は最も近い特徴点が選ばれる仕組みになっているので比較的特徴点が少ない場所でも距離測定ができるようになります。

この性質上正確性でいえば平面認識の方が高いです。

まとめ

基本的な使い方で収まる範囲であればARkitが提供しているものをシンプルに活用するだけで機能を実現出来るようになってきましたが、ビジネスでの要件に落とせるレベルにはもっと根本的な理解が必要に感じています。

その理解がその内アウトプットできるように学習と手を動かすことを続けたいです。