これは Retty Inc. Advent Calendar 2017 18日目の記事です。
前回は @takaaki-suzuki の NVIDIA DGX Stationを試してみた でした。
背景
こんな感じの画像filterを作りたい。
iOS上での画像処理は以下の4つの選択肢があるが、どれも一長一短である。
- Core Graphics
- 古くから画像処理用のAPI
- Interfaceが古く使いにくいが、多くの描画用のclassが対応している
- Core Image
- Core Graphicsより新しい画像処理用のAPI
- Core Graphicsよりdocumentやsampleが少ない
- Metal
- Metal 2 - Apple Developer
- GPUを明示的に使い画像処理や3D rendering/VRなどができる
- 他の方法より後発の技術
- OpenGL
- iOS上でのOpenGLが利用できる
- OpenGLとiOSの知識が必要
instagramで使われているような画像のフィルタ処理を実装する上で、以下の点でどれを使えば良いかというまとまった資料がなかったので調べた。
- instagramと同じようなfilter処理が機能的にできるか
- 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
以下は、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への変換
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の一覧 の中で、そのまま使えそうなもの
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
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
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
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
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)上で測定
- 以下の表で、
- 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を作る。
// 自作の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 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
- officialの
Core Image Kernel Language
のrefeernce
- officialの
-
CIKernel を使用してCore Imageのカスタムフィルタをつくる - Qiita
- swiftでのcustom filterの作り方
- GitHub - KawabataLemon/PhotoSample
- Swift 3.0 for Core Image Developers
Blending algorithm
FilterのBlendingのalgorithmは以下のPDFのblendingのalgorithmにもとづいている。
blendingの正確な計算式が必要であれば参照する。
参考になった記事など
感謝
- Advanced Image Processing with Core Image
- CoreImageのフィルターを試してみる(CICategoryColorAdjustment、その2) - しめ鯖日記
- Performance Tips
- Swiftで実行時間計測 - Qiita
- Tuning for Performance and Responsiveness
- [iOS] MetalでGPUコンピューティング (1) 最小限のコードの記述と特性の把握 - Qiita
- iOSの新グラフィックAPI - Metal入門してみる - Qiita
- shader入門 -CIKernelでカスタムフィルター作成- | eureka tech blog
-
ぱくたそのご利用規約・ガイド | ぱくたそフリー素材
- ネコ画像
明日は @resessh の Vue.jsをTypeScriptで書く環境を構築する (Sublime Text編) です。