every Tech Blog

株式会社エブリーのTech Blogです。

iOSアプリでの画像処理におけるCPUとGPUのパフォーマンス比較

はじめに

デリッシュキッチンのiOSアプリを開発している成田です。 アプリで画像処理を行う場合、そのパフォーマンスは非常に重要です。
画像処理を行うケースとして、例えばユーザーが送信した画像を使って何かしたいといった時に、「予めローカルで画像の平均輝度を算出し、解析できないほど暗すぎる画像は弾く」といったユースケースがあります。
そして、平均輝度を計算する時に単純にCPUで処理するか、GPUで処理するのかで大きくパフォーマンスが異なります。

本記事では、以下の3つのアプローチで実際にiOSアプリで画像処理のパフォーマンス比較をしてみたいと思います。

  1. CPUベースの逐次処理
  2. Core Imageフレームークを利用したGPU処理
  3. Metalを直接利用したGPU処理(おまけ)

それぞれのアプローチを紹介した後、最後にまとめてデモアプリを使ってサンプル画像の処理時間を比較してみようと思います。

今回算出する輝度について

輝度とは本来、光の強さなど明るさを表す物理量を意味しますが、画像で輝度を求める場合は、sRGB色空間における相対輝度を用います。 sRGB色空間における相対輝度とは、最も暗い黒を0、最も明るい白を1として正規化した、色空間内における相対的な明るさのことです。 ここで扱う RGB 値は 8bit(0〜255) で表現されるピクセル値です。
これを0〜1に正規化して、次の重み付き和で計算します。

 \displaystyle
Y = 0.2126 × R + 0.7152 × G + 0.0722 × B

この式を画像全体に適用して平均を取ったものが「平均輝度」となります。

1.CPUでの処理

最もシンプルなのは、for文でループを回してピクセルごとに輝度を計算する方法です。 しかし、iPhoneのCPUはシングルスレッド的に処理していくため、数百万画素以上などの画像を扱ったりすると一気に処理が重くなっていきます。

func calculateAverageLuminanceCPU(from uiImage: UIImage) -> (result: Float, time: TimeInterval) {
    // 処理開始時間を記録
    let startTime = CFAbsoluteTimeGetCurrent()
    
    // UIImageからCGImageを取得
    guard let cgImage = uiImage.cgImage else { 
        return (0, 0) 
    }
    
    // 画像の幅・高さを取得
    let width = cgImage.width
    let height = cgImage.height
    
    // ピクセルあたりのバイト数(RGBAなので4)
    let bytesPerPixel = 4
    // 1行あたりのバイト数
    let bytesPerRow = bytesPerPixel * width
    // 1色成分あたりのビット数(8bit)
    let bitsPerComponent = 8
    
    // ピクセルデータを格納する配列を用意(RGBA順)
    var pixelData = [UInt8](repeating: 0, count: width * height * bytesPerPixel)
    
    // RGB色空間を作成
    let colorSpace = CGColorSpaceCreateDeviceRGB()
    
    // CGContext を作成してピクセルデータ配列に描画
    let context = CGContext(data: &pixelData,
                           width: width,
                           height: height,
                           bitsPerComponent: bitsPerComponent,
                           bytesPerRow: bytesPerRow,
                           space: colorSpace,
                           bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
    
    // CGImage を CGContext に描画
    context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
    
    // 輝度の合計値を格納する変数
    var totalLuminance: Float = 0.0
    let pixelCount = width * height
    
    // ループで画像の全てのピクセルを処理
    for i in 0..<pixelCount {
        let pixelIndex = i * bytesPerPixel
        // RGB値を0〜1に正規化し、sRGB → 線形輝度に変換
        let r = sRGBToLinear(Float(pixelData[pixelIndex]) / 255.0)
        let g = sRGBToLinear(Float(pixelData[pixelIndex + 1]) / 255.0)
        let b = sRGBToLinear(Float(pixelData[pixelIndex + 2]) / 255.0)

        // 輝度を計算
        let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
        totalLuminance += luminance
    }
    
    // 平均輝度を計算
    let averageLuminance = totalLuminance / Float(pixelCount)
    
    // 処理終了時間を取得して経過時間を計算
    let endTime = CFAbsoluteTimeGetCurrent()
    
    // 平均輝度と処理時間を返す
    return (averageLuminance, endTime - startTime)
}

2. Core ImageでのGPU処理

Core ImageはAppleが提供する画像処理フレームワークです。 多数の組み込みフィルタを使用して画像や動画を処理でき、またフィルタを連携させることで複雑なエフェクトも構築できます。画像のぼかしやシャープ化、色味や彩度の調整用など多くのフィルタが用意されています。 平均輝度を求める場合も CIAreaAverage フィルタを使えばワンライナーで計算可能です。 このフィルターを使うと全ピクセルの平均色を計算して1×1ピクセルの画像として返してくれます。 内部ではGPUの巨大なスレッド群で全ピクセルを複数のグループで並列に処理して平均色を算出していると考えられます。

func calculateAverageLuminanceCoreImage(from uiImage: UIImage) -> (result: Float, time: TimeInterval) {
    // 処理開始時間を記録
    let startTime = CFAbsoluteTimeGetCurrent()
    
    // UIImage から CGImage を取得
    guard let cgImage = uiImage.cgImage else {
        return (0, 0)
    }
    
    // CGImage から CIImage を作成(Core Image フィルタで処理するため)
    let ciImage = CIImage(cgImage: cgImage)
    
    // CIAreaAverage フィルターを使用して平均色を取得
    // CIAreaAverage は指定領域の平均色を計算して 1x1 ピクセル画像を返すフィルタ
    guard let areaAverageFilter = CIFilter(name: "CIAreaAverage") else {
        return (0, 0)
    }
    
    // フィルターに入力画像をセット
    areaAverageFilter.setValue(ciImage, forKey: kCIInputImageKey)
    // フィルターを適用する範囲を画像全体に設定
    areaAverageFilter.setValue(CIVector(cgRect: ciImage.extent), forKey: kCIInputExtentKey)
    
    // フィルターの出力画像を取得
    guard let outputImage = areaAverageFilter.outputImage else {
        return (0, 0)
    }
    
    // 出力画像は 1x1 ピクセルなので、ここから RGBA データを取得
    var pixelData = [UInt8](repeating: 0, count: 4)
    ciContext.render(outputImage,
                     toBitmap: &pixelData,
                     rowBytes: 4,
                     bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
                     format: .RGBA8,
                     colorSpace: CGColorSpaceCreateDeviceRGB())
    
    // ピクセル値は UInt8 (0〜255) なので 0〜1 に正規化し、
    // sRGB → 線形輝度に変換(ガンマ補正の逆変換)
    let r = sRGBToLinear(Float(pixelData[0]) / 255.0)
    let g = sRGBToLinear(Float(pixelData[1]) / 255.0)
    let b = sRGBToLinear(Float(pixelData[2]) / 255.0)

    // 相対輝度を計算
    let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
    
    // 処理終了時間を記録
    let endTime = CFAbsoluteTimeGetCurrent()
    
    // 平均輝度と処理時間を返す
    return (luminance, endTime - startTime)
}

3. MetalでのGPU処理

MetalとはAppleが提供する低レベルのAPIで、GPUにアクセスして独自のグラフィック処理などを行うことができます。 Metalを直接利用すると、スレッド数や並列化単位を自分で設計したりできます。
これにより、GPUの並列性能を大きく引き出すことが可能です。 独自のカスタムがしやすい分、実装量はかなり大きいです。

まず、GPUで平均輝度を計算するための関数を定義する必要があります。 ファイルの拡張子はmetalです。 Swiftではなく、Metal Shading LanguageというMetal用の言語で書きます。 ここでGPU上で動くプログラムを定義します。

#include <metal_stdlib>
using namespace metal;

// 平均輝度を計算する関数
kernel void calculateAverageLuminanceLinear(texture2d<float, access::read> inputTexture [[texture(0)]],
                                           device atomic<float>* resultBuffer [[buffer(0)]],
                                           uint2 gid [[thread_position_in_grid]]) {
    
    uint width = inputTexture.get_width();
    uint height = inputTexture.get_height();
    
    // テクスチャの境界チェック
    if (gid.x >= width || gid.y >= height) {
        return;
    }
    
    // ピクセルの色を読み取り
    float4 color = inputTexture.read(gid);
    
    // sRGB -> Linear変換
    float r = sRGBToLinear(color.r);
    float g = sRGBToLinear(color.g);
    float b = sRGBToLinear(color.b);

    float luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
    
    // 累積輝度を計算
    atomic_fetch_add_explicit(resultBuffer, luminance, memory_order_relaxed);
}

次に上記で定義したものを使ってMetalの初期化します。

init() {
    setupMetal()
}

private func setupMetal() {
    // デフォルトのMetalデバイス(GPU)を取得
    metalDevice = MTLCreateSystemDefaultDevice()
    
    // GPUに送るコマンドキューを作成
    metalCommandQueue = metalDevice?.makeCommandQueue()

    guard let device = metalDevice else {
        print("Metal device not available")
        return
    }

    do {
        // プロジェクト内のMetalシェーダライブラリを取得
        let library = device.makeDefaultLibrary()
        
        // ライブラリから計算用関数を取得
        let function = library?.makeFunction(name: "calculateAverageLuminanceLinear")
        
        // 取得した関数をGPUで実行可能なパイプラインに変換
        metalComputePipelineState = try device.makeComputePipelineState(function: function!)
    } catch {
        print("Failed to create Metal pipeline state: \(error)")
    }
}


func calculateAverageLuminanceMetal(from uiImage: UIImage) -> (result: Float, time: TimeInterval) {
    // 処理開始時間を記録
    let startTime = CFAbsoluteTimeGetCurrent()
    
    guard let device = metalDevice,
          let commandQueue = metalCommandQueue,
          let pipelineState = metalComputePipelineState,
          let cgImage = uiImage.cgImage else {
        return (0, 0)
    }
    
    let width = cgImage.width
    let height = cgImage.height
    let bytesPerPixel = 4
    
    // テクスチャ作成
    // Metal上で画像データを扱うためのテクスチャを作成
    let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
                                                                    width: width,
                                                                    height: height,
                                                                    mipmapped: false)
    textureDescriptor.usage = [.shaderRead]
    
    guard let inputTexture = device.makeTexture(descriptor: textureDescriptor) else {
        return (0, 0)
    }
    
    // CGImageからテクスチャにデータコピー
    // CGContextを使ってCGImageのピクセルデータを配列に読み込み
    let colorSpace = CGColorSpaceCreateDeviceRGB()
    var pixelData = [UInt8](repeating: 0, count: width * height * bytesPerPixel)
    let context = CGContext(data: &pixelData,
                            width: width,
                            height: height,
                            bitsPerComponent: 8,
                            bytesPerRow: bytesPerPixel * width,
                            space: colorSpace,
                            bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
    
    context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
    
    // ピクセルデータをテクスチャにコピー
    inputTexture.replace(region: MTLRegionMake2D(0, 0, width, height),
                         mipmapLevel: 0,
                         withBytes: &pixelData,
                         bytesPerRow: bytesPerPixel * width)
    
    // 結果用バッファ作成
    // 平均輝度を格納するためのFloatバッファ
    guard let resultBuffer = device.makeBuffer(length: MemoryLayout<Float>.size, options: []) else {
        return (0, 0)
    }
    
    // バッファを0で初期化
    let bufferPointer = resultBuffer.contents().bindMemory(to: Float.self, capacity: 1)
    bufferPointer[0] = 0.0
    
    // コマンドバッファとコンピュートエンコーダ作成
    guard let commandBuffer = commandQueue.makeCommandBuffer(),
          let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
        return (0, 0)
    }
    
    // パイプラインステート、テクスチャ、結果バッファをセット
    computeEncoder.setComputePipelineState(pipelineState)
    computeEncoder.setTexture(inputTexture, index: 0)
    computeEncoder.setBuffer(resultBuffer, offset: 0, index: 0)
    
    // スレッドグループの設定
    // 16x16のスレッドブロックで分割して計算
    let threadgroupSize = MTLSize(width: 16, height: 16, depth: 1)
    let threadgroupCount = MTLSize(width: (width + threadgroupSize.width - 1) / threadgroupSize.width,
                                   height: (height + threadgroupSize.height - 1) / threadgroupSize.height,
                                   depth: 1)
    
    // 「このスレッドグループで計算してね」という命令をコマンドバッファに記録
    computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize)
    computeEncoder.endEncoding()
    
    // GPUでの処理実行 
    commandBuffer.commit()
    commandBuffer.waitUntilCompleted()
    
    // 結果取得
    let resultPointer = resultBuffer.contents().bindMemory(to: Float.self, capacity: 1)
    // 累積輝度をピクセル数で割って平均輝度を算出
    let result = resultPointer[0] / Float(width * height)
    
    // 処理終了時間を記録
    let endTime = CFAbsoluteTimeGetCurrent()
    
    // 平均輝度と処理時間を返す
    return (result, endTime - startTime)
}

実際に処理速度を比較してみる

上記3つの処理速度を実際に計算するためにデモアプリを作ってみました。 画像を選択して実行ボタンを押すと3通り順次平均輝度を算出して全てが完了したら結果が出力されます。

実行結果はこちらになりました。

今回使用した画像は約1200万画素ですが、CPUとGPUを使った場合で処理速度に明確な差を確認することができました! また、Core Imageを使った場合とMetalを直接使った場合では大きな差はみられませんでした。 今回はシンプルな平均輝度計算だったのでCore ImageとMetalではそこまで差がつかないのかもしれません。

まとめ

  • CPU処理: 実装は簡単だがシングルスレッド的処理になるので非常に遅い
  • Core Image: 抽象度が高く、簡単かつ高速。標準的な処理ならベスト
  • Metal: 最も柔軟で速いが、学習コスト・実装コストは高い

iPhone端末のスペックも年々向上しているため、普段のアプリ開発ではGPUをあまり意識しなくても問題ないケースが多いと思います。 しかし、グラフィック処理といった分野では、一度に扱うデータ量が膨大になり、処理が重くなりやすいのも事実です。 だからこそ、CPUとGPUの役割や得意分野を理解し、必要に応じてうまく使い分けることが、快適なアプリ体験を実現する上で大切になります。