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

CVPixelBufferを回転させたい時の話

あんまり役に立つ人はいないかもしれないけど、折角分かった事だから残しておこうと思います。

CVPixelBuffer型の何か(以後pixelBufferとします)を回転させたい場合、これを直接回転させることはできません。
CVPixelBufferをUIImage、CGImage、CIImageの何れかに変換して回転させる必要があります。

調べた限りCIImageに変換して回転させるのが省エネだし一番簡単です。

CIImageへの変換

まずCIImageへの変換

let ciImage: CIImage = CIImage(cvPixelBuffer: pixelBuffer)

上記で変換できます。

回転

お次は回転。
回転については色々やり方がありますが…
今回はiOS11で追加されたメソッド oriented を使います。
理由はとてもシンプルな使い勝手だから。

(参考) oriented(_:) - CIImage | Apple Developer Documentation

let orientation :CGImagePropertyOrientation = CGImagePropertyOrientation.right
let orientedImage = ciImage.oriented(orientation)

上記だとorientedImageが元画像は右に90°回転したCIImageになります。

CIImageからCVPixelBufferへの戻し変換

最後にCIImage→CVPixelBufferへの戻し変換作業です。
実はここが一番引っかかったところ。
ciImageには元々CVPixelBuffer型のpixelBufferというプロパティがあり、
これを参照すれば楽勝で回転したnewPixelBufferを取得できると考えていました。
しかし…
これ、うまくいきません!!!
正確な理由は調査していません(失礼!)が、ある決まった作成方法で作られたCIImageのみ情報が保存されているでしょうね。
(証拠にpixelBufferをCIImageに変換し、回転させずにそのままnewPixelBufferに戻したものは問題なく取り出せました。)

ではどうするのか?(CIImageからCVPixelBufferへの戻し変換)

  1. CVPixelBuffer型の変数を新たに作成する。
  2. CIContext型のコンテキストを用意して、CIImageを元にCVPixelBuffer型へrenderする。

と、してあげるのが一番シンプルな書き方っぽいです。(この書き方であってるんだろうか..??)

今回いっちばん引っかかったのは「CVPixelBuffer型の変数を新たに作成する」ところ。
CVPixelBufferCreateで新たにBuffer変数を作成する時、作成Optionを指定してあげなければなりません。
実は今回は前回の記事 AVPlayerItemVideoOutputを使ってムービーファイルから取得したPixelBufferからムービーの再生させてみた で再生されているムービーを回転させたかったことが出発点になっているのですが、その場合、正確にoptionを指定してあげなければ再生可能なCVPixelBufferにならないのです。
具体的には「iosurface」が存在するCVPixelBufferを作成するために kCVPixelBufferIOSurfacePropertiesKey as String: [:]] as [String : Any] を指定してあげなければいけないのです。ここ大事です。

コードは以下。convertFromCIImageToCVPixelBuffer というメソッドを作りました。

 private func convertFromCIImageToCVPixelBuffer (ciImage:CIImage, pxbuffer:CVPixelBuffer) -> 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)
        
        
        let ciContext = CIContext()
        
        if (status == kCVReturnSuccess && pixelBuffer != nil) {
            ciContext.render(ciImage, to: pixelBuffer!)
        }
        
        return pixelBuffer
    }

はじめに書いた通り、役に立つ方がおられるかは不明ですが、折角調べたことを忘れちゃうのもアレなんでブログに残しときました。
それでは。

AVPlayerItemVideoOutputを使ってムービーファイルから取得したPixelBufferからムービーの再生させてみた

iOSでの動画の再生方法には以下の3つがありますよね。

  1. UIWebViewを使う
  2. AVPlayerViewControllerを使う
  3. AVPlayerを使う

番号が大きくなるほど自由度は高くなりますが、難易度は増していきます。
しかしながら今回は上記の方法の何れでもない方法で再生します。AVPlayerを普通に使用するよりも、もう少し込み入った方法でムービーファイルを再生したくなったのです。
具体的には「再生する際に使用されるPixelBufferを実装者の意思で一回取り出して、そのPixelBufferを使って動画を再生する」というものです。
その際に少しAVPlayerの助けはお借りしますが。

以下のレポジトリのソースを参考にしまくってますので合わせてご覧ください。
(今回、ほぼほぼ以下のコピーなんですがXcode 9 + swift4で動かさねばならなかったので…)

github.com

どうやって動画ファイルからPixelBufferを取り出すか

今回ある理由(後日更新予定)で、どうしても動画ファイルからPixelBufferを取り出したかったんですが、そうするにはどうすればいいんだ?というのがはじめのハードルでした。どうやって調べればそこにたどり着くのかすら、初めはわかりませんでした(苦笑)
色々調べた結果、AVPlayerItemVideoOutput + AVPlayerItemOutputPullDelegate を使用すれば取り出せそうだなーということが分かりました。

##今回の動画再生の流れ
今回の動作再生の流れの理解は以下のような感じです(多分…)。

  1. (準備)動画再生時の画像を表示するクラス(親: UIView)をまず作成。これをSampleBufferDisplayLayerViewとする。
  2. (準備)SampleBufferDisplayLayerViewのプロパティにplayer(AVPlayer型)を追加する。
  3. (準備)SampleBufferDisplayLayerViewのプロパティにplayerItemVideoOutput(AVPlayerItemVideoOutput型)を追加する。
  4. (準備)SampleBufferDisplayLayerViewのlayerをvideoLayer(AVSampleBufferDisplayLayer型)に置き換える。
  5. ViewControllerで動画を含めたライブラリを呼び出してムービーを選択。→ファイルのURLを取得。
  6. 5で取得したURLからAssetを取得。そしてそのAssetからitem(AVPlayerItem型)を取得。
  7. SampleBufferDisplayLayerView(1参照)のplayerプロパティ(2参照)に5で取得したitemを追加(というか置き換えって言った方が正しい??)。
  8. SampleBufferDisplayLayerViewがitemが追加されたことを(予めaddしているObserverによって)検知したら、追加されたitemを取得しplayerItemVideoOutput(3参照)に追加。
  9. playerItemVideoOutputのPixelBufferの変化を検知するためにCADisplayLinkとかrequestNotificationOfMediaDataChangeとかsetDelegateとかをごにょごにょ設定を前もってしておく。そのためにはAVPlayerItemOutputPullDelegateをSampleBufferDisplayLayerViewに追加しとく必要がある。
  10. 9で設定したもの達が判断した適切なタイミングでvideoLayerにPixelBufferを格納する。→Viewで表示されている内容が更新される。

SampleBufferDisplayLayerViewクラスのコード

今回の動画再生の心臓部、SampleBufferDisplayLayerViewのコードは以下です。

import UIKit
import AVFoundation

class SampleBufferDisplayLayerView: UIView, AVPlayerItemOutputPullDelegate {
    
    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
        
        if videoInfo == nil {
            err = CMVideoFormatDescriptionCreateForImageBuffer(nil, pixelBuffer, &videoInfo)

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

        sampleBuffer = nil
    }
}

Github

今回作成したプロジェクトは以下にあげてます。
ViewControllerについても、ファイルの再生が終わったことを認識する部分などにクセがあるので気をつけて見てみてください(今回は説明を割愛致します)。

github.com

アニメーションしているViewを手軽に録画してムービーとして保存出来るクラスRecViewAnimationを作成・公開しました

お疲れ様です。

タイトルの通りアニメーションしているViewを録画してムービーとして保存出来るクラス、RecViewAnimationを作成しました。

以下にて公開しております。

▶︎GitHub - anthrgrnwrld/tryRecordingViewAnimation

「アニメーションしているView」ってのは、アニメーションしているViewそのものではなくて、そうゆうViewを内包している親Viewのことです。そいつに映ってるものを録画出来るというわけ。

前回、前々々回の更新はこいつの作成の中で調べた内容なんですよ。

anthrgrnwrld.hatenablog.com

anthrgrnwrld.hatenablog.com

作成した経緯

自作アプリでスクリーンに映っているアニメーションを録画する機能があります。
録画方法はAppleが標準で提供してくれているReplayKitを使用しているのですが、iOS11になってから、極端にその録画に失敗するようになってしましました。
何とか使えるようにならないか検討したのですがどうにもならず、それなら自分で録画用クラスを作成してしまおう!となった訳です。

使用方法

使い方はとてもシンプルにしたつもりです。
このプロジェクト内にある"RecordingViewAnimation.swift”を使用したいプロジェクトにまず放り込みます。 そして使いたいところで以下のような感じでレコーダーを作成してあげます。
let recorder = RecViewAnimation.shared

録画開始したい時には
recorder.startRecording(view: [対象のView], fpsSetting: [フレームレート(1秒間に何コマ表示させるか)]
をコールします。
そして録画停止したい時には
recorder.stopRecording()
をコールします。
このstartRecordingのコールで写真ライブラリへの保存まで行います。
(今後オプションでライブラリへの保存はせず、ファイルパスを渡すようなUpdateもアリかもしれません。)

録画保存の完了を通知して欲しい場合もあるかと思います。
その場合にはrecViewDidFinishedToSaveDelegateを使用して下さい。
その際にはUIViewControllerへのRecViewAnimationDelegateの追加、そしてrecorder.delegate = self の記載を忘れないで下さい。

因みにRecViewAnimationはSingletonクラスなので、どこでRecViewAnimation.shared作っても内容は保持されてますので安心です。
(もちろん停止したら消去するようにしてます。)

また、途中でViewのサイズが変更されてしまった際(画面がRotateしちゃった時とかね)、強制的に録画をStopする仕様にしてます。
このようなケースで録画終了してしまった場合にはDelegateメソッド recViewDidFinishedWithoutCallToStop で通知します。

あ〜最後に、このクラスはPhotoライブラリーへファイルを追加する為、Info.plistにPrivacy - Photo Library Additions Usage Descriptionの記載も必要になります。気をつけて!

以上になります。

そんなに多機能ではありませんが、使ってもらえると嬉しいです。

因みに...

きっかけとなったアプリはiOS11のアップデートによって現象が発生しなくなりました。。。
なんだかな〜。

他で使えるアプリを考えているところです。

UIView(withアニメーション)からUIImageへの変換する時の注意点

お疲れ様です。

UIViewからUIImageへの変換って割とよく行う処理かと思います。
今まで私は以下みたいなUIViewのExtensionペタッと貼り付けて、任意のタイミングで使用していました。

fileprivate extension UIView {
    
    /**
   対象のViewをキャプチャしUIImageで保存
   
   - returns : キャプチャ画像 (UIImage)
   */
    fileprivate func getCaptureImageFromView() -> UIImage? {
        
        let contextSize = self.frame.size
        
        UIGraphicsBeginImageContextWithOptions(contextSize, false, 0.0)       //コンテキストを作成
        self.layer.render(in: UIGraphicsGetCurrentContext()!)              //画像にしたいViewのコンテキストを取得
        
        let captureImage = UIGraphicsGetImageFromCurrentImageContext()        //取得したコンテキストをUIImageに変換
        UIGraphicsEndImageContext()                                         //コンテキストを閉じる
        
        return captureImage
        
    }
    
    
}

上記を機嫌よく使っていたのですが、以下のような問題が発生しました。

アニメーションが入ったViewをキャプチャしたとき思てたんと違う

今回、アニメーション付きのViewを内部で保持している親Viewをキャプチャーしようと思いました。
アニメーションしているViewと瞬間の表示位置も正しく、です。

しかし結果は…
どのタイミングでキャプチャーしてもアニメーションViewがずっとおんなじ場所じゃん!!
なんで?ねぇ何で??

コードを見直してみました。
そしてあることに気がつきました。

self.layer.render(in: UIGraphicsGetCurrentContext()!)

ん??このコンテキストを取得している部分、対象ViewのCALayerに対してレンダリングしてるな…。
一方、今回のアニメーションは animate(withDuration:animations:completion:) で行なっていました。
この方法ってViewの座標位置を示すポイント値は変化せずにViewがアニメーションしているんですよね。
しかしコンテキストを取得した対象は対象ViewのCALayer…。
一つの仮説が生まれました。

viewが保持している座標位置に従ってコンテキストを取得している!

上記が正しいのかどうかちゃんと調査してませんが、多分そんなに遠い憶測ではないと思っています。

それはそうと、うーん、これは困りました…。
今回はどうしても animate(withDuration:animations:completion:) でアニメーションしているViewを任意のタイミングでキャプチャしたかったんです。
(試してませんが、アニメーションをCABasicAnimationで行なった場合にはちゃんとアニメーションしている位置に従ってキャプチャできると思われます。
がしかし、そこでView側にて制限を加えたくなかったのです。)

他の人の書いたUIView→UIImageのコードを眺めるじーっと眺めてみることにしました。
その時間、約一時間くらい。
解決策がないか探って見ました。

すると、、、
おんなじ様に見えていたコードが、よく見ると違うものが混じっていることに気づいてきました。
そして解答に行き着いたのです。 drawHierarchy(in:afterScreenUpdates:) に。

どうやらiOS7から追加されたメソッドの模様。
リファレンスには以下の記載があります。

Renders a snapshot of the complete view hierarchy as visible onscreen into the current context.

なんかいけそうな感じがする!

引数inには指定したViewのRectを入れるみたい。
この時、view.frame の場合には正味そのViewのみのキャプチャを行い、view.boundsの場合には、内包されているviewも対象になるみたい。今回はview.boundsがいいですね。

afterScreenUpdatesは対象のViewに変化があったかどうかを確認可能にするか否か(ややこしや)を指定するものみたいです。
今回はfalseとしました。

上記を考慮し、以下の様にUpdateしました。

fileprivate extension UIView {
    
    /**
   対象のViewをキャプチャしUIImageで保存
   
   - returns : キャプチャ画像 (UIImage)
   */
    fileprivate func getCaptureImageFromView() -> UIImage? {
        
        let contextSize = self.frame.size
        
        UIGraphicsBeginImageContextWithOptions(contextSize, false, 0.0)       //コンテキストを作成
        
        self.drawHierarchy(in: self.bounds, afterScreenUpdates: false)
        
        let captureImage = UIGraphicsGetImageFromCurrentImageContext()        //取得したコンテキストをUIImageに変換
        UIGraphicsEndImageContext()                                         //コンテキストを閉じる
        
        return captureImage
        
    }
    
    
}

これでanimate(withDuration:animations:completion:) で作成したアニメーションも任意の表示位置のキャプチャが出来る様になりましたとさ。

​ SceneKitについて興味が湧いてきたので少し調べてみた

​ あけましておめでとうございます。
今年もよろしくお願いいたします。

急なのですが今更ながらSceneKitに興味が湧いてきました。
なぜ今更?Unityの方がいいんじゃないの??って声が聞こえてくる。。。

きっかけはコレ
出来上がったものは何かすごそうなのに、コードを見ると、、「ん?何となくわかる!」となり詳しく知りたくなったからです。

lepetit-prince.net

SceneKitについて学べれば、その後にはARKitみたいな面白そうなものもあるし!
今日はとりあえず調べたことを箇条書きにして自分メモを残してみます。

SceneKitメモ

  • SCNView(frame: CGRect)でScene用のViewを作成できる
  • SCNViewは通常のUIViewと共存可能
  • SCNViewにはsceneというプロパティー(SCNScene型)がおり、この子が起点となって3Dの物体を配置される
  • sceneは初期化(SCNScene())してあげないと何故か使えない。理由はよくわからない。
  • SCNViewをself.viewに追加する場合には、通常通りaddSubviewすればOK
  • scene内に置ける3D物体をNodeといいSCNNode(geometry: SCNGeometry?)、若しくはSCNNode(mdlObject: MDLObject)で初期化できる
  • というかsceneにはデフォルトでrootNodeとやらがぶら下げっている
  • SCNNodeはCNNode(geometry: SCNGeometry?)での初期化でOKだと思われる
  • SCNNode(mdlObject: MDLObject)での初期化方法についてはよくわからない。mdlObjectって多分3Dオブジェクトのことだけどあんまりよくわかってない(気にしない)
  • SCNGeometryには以下の12の種類がある。

    • 無限平面(SCNFloor)
    • 立方体(SCNBox)
    • カプセル型(SCNCapsule)
    • 円錐(SCNCone)
    • 円柱(SCNCylinder)
    • 平面(SCNPlane)
    • 三角錐(SCNPyramid)
    • 球(SCNSphere)
    • ドーナツ型(SCNTorus)
    • チューブ型(SCNTube)
    • テキスト(SCNText)
    • パスからの図形(SCNShape)
  • 作成したSCNNodeはrootNodeに対しaddChildNodeすることで追加して見える化できる

  • 作成したSCNNodeは表示する座標(3次元)を指定したり大きさを変更したり形を変形させたりすることができる
  • ここまでではまだ立体のオブジェクトが存在するだけで立体的には見えない。立体的に見せるには、カメラと光源の定義が必要である
  • scene.autoenablesDefaultLightingをtrueにすると光源についてよろしくやってくれる
  • scene.allowsCameraControlをtrueにすると画面を指でグリグリすることでカメラの位置を変えれたり、表示の大きさを変えれたりすることが出来る
  • カメラと光源もノードとして扱われる為、バシッと位置とかを指定することが出来る。(けど詳細は今は知らない。)
  • [おまけ]daeという拡張子の3DのドキュメントファイルからSCNSceneを直接作成する方法もある模様(SCNScene(url: URL, options: ))
    (参考)
    qiita.com

また以下のページからはじまるまとめがかなりわかりやすいと思われます。まだ全然読めてないけど。。。

appleengine.hatenablog.com

今日は以上。

CADisplayLinkで定期的に処理を実行してみる (swift4)

ちょっと定期的に実行させたい処理があったんです。
そこで今回はCADisplayLinkを使ってみようと思います。

今までも勿論そうゆうのをしている部分はあって、scheduledTimerWithTimeIntervalとかを使ってました。
1分毎とか10秒毎とか、最も短くでも1秒毎とかの定期処理なんで、そこまでタイミングにシビアではないケースでしたが。

しかし今回は1/60秒くらいで実行させたい処理で、これだけ間隔の短い定期処理を行うのにNSTimer使うのってどうなん??というばっくりした不安がありました。(結局そんな気にすることはないって話もあるかもですが…)

そんな中、「それならCADisplayLinkを使ってみるといいよ〜」と諸先輩から教えて頂きました。
これがなかなか使いやすい。
また、画面のリフレッシュレートに同期しているということで、タイミングに関する信頼性がかなり増しております(自分的に)。

CADisplayLinkの使い方

使い方は非常に簡単。
タイマーを開始したいタイミングで以下の処理を実行し、CADisplayLink設定を行うだけ。

     // CADisplayLink設定
        let displayLink = CADisplayLink(target: self, selector: #selector(update(_:)))   //#selector部分については後述
        displayLink.preferredFramesPerSecond = 20  // FPS設定  //この場合は1秒間に20回
        displayLink.add(to: RunLoop.current, forMode: RunLoopMode.commonModes)    //forModeについては後述

selector部分も単純に定期的に行いたいメソッドを書くだけ。
今回はprintするだけにしてます。
Swift4だとRunTimeの絡みがあるので@objcの記載を忘れずに。

 @objc func update(_ displayLink: CADisplayLink) {

        // timeOffsetに現在時刻の秒数を設定
        print("\(#function) is called! \(count)\n");
        
        count += 1

    }

displayLink.addの引数のforModeはRunLoopMode型の構造体です。
RunLoopModeについてはあんまり調べれてませんが、リファレンスを見る限り以下のような種類で設定可能な模様です。
またいじりながら実地研修したいと思います。

static let commonModes: RunLoopMode
Objects added to a run loop using this value as the mode are monitored by all run loop modes that have been declared as a member of the set of “common" modes; see the description of CFRunLoopAddCommonMode(_:_:) for details.
static let defaultRunLoopMode: RunLoopMode
The mode to deal with input sources other than NSConnection objects.
static let eventTrackingRunLoopMode: RunLoopMode
A run loop should be set to this mode when tracking events modally, such as a mouse-dragging loop.
static let modalPanelRunLoopMode: RunLoopMode
A run loop should be set to this mode when waiting for input from a modal panel, such as NSSavePanel or NSOpenPanel.
static let UITrackingRunLoopMode: RunLoopMode
The mode set while tracking in controls takes place. You can use this mode to add timers that fire during tracking.

CADisplayLink、なかなか楽しかったです。

CADisplayLink - Core Animation | Apple Developer Documentation