ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Core Imageをつかって画像処理はじめました

こんにちはiOSエンジニアのしだです。
写真の画像補正をiOS上でOpenCVを使って実装したらメモリの消費やCPU使用率が激しくつらい気持ちになりました。 CIFilterいいよって教えてもらって、Core Image をあまり真面目に使ったことがなかったのですが使い始めてみました。

はじめは ビルトインの CIFilter と CI Kernel Language を見ていたのですが、 CIKernel(source string: String) のAPIが非推奨になっていたため調べてみたら Metal Shading Language が使えるようになっていました。 WWDC2018によると今後はCI Kernel Language は非推奨になってくようです。

CI Kernel Language は実行時にコンパイルされるのに対して、Metal Shading のほうはアプリのビルド時にコンパイルされるのでシンタックスエラーもすぐわかるので便利です。 CIKernel 向けの Metal Shading Language を使ってみたので、いくつかサンプルを上げておきます。

準備

  • Xocde 10.1

プロジェクトファイルを作成したら、Metal ファイル作成しておきます。そして、Metal Shading Language for Core Image Kernels を参考に以下のような設定します。

  • Build Settings -> Metal Compiler - Build Options -> Other Metal Compiler Flags-fcikernel を追加しておきます。
  • User-DefinedMTLLINKER_FLAGS を作成して -cikernel を設定しておきます 。

アプリをビルドすると default.metallib というファイルがアプリ内に生成されるようになります。 CIKernel からはこの default.metallib を読み込んでメソッド名を指定すると呼び出せます。

グレースケール

metal

#include <metal_stdlib>
using namespace metal;

#include <CoreImage/CoreImage.h>
extern "C" {
    namespace coreimage {
        half4 grayscale(sample_h s) {
            half y = 0.2126 * s.r + 0.7152 * s.g + 0.0722 * s.b;
            return half4(y, y, y, s.a);
        }
    }
}

swift

extension CIImage {

    func grayscale(metalLib: Data) -> CIImage? {
        do {
            let kernel = try CIColorKernel(functionName: "grayscale", fromMetalLibraryData: metalLib)
            let grayImage = kernel.apply(extent: extent, roiCallback: { i, r in r }, arguments: [self])
            return grayImage
        } catch {
            return self
        }
    }
}

guard let url = Bundle.main.url(forResource: "default", withExtension: "metallib") else {
    fatalError("Not found default.metallib.")
}
guard let data = try? Data(contentsOf: url) else {
    fatalError("The default.metallib can not read as Data.")
}
let image = UIImage(named: "lena.png")!
let ciImage = CIImage(image: image)
let grayImage = ciImage?.grayscale(metalLib: data)
オリジナル画像 変換後

ラプラシアンフィルタ

WWDC2018 の redConvolve を少し書き換えただけです。

metal

#include <metal_stdlib>
using namespace metal;

#include <CoreImage/CoreImage.h>
extern "C" {
    namespace coreimage {
        void laplacian(sampler_h s, group::destination_h dest) {
            float2 dc = dest.coord();
            half4 g1 = s.gatherX(s.transform(dc + float2(-0.5,-0.5)));
            half4 g2 = s.gatherX(s.transform(dc + float2( 1.5,-0.5)));
            half4 g3 = s.gatherX(s.transform(dc + float2(-0.5, 1.5)));
            half4 g4 = s.gatherX(s.transform(dc + float2( 1.5, 1.5)));

            half r1 = (g1.y -4 * g1.z + g1.w + g2.w + g3.y);
            half r2 = (g2.y -4 * g2.z + g2.w + g1.z + g4.x);
            half r3 = (g3.x -4 * g3.y + g3.w + g1.z + g4.x);
            half r4 = (g4.x -4 * g4.y + g4.w + g2.w + g3.y);

            dest.write(half4(r1, r1, r1, 1), half4(r2, r2, r2, 1), half4(r3, r3, r3, 1), half4(r4, r4, r4, 1));
        }
    }
}

swift

extension CIImage {
    func laplacian(metalLib: Data) -> CIImage? {
        guard let kernel = try? CIKernel(functionName: "laplacian", fromMetalLibraryData: metalLib) else {
            return self
        }

        let correctedImage = kernel.apply(extent: extent, roiCallback: { i, r in r }, arguments: [self])
        return correctedImage
    }
}

guard let url = Bundle.main.url(forResource: "default", withExtension: "metallib") else {
    fatalError("Not found default.metallib.")
}
guard let data = try? Data(contentsOf: url) else {
    fatalError("The default.metallib can not read as Data.")
}

let image = UIImage(named: "lena.png")!
let ciImage = CIImage(image: image)
let laplacianImage = ciImage?.laplacian(metalLib: data)
オリジナル画像 変換後

AGC(Adaptive Gamma Correction)

ピクセルの累積分布関数を使って、各ピクセルレベルに適用するガンマ補正値を調整します。 CDF-truncated AGC こちらを参考に実装してみました。

metal

#include <metal_stdlib>
using namespace metal;

#include <CoreImage/CoreImage.h>

extern "C" {
    namespace coreimage {

        half3 rgb2hsv_h(half3 c) {
            half4 K = half4(0.0h, -1.0h / 3.0h, 2.0h / 3.0h, -1.0h);
            half4 p = mix(half4(c.bg, K.wz), half4(c.gb, K.xy), step(c.b, c.g));
            half4 q = mix(half4(p.xyw, c.r), half4(c.r, p.yzx), step(p.x, c.r));
            half distance = q.x - min(q.w, q.y);
            half e = 1.0e-4;
            return half3(abs(q.z + (q.w - q.y) / (6.0h * distance + e)), distance / (q.x + e), q.x);
        }

        half3 hsv2rgb_h(half3 c) {
            half4 K = half4(1.0h, 2.0h / 3.0h, 1.0h / 3.0h, 3.0h);
            half3 p = abs(fract(c.xxx + K.xyz) * 6.0h - K.www);
            return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0h, 1.0h), c.y);
        }

        half4 histogram(sample_h s) {
            half3 hsv = rgb2hsv_h(s.rgb);
            return half4(half3(hsv.b), 1.0);
        }

        half4 transform_intensity(sampler_h s, sampler_h t) {
            half4 c = s.sample(s.coord());
            half3 hsv = rgb2hsv_h(c.rgb);
            hsv.z = t.sample(float2(0, hsv.z)).z;
            half3 rgb = hsv2rgb_h(hsv);
            return half4(rgb, c.a);
        }
    }
}

swift

extension CIImage {

    var bytes: [UInt8] {
        let context = CIContext(options: nil)
        let cgImage = context.createCGImage(self, from: extent)
        let cfData = cgImage!.dataProvider!.data!
        let length = CFDataGetLength(cfData)
        var data = [UInt8](repeating: 0, count: length)
        CFDataGetBytes(cfData, CFRange(location: 0, length: length), &data)
        return data
    }

    func lightnessHistgram(metalLib: Data) -> CIImage? {
        guard let kernel = try? CIColorKernel(functionName: "histogram", fromMetalLibraryData: metalLib) else {
            return self
        }
        let vImage = kernel.apply(
            extent: extent, roiCallback: { i, r in r }, arguments: [self])
        return vImage
    }

    func transformIntensity(metalLib: Data, intensity: CIImage) -> CIImage? {
        let name = "transform_intensity"
        guard let kernel = try? CIKernel(functionName: name, fromMetalLibraryData: metalLib) else {
            return self
        }

        let correctedImage = kernel.apply(
            extent: extent, roiCallback: { i, r in r }, arguments: [self, intensity])
        return correctedImage
    }

    func AGC(metalLib: Data, alpha: Float = 0.5, t: Float = 0.5) -> CIImage? {

        /// HSVの明度に対する画像を作る
        guard let image = self.lightnessHistgram(metalLib: metalLib) else {
            return self
        }

        /// 大きい画像の場合、あとの計算時間がかかるので小さくしておきます
        let params: [String: Any] = [kCIInputImageKey: image, "inputScale": 0.1, "inputAspectRatio": 1.0]
        guard let vImage = CIFilter(name: "CILanczosScaleTransform", parameters: params)?.outputImage else {
            return self
        }

        let bytes = vImage.bytes
        let pixels: [UInt8] = stride(from: 0, to: bytes.count, by: 4)
            .filter { i in bytes[i+3] >= 255 }
            .map { i in bytes[i] }

        /// 明度のヒストグラムを作成
        var hist: [Int] = Array<Int>(repeating: 0, count: 256)
        pixels.map { Int($0) }
            .forEach { i in hist[i] += 1 }

        let pdf = hist.map { Float($0) / Float(pixels.count) }
        let pMax = pdf.max()!
        let pMin = pdf.min()!

        /// pw(l) = pmax ((p(l) - pmin) / (pmax - pmin)) ^ alpha
        let pdfW = pdf.map { v in pMax * pow((v - pMin) / (pMax - pMin), alpha) }
        let pdfWSum = pdfW.reduce(0, +)
        
        /// 累積分布の作成
        let cdfW = [Int](0..<pdfW.count)
            .map { i in pdfW[0..<i].reduce(0, +) }
            .map { $0 / pdfWSum }

        /// rw(l) = max(t, 1 - cw(l))
        let gammaW = cdfW.map { max(t, 1 - $0) }

        /// T(l) = round(l_max * (l / l_max) ^ r(l))
        let r = [Int](0..<256)
            .map { l in round(255 * pow(CGFloat(l)/255.0, CGFloat(gammaW[l]))) }
            .map { UInt8($0) }

        let bitmap = CIImage(bitmapData: Data(r), bytesPerRow: 1, size: CGSize(width: 1, height: 256), format: CIFormat.L8, colorSpace: nil)
        return self.transformIntensity(metalLib: metalLib, intensity: bitmap)
    }
}
let image = UIImage(named: "image.jpg")!
let ciImage = CIImage(image: image)
let laplacianImage = ciImage?.AGC(metalLib: strong.metalLib, alpha: 1.5, t: 0.5)
オリジナル画像 変換後

ピクセルレベルを計算する方法がわからなかったので、すごく冗長になってしまいました。もっとうまいやり方がありそうな気がしています。 CIFilter の ビルトインに CIColorControlsCIGammaAdjust などがあるので利用用途によって使い分けたいです。
Core Image やってみたら結構面白かったのでなるべく活用していきたいです。

参考