ARKit+Vision+iOS14 で らくがき のジオメトリ化①【輪郭検出】

有名なARアプリ『[らくがきAR](https://apps.apple.com/jp/app/らくがきar/id1515215584)』がやっている、**"キャプチャ画像から輪郭を検出しそれをジオメトリにする"** 、を試してみた。
この記事では、まず、輪郭検出の過程を説明します。（ジオメトリ化は次回）
＜完成イメージ＞
![demo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/90683807-bf39-34e3-df62-2bd1497e0674.png) ![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/d11294d5-81de-ca5a-4ec7-724a1d7cc462.gif)

iOS14からVisonに新しく輪郭検出の機能 **VNContour** が追加されたのでこれを利用。
VNContour は[WWDC2020のビデオ Explore Computer Vision APIs](https://developer.apple.com/videos/play/wwdc2020/10673/) の11:32あたりから詳しく説明されている。

VNContour からは **輪郭を画像ではなく、閉じたパス（CGPath）情報として取得できる** ので、パス上の座標を使って３Dのジオメトリをつくれる（~~はず。次の記事を書くときに試す~~ 一応できた）。

##  輪郭検出の手順

①キャプチャ画像からスクリーンに表示されている範囲を切り出す
②輪郭検出の前に①の画像を加工し輪郭検出しやすくする
③輪郭を検出する
④画像にある輪郭は複数検出されるので、着目したい輪郭のみ選択する
⑤④を表示する

以下、詳細を説明します。
## ①キャプチャ画像からスクリーンに表示されている範囲を切り出す

ARKitからキャプチャ画像を取得する場合、`ARSessionDelegate` の `session(_:didUpdate:)` のタイミングで `ARFrame` の `capturedImage` から取得するが、この画像はスクリーンサイズやデバイスの向きが全く考慮されていない。
そのため、輪郭検出機能に画像のどの部分を検出させるのか、検出結果として返される `CGPath` をスクリーン座標にどう合わせれば良いのか、をキャプチャ画像からは判断できない。

ARKitはキャプチャ画像をスクリーン表示サイズに変換する手段として `ARFrame` の `displayTransform(for:viewportSize:)` をとおして変換行列を提供している。
が、このメソッドの活用は単純ではなく、[stackoverflowの質問回答](https://stackoverflow.com/questions/58809070/transforming-arframecapturedimage-to-view-size) の情報でなんとか変換することができた。

具体的に何をやならければならないかというと、次のようにする。

1) キャプチャした画像全体（スクリーン外も含む）を 0.0〜1.0 の座標系に変換し、
2) ポートレートの場合、Y座標の上下、X座標の左右を反転し、
3) スクリーンで見えている向き・位置に座標を移動し、
4) 移動後、スクリーンサイズに戻し、
5) スクリーンサイズで画像を切り出す

stackoverflowのまんまですがコードは次の通り。

```swift
// 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)
```

## ②輪郭検出の前に①の画像を加工し輪郭検出しやすくする
VNContour で白紙に書いた絵を認識させたとき、明るい場所で、白紙も一様に「白」ならきれいに輪郭を検出してくれるが、白紙にすこし暗い場所があると、それを輪郭として認識してしまい扱いにくくなる現象にぶつかった。
そこで輪郭検出の前に次の処理を行うことで、白紙上の絵を認識させやすくすることができた。

```swift
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))
```

1) `CIFilter.morphologyMinimum()` で、暗い部分を広げる
　これは、線が細い・薄いと輪郭が途切れてしまうことを回避し、輪郭検出を安定させるための加工。
　[ドキュメント : morphologyMinimumFilter](https://developer.apple.com/documentation/coreimage/cifilter/3228366-morphologyminimumfilter)
2) `CIFilter.colorThreshold()` で、特に色の暗い部分のみ抽出する
　これはグラデーションのような陰影があっても、特に暗い部分（ペンで書いた線）だけ輪郭検出させるための加工。
　ちなみに `CIColorThreshold`はiOS14から利用できるようになったフィルター。
3) CIImage の croppedメソッド を使ってスクリーンの中心部分を切り出す
　輪郭検出を画面の中心部分（着目している部分）に限定するための加工。

　ポイントは 3) の切り出しの前に、1)、2)のフィルタをかけるところ。
　1)や2)は画面全体に処理をするので負荷を考慮して 3) の画像切り出し後に実施したいが、画像切り出し後に実施すると、のちの輪郭検出処理で画像の縁が「輪郭」と検出されてしまう現象に悩まされることになる。特に`CIFilter.morphologyMinimum()`の処理画像は radius で指定したサイズ分だけ画像が拡張され四辺のいずれでも色情報が無い領域が追加さるので、思ったような輪郭検出ができない。

以下、画像がどのように加工されていくのかの例示。

■元画像
![img1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/a5c8370d-52a4-955b-b1ad-9d4ffbcf09b9.png)
■`CIFilter.morphologyMinimum()`で加工後。線が太くなっていることがわかる。欲しいのは絵の外側の輪郭だけなので詳細部分が潰れてしまっても問題ない。
![img2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/c5662bc2-01a6-3218-f19b-3bab7ac965d9.png)
■`CIFilter.colorThreshold()`で加工＆画像中央だけクリップ後。線だけ抽出できている。
![img5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/d37f4c2c-7087-8d11-bd7d-e3649d713c71.png)

## ③輪郭を検出する

輪郭検出はシンプル。
処理対象の画像を引数として`VNDetectContoursRequest` を作って `VNImageRequestHandler` に渡すだけ。

```swift
let handler = VNImageRequestHandler(ciImage: preprocessedImage)
let contourRequest = VNDetectContoursRequest.init()
contourRequest.maximumImageDimension = Int(detectSize)  // 検出画像サイズはクリップした画像と同じにする。デフォルトは512。
contourRequest.detectsDarkOnLight = true                // 明るい背景で暗いオブジェクトを検出
try? handler.perform([contourRequest])
```
`contourRequest.detectsDarkOnLight = true` としているが、デフォルトが true なので指定しなくても良い。

## ④画像にある輪郭は複数検出されるので、着目したい輪郭のみ選択する

輪郭検出を行うと、輪郭と思われるものは片っ端から検出される。
次の画像は検出された輪郭（赤線）のすべてを表示させた場合のもの。
![path.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/0b782e37-2002-be82-181a-4bfefc64dc28.png)
隙間という隙間の輪郭が検出されていることがわかる(`CIFilter.morphologyMinimum()`で加工しなければぴったりとペンの線に沿ったパスの取得も可能)。
欲しいのは一番外側の輪郭なので、これを特定したい。
ここで、輪郭検出の結果`VNContoursObservation`の中の構造は次のようになっている。
![result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/efdc9319-4b73-3bc9-4cf4-a8d6bcdd31b9.png)
検出された輪郭はネスト構造になっており、ネストの一番外側の輪郭は特定できるものの複数あり得ることを示している。となると、複数の輪郭があった場合、どうやって着目したいの輪郭を選び出すか？

ここでは次のように単純に判定することにした。

**『トップレベルの輪郭について、構成する座標数が一番多いもの』**

トップレベルの輪郭は`observation.topLevelContours`で取得できるので、この中から選び出す。

```swift
let outSideContour = observation.topLevelContours.max(by: { $0.normalizedPoints.count < $1.normalizedPoints.count })
```
`VNContour` には `normalizedPoints`という輪郭を構成する点座標の配列を持っている。
この配列の要素数が多ければノイズのような小さなものではなく、複雑な輪郭を持ったものであり、おそらくそれが着目したいものの輪郭であろう、という判定方法である。
単純な判定ではあるが試した限り、自分の認識と異なるような輪郭を捉えることはなかった。

## ⑤④を表示する

検出された輪郭のCGPathは左下が(0, 0)、右上が(1, 1) という座標系になっているので、UIKitの座標系である 左上が(0 ,0)の座標系に変換する必要がある。
CGPathの座標変換は `CGAffineTransform` を使い、次のように行う。

```swift
var transform = CGAffineTransform(scaleX: detectSize, y: -detectSize)
transform = transform.concatenating(CGAffineTransform(translationX: 0, y: detectSize))
let transPath = path.copy(using: &transform)
```

まず、`CGAffineTransform(scaleX: detectSize, y: -detectSize)` でスクリーン上のサイズに拡大すると同時に上下を反転する変換行列を作る。このままでは上下反転によりパス全体がY座標のマイナス側に移動してしまうため、 `CGAffineTransform(translationX: 0, y: detectSize)` で左上が (0, 0) になるようにパスを移動する行列を作り、これを乗算する。あとは、変換行列を CGPath の `copy(using:)` に与えて座標変換されたCGPathを作成し、これを`CAShapeLayer` の `path` に与えて描画する。

```swift
let pathLayer = CAShapeLayer()
（略）
pathLayer.path = transPath
pathLayer.strokeColor = UIColor.blue.cgColor
pathLayer.lineWidth = 10
pathLayer.fillColor = UIColor.clear.cgColor
self.view.layer.addSublayer(pathLayer)
```

説明は以上です。
次回は、取得したCGPathからSceneKitで扱えるジオメトリ の生成に挑戦。

## 全体ソースコード

```swift:ViewController.swift
import ARKit
import Vision
import CoreImage.CIFilterBuiltins

class ViewController: UIViewController, ARSessionDelegate {

    @IBOutlet weak var scnView: ARSCNView!
    
    private let device = MTLCreateSystemDefaultDevice()!
    private var contourPathLayer: CAShapeLayer?
    // キャプチャ画像上の輪郭検出範囲
    private let detectSize: CGFloat = 320.0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // AR Session 開始
        self.scnView.session.delegate = self
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        self.scnView.session.run(configuration, options: [.removeExistingAnchors, .resetTracking])
    }
    
    // ARフレームが更新された
    func session(_ session: ARSession, didUpdate frame: ARFrame) {
        // 一番外側の輪郭を取得
        guard let contour = getFirstOutsideContour(frame: frame) else { return }
        
        DispatchQueue.main.async {
            // 輪郭を描画
            self.drawContourPath(contour: contour)
        }
    }
    
    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(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
    }
    
    private func drawContourPath(contour: VNContour) {
        // 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)
        // 表示中のパスは消す
        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 = transPath
        pathLayer.strokeColor = UIColor.blue.cgColor
        pathLayer.lineWidth = 10
        pathLayer.fillColor = UIColor.clear.cgColor
        self.view.layer.addSublayer(pathLayer)
        self.contourPathLayer = pathLayer
    }
}
```