画像のテロップからフォントを特定する作業では、最終的に「見た目が重なる」ことが必要です。しかし、単純な重なり率だけで判断すると、丸ゴシック系・テレビ向け加工・低解像度のアンチエイリアスが重なった瞬間に、容易に別フォントと誤判定されてしまいます。今回の改善は、その失敗を前提に、過去のGAN/HLE系の成功記録を読み直し、全有効フォント候補から恣意的に絞らず、現実的な時間内に候補を当てるための識別器へ戻す作業でした。

特に重要だったのは、4枚目の画像に含まれるルビです。「なに」「はな」のような小さいルビはキャピーではなく、ロダンNTLG Pro系と見たほうが自然です。そのため、今回のフォント特定ではルビを同じグループに混ぜず、大きな文字である「青春時代」と「何について話す」だけを同一デザインの検証対象にしました。

今回の問題:重なり率だけではフォント固有の形が見えない

従来のDice/Soft-IoU中心の評価は、面積の重なりをよく見ます。これは「だいたい同じ太さ」「だいたい同じ字幅」の候補を上位に残すには有効ですが、フォント固有の曲線、端点、入り抜き、交差部の形を十分に見ません。その結果、CapieN+のような丸みのある字形が、UD丸ゴ・新丸ゴ・スーラ/丸ゴ系に吸われる現象が起きました。

また、過去の成功報告を読み返すと、GANテストの高正解率は、完全なオープンセットでの実証ではありませんでした。queryfontLimit が正解ファミリーに寄っていたり、同じレンダラで生成した合成画像を照合していたりしたため、現実のHLE画像とは難しさが違います。ここを混同すると、適切な評価ができなくなります。

候補集合:数千件へ落とすが、フォント名で決め打ちしない

探索対象は、coverage CSVから日本語上位フォントだけに絞りました。ここでの絞り込みは「この画像はキャピーっぽいからキャピー周辺だけ見る」という意味ではなく、そもそも日本語テロップを描けないフォント、重複しているファイル、文字セットが足りないフォントを除外するための前処理です。

段階件数意味
coverage CSV行57,888検出済みフォント/フェイスの総数
日本語上位行4,042JIS第一水準・かな・句読点などがほぼ揃う行
font_list照合後3,996手元のフォントリストに載る信頼候補
重複除去後3,564同一フェイス/同一PostScript名系を整理した探索対象

この3,564件に対して、まず粗い正規化レンダリングを行い、上位だけを構造スコアで再評価します。正解フォント名をLLMが自動的に推定して候補を狭める処理は入れていません。

スコア設計:面ではなく輪郭・曲線・局所構造を見る

新しい構造スコアは、単一指標ではなく複数の指標の合成です。実装上の中心は scratch/hle_structural_identifier.pystructural_score() です。

score = (
    soft_iou * 0.12
    + ncc * 0.13
    + edge_dice * 0.17
    + chamfer * 0.20
    + grad * 0.16
    + topo_score * 0.09
    + split * 0.08
    + proj * 0.03
    + width * 0.02
)

Chamfer距離は輪郭の1px未満のズレを拾い、Gradient orientationはSobel方向ベクトルで曲線の曲がり方を比較します。さらにスケルトン端点・交差点の周辺を重くすることで、ハネ、入り、分岐の形を見ます。面積が似ているだけの丸ゴ系を落とすための目を増やした、という位置づけです。

探索手順:正規化でフォントを探し、上位だけをパラメータ精査する

実運用では、最初からサイズ・長体・斜体・字送りを全組み合わせで網羅的に検証すると時間がかかります。そこで、まず正規化したマスクでフォント候補を探し、残った上位候補に対してだけ細かい変形を試す構造にしました。

target, coarse_rows, supported_count, phase_timings = run_coarse(...)
if skip_param_search:
    refined = coarse_rows_as_refined(coarse_rows, topk)
else:
    refined = run_refine(task, target, coarse_rows, topk, workers, grid_mode)

--skip-param-search を使うと「フォント名だけを高速に探す」モードになります。ただし、今回のベンチでは、これだけを既定にするのは危険でした。秀英5号+の合成サンプルは正規化だけだと3位止まりで、構造精査を入れると1位に戻りました。つまり、速度優先モードは診断用途、既定は上位候補の構造精査付きにするべきです。

速度改善:cmapを毎タスク全読みしない

大きなボトルネックは、各タスクごとに全フォントのcmapを読み直して文字対応を確認する処理でした。厳密には安全ですが、3,564フェイスを毎回読むため、1タスクあたりではなく「タスク集合に対して1回」で済ませるべきです。

support_masks, task_masks, support_mask_seconds = build_support_masks(fonts, selected_tasks)

この変更で、HLEベンチではサポート確認が各タスク50〜90秒程度から、最初の1回約54.8秒にまとまりました。WebUI/APIではさらに、候補集合がすでに日本語上位に限定されている場合、既定ではcoverageを信頼してcmap全読みを省略し、必要な場合だけ strictSupport=true で厳密確認を行うようにしました。

HLE画像での結果:4番は単独ではなく同一デザインのグループで見る

HLE側の大きな文字だけを見ると、既知の正解は次の通りです。

タスク対象正解今回の扱い
1大きい歌詞秀英5号+構造探索でrank 1
2大きい文字ラグランパンチ構造/consensusでrank 1
3大きい文字ラグランパンチ構造/consensusでrank 1
4_0何について話すキャピーN+単独では丸ゴ系が強いが、グループで復帰
4_1青春時代キャピーN+単独では丸ゴ系が強いが、グループで復帰

4番のルビは混ぜません。「なに」「はな」は小さい文字で、ロダンNTLG Pro系と考えるべき別タスクです。大文字2本だけを 4_telop グループに入れることで、CapieN+グループが1位になります。

TASK_GROUP_IDS = {
    "4_0": "4_telop",
    "4_1": "4_telop",
}

ただし、font_list_for_copy.txt の順序を使う --font-list-prior は画像だけの証拠ではありません。レポートでは source-prior と image-only rank を分けて記録しました。開発判断では、この区別を消してはいけません。

GAN/復元サンプルでの検証

過去のGAN系ロジックで生成した復元サンプル8件を、同じ3,564候補から再照合しました。レポートは outputs/structural_sample_benchmark/restored_samples_structural_20260601/sample_benchmark.md に保存しています。

項目結果
候補数3,564フェイス
単独cropのファミリーTop17/8
CapieN+ペアのグループ順位1位
RaglanPunchペアのグループ順位1位
総時間344.1秒
ピークWorking Set約3.18GB
ピークPrivate約33.7GB(ProcessPoolの子プロセス合算。実RAM常駐量とは分けて読む)

唯一の単独ミスは「青春時代」のCapieN+サンプルでした。Jo Shin Maru Goなどがわずかに上回りましたが、スコア差は0.0006程度で、見た目も非常に近いものでした。さらに、同じCapieN+の「何について話す」とグループ化すると、CapieN+が明確に1位へ戻ります。

この結果から、短い文字列・文字数の少ないcropでは、単独画像だけで一意に断定するのではなく、同じテロップデザインの複数cropを束ねて判断するほうが堅牢だと分かりました。

WebUI/APIへの統合

既存の /api/search は壊さず、新しく /api/structural-search を追加しました。WebUIにも別ボタンとして Structural Search (all valid JP fonts) を追加しています。

POST /api/structural-search
{
  "reference": "...",
  "crop": [x1, y1, x2, y2],
  "text": "青春時代",
  "coarseGrid": "mini",
  "gridMode": "fast",
  "structuralTopk": 160,
  "workers": 20,
  "strictSupport": false
}

APIのスモークテストでは、サーバー起動、ジョブ作成、進捗更新、レポート出力、既存結果テーブルに流せる行の返却まで確認しました。軽量テストのため identity + skipParamSearch で走らせたので、これは精度検証ではなくAPI疎通確認です。

今後の改善点

  • 抽出方式の自動選択: bgdiff は白字青背景の合成サンプルでCapieN+順位を少し上げましたが、RodinやUD ShinGoでは悪化しました。既定にせず、状況に応じて選べる形にします。
  • グループ指定UI: HLE4のように「この2cropは同じデザイン」「このルビは別フォント」をUIで明示できるようにする必要があります。
  • GPU利用: 現時点のレポートでは torch.cuda.is_available() はFalseでした。CPU ProcessPoolで十分実用域ですが、距離場・勾配スコアはGPUバッチ化の余地があります。
  • 完全一致と同形フォントの分離: 一部の文字列では、複数フォントがほぼ同じ、または実質的に同じアウトラインを返します。この場合は、無理に1位だけを断言せず、同形クラスタとして表示するべきです。

まとめ

今回の改善で、少なくとも「数千候補から普通の時間内に探索する」土台は戻りました。HLEの1〜3は正解へ戻り、4番の大文字2本もグループ一貫性でCapieN+へ戻せています。一方で、短い文字列単独では丸ゴ系に吸われる現象がまだ残るため、単独スコアを過信しない設計が必要です。

重要なのは、過去の成功を「成功した」とだけ読むのではなく、どの条件で成功したのかを分解することでした。queryで正解に寄せた成功、同じレンダラで作った合成画像の成功、現実のHLE画像の成功は別物です。今後も docs/font_identifier_continuity.mddocs/font_identifier_logic_registry.jsonl に、試したこと・失敗したこと・改善したことを残していきます。

追記:HLEのラグランパンチに対する最終パラメータ推定

その後、HLE画像のうちラグランパンチ系と見られる大きな文字「安」「空姫」について、全フォント識別の後段に置くラストパスを追加しました。これは全フォントから探し直す処理ではなく、構造検索で残った候補に対して、condensetrackingitalic、描画時の内部サイズ、そして疑似的な太らせ・痩せを表す morph を詰める処理です。

最初は各パラメータの直積グリッドで試しましたが、これは失敗でした。1候補の fine 段階に到達するだけで900秒のタイムアウトに達し、普段使いできません。そこで、各ステージで一つの変数だけを動かし、スコアが改善した場合のみ採用し、悪化したら前の値へ戻す座標探索に切り替えました。この変更により、RaglanPunch に絞った HLE 2/3 の最終推定は78.1秒で完走しました。

coarse: condense / tracking / italic / morph / renderSize を広めに探索
fine  : 良かった値の周辺だけを細かく探索
micro : さらに小さい半径で確認
if score worsens:
    rollback to previous best

結果として、HLEのラグランパンチは生フォントをそのまま重ねるよりも、横方向に強く圧縮し、少し太らせたほうが構造スコアが上がりました。たとえば 空姫 では FTT-RaglanPunch UB に対して condense=0.8475italic=-1.25morph=1.00 が最良になっています。これは「フォント自体はラグランパンチ系だが、放送テロップとして何らかの加工が乗っている」という仮説と整合します。

ただし、単文字の「安」では tracking は意味を持ちません。文字間が存在しないため、探索値として出てもフォントデザインの証拠にはしない扱いにしています。また、Raglan と RaglanPunch のような近縁フォントは、加工パラメータを自由にしすぎると細部が吸収されるため、最終パラメータ推定はフォント同定を上書きするものではなく、同定後の再現用パスとして分離して使うのが安全です。

  • 出力: outputs/hle_parameter_refine/hle_raglanpunch_only_param_refine_20260601/report.md
  • 実装: scratch/hle_parameter_final_refine.py
  • 重要な学び: 全直積探索ではなく、段階的な座標探索とロールバックを使う。