はじめに

iOS11が登場してしばらくが経ちました。

そんな中、今回は遅ればせながら ARKit の影に隠れがちな(?) Vision.Framework にある オブジェクトトラッキング について紹介をしていきたいと思います。

Vision.Framework について

まず Vision.Framework について簡単に説明をします。

Vision.Framework は画像解析を行うフレームワークで、画像、動画のどちらも対象とすることができます。

解析の推論に特化した内容となっており、独自に用意した学習モデルを元に推論による画像解析を行うこともできますが、 学習モデルを用意せずとも気軽に高性能な画像解析を行なう機能も備わっています。

Vision.Framework による画像解析はデバイスのみで完結できるので、 オフラインの状況でも画像解析を利用したいというような場面で活躍すると思います。

Vision.Framework で解析できる内容は以下になります。

  • 顔検出と認識
  • 機械学習画像解析
  • バーコード検出
  • 画像整列解析
  • テキスト検出
  • 水平線の検出
  • オブジェクトの検出と追跡

今回はこの中の「オブジェクトの検出と追跡」を試してみることにします。

試してみる

カメラから入力される動画の内容から対象をタップで選択し、選択した内容を追跡し続けるアプリを作ります。 プロジェクトは Single View APP で作成するものとして進めていきます。

プロジェクトの設定

まず Vision.Framework は iOS11以上が対象ですので、 Deployment Target を11.0としましょう。

また今回のサンプルではカメラを使いますので、 info.plist の内容に Privacy - Camera Usage Description を追加しておきましょう。

フレームワークの import

Single View APP の選択で作成されている ViewCotroller.swift にコードを書いていきます。

import する内容としては VisionAVFoundation を設定します。

import Vision
import AVFoundation

AVFoundation による動画表示の実装

まず AVCaptureVideoDataOutputSampleBufferDelegateViewController に追加します。

class ViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate {

入出力を管理する AVCaptureSessionViewController のプロパティに用意します。

private lazy var captureSession: AVCaptureSession = {
    let session = AVCaptureSession()
    guard
        let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
        let input = try? AVCaptureDeviceInput(device: backCamera)
        else { return session }
    session.addInput(input)
    return session
}()

ビデオを表示するレイヤーとなる AVCaptureVideoPreviewLayer を設定します。

private lazy var previewLayer: AVCaptureVideoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)

AVCaptureSession の設定を viewDidLoad で行っていきます。

AVCaptureVideoDataOutput を生成し AVCaptureSession に出力内容として設定します。

また、表示用のレイヤーである AVCaptureVideoPreviewLayerViewControllerView に追加します。

override func viewDidLoad() {
    super.viewDidLoad()

    let videoOutput = AVCaptureVideoDataOutput()
    videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "Queue"))
    captureSession.addOutput(videoOutput)
    captureSession.startRunning()

    view.layer.addSublayer(previewLayer)
}

AVCaptureVideoDataOutputdelegate のメソッドを用意します。

func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    guard
        let pixelBuffer: CVPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
        else { return }
}

また ViewController の View のレイアウトが確定した段階で AVCaptureVideoPreviewLayer のサイズを変更しておきます。

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    previewLayer.frame = self.view.bounds
    previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
}

選択範囲を表す UIView の実装

まず、選択範囲を表す UIView を ViewController のプロパティに用意します。

今回は背景色を透過とし、枠線に白を設定したものとします。

private lazy var highlightView: UIView = {
    let view = UIView()
    view.layer.borderColor = UIColor.white.cgColor
    view.layer.borderWidth = 4
    view.backgroundColor = .clear
    return view
}()

画面タッチの判定は touchesBegantouchesEnded で組みわせて行なっていくことにします。

選択範囲を表す UIView の frame が zero となるようにします。

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    highlightView.frame = .zero
}

選択範囲を表す UIView を、タッチされた位置に表示されるようにします。

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch: UITouch = touches.first else { return }
    highlightView.frame.size = CGSize(width: 120, height: 120)
    highlightView.center = touch.location(in: view)
}

ここまででカメラの内容が表示され、タッチされた位置に矩形の枠線が表示されるようになりました。

オブジェクトの検出と追跡の実装

ここから Vision.Framework を使用していきます。

まず Vision の画像認識では簡単にですが以下の役割を持ったクラスが出てきます。

  • Request
    • 画像解析要求
  • RequestHandler
    • 画像解析要求を処理するオブジェクト
  • Observation
    • 画像解析結果

今回のケースではタッチの位置でまず Observation を作成し、その後は前の Observation を元に新たに Observation を生成を繰り返していきます。

Observation の情報を UIView で表示することで追跡が行われていることを視覚的に確認していきます。

RequestHandler としては複数の画像に関連する要求を処理する VNSequenceRequestHandler を使用し、Observation としては検出された範囲を持つ VNDetectedObjectObservation を使用します。

まず、ViewController のプロパティとして VNSequenceRequestHandlerVNDetectedObjectObservation を設定します。

また VNDetectedObjectObservation は追跡の対象を変更する上で一度 nil としたいので、オプショナルとしておきます。

private var requestHandler: VNSequenceRequestHandler = VNSequenceRequestHandler()
private var lastObservation: VNDetectedObjectObservation?

また、切り替える上でタッチが開始されてから離されるまでは追跡を行わない作りとします。

そのため現在タッチ中かどうかのフラグを用意します。

private var isTouched: Bool = false

ここまでで ViewController で用意するプロパティは出揃いました。

それではまず先ほど用意した touchesBegan に対して VNDetectedObjectObservation を nil とし、 isTouched を true とします。

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    highlightView.frame = .zero
    lastObservation = nil
    isTouched = true
}

また、 touchesEnded で追跡を追跡対象となる Observation を設定し、 isTouched は false とします。

AVCaptureVideoPreviewLayer における範囲(0〜1で表される)を取得した上で VNDetectedObjectObservation に渡しますが、 VNDetectedObjectObservation では y の座標が逆となるため、1から引いた値とすることで対応します。

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch: UITouch = touches.first else { return }
    highlightView.frame.size = CGSize(width: 120, height: 120)
    highlightView.center = touch.location(in: view)
    isTouched = false
    var convertedRect = previewLayer.metadataOutputRectConverted(fromLayerRect: highlightView.frame)
    convertedRect.origin.y = 1 - convertedRect.origin.y
    lastObservation = VNDetectedObjectObservation(boundingBox: convertedRect)
}

AVCaptureVideoDataOutput の更新部分に手を入れていきます。

func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    guard
        let pixelBuffer: CVPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer),
        let lastObservation = self.lastObservation
    else {
        requestHandler = VNSequenceRequestHandler()
        return
    }

    if self.isTouched { return }

    let request = VNTrackObjectRequest(detectedObjectObservation: lastObservation, completionHandler: update)
    request.trackingLevel = .accurate
    do {
        try requestHandler.perform([request], on: pixelBuffer)
    } catch {
        print("Throws: \(error)")
    }
}

追跡対象を数回繰り返した際に問題とならないように追跡対象が変更される(nil となっている)タイミングで VNSequenceRequestHandler を作り直す処理を行っています。

VNTrackObjectRequest には前回の結果となる detectedObjectObservation と処理完了時に呼ぶ completionHandler を引数として渡します。

今回は completionHandler には別のメソッドとして update を用意する形にしました。

VNTrackObjectRequest の品質は精度を重視する accurate を指定しています。

VNSequenceRequestHandlerperformrequest と動画の内容を渡し解析を開始しています。

画像認識の結果に対する処理を行っていきます。

private func update(_ request: VNRequest, error: Error?) {
    DispatchQueue.main.async {
        guard let newObservation = request.results?.first as? VNDetectedObjectObservation else { return }
        self.lastObservation = newObservation
        guard newObservation.confidence >= 0.3 else {
            self.highlightView.frame = .zero
            return
        }
        var transformedRect = newObservation.boundingBox
        transformedRect.origin.y = 1 - transformedRect.origin.y
        let convertedRect = self.previewLayer.layerRectConverted(fromMetadataOutputRect: transformedRect)
        self.highlightView.frame = convertedRect
    }
}

Observation として結果を取得できていたとしても VNDetectedObjectObservationconfidence で信頼度を確認し、一定の値以下であれば表示は行わないとしています。

最後に VNDetectedObjectObservation から得られた範囲を元に、選択範囲を表す UIView の frame を算出しています。

完成

まとめ

今回は Vision.Framework のオブジェクトの検出と追跡といった機能を試してみました。

画像処理の専門的な知識がなくとも、少ない記述量で簡単に実装できてしまいした。

普段開発するようなアプリでも Vision.Framework の他の機能や ARKit などを組み合わせることで、体験としてより価値の高いものを少ない労力で実現できるようになったのではないでしょうか?

公式

https://developer.apple.com/documentation/vision