3
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ARKit+Vision+iOS14 で らくがき のジオメトリ化②【物理判定付き3Dモデル生成】

Last updated at Posted at 2020-11-23

前回のつづきで検出した輪郭からジオメトリを生成する。
<完成イメージ>
demo.png demo.gif

※作成したジオメトリにテクスチャを貼る手順は次の記事で記載します(これから作成)。

ジオメトリ化の手順

検出した輪郭をジオメトリを表す SCNShape に設定して3Dモデルとして扱えるようにする。
SCNShape には輪郭情報を UIBezierPath にして渡す必要があるので、VNContour から取得した CGPath を変換する。

手順は次の通り。①〜⑤は前回の記事と同じなので、そちらを参照ください。

①キャプチャ画像からスクリーンに表示されている範囲を切り出す
②輪郭検出の前に①の画像を加工し輪郭検出しやすくする
③輪郭を検出する
④画像にある輪郭は複数検出されるので、着目したい輪郭のみ選択する
⑤④を表示する
〜今回はここから〜
⑥CGPathの輪郭情報をUIBezierPathに変換
⑦⑥の情報からSCNNodeを作成
⑧⑦をシーンに追加

以下、詳細を説明します。

⑥CGPathの輪郭情報をUIBezierPathに変換

検出された輪郭は2Dなので、これを3Dにする必要がある。
取得した2Dのパスは近くも遠くも関係がない大きさであり、これを遠近のある3D水平上のスケールに合わせて変換しなければならない。カメラが斜めならこれも考慮する必要がある。レンダリングで行うModel-View-Projection変換の逆行列で位置は取得できるはずだが手元にあるのはX、Yの情報であり、Z軸の情報がない。どうやってやるのか。。。むづかしい数学はわからない。。。

この記事ではお手軽にそれらしく変換する方法として以下を行った。

  1. 輪郭検出範囲の四隅のワールド座標をレイキャストで取得
  2. 上記で取得したワールド座標をもとにパスの各座標を重心座標を用いてワールド座標に変換

⑥-1) 輪郭検出範囲の四隅のワールド座標をレイキャストで取得

ARKit(ARSCNView)にはスクリーン座標(2D)→ワールド座標(3D)の変換方法として ARSCNViewraycastQuery(from:allowing:alignment:) が用意されている。Z軸にレイを飛ばすことで物体と交差した座標を取得できるので、これを使って輪郭検出対象の四隅のワールド座標を取得する。

guard let query = self.scnView.raycastQuery(from: from, allowing: .existingPlaneGeometry, alignment: .horizontal),
      let result = self.scnView.session.raycast(query).first else {
    return nil
}
let p = result.worldTransform.columns.3
return SCNVector3(p.x, p.y, p.z)

今回作成したサンプルでは取得したワールド座標に小さな赤い球ノードを置いている(シーン内に配置)。輪郭検出の枠とぴったり一致した場所にあることがわかる(=ワールド座標を正確に取得できている)。
redSphere.png

そもそも、こんな便利な仕組みがあるなら、これを使ってパス上の全ての座標をワールド座標に変換することも可能では?と考える。可能かもしれないがこの記事の方法で輪郭検出すると、輪郭上の座標数は数千になることもあるため、計算速度が心配。座標数を減らせば心配ないかもしれないし、そもそもiPhoneは高速に処理してくれるかもしれない。が、今回はより負荷が軽いと思われる方式として、次の重心座標による変換を採用した。

⑥-2) 上記で取得したワールド座標をもとにパスの各座標を重心座標を用いてワールド座標に変換

パス上の各座標をワールド座標のスケールに合わせて変換する。
四隅のワールド座標はわかっているので、これを使ってVNContourの結果であるCGPathの座標(左下が(0, 0)、右上が(1, 1))をワールド座標のスケールに変換する。
この変換をそれっぽく実現するため**『重心座標系での線形補間』** を行う。この線形補間を行うと「三角形内のある点Pの座標を、対応する別の三角形の座標に変換する」ことができる。

まず、手元には次の情報がある。

  • 輪郭検出で得られたパスの点の集まり
  • 輪郭検出対象の画像(スクリーン座標のスケールだと(0, 0)から(0, 320))の四隅に対応するワールド座標

下図のように輪郭検出対象の画像を三角形ABC、三角形BCDの2つの三角形に分けて、三角形ABC上にはパスの点Pがあるとする。ここで点Pの座標を A、B、Cの各座標の重みで決定することとし、A、B、Cの重みは T1、T2、T3 の面積に対応させる。
jushin.png

具体的には三角形ABCの面積=T1、T2、T3の面積の合計 であることを利用し、
・T1の面積が大きい場合、Aの座標の割合が大きくなり、B、Cの割合は減る、とする
・T2の面積が大きい場合、Bの座標の割合が大きくなり、A、Cの割合は減る、とする
・T3の面積が大きい場合、Cの座標の割合が大きくなり、A、Bの割合は減る、とする
のように考える。
例えば、三角形ABCの面積=T1の面積 となった場合、Pのワールド座標上の位置は ⑥-1) で取得した左上の座標ということになる。
三角形BCDについても同様に計算する。

参考サイト:

実際にこの方法で実装するとそれっぽい結果になる。が、理屈としてこの方法が正確かどうかは、、、わかりません。それっぽく見えているので「それっぽく見せる方法の1つ」ということで本記事を読んでいただければと思います。

let convertPoint: (CGPoint) -> CGPoint = {
    // パスの各座標について三角形の重心座標系でワールド座標を導出
    var point = CGPoint.zero
    let pl: CGFloat = 1.0     // CGPathの一辺の長さ。VNContourの返す輪郭は(0,0)〜(1,1)の範囲
    if $0.y > $0.x {
        // 四角形の上側の三角形
        let t: CGFloat = pl * pl / 2    // 四角形の上側の三角形の面積
        let t2 = pl * (pl - $0.y) / 2   // t2の面積
        let t3 = pl * $0.x / 2          // t3の面積
        let t1 = t - t2 - t3            // t1の面積

        let ltRatio = t1 / t    // 左上座標の割合
        let rtRatio = t3 / t    // 右上座標の割合
        let lbRatio = t2 / t    // 左下座標の割合
        
        // 各頂点の重みに応じてワールド座標を算出
        let p = leftTop * ltRatio + rightTop * rtRatio + leftBottom * lbRatio
        point.x = p.x.cg
        point.y = p.z.cg * -1
    } else {
        // 四角形の下側の三角形
        let t: CGFloat = pl * pl / 2    // 四角形の下側の三角形の面積
        let t5 = pl * $0.y / 2          // t5の面積
        let t6 = pl * (pl - $0.x) / 2   // t6の面積
        let t4 = t - t5 - t6            // t4の面積
        
        let rtRatio = t5 / t    // 右上座標の割合
        let lbRatio = t6 / t    // 左下座標の割合
        let rbRatio = t4 / t    // 右下座標の割合
        
        // 各頂点の重みに応じてワールド座標を算出
        let p = rightTop * rtRatio + leftBottom * lbRatio + rightBottom * rbRatio
        point.x = p.x.cg
        point.y = p.z.cg * -1
    }
    // 後でSCNShapeに与える座標となるが、SCNShapeに小さい座標を与えると正しく表示されないのでいったん、拡大しておく。
    return point * self.tempGeometryScale
}

次に、CGPath -> UIBezierPath 変換の説明。
CGPathは単なる点座標の集まりではなく、点と点の間のカーブを表現することもできる。
【ドキュメント:CGPathElementType】
ただ、VNContour が返す CGPath の中にはカーブ情報である QuadCurveToPoint や CurveToPoint は含まれていなかった。これは輪郭検出の要求精度である VNDetectContoursRequestmaximumImageDimension を下限の 64 に設定しても変わらず。
VNContour には輪郭のノイズを除去する polygonApproximationWithEpsilon:error: というメソッドがありこ、この辺りを設定すると使われるのかもしれないが未検証。カーブを無視した実装は次の通り。

let geometryPath = UIBezierPath()
let path = Path(normalizedPath)
path.forEach { element in
    switch element {
    case .move(to: let to):
        geometryPath.move(to: convertPoint(to))
    case .line(to: let to):
        geometryPath.addLine(to: convertPoint(to))
    case .quadCurve(to: let to, control: _):
        geometryPath.addLine(to: convertPoint(to))
    case .curve(to: let to, control1: _, control2: _):
        geometryPath.addLine(to: convertPoint(to))
    case .closeSubpath:
        geometryPath.close()
        break
    }
}

CGPathから座標を取り出すために、いったん、let path = Path(normalizedPath) で SwiftUI で追加された Path に変換している。
【ドキュメント:Path】
なぜ、一度変換するかというと、CGPath から座標を取り出す CGPath.apply(info:function:) がC言語の関数であるためブロックの外との値の受け渡しが面倒なため。
【参考:Equivalent of or alternative to CGPathApply in Swift?】
Pathから1つ1つ要素を取り出しワールド座標系に変換して、UIBezierPathに追加する。

⑦⑥の情報からSCNNodeを作成

作成した UIBezierPath から SCNShape を使ってジオメトリ を作成する。

let geometry = SCNShape(path: geometryPath, extrusionDepth: 0.01 * self.tempGeometryScale)
geometry.firstMaterial?.diffuse.contents = UIColor.gray
let node = SCNNode(geometry: geometry)
node.eulerAngles = SCNVector3(x: -Float.pi/2, y: 0, z: 0)
// ベジェパスの座標計算時にいったん、拡大していたので縮小する
node.scale = SCNVector3(1/self.tempGeometryScale, 1/self.tempGeometryScale, 1/self.tempGeometryScale)

SCNShapeの引数にUIBezierPathを与えるだけだとジオメトリ の面数が極端に少なくなり、なぜか棒状に長くなる、というおかしな現象に直面した。どうやら、0.1程度以下の小さい座標の集まりとなっているUIBezierPathを与えると、まともにジオメトリ が作られないらしい。致し方なく、UIBezierPathを作成する際、座標を 10倍にしておき、SCNShapeを生成&SCNNodeに設定後、SCNNode の scale を 1/10 にすることでこの現象を回避した。この動作が仕様なのかバグなのかは不明。

あと、SCNNodeには物理的な振る舞いをさせるため SCNPhysicsBody を設定している。

let bodyShape = SCNPhysicsShape(geometry: bodyGeometry, options: nil)
node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: bodyShape)
node.physicsBody?.friction = 1.0
node.physicsBody?.restitution = 0.0
node.physicsBody?.rollingFriction = 1.0
node.physicsBody?.angularDamping = 1.0
node.physicsBody?.linearRestingThreshold = 1.0
node.physicsBody?.angularRestingThreshold = 1.0

ここで、SCNPhysicsBody のパラメータに色々と値を設定しているのには理由がある。SCNNodeが大きければこんな設定は不要なのだが、数cm程度の3Dモデルだと、飛び跳ねるし動き続けるしという現象が起こる。ここで設定しているパラメータは、「面の摩擦を大きく(friction)」「弾まないようにする(restitution)」「転がり摩擦を大きく(rollingFriction)」「回転摩擦を大きく(angularDamping)」「ある程度大きな移動をする場合のみ移動(linearRestingThreshold)」「ある程度大きな回転をする場合のみ回転(angularRestingThreshold)」のように、運動を抑止するものである。ここまで設定すると、小さな3Dモデルの運動もだいぶ落ち着くが、不自然さは残る。小さなものに対してどのような設定をすれば良いのかは、ノウハウが必要かもしれないし、そもそも向かないのかもしれない。

参考サイト:

⑧⑦をシーンに追加

作成したSCNNodeをシーンに追加する。
画面中央より少し上の位置のワールド座標を取得し、20cm上から落とす。

// 画面中央上の20cm上から落とす
let screenCenter = CGPoint(x: self.view.bounds.width/2, y: self.view.bounds.height/2 - 150)
guard var position = self.getWorldPosition(from: screenCenter) else { return }
position.y += 0.2
node.worldPosition = position
self.scnView.scene.rootNode.addChildNode(node)

ここまでで処理の流れの説明は終わり。
以下、その他細かな部分について。

その他

1) シーンに追加したSCNNodeに影をつける

下の例では床にイカモデルの影が落ちているのがわかる。この方法を説明。
shadow.png
影を落とすにはシーンにライトを追加する必要があるので、まずライトの設定から。

// ディレクショナルライト追加
let directionalLightNode = SCNNode()
directionalLightNode.light = SCNLight()
directionalLightNode.light?.type = .directional
directionalLightNode.light?.castsShadow = true  // 影が出るライトにする
directionalLightNode.light?.shadowMapSize = CGSize(width: 2048, height: 2048)   // シャドーマップを大きくしてジャギーが目立たないようにする
directionalLightNode.light?.shadowSampleCount = 2   // 影の境界を若干柔らかくする
directionalLightNode.light?.shadowColor = UIColor.lightGray.withAlphaComponent(0.8) // 影の色は明るめ
directionalLightNode.position = SCNVector3(x: 0, y: 3, z: 0)
directionalLightNode.eulerAngles = SCNVector3(x: -Float.pi/3, y: 0, z: -Float.pi/3)
scnView.scene.rootNode.addChildNode(directionalLightNode)

ポイントは次の場所。

  • castsShadow = true で影がでるライトになる
  • 影の色はデフォルトだと真っ黒で不自然なので、shadowColor = UIColor.lightGray.withAlphaComponent(0.8)で 半透明のグレーを設定して影が落ちる部分の床面が見えるようにする

ライトについては↓のサイトの情報がとてもわかりやすい
iOS で SceneKit を試す(Swift 3) その49 - Scene Editor の Spot Light と Cast Shadow (Shadow Mapping)】

つぎに、床面の設定。

let geometry = SCNBox(width: 3.0, height: 3.0, length: self.floorThickness, chamferRadius: 0.0)
let material = SCNMaterial()
material.lightingModel = .shadowOnly    // 平面の色は影だけになるように指定
geometry.materials = [material]
let floorNode = SCNNode(geometry: geometry)

ポイントは material.lightingModel = .shadowOnlyの部分。これは床面のマテリアルの色は出さずに、影だけ落ちるようにする、という設定。これを設定すると例えば、material.diffuse.contents = UIColor.red のように設定しても色は付かない。

最後に、配置する3Dモデルの設定。

let geometry = SCNShape(path: geometryPath, extrusionDepth: 0.01 * self.tempGeometryScale)
let node = SCNNode(geometry: geometry)

node.castsShadow = true // ノードの影をつける

SCNNode の castsShadow を true にすることで、そのモデルの影が落ちるようになる。

2) 物理判定のすりぬけ対策 

床面のジオメトリを ARSCNPlaneGeometry や SCNPlane にすると、配置した3Dモデルが床をすり抜けてしまう現象が発生。3Dモデルが数センチ程度のものだと物理判定が期待した通りにならない。致し方なく、床を箱上(SCNBox)にして回避。

説明は以上です。
次回は、画面からキャプチャした画像を3Dモデルに貼り付けます。

全体ソースコード

ViewController.swift
import ARKit
import Vision
import CoreImage.CIFilterBuiltins
import SwiftUI
import UIKit

class ViewController: UIViewController, ARSessionDelegate, ARSCNViewDelegate {

    @IBOutlet weak var scnView: ARSCNView!
    // 輪郭描画用
    private var contourPathLayer: CAShapeLayer?
    // キャプチャ画像上の輪郭検出範囲
    private let detectSize: CGFloat = 320.0
    // 3次元化ボタンが押下状態
    private var isButtonPressed = false
    // 床の厚さ(m)
    private let floorThickness: CGFloat = 1.0
    // 床のローカル座標。床の厚さ分、Y座標を下げる
    private lazy var floorLocalPosition = SCNVector3(0.0, -self.floorThickness/2, 0.0)
    // SCNShapeの仮の拡大率。SCNShapeに小さいジオメトリ を与えるとジオメトリが崩れるので拡大する
    private let tempGeometryScale: CGFloat = 10.0
    // 検出領域の四隅のシーン内の位置を示すマーカーノード
    private var cornerMarker1: SCNNode!
    private var cornerMarker2: SCNNode!
    private var cornerMarker3: SCNNode!
    private var cornerMarker4: SCNNode!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // シーンの設定
        self.setupScene()
        // AR Session 開始
        self.scnView.delegate = self
        self.scnView.session.delegate = self
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        self.scnView.session.run(configuration, options: [.removeExistingAnchors, .resetTracking])
    }

    // アンカーが追加された
    func renderer(_: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard anchor is ARPlaneAnchor else { return }
        // 落ちてくるノードを受け止めるためアンカーに大きめなSCNBoxを設定する。
        // ARSCNPlaneGeometry だと衝突判定されなかった。SCNPlane だと小さいモデルがすり抜けてしまう。
        // → SCNBoxを利用
        let geometry = SCNBox(width: 3.0, height: 3.0, length: self.floorThickness, chamferRadius: 0.0)
        let material = SCNMaterial()
        material.lightingModel = .shadowOnly    // 平面の色は影だけになるように指定
        geometry.materials = [material]
        let floorNode = SCNNode(geometry: geometry)
        floorNode.position = self.floorLocalPosition
        floorNode.castsShadow = false           // これがないとplaneNodeがチラつくことがある
        floorNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0)
        floorNode.physicsBody = SCNPhysicsBody.static()
        floorNode.physicsBody?.friction = 1.0   // この辺りのプロパティはモデルの物理運動を抑止するためのもの
        floorNode.physicsBody?.restitution = 0.0
        floorNode.physicsBody?.rollingFriction = 1.0
        floorNode.physicsBody?.angularDamping = 1.0
        floorNode.physicsBody?.linearRestingThreshold = 1.0
        floorNode.physicsBody?.angularRestingThreshold = 1.0

        DispatchQueue.main.async {
            node.addChildNode(floorNode)
        }
    }

    // アンカーが更新された
    func renderer(_: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard anchor is ARPlaneAnchor else { return }

        if let childNode = node.childNodes.first {
            DispatchQueue.main.async {
                // 床(SCNBox)の位置を再設定
                childNode.position = self.floorLocalPosition
            }
        }
    }

    // ARフレームが更新された
    func session(_ session: ARSession, didUpdate frame: ARFrame) {
        // 一番外側の輪郭を取得
        guard let contour = getFirstOutsideContour(frame: frame) else { return }
        // UIKitの座標系のCGPathを取得
        guard let path = getCGPathInUIKitSpace(contour: contour) else { return }
        
        DispatchQueue.main.async {
            // 輪郭(2D)を描画
            self.drawContourPath(path)
            // 輪郭(3D)を描画
            if  self.isButtonPressed {
                self.isButtonPressed = false
                self.drawContour3DModel(normalizedPath: contour.normalizedPath)
            }
        }
    }
    
    // ジオメトリ化ボタンが押された
    @IBAction func pressButton(_ sender: Any) {
        isButtonPressed = true
    }
    
    private func setupScene() {
        // ディレクショナルライト追加
        let directionalLightNode = SCNNode()
        directionalLightNode.light = SCNLight()
        directionalLightNode.light?.type = .directional
        directionalLightNode.light?.castsShadow = true  // 影が出るライトにする
        directionalLightNode.light?.shadowMapSize = CGSize(width: 2048, height: 2048)   // シャドーマップを大きくしてジャギーが目立たないようにする
        directionalLightNode.light?.shadowSampleCount = 2   // 影の境界を若干柔らかくする
        directionalLightNode.light?.shadowColor = UIColor.lightGray.withAlphaComponent(0.8) // 影の色は明るめ
        directionalLightNode.position = SCNVector3(x: 0, y: 3, z: 0)
        directionalLightNode.eulerAngles = SCNVector3(x: -Float.pi/3, y: 0, z: -Float.pi/3)
        scnView.scene.rootNode.addChildNode(directionalLightNode)
        // 暗いので環境光を追加
        let ambientLightNode = SCNNode()
        ambientLightNode.light = SCNLight()
        ambientLightNode.light?.type = .ambient
        directionalLightNode.position = SCNVector3(x: 0, y: 0, z: 0)
        scnView.scene.rootNode.addChildNode(ambientLightNode)
        // 検出領域の四隅のシーン内のマーカーノード
        self.cornerMarker1 = makeMarkerNode()
        self.cornerMarker1.isHidden = true
        self.scnView.scene.rootNode.addChildNode(self.cornerMarker1)
        self.cornerMarker2 = makeMarkerNode()
        self.cornerMarker2.isHidden = true
        self.scnView.scene.rootNode.addChildNode(self.cornerMarker2)
        self.cornerMarker3 = makeMarkerNode()
        self.cornerMarker3.isHidden = true
        self.scnView.scene.rootNode.addChildNode(self.cornerMarker3)
        self.cornerMarker4 = makeMarkerNode()
        self.cornerMarker4.isHidden = true
        self.scnView.scene.rootNode.addChildNode(self.cornerMarker4)
    }
}

// MARK: - 輪郭検出関連

extension ViewController {
    
    private func getFirstOutsideContour(frame: ARFrame) -> VNContour? {
        // キャプチャ画像をスクリーンで見える範囲に切り抜く
        let screenImage = cropScreenImageFromCapturedImage(frame: frame)
        // 輪郭検出しやすいように画像処理を行う
        guard let preprocessedImage = preprocessForDetectContour(screenImage: screenImage) else { return nil }
        // 輪郭検出
        let handler = VNImageRequestHandler(ciImage: preprocessedImage)
        let contourRequest = VNDetectContoursRequest.init()
        contourRequest.maximumImageDimension = Int(self.detectSize)  // 検出画像サイズはクリップした画像と同じにする。デフォルトは512。
        contourRequest.detectsDarkOnLight = true                // 明るい背景で暗いオブジェクトを検出
        try? handler.perform([contourRequest])
        // 検出結果取得
        guard let observation = contourRequest.results?.first as? VNContoursObservation else { return nil }
        // トップレベルの輪郭のうち、輪郭の座標数が一番多いパスを見つける
        let outSideContour = observation.topLevelContours.max(by: { $0.normalizedPoints.count < $1.normalizedPoints.count })
        if let contour = outSideContour {
            return contour
        } else {
            return nil
        }
    }
    
    private func cropScreenImageFromCapturedImage(frame: ARFrame) -> CIImage {
        let imageBuffer = frame.capturedImage
        // カメラキャプチャ画像をスクリーンサイズに変換
        // 参考 : https://stackoverflow.com/questions/58809070/transforming-arframecapturedimage-to-view-size
        let imageSize = CGSize(width: CVPixelBufferGetWidth(imageBuffer), height: CVPixelBufferGetHeight(imageBuffer))
        let viewPortSize = self.scnView.bounds.size
        let interfaceOrientation  = self.scnView.window!.windowScene!.interfaceOrientation
        let image = CIImage(cvImageBuffer: imageBuffer)
        // 1) キャプチャ画像を 0.0〜1.0 の座標に変換
        let normalizeTransform = CGAffineTransform(scaleX: 1.0/imageSize.width, y: 1.0/imageSize.height)
        // 2) 「Flip the Y axis (for some mysterious reason this is only necessary in portrait mode)」とのことでポートレートの場合に座標変換。
        //     Y軸だけでなくX軸も反転が必要。
        var flipTransform = CGAffineTransform.identity
        if interfaceOrientation.isPortrait {
            // X軸Y軸共に反転
            flipTransform = CGAffineTransform(scaleX: -1, y: -1)
            // X軸Y軸共にマイナス側に移動してしまうのでプラス側に移動
            flipTransform = flipTransform.concatenating(CGAffineTransform(translationX: 1, y: 1))
        }
        // 3) キャプチャ画像上でのスクリーンの向き・位置に移動
        // 参考 : https://developer.apple.com/documentation/arkit/arframe/2923543-displaytransform
        let displayTransform = frame.displayTransform(for: interfaceOrientation, viewportSize: viewPortSize)
        // 4) 0.0〜1.0 の座標系からスクリーンの座標系に変換
        let toViewPortTransform = CGAffineTransform(scaleX: viewPortSize.width, y: viewPortSize.height)
        // 5) 1〜4までの変換を行い、変換後の画像をスクリーンサイズでクリップ
        let transformedImage = image.transformed(by: normalizeTransform.concatenating(flipTransform).concatenating(displayTransform).concatenating(toViewPortTransform)).cropped(to: self.scnView.bounds)
        return transformedImage
    }
    
    private func preprocessForDetectContour(screenImage: CIImage) -> CIImage? {
        // 画像の暗い部分を広げて細い線を太くする。
        // WWDC2020(https://developer.apple.com/videos/play/wwdc2020/10673/)
        // 04:06あたりで紹介されているCIMorphologyMinimumを利用。
        let blurFilter = CIFilter.morphologyMinimum()
        blurFilter.inputImage = screenImage
        blurFilter.radius = 5
        guard let blurImage = blurFilter.outputImage else { return nil }
        // ペンの線を強調。RGB各々について閾値より明るい色は 1.0 にする。
        let thresholdFilter = CIFilter.colorThreshold()
        thresholdFilter.inputImage = blurImage
        thresholdFilter.threshold = 0.1
        guard let thresholdImage = thresholdFilter.outputImage else { return nil }
        // 検出範囲を画面の中心部分に限定する
        let screenImageSize = screenImage.extent    // CIMorphologyMinimumフィルタにより画像サイズと位置が変わってしまうので、オリジナル画像のサイズ・位置を基準にする
        let croppedImage = thresholdImage.cropped(to: CGRect(x: screenImageSize.width/2 - detectSize/2,
                                                             y: screenImageSize.height/2 - detectSize/2,
                                                             width: detectSize,
                                                             height: detectSize))
        return croppedImage
    }
}
// MARK: - パス描画(2D)

extension ViewController {
    
    private func getCGPathInUIKitSpace(contour: VNContour) -> CGPath? {
        // UIKitで使うため、クリップしたときのサイズに拡大し、上下の座標を反転後、左上が (0,0)になるようにする
        let path = contour.normalizedPath
        var transform = CGAffineTransform(scaleX: detectSize, y: -detectSize)
        transform = transform.concatenating(CGAffineTransform(translationX: 0, y: detectSize))
        let transPath = path.copy(using: &transform)
        return transPath
    }
    
    private func drawContourPath(_ path: CGPath) {
        // 表示中のパスは消す
        if let layer = self.contourPathLayer {
            layer.removeFromSuperlayer()
            self.contourPathLayer = nil
        }
        // 輪郭を描画
        let pathLayer = CAShapeLayer()
        var frame = self.view.bounds
        frame.origin.x = frame.width/2 - detectSize/2
        frame.origin.y = frame.height/2 - detectSize/2
        frame.size.width = detectSize
        frame.size.height = detectSize
        pathLayer.frame = frame
        pathLayer.path = path
        pathLayer.strokeColor = UIColor.blue.cgColor
        pathLayer.lineWidth = 10
        pathLayer.fillColor = UIColor.clear.cgColor
        self.view.layer.addSublayer(pathLayer)
        self.contourPathLayer = pathLayer
    }
}
// MARK: - パス描画(3D)

extension ViewController {
    
    private func drawContour3DModel(normalizedPath: CGPath) {
        // 輪郭(CGPath)をワールド座標のUIBezierPathに変換
        guard let geometryPath = convertPath(from: normalizedPath) else { return }
        
        // ベジェパスをもとにノードを生成
        let node = makeNode(from: geometryPath)
        
        // 画面中央上の20cm上から落とす
        let screenCenter = CGPoint(x: self.view.bounds.width/2, y: self.view.bounds.height/2 - 150)
        guard var position = self.getWorldPosition(from: screenCenter) else { return }
        position.y += 0.2
        node.worldPosition = position
        self.scnView.scene.rootNode.addChildNode(node)
    }
    
    // レイキャストでワールド座標を取得(精度は問題なさそう)
    private func getWorldPosition(from: CGPoint) -> SCNVector3? {
        guard let query = self.scnView.raycastQuery(from: from, allowing: .existingPlaneGeometry, alignment: .horizontal),
              let result = self.scnView.session.raycast(query).first else {
            return nil
        }
        let p = result.worldTransform.columns.3
        return SCNVector3(p.x, p.y, p.z)
    }
    
    private func makeMarkerNode() -> SCNNode {
        let sphere = SCNSphere(radius: 0.001)
        let material = SCNMaterial()
        material.diffuse.contents = UIColor.red
        sphere.materials = [material]
        return SCNNode(geometry: sphere)
    }
    
    private func convertPath(from normalizedPath: CGPath) -> UIBezierPath? {
        // 検出領域の四隅のワールド座標を取得
        let origin = CGPoint(x: self.view.bounds.width/2 - self.detectSize/2,
                             y: self.view.bounds.height/2 - self.detectSize/2)
        guard let leftTopWorldPosition = self.getWorldPosition(from: origin),
              let rightTopWorldPosition = self.getWorldPosition(from: CGPoint(x: origin.x + self.detectSize,
                                                                              y: origin.y)),
              let leftBottomWorldPosition = self.getWorldPosition(from: CGPoint(x: origin.x,
                                                                                y: origin.y + self.detectSize)),
              let rightBottomWorldPosition = self.getWorldPosition(from: CGPoint(x: origin.x + self.detectSize,
                                                                                 y: origin.y + self.detectSize)) else {
            print("検出領域の四隅のワールド座標が取れない。iPhoneを前後左右に動かしてください。")
            return nil
        }
        // 検出した座標にワールド座標位置確認用の赤い球を配置
        self.cornerMarker1.worldPosition = leftTopWorldPosition
        self.cornerMarker1.isHidden = false
        self.cornerMarker2.worldPosition = rightTopWorldPosition
        self.cornerMarker2.isHidden = false
        self.cornerMarker3.worldPosition = leftBottomWorldPosition
        self.cornerMarker3.isHidden = false
        self.cornerMarker4.worldPosition = rightBottomWorldPosition
        self.cornerMarker4.isHidden = false
        // 四隅の座標をワールド座標の中心を基準にした座標に変換
        let worldCenter = (leftTopWorldPosition + rightTopWorldPosition + leftBottomWorldPosition + rightBottomWorldPosition) / 4
        let leftTop = leftTopWorldPosition - worldCenter
        let rightTop = rightTopWorldPosition - worldCenter
        let leftBottom = leftBottomWorldPosition - worldCenter
        let rightBottom = rightBottomWorldPosition - worldCenter
        // 2次元のCGPathを3次元の座標系に変換
        let geometryPath = UIBezierPath()
        let path = Path(normalizedPath)
        var elementCount = 0
        path.forEach { element in
            switch element {
            case .move(to: let to):
                geometryPath.move(to: convertPathPoint(to, leftTop: leftTop, rightTop: rightTop, leftBottom: leftBottom, rightBottom: rightBottom))
            case .line(to: let to):
                geometryPath.addLine(to: convertPathPoint(to, leftTop: leftTop, rightTop: rightTop, leftBottom: leftBottom, rightBottom: rightBottom))
            case .quadCurve(to: let to, control: _):
                geometryPath.addLine(to: convertPathPoint(to, leftTop: leftTop, rightTop: rightTop, leftBottom: leftBottom, rightBottom: rightBottom))
            case .curve(to: let to, control1: _, control2: _):
                geometryPath.addLine(to: convertPathPoint(to, leftTop: leftTop, rightTop: rightTop, leftBottom: leftBottom, rightBottom: rightBottom))
            case .closeSubpath:
                geometryPath.close()
                break
            }
            elementCount += 1
        }
        print("path element count[\(elementCount)]")
        return geometryPath
    }
    
    private func convertPathPoint(_ from: CGPoint,
                                  leftTop: SCNVector3,
                                  rightTop: SCNVector3,
                                  leftBottom: SCNVector3,
                                  rightBottom: SCNVector3) -> CGPoint {
        // パスの各座標について三角形の重心座標系でワールド座標を導出
        var point = CGPoint.zero
        let pl: CGFloat = 1.0     // CGPathの一辺の長さ。VNContourの返す輪郭は(0,0)〜(1,1)の範囲
        if from.y > from.x {
            // 四角形の上側の三角形
            let t: CGFloat = pl * pl / 2    // 四角形の上側の三角形の面積
            let t2 = pl * (pl - from.y) / 2   // t2の面積
            let t3 = pl * from.x / 2          // t3の面積
            let t1 = t - t2 - t3            // t1の面積

            let ltRatio = t1 / t    // 左上座標の割合
            let rtRatio = t3 / t    // 右上座標の割合
            let lbRatio = t2 / t    // 左下座標の割合
            
            // 各頂点の重みに応じてワールド座標を算出
            let p = leftTop * ltRatio + rightTop * rtRatio + leftBottom * lbRatio
            point.x = p.x.cg
            point.y = p.z.cg * -1
        } else {
            // 四角形の下側の三角形
            let t: CGFloat = pl * pl / 2    // 四角形の下側の三角形の面積
            let t5 = pl * from.y / 2          // t5の面積
            let t6 = pl * (pl - from.x) / 2   // t6の面積
            let t4 = t - t5 - t6            // t4の面積
            
            let rtRatio = t5 / t    // 右上座標の割合
            let lbRatio = t6 / t    // 左下座標の割合
            let rbRatio = t4 / t    // 右下座標の割合
            
            // 各頂点の重みに応じてワールド座標を算出
            let p = rightTop * rtRatio + leftBottom * lbRatio + rightBottom * rbRatio
            point.x = p.x.cg
            point.y = p.z.cg * -1
        }
        // 後でSCNShapeに与える座標となるが、SCNShapeに小さい座標を与えると正しく表示されないのでいったん、拡大しておく。
        return point * self.tempGeometryScale
    }
    
    private func makeNode(from geometryPath: UIBezierPath) -> SCNNode {
        let geometry = SCNShape(path: geometryPath, extrusionDepth: 0.01 * self.tempGeometryScale)
        geometry.firstMaterial?.diffuse.contents = UIColor.gray
        let node = SCNNode(geometry: geometry)
        node.eulerAngles = SCNVector3(x: -Float.pi/2, y: 0, z: 0)
        // ベジェパスの座標計算時にいったん、拡大していたので縮小する
        node.scale = SCNVector3(1/self.tempGeometryScale, 1/self.tempGeometryScale, 1/self.tempGeometryScale)
        node.castsShadow = true // ノードの影をつける
        
        let bodyMax = geometry.boundingBox.max
        let bodyMin = geometry.boundingBox.min
        let bodyGeometry = SCNBox(width: (bodyMax.x - bodyMin.x).cg * 1/self.tempGeometryScale,
                                  height: (bodyMax.y - bodyMin.y).cg * 1/self.tempGeometryScale,
                                  length: (bodyMax.z - bodyMin.z).cg * 1/self.tempGeometryScale,
                                  chamferRadius: 0.0)
        let bodyShape = SCNPhysicsShape(geometry: bodyGeometry, options: nil)
        node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: bodyShape)
        node.physicsBody?.friction = 1.0
        node.physicsBody?.restitution = 0.0
        node.physicsBody?.rollingFriction = 1.0
        node.physicsBody?.angularDamping = 1.0
        node.physicsBody?.linearRestingThreshold = 1.0
        node.physicsBody?.angularRestingThreshold = 1.0

        return node
    }
}

extension SCNVector3 {
    static func + (lhs: SCNVector3, rhs: SCNVector3) -> SCNVector3{
        return SCNVector3(lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z)
    }
    
    static func - (lhs: SCNVector3, rhs: SCNVector3) -> SCNVector3{
        return SCNVector3(lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z)
    }
    
    static func * (lhs: SCNVector3, rhs: CGFloat) -> SCNVector3{
        return SCNVector3(lhs.x * Float(rhs), lhs.y * Float(rhs), lhs.z * Float(rhs))
    }
    
    static func / (lhs: SCNVector3, rhs: Float) -> SCNVector3{
        return SCNVector3(lhs.x / rhs, lhs.y / rhs, lhs.z / rhs)
    }
}

extension CGPoint {
    static func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint{
        return CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
    }
}

extension Float {
    var cg: CGFloat { CGFloat(self) }
}
3
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?