AVAssetWriterによるビデオ録画時に、先頭にblank frameが入ってしまう

急にiosのネタですが、かなりハマったので、メモしておきます。

 

非常にレアケースですが、カメラ、マイクを使ったAVAssetWriterによる音声付きビデオの録画時、まれに先頭フレームが空になるケースがあります。これは、ビデオに対し遅延が発生するようなエフェクト等の重い処理を行った場合に特に発生しやすいようです。

 

現象を追跡してみました。

 

カメラ、マイクから毎フレーム送られてくるCMSampleBufferRefには、タイミング情報が含まれています。

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection

CMTime timestamp = CMSampleBufferGetPresentationTimeStamp( sampleBuffer );

ここで取得できる時間情報はおそらくelapsed timeだと思います。(アプリの起動からの時間)

 

録画処理を開始するとAVAssetWriterに対して、AudioとVideoのCMSampleBufferを追記していくわけですが、このタイミング情報を一緒に渡すことで、同期が取られるようです。タイミング情報は前述のとおりelapsedTimeとなっているので、先頭フレームの書き込み時には、下記のように録画開始のタイミングをAVAssetWriterに通知します。

[_assetWriter startSessionAtSourceTime:CMSampleBufferGetPresentationTimeStamp(sampleBuffer)];

この先頭フレームのタイミングは、通常、ほぼ同時またはVideoバッファが先行して送られてくるわけですが、ビデオに対し一定の処理時間をかけた場合、前フレームの処理遅延により、Audioバッファが数フレーム先行して記録されてしまうケースが発生します。startSessionAtSourceTimeでは、audio,videoを識別せずに追記した場合、videoが抜け、Audioだけ存在するフレームが発生してしまいます。先頭フレームの場合、dropframeにならないため、ブランクビデオが記録されたような状態になります。

 

以上がほぼ原因として間違いなさそうなので、初期フレームをVideoに限定することで対応が可能です。

if(!_haveStartedSession){
  //初期バッファの処理
  if(mediaType == AVMediaTypeVideo){
    //ビデオの場合にタイミングを通知
    [_assetWriter startSessionAtSourceTime:CMSampleBufferGetPresentationTimeStamp(sampleBuffer)];
    _haveStartedSession = YES;
  }else{
    //初期バッファがオーディオなら一旦破棄して次のバッファーを待機
    CFRelease( sampleBuffer );
    return;
  }
}
//以降sampleBufferを記録

AVFoundation、膨大で大変すね・・・。

TypoGraphics Processing Engine

フォントで写真やイラストを再構成する画像処理プログラム。2年前にiOSアプリとして作ったものだが、アプリとしては重すぎるのでストアには公開できなかった・・・。

このプログラムでは様々なFONTや、単色のイラスト(マーク)などで写真を構築できる。画像処理としてはそれほど複雑なものではなく、単純にピクセルのテスト・アップデートの繰り返しである。ただしテスト・描画用のメモリを別にもつなど、高速化の工夫はいくつかやっている。あとはマルチスレッドとランダムアクセスをやればかなり早くなると思う。

アニメーションは画像処理のプロセスを単純に時間軸にそって出力した結果である。このままだと単調でつまんないのでインスタレーションとして再構築しようと思ってる。レンダリングマップをDBに保存して3D空間で一つ一つの文字を動かすみたいな。

kCVPixelFormatTypeについての考察

iPhoneで動画像処理を行う場合kCVPixelBufferPixelFormatTypeKeyの値には
kCVPixelFormatType_32BGRA を用いるよりkCVPixelFormatType_420YpCbCr8BiPlanarVideoRangeが遥かに高速である。内部的には yuv->rgb 変換をやっているイメージ。一般的にピクセル単位の処理を行う場合、BGRチャンネルは扱いやすいが、YUV420で同様の処理を実装してみる。ARとかOpenCVとかやるときには結局輝度(Y)だけ使うことが多くてYUVのほうが都合が良いこともある。となるとこういう処理もたまには必要だろう。

YUV420のバイト配列は [YYYYYYYY・・・YYYYUVUVUV・・・UVUVUV]とのこと。つまり適当にピクセルのアドレスをセットした場合、
Y = *(src+y*width+x);
U = *(src+(y>>1)*width+(x>>1<<1)+width*height);
V = *(src+(y>>1)*width+(x>>1<<1)+width*height+1);
となるはず。

・・・ではないらしい。

この手法でピクセルデータを取得した場合、sessionPresetがAVCaptureSessionPreset640x480の場合は正常に動作しているのだが、AVCaptureSessionPresetHigh,AVCaptureSessionPresetMediumのとき、UVチャンネルがどうしても8px(uv値上は4px)ほどずれている。何らかのpadding処理かと思ってもルールが不明だった。ちなみにlowの場合、CVPixelBufferGetのサイズは480×360で返却されるが、上記の処理を行う場合は480×368にすると画像が正常にと表示される。ますます混乱する。

いろいろ調べたところ、CVPixelBufferGetBaseAddressOfPlaneの第2引数が使えるようだ。CVPixelBufferGetPlaneCountの値が2を返すことから2枚のPlaneとして構成されていると推測。

つまり
uint8_t *y = (uint8_t*)CVPixelBufferGetBaseAddressOfPlane(imageBuffer,0);
uint8_t *uv = (uint8_t*)CVPixelBufferGetBaseAddressOfPlane(imageBuffer,1);
である。うまくいった。

以下、座標x,yからrgb 取得するファンクション。
上で取得したYのアドレスとUVのアドレスを使えばOK

inline void getPixelColorAtPoint(int x,int y,color *out,uint8_t *src,uint8_t *srcUV=0 ){
 int t,Y,Cr,Cb;
 if(420YpCbCr8BiPlanarVideoRange){
 t = (y>>1) * mWidth + (x>>1<<1);
 Y = (-16 + *(src+ y * mWidth + x))*1.16438356;
 Cb = (-128 + *(srcUV+t))*1.1383928;
 Cr = (-128 + *(srcUV+t + 1))*1.1383928;
 out->r=clamp(Y+Cr+(Cr>>2)+(Cr>>3)+(Cr>>5));
 out->g=clamp(Y-((Cb>>2)+(Cb>>4)+(Cb>>5))-((Cr>>1)+(Cr>>3)+(Cr>>4)+(Cr>>5)));
 out->b=clamp(Y+Cb+(Cb>>1)+(Cb>>2)+(Cb>>6));
 }else{
 t=(y*mWidth+x)*mInDepth;
 out->b=*(src+t);
 out->g=*(src+t+1);
 out->r=*(src+t+2);
 }
}

CPPです。ちなみにレンジは Y’が 16–235。Cb/Crが 16–240。この辺は http://en.wikipedia.org/wiki/YUV を参考に書いてみたけど、ちょっと自信ない。clampしないとオーバーフローするし。なんとなくpreviewと色は合ってるのでまぁいいか。

ちなみに、このやり方だとUVチャンネルの読み取りに4倍コストかかるし、ポインタの連読性もないので、画像全体を描画するにはロスが大きい。スポイトのような用途で特定の1pxのBGR取得したい場合につかえます。そもそも画面全体を変換するならGPUに委譲すべきだね。