動画のフレーム間の差分を判定する
以前、クソアプリアドベントカレンダー に参加した際に動画からスライドを自動生成するiOS/macOSアプリをつくった。
数時間で自分用に雑につくったものなので非常に荒削りなのだが、毎年WWDCの時期には毎年必ず使っている。
で、課題として残っていたのが、
類似画像の除去
の部分だった。
こちらが全自動で生成したスライドのサンプルだが、
たとえばp.7〜p.9は同じページが連続してしまっている。
こういうのを自動で除去したい、というのが上に挙げた「類似画像の除去」の課題。
同じ画像であればスキップする
というのが当然思いつく解決法で、2つの画像が同じかどうかは以下で判定できる。
func ==(lhs: UIImage, rhs: UIImage) -> Bool {
lhs === rhs || lhs.pngData() == rhs.pngData()
}
ピクセルデータが全く同じであれば、これが true
になる。
これだけでいくつかの「同じページの連続」は除去できた。
しかし、実際はかなり多くのケースで、この方法はうまくいかない。
たとえば以下のようなケース:
画像A
画像B
まったく同じ画像を2枚貼っているようだが、ピクセルデータ的には差分がある。
実際に差分をとった画像を出力してみると、うっすらと差分がある:
ほぼ真っ黒に見えるかもしれないので、見やすくなるように色のレベルを調整してやると、
macOSの標準アプリ「プレビュー」利用
こんな感じで差分があることが確認できる:
これはスライドや動画の制作者がこのフレーム間に意図的に差分を設けたわけでなく、おそらく動画エンコーディングによって発生する差分であると思われる。
これをどう除去するか。
2つの画像の類似度を判定する / 差分を計算する
連続するフレームの2つの画像の類似度を判定する(ために、差分を計算する)機能を、iOS/macOSでシンプルに実装するにはどういう方法がいいだろうか。
Visionフレームワークにこういう類似度を計算するAPIがあるが、
これはまったく違う画像の類似性を計算するものなので、今回の課題とは用途がまったく違う。
Core Imageを利用して次のような方法を取ることにした。
- CIFilterの CIDifferenceBlendMode で差分画像を得る
- CIAreaAverage で画像の平均値を求める(=差分の大きさ)
- 2に対して閾値処理をかける(全体的に真っ暗な画像は平均値も0に近くなる。0に近いほど差分がほとんどないといえる。)
コードはこんな感じ:
func compareImages(_ image1: CIImage, _ image2: CIImage) -> Float? {
// 差分画像の計算
let diffFilter = CIFilter(name: "CIDifferenceBlendMode")!
diffFilter.setDefaults()
diffFilter.setValue(image1, forKey: kCIInputImageKey)
diffFilter.setValue(image2, forKey: kCIInputBackgroundImageKey)
guard let diffImage = diffFilter.value(forKey: kCIOutputImageKey) as? CIImage else {
return nil
}
// 差分画像の平均値を計算
let avgFilter = CIFilter(name: "CIAreaAverage")!
avgFilter.setValue(diffImage, forKey: kCIInputImageKey)
avgFilter.setValue(CIVector(cgRect: diffImage.extent), forKey: kCIInputExtentKey)
guard let avgImage = avgFilter.outputImage else {
return nil
}
// 差分の大きさを 0〜1 で出力
let average = context.readFloat32PixelValue(from: avgImage, at: CGPoint.zero)
return (average.r + average.g + average.b) / 3
}
なお、CIAreaAverage
で得られる平均値は 1x1 ピクセルの CIImage
オブジェクトとして得られるので、ピクセル値の読み取りには CoreImageExtensions というヘルパーライブラリを用いている。
使わない場合は、ピクセル値の読み取りはこういう実装になる:
var bitmap = [UInt8](repeating: 0, count: 4)
let context = CIContext(options: [.workingColorSpace: kCFNull])
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
別の実装
ちなみに差分を計算する他の実装として、gistにこういうのもあった:
doImagesHaveSameMeta
関数では、メタデータを比較している:
/**
@brief Returns true if images have same meta. Width, Height, bit depth.
@discussion Assumes images are non null.
*/
func doImagesHaveSameMeta(#image1:CGImage, #image2:CGImage) -> Bool {
if CGImageGetWidth(image1) != CGImageGetWidth(image2) {
return false
}
if CGImageGetHeight(image1) != CGImageGetHeight(image2) {
return false
}
if CGImageGetBitsPerComponent(image1) != CGImageGetBitsPerComponent(image2) {
return false
}
if CGImageGetBytesPerRow(image1) != CGImageGetBytesPerRow(image2) {
return false
}
if CGImageGetBitsPerPixel(image1) != CGImageGetBitsPerPixel(image2) {
return false
}
return true
}
これで画像のバイトサイズの違い等が判定できる(ピクセルデータという大きなデータにアクセスせずに済む)が、今回のケースで言えば、単純にバイトサイズが違うからといって「違う画像だ」と判定したいわけではないので用途が合わない。
また compareImages
関数において CIDifferenceBlendMode
を利用している点は同じだが、その後に CIAreaMaximum
を利用している点が僕の手法と違う:
/**
@brief Returns the maximum difference of pixel values in the image.
@discussion Assumes doImagesHaveSameMeta has already returned true on
the images passed into this function. OSX only as iOS doesn't have the
CIAreaMaximum filter.
*/
func compareImages(#image1:CGImage, #image2:CGImage) -> Int {
var diff = 0
// First create the CIImage representations of the CGImage.
let ciImage1 = CIImage(CGImage: image1)
let ciImage2 = CIImage(CGImage: image2)
// Create the difference blend mode filter and set its properties.
let diffFilter = CIFilter(name: "CIDifferenceBlendMode")
diffFilter.setDefaults()
diffFilter.setValue(ciImage1, forKey: kCIInputImageKey)
diffFilter.setValue(ciImage2, forKey: kCIInputBackgroundImageKey)
// Create the area max filter and set its properties.
let areaMaxFilter = CIFilter(name: "CIAreaMaximum")
areaMaxFilter.setDefaults()
areaMaxFilter.setValue(diffFilter.valueForKey(kCIOutputImageKey),
forKey: kCIInputImageKey)
let compareRect = CGRectMake(0.0, 0.0, CGFloat(CGImageGetWidth(image1)),
CGFloat(CGImageGetHeight(image1)))
let extents = CIVector(CGRect: compareRect)
areaMaxFilter.setValue(extents, forKey: kCIInputExtentKey)
// The filters have been setup, now set up the CGContext bitmap context the
// output is drawn to. Setup the context with our supplied buffer.
let alphaInfo = CGImageAlphaInfo.PremultipliedLast
let bitmapInfo = CGBitmapInfo(rawValue: alphaInfo.rawValue)
let colorSpace = CGColorSpaceCreateDeviceRGB()
var buf: [CUnsignedChar] = Array<CUnsignedChar>(count: 16, repeatedValue: 255)
let context = CGBitmapContextCreate(&buf, 1, 1, 8, 16, colorSpace, bitmapInfo)
// Now create the core image context CIContext from the bitmap context.
let ciContextOpts = [
kCIContextWorkingColorSpace : colorSpace,
kCIContextUseSoftwareRenderer : false
]
let ciContext = CIContext(CGContext: context, options: ciContextOpts)
// Get the output CIImage and draw that to the Core Image context.
let valueImage = areaMaxFilter.valueForKey(kCIOutputImageKey)! as CIImage
ciContext.drawImage(valueImage, inRect: CGRectMake(0,0,1,1),
fromRect: valueImage.extent())
// This will have modified the contents of the buffer used for the CGContext.
// Find the maximum value of the different color components. Remember that
// the CGContext was created with a Premultiplied last meaning that alpha
// is the fourth component with red, green and blue in the first three.
let maxVal = max(buf[0], max(buf[1], buf[2]))
diff = Int(maxVal)
return diff
}
一番差分の大きいピクセルの差分値を出力する方式。
これも今回のケースで判定したい差分とはちょっと違うが、なるほどこういう方法もあるなと思ったのでここに載せておいた。
iOSアドベントカレンダー
本記事はiOS Advent Calendar 2023 シリーズ2の10日目 [1]の記事です。
-
12/14時点で空いていたので書きました。 ↩︎
Discussion