🎞️

動画のフレーム間の差分を判定する

2023/12/14に公開

以前、クソアプリアドベントカレンダー に参加した際に動画からスライドを自動生成するiOS/macOSアプリをつくった。

https://qiita.com/shu223/items/9bf0f24058665ec2fc6e

数時間で自分用に雑につくったものなので非常に荒削りなのだが、毎年WWDCの時期には毎年必ず使っている。

で、課題として残っていたのが、

類似画像の除去

の部分だった。

こちらが全自動で生成したスライドのサンプルだが、

https://speakerdeck.com/shu223/slidify-sample

たとえば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があるが、

https://zenn.dev/shu223/articles/vision_image-feature-print

これはまったく違う画像の類似性を計算するものなので、今回の課題とは用途がまったく違う。

Core Imageを利用して次のような方法を取ることにした。

  1. CIFilterの CIDifferenceBlendMode で差分画像を得る
  2. CIAreaAverage で画像の平均値を求める(=差分の大きさ)
  3. 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)

(参考: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage

別の実装

ちなみに差分を計算する他の実装として、gistにこういうのもあった:

https://gist.github.com/SheffieldKevin/566dc048dd6f36716bcd#file-compareimages-swift

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]の記事です。
https://qiita.com/advent-calendar/2023/ios

脚注
  1. 12/14時点で空いていたので書きました。 ↩︎

Discussion