MILLEN BOX

音楽好きの組み込みソフトエンジニアによるプログラミング(主にiOSアプリ開発)の勉強の記録

端末に保存されているムービーでオブジェクトトラッキング(Vision.Framework)を行う方法

この記事を書いている現在(2018.6.5)WWDC 2018が絶賛開催中ですが、去年のWWDCで発表となったVision.Frameworkのオブジェクトトラッキング機能についてごにょごにょしてみたので備忘録として残しておきます。

以下を参考にしました。

Vision.Framework のオブジェクトトラッキングを使ってみよう! | ギャップロ

AVCaptureSession(端末のカメラを使用したライブの映像)を使ったオブジェクトトラッキングなら上記方法で全く問題ないありません。
Webにも上記の例であれば結構たくさんあがっています。
しかしながら今回私は端末に保存されているムービーでオブジェクトトラッキングを実行したかったのです。
そうなるとなかなか「使える」情報が転がってませんでした。

端末に保存されているムービーでオブジェクトトラッキング 実現するにはどうすれば良いのか?

端末に保存されているムービーでオブジェクトトラッキングを実行するには何をしてあげればいいのでしょうか。
リンク参考に作成したプロジェクトを眺めてコードの思いを理解しようと努めましたところ、pixelBuffer(CVPixelBuffer型)の変数が怪しそうだと感じました。カメラからキャプチャした映像から取得したバッファpixelBufferをオブジェクトトラッキングを実行するメソッドに与え検出と追跡を行なっているように見えたのです。
とすればですよ。端末に保存されたムービーを読み出し再生するときにバッファpixelBufferを取得しそれでムービー再生できればオブジェクトトラッキングを実行できるのでは?と推測しました。
その為に行なったのが二つ前の記事 AVPlayerItemVideoOutputを使ってムービーファイルから取得したPixelBufferからムービーの再生させてみた です。

PixelBufferでのムービー再生とオブジェクトトラッキングの合体 そしてトラップ

pixelBufferでのムービー再生とオブジェクトトラッキングの合体しようとしました。
しかしまたここでもトラップがありました。
オブジェクトトラッキングの参考にしたコードでは、追跡箇所の座標(highlightView用)を画像解析用座標(lastObservation用)に変換するメソッドとして metadataOutputRectConverted を使用しています。また画像解析用座標から、通常の座標軸への変換するメソッドとして layerRectConverted を使用しています。
これらを今回のケースでも同じように使いたいと考えましたが、これらは二つとも AVCaptureVideoPreviewLayer のメソッドです。
AVCaptureVideoPreviewLayer は名前からわかる通り端末のカメラを使用したい時に使用するクラスですので、今回は使用できません。
結構調べたのですが、今回のケースで使用できる似たようなメソッドを見つけることはできませんでした。

PixelBufferでのムービー再生とオブジェクトトラッキングの合体 解決法

解決の糸口を見つける為に、色々なAVCaptureSessionでのオブジェクトトラッキングの例を見ましたが(ほとんどが同じやり方でしたが)、あるページを見た時、「ん?これはちょっと他のとは違うな…」と感じました。
引っかかったのはこのページ。
iOS ARKit 教程:不触摸屏幕,用空气中的手势作画 - CocoaChina_让移动开发更简单

中国語の為、何が書いてあるかはサーッパリ分からないですがコードは読めます。
気になった部分はタップした箇所の座標を画像解析用座標に変換して追跡対象として登録する tapAction メソッド。
その他のページの例では先で述べたように metadataOutputRectConverted メソッドを使って変換作業をしていますが、このページではmetadataOutputRectConverted を使用せずに変換を行なっているではありませんか!
ここから以下の工程で画像解析用座標に変換できることがわかりました。

1.  タップした座標位置(CGRect型)のwidthとheightそれぞれについて逆数になるように変換する
2.  Y座標を1から引いた値にする (VNDetectedObjectObservation では y の座標が逆となるため)

このページでは displayTransform というメソッドを使った処理が挟まれていますが、これはライブラリから読み出したファイルの場合には必要ありません。
displayTransform にしても metadataOutputRectConverted にしても端末のカメラを使った映像となっている為、解像度なんかがまだ未決で、その為に特別な変換処理が追加されたメソッドを準備しているのではないか、と推測しております。

オブジェクトトラッキングを追加したSampleBufferDisplayLayerViewクラスのコード

AVPlayerItemVideoOutputを使ってムービーファイルから取得したPixelBufferからムービーの再生させてみた 編集 からの変更になります。
PixelBufferでのムービー再生とオブジェクトトラッキングの合体しているという部分を意識すればそこまで複雑な流れではないかと思います。(長いですが…)

import UIKit
import AVFoundation
import Vision

class SampleBufferDisplayLayerView: UIView, AVPlayerItemOutputPullDelegate {
    
    private var requestHandler: VNSequenceRequestHandler = VNSequenceRequestHandler()
    private var lastObservation: VNDetectedObjectObservation?
    private lazy var highlightView: UIView = {
        let view = UIView()
        view.layer.borderColor = UIColor.white.cgColor
        view.layer.borderWidth = 4
        view.backgroundColor = .clear
        return view
    }()
    private var isTouched: Bool = false
    
    override public class var layerClass: Swift.AnyClass {
        get {
            return AVSampleBufferDisplayLayer.self
        }
    }
    
    let player = AVPlayer()
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        player.addObserver(self, forKeyPath: "currentItem", options: [.new], context: nil)
        playerItemVideoOutput.setDelegate(self, queue: queue)
        playerItemVideoOutput.requestNotificationOfMediaDataChange(withAdvanceInterval: advancedInterval)
        displayLink = CADisplayLink(target: self, selector: #selector(displayLinkCallback(_:)))
        displayLink.preferredFramesPerSecond = 1 / 30
        displayLink.isPaused = true
        displayLink.add(to: RunLoop.current, forMode: RunLoopMode.defaultRunLoopMode) //タイマーを開始
    }
    
    // KVO
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        switch keyPath {
        case "currentItem"?:
            if let item = change![NSKeyValueChangeKey.newKey] as? AVPlayerItem {
                item.add(playerItemVideoOutput)
                videoLayer.controlTimebase = item.timebase
            }
        default:
            //super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
            break
        }
    }
    
    // MARK: AVPlayerItemOutputPullDelegate
    func outputMediaDataWillChange(_ sender: AVPlayerItemOutput) {
        print("outputMediaDataWillChange")
        displayLink.isPaused = false
    }
    
    func outputSequenceWasFlushed(_ output: AVPlayerItemOutput) {
        videoLayer.controlTimebase = player.currentItem?.timebase
        videoLayer.flush()
    }
    
    //private
    private let playerItemVideoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: [kCVPixelBufferPixelFormatTypeKey as String : kCVPixelFormatType_32ARGB])  // ピクセルフォーマット(32bit BGRA)
    private let queue = DispatchQueue.main
    private let advancedInterval: TimeInterval = 0.1
    private var displayLink: CADisplayLink!
    private var lastTimestamp: CFTimeInterval = 0
    private var videoInfo: CMVideoFormatDescription?
    
    private var videoLayer: AVSampleBufferDisplayLayer {
        return self.layer as! AVSampleBufferDisplayLayer
    }
    
    /**
   setCADiplayLinkSettingに呼び出されるselector。
   */
    @objc private func displayLinkCallback(_ displayLink: CADisplayLink) {
        
        let nextOutputHostTime = displayLink.timestamp + displayLink.duration * CFTimeInterval(displayLink.preferredFramesPerSecond)
        let nextOutputItemTime = playerItemVideoOutput.itemTime(forHostTime: nextOutputHostTime)
        if playerItemVideoOutput.hasNewPixelBuffer(forItemTime: nextOutputItemTime) {
            lastTimestamp = displayLink.timestamp
            var presentationItemTime = kCMTimeZero
            let pixelBuffer = playerItemVideoOutput.copyPixelBuffer(forItemTime: nextOutputItemTime, itemTimeForDisplay: &presentationItemTime)
            displayPixelBuffer(pixelBuffer: pixelBuffer!, atTime: presentationItemTime)
        } else {
            if displayLink.timestamp - lastTimestamp > 0.5 {
                displayLink.isPaused = true
                playerItemVideoOutput.requestNotificationOfMediaDataChange(withAdvanceInterval: advancedInterval)
            }
        }
    }

    @objc private func displayPixelBuffer(pixelBuffer: CVPixelBuffer, atTime outputTime: CMTime) {
        
        var err: OSStatus = noErr
        
        let ciImage:CIImage = CIImage(cvPixelBuffer: pixelBuffer)
        let orientation:CGImagePropertyOrientation = CGImagePropertyOrientation.right
        let targetCIImage = ciImage.oriented(orientation) //回転させたい時はこっち
//     let targetCIImage = ciImage                         //回転させたくない時はこっち
        
        let targetPixelBuffer:CVPixelBuffer = convertFromCIImageToCVPixelBuffer(ciImage: targetCIImage)!
        
        if videoInfo == nil {
            err = CMVideoFormatDescriptionCreateForImageBuffer(nil, targetPixelBuffer, &videoInfo)

            if (err != noErr) {
                print("Error at CMVideoFormatDescriptionCreateForImageBuffer \(err)")
            }
            
        }
        
        detectObject(pixelBuffer: targetPixelBuffer)
        
        var sampleTimingInfo = CMSampleTimingInfo(duration: kCMTimeInvalid, presentationTimeStamp: outputTime, decodeTimeStamp: kCMTimeInvalid)
        
        var sampleBuffer: CMSampleBuffer?
        err = CMSampleBufferCreateForImageBuffer(nil, targetPixelBuffer, true, nil, nil, videoInfo!, &sampleTimingInfo, &sampleBuffer)
        if (err != noErr) {
            NSLog("Error at CMSampleBufferCreateForImageBuffer \(err)")
        }
        
        if videoLayer.isReadyForMoreMediaData {
            videoLayer.enqueue(sampleBuffer!)
        }

        sampleBuffer = nil
    }
    

    
    
    private func convertFromCIImageToCVPixelBuffer (ciImage:CIImage) -> CVPixelBuffer? {
        let size:CGSize = ciImage.extent.size
        var pixelBuffer:CVPixelBuffer?
        let options = [
            kCVPixelBufferCGImageCompatibilityKey as String: true,
            kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
            kCVPixelBufferIOSurfacePropertiesKey as String: [:]
            ] as [String : Any]
        
        let status:CVReturn = CVPixelBufferCreate(kCFAllocatorDefault,
                                                  Int(size.width),
                                                  Int(size.height),
                                                  kCVPixelFormatType_32BGRA,
                                                  options as CFDictionary,
                                                  &pixelBuffer)
        
        
        CVPixelBufferLockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
        CVPixelBufferUnlockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
        
        let ciContext = CIContext()
        
        if (status == kCVReturnSuccess && pixelBuffer != nil) {
            ciContext.render(ciImage, to: pixelBuffer!)
        }
        
        return pixelBuffer
    }
    
    
    private func detectObject (pixelBuffer: CVPixelBuffer) {
        
        guard
            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)")
        }
        
    }
    
    
    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 t = CGAffineTransform(scaleX: self.frame.size.width, y: self.frame.size.height)
            let convertedRect = transformedRect.applying(t)
            self.highlightView.frame = convertedRect
        }
    }
    
    //ViewControllerのtouchesBeganの中の処理でSampleBufferDisplayLayerView.touchBeganを呼び出してあげる
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        highlightView.frame = .zero
        lastObservation = nil
        isTouched = true
    }
    
    //ViewControllerのtouchesEndedの中の処理でSampleBufferDisplayLayerView.touchesEndedを呼び出してあげる
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch: UITouch = touches.first else { return }
        highlightView.frame.size = CGSize(width: 90, height: 90)
        highlightView.center = touch.location(in: self)
        isTouched = false
        
        let t = CGAffineTransform(scaleX: 1.0 / self.frame.size.width, y: 1.0 / self.frame.size.height)
        var normalizedTrackImageBoundingBox = highlightView.frame.applying(t)
        normalizedTrackImageBoundingBox.origin.y = 1 - normalizedTrackImageBoundingBox.origin.y
        lastObservation = VNDetectedObjectObservation(boundingBox: normalizedTrackImageBoundingBox)
        self.addSubview(highlightView)
    }
    
}

Github

今回作成したプロジェクトは以下にあげてます。
ViewControllerで受け取ったタップ座標をSampleBufferDisplayLayerViewに伝える箇所は少し気をつけて見てください。

github.com