フォントファイルを使用して、『似た文字(類似文字)』の一覧を作る Python コード例です。
検索エンジンで、『見た目が似ている文字』を自動的に含めて検索するために書きました。
『似た文字』をリストアップする手順です。
- フォントファイルの収録文字を『画像』にします。自分は Pillow を使いました。
- 画像どうしの『類似度』を計算します。自分は NumPy を使いました。
- 類似度が高い順に並べて、上位を CSV ファイルに『保存』します。
これで、見た目が似ている文字を抽出して、保存することができました。
フォントファイルの収録文字を『画像』にする方法は、
に書きました。
ここでは、文字の画像から『似た文字』を列挙するところの Python コード例を紹介します。
ところで、類似文字列検索では、レーベンシュタイン距離のように、文字列の編集操作で類似度を決める方法がありました。
『文字の見た目』は全く使っていないのに、文字列の類似度を決めることができました。動作も高速でした。
そこに、文字の見た目を加味したら、『もっと良い検索ができるんじゃないか?』って思うんですよね。
文字を画像的に処理するのは、とても重い処理でしたが、事前に『似た文字の一覧』を作っておけば、きっと実用的に使えると思います。
そういったことも期待して、Python コードを書いてみました。
類似度の決め方は、ほかにも色々あると思います。
使用したフォントファイル
似た文字を調べるために使用した『フォントファイル』です。
(mplusfonts.github.io) M+ FONTS
コード例では、『mplus-1m-regular.ttf(固定幅)』を使用させていただきました。
自分が使用したときは、7761 個もの文字を使用することができました。
ありがとうございます。
Pillow マニュアルの場所
Pillow は、文字の PNG 画像をデコードするために使用しました。
Pillow でピクセルデータにデコードして、そこから NumPy 配列にする作戦です。
(Pillow) Windows Installation 『Pillow』のインストール方法。
(Pillow) PIL.Image.open(fp, mode='r', formats=None)
画像ファイルを開く。ファイルではなくて、バイナリーデータから読み込むときは、BytesIO() で作ったバッファを指定したらできました。
NumPy マニュアルの場所
(Numpy) numpy.array(object, dtype=None, *, copy=True, order='K', subok=False, ndmin=0)
PIL 画像から ndarray(NumPy 配列)を作るために使用。
(Numpy) class numpy.dtype(obj, align=False, copy=False)
NumPy 配列のデータ型。型は 'float32'
を使用しました。
(Numpy) ndarray.flatten(order='C')
2 次元の配列を 1 次元にする。
(Numpy) numpy.linalg.norm(x, ord=None, axis=None, keepdims=False)
2 乗和の平方根(ユークリッド距離)を計算するために使用。
Python マニュアルの場所
(Python) os.path.splitext(path)
ファイル名から拡張子を除去するために使用。
(Python) class int([x])
文字列で書いた数値を int 型に変更。
(Python) chr(i)
シーエイチアール関数(キャラクター関数)。Unicode コードポイントから『文字』を取得する。
(Python) str.strip([chars])
空白文字を削除するために使用。
(Python) string.whitespace
文字列定数。空白文字 (whitespace) として扱われる文字の一覧。(例)' \t\n\r\x0b\x0c'
(Python) len(s)
レン関数。
(Python) class range(stop)
レンジ関数(イミュータブルなシーケンス型)。
(Python) class tuple([iterable])
タプル (tuple) を作る。生成速度がリストよりもほんの少しだけ速かった。
(Python) ZipFile.namelist()
zip ファイルの中のファイル名を列挙する。
(Python) ZipFile.read(name, pwd=None)
zip ファイルの中のファイルを読み込む。
(Python) class io.BytesIO([initial_bytes])
バイツアイオー(バイツイオ)。バッファを用意する。PNG 画像のバイナリーデータを Pillow で読み込むときに使いました。
(Python) operator.itemgetter(item)
アイテムゲッター。.sort()
メソッドを少しでも高速化しようと思って使用しました。時間を計ったら、微々たる差でしたが、ラムダ式で key を指定した時よりも速かったです。お好みで。
(Python) operator
モジュール関数 itemgetter()
を使用すると、ソートがもっと簡単で高速になる旨の説明がありました。
(Python) list.sort(*, key=None, reverse=False)
リストの中身を並べ替える。
(Python) csv.writer(csvfile, dialect='excel', **fmtparams)
シーエスブイライター。
(Python) csvwriter.writerow(row)
CSV に 1 行を書き込む。
(Python) class pathlib.Path(*pathsegments)
パスオブジェクトを作成。
(Python) PurePath.joinpath(*other)
パスを連結する。
(Python) Path.is_dir()
フォルダの存在確認。
(Python) Path.mkdir(mode=0o777, parents=False, exist_ok=False)
フォルダを作る。
(Python) Path.open(mode='r', buffering=-1, encoding=None, errors=None, newline=None)
ファイルを開く。
(Python) フォーマット済み文字列リテラル (f-string) エフ f
から始まる文字列 f''
は『エフストリング (f-string)』。
(Python) str.format(*args, **kwargs)
フォーマットメソッド。f''
だと1行が長くて書きにくい時に使用しました。
(Python) 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』に変更したときのスクリーンショットです。
『同じデザイン』だけど『違う文字』が複数あった
実際に『似た文字』を列挙してみて分かったのですが、『同じデザイン』だけど『違う文字』というのが、結構ありました。
差分画像の二乗和平方根がゼロだったので、画像としては完全一致でした。
そういった文字は、機械的に『似た文字リスト』に追加しても良さそうですね。
きっと、諸般の事情で、異なるコードポイントが割り当てられたんだと思います。
あとは、ぜんぜん似ていなかった文字を排除したいところです。
『余白あり』の文字画像と、『余白無し』の結果を両方使えば、もう少し妥当な『似た文字リスト』が作れると思うんですよね。
以上です。