みんなで翻刻 くずし字OCR — 構造タグ付き行認識モデル

English below

このリポジトリは常に最新版を指します。現行は v19(2026年9月)です。 過去の版は git タグ(v19 等)で固定できます: AutoModel.from_pretrained("yuta1984/honkoku-ocr", revision="v19", trust_remote_code=True)

日本の前近代資料(版本・写本・古文書・記録類)の縦書き1行画像を、翻刻文へ変換するモデルです。 本文だけでなく、ふりがな・返り点・送り仮名・割書といった注釈構造を同時にタグとして出力します。

  • encoder: ConvNeXt V2-Base(facebook/convnextv2-base-22k-384 から初期化)+ 学習可能な2D位置埋め込み
  • decoder: RoBERTa 6層・512次元・8ヘッド(翻刻コーパスで MLM 事前学習)
  • 入力: 256 × 2048(縦書き1行を横倒しにしたもの)
  • 語彙: 7,710(旧字・異体字を忠実保存)
  • 学習: 1,047,279 行 / 5 epoch / 81,815 step(A100 40GB で約3日)

⚠️ 読み込み方(重要)

VisionEncoderDecoderModel.from_pretrained() を直接使わないでください。 encoder が素の ConvNeXt V2 ではなく位置埋め込みを足すラッパになっているため、標準クラスでは row_emb / col_emb / pos_norm が unexpected key として黙って捨てられエラーが出ないまま精度だけが落ちます。必ず trust_remote_code=True を付けてください。

from transformers import AutoModel, AutoTokenizer, AutoImageProcessor
from PIL import Image
import torch

REPO = "yuta1984/honkoku-ocr"
model = AutoModel.from_pretrained(REPO, trust_remote_code=True).eval()
tok   = AutoTokenizer.from_pretrained(REPO)
proc  = AutoImageProcessor.from_pretrained(REPO, trust_remote_code=True)

img = Image.open("line.jpg")            # ★ページ画像ではなく「1行の切り出し画像」
pv  = proc(img, return_tensors="pt").pixel_values

with torch.no_grad():
    ids = model.generate(pv)            # beam=5 等は generation_config.json から読まれる

# ★構造タグは special token として登録されているため、skip_special_tokens=True にすると
#   タグごと消えてしまう。マークアップを残すには False にして制御トークンだけを除く。
CTRL = ("<PAD>", "<UNK>", "<CLS>", "<SEP>", "<MASK>")
def decode_markup(seq):
    s = tok.decode(seq, skip_special_tokens=False)
    for t in CTRL:
        s = s.replace(t, "")
    return s.strip()

print(decode_markup(ids[0]))
# → 三崎<OKURI>ニ</OKURI>罷<ruby>越<rt>こし</rt></ruby>候

正しく読み込めているかは同梱の検証スクリプトで確認できます:

python3 examples/verify_parity.py <repo_or_dir>

出力される構造タグ

タグ 意味
<ruby>…<rt>…</rt></ruby> ふりがな <ruby>越<rt>こし</rt></ruby>
<OKURI>…</OKURI> 送り仮名 <OKURI>ニ</OKURI>
<KAERI>…</KAERI> 返り点 <KAERI>レ</KAERI>
<WARI>右<WARI_SEP>左</WARI> 割書(1行2段組み)
<TATE> 連読符
<BLOCK> 見出し

本文だけが必要な場合は、上の decode_markup() の結果に re.sub(r'<[^>]+>', '', s) を掛けてください。 (skip_special_tokens=True でも本文は得られますが、<WARI_SEP> の位置情報まで失われます)

★v18 からの非互換: v19 は送り仮名を紙面どおり片仮名で出します(<OKURI>ニ</OKURI>)。 v18 は平仮名へ畳んでいました(<OKURI>に</OKURI>)。平仮名へ寄せたい場合は、孤立した片仮名 (前後が片仮名でないもの)を平仮名へ変換してください。連続する片仮名は語なので畳まないこと。

評価

書物単位で分割したテスト集合(58,521 行 / 214 資料 / 21 所蔵機関、学習データと資料重複なし)。 全角空白は評価対象外としています。翻刻の空白は「欄をどれだけ空けるか」という版面の表現であり、 文字認識の成否とは別のものだからです。

文字誤り率

指標 対象文字数 CER
本文のみ・空白除外 888,995 0.0608
本文のみ(空白込み) 920,544 0.0773
構造タグ込み(タグを1トークンとして) 1,089,846 0.0642

翻刻者ごとの片仮名/平仮名の表記ゆれを吸収すると 0.0556(完全一致した行 52.5%、行CERの中央値 0.0000)。

構造要素の再現(span の中身まで完全一致した場合のみ正解)

構造 gold件数 Precision Recall F1
ふりがな 26,424 0.794 0.751 0.772
返り点 368 0.842 0.883 0.862
割書 835 0.366 0.366 0.366
送り仮名 325 0.328 0.345 0.336

NDL古典籍OCR-Lite との比較(同一 gold・同一 58,521 行)

正規化 NDL古典籍OCR-Lite v19 相対
本文のみ・空白除外 0.0805 0.0609 −24%
+かな表記ゆれ吸収 0.0751 0.0556 −26%
+旧字→新字統一 0.0694 0.0508 −27%

この比較は NDL 側に有利な条件です。 評価集合は「NDLの認識結果と翻刻文の編集距離 ≤ 0.4」を 満たす行だけを採録しており、NDLが大きく外した行は母集団に入っていません。

制約・既知の弱点

  • 入力は1行の切り出し画像です。ページからの行検出・読み順推定は含みません(別モデルの仕事)。
  • 帳簿・欄組み資料が苦手: 本文CER 0.095(一般行 0.059)。NDL古典籍OCR-Lite の方が良い唯一の領域です。
  • 割書(1行2段組み)が弱い: F1 0.37。
  • 送り仮名の評価値は解釈に注意: 記法を使う資料は学習データ全体の 9.2% しかなく、同じ字が 同じ資料内で本文として翻刻される割合も 76〜93%。F1 の一部は「翻刻者の流儀を当てる」問題に なっています。記法を使う資料に限れば F1 は 0.396 です。
  • 誤りは偏在します: 誤りの多い上位10%の行が全誤りの44%を占め、行CER≥0.5の崩壊行が0.8%あります。
  • 評価値の精度について: 誤りは資料単位で強く相関するため(design effect 約320倍)、 資料をまたいだ一般化の95%信頼区間は ±0.012 程度です。版間の 0.001 規模の差はこの集合では 区別できません。

Fine-tuning(自分の資料で追学習する)

examples/ に最小構成のコードを同梱しています。依存は transformers / torch / pillow のみです。

python3 examples/check_data.py my_data/data.jsonl          # 1. 事前点検
python3 examples/finetune.py  --data my_data/data.jsonl --out ./my-model   # 2. 追学習
python3 examples/evaluate.py  --data held_out/data.jsonl --repo ./my-model # 3. 評価

データ形式(JSONL)

{"image": "images/line_0001.jpg", "text": "三崎<OKURI>ニ</OKURI>罷", "book": "資料A"}

image縦書き1行の切り出し画像。ページからの行検出は本モデルの範囲外です。 book(資料名)は任意ですが強く推奨します(下記④)。形式の実例は examples/sample_data/

★落とし穴(ここを外すと「動くが精度が出ない」)

① 本文のみのラベルで学習すると、構造タグを出さなくなります。しかも不可逆です。 本文だけが目的なら問題ありませんが、意図した選択かを確認してください。構造を保ちたい場合は --freeze-decoder を使うか、タグ付きデータを混ぜてください。check_data.py が警告します。

② 学習時の 2群 LR をそのまま持ち込まないでください。 本モデルの学習では cross-attention・enc_to_dec_projlm_head・位置埋め込みを 1e-3、 残りを 5e-5 にしていましたが、これはそれらが乱数初期化だったからです。追学習では全部が 学習済みなので、一様な低 LR(既定 --lr 2e-5)を使ってください。

③ SEP focal penalty(--sep-alpha、既定 0.5)を 0 にしないでください。 これが無いと早期 EOS に落ち、CER が 1.0 付近で固着します。CER が 0.9 から動かない場合は まずここを疑ってください。

④ 小さな val で良し悪しを判断しないでください。 誤りは資料(冊)単位で強く相関します(本モデルの評価では design effect 約 320 倍)。 数十行の val が上下しても意味を持ちません。finetune.pybook を見て資料単位で train/val を分けます。最終判断は evaluate.py で保留集合の CER を測ってください。

⑤ 語彙は 7,710 で固定です。 範囲外の文字は <UNK> になり学習も推論もできません。 check_data.py で OOV 率を確認してください。拡張するには model.decoder.resize_token_embeddings(新語彙数) で既存重みを保ったまま追加分を初期化し、 拡張トークナイザで追学習します(ONNX と Web 側の語彙差し替えも必要になります)。

⑥ 画像前処理は同梱の image processor をそのまま使ってください。 自前でリサイズすると 精度が落ちます(学習時と bit 一致することを検証済みです)。

主なオプション

オプション 既定 備考
--lr 2e-5 一様。上げすぎると壊れる
--epochs / --batch / --accum 5 / 2 / 8 実効バッチ16。極端に小さくしない
--struct-weight 2.0 構造トークンの CE 重み。希少なので等重みだと学ばれない
--sep-alpha 0.5 ③参照。0 にしない
--label-smoothing 0.1 学習時と同値
--freeze-encoder / --freeze-decoder off 小規模データ向け

データ拡張は既定 OFF です(小規模データでの効果が読めず、追加依存も避けるため)。

どれくらいのデータが必要か

測っていないので目安を示しません。 資料単位で保留集合を作り、evaluate.py で 追学習前後の CER を比較してください。誤りは資料単位で強く相関するため、少数の資料で 測った改善は一般化しません。

学習データ

みんなで翻刻 の翻刻テキストと、各所蔵機関が IIIF で公開する資料画像から 構築した 117 万行のデータセット。行の切り出しは RTMDet ベースの検出器によります。

ONNX

onnx/ に推論用の ONNX を同梱しています(examples/infer_onnx.py 参照)。

ファイル サイズ 用途
encoder.onnx / .int8 / .fp16 353 / 90 / 183 MB fp32 / CPU量子化 / WebGPU
decoder_prefill.onnx / .int8 135 / 34 MB 初回ステップ
decoder_step.onnx / .int8 122 / 31 MB 2手目以降(KVキャッシュ)

parity: encoder 2.3e-5、decoder 4.5e-6。greedy 出力は PyTorch = ONNX = int8 で完全一致。 fp16 は WebGPU 用で、GRN と LayerNorm を fp32 に据え置いています(畳むと実GPUで NaN が出ます)。

引用

@misc{kuzushiji_ocr_v19,
  title  = {くずし字OCR v19: 構造タグ付き行認識モデル},
  author = {Hashimoto, Yuta},
  year   = {2026},
  url    = {https://huggingface.co/yuta1984/honkoku-ocr}
}

Kuzushiji OCR — line-level recognition with annotation structure

This repository always points at the latest release. The current one is v19 (September 2026). Earlier releases can be pinned with a git tag: AutoModel.from_pretrained("yuta1984/honkoku-ocr", revision="v19", trust_remote_code=True)

A model that converts a single vertical line cropped from a pre-modern Japanese document (woodblock prints, manuscripts, records, ledgers) into its transcription. Beyond the body text it also emits the annotation structure—furigana, kaeriten, okurigana and warigaki—as inline tags.

  • encoder: ConvNeXt V2-Base (initialized from facebook/convnextv2-base-22k-384) plus learned 2-D positional embeddings
  • decoder: RoBERTa, 6 layers / 512 dim / 8 heads (MLM-pretrained on a transcription corpus)
  • input: 256 × 2048 (one vertical line laid on its side)
  • vocabulary: 7,710 (classical and variant glyphs preserved faithfully)
  • training: 1,047,279 lines / 5 epochs / 81,815 steps (~3 days on one A100 40GB)

⚠️ How to load it (important)

Do not call VisionEncoderDecoderModel.from_pretrained() directly. The encoder is not a bare ConvNeXt V2 but a wrapper that adds positional embeddings, so the standard class drops row_emb / col_emb / pos_norm as unexpected keys — silently, with no error, and with degraded accuracy. Always pass trust_remote_code=True.

from transformers import AutoModel, AutoTokenizer, AutoImageProcessor
from PIL import Image
import torch

REPO = "yuta1984/honkoku-ocr"
model = AutoModel.from_pretrained(REPO, trust_remote_code=True).eval()
tok   = AutoTokenizer.from_pretrained(REPO)
proc  = AutoImageProcessor.from_pretrained(REPO, trust_remote_code=True)

img = Image.open("line.jpg")            # a single-line crop, NOT a full page
pv  = proc(img, return_tensors="pt").pixel_values

with torch.no_grad():
    ids = model.generate(pv)            # beam=5 etc. come from generation_config.json

# The structure tags are registered as special tokens, so skip_special_tokens=True would
# strip them along with the control tokens. Keep the markup by removing only the latter.
CTRL = ("<PAD>", "<UNK>", "<CLS>", "<SEP>", "<MASK>")
def decode_markup(seq):
    s = tok.decode(seq, skip_special_tokens=False)
    for t in CTRL:
        s = s.replace(t, "")
    return s.strip()

print(decode_markup(ids[0]))
# -> 三崎<OKURI>ニ</OKURI>罷<ruby>越<rt>こし</rt></ruby>候

You can confirm the model was reconstructed correctly with the bundled checker:

python3 examples/verify_parity.py <repo_or_dir>

Structure tags

Tag Meaning Example
<ruby>…<rt>…</rt></ruby> furigana (reading gloss) <ruby>越<rt>こし</rt></ruby>
<OKURI>…</OKURI> okurigana <OKURI>ニ</OKURI>
<KAERI>…</KAERI> kaeriten (kanbun reading marks) <KAERI>レ</KAERI>
<WARI>right<WARI_SEP>left</WARI> warigaki (two columns inside one line)
<TATE> continuation mark
<BLOCK> heading

For body text only, apply re.sub(r'<[^>]+>', '', s) to the output of decode_markup().

★Incompatibility with v18: v19 emits okurigana in katakana, as written on the page (<OKURI>ニ</OKURI>); v18 folded it to hiragana (<OKURI>に</OKURI>). To fold it yourself, convert isolated katakana (those not adjacent to another katakana) to hiragana. Do not fold consecutive katakana — those are words.

Evaluation

A book-level test split (58,521 lines / 214 materials / 21 holding institutions, with no material shared with the training data). Whitespace is excluded from the metric: in these transcriptions, spacing encodes how much of a column was left blank, which is a matter of page layout rather than character recognition.

Character error rate

Metric Characters CER
body text only, whitespace excluded 888,995 0.0608
body text only (whitespace included) 920,544 0.0773
including structure tags (tags as single tokens) 1,089,846 0.0642

Absorbing transcriber-level katakana/hiragana variation brings this to 0.0556 (52.5% of lines exactly correct; median line CER 0.0000).

Annotation structure (a span counts only on an exact content match)

Structure gold spans Precision Recall F1
Furigana 26,424 0.794 0.751 0.772
Kaeriten 368 0.842 0.883 0.862
Warigaki 835 0.366 0.366 0.366
Okurigana 325 0.328 0.345 0.336

Against NDL Kotenseki OCR-Lite (same gold, same 58,521 lines)

Normalization NDL Kotenseki OCR-Lite v19 Relative
body only, whitespace excluded 0.0805 0.0609 −24%
+ katakana/hiragana variation absorbed 0.0751 0.0556 −26%
+ classical→modern glyph unification 0.0694 0.0508 −27%

This comparison favours NDL. The evaluation set only admits lines whose edit distance between that same OCR output and the transcription was ≤ 0.4, so lines where NDL failed badly are absent from the population by construction.

Limitations and known weaknesses

  • The input is a single-line crop. Line detection and reading-order estimation are out of scope (a separate model's job).
  • Ledgers and tabular layouts are weak: body CER 0.095 versus 0.059 on ordinary lines. This is the one area where NDL Kotenseki OCR-Lite does better.
  • Warigaki (two columns in one line) is weak: F1 0.37.
  • Read the okurigana score with care. Only 9.2% of the materials in the training data use the okurigana notation at all, and within those the same character is transcribed as plain body text 76–93% of the time. Part of this F1 therefore measures "guessing the transcriber's convention" rather than recognition. Restricted to materials that do use the notation, F1 is 0.396.
  • Errors are highly concentrated: the worst 10% of lines carry 44% of all errors, and 0.8% of lines are "collapsed" (line CER ≥ 0.5).
  • On the precision of these figures: errors correlate strongly within a material (design effect ≈ 320), so the 95% confidence interval for generalizing across materials is about ±0.012. Differences of ~0.001 between releases cannot be resolved on this set.

Fine-tuning on your own materials

examples/ contains a minimal, self-contained implementation. It only needs transformers / torch / pillow.

python3 examples/check_data.py my_data/data.jsonl          # 1. inspect the data
python3 examples/finetune.py  --data my_data/data.jsonl --out ./my-model   # 2. fine-tune
python3 examples/evaluate.py  --data held_out/data.jsonl --repo ./my-model # 3. evaluate

Data format (JSONL)

{"image": "images/line_0001.jpg", "text": "三崎<OKURI>ニ</OKURI>罷", "book": "Material A"}

image is a single vertical line crop; line detection on a page is out of scope for this model. book is optional but strongly recommended (see ④). See examples/sample_data/ for a worked example.

★Pitfalls (get these wrong and it runs but underperforms)

① Training on body-text-only labels makes the model stop emitting structure tags — irreversibly. That is fine if you only need body text, but make sure it is a deliberate choice. To preserve the tagging, use --freeze-decoder or mix in tagged data. check_data.py warns about this.

② Do not carry over the two-group learning rate used in pre-training. Training used 1e-3 for cross-attention, enc_to_dec_proj, lm_head and the positional embeddings, and 5e-5 for everything else — because those parts were randomly initialized. In fine-tuning everything is already trained, so use a uniform low LR (default --lr 2e-5).

③ Do not set the SEP focal penalty (--sep-alpha, default 0.5) to zero. Without it the model collapses to emitting EOS early and CER sticks near 1.0. If your CER refuses to drop below ~0.9, check this first.

④ Do not judge quality on a small validation set. Errors correlate strongly within a material (design effect ≈ 320 on this model's evaluation), so a few dozen validation lines tell you nothing. finetune.py splits train/val by material using the book field. Make the final call with evaluate.py on a held-out set.

⑤ The vocabulary is fixed at 7,710. Characters outside it become <UNK> and can be neither learned nor produced. Check the OOV rate with check_data.py. To extend it, call model.decoder.resize_token_embeddings(new_size) — existing weights are preserved and the new rows initialized fresh — then fine-tune with the extended tokenizer (you will also need to re-export ONNX and update the vocabulary on any deployment).

⑥ Use the bundled image processor as-is. Rolling your own resize costs accuracy; the bundled one is verified to be bit-identical to the training-time preprocessing.

Main options

Option Default Note
--lr 2e-5 uniform; raising it too far breaks the model
--epochs / --batch / --accum 5 / 2 / 8 effective batch 16; do not go much lower
--struct-weight 2.0 CE weight on structure tokens; they are rare and are not learned at weight 1
--sep-alpha 0.5 see ③; do not set to 0
--label-smoothing 0.1 same as training
--freeze-encoder / --freeze-decoder off for small datasets

Augmentation is off by default (its effect on small datasets is unmeasured and it would add dependencies).

How much data do I need?

We have not measured this, so we do not quote a figure. Build a held-out set split by material and compare CER before and after with evaluate.py. Because errors correlate within a material, an improvement measured on a handful of materials will not generalize.

Training data

117万 (1.17M) lines built from the transcriptions of Minna de Honkoku and IIIF images published by the holding institutions. Lines were cropped with an RTMDet-based detector.

ONNX

Inference-ready ONNX graphs are bundled under onnx/ (see examples/infer_onnx.py).

File Size Use
encoder.onnx / .int8 / .fp16 353 / 90 / 183 MB fp32 / CPU quantized / WebGPU
decoder_prefill.onnx / .int8 135 / 34 MB first step
decoder_step.onnx / .int8 122 / 31 MB subsequent steps (KV cache)

Parity: encoder 2.3e-5, decoder 4.5e-6. Greedy output is identical across PyTorch, ONNX and int8. The fp16 graph is for WebGPU and keeps GRN and LayerNorm in fp32 — folding them produces NaNs on real GPUs (this is invisible on CPU).

Citation

@misc{kuzushiji_ocr_v19,
  title  = {Kuzushiji OCR v19: line-level recognition with annotation structure},
  author = {Hashimoto, Yuta},
  year   = {2026},
  url    = {https://huggingface.co/yuta1984/honkoku-ocr}
}
Downloads last month
29
Safetensors
Model size
0.1B params
Tensor type
F32
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support