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

これは [Retty Inc. Advent Calendar 2017](https://qiita.com/advent-calendar/2017/retty) 18日目の記事です。
前回は @takaaki-suzuki の [NVIDIA DGX Stationを試してみた](https://qiita.com/takaaki-suzuki/items/68fd1696b11be8255cb0) でした。

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

![instagram_like_filter_default_thumbnail.png](https://qiita-image-store.s3.amazonaws.com/0/179108/e4c482c6-84b3-49c8-900c-4eb7c07d4d39.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](https://developer.apple.com/metal/)
    * 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が`Metal`や`iOS上のOpenGL`より多い

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

[makoto-nagai/ios-instagram-like-filter](https://github.com/makoto-nagai/ios-instagram-like-filter)

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

## CoreImageでのfilter処理
`Core Image library`は`CIImage`, `CIFilter`などのCIの接頭辞のclass群。
提供されている機能として以下のようなものがある。

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

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

**UIImageからCIImageへの変換**

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

**CIImageからUIImageへの変換**

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

**CIContextでのCIImageからUIImageへの変換**

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

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

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

filterの合成でよく使う。

```swift
    //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の一覧](https://developer.apple.com/library/content/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html#//apple_ref/doc/uid/TP30000136-SW29) の中で、そのまま使えそうなもの

![instagram_like_filter_default_thumbnail_with_filter_name.png](https://qiita-image-store.s3.amazonaws.com/0/179108/8587d596-a500-a270-72dd-57045adbd1c0.png)

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

[GitHub - makomori/Sharaku: Image filtering UI library like Instagram.](https://github.com/makomori/Sharaku)

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

```swift
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](https://qiita-image-store.s3.amazonaws.com/0/179108/af26c661-81b0-fdc0-12bd-3a451bc89cd4.png)

codeは以下

```swift
    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](https://qiita-image-store.s3.amazonaws.com/0/179108/b2c9c8cd-d4c7-3c7e-8c4c-d90704633508.png)

```swift
    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](https://qiita-image-store.s3.amazonaws.com/0/179108/3fccb1bf-1bf0-2420-61c1-6a267ebd6ec3.png)

```swift
    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](https://qiita-image-store.s3.amazonaws.com/0/179108/c17f62c1-3dbf-9a4d-75c5-e57a61f7a420.png)

```swift
    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
    * 以下の表で、`simulator`と表記
    * [MacBook Air (13-inch, Mid 2013)](https://support.apple.com/kb/sp678?locale=en_US)上で測定
* 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の修正時の精神的負担が減る。
計測方法は色々あるが、例えば、

```swift
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](https://developer.apple.com/library/content/documentation/GraphicsImaging/Conceptual/CoreImaging/ci_performance/ci_performance.html#//apple_ref/doc/uid/TP30001185-CH10-SW1)を参照すると良いと思う。

## 補足

**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](https://qiita-image-store.s3.amazonaws.com/0/179108/380f12f9-d7e4-a230-46af-fdd20de101bd.png)

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

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

```swift
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 Language`は`GLSL`と比較して以下のようにかなり制約が強いので、あまり複雑な処理はできない。

* `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が参考になる。

* [Core Image Kernel Language](https://developer.apple.com/library/content/documentation/GraphicsImaging/Reference/CIKernelLangRef/ci_gslang_ext.html#//apple_ref/doc/uid/TP40004397-CH206-BCIEGAIJ)
    * officialの`Core Image Kernel Language`のrefeernce
* [CIKernel を使用してCore Imageのカスタムフィルタをつくる - Qiita](https://qiita.com/shu223/items/6959dc7ba6ac0844340b)
    * swiftでのcustom filterの作り方
* [GitHub - KawabataLemon/PhotoSample](https://github.com/KawabataLemon/PhotoSample)
* [Swift 3.0 for Core Image Developers](http://flexmonkey.blogspot.jp/2016/05/swift-30-for-core-image-developers.html)

**Blending algorithm**

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

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

## 参考になった記事など
感謝

* [Advanced Image Processing with Core Image](https://academy.realm.io/posts/tryswift-gladman-simon-advanced-core-image/)
* [CoreImageのフィルターを試してみる(CICategoryColorAdjustment、その2) - しめ鯖日記](http://llcc.hatenablog.com/entry/2017/09/15/151745)
* [Performance Tips](https://developer.apple.com/library/content/documentation/iPhone/Conceptual/iPhoneOSProgrammingGuide/PerformanceTips/PerformanceTips.html)
* [Swiftで実行時間計測 - Qiita](https://qiita.com/ken0nek/items/7577d26143a6fe4355f9)
* [Tuning for Performance and Responsiveness](https://developer.apple.com/library/content/documentation/General/Conceptual/MOSXAppProgrammingGuide/Performance/Performance.html)
* [[iOS] MetalでGPUコンピューティング (1) 最小限のコードの記述と特性の把握 - Qiita](https://qiita.com/yuky_az/items/ece9b64befc635e89f1a)
* [iOSの新グラフィックAPI - Metal入門してみる - Qiita](https://qiita.com/edo_m18/items/0fd6971c98c27a125d6e)
* [shader入門 -CIKernelでカスタムフィルター作成- | eureka tech blog](https://developers.eure.jp/tech/shader_introduction/)
* [ぱくたそのご利用規約・ガイド | ぱくたそフリー素材](https://www.pakutaso.com/userpolicy.html)
    * ネコ画像

明日は @resessh の [Vue.jsをTypeScriptで書く環境を構築する (Sublime Text編)](https://qiita.com/resessh/items/3ab3ccfd17954da94e6b) です。