ARKit + Visionで四角形を検知する


## やりたかったこと

iOS11以降に搭載されたApple公式の計測アプリの様に自動的に四角形を見つけて長さを測ること

![IMG_0308.png](https://qiita-image-store.s3.amazonaws.com/0/200963/0ec4583f-c604-a306-81f8-a2111ddeec37.png "IMG_0308.png")


## ARKitとVisionを使えばそれできるみたい

調べてみるとVisionという画像の特徴点を解析して何が表示されているかを解析するフレームワークとARKitを組み合わせれば計測アプリと同じことができることが分かった。
Melissa Ludowiseさんが２つを組み合わせたデモを作成しており、その様子が[Youtubeにアップ](https://www.youtube.com/watch?v=57rLJlsp8YM)されている。さらにソースも[githubに公開](https://github.com/mludowise/ARKitRectangleDetection)されている。

公開されたソースを実機で動かしてみたところ、四角形の横幅が原寸よりも小さく表示されたためMeslissaさんのコードをベースに自分で作り直してみることにした。

## 四角形検知のための大まかな処理の流れと簡単な解説

### 1. ARSessionの中の1フレーム中のキャプチャ画像を取得する
Apple公式のVisionのサンプルアプリは静止画像(UIImage)を処理しているが、ARSessionが1フレームごとに処理しているキャプチャ画像(CVPixelBuffer)も同じ画像情報であり、クラスは違えど両方ともVisionで処理が可能。

### 2. キャプチャ画像をVisionフレームワークのVNDetectRectanglesRequestで解析し四角形を検索する
Visionを使えばバーコードや文字などを検知することができるが、今回欲しいのは四角形なのでVNDetectRectanglesRequestで処理する。

### 3. 見つかった四角形の四隅の座標を実際のARSceneViewの大きさに合わせて変形させる
ここがMelissaさんのコードで横幅が小さく表示された原因。見つかった四隅の情報はとても小さく、UI上で表示するためにはUIの領域の倍率まで拡大する必要があるが、四隅はVisionで処理したキャプチャ画像の比率に対するもので、実際の画面の縦横比率が異なる場合それに合わせて変形を行う必要がある。
例）画面の縦横の比率が3：4でも、キャプチャ画像は9：16であったりするため、単純に画面の大きさを掛け合わせると意図せぬ場所に四角形が表示される

### 4. 変形した座標に合わせてUIBezierPathで線を引く
四隅の座標が決まればそれに合わせて、ARSceneViewの上に同じ大きさのCALayerを起き、その上にUIBezierPathで線を引けばARKitで表示している四角形の物体に合わせて四角形を表示することができる。


## ARSessionの中の1フレーム中のキャプチャ画像を取得する

トリガはなんでも良いのでそのタイミングのARFrameを取得する。例では画面のダブルタップをトリガとしている。

``` Swift
    // ダブルタップをトリガとして処理を行うためActionを定義
    @IBAction func doubleTapped(_ sender: Any) {
        let tapRecognizer = sender as! UITapGestureRecognizer
        
        if tapRecognizer.state == .recognized {
            NSLog("tapped")
            
            // self.pathLayer -> ARSceneViewの大きさに合わせて作成したCALayer
            // タップ時に過去に描画した四角形を一度クリアする
            if let drawLayer = self.pathLayer {
                drawLayer.sublayers?.forEach({ layer in
                    layer.removeFromSuperlayer()
                })
            }
            
            if let frame = self.sceneView.session.currentFrame {
                // findRectangle -> Visionで四角形検索を行う自作メソッド
                findRectangle(frame: frame)
            }
        }
    }
```

## キャプチャ画像をVisionフレームワークのVNDetectRectanglesRequestで解析し四角形を検索する

``` Swift
    func findRectangle(frame currentFrame: ARFrame) {
        // Visionの解析処理をメインスレッドで行うとレスポンスが悪くなるのでバックグラウンドで行う
        DispatchQueue.global(qos: .background).async {
            // リクエストには処理が完了したときに呼ばれるハンドラを登録しておく        
            let request = VNDetectRectanglesRequest(completionHandler: self.handleDetectedRectangles)
            request.maximumObservations = 1
            
            // キャプチャした画像のサイズを取得する
            let width = CVPixelBufferGetWidth(currentFrame.capturedImage)
            let height = CVPixelBufferGetHeight(currentFrame.capturedImage)
            let imageBounds = CGRect(x: 0, y: 0, width: width, height: height)
            
            if let displayBounds = self.pathLayer?.bounds {
                // キャプチャ画像のサイズと表示画面のサイズより、変形する値を事前に計算しておく
                self.updateRectTransformParam(capturedImageBounds: imageBounds, DisplayBounds: displayBounds)
            }
            
            // 四角形を検索するためのリクエストを処理する
            let handler = VNImageRequestHandler(cvPixelBuffer: currentFrame.capturedImage, options: [:])
            do {
                try handler.perform([request])
            } catch let error as NSError {
                print("Failed to perform image request: \(error)")
                return
            }
        }
    }
```

変形値の計算処理

``` Swift
    func updateRectTransformParam(capturedImageBounds imageBounds: CGRect, DisplayBounds displayBounds : CGRect){
        // rectTransform -> 事前に定義したクラスのメンバ変数
        // ディスプレイが横長か縦長かを判断する
        if (displayBounds.width > displayBounds.height) {
            // 横長の場合は縦のスケールを基準にするため、yの変換は不要
            rectTransform.yScale = 1
            // 縦の長さを同じにした場合、実ディスプレイサイズがキャプチャイメージの何倍になるかを計算する
            rectTransform.xScale = (displayBounds.height * imageBounds.width) / (displayBounds.width * imageBounds.height)
            // 縦の余白は生まれないためyShiftは0
            rectTransform.yShift = 0
            // 縦の長さを同じにして重ね合わせた場合、横に余白が生まれるためその分を計算する
            rectTransform.xShift = ( imageBounds.width - displayBounds.width ) / 2
        } else {
            rectTransform.xScale = 1
            rectTransform.yScale = (displayBounds.width * imageBounds.height) / (displayBounds.height * imageBounds.width)
            
            rectTransform.xShift = 0
            rectTransform.yShift = ( imageBounds.height - displayBounds.height ) / 2
        }
        
        // VNDetectRectanglesRequestで検知した四角形はy軸またはx軸で反転した状態であり、元に戻す際に表示領域（ディスプレイ）のサイズが必要になるため記録しておく
        rectTransform.displayBounds = displayBounds
    }
```

VNDetectRectanglesRequest完了時のハンドラ

``` Swift
    func handleDetectedRectangles(request: VNRequest?, error: Error?) {
        if let nsError = error as NSError? {
            print("Rectangle Detection Error \(nsError)")
            return
        }
        // 描画処理はUI処理なのでメインスレッドで行う
        DispatchQueue.main.async {
            guard let drawLayer = self.pathLayer,
            let results = request?.results as? [VNRectangleObservation] else {
                return
            }
            self.draw(rectangles: results, onImageLayer: drawLayer)
            drawLayer.setNeedsDisplay()
        }
    }
```


## 見つかった四角形の四隅の座標を実際のARSceneViewの大きさに合わせて変形させる ＆ 変形した座標に合わせてUIBezierPathで線を引く

四角形を任意の場所に合わせて描画するための変型処理は[ここの情報](https://stackoverflow.com/questions/47928691/convert-vnrectangleobservation-points-to-other-coordinate-system)を参考にした。

``` Swift
    func draw(rectangles: [VNRectangleObservation], onImageLayer drawlayer: CALayer) {
        // 四角形を描画するためのトランザクションを開始
        CATransaction.begin()
        
        for observation in rectangles {
            // 四角形を描画したレイヤーを取得する
            let rectLayer = shapeLayerRect(color: .blue, observation: observation)
            
            // 四角形のレイヤーを追加（上に乗せる）する
            drawlayer.addSublayer(rectLayer)
        }
        CATransaction.commit()
    }
    
    func shapeLayerRect(color: UIColor, observation: VNRectangleObservation) -> CAShapeLayer {
        let layer = CAShapeLayer()
        
        // 事前に計算した変型を適用する
        let transform = CGAffineTransform.identity
            .translatedBy(x: rectTransform.displayBounds.minX, y: rectTransform.displayBounds.minY)
            .scaledBy(x: 1, y: -1)
            .scaledBy(x: rectTransform.xScale, y: rectTransform.yScale)
            .translatedBy(x: -rectTransform.xShift, y: -rectTransform.yShift)
            .translatedBy(x: 0, y: -rectTransform.displayBounds.height)
            .scaledBy(x: rectTransform.displayBounds.width, y: rectTransform.displayBounds.height)
        
        let convertedTopLeft = observation.topLeft.applying(transform)
        let convertedTopRight = observation.topRight.applying(transform)
        let convertedBottomLeft = observation.bottomLeft.applying(transform)
        let convertedBottomRight = observation.bottomRight.applying(transform)
        
        // UIBezierPathを引いて四角形を描画する
        let linePath = UIBezierPath()
        linePath.move(to: convertedTopLeft)
        linePath.addLine(to: convertedTopRight)
        linePath.addLine(to: convertedBottomRight)
        linePath.addLine(to: convertedBottomLeft)
        linePath.addLine(to: convertedTopLeft)
        linePath.close()
        layer.strokeColor = color.cgColor
        layer.lineWidth = 2
        layer.path = linePath.cgPath
        
        return layer
    }
```

## 実際の結果

手ブレで若干ずれているが、実際の本に合わせて四角形が描画される

![IMG_0312.png](https://qiita-image-store.s3.amazonaws.com/0/200963/d969f566-d824-9b97-f180-8387540d07a1.png "IMG_0312.png")

この検知した座標に向けてhitTestを行えば、ARKitで検知した平面上にSCNPlaneを配置することも可能。