【Python】フォントから『類似文字』の一覧を作成するコード例

フォントファイルを使用して、『似た文字(類似文字)』の一覧を作る Python コード例です。

検索エンジンで、『見た目が似ている文字』を自動的に含めて検索するために書きました。

『似た文字』をリストアップする手順です。

  1. フォントファイルの収録文字を『画像』にします。自分は Pillow を使いました。
  2. 画像どうしの『類似度』を計算します。自分は NumPy を使いました。
  3. 類似度が高い順に並べて、上位を CSV ファイルに『保存』します。

これで、見た目が似ている文字を抽出して、保存することができました。

フォントファイルの収録文字を『画像』にする方法は、

に書きました。

ここでは、文字の画像から『似た文字』を列挙れっきょするところの Python コード例を紹介します。

ところで、類似文字列検索るいじもじれつけんさくでは、レーベンシュタイン距離のように、文字列の編集操作で類似度を決める方法がありました。

(Wikipedia) レーベンシュタイン距離

『文字の見た目』は全く使っていないのに、文字列の類似度を決めることができました。動作も高速でした。

そこに、文字の見た目を加味したら、『もっと良い検索ができるんじゃないか?』って思うんですよね。

文字を画像的に処理するのは、とても重い処理でしたが、事前に『似た文字の一覧』を作っておけば、きっと実用的に使えると思います。

そういったことも期待して、Python コードを書いてみました。

類似度の決め方は、ほかにも色々あると思います。

使用したフォントファイル

似た文字を調べるために使用した『フォントファイル』です。

M+ FONTS | JAPANESE

コード例では、『mplus-1m-regular.ttf(固定幅)』を使用させていただきました。

自分が使用したときは、7761 個もの文字を使用することができました。

ありがとうございます。

Pillow マニュアルの場所

Pillowピロー は、文字の PNG 画像をデコードするために使用しました。

Pillow でピクセルデータにデコードして、そこから NumPy 配列にする作戦です。

(Pillow) Windows Installation 『Pillow』のインストール方法。

PIL.Image.open(fp, mode='r', formats=None) 画像ファイルを開く。ファイルではなくて、バイナリーデータから読み込むときは、BytesIO() で作ったバッファを指定したらできました。

NumPy マニュアルの場所

numpy.array(object, dtype=None, *, copy=True, order='K', subok=False, ndmin=0) PIL 画像から ndarray(NumPy 配列)を作るために使用。

class numpy.dtype(obj, align=False, copy=False) NumPy 配列のデータ型。型は 'float32' を使用しました。

ndarray.flatten(order='C') 2 次元の配列を 1 次元にする。

numpy.linalg.norm(x, ord=None, axis=None, keepdims=False) 2 乗和の平方根(ユークリッド距離)を計算するために使用。

Python マニュアルの場所

os.path.splitext(path) ファイル名から拡張子を除去するために使用。

class int([x]) 文字列で書いた数値を int 型に変更。

chr(i) シーエイチアール関数(キャラクター関数)。Unicode コードポイントから『文字』を取得する。

str.strip([chars]) 空白文字を削除するために使用。

string.whitespace 文字列定数。空白文字 (whitespace) として扱われる文字の一覧。(例)' \t\n\r\x0b\x0c'

len(s) レン関数。

class range(stop) レンジ関数(イミュータブルなシーケンス型)。

class tuple([iterable]) タプル (tuple) を作る。生成速度がリストよりもほんの少しだけ速かった。

class zipfile.ZipFile(file, mode='r', compression=ZIP_STORED, allowZip64=True, compresslevel=None, *, strict_timestamps=True) zip ファイルを開く。

ZipFile.namelist() zip ファイルの中のファイル名を列挙する。

ZipFile.read(name, pwd=None) zip ファイルの中のファイルを読み込む。

class io.BytesIO([initial_bytes]) バイツアイオー(バイツイオ)。バッファを用意する。PNG 画像のバイナリーデータを Pillow で読み込むときに使いました。

operator.itemgetter(item) アイテムゲッター。.sort() メソッドを少しでも高速化しようと思って使用しました。時間を計ったら、微々たる差でしたが、ラムダ式で key を指定した時よりも速かったです。お好みで。

operator モジュール関数 itemgetter() を使用すると、ソートがもっと簡単で高速になるむねの説明がありました。

list.sort(*, key=None, reverse=False) リストの中身を並べ替える。

csv.writer(csvfile, dialect='excel', **fmtparams) シーエスブイライター。

csvwriter.writerow(row) CSV に 1 行を書き込む。

class pathlib.Path(*pathsegments) パスオブジェクトを作成。

PurePath.joinpath(*other) パスを連結する。

Path.is_dir() フォルダの存在確認。

Path.mkdir(mode=0o777, parents=False, exist_ok=False) フォルダを作る。

Path.open(mode='r', buffering=-1, encoding=None, errors=None, newline=None) ファイルを開く。

(f-string) フォーマット済み文字列リテラル エフ f から始まる文字列 f'' は『エフストリング (f-string)』。

str.format(*args, **kwargs) フォーマットメソッド。f'' だと1行が長くて書きにくい時に使用しました。

assert アサートぶん。デバッグ用。『当然に成り立つ条件』を入れておいて、違ったら AssertionError の例外で教えてくれる機能。

コード例

1つ1つの文字について、『似た文字(類似文字るいじもじ)』の一覧を、CSV に保存する Python コード例です。

画像的に『似た文字』を調べるのは、とても重い処理でした。

1文字わずか 100 x 100 ピクセルの画像でも、『1 万ピクセル』になりましたし、そのうえ、7761 文字から2文字を取り出して比較する組み合わせは、『3000 万以上』にもなりました。

NumPy の 'float64' で2時間くらいかかって、'float32' だと12分くらいかかりました。

CPU は Intel Core i5-9500 でした。

"""
文字の画像どうしを比較して、
似た文字の一覧を CSV に保存する Python コード例です。
calc_similarity.py
"""
import csv
import numpy as np

def main():
    import datetime
    from pathlib import Path

    print('start')
    t1 = datetime.datetime.now()

    print('(1/4) zip ファイルから『文字画像』を読み込みます。')
    zip_file = Path(r'F:\project\font_data\png.zip')
    character_datas = get_character_datas(zip_file)
    print(f'文字の数: {len(character_datas)} 個')

    print('(2/4) 文字どうしの『類似度 (るいじど)』を計算します。')
    results = calc_similarities(character_datas)

    print('(3/4) 結果を CSV に保存します (一覧)。')
    data_dir = Path(r'F:\project\font_data\similarity')
    if not data_dir.is_dir():
        data_dir.mkdir()
    results_csv = data_dir.joinpath('similar_characters.csv')
    save_to_csv_summary(results_csv, results)

    print('(4/4) 結果を CSV ファイル群に保存します (全部の文字)。')
    save_to_csv_all(data_dir, results)

    elapsed_time = (datetime.datetime.now() - t1).total_seconds()
    print(f'{elapsed_time:.1f} 秒かかりました。')
    print('end')

    # 以上です。
    return


def get_character_datas(zip_file):
    """(1/4) zip ファイルから『文字画像』を読み込みます。"""
    from zipfile import ZipFile
    from os.path import splitext
    from io import BytesIO
    from PIL import Image # (Pillow ライブラリ)

    datas = []

    # zip ファイルを開きます。
    with ZipFile(zip_file, 'r') as z:

        # ファイル名を列挙します。
        for name in z.namelist():

            # 自分は、文字画像のファイル名に
            # 『Unicode コードポイント』の番号を使っていました。
            # なので、拡張子を除去して、その番号を取得します。
            codepoint = int(splitext(name)[0])

            # zip から、画像のバイナリーデータを取得します。
            image_data = z.read(name)

            # それを『バイナリーストリーム』として開きます。
            # (注意) buffer は、im を使い終わるまで
            # 開いておきます。
            # with を抜けた後で im を使用したら、
            # 『ValueError: I/O operation on closed file.』
            # という例外が発生しました。
            # なので、Image.open() のあとに im.convert() などの
            # 画像処理を実行するか、np.array(im) でデータを
            # 実際に取り出すまでは、buffer を開いておきます。
            with BytesIO(image_data) as buffer:

                # Pillow で PNG 画像を開きます。
                # これで、PNG 画像のデコードができました。
                im = Image.open(buffer, 'r')

                # 画像を NumPy 配列に変換します。
                # (2 次元配列)
                data = np.array(im, dtype='float32')

                # 2 次元配列のままだと、
                # 類似度の計算がしづらいので、
                # 普通の『1 次元配列』に変換します。
                data = data.flatten()

            # コードポイントと画像データをタプルにして、
            # リストに追加します。
            # [0] Unicode コードポイントの番号。
            # [1] 文字の画像データ (1 次元配列)。
            datas.append((codepoint, data))
    return datas


def calc_similarities(datas):
    """
    (2/4) 文字どうしの『類似度 (るいじど)』を計算します。
    自分は試しに、差分画像から
    『二乗和の平方根 (にじょうわのへいほうこん)』を計算して、
    それを『類似度 (similarity)』と考えてみました。
    二乗和の平方根は、『ユークリッド距離 (euclidean distance)』
    とも言うようでした。
    """
    # (準備) 類似度をソートするときに、
    # リストから [1] distance を取得する関数を作っておきます。
    # .sort(key=lambda x: x[1]) よりも、アイテムゲッターの
    # .sort(key=get_distance) のほうが
    # 少しだけ速くなることを期待して用意しました。
    from operator import itemgetter
    get_distance = itemgetter(1)

    # 文字を 1 つずつ列挙して、
    # 『自身』と『他 (ほか)』との
    # 類似度を計算していきます。

    results = []
    for i in range(len(datas)):
        # リストから『自身』の文字を取得します。
        (self_codepoint, self_data) = datas[i]

        # 『自身』と『他 (ほか)』とでペアを作って、
        # 1ペアずつ、差分画像の『二乗和平方根』を
        # 計算していきます。
        is_skipped = False
        distances = []
        for index in range(len(datas)):
            # 『他 (ほか)』の文字を 1 つ取得します。
            (other_codepoint, other_data) = datas[index]

            # 同じ文字はスキップします。
            if other_codepoint == self_codepoint:
                is_skipped = True
                continue

            # 差分画像を計算します。
            diff = self_data - other_data

            # 『二乗和平方根』を計算します。
            distance = np.linalg.norm(diff)

            # 結果をリストに追加します。
            distances.append((other_codepoint, distance))

        # (デバッグ) 同じ文字はスキップしたはず。
        assert is_skipped is True

        # distance の昇順 (しょうじゅん) でソートします。
        # これで、類似度が高い順に並びました。
        distances.sort(key=get_distance)

        # 結果をリストに追加します。
        results.append((self_codepoint, distances))

        # (デバッグ) 進捗表示を兼ねて、一番類似度が高かった文字を
        # 表示してみました。
        # .strip() は、制御文字などを消すために使いました。
        # (文字化け)
        # 一部の文字は、表示が中点 '・' になってしまいました。
        # cmd や powershell 上での文字化けは、
        # それぞれのコンソールのプロパティーで、
        # 類似度判定に使用した時のフォントを選ぶと直りました。
        # (例) 『MS ゴシック』 ⇒ 『M+ 1m』
        # フォントは Windows にインストールしておく必要がありました。
        # コードページは 932 (ANSI/OEM - 日本語 Shift-JIS) のままで
        # 大丈夫でした。
        print("{i} {code} '{char}' '{d_char}' {dist} {d_code}".format(
            i=i,
            code=self_codepoint,
            char=chr(self_codepoint).strip(),
            d_char=chr(distances[0][0]).strip(),
            dist=distances[0][1],
            d_code=distances[0][0],
        ))
    return results


def save_to_csv_summary(file, src_datas):
    """(3/4) 結果を CSV に保存します (一覧)。"""
    head = ('codepoint', 'character', 'similar characters')
    with file.open('w', encoding='utf-8', newline='') as f:
        w = csv.writer(f)
        w.writerow(head)

        for (codepoint, similar_chars) in src_datas:
            # Unicode コードポイントを、普通の 1 文字に変換します。
            character = chr(codepoint)

            if len(character.strip()) == 0:
                # CSV ファイルでは、制御文字を含めると
                # 表示が崩れてしまったので、
                # 半角空白に変換しておきます。
                character = ' '

            # 似ている文字の上位 30 位を取得してみます。
            xs = []
            for (x_codepoint, _x_distance) in similar_chars[:30]:
                x_character = chr(x_codepoint)

                if len(x_character.strip()) == 0:
                    x_character = ' '

                xs.append(x_character.strip())

            # 文字を連結します。
            text_similar_chars = ''.join(xs)

            # CSV に書き込みます。
            w.writerow((codepoint, character, text_similar_chars))
    return


def save_to_csv_all(data_dir, src_datas):
    """(4/4) 結果を CSV ファイル群に保存します (全部の文字)。"""
    head = ('codepoint', 'character', 'distance')
    for (codepoint, distances) in src_datas:
        # 文字の数だけ CSV ファイルに保存します。
        # (数千ファイルになりました)
        file = data_dir.joinpath(f'{codepoint}.csv')
        with file.open('w', encoding='utf-8', newline='') as f:
            w = csv.writer(f)
            w.writerow(head)
            for (codepoint, distance) in distances:
                character = chr(codepoint).strip()
                w.writerow((codepoint, character, distance))
    return

if __name__ == '__main__':
    main()

実行結果

フォントの文字画像から、『似た文字(類似文字)』をリストアップした結果です。

文字の画像は、文字の余白を保持したものを使用しました。

コマンドプロンプトの表示

プログラムを実行したときの『コマンドプロンプト』の表示です。

7761 文字の解析に、744.8 秒(約 12 分)かかっていました。

もし、Google Noto Fonts のように、数万文字を収録したフォントを使う場合は、もっと工夫しないと、計算が終わらないかもしれませんね。

start
(1/4) zip ファイルから『文字画像』を読み込みます。
文字の数: 7761 個
(2/4) 文字どうしの『類似度 (るいじど)』を計算します。
0 32 '' '' 0.0 160
1 33 '!' 'ǃ' 0.0 451
2 34 '"' 'ˮ' 0.0 750
3 35 '#' 'ỻ' 29.137603759765625 7931
4 36 '$' 'S' 26.925823211669922 83
5 37 '%' 'ƚ' 28.1069393157959 410
6 38 '&' '8' 29.017236709594727 56
(中略)
7758 159449 '𦻙' '萠' 43.416587829589844 33824
7759 163767 '𧾷' '⻊' 0.0 11978
7760 983040 '󰀀' '英' 10.488088607788086 33521
(3/4) 結果を CSV に保存します (一覧)。
(4/4) 結果を CSV ファイル群に保存します (全部の文字)。
744.8 秒かかりました。
end

似た文字の一覧

すべての文字について、『似ていた文字』の上位 30 文字を取得した一覧です。

全部は載せられなかったので、7761 文字の中の一部を紹介します。

差分画像の2乗和平方根を計算しただけでしたが、自分としては、良い結果が得られたと思いました。

もちろん、フォントのデザインによって、結果は大きく変わってくると思います。

ファイル名:similar_characters.csv

LibreOffice で開いて、表示フォントも『M+ 1m』に変更したときのスクリーンショットです。

『ぬ』と似た文字の一覧

試しに、『ぬ (codepoint: 12396, 0x306C)』と似た文字の一覧を開いてみました。

ファイル名:12396.csv

LibreOffice で開いて、表示フォントも『M+ 1m』に変更したときのスクリーンショットです。

『同じデザイン』だけど『違う文字』が複数あった

実際に『似た文字』を列挙してみて分かったのですが、『同じデザイン』だけど『違う文字』というのが、結構ありました。

差分画像の二乗和平方根がゼロだったので、画像としては完全一致でした。

そういった文字は、機械的に『似た文字リスト』に追加しても良さそうですね。

きっと、諸般の事情で、異なるコードポイントが割り当てられたんだと思います。

あとは、ぜんぜん似ていなかった文字を排除したいところです。

『余白あり』の文字画像と、『余白無し』の結果を両方使えば、もう少し妥当な『似た文字リスト』が作れると思うんですよね。

以上です。

タイトルとURLをコピーしました