iOS
ImageProcessing
Swift

Core Imageを使ったiOS上での画像フィルタの実装

これは Retty Inc. Advent Calendar 2017 18日目の記事です。
前回は @takaaki-suzukiNVIDIA DGX Stationを試してみた でした。

背景

こんな感じの画像filterを作りたい。

instagram_like_filter_default_thumbnail.png

iOS上での画像処理は以下の4つの選択肢があるが、どれも一長一短である。

  1. Core Graphics
    • 古くから画像処理用のAPI
    • Interfaceが古く使いにくいが、多くの描画用のclassが対応している
  2. Core Image
    • Core Graphicsより新しい画像処理用のAPI
    • Core Graphicsよりdocumentやsampleが少ない
  3. Metal
    • Metal 2 - Apple Developer
    • GPUを明示的に使い画像処理や3D rendering/VRなどができる
    • 他の方法より後発の技術
  4. OpenGL
    • iOS上でのOpenGLが利用できる
    • OpenGLとiOSの知識が必要

instagramで使われているような画像のフィルタ処理を実装する上で、以下の点でどれを使えば良いかというまとまった資料がなかったので調べた。

  1. instagramと同じようなfilter処理が機能的にできるか
  2. filterの処理時間は実用的か

調べた結果として、Core Imageが以下の点で良さそうだった。

  • libraryで提供されているfilterが100種類以上で豊富
  • libraryにないfilterもCIKernelを使ってある程度自作できる
  • CIContextを使えば、ある程度自動でGPU, CPUを適切に選んで処理を効率化してくれる
  • sampleやdocumentがMetaliOS上のOpenGLより多い

ただ、filterの自作と自作時の実際の処理時間について言及しているものが、あまりなかったのでそれっぽい画面を一通り作って実機で試した。
作成したcodeは以下にある。

makoto-nagai/ios-instagram-like-filter

以下は、Core Imageでfilterする際のcodeと処理時間についてまとめている。

CoreImageでのfilter処理

Core Image libraryCIImage, CIFilterなどのCIの接頭辞のclass群。
提供されている機能として以下のようなものがある。

  • Feature detecting
    • 顔認識
  • Auto enhancement filters
    • 赤目の修正
    • 顔の肌色補正
    • 彩度の修正
    • contrastの修正
  • Transformation
    • 画像の変形

前処理/後処理

Core Imageでは画像は、基本的にCIImage classで画像を扱う。
iOSで画像を描画する際は、UIImageUIImageView(のUIImage)を扱うことが多いが、これらのclassからは簡単に相互変換できるようになっている。

UIImageからCIImageへの変換

let ciImage:CIImage? = CIImage(image: uiImage)

CIImageからUIImageへの変換

let uiImage:UIImage? = UIImage(ciImage: ciImage)

CIContextでのCIImageからUIImageへの変換

CIContextを使うと高速に変換できるが、 CIContextの生成は時間がかかるので、propertyなどにして毎回生成しないようにすることが推奨されている。

// outputImageはCIImage class
let ciContext = CIContext(options: nil)
let outputCGImage: CGImage = ciContext.createCGImage(
    (outputImage)!,
    from: (outputImage?.extent)!)
UIImage(cgImage: outputCGImage!)

filter処理であると便利な関数

filterの合成でよく使う。

    //0, 255の値でで単色画像を生成する関数
    static func getColorImage(
        red: Int,
        green: Int,
        blue: Int,
        alpha: Int = 255,
        rect: CGRect) -> CIImage {
        let color = self.getColor(
            red: red, green: green, blue: blue, alpha: alpha)
        return CIImage(color: color).cropped(to: rect)
    }

Core Imageで提供されているfilter

Filterの一覧 の中で、そのまま使えそうなもの

instagram_like_filter_default_thumbnail_with_filter_name.png

defaultで提供されているfilterで十分であれば、下記のGitHubのcodeが簡潔にまとまっていてわかりやすいと思う。

GitHub - makomori/Sharaku: Image filtering UI library like Instagram.

実際の変換部分のcodeは以下のようになる。

class ViewController: UIViewController {
    // nameでfilter名を指定する
    let filter = CIFilter(name: "CISepiaTone")!
    @IBOutlet var imageView: UIImageView!

    func displayFilteredImage(image: UIImage) {
        // UIImage -> CIImage
        let inputImage = CIImage(image: image)!
        // filterにinputImageを入力
        filter.setValue(inputImage, forKey: kCIInputImageKey)
        // filterを適用してimageViewに適用
        imageView.image = UIImage(CIImage: filter.outputImage!)
    }
}

Nashville

instagram_like_filter_thumbnail_original_nasiville.png

codeは以下

    static func applyNashvilleFilter(foregroundImage: CIImage) -> CIImage? {
        let backgroundImage = getColorImage(
            red: 247, green: 176, blue: 153, alpha: Int(255 * 0.56), rect: foregroundImage.extent)
        let backgroundImage2 = getColorImage(
            red: 0, green: 70, blue: 150, alpha: Int(255 * 0.4), rect: foregroundImage.extent)
        return foregroundImage
            .applyingFilter("CIDarkenBlendMode", parameters: [
                "inputBackgroundImage": backgroundImage,
                ])
            .applyingFilter("CISepiaTone", parameters: [
                "inputIntensity": 0.2,
                ])
            .applyingFilter("CIColorControls", parameters: [
                "inputSaturation": 1.2,
                "inputBrightness": 0.05,
                "inputContrast": 1.1,
                ])
            .applyingFilter("CILightenBlendMode", parameters: [
                "inputBackgroundImage": backgroundImage2,
                ])
    }

Clarendon

instagram_like_filter_thumbnail_original_clarendon.png

    static func applyClarendonFilter(foregroundImage: CIImage) -> CIImage? {
        let backgroundImage = getColorImage(
            red: 127, green: 187, blue: 227, alpha: Int(255 * 0.2), rect: foregroundImage.extent)
        return foregroundImage
            .applyingFilter("CIOverlayBlendMode", parameters: [
                "inputBackgroundImage": backgroundImage,
                ])
            .applyingFilter("CIColorControls", parameters: [
                "inputSaturation": 1.35,
                "inputBrightness": 0.05,
                "inputContrast": 1.1,
                ])
    }

1977

instagram_like_filter_thumbnail_original_1977.png

    static func apply1977Filter(ciImage: CIImage) -> CIImage? {
        let filterImage = getColorImage(
            red: 243, green: 106, blue: 188, alpha: Int(255 * 0.1), rect: ciImage.extent)
        let backgroundImage = ciImage
            .applyingFilter("CIColorControls", parameters: [
                "inputSaturation": 1.3,
                "inputBrightness": 0.1,
                "inputContrast": 1.05,
                ])
            .applyingFilter("CIHueAdjust", parameters: [
                "inputAngle": 0.3,
                ])
        return filterImage
            .applyingFilter("CIScreenBlendMode", parameters: [
                "inputBackgroundImage": backgroundImage,
                ])
            .applyingFilter("CIToneCurve", parameters: [
                "inputPoint0": CIVector(x: 0, y: 0),
                "inputPoint1": CIVector(x: 0.25, y: 0.20),
                "inputPoint2": CIVector(x: 0.5, y: 0.5),
                "inputPoint3": CIVector(x: 0.75, y: 0.80),
                "inputPoint4": CIVector(x: 1, y: 1),
                ])
    }

Toaster

instagram_like_filter_thumbnail_original_toaster.png

    static func applyToasterFilter(ciImage: CIImage) -> CIImage? {
        let width = ciImage.extent.width
        let height = ciImage.extent.height
        let centerWidth = width / 2.0
        let centerHeight = height / 2.0
        let radius0 = min(width / 4.0, height / 4.0)
        let radius1 = min(width / 1.5, height / 1.5)
        print(width, height, centerWidth, centerHeight, radius0, radius1)

        let color0 = self.getColor(red: 128, green: 78, blue: 15, alpha: 255)
        let color1 = self.getColor(red: 79, green: 0, blue: 79, alpha: 255)
        let circle = CIFilter(name: "CIRadialGradient", withInputParameters: [
            "inputCenter": CIVector(x: centerWidth, y: centerHeight),
            "inputRadius0": radius0,
            "inputRadius1": radius1,
            "inputColor0": color0,
            "inputColor1": color1,
            ])?.outputImage?.cropped(to: ciImage.extent)

        return ciImage
            .applyingFilter("CIColorControls", parameters: [
                "inputSaturation": 1.0,
                "inputBrightness": 0.01,
                "inputContrast": 1.1,
                ])
            .applyingFilter("CIScreenBlendMode", parameters: [
                "inputBackgroundImage": circle!,
                ])
    }

処理速度について

simulator上と実機でのfilterの処理時間は、実機の方が1-100倍早い場合があるので、必ず実機で確認した方が良い。
一方、debug buildとrelease buildでは大きな差は感じられなかった。
実機, simulatorの処理時間は環境にもよるので、参考値として見て欲しい。

  • iPhoneSE simulator
  • iPhoneSE 実機
    • 以下の表で、deviceと表記
    • iOS10.3.3のiPhoneSE

時間は全て、3回の平均を、端数は切り捨てで小数点4位まで出している。

CollectionViewのthumbnailのfilterの処理時間

filter device (sec) simulator (sec)
1977 0.0121 1.4867
Chrome 0.0150 0.6211
Clarendon 0.0094 1.2565
Fade 0.0097 0.3077
HazeRemoval 0.0130 0.6795
Instant 0.0094 0.1236
Linear 0.0062 0.0006
Mono 0.0087 0.0222
Nashville 0.0330 1.6097
Noir 0.0087 0.0212
Process 0.0091 0.0236
Toaster 0.0147 0.8657
Tonal 0.0083 0.1920
Tone 0.0061 0.3095
Transfer 0.0095 0.0238

内部的にcacheなどで効率化がされる場合があるので、一回目のfilter処理と二回目のfilter処理の比較をしている。
simulatorの1回目のfilter処理と2回目のfilter処理の時間

filter 1st simulator (sec) 2nd simulator (sec)
1977 2.0348 1.3711
Chrome 0.6523 0.2420
Clarendon 1.0200 0.6433
Fade 0.6509 0.0414
HazeRemoval 0.7720 0.4865
Instant 0.4845 0.2717
Linear 0.3063 0.1394
Mono 0.4311 0.2414
Nashville 1.9360 0.2677
Noir 0.2444 0.0456
Process 0.2538 0.4670
Toaster 1.0061 0.4198
Tonal 0.2417 0.2498
Tone 0.2053 0.1266
Transfer 0.2363 0.0418

内部的にcacheなどで効率化がされる場合があるので、一回目のfilter処理と二回目のfilter処理の比較をしている。
実機の1回目のfilter処理と2回目のfilter処理の時間

filter 1st device (sec) 2nd device (sec)
1977 0.0180 0.0180
Chrome 0.0159 0.0169
Clarendon 0.0159 0.0175
Fade 0.0159 0.0165
HazeRemoval 0.0181 0.0174
Instant 0.0172 0.0159
Linear 0.0146 0.0152
Mono 0.0160 0.0164
Nashville 0.0206 0.0180
Noir 0.0153 0.0164
Process 0.0162 0.0160
Toaster 0.0177 0.0169
Tonal 0.0162 0.0169
Tone 0.0151 0.0146
Transfer 0.0164 0.0154

実機とsimulatorの差を比較している。
simulatorと実機の1回目のfilter処理の差

filter 1st device (sec) 1st simulator (sec)
1977 0.0180 2.0348
Chrome 0.0159 0.6523
Clarendon 0.0159 1.0200
Fade 0.0159 0.6509
HazeRemoval 0.0181 0.7720
Instant 0.0172 0.4845
Linear 0.0146 0.3063
Mono 0.0160 0.4311
Nashville 0.0206 1.9360
Noir 0.0153 0.2444
Process 0.0162 0.2538
Toaster 0.0177 1.0061
Tonal 0.0162 0.2417
Tone 0.0151 0.2053
Transfer 0.0164 0.2363

開発中はfilterの前後に計測用のcodeを書いておくと、filterの修正時の精神的負担が減る。
計測方法は色々あるが、例えば、

func getStartTime() -> CFAbsoluteTime {
    return CFAbsoluteTimeGetCurrent()
}

func printElapsedTime(title: String, startTime: CFAbsoluteTime) {
    let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
    print("Time elapsed for \(title): \(timeElapsed) seconds")
}

func applySomeFilter(title: String, startTime: CFAbsoluteTime) {
    // 計測開始
    let startTime = getStartTime()

    // filter処理

    // 時間の出力
    printElapsedTime(title: "viewDidload", startTime: startTime)
}

performanceの改良が必要な場合はまずはofficialの Guideを参照すると良いと思う。

補足

Custom Filter

Defaultで100種類以上のfilterが提供されているが、必要とするfilterがない場合やfilterの組み合わせで実現できない場合はは自分でfilterを定義できる。
RGBAの画像を変換する処理は、openGL Shading Language (GLSL)をbaseとしたCore Image Kernel Languageと呼ばれる言語で記述する必要がある。
CoreImageのtutorialにあるHazeRemovalFilterを、custom filterとして作る場合は以下のようなfilterを作る。

instagram_like_filter_thumbnail_original_haze_removal.png

    // 自作のfitlerを適用する
    static func applyHazeRemovalFilter(image: CIImage) -> CIImage? {
        let filter = HazeRemovalFilter()
        filter.setValue(inputImage, forKey: kCIInputImageKey)
        return filter.outputImage
    }

CIFilterのsubclassの実装は以下のようになる。

import Foundation
import CoreImage

// Haze Removal用 custom filter
class HazeRemovalFilter: CIFilter {
    var inputImage: CIImage!
    var inputColor: CIColor! = CIColor(red: 0.7, green: 0.9, blue: 1.0)
    var inputDistance: Float! = 0.2
    var inputSlope: Float! = 0.0
    var hazeRemovalKernel: CIKernel!

    override init()
    {
        // 自作のfilterのkernelのcode
        // Budnleで読み込んでも良い
        let code: String = """
kernel vec4 myHazeRemovalKernel(
    sampler src, // CISamplerが、pixelのRGBA値を渡す
    __color color,
    float distance, // HazeRemovalFilter classから渡されるinputDistance
    float slope) {
    vec4 t;
    float d;

    d = destCoord().y * slope + distance;
    t = unpremultiply(sample(src, samplerCoord(src)));
    t = (t - d * color) / (1.0 - d);

    return premultiply(t);
}
"""
        self.hazeRemovalKernel = CIKernel(source: code)
        super.init()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override var outputImage: CIImage? {
        guard let inputImage = self.inputImage,
            let hazeRemovalKernel = self.hazeRemovalKernel,
            let inputColor = self.inputColor,
            let inputDistance = self.inputDistance,
            let inputSlope = self.inputSlope
            else {
            return nil
        }
        let src: CISampler = CISampler(image: inputImage)
        return hazeRemovalKernel.apply(
            extent: inputImage.extent, // 画像のsize
            roiCallback: { (index, rect) -> CGRect in // kernelの処理で画像のsizeが変わる場合指定必須
                return rect
            }, arguments: [ // kernelに渡す引数を指定する
                src,
                inputColor,
                inputDistance,
                inputSlope,
            ])
    }
}

Core Image Kernel LanguageGLSLと比較して以下のようにかなり制約が強いので、あまり複雑な処理はできない。

  • if, for, whileなどの制御文はcompile時に決定可能なloopにしか使えない
  • mat2, mat3, mat4, struct, arraysをsupportしていない
  • % << >> | & ^ || && ^^ ~などのoperatorをsupportしていない
  • ftransform, matrixCompMult, dfdx, dfdy, fwidth, noise1, noise2, noise3, noise4, refractのbuilt in functionをsupportしていない

どうしても必要な場合は、以下のsiteが参考になる。

Blending algorithm

FilterのBlendingのalgorithmは以下のPDFのblendingのalgorithmにもとづいている。
blendingの正確な計算式が必要であれば参照する。

http://wwwimages.adobe.com/content/dam/acom/en/devnet/pdf/PDF32000_2008.pdf

参考になった記事など

感謝

明日は @resesshVue.jsをTypeScriptで書く環境を構築する (Sublime Text編) です。