移動する物体を除いた背景を作り、それをベースに物体に光学迷彩エフェクトをかける方法の紹介です。
<完成イメージ>
人物 | ボトル |
---|---|
物体のセグメンテーションには DeeplabV3 を使っています。
1. 光学迷彩画像の作り方
この記事では物体を透かして表示することで光学迷彩を表現しています(つまり物体の位置に、物体の裏にある背景色を出力)。その際、物体の真後ろの背景色ではなく、物体の法線方向にずらした位置の背景色を取得して出力することで、それっぽく物体が歪んで見せています。
ここで、物体が3Dモデルであれば法線は自明ですが、カメラ画像から物体の法線を推定するのは大変です。LiDAR搭載機種であれば深度情報から法線を作れますが、未搭載機種で難しそうです。
そこで、本記事ではCIFilterの『CIHeightFieldFromMask』と『CIShadedMaterial』を使って擬似的に物体の形を作る方法を紹介します。
【参考】3Dモデルで光学迷彩を実現する方法『ARKit+SceneKit+Metalで光学迷彩①』
【参考】LiDAR搭載機種で取得できる深度情報『ARKit+CoreML+LiDAR で 物体のコピー』
2. 背景画像の作り方
上記の光学迷彩を実現するには、物体が無い状態の画像(背景画像)が必要です。
画像からどうやって物体を取り除くかというと、動画の各フレームを保存しておき、各フレームの画素値の中央値(≠平均値)を背景色とすることで移動する物体を取り除きます。
次のWWDC2017のビデオがわかりやすいです。
【参考】WWDC2017 Advances in Core Image: Filters, Metal, Vision, and More
※ちなみに、このビデオで画素値の中央値を取得する前にVNHomographicImageRegistrationRequestでフレーム間の位置の差を整える方法が紹介されています。2枚の画像を一致させる射影変換の行列を生成してくれるのですが試したところピーキーな動作で、わずかな手振れ程度であれば移動・回転等が補正されるものの、画像に占める移動体のサイズが大きかったり、画像の特徴点(?)が少なかったりすると変換結果が不安定&ガタガタにズレまくるのでこの記事での採用は見送りました。
3. 処理の流れ
ソースコードはGithubに置いています。
- 背景画像を作る
- 物体を切り出す
- 切り出し結果をCGImageに変換
- モルフォロジー処理で穴や小さな物体を除去(マスク画像生成)
- 光学迷彩画像生成
3-1) 背景画像を作る
背景画像は過去の動画フレームの中央値から作成します。
下図は直近の5つのフレームから画素毎の中央値を選択して1枚の画像を生成している時のCIImageのPreviewです。
5枚中2枚にはボールペンが写っていますが、残りの3枚には写っていないため、画像の各画素値をソートして中央に位置する画素値を選択すると、ボールペンが消えることになります。
ここでフレーム5枚を貯める時間は15fpsだと0.33秒という短い時間です。物体を取り除くにはさらに短い時間しか写り込みは許されません(高速に移動する物体でないと除去できません)。そこで、本サンプルでは5枚分の中央値を5回ためて、その中央値を計算して、さらにそれを5回ためて、その中央値を計算して、それを背景とする、という方法で時間稼ぎをしています(サンプルプログラムのcreateMedianImage())。
中央値の取得にはCIColorKernel
による独自フィルターを利用しています。
override var outputImage : CIImage? {
get {
guard let inputList = inputList,
inputList.count == Const.imageListCount,
let kernel = Self.ciKernel else { return nil }
let roiCallback: CIKernelROICallback = { (index, destRect) in
return destRect
}
return kernel.apply(extent: inputList[0].extent,
roiCallback: roiCallback,
arguments: [
inputList[0],inputList[1],inputList[2],inputList[3],inputList[4]
])
}
}
inputList[0]
〜inputList[4]
が5枚分の画像です。
この画像を受け取って、中央値を計算するのはMSL側です。
inline void swap(thread float4 &a, thread float4 &b) {
float4 tmp = a; a = min(a,b); b = max(tmp, b);
}
extern "C" {
namespace coreimage {
float4 median(sample_t v0,sample_t v1,sample_t v2,sample_t v3,sample_t v4, destination dest)
{
swap(v0, v1);
swap(v1, v2);
swap(v2, v3);
swap(v3, v4);
swap(v0, v1);
swap(v1, v2);
swap(v2, v3);
swap(v0, v1);
swap(v1, v2);
return v2;
}
}
}
median()
が独自に実装したもので、引数のv0
からv4
がswift側から渡された**各画像の画素値(float4)**です。ここで何をやっているかというとバブルソートです。5つの値の内、3番目(中央値)がわかればいいので、3番目を決めるまでソートします。
※前述のWWDC2017のビデオではBose-Nelson sorting network
という聞いたことがないソートアルゴリズムを使っているので(不勉強なので)、わかりやすくバブルソートにしています。
3-2) 物体を切り出す
Vison+CoreML(DeepLabV3) を使って物体のセグメンテーション を行います。
やり方はこの記事『Vision+CoreMLを使って①をセグメンテーション』 と同じです。
3-3) 切り出し結果をCGImageに変換
セグメンテーション結果(ラベルの値)はInt32の配列(513x513)となるのですが、ここからCGImageを作る方法は単純な方法をとっています(GPUに渡してMTLTextureにするとか、もっと早い方法がありそうですが)。
func createMaskImage(segmentedMap: MLMultiArray) -> CIImage? {
let size = segmentedMap.shape[0].intValue * segmentedMap.shape[1].intValue
var pixels = [UInt8](repeating: 0, count: size)
for i in 0 ..< size {
pixels[i] = segmentedMap[i].intValue == Const.objectLabel ? 255 : 0
}
guard let segmentedImage = createCGImage(from: &pixels, width: Const.imageSize, height: Const.imageSize) else {
return nil
}
func createCGImage(from: inout [UInt8], width: Int, height: Int) -> CGImage? {
return from.withUnsafeMutableBufferPointer { pixelPointer in
// 画素値配列をvImage_Bufferの形にする
let sourceBuffer = vImage_Buffer(data: pixelPointer.baseAddress!,
height: vImagePixelCount(height),
width: vImagePixelCount(width),
rowBytes: width)
// 画像のピクセルフォーマットを定義
guard let format = vImage_CGImageFormat(bitsPerComponent: 8,
bitsPerPixel: 8,
colorSpace: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGBitmapInfo(rawValue: 0)) else {
return nil
}
// CGImageに変換
return try? sourceBuffer.createCGImage(format: format)
}
}
まず、光学迷彩対象としたいラベルを白(255)、それ以外を黒(0)とする画素値の配列を作ります。
次にAccelerateフレームワークを使ってCGImageに変換します。
画素値の配列からCGImageにする方法はこちらの記事『画素値の配列からCGImageを作る』で解説しています。
3−4) モルフォロジー処理で穴や小さな物体を除去(マスク画像生成)
DeeplabV3で認識した物体には穴やギザギザした部分があるため、これをモルフォロジー処理で除去します。
加工前 | クロージング後 | オープニング後 |
---|---|---|
モルフォロジー処理もCIFilterのCIMorphologyMinimum、CIMorphologyMaximumを使っています。
使い方についてはこちらの記事『iPhone のGPUで物体の重心をリアルタイムに計算』で解説しています。
ここで得られた白黒画像をこの後の処理でマスク画像として利用します。
3−5) 光学迷彩画像作成
光学迷彩画像の作成過程は次の通りです。
①ハイトマップ生成 | ②透かした画像生成 | ③物体切り出し | ④背景と合成 |
---|---|---|---|
①ハイトマップ作成
CIFIlterのCIHeightFieldFromMaskを使います。
マスク画像をインプットとし、エッジに近いほど暗く(低く)、エッジから離れるほと明るく(高く)なる画像が得られます。この画像が物体の奥行き方向の形状となります(擬似的に作った形状で、LiDARを使えば正確に得られたであろう情報)。
// マスク画像からハイトマップ生成
heightFieldFilter.setValue(mask, forKey: kCIInputImageKey)
heightFieldFilter.setValue(NSNumber(value: Const.heightFieldRadius), forKey: kCIInputRadiusKey)
guard let heightFieldImage = heightFieldFilter.outputImage else { return nil }
②透かした画像生成
背景画像と①のハイトマップを入力として、背景を歪めた画像を作ります。
これもCIFilterでCIShadedMaterialを使います。
// 光学迷彩化(背景とハイトマップをミックス)
shaderMaterialFilter.setValue(heightFieldImage, forKey: kCIInputImageKey)
shaderMaterialFilter.setValue(CIImage(cgImage: median), forKey: kCIInputShadingImageKey)
shaderMaterialFilter.setValue(NSNumber(value: Const.shaderMaterialScale), forKey: kCIInputScaleKey)
guard let shaderOutput = shaderMaterialFilter.outputImage else { return nil }
ハイトマップから法線が計算され、その法線の方向から背景画像の色が選択され、画像を出力してくれる、という便利なフィルタです。
③物体の切り出し
②の結果ではマスク画像の黒い部分に対応する画素に色ついてしまうため、②の結果にマスク画像をあてて物体部分だけ切り出します。
これもCIFilterでCIMultiplyCompositingを使います。
// 光学迷彩画像のマスク部分を抽出
multiplyCompositingFilter.setValue(shaderOutput, forKey: kCIInputImageKey)
multiplyCompositingFilter.setValue(mask, forKey: kCIInputBackgroundImageKey)
guard let opticalObject = multiplyCompositingFilter.outputImage else { return nil }
②の結果とマスク画像を乗算することで、マスク画像の白い部分だけ残した画像を生成できます。
④背景と合成
ここでやりたいことは、③の切り出し部分は③の画素値、それ以外は背景画像の画素値を採用した画像を作ることです。標準のCIFilterを見たところ一度にこの処理ができるようなFilterが見当たらなかったので、これもCIColorKernelによる独自フィルターで解決しています。
(CompositeFilter.swift/shader.metal)
4. 最後に
物体の切り出しにDeeplabV3を使いましたが、遅いし綺麗なセグメンテーションとは言えないかもしれません。
人物に限定するのであればARKitのARMatteGeneratorの方が高速で綺麗にセグメンテーションしてくれそうです。
【参考】ARMatteGeneratorを使った例『ARKit+Metal で みんな超サイヤ人』