🎞️

YUV形式のCVPixelBufferの特定の領域をvImageで加工する

2023/03/12に公開

vImage は Accelerate フレームワークに定義されている、効率的に画像データを加工するための仕組みです。

Apple のドキュメントに Applying vImage Operations to Regions of Interest があり、vImage を介して画像データの指定した領域だけを編集するための処理が説明されていますが、活用しようとするとサンプルが少なく記述に困ります。

この記事では、iOS の Broadcast Upload Extension で端末から届く YUV 形式の CMSampleBuffer を加工することを前提として、輝度平面と色差平面の指定した領域を加工することについて説明をします。

CMSampleBufferからCVPixelBufferを取り出して中身を確認する

手元で簡単に CVPixelBuffer を画像として確認する際は、CMSampleBuffer → CVPixelBuffer → CIImage と変換するのが簡単です。

guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
  return
}
let image = CIImage(cvImageBuffer: pixelBuffer)
print(image)

print の行にブレークポイントを設定して処理を止め、image 変数にカーソルを合わせて目のマークをクリックすると、Xcode 上で画像をプレビューできます。

Xcode上でCVPixelBufferをプレビューする
Xcode上でCVPixelBufferをプレビューする

この状態で右上の「Open With Preview」をクリックすると標準のプレビューアプリでこの画像を開くことができます。これは複数の画像が組み合わさって構成された pdf の画像になっています。xpdfimagemagick などで pdf から画像を取り出すと扱いやすく、おすすめです。

$ pdfimages output.pdf images // pdf から ppm 形式の画像を取り出す
$ convert images-0000.ppm result.png // ppm を png に変換する

加工前のCVPixelBufferの中身
加工前のCVPixelBufferの中身

加工する

Broadcast Upload Extension を介して取得できる CVPixelBuffer は輝度平面と色差平面から構成されています。フォーマットは YUV420 となっていて、輝度平面は画像の縦横幅と同じ大きさ、色差平面は縦横の両方で輝度平面の半分の解像度で情報を保持しています。加工をする領域を指定する際には、データがどのように格納されているかを意識する必要があります。

ここでは、画像内の以下の領域を加工することを想定としたコードを考えてみます。

let rect = CGRect(x: 200, y: 500, width: 300, height: 300)

輝度平面を加工する

輝度平面は画像の縦横幅と同じ大きさなので、加工したい領域をそのままの大きさで考えて vImage を生成します。指定した領域にボックスブラーを適用してみます。

呼び出し側

let rect = CGRect(x: 200, y: 500, width: 300, height: 300)
processYPlane(of: pixelBuffer, in: rect)

加工処理

private func processYPlane(of pixelBuffer: CVPixelBuffer, in rect: CGRect) {
  CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
  // 輝度平面はひとつめの平面(インデックス 0)
  guard let address = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else {
    return
  }
  let bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
  // CVPixelBuffer が保持する輝度平面内で、加工の開始点であるオフセットを計算する
  let offset = bytesPerRow * Int(rect.minY) + Int(rect.minX)
  // CVPixelBuffer から vImage を生成する
  // vImage が指す領域は CVPixelBuffer が保持する輝度平面の一部分になっている
  var vImageBuffer = vImage_Buffer(
    data: address.advanced(by: offset),
    height: vImagePixelCount(rect.height),
    width: vImagePixelCount(rect.width),
    rowBytes: bytesPerRow
  )
  // カーネルサイズ31のボックスブラーを適用する
  let result = vImageBoxConvolve_Planar8(&vImageBuffer, &vImageBuffer, nil, 0, 0, 31, 31, 255, vImage_Flags(kvImageTruncateKernel))
  print("Result code for vImageBoxConvolve_Planar8: ", result)
}

結果

指定した領域内にブラーがかかっています。(領域中央下部に小さく白い矩形が出ていますが、原因は分かりません。)

輝度平面を加工した結果
輝度平面を加工した結果

色差平面を加工する

次に輝度平面の加工に加えて、色差平面も加工します。

色差平面は輝度平面と情報の持ち方が違うので、データが格納されているようすを想像しながら、加工の開始点や領域の大きさを指定する必要があります。例えば、4x4 の画像とすると、YUV420 形式のデータは以下のように並んでいます。

輝度平面                 色差平面
[ y1][ y2][ y3][ y4]   [cb1][cr1][cb2][cr2]
[ y5][ y6][ y7][ y8]
[ y9][y10][y11][y12]   [cb3][cr3][cb4][cr4]
[y13][y14][y15][y16]

vImage の生成時、データ列上で加工領域の開始点を表すオフセットの計算では、縦方向は輝度平面の半分、横方向は同じ値、領域の大きさを指定する際には横方向は Cb と Cr はペアで扱われるので、縦横いずれも輝度平面の半分で計算をします。

呼び出し側

let rect = CGRect(x: 200, y: 500, width: 300, height: 300)
processYPlane(of: pixelBuffer, in: rect)
processCbCrPlane(of: pixelBuffer, in: rect)

加工処理

// 引数で渡される CGRect(rectInYplane)は輝度平面上の矩形を表している
private func processCbCrPlane(of pixelBuffer: CVPixelBuffer, in rectInYplane: CGRect) {
  CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
  // 色差平面はひとつめの平面(インデックス 1)
  guard let address = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) else {
    return
  }
  let bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
  // 開始点を表すオフセットの計算では、縦方向は輝度平面の半分、横方向は同じ値
  let offset = bytesPerRow * Int(rectInYplane.minY / 2) + Int(rectInYplane.minX)
  // 領域の大きさを指定する際には、縦横いずれも輝度平面の半分
  var vImageBuffer = vImage_Buffer(
    data: address.advanced(by: offset),
    height: vImagePixelCount(rectInYplane.height / 2),
    width: vImagePixelCount(rectInYplane.width / 2),
    rowBytes: bytesPerRow
  )
  // Cb → 128, Cr → 255 と指定する
  var color: [UInt8] = [128, 255]
  // 上で指定した色差の値で指定領域を埋める
  let result = vImageBufferFill_CbCr8(&vImageBuffer, &color, vImage_Flags(kvImageNoFlags))
  print("Result code for vImageBufferFill_CbCr8: ", result)
}

結果

ブラーに加え、色差平面も加工をすることで指定領域の色が変えることができました。

色差平面にも加工を加えた状態
色差平面にも加工を加えた状態

まとめ

vImage を介して、YUV420 形式の CVPixelBuffer が保持する画像情報の指定した領域を加工する方法をまとめました。Accelerate フレームワークはリアルタイム処理にも利用できるパワフルな処理を提供してくれるので、夢が拡がりますね。

ネットで検索をしてもコード例が少なく応用に手こずる領域ですが、なにかの参考になりましたら幸いです。

Discussion