ひらがなと常用漢字を用いて画像を文字アートに変えちゃうアプリを作った話

# はじめに

これは今から約2年前の話。大学院でのとある授業の最終課題。お題は、論文を複数読んで比較したレポートを書くか、アプリまたはサービスを作成してそのリリースレポートをまとめるかの二択でした。ほとんどの生徒が大人しく論文レポートを選択する中、アプリをリリースする方が楽じゃないか？などと考える生徒がそこにいました。

当時のマイブームは**ドット絵**を描くことでした。また、画像にフィルタをかけて遊ぶのにもハマっていました。そんな中、フォント警察の人のLTをたまたま見て思い至ったことが一つ。**「文字って情報量の多いドットとして扱えるんじゃないか？」**と。そして、画像を文字列にコンバートすれば、少ないデータ量でも割と鮮明に読み取れる絵を生成することができるのではないかということを思いつきました。

上の二つの出来事がちょうど同時期に起きた結果、一つのアプリが生まれました。

# 成果物：MojiPhoto

![movie2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/20094ce2-90dc-454f-f762-ea525c804ec1.gif)   ![image2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/1212653c-2c0d-ee3f-d8ab-2953a38cf344.gif)

- カメラによる動画撮影内容をリアルタイムで文字アートに変換して表示したり、アルバムの写真を文字アートに変換したりできます。
- リアルタイムでの表示は解像度低めですが、静止画に関しては5段階の解像度で調節できます。
- 気に入った文字アートを文字列としてクリップボードにコピーしたり、静止画としてアルバムに保存したりできます。

# 原理とか実装とか

## ドットとフォントの関係性について

画面上において、文字はドットの集まりです。つまり、ある文字がそこに在るというデータあればフォント情報を通してテキストフィールドなどにドットが描かれます。当たり前ですが、これはすごいことだと個人的に思います。例えば、「あ」という文字を表示しようと思った時、それを画像として取得して表示しようとした場合は数100Byte~数KBほどになりますが、文字データとして取得してフォントという衣を与え、テキストフィールドという舞台を与えて舞わせれば、たった数Byteで描画できることになります。

例:
- あ (UTF-8): 3 Byte
- あ (png, 14×17px): 2184 Byte 

![14x17px.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/d251e535-9043-bedb-f047-1b05c1452912.png)

「いやいや、フォントとテキストフィールドはズルでしょ。」というのはごもっともですが、OSが用意してくれているものをありがたく使うことで、同様のものを表すのであってもデータ量的に節約できる可能性があるということを言いたいのです。

## フォントの重心と濃度

上記の発想から、あるフォントにおける文字の重心と濃度のデータを予め持っておけば、モノクロ画像をものすごく圧縮できるのでは？という考えに至りました。そこで、特定のサイズに固定したシステムフォントにおける重心と濃度を求めることにしました。ここでいう重心とは、文字の線（黒塗り部分）の重心のことで、濃度とは文字領域に対して黒塗り部分の占める面積のことです。

例えば、iOSのシステムフォントでサイズを25に固定した場合、テキストフィールドに占める文字領域のサイズは24×29pxとなり、重心はだいたい（11,15）になります。また、文字領域が真っ白だったら0、真っ黒だったら1になるように濃度を求めると、0.32となります
![a2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/ad92158f-e738-888f-0c36-ecdaeda69f8d.png)
（オレンジの点が重心）

今回作成したアプリでは、まず、ひらがなと常用漢字を合わせた2209文字に対して、重心と濃度を算出してリストアップしました。続いて、ほぼ重心と濃度が同じものは省くという処理を行い、最終的に433文字の精鋭たちを利用することにしました。

「あ」について重心と濃度を算出するコードはこんな感じでした。

```swift
import Cocoa

func drawText(text: String, font: NSFont) -> NSImage {
    let mojiphoto = NSString(string: text)
    let size = mojiphoto.size(withAttributes: [NSAttributedString.Key.font: font])
    let image = NSImage(size: size)
    let textFontAttributes: [NSAttributedString.Key: Any] = [
        .font: font,
        .foregroundColor: NSColor.black,
        .backgroundColor: NSColor.clear
    ]
    image.lockFocus()
    mojiphoto.draw(at: .zero, withAttributes: textFontAttributes)
    image.unlockFocus()
    return image
}

func convert(nsImage: NSImage) -> CGImage? {
    guard let imageData = nsImage.tiffRepresentation else { return nil }
    guard let sourceData = CGImageSourceCreateWithData(imageData as CFData, nil) else { return nil }
    return CGImageSourceCreateImageAtIndex(sourceData, 0, nil)
}

func extractBalance(cgImage: CGImage) -> (CGPoint, CGFloat) {
    let w = CGFloat(cgImage.width)
    let h = CGFloat(cgImage.height)
    var denominator: CGFloat = 0.0
    var numeratorX: CGFloat = 0.0
    var numeratorY: CGFloat = 0.0
    if let provider = cgImage.dataProvider, let pixelData = provider.data {
        let data: UnsafePointer = CFDataGetBytePtr(pixelData)
        for y in (0 ..< cgImage.height) {
            for x in (0 ..< cgImage.width) {
                let pixel: Int = 4 * (x + (cgImage.width * y)) + 3
                if data[pixel] > 0 {
                    denominator += 1.0
                    numeratorX += CGFloat(x)
                    numeratorY += CGFloat(y)
                }
            }
        }
        return (CGPoint(x: round(100 * (numeratorX / denominator) / w) / 100,
                        y: round(100 * (numeratorY / denominator) / h) / 100),
                round(100 * denominator / CGFloat(w * h)) / 100)
    }
    return (CGPoint(x: -1.0, y: -1.0), 0.0)
}

func save(image: NSImage) {
    guard let dec = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first else {
        return
    }
    guard let rep = image.tiffRepresentation,
        let bitRep = NSBitmapImageRep(data: rep),
        let data = bitRep.representation(using: .png, properties: [:])
        else { return }
    try? data.write(to: dec.appendingPathComponent("a.png"))
}

let imageA = drawText(text: "あ", font: NSFont.systemFont(ofSize: 25))
if let cgImage = convert(nsImage: imageA) {
    let balance = extractBalance(cgImage: cgImage)
    Swift.print(balance.0.x, balance.0.y, balance.1)
}
save(image: imageA)
```

## 文字アート変換エンジン

画像を文字アートに変換するエンジンの仕組みは非常にシンプルです。

1. オリジナルの画像をモノクロにする
2. モノクロ画像を格子状に切り刻んで小さな画像の集合にする
3. 小さな画像一つ一つについて、濃度とその重心を求める
4. 文字のリストの中から濃度と重心が最も近いものを選択する
5. 選択された文字群から文字列を生成する


## ドットで置き換える場合とフォントで置き換える場合の違い

![hoge1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/2395b869-bbc3-353d-e85a-5793e811d8d6.png)
この画像を24×29pxのドット画像に置き換えると下のようになります。
![hoge2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/6b7cc5f0-8417-50d3-8522-e762b3c79d5a.png)
そして、私の作った`MojiPhoto エンジン`によると「助」で置き換えられるようです。
![a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/c3fc2a45-21b7-9b8e-8ca0-b1d84744c73c.png)

## え? これでいいの？

一見全然ダメに見えるのですが、これには明確な原因があります。まず、リソースがひらがなと常用漢字を合わせた2209文字なので、濃度についてはそこそこいけるのですが、重心位置についてはあまり網羅できていないのです。ただ、「助」くんは2209文字の中では、最もオリジナルの画像に近いはずなのです。ミクロで見るとダメに見えますが、マクロで、画像全体で見れば少しはマシになっていると思います。

逆に言えば、リソースをUTF-8の全範囲にまで広げればもっといい感じになる可能性があります。ただ、MojiPhotoでは、全角文字を使いたかったので、確実に全角であるひらがなと漢字を対象にしています。

## 濃度

とりあえず、濃度で文字たちの代表をマッピングするとこんな感じになります。当たり前ですが、濃度の濃い文字の方が画数の多い漢字になっていますね。

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| 一 | 亡 | れ | 壮 | 訃 | 帆 | 岬 | 酬 | 霧 |

# 圧縮性能（？)

文字アートにした場合と、文字アートの行数×列数と同じサイズのドット化した場合のファイルサイズを比較してみます。

## オリジナル：（PNG、500×667px、529KB）

![original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/64b8c939-9169-cc70-ac2e-917c140ec751.png)

## モノクロ：（PNG、500×667px、332KB）

![mono.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/56cb5a05-a6b5-e08f-11b2-2982a5265434.png)

## 文字アート：（プレーンテキスト、64×68 文字、13KB）

![moji.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/41765093-f5f1-bfc8-4a4b-f241f74fdddd.png)

## ドット化：（PNG、64×68px、11KB）

![dot1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/50acde2a-67e9-d610-a105-0651d1c7e38d.png)

なんと、単純なドット絵と比較すると、データ量では負けてしまうことがわかりました。悲しい。ただ、ここで負けたとするのは早計かもしれません。実際に読み取れる絵の美しさの違いに注目してみるとどうでしょうか。文字アートのほうがちょっと綺麗じゃないですか？？、フォントや背景色を調節すればもっと綺麗に見えるかもしれません。

# おわりに

今回は主にドットとフォントについて主に書きましたが、他にも動画撮影時のリアルタイム変換描画のための軽量化の話や、非同期処理の話などもあるので、別途記事をいつか書きたいと思います。また、まだチャレンジできていませんが、フォントの選定やリソースとする文字の増加による文字アートの美しさ向上についていつか報告できたらいいなぁと思っています。

# 余談
冒頭で授業の課題がきっかけで作ったことを書きましたね。アプリのリリース自体は課題に間に合ったのですが、ユーザ評価がつかず、これについての考察をまとめることができなかったため、レポートの評価はまずまずだったようです。トホホ...