SPARKCREATIVE Tech Blog

https://www.spark-creative.jp/

アドベンチャーゲームに特化した日本語OCRを作ってみた その2

こんにちは!!!クライアントエンジニアの小林です。

今回は以前に投稿した内容アドベンチャーゲームに特化した日本語文字認識の続編です。

作業環境

windows 10
visual studio code
python 3.9.12
・pytorch
・pytorch lightning
DRAM 64 GB
・VRAM 24 GB

概要

アドベンチャー、ノベルゲームなどの文章を主体としたゲームに特化した日本語文字認識です。

前回投稿したのが4、5ヶ月ほど前でしたね。
元々は勉強目的で作成していましたが、現在はアドベンチャーゲームの音源を元に音声コーパスを作成するPythonツールに組み込んでひっそりと運用をしていたりします。

運用をしていく中で更に精度を上げたいなと思ったり、学習やチューニングフローが複雑なので、簡易的にしたいなと思ったり、色々と欲が出てきました。

そんな欲をちまちまメモして、ちまちま改修をしていたら、いつの間にか従来のフローから割と変わってしまったので、一旦ブログでREADME化して整理しようという魂胆でございます。

リポジトリ

実装の全てを載せている訳ではないので参考程度です。
最終目標のTTSまで終わったら改めて個人のgit垢で再公開を予定していますが、あと数年は掛かるでしょう。

機能ごとにブランチを作成するところまでは前回同様で、異なる点は、そこにmasterを作成してそのmasterブランチにコードを突っ込んでいます。

public版においてはmasterブランチ以外を追加する予定が無いので利点も無いですが、private版ではここに開発中のブランチを伸ばせるので試作したモデルの管理がかなり楽になりました。

ブランチ名 内容
master 空っぽ
resources/master 公開できる範囲のリソースファイル
utils/master 汎用ライブラリ
craft/master 文字領域検出
char-seg/master 文字ピクセル抽出
coatnet/master 文字分類
distil-bert/master 類似文字推定
adventure-game-ocr/master 日本語文字認識

筆者のイチオシポイントはutilsという汎用ライブラリブランチをサブモジュールとしてそれぞれに追加、運用している点です。

型定義やデータセット生成関数などの共通機能をバージョン管理しつつ足並みを揃えて更新できるという我ながらにいい運用です。

実務では同じリポジトリの異なるブランチをサブモジュールで追加なんて見たことないので、正統派なやり方ではないんでしょうね。でも筆者はこれで作業が捗るのでいいのです。

文字領域の検出(CRAFT)

文字に被せる様に楕円図形をプロットしたセグメンテーションマップを生成、生成されたマップをラベリングすることでバウンディングボックスを算出している文字領域検出モデルです。

Character Region Awareness for Text Detection

Character Region Awareness for Text DetectionことCRAFTさん。
文字領域検出タスクで結構なスコアを出しているモデルです。

アドベンチャーゲームでは基本的に文字列の連続性が担保されているため、接続部の強度補正に当たるAffinityMapは生成せずにRegionMapのみで運用しています。

上から入力画像、出力されたRegionMap、RegionMapにカラーマップを適用したもの、RegionMapを元にバウンディングボックスを描いたものです。

入力画像
出力画像
出力画像(カラーマップ適用)
出力画像をラベリングしてバウンディングボックスを算出

単語と文章の生成方法を変更

以前は単語をWordNet、文章を実際のゲームの台詞を記録したテキストファイルを用いていましたが、それらを廃止、単語と文章共に適当な文字を任意の長さで結合する形に変更しました。

我々人間からしたら単語や文章には明確な違いがありますが、CRAFTさんからしたら長いか短いかぐらいの違いしかないわけでした。

単語
文章

以前に比べて網羅的にプロットすることになり、こっそり問題になっていた、低頻出な記号が文字領域として検出されない問題が改善されました。

実例として以下の画像では、記号のひし形が検出されないことがありましたが、本改修により解決しました。

♪は使いどころ分かるけど、この子◆意味わからない

このようなレアケースな文字列も正常に検出が出来るようになった反面、精度が良すぎる故な問題も出てきました。

作品によりますが、テキストの最後に「いまボイス再生していますよ、読んでますよ、ぐるぐる」みたいな何かしらのアイコンが挿入されていることがあります。

なんとそのアイコンが文字列として検出されるようになってしまいました。おそらく記号の学習が影響しているんでしょう。

ぐるぐる

オプションから非表示に出来たり、そもそもそんな丁寧な仕掛けがされている作品が限られるため、現状では支障は出ていませんが、後々チューニングが必要な問題です。

今更ですがアドベンチャーゲームの画像の利用規約って普通に厳しいのでブログ向けの題材では無かったなと思いました。実例画像を掲載出来ない問題。こういう無駄な画像作成するの結構好きなので楽しめてはいますが、シンプルに面倒ではあるんですよね。

生成文字の出現数調整

日本語の文章を構成する文字列中の重複率は、漢字よりもひらがなやカタカタの方が多いです。

データセットもそれに準拠した方が実践に耐えうるモデルが出来るため、出現数を設定ファイルから調整できるようにしています。

# 生成文字設定
#
# 第1引数: 生成文字列 or テキストファイルの絶対パス
#
# 第2引数: repeat数
#
# 生成比率は漢字とそれ以外で1:3ぐらいが理想的
#
character_params: [
  # ひらがな(濁音と半濁音を除く)
  [
    あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわゐゑをん,
    100,
  ],

  # ひらがな小文字
  [
    ぁぃぅぇぉっゃゅょゎゕゖ,
    180,
  ],

  # 常用漢字
  [
    F:\AdventureGameOCR\resources\codes\常用漢字.txt,
    1,
  ],
]

生成文字の出現数調整の担保

生成文字の出現比率を調整出来るようにしましたが、実際にその文字列がプロットされるか否かは、画像に空き領域があるかに依存します。現状では空き領域が見つからない場合、その文字列はプロットせずに破棄しています。これが原因で出現率の調整結果と多少の乖離が発生しています。

精度に影響を及ぼすほどのことではないのですが、筆者的に気になる部分だったので、プロット出来なかった文字列を共有メモリに突っ込んで、次回のプロット時に使用する形に変更しました。

これにより調整比率通りなプロットが担保されるようになりました。

def get_character(
    lock:Lock,
    src_characters:str,
    dst_characters:ListProxy,
) -> str:
    """文字の取得

    dst_charactersから1文字ずつ取り出します。
    dst_charactersが空になった場合は、シャッフルしたsrc_charactersを追加します。

    Args:
        lock (Lock): lock
        src_characters (str): source
        dst_characters (ListProxy): dest

    Returns:
        str: 文字
    """
    with lock:
        try:
            return dst_characters.pop()
        except Exception as _:
            dst_characters.extend(random.sample(list(src_characters), len(src_characters)))
            return dst_characters.pop()


def add_characters(
    lock:Lock,
    src_characters:str,
    dst_characters:ListProxy,
) -> None:
    """文字の追加

    dst_charactersにsrc_charactersを追加します。

    Args:
        lock (Lock): lock
        src_characters (str): source
        dst_characters (ListProxy): dest
    """
    with lock:
        dst_characters.extend(list(src_characters))

テキストボックスの画像対応

以前は単色のグラデーション画像のみをテキスト背景として扱っていましたが、画像にも対応させてよりデータセットに幅を持たせることにしました。

背景画像
テキストボックス画像
透明度にグラデーションマップを適用して半透明合成

テキストのランダムカラー対応

既読文字対応の布石としてプロット時に一定間隔でランダムカラーを使えるようにしました。

色の選出方法はHSV色空間で適当に範囲内からランダムで取得しています。

@property
def random_text_box_color_or_path(self) -> Union[Int3, str]:
    """ランダムなテキストボックス色、もしくはテキストボックス画像パスを取得します

    呼び出すたびにカウンタがインクリメントされ結果が変わります。

    Returns:
        Union[Int3, str]: テキストボックス色、もしくはテキストボックス画像パス
    """
    self.random_text_box_image_count += 1
    if self.random_text_box_image_count % self.random_text_box_image_rate == 0:
        return self.text_box_path
    else:
        hsv = randrange(*self.text_box_hue), randrange(*self.text_box_saturation), 100
        rgb = hsv2rgb(hsv)
        return rgb
def hsv2rgb(hsv:Int3) -> Int3:
    """HSV2RGB

    h = [0, 360], s = [0, 100], v = [0, 100]

    Args:
        hsv (Int3): HSV色空間

    Returns:
        Int3: RGB色空間
    """
    h, s, v = hsv

    h = clamp(h, 0, 360)
    s = s * 0.01
    v = v * 0.01

    c = v * s
    x = c * (1 - abs((h / 60) % 2 - 1))
    m = v - c

    if 60 <= h < 120:
        r, g, b = x, c, 0.0
    elif 120 <= h < 180:
        r, g, b = 0, c, x
    elif 180 <= h < 240:
        r, g, b = 0, x, c
    elif 240 <= h < 300:
        r, g, b = x, 0, c
    elif 300 <= h < 360:
        r, g, b = c, 0, x
    else:
        r, g, b = c, x, 0.0

    return tuple([int((val + m) * 255) for val in (r, g, b)])

1行あたりの最大文字数の自動算出

毎回ビューアを使用して調整するのが面倒だったので、キャンバスサイズと文字幅から自動算出するように変更しました。

@property
def characters_per_line(self) -> int:
    """1行あたりの最大文字数を取得

    Returns:
        int: 1行あたりの最大文字数
    """
    try:
        return self._characters_per_line
    except Exception as _:
        # 対応文字列の中での最長を取得
        # NOTE: 横幅はフォントサイズと同値だと思うけど一応
        length = max((self.font.getlength(char) for char in self.characters))

        # キャンバスサイズを元にプロットできる最大長を算出
        self._characters_per_line = math.floor(self.canvas_size[1] / length)

        return self.characters_per_line

低品質アンチエイリアスの作成モード

昔のブランドに対して安定的に動作して欲しかったので、低品質アンチエイリアスなフォント描画を出来るようにしました。

低品質アンチエイリアスは、エッジをトゲトゲさせています。

画像の色味に違和感があるのはBGR配置で出力しているからです。

低品質アンチエイリアス:無効
低品質アンチエイリアス:有効

文字ピクセルの抽出(CharSeg)

画像から文字ピクセルとそれ以外で分類することでグレースケールな文字画像を生成しているセマンティックセグメンテーションモデルです。

Character Segmentation

Character SegmentationことCharSegさんです。

前回同様にCRAFTのアーキテクチャを流用していますが、入力サイズを小さくした関係で、エンコーダとデコーダ層を1層ずつ削っています。

背景画像の選出方法

使用する背景画像を色成分の配分から数枚選ぶスタイルから、CRAFTと同様に指定されたディレクトリの背景画像を使用する形に変更しました。

背景画像の切り抜き方法だけはCharSegの入力サイズが32x32と小さいため少し変更しています。

内容としては切り抜き開始位置をランダム化するのではなく、背景画像をグリッド化、そのインデックスをランダム選出する形にして、重複使用を防止しています。


def __call__(self) -> np.ndarray:
    """切り抜き画像の作成と取得

    Returns:
        np.ndarray: 切り抜き画像
    """
    try:
        index = next(self.index_iter)
        yi = math.floor(index / self.split_width_num)
        xi = index % self.split_width_num
        # 切り抜き座標の作成
        xmin, ymin = xi * self.split_width_size, yi * self.split_height_size
        xmax, ymax = xmin + self.split_width_size, ymin + self.split_height_size
        return self.image[ymin:ymax, xmin:xmax]
    except Exception as _:
        # 切り抜き開始位置の設定、再設定
        self.xpos, self.ypos = self.rand_xpos, self.rand_ypos
        # 切り抜き場所のランダム化
        self.index_iter = iter(random.sample([i for i in range(self.total_split_num)], self.total_split_num))
        return self()

プロセスの積み方を少し修正

画像作成毎にプロセスを作成していましたが、1プロセスにつき何枚作成するという形に変更しました。

シンプルにプロセスの立ち上げコストが無駄でした。

# 生成パラメータの作成
params = config.create_params(stage)

# バッチサイズに対していくつのタスクを用意する必要があるか
params_num = len(params)
task_num = math.ceil(params_num/config.batch_size)

# 生成パラメータをタスクごとに分割
params_list = (
    [
        params.pop()
        for _ in range(task_num)
        if len(params) > 0
    ]
    for _ in range(config.batch_size)
)

futures = [
    executor.submit(
        create_character_images,
        params,
        stage_dir,
        idx * task_num,
    )
    for idx, params in enumerate(params_list)
    if len(params) > 0
]

文字画像の分類(CoAtNet)

画像 ラベル 文字
54
102
93
97
193
213
155
55

約7,000前後の画像分類を行っているモデルです。

Marrying Convolution and Attention

ConvolutionとAttentionを取ってCoAtNetだと思います。

最新モデルではないのですが、入力画像が1チャンネルの場合、最新モデルと張り合える程度には高スコアな分類結果が得られるため、現在でも使い続けています。

入力画像をモノクロからグレースケールに変更

以前はCharSegの抽出精度にばらつきがあり、それを平均化するために2値化をしていましたが、改修により抽出精度が安定化、以前ほどピクセルの欠損が見られなくなったため、入力画像をグレースケールに変更しました。

変更はしましたが、モノクロとグレースケールでそこまで精度差分が発生しない、強いて言うならば、モノクロは濁音や半濁音が苦手、グレースケールはCharSegの結果に大きく依存するという感じで、再度モノクロに戻す可能性は若干あります。

画像文字の対応

最新作では環境依存文字の「♥」などが出現するようになりました。

pillowでこれを描画しようとすると非対応文字である「・」となってしまいます。

MSゴシックは対応していた
モトヤLマルベリは非対応だった

このような非対応文字は、ペイントツール等で予め作成しておいて、その画像を読む込み形で対応しました。

♥に該当する文字画像

ガウシアンフィルタの追加

CRAFTを流用したCharSegは本家同様にアップサンプリングの学習を省いています。

その影響もあってぼやけたグレースケール画像が生成されることがあります。

かといってConvTransposeを積むのは、領域特化とはいえ、現状ではコストに見合わないため、一旦データセットにガウシアンフィルタを適用したデータを追加することで対応しています。

こうなると結局モノクロ化した方が良かったりするのかなど、色々と悩みどころです。

ガウシアンフィルタ:なし
ガウシアンフィルタ:あり

小文字(ひらがな、カタカナ、英字)が苦手

「あ」や「ぁ」など、小文字と対となる大文字が存在する文字は、分類結果が不安定になりがちです。

原因としては差分がサイズでしか見れないことにあります。

このような文字は画像サイズの変更を制限しています。

def is_applicable_expand_layer_size(
    self,
    character:str,
    expand_layer_size:int,
) -> bool:
    """レイヤーの拡張サイズが適用可能か

    小文字や小文字と対になる大文字は、下手に画像サイズを変えると分類精度落とすことになる。

    Args:
        character (str): 文字
        expand_layer_size (int): 拡張幅

    Returns:
        bool: 拡張可能ならTrueを返す
    """
    if character in KOMOJIS:
        # 小文字はレイヤーサイズを小さくすると大文字と区別できなくなるので禁止
        return expand_layer_size >= 0
    elif character in KOMOJIS_PAIR:
        # 大文字はレイヤーサイズを大きくすると小文字と区別できなくなるので禁止
        return expand_layer_size <= 0
    return True

見た目が似ている文字画像(類似文字)の扱い方

カタカナのロ、漢字の口、これら見た目が似ている文字を類似文字と呼んでいます。
上から順にロの類似文字グループ、長音符の類似文字グループ、カの類似文字グループです。

類似文字 類似文字の内訳
カタカナのロ(ろ)
漢字の口(くち)
長音符
漢字の一(イチ)
全角のハイフン
ダッシュ記号
カタカナのカ(か)
漢字の力(ちから)

類似文字グループはデータセットでの扱い方が他の文字と異なり、先頭の文字のみをデータセットに含めて、グループ内の他の文字はデータセットに含めないようにしています。

これは見た目がほぼ同じなのに異なるラベル値が振り分けられていることで、正常な損失計算が出来なくなっていることへの対応策です。

類似文字グループだけに影響を及ぼす分には無視しても良かったのですが、類似文字以外の分類結果にも悪影響を及ぼしたため、このような対応をしています。

類似文字グループは先頭の文字のみをデータセットに含めている関係で、類似文字グループ内のその他の文字が必然的に分類できなくなる問題もあります。その問題は後述の類似文字推定で解決しています。

類似文字推定(DistilBERT)

文字 推定結果
N/A
N/A
N/A
N/A
N/A
カタカナ
N/A
N/A

先の類似文字をBERTのMaskedLMを利用して推定しています。

この場合だと、ブログの「ロ」がカタカナのロか、漢字の口、どちらなのかを推定してもらっています。

深層学習のストレージにはM.2 SSDがおすすめ

BERTはその性質上、データセットサイズと精度が比例関係に近くあります。

これに倣いデータセットサイズを以前の倍以上大きくしました。
結果として精度は向上しましたが、学習イテレータが最悪になりました。

DataLoaderの読込速度は結局のところハードに依存するので爆速なM.2 SSDに換装したところ、割とすんなり改善しました。

設備投資の重要性ですね。
趣味はリターンを考えなくていいので楽なもんですわ。

ユーザー辞書とシステム辞書

一部の文字列はMaskedLMによる推定ではなく、昔ながらの辞書置換をしています。


「もしかしてーー」「寸前でーー」

上記の長音符を例にすると分かりやすいのですが、このような配置の場合、基本的に漢数字の一が途中に紛れ込むことはありません。なぜなら大体は間を文で表すための表現や、発音する上での伸ばし記号として使用されているからですね。

こんなものをいちいちMaskedLMで推定していては勿体ないというか、シンプルに推定が難しい内容なため、システム辞書による置換をしています。


ユーザー辞書は作品ごとにしか登場しない人名や架空の地名、単語などを入力しています。
学習していない言語や単語には対応できないですからね。

# システム辞書
self.system_dictionary = {
        "への字": "への字",
        "ーーーーーー": "ーーーーーー",
        "ーーーーー": "ーーーーー",
        "ーーーー": "ーーーー",
        "ーーー": "ーーー",
        "ーー": "ーー",
}

# ユーザー辞書
self.user_dictionary = {
        "アーティファクト": "アーティファクト",
        "AIMS": "AIMS",
}

Adventure Game OCR(ADV-OCR

諸々のモデルを組み合わせて作成した日本語OCRです。
画像は圧倒的著作権ガードを適用しています。

名称変更

Rein Vision OCRからAdventure Game OCRに変更しました。
特段理由という理由もないので、筆者の気分でございます。
しばらくはこの名称がお気に入りです。

文字領域の縦横比率を1対1に調整

モトヤLマルベリやMSゴシックの全角文字は等幅なため、文字領域の縦横比率が異なることはありません。そのため、文字領域検出のポストプロセスに縦横比率を1対1に調整する処理を追加しました。

調整方法は単純に周囲の空き領域方向に不足分を拡張したり、削ったりしています。
CRAFTが優秀で文字の中心を捉えてくれているので、シンプルな処理で十分に機能してくれています。

若干後述のネタバレを含みますが、源ノ角ゴシックに伴い処理を固定化から、モード選択時のみの分岐実行に変更しました。

異体字変換

定義的には類似文字に該当するんですが、異体字は読んで字の如く、そっちで分類しても合ってはいます。

わざわざ対応文字リストと漢字辞書を睨めっこする気も起きなかったので見つけ次第順次、辞書に突っ込んで変換しています。

# 異体字変換表
kanji_variants_table = {
    "隆":"隆",
    "奧":"奥",
    "昻":"昂",
    "竸":"競",
    "郞":"郎",
    "齡":"齢",
    "逸":"逸",
    "鶴":"鶴",
}

# 異体字変換
outputs.text = outputs.text.translate(self.kanji_variants_table)

精度テスト

実際の作品を元に精度を測ってみます。

ゲームの共通設定は、既読文字に色付けしないことです。
文字修飾は作品のデフォルト設定に従っています。

精度は2種類の方法で計測します。
1つは類似文字推定の成否を問わない値で、もう1つは類似文字推定の成否を問うものです。

類似文字推定は仮に失敗したとして発音記号が同等であれば特に利用先で困らないという筆者の事情があるため、このような計測方法を採用しています。

例として「へりぽーと」と「ヘリポート」、声に出して読んだらどちらも同じでしょう、という感じです。

それでは計測結果です。

テストデータ情報 フォント設定 類似文字推定を除いた精度 類似文字推定を含む精度
test
2018年 発売
共通ルートまで
モトヤLマルベリ 99.99954157%
(218,137/218,138/1)
99.99908315%
(218,136/218,138/2)
test13
2023年 発売
共通ルートまで
モトヤLマルベリ 100.00000000%
(245,692/245,692/0)
99.99959299%
(245,691/245,692/1)
test12
2008年 発売
全ルート
MSゴシック
低品質アンチエイリアス
99.99964624%
(848,034/848,037/3)
99.99693410%
(848,011/848,037/26)
test4
2019年 発売
共通ルート途中まで
MSゴシック 100.00000000%
(24,384/24,384/0)
100.00000000%
(24,384/24,384/0)
test8
2013年 発売
共通ルート途中まで
MSゴシック 100.00000000%
(50,695/50,695/0)
100.00000000%
(50,695/50,695/0)

音声コーパスツールに本OCRを組み込んだ関係で前回よりもサンプル数が大幅に増加しています。
その弊害としてプレイ後の1週間ぐらいはテキストの整合性を人力でチェックするという地獄。

モトヤLマルベリは、文字が太くてゴシック系ということもあり、前回と同様に安定した結果ですね。

MSゴシックは、前回のモデルでは割とそこら辺のOCRと大差のない精度でしたが、モトヤLマルベリ程度には近づいてくれましたね。test4とtest8はサンプル数が不足しているので信用に値しない結果です。それならサンプル数を増やせば解決する話ではあるのですが、コード書くの楽しすぎてゲーム起動する気があんまりおきない筆者さんでした。

類似文字推定は案の定サンプル数の増加に伴い失敗数が目立っていますが、失敗している文字が「ぺ」や「へ」などの発音記号的に同一なものなので、実運用では特段問題にならなさそうですね。DistilBERT用に学習環境を全部M.2 SSDにぶち込んで学習データもりもりにした甲斐がありました。

次は汎用モデルの学習用にA6000あたりの購入を視野に。。。オサイフ スッカスカ。。。

第3回は規約的にセーフな作品でも買おうかな。。。

源ノ角ゴシックを試してみる

精度測定の総評としては割と満足な結果でした。

話は変わりますが、nineシリーズはデフォルトフォントに源ノ角ゴシックが採用されています。

MSゴシックとモトヤLマルベリでしか試していなかったので、ちょっくら、ふふんと学習してみます。

問題発生

この子、等幅じゃない?!

MSゴシックとモトヤLマルベリの全角文字が全て等幅対応していたので、この世のフォントが全てそういうものだと思い込んでいました。

まーた改修要項が増えました。

この辺りは第3回のネタにでもしますか。

ブログを書くとこういう普段気付かない問題点を洗い出せるので結構いいですね。
趣味というジャンルの都合上、どうしても自分の好きな部分しか触らなくなるので、こういう潜在的な不具合との遭遇率が極端に低いのですよね。

おわり!!!

お疲れさまでした!!!

最近は開発コストを徐々にTTSに移しているので色々と忘却する前にざっくりと纏めちゃいました。