まだまだ続いているフィルムカメラブーム。デジタルカメラと違い、現像後のデータ化費用が嵩んだり、撮影後のネガフィルムも整理が間に合わず溜まっていくことが多かったり...
専用の機械なども多くありますが、お手軽にiPhoneでなんとか...と考える人は多いはず。
実際AppStoreにはいくつかのフィルムスキャナーアプリがリリースされています。
有名どころだとKodak Mobile Film Scannerなどがあります。
ただ、これらのアプリってどのようにiOS上でネガポジ変換を実現してるのかと聞かれると、開発を行っている人が少なく実現方法が分かりません。
なので、今回は同様のネガポジ変換を実現してみたいと思います。
陰画から陽画を取得する
ネガフィルムは、撮影時の被写体の色が反転して画像が作られます。
白い被写体であれば、ネガフィルム上には真っ黒の被写体となります。
これをネガティブ画像と呼びます。
ネガティブ画像をプリント、データ化するときに色の再反転を行い、撮影時の被写体の色を復元します。
ここで得られる画像をポジティブ画像と呼びます。
昔は、データ化などなくフィルムから印画紙にプリントしていました。印画紙にもネガフィルム同様色を反転する感光剤が塗布されているので、ネガフィルムに写っているコマを引き伸ばし機などを用いて印画紙に投影し、印画紙にネガティブ画像を感光させることで色を再反転し、撮影時の被写体の色を復元していました。
デジタル化では、ネガティブ画像を画像処理で色の再反転を行うことで撮影時の被写体の色を復元するということを行っているだけになります。
シンプルに考えると、色の再反転を行うことでネガフィルムからポジティブ画像を取得することができるようになるということです。
トーンカーブを反転させる
ネガティブ状態の画像からポジティブ画像を取得するには、色の再反転を行う必要ですが、トーンカーブのを上下反転させることで実現することが可能です。
Swiftでは、CIFilterに用意されたToneCurveFilterを利用します。
CIFilterとは?
CoreImageFrameworkで提供される画像処理フィルターがCIFilterです。
CoreImageFrameworkもiOS5から提供されている歴史あるFrameworkで、現在では100種類以上のフィルターが提供されています。
CIFilterを駆使することができるようになると、リッチな画像編集アプリなどを作ることが可能です。
CIFilterの種類は、こちらのドキュメントで確認できます。是非試してみてください。
Core Image Filter Reference
ToneCurveFilterを利用する
サンプルコードは下記のようになります。
class ToneReverse {
func demo() {
guard
// AssetsCatalogに「nega」という名称のネガ画像がある想定で読み込む
let negaImage = UIImage(named: "nege"),
// CIFilterで処理するため、UIImageからCIImageへ変換する
let ciNegaImage = CIImage(image: negaImage),
// toneCurve反転を行うメソッドを介して反転後の画像を取得する
let toneReversedImage = reverse(with: ciNegaImage) else {
return
}
// CIImageからUIImageへ再変換
let posiImage = UIImage(ciImage: toneReversedImage)
}
/// Tone Reverse
/// - Parameter image: base image
/// - Returns: filtered image
func reverse(with image: CIImage) -> CIImage? {
return image.applyingFilter(
"CIToneCurve",
parameters: [
"inputPoint0": CIVector(x: 0.0, y: 1.0),
"inputPoint1": CIVector(x: 0.25, y: 0.75),
"inputPoint2": CIVector(x: 0.5, y: 0.5),
"inputPoint3": CIVector(x: 0.75, y: 0.25),
"inputPoint4": CIVector(x: 1.0, y: 0.0)
])
}
}
CIFilterにはinputPoint0からinputPoint4までのToneCurveの座標を設定することができます。
何も触らない状態のトーンカーブは、下記の画像のようになります。
ToneCurveで色反転を行うにはToneCurveの上下反転すれば良いので、Point0からPoint4までのY座標を反転させることで実現できます。
上記図のトーンカーブをCIFilterで行うと下記のような実装になります。
/// Tone Reverse
/// - Parameter image: base image
/// - Returns: filtered image
func reverse(with image: CIImage) -> CIImage? {
return image.applyingFilter(
"CIToneCurve",
parameters: [
"inputPoint0": CIVector(x: 0.0, y: 1.0),
"inputPoint1": CIVector(x: 0.25, y: 0.75),
"inputPoint2": CIVector(x: 0.5, y: 0.5),
"inputPoint3": CIVector(x: 0.75, y: 0.25),
"inputPoint4": CIVector(x: 1.0, y: 0.0)
])
}
これでネガティブ画像からポジティブ画像を取得することが可能です。
ただ、ポジティブ画像になってはいるものの青味が強すぎてしまい撮影時の被写体の色を再現できていません。
被写体の色を再現するにはここからさらに色調補正を行う必要があります。
※Debug用にカメラ入力をトーン反転のみフィルター適用してライブ表示した場合が上記みたいになります。
#色調補正を行う
一応ポジティブ画像を取得することはできましたが、このままでは見るに耐えられません。
なので色調補正を行うのですが、CoreImageに用意されているToneCurveFilterはRGB値の合成チャンネルでしかToneCurveを弄ることしか出来ません。
被写体の色を再現するにはRed、Green、Blueの各チャンネルごとの色調量を調整してバランスを保つ必要があります。今回で言うと青みが強いので、青の色調量を下げて相対的なバランスを作っていくことが必要です。
ただ、前述の通りCIFilterに用意されたToneCurveは合成チャンネルでしか触ることは出来ません。
ではどうするか...
CIKernelを用いてカスタムフィルターを作ります。
現在CIKernelを利用する際は、Metalを利用します。
Metalとは
Metalは、2014年に登場したAppleプラットフォーム向けのグラフィックAPIです。
記述言語は、C++11ベースのMetal Shading Languageで記述します。
Metal利用時の設定
Metalを利用する際には、Build SettingsのOther Metal Compiler FlagsとOther Metal Linker Flagsに**-fcikernelの指定が必要です。
ここで落とし穴ですが、下記のAppleの公式ドキュメントでは、MELLINKER_FLAGSをuser-definedに追加し-cikernel**を設定しろという記述がありますがXcode12.3の環境下ではWarningが表示されます。上記の設定通りにすることが求められるので注意が必要です。
Apple - Type Method kernelWithFunctionName:fromMetalLibraryData:error:
Metal側の実装
今回行いたいことは、RGBごとのチャンネルをToneCurveで弄り、各チャンネルの結果を合成して1枚の画像として取得することです。
ToneCurveの操作自体は、CIFliterで各チャンネル向けにCIFilterで行い、得られたCIImageからR、G、Bの各要素だけを抽出し、各要素を掛け合わせた1枚の画像を生成することでRGBチャンネルごとにいじれるToneCurve機能を実現します。
実現にあたり、ページを参考にさせていただきました。
HSL color adjustment filter in an iOS 8.0+ app using CoreImage
#include <metal_stdlib>
using namespace metal;
#include <CoreImage/CoreImage.h>
extern "C" {
namespace coreimage {
float4 rgbChannelCompositing(sample_t red, sample_t green, sample_t blue) {
return float4(red.r, green.g, blue.b, 1.0);
}
}
}
上記の実装は、任意の名前 + .metalの名称のファイルに記述しておきます。
Swift側の実装
先に全体のコードです。
ToneCurvePointModelのXYに入っている値は、デモ用に調整した値です。
微調整することで寒暖色などに調整することが可能です。
class RgbCompositing {
private let redToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0,
oneX: 0.38, oneY: 0.07,
twoX: 0.63, twoY: 0.24,
threeX: 0.78, threeY: 0.49,
fourX: 1.0, fourY: 1.0)
private let greenToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0,
oneX: 0.67, oneY: 0.07,
twoX: 0.85, twoY: 0.19,
threeX: 0.92, threeY: 0.39,
fourX: 1.0, fourY: 0.90)
private let blueToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0,
oneX: 0.69, oneY: 0.06,
twoX: 0.90, twoY: 0.20,
threeX: 0.96, threeY: 0.44,
fourX: 1, fourY: 0.92)
func rgbCompositing(with image: CIImage) -> CIImage? {
let redImage = image.applyingFilter(
"CIToneCurve",
parameters: [
"inputPoint0": redToneCurveModel.pointZero,
"inputPoint1": redToneCurveModel.pointOne,
"inputPoint2": redToneCurveModel.pointTwo,
"inputPoint3": redToneCurveModel.pointThree,
"inputPoint4": redToneCurveModel.pointFour
])
let greenImage = image.applyingFilter(
"CIToneCurve",
parameters: [
"inputPoint0": greenToneCurveModel.pointZero,
"inputPoint1": greenToneCurveModel.pointOne,
"inputPoint2": greenToneCurveModel.pointTwo,
"inputPoint3": greenToneCurveModel.pointThree,
"inputPoint4": greenToneCurveModel.pointFour
])
let blueImage = image.applyingFilter(
"CIToneCurve",
parameters: [
"inputPoint0": blueToneCurveModel.pointZero,
"inputPoint1": blueToneCurveModel.pointOne,
"inputPoint2": blueToneCurveModel.pointTwo,
"inputPoint3": blueToneCurveModel.pointThree,
"inputPoint4": blueToneCurveModel.pointFour
])
guard
let url = Bundle.main.url(forResource: "default", withExtension: "metallib"),
let data = try? Data(contentsOf: url),
let rgbChannelCompositingKernel = try? CIColorKernel(functionName: "rgbChannelCompositing", fromMetalLibraryData: data) else {
return nil
}
let extent = redImage.extent.union(greenImage.extent.union(blueImage.extent))
let arguments = [redImage, greenImage, blueImage]
guard let ciImage = rgbChannelCompositingKernel.apply(extent: extent, arguments: arguments) else {
return nil
}
return ciImage
}
}
少しづつ分解してみていきましょう。
func rgbCompositing(with image: CIImage) -> CIImage? {
let redImage = image.applyingFilter(
"CIToneCurve",
parameters: [
"inputPoint0": redToneCurveModel.pointZero,
"inputPoint1": redToneCurveModel.pointOne,
"inputPoint2": redToneCurveModel.pointTwo,
"inputPoint3": redToneCurveModel.pointThree,
"inputPoint4": redToneCurveModel.pointFour
])
let greenImage = image.applyingFilter(
"CIToneCurve",
parameters: [
"inputPoint0": greenToneCurveModel.pointZero,
"inputPoint1": greenToneCurveModel.pointOne,
"inputPoint2": greenToneCurveModel.pointTwo,
"inputPoint3": greenToneCurveModel.pointThree,
"inputPoint4": greenToneCurveModel.pointFour
])
let blueImage = image.applyingFilter(
"CIToneCurve",
parameters: [
"inputPoint0": blueToneCurveModel.pointZero,
"inputPoint1": blueToneCurveModel.pointOne,
"inputPoint2": blueToneCurveModel.pointTwo,
"inputPoint3": blueToneCurveModel.pointThree,
"inputPoint4": blueToneCurveModel.pointFour
])
上記の部分では、受け取ったCIImageに対してRed、Green、Blueの各チャンネルの弄りたいToneCurveの値を適応しています。
この時点では、redImage
などに入っているCIImageは、合成チャンネルでトーンカーブを弄った画像になるので理想の色調補正はまだ行われていません。
guard
let url = Bundle.main.url(forResource: "default", withExtension: "metallib"),
let data = try? Data(contentsOf: url),
let rgbChannelCompositingKernel = try? CIColorKernel(functionName: "rgbChannelCompositing", fromMetalLibraryData: data) else {
return nil
}
上記部分では、CIColorKernelを生成しています。
Metalファイルは、コンパイル後metallib
形式でrootディレクトリ直下に生成されます。特段設定しなければリソース名はdefault
で書き出される模様です。
Bundle.main.url(forResource:, withExtension:)
で、default.metallib
のパスを取得し、Data(contentsOf:)
でData型としてファイルを取得しています。
最後に CIColorKernel(functionName:, fromMetalLibraryData:)
に対してMetalファイルで定義した画像処理メソッドのメソッド名と取得したdefault.metallib
を引数で渡すと、Metal側で実装したrgbChannelCompositing
が利用可能になります。
let extent = redImage.extent.union(greenImage.extent.union(blueImage.extent))
let arguments = [redImage, greenImage, blueImage]
guard let ciImage = rgbChannelCompositingKernel.apply(extent: extent, arguments: arguments) else {
return nil
}
return ciImage
}
続いてCIKernelを用いて、新しい画像を取得します。
extent
は画像の出力範囲です。union
を利用して3つの画像を含むサイズを取得します。今回で言うと元画像が同一であるため基本サイズがずれることはないはずです。
arguments
は、CIKernelに対して渡す元画像の配列です。並び順はrgbです。
extent
とarguments
を引数にし、apply(extent:arguments :)
を実行すると、RGB各チャンネルのToneCurveを調整したCIImageを取得することができます。
成果物
こんな感じの画像にネガポジ変換して、色調補正をかけた画像を取得することができます。
Framework化しました
一連の処理を整理してFramework化までしてます。
サンプルコードも一緒にしてあるので気になる方は下記からどうぞ。
(Cocoapods、Carthage対応は追って行います。)
NegaDeveloping - Masami Yamate
最後に
探せば世界のどこかでやってる人はいてOSSのFrameworkがすでにあるかもしれませんが、自分で仕組みを辿ってみると楽しいですね。
個人的に使うアプリとして開発していますが、冬休みの課題で間に合えばApp Storeでも現像アプリとして公開するかもしれないので期待せずに待っていただけると嬉しいです。