cockscomblog?

cockscomb on hatena blog

紛らわしい文字列をもっと紛らわしくする

同僚にid:yashigani_wヤシガニ)とid:yigarashi(ワイイガラシ)がいる。いかにも似ているが、あるとき一緒に仕事をすることになって、紛らわしさが限界を超えた。

yashigani
yigarashi

そうとわかって見たら、なるほど「y」で始まって「i」で終わるのが似ているね、くらいに思うかもしれない。しかし不意にSlackやGitHubで見かけると、一瞬どちらかわからない。

この紛らわしさをより俯瞰してみるため、画像にしてみました。

f:id:cockscomb:20200101161439j:plain
Photoshopで作った

どうだ紛らわしいだろう。全体的な形がなんだか似ている。

ところでこの画像はPhotoshopで作った。テキストにガウシアンぼかしをかけて、その後から二値化した。なかなかかっこいいから、もっといろいろ試してみたい。

前置きが長くなったが、テキストにガウシアンぼかしをかけて、その後から二値化 するのを、macOSiOSの仕組みでやってみるのが、本題である。

Core Textでテキストの画像を作る

まずはテキストを画像化しないと始まらない。テキストの画像を作るのは、Core Text(とCore Graphics)で素朴に実装できる。

import CoreGraphics
import CoreText
import Foundation

struct Padding {
    let top: CGFloat
    let left: CGFloat
    let bottom: CGFloat
    let right: CGFloat

    static var zero: Padding {
        return Padding(top: 0, left: 0, bottom: 0, right: 0)
    }
}

func render(text: NSAttributedString, padding: Padding = .zero) -> CGImage {
    let framesetter = CTFramesetterCreateWithAttributedString(text)
    let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(
        framesetter,
        CFRange(),
        nil,
        CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
        nil)

    let width = Int(ceil(frameSize.width + padding.left + padding.right))
    let height = Int(ceil(frameSize.height + padding.top + padding.bottom))

    let ctx = CGContext(
        data: nil,
        width: width,
        height: height,
        bitsPerComponent: 8,
        bytesPerRow: width * 4,
        space: CGColorSpaceCreateDeviceRGB(),
        bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
    guard let context = ctx else {
        fatalError()
    }

    context.saveGState()
    context.setFillColor(.white)
    context.fill(CGRect(origin: CGPoint.zero, size: CGSize(width: width, height: height)))
    context.restoreGState()

    let path = CGPath(
        rect: CGRect(
            origin: CGPoint(
                x: (CGFloat(width) - frameSize.width) / 2,
                y: (CGFloat(height) - frameSize.height) / 2),
            size: frameSize),
        transform: nil)
    let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)
    CTFrameDraw(frame, context)

    guard let image = context.makeImage() else {
        fatalError()
    }
    return image
}

NSAttributedStringからCore TextのAPIを使ってCGImageを作る。このときpaddingを設定できるようにすると、あとで都合がいい。

Core Imageで画像を加工する

画像の加工ではいつものように、Core Imageを使うことを検討する。以前CoreImage.CIFilterBuiltinsで試したように、CIFilter.gaussianBlur()を使えば、ガウシアンぼかしは簡単だ。あとは二値化するだけだが、ここで手が止まる。意外なことに、組み込みのフィルターには二値化が存在しない。

Metal Shading Languageによるカスタムカーネル

二値化は簡単そうなので手作りする。Core ImageカーネルをMetal Shading Languageで書く。ThresholdBinary.metalを以下の内容で作った。またXcodeのBuild Settingsで、Other Metal Compiler Flags-fcikernelを、User-Defined Settingsに追加したMTLLINKER_FLAGSキーに-cikernelを、それぞれ設定する必要がある。

#include <metal_stdlib>
#include <CoreImage/CoreImage.h>
using namespace metal;

extern "C" {
    namespace coreimage {
        float4 threshold_binary(sample_t source, float threshold) {
            float4 image = premultiply(source);
            float y = 0.299 * image.r + 0.587 * image.g + 0.114 * image.b; // BT.601
            float binary = y < threshold ? 0.0 : 1.0;
            return float4(binary, binary, binary, 1.0);
        }
    }
}

RGB値から輝度成分を取り出すために、YUV色空間のYを計算する。これが閾値を超えていれば1、そうでなければ0とすることで、二値化できる。

このカーネルは以下のように読み込める。

import CoreImage

let thresholdBinaryKernel: CIColorKernel = {
    guard
        let url = Bundle.main.url(forResource: "default", withExtension: "metallib"),
        let data = try? Data(contentsOf: url)
    else {
        fatalError("Unable to get metallib")
    }
    guard
        let kernel = try? CIColorKernel(
            functionName: "threshold_binary", fromMetalLibraryData: data)
    else {
        fatalError("Unable to create CIKernel from threshold_binary")
    }
    return kernel
}()

Core Imageによる画像の加工

二値化のカーネルが書けたので、あとは実際に画像を加工するだけだ。

import CoreImage
import CoreImage.CIFilterBuiltins

let ciContext: CIContext = CIContext()
func process(image: CGImage, sigma: Float, threshold: Float) -> CGImage? {
    let ciImage = CIImage(cgImage: image)

    let gaussianBlur = CIFilter.gaussianBlur()
    gaussianBlur.inputImage = ciImage.clampedToExtent()
    gaussianBlur.radius = sigma
    guard let blurredImage = gaussianBlur.outputImage else {
        return nil
    }

    guard
        let processedImage = thresholdBinaryKernel.apply(
            extent: ciImage.extent, arguments: [blurredImage, threshold]
        )
    else {
        return nil
    }

    guard
        let cgImage = ciContext.createCGImage(
            processedImage, from: processedImage.extent)
    else {
        fatalError()
    }
    return cgImage
}

CIImage.clampedToExtent()を使うのがコツで、ガウシアンぼかしをかけるときに画像の端が変な色になってしまうのを防げる。

どうですか

ここまでで、テキストにガウシアンぼかしをかけて、その後から二値化 ができた。iOSでもmacOSでも同じように動くはずだ。

f:id:cockscomb:20200106103459p:plain

なんだかちょっとかっこよくないですか。

参考文献

Metalは初めて使うので、ちょっとだけ勉強しました。