GaussianBlurFilter で学ぶ自作 CIFilter(Filter-Chain の場合)の作り方

  • 11
    Like
  • 0
    Comment
More than 1 year has passed since last update.

どうもお久しぶりです、いろんなものに手を突っ込んで墓穴を掘ることを日課としている筆者です。最近は画像フィルターの製作に悩まされたりしているところです。

CoreImage は便利だよ。はい。通常の UIKit と比べると非常に資料が少なくて調べるのに大変だけど。

そして一時期 GPUImage と言うフレームワークも使ってみたけどまあベースは Objective-C だからね、Swift のプロジェクトでも使えるけどまあカスタマイズしようとすると大変ですね。そして最近これの Swift 版である GPUImage2 も同じ作者からリリースされたけど、これはこれでなんか微妙に命名規約がキモかったりするんだよね…

と言うわけで結局散々チャレンジしてみた結果おとなしく CoreImage 使うことにしました。

さて、本題に戻りますが、CIFilter ていうフィルタークラスが CoreImage に提供されているけど、これもなかなかのクセモノですね。UIView みたいにベースクラスを作っていろんなサブクラスを定義されたわけではなく、すべて CIFilter(name: String) で作られるから全部 CIFilter オブジェクトだったり、その name が提供された定数で決めてるわけではなくドキュメント読んで名前を引き出さねばならんかったり、さらにこれオプショナルイニシャライザーだから名前間違えると nil で返されたりする可能性もあったり、おまけに仮にちゃんと作られたとしてもそのフィルターはどんなプロパティーが設定できるかもわからなかったりと。まあクソ面倒ですね。

と言うわけで CIFilter をベースとした自作サブクラスを作ることにした。

公式ドキュメントではこう書いてあります:

You can create custom effects by using the output of one image filter as the input of another, chaining as many filters together as you’d like. When you create an effect this way that you want to use multiple times, consider subclassing CIFilter to encapsulate the effect as a filter.

つまりフィルターチェインを作りたいなら自分のサブクラスを作れ、と。そして一応サンプルコードとして、Objective-C ですがまあ参考程度になるものがあるから自分でなんとかしろ、と

CIColorInvert.h
@interface CIColorInvert: CIFilter {
    CIImage *inputImage;
}
@property (retain, nonatomic) CIImage *inputImage;
@end
CIColorInvert.m
@implementation CIColorInvert
@synthesize inputImage;
- (CIImage *) outputImage
{
    CIFilter *filter = [CIFilter filterWithName:@"CIColorMatrix"
                            withInputParameters: @{
            kCIInputImageKey: inputImage,
        @"inputRVector": [CIVector vectorWithX:-1 Y:0 Z:0],
        @"inputGVector": [CIVector vectorWithX:0 Y:-1 Z:0],
        @"inputBVector": [CIVector vectorWithX:0 Y:0 Z:-1],
        @"inputBiasVector": [CIVector vectorWithX:1 Y:1 Z:1],
        }];
    return filter.outputImage;
}

まあ Objective-C が読めない人や苦手な人に、これの Swift 版を自分で書いてみました:

CIColorInvert.swift
class CIColorInvert: CIFilter {

    var inputImage: CIImage?
    override var outputImage: CIImage? {
        let filter = CIFilter(name: "CIColorMatrix", withInputParameters: [
            "inputRVector": CIVector(x: -1, y: 0, z: 0),
            "inputGVector": CIVector(x: 0, y: -1, z: 0),
            "inputBVector": CIVector(x: 0, y: 0, z: -1),
            "inputBiasVector": CIVector(x: 1, y: 1, z: 1),
        ])
        filter?.setValue(inputImage, forKey: kCIInputImageKey)
        return filter?.outputImage
    }

}

と、まあぶっちゃけあんまり参考になるポイントがない(そもそもチェイニングしてねぇし)けど、ソースコードを読むことで察せるものもある、それは Swift 版を読めばわかりますが outputImage というやらはただの算出プロパティーであって、こいつこそがフィルターの実態だということ、つまり他のプロパティーとかはあくまでフィルターに必要な変数を格納しているだけであって outputImage が呼ばれるまでフィルターは何も動作しない、ということです。これでわかったことは一つ:どんな自作 CIFilter でも、inputImage: CIImage?outputImage: CIImage? は必ず存在すること、しかもその中の outputImage は算出プロパティーだ、ということです。となると、CIFilter をサブクラス化するときに、どれもこれも inputImageoutputImage 書くのダルくね?実際のフィルタリングのプロセスだけ書きたいんだけど?

というわけで、CIFilter のサブクラス化用のためのサブクラス()を作ろう。

CustomFilter
class CustomCIFilter: CIFilter {

    var inputImage: CIImage?

    override var outputImage: CIImage? {

        return self.inputImage

    }

}

と上記のように、最初から inputImage: CIImage? が入ってあるフィルタークラスを作ります。これで outputImage だけをオーバーライドすればいい。便利。

ではこのように、最初のサンプルである CIColorInvert をこちらでサブクラス化してみましょう。

CIColorInvert
class CIColorInvert: CustomCIFilter {

    override var outputImage: CIImage? {
        let filter = CIFilter(name: "CIColorMatrix", withInputParameters: [
            "inputRVector": CIVector(x: -1, y: 0, z: 0),
            "inputGVector": CIVector(x: 0, y: -1, z: 0),
            "inputBVector": CIVector(x: 0, y: 0, z: -1),
            "inputBiasVector": CIVector(x: 1, y: 1, z: 1),
            ])
        filter?.setValue(self.inputImage, forKey: kCIInputImageKey)
        return filter?.outputImage
    }

}

まあなんか最初の例と比べてそんなに大して短くなってないような感は否めないが…でもまあ自分で inputImage: CIImage? を定義しなくて済むしね

しかしまあこれではすべて CIImage でやり取りしないといけないが、実際のプログラムでは UIImage に落とし込まないと話にならないですね。というわけで、CIFilter に直接 UIImage を取り扱う拡張もついでに一緒に作りましょう。

CIFilter.swift
extension CIFilter {

    func getFilteredImage(from originalImage: UIImage?, in context: CIContext? = nil) -> UIImage? {

        guard let originalImage = originalImage else {
            return nil
        }

        guard let originalCIImage = originalImage.ciImage ?? CIImage(image: originalImage) else {
            return originalImage
        }

        self.setValue(originalCIImage, forKey: kCIInputImageKey)

        guard let outputCIImage = self.outputImage else {
            return originalImage
        }

        if let context = context, let outputCGImage = context.createCGImage(outputCIImage, from: outputCIImage.extent) {
            return UIImage(cgImage: outputCGImage)

        } else {
            return UIImage(ciImage: outputCIImage)

        }

    }

}

ここで in context: CIContext? = nil の引数を作っているのは、CIImage から直接 UIImage を作ると、新しく CIContext をその場で生成しないといけない(はず、確か、確信はないが…間違ってたらツッコミお願いします)のだが、この CIContext の生成は非常に時間がかかるものなので、このようなフィルタリングが非常に頻繁にやりたい場合は自分で CIContext を作って保持しておく必要があるので、そのような場合は保持してある CIContext を直接渡してあげればオーバーヘッドが少なくなる、ということです。

と前振りとしてはだいぶ長くなってしまいましたが、ここからが本題です:GaussianBlurFilter の製作です。

いやまあ CIFilter(name: "CIGaussianBlur") で作ればいいんだろ?と思うあなた、甘いですね。甘々です。おなじみのレナさんで見てみましょうか。試しに Playground に下記のようなコードを書いてみましょう。下記の比較画像のようなことになります。

let basicImage = UIImage(named: "lena.png")
let view = UIImageView(image: basicImage)
PlaygroundPage.current.liveView = view

let filter = CIFilter(name: "CIGaussianBlur")
let filteredImage = filter?.getFilteredImage(from: basicImage)

view.image = filteredImage

preview.png

これはなぜかというと、画像をぼかした場合、ピクセルデータが周囲に拡散するから、画像解像度が高くなってしまうわけですよ。つまり CIFilter 内蔵の CIGaussianFilter をそのまま使っても、我々が望む結果にはならない、ということです。さてここからが本来のフィルターチェイニングの活躍だ。

やることは非常に簡単、要するにボカされた画像を元画像の解像度で一回切り取りすれば OK になるはずです。早速試してみましょう。

class GaussianBlurFilter: CustomCIFilter {

    override var outputImage: CIImage? {

        guard let inputImage = self.inputImage else {
            return nil
        }

        guard let blurFilter = CIFilter(name: "CIGaussianBlur") else {
            return inputImage
        }
        blurFilter.setValue(inputImage, forKey: kCIInputImageKey)
        guard let blurredImage = blurFilter.outputImage else {
            return inputImage
        }

        guard let cropFilter = CIFilter(name: "CICrop") else {
            return blurredImage
        }
        let originalRect = inputImage.extent
        cropFilter.setValue(blurredImage, forKey: kCIInputImageKey)
        cropFilter.setValue(originalRect, forKey: "inputRectangle")
        guard let croppedImage = cropFilter.outputImage else {
            return blurredImage
        }

        return croppedImage

    }

}

let basicImage = UIImage(named: "lena.png")
let view = UIImageView(image: basicImage)
PlaygroundPage.current.liveView = view

let filter = GaussianBlurFilter()
let filteredImage = filter.getFilteredImage(from: basicImage)

view.image = filteredImage

preview.png

ふむふむ、だいぶ良くなりましたね。ところがよく見てみるとやはり周りが微妙に黒い帯が付いています。

これを解決するにはどうすればいいかと色々ググってみたが、ここによりますとどうやらぼかしを適用する前に一回 CIAffineClamp のフィルターを適用しなければならないそうです。早速改良してみよう。

class GaussianBlurFilter: CustomCIFilter {

    override var outputImage: CIImage? {

        guard let inputImage = self.inputImage else {
            return nil
        }

        guard let affineClampFilter = CIFilter(name: "CIAffineClamp") else {
            return inputImage
        }
        let transform = CGAffineTransform(scaleX: 1, y: 1)
        affineClampFilter.setValue(inputImage, forKey: kCIInputImageKey)
        affineClampFilter.setValue(transform, forKey: kCIInputTransformKey)
        guard let affineClampedImage = affineClampFilter.outputImage else {
            return inputImage
        }

        guard let blurFilter = CIFilter(name: "CIGaussianBlur") else {
            return affineClampedImage
        }
        blurFilter.setValue(affineClampedImage, forKey: kCIInputImageKey)
        guard let blurredImage = blurFilter.outputImage else {
            return affineClampedImage
        }

        guard let cropFilter = CIFilter(name: "CICrop") else {
            return blurredImage
        }
        let originalRect = inputImage.extent
        cropFilter.setValue(blurredImage, forKey: kCIInputImageKey)
        cropFilter.setValue(originalRect, forKey: "inputRectangle")
        guard let croppedImage = cropFilter.outputImage else {
            return blurredImage
        }

        return croppedImage

    }

}

let basicImage = UIImage(named: "lena.png")
let view = UIImageView(image: basicImage)
PlaygroundPage.current.liveView = view

let filter = GaussianBlurFilter()
let filteredImage = filter.getFilteredImage(from: basicImage)

view.image = filteredImage

preview.png

よし、これでパーフェクトなぼかし画像が出来上がりました。

ところがちょっと待った。これ、ガウスぼかしだよ。ガウスぼかしってぼかしの具合が調節できるんだよ。しかしこのガウスぼかしフィルターじゃあそのぼかし具合ってのが調節できないじゃん。

というわけで最後もう一手間。この自作 GaussianBlurFilterradius: CGFloat のプロパティーも追加してあげましょう。

class GaussianBlurFilter: CustomCIFilter {

    var radius: CGFloat = 10

    override var outputImage: CIImage? {

        guard let inputImage = self.inputImage else {
            return nil
        }
        let radius = self.radius

        guard let affineClampFilter = CIFilter(name: "CIAffineClamp") else {
            return inputImage
        }
        let transform = CGAffineTransform(scaleX: 1, y: 1)
        affineClampFilter.setValue(inputImage, forKey: kCIInputImageKey)
        affineClampFilter.setValue(transform, forKey: kCIInputTransformKey)
        guard let affineClampedImage = affineClampFilter.outputImage else {
            return inputImage
        }

        guard let blurFilter = CIFilter(name: "CIGaussianBlur") else {
            return affineClampedImage
        }
        blurFilter.setValue(affineClampedImage, forKey: kCIInputImageKey)
        blurFilter.setValue(radius, forKey: kCIInputRadiusKey)
        guard let blurredImage = blurFilter.outputImage else {
            return affineClampedImage
        }

        guard let cropFilter = CIFilter(name: "CICrop") else {
            return blurredImage
        }
        let originalRect = inputImage.extent
        cropFilter.setValue(blurredImage, forKey: kCIInputImageKey)
        cropFilter.setValue(originalRect, forKey: "inputRectangle")
        guard let croppedImage = cropFilter.outputImage else {
            return blurredImage
        }

        return croppedImage

    }

}

let basicImage = UIImage(named: "lena.png")
let view = UIImageView(image: basicImage)
PlaygroundPage.current.liveView = view

let filter = GaussianBlurFilter()
filter.radius = 50
let filteredImage = filter.getFilteredImage(from: basicImage)

view.image = filteredImage

preview.png

これで、filter.radius を設定することで、ガウスぼかしのぼかし具合が簡単に設定できるようになります。そもそもどんなプロパティーが設定できるかもわかるようになる。便利。もう昔のように filter.setValue(50, forKey: kCIInputRadiusKey) とか見たいなわけわからんキーにバリューを設定しなくて済む。これで今すぐ使える GaussianBlurFilter が自作できました。

でも落ち着こう。もう一度よ〜く考えてみよう。そもそもこれらのフィルターってチェインに使うフィルターってのは最初から決まってあるわけですよ。だからこれらのフィルターを一回作って格納しておけばいいんじゃね?しかもアップルも最初から丁寧にフィルターのプロパティー初期化というかデフォルト値に戻すメソッドまで用意してあります。というわけで、サブクラス化用のクラスをこのように直します:

CustomCIFilter.swift
open class CustomCIFilter: CIFilter {

    public var inputImage: CIImage?

    open override func setDefaults() {
        self.inputImage = nil
    }

    override open var outputImage: CIImage? {

        return self.inputImage

    }


}
GaussianBlurFilter.swift
public class GaussianBlurFilter: CustomCIFilter {

    private let _affineClampFilter = CIFilter(name: "CIAffineClamp")
    private let _gaussianBlurFilter = CIFilter(name: "CIGaussianBlur")
    private let _cropFilter = CIFilter(name: "CICrop")

    public var radius: CGFloat = 10

    public override func setDefaults() {
        super.setDefaults()
        self._affineClampFilter?.setDefaults()
        self._gaussianBlurFilter?.setDefaults()
        self._cropFilter?.setDefaults()
    }

    public override var outputImage: CIImage? {

        guard let inputImage = self.inputImage else {
            return nil
        }

        guard let affineClampFilter = self._affineClampFilter else {
            return inputImage
        }
        let transform = CGAffineTransform(scaleX: 1, y: 1)
        affineClampFilter.setValue(inputImage, forKey: kCIInputImageKey)
        affineClampFilter.setValue(transform, forKey: kCIInputTransformKey)
        guard let affineClampedImage = affineClampFilter.outputImage else {
            return inputImage
        }

        guard let blurFilter = self._gaussianBlurFilter else {
            return affineClampedImage
        }
        let radius = self.radius
        blurFilter.setValue(affineClampedImage, forKey: kCIInputImageKey)
        blurFilter.setValue(radius, forKey: kCIInputRadiusKey)
        guard let blurredImage = blurFilter.outputImage else {
            return affineClampedImage
        }

        guard let cropFilter = self._cropFilter else {
            return blurredImage
        }
        let originalRect = inputImage.extent
        cropFilter.setValue(blurredImage, forKey: kCIInputImageKey)
        cropFilter.setValue(originalRect, forKey: "inputRectangle")
        guard let croppedImage = cropFilter.outputImage else {
            return blurredImage
        }

        return croppedImage

    }

}

これで、GaussianBlurFilter を一回インスタンス化したら、中の CIAffineClampCIGaussianBlurCICrop のフィルターがそのまま保持され、もし初期化したかったら setDefaults を呼び出せばいい。

ちなみにこれのフレームワーク版である Usami もただいま GitHub で絶賛公開中Usami の名前はもちろん「この美術部には問題がある!」の宇佐美さんから来ています