技術 約13分で読めます

NDLOCR-LiteとローカルLLMで昭和の文書をOCR校正する

前回の記事ではNDLOCR-LiteをWindows 11で動かした。今回はApple Silicon Mac(M1 Max)で同じことをやりつつ、OCR結果をローカルLLMに渡して校正させるところまで持っていく。

リポジトリ: ndl-lab/ndlocr-lite

テスト環境

項目スペック
OSmacOS Tahoe 26.2
チップApple M1 Max
RAM64GB
Python3.13.11(Homebrew / miniconda)

CLI版セットアップ

GUI版もあるがバッチ処理やスクリプト連携を見据えてCLI版を入れる。

cd ~/projects
git clone https://github.com/ndl-lab/ndlocr-lite.git
cd ndlocr-lite
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Apple Silicon向けのonnxruntimeはwheel配布されているので、ビルドで詰まることはない。依存は全部すんなり入った。

サンプル画像で動作確認

リポジトリの resource/ にサンプル画像が3枚入っている。出力先ディレクトリを作って実行する。

mkdir -p output
cd src
python ocr.py --sourceimg ../resource/digidepo_2531162_0024.jpg --output ../output --viz True
[INFO] Intialize Model
[INFO] Inference Image
44
[INFO] Saving result on /Users/.../output/viz_digidepo_2531162_0024.jpg
Total calculation time (Detection + Recognition): 2.3908920288085938

44領域検出、2.39秒で処理完了。TXT / JSON / XML / 可視化画像がすべて出力された。

Windows版(Ryzen 7 5800HS)では同じ画像で3.30秒だったので、M1 Maxのほうが若干速い。GPU未使用のCPU推論のみでこの差が出るのはApple Siliconのメモリ帯域の恩恵か。

OCR結果

昭和38年(1963年)の国会図書館スタッフ・マニュアル。

(z)気送子送付管
気送子送付には、上記気送管にて送付するものと、空
気の圧縮を使用せず,直接落下させる装置の二通りがあ
る。後者の送付雪は出納台左側に設置されており.5
3.1の各層ステーションに直接落下するよう3本の管
が通じ投入ロのフタに層表示が記されている。

Windows版と同じ結果。「管」→「雪」、「(ヱ)」→「(z)」といった誤認識も同様に出ている。Mac固有の問題はなく、同一モデル・同一出力であることが確認できた。

OCR結果をローカルLLMで校正する

OCRの誤認識を人間が目視で直すのは面倒だし、旧字体や文脈依存の誤変換はパターンマッチでは対応しきれない。LLMに「元の文書はこういう文脈だから、ここは誤認識だろう」と推測させる。

校正用のLLMにはQwen 3.5(35B Dense、24GB)を選んだ。M1 Max 64GBなら余裕で載る。OCR校正は旧字体の文脈推測が要るので、推論速度よりパラメータ数を優先した。

ollamaのバージョン問題

ここでハマった。Homebrew版のollamaは0.17.0だが、Qwen 3.5は2/25に公開されたばかりで、安定版では対応していない。

Error: pull model manifest: 412:
The model you are attempting to pull requires a newer version of Ollama
that may be in pre-release.

brew upgrade ollama しても0.17.0のまま。公式のインストールスクリプト(curl -fsSL https://ollama.com/install.sh | sh)でも同じバージョンが入る。

GitHubのReleasesを確認すると、v0.17.1-rc2(2/24公開)のpre-release版が必要だった。

# pre-release版のインストール
curl -L https://github.com/ollama/ollama/releases/download/v0.17.1-rc2/ollama-darwin -o /usr/local/bin/ollama
chmod +x /usr/local/bin/ollama

実際にはアセット名が ollama-darwin ではなく ollama-darwin.tgz だったので、以下のコマンドで入れた。

curl -L -o /tmp/ollama-darwin.tgz https://github.com/ollama/ollama/releases/download/v0.17.1-rc2/ollama-darwin.tgz
tar xzf /tmp/ollama-darwin.tgz -C /tmp
sudo mv /tmp/ollama /usr/local/bin/ollama
sudo chmod +x /usr/local/bin/ollama

さらにHomebrewのollamaがサービスとして常駐していると、ポート11434が掴まれたまま新しいバイナリで ollama serve できない。brew services stop ollama で止めてから起動する必要がある。

出たばかりのモデルを使うときはollamaのpre-release追いが必要になる。覚えておくと吉。

校正テスト(thinking ONのまま)

ollama run でOCR結果を食わせてみた。

cat output/digidepo_2531162_0024.txt | ollama run qwen3.5:35b \
  "以下はOCRで読み取った昭和38年の国会図書館スタッフ・マニュアルのテキストです。
   OCRの誤認識を文脈から推測して校正してください。
   修正箇所は【修正前→修正後】の形式で示してください。"

校正結果:

修正前修正後理由
(z)(a)後の記号「b」「c」との整合性
送付雪送付箱装置・台の名称
5 3.15.3.1セクション番号
通じ通じて助詞の補足
投入ロ投入口カタカナ「ロ」→漢字「口」
投入優投入時動作のタイミング
5.3./心5.3.1 のセクション番号の誤読
待成待機係員の動作
気送子送子管気送子送付管セクションタイトルとの整合性
一方交通一方通行通路の表現
受けー方受け口設備としての開口部

校正精度は悪くない。「送付雪→送付箱」「投入ロ→投入口」「待成→待機」あたりは文脈から正しく推測できている。

ただし問題が一つ。Qwen 3.5はデフォルトでthinkingモード(推論過程の出力)がONになっていて、4KB程度のOCRテキストに対して144KBもの思考ログを吐いた。校正結果にたどり着くまでの待ち時間も長い。プロンプトに /no_think を入れても効かなかった。

thinkingモードを切る

ollamaでQwen 3.5のthinkingを切るには、ollama run--think=false フラグを使う。

cat output/digidepo_2531162_0024.txt | ollama run qwen3.5:35b --think=false \
  "以下はOCRテキストです。誤認識を校正し、修正箇所のみ表形式で出力してください。
   説明不要。列は【修正前|修正後】のみ。"

thinking OFFの校正結果:

修正前修正後
送付雪送付管
5 3.15.3.1
投入ロ投入口
投入優投入は
5.3./心5.3.の
待成待機
気送子送子管気送子送付管
受けー方受け入れ方
たて二っ折りたて二つ折り

体感速度は大幅に改善。思考ログが消えた分、出力も簡潔になった。

thinking ON/OFFで校正結果が変わる

同じOCRテキストでもthinkingのON/OFFで校正結果にブレが出た。

誤認識thinking ONthinking OFF
送付雪送付箱送付管
投入優投入時投入は
5.3./心5.3.1 の5.3.の
受けー方受け口受け入れ方

thinking ONのほうが文脈をじっくり検討する分、「送付雪→送付管」(セクションタイトルとの整合性)や「投入優→投入時」(動作のタイミング)など、推測の精度が高い印象。一方でthinking OFFは処理が速い代わりに、文脈の深い読みが甘くなる。

OCR校正のように「正解が一意に決まらない誤字を文脈から推測する」タスクだと、thinkingモードの有無が精度に直結する。速度を取るか精度を取るかはケースバイケースだが、校正用途ならthinking ONのまま使って、出力からthinking部分だけ除去するのが現実的かもしれない。

プロンプトを変えてみる: 校正済みフルテキスト出力

表形式だとプロンプトの指示に引きずられて精度が落ちる可能性がある。「校正した文章だけ出せ」にしてみた。

cat output/digidepo_2531162_0024.txt | ollama run qwen3.5:35b --think=false \
  "以下はOCRで読み取ったテキストです。誤認識を校正した文章だけを出力してください。
   説明は不要です。"

校正結果と元テキストの差分:

箇所OCR原文校正後判定
送付雪送付雪送付管OK
.5 3.1.5 3.15.3.1OK
投入ロ投入ロ投入口OK
投入優投入優投入時OK(表形式では「投入は」だった)
5.3./心5.3./心5.3. の微妙(「5.3.1 の」が正しそう)
待成待成待機OK
気送子送子管気送子送子管気送子送付管OK
一方交通一方交通そのまま見逃し
受けー方受けー方受け入れ方微妙
CCcOK
二っ折り二っ折り二つ折りOK
(z)(z)そのまま見逃し

フルテキスト出力にすると「投入優→投入時」が正しく出た。表形式のときは「投入は」だったので、プロンプトの出力形式が校正精度に影響している。

一方で「一方交通→一方通行」と「(z)」は見逃している。thinking OFFだと文脈の浅い読みになるので、明らかな字形エラー(投入ロ→投入口)は拾えるが、語彙レベルの誤り(一方交通)や記号の不整合((z)→(a))は取りこぼしやすい。

日本語特化モデルで試す: Qwen3 Swallow

東工大(岡崎研・横田研)とAISTが共同開発した日本語特化モデル Qwen3 Swallow。Qwen3ベースに日本語の継続事前学習とRLを施したもので、8Bと32Bで同サイズ帯のオープンLLM最高精度を謳っている。

公式にはGGUFを配布していないが、有志が変換したものがHuggingFaceにある。今回は30B-A3B(MoE、実質3Bアクティブ)のQ4_K_M(18.6GB)を使った。

ollama pull hf.co/yuseiito/Qwen3-Swallow-30B-A3B-RL-v0.2-GGUF:Q4_K_M

同じプロンプトで校正テスト。

cat output/digidepo_2531162_0024.txt | ollama run \
  hf.co/yuseiito/Qwen3-Swallow-30B-A3B-RL-v0.2-GGUF:Q4_K_M --think=false \
  "以下はOCRで読み取ったテキストです。誤認識を校正した文章だけを出力してください。
   説明は不要です。"

--think=false を指定したが、thinking部分が出力に含まれてしまった。プロンプト内に /no_think を入れても同様。プロンプト自体のエコーバックも混入していた。

公式ollamaライブラリのQwen3.5ではthinking制御が効くので、MoEだから効かないわけではなく、有志によるGGUF変換の過程でthinking制御用のテンプレート設定やトークンが欠落している可能性が高い。公式がGGUFを配布していないモデルを使う場合、こういった制御系の機能が動かないリスクがある。

校正結果の比較:

箇所Qwen3.5 35BSwallow 30B-A3B
送付雪送付管装置
投入ロ投入口投入口
投入優投入時投入後
5.3./心5.3. の5.3. 心(そのまま)
待成待機待機
気送子送子管気送子送付管気送子送管
一方交通そのまま一方通行
受けー方受け入れ方受け側
二っ折り二つ折り二つ折り

Qwen3.5が見逃した「一方交通→一方通行」をSwallowは拾えている。「受けー方→受け側」も日本語としてより自然。日本語の語彙力で差が出ている。

一方で「送付雪→装置」はSwallowの独自解釈で、セクションタイトルの「送付管」との整合性を考えるとQwen3.5の「送付管」のほうが正確。「気送子送子管→気送子送管」も微妙で、文脈的には「気送子送付管」(Qwen3.5)のほうが合っている。

どちらも一長一短で、モデル単体で完璧な校正は難しい。実用的には両方のモデルの出力をdiffして、食い違う箇所だけ人間が判断するのが現実的なワークフローかもしれない。

画像を直接LLMに投げる

Qwen3.5はマルチモーダル対応で画像を読める。OCRを通さず元画像をそのままLLMに投げれば、OCRの誤認識自体が発生しない。NDLOCR-Liteの出力と突合するもう1本の軸になる。

CLIでは画像を読めない

ollama run で画像ファイルを引数に渡してみたが、「画像を読み取る機能は備えておりません」と返された。ファイルパスの位置を変えても同様。

# どちらもNG
ollama run qwen3.5:35b --think=false "画像のテキストを読んで" resource/digidepo_2531162_0024.jpg
ollama run qwen3.5:35b --think=false resource/digidepo_2531162_0024.jpg "画像のテキストを読んで"

ollama show qwen3.5:35b でvision対応と表示されるのに読めない。

API経由なら読める

base64エンコードした画像をAPIの images パラメータに渡すと読めた。

import json, urllib.request, base64

img = base64.b64encode(open('resource/digidepo_2531162_0024.jpg', 'rb').read()).decode()
data = json.dumps({
    'model': 'qwen3.5:35b',
    'messages': [{
        'role': 'user',
        'content': 'この画像に書かれているテキストをすべて読み取って出力してください。説明は不要です。',
        'images': [img]
    }],
    'stream': False,
    'think': False
}).encode()
req = urllib.request.Request(
    'http://localhost:11434/api/chat',
    data=data,
    headers={'Content-Type': 'application/json'}
)
resp = json.loads(urllib.request.urlopen(req, timeout=300).read())
print(resp['message']['content'])

NDLOCR-Lite vs Qwen3.5画像直読み

同じ画像(昭和38年スタッフ・マニュアル)での比較:

箇所NDLOCR-LiteQwen3.5 画像直読み
(z) / (2)(z)(2)
送付雪 / 送付管送付雪送付管
投入ロ / 投入口投入ロ投入口
投入優 / 投入後投入優投入後
待成 / 待機待成待機
気送子送子管気送子送子管気送子送子管
一方交通一方交通一方交通
受けー方 / 受け一方受けー方受け一方

NDLOCR-Liteが誤認識した「(z)」「送付雪」「投入ロ」「投入優」「待成」をQwen3.5は画像から正しく読み取れている。特に「(z)→(2)」はテキストベースの校正LLMでは一度も正解が出なかった箇所で、画像を見て初めて正しく認識できた。

一方で「一方交通」「気送子送子管」はQwen3.5の画像直読みでも誤認識のまま。字形が似ている文字の取り違え(ロ→口)はLLMのほうが強いが、語彙レベルの判断(一方交通→一方通行)はSwallowのようなテキスト校正のほうが拾える。

画像+OCRテキストの同時入力

じゃあ画像とOCRテキストを両方渡して「画像と見比べて校正して」と頼めばさらに精度が上がるのでは?と思って試した。

prompt = f'''以下はこの画像をOCRで読み取ったテキストです。
画像と見比べて誤認識を校正した文章だけを出力してください。説明は不要です。

{ocr_text}'''

# messagesのimagesに画像のbase64を渡す(コードは上と同じ)

3パターンの比較:

箇所画像のみテキストのみ画像+OCRテキスト
(z)(2)(z)(z)
送付雪送付管送付管送付管
投入ロ投入口投入口投入口
投入優投入後投入時投入後
5.3./心5,3,15.3. の5、3、1
待成待機待機待機
気送子送子管気送子送子管気送子送付管気送子送子管
一方交通一方交通一方交通一方交通
受けー方受け一方受け入れ方受け一方

面白いことが起きた。画像のみだと「(z)→(2)」と正しく読めていたのに、OCRテキストを一緒に渡すと「(z)」のまま修正されなくなった。画像を見て「確かに(z)って書いてある」とOCRテキスト側に引きずられてしまったらしい。

OCRテキストという「答え」を先に見せると、LLMはそれをアンカーにしてしまう。画像だけ渡したほうが先入観なく読めるので、むしろ精度が高くなるケースがある。人間が「他人の答案を見てから採点すると引きずられる」のと同じ現象がLLMでも起きている。

人間が見たら原文通りだった

ここで実際に元画像を目視確認して、全箇所の「人間の目で見た結果」をまとめた。

箇所NDLOCR-LiteQwen3.5 画像直読みSwallow人間の目視
(z) / (2)(z)(2)(2)
送付雪 / 送付管送付雪送付管装置送付管
投入ロ / 投入口投入ロ投入口投入口投入口
投入優投入優投入後投入後投入後
5 3.1 / 5,3,1.5 3.15,3,15、3、1
待成 / 待機待成待機待機待機
気送子送子管気送子送子管気送子送子管気送子送管気送子送子管
一方交通一方交通一方交通一方通行一方交通
受けー方 / 受け一方受けー方受け一方受け側受け一方

「5、3、1」は意外な発見だった。テキストだけ見ると「5.3.1」というセクション番号に見えるが、図2の送付管の図に「5層」「3層」「1層」と3本の管が描かれている。5階・3階・1階の各ステーションに落下する、という意味で「5、3、1の各層ステーション」が正解。Qwen3.5の画像直読みが「5,3,1」と出したのは図を見て正しく読んでいて、テキスト校正LLMが「5.3.1」に直したのはむしろ間違い。テキストだけでは判断できない情報が図にあった。

「一方交通」「受け一方」も原文にそのまま書いてある。Swallowが「一方通行」「受け側」に直したのは、昭和38年の用語を現代語に書き換えてしまっただけで、OCR校正ではなく原文の改変。

LLMは「現代語として不自然だから直す」のと「OCRの誤認識を直す」の区別がつかない。60年前の文書には当時の語彙や言い回しがそのまま使われていて、今の日本語の感覚で校正すると原文を改変してしまう。これはプロンプトで「原文の語彙は尊重して、明らかな文字化けだけ直せ」と指示しても完全には防げないだろう。

ベストな組み合わせ

ここまでの結果を踏まえると、精度を最大化するなら3段構えになる。

  1. NDLOCR-Lite でOCR(高速、座標情報あり)
  2. Qwen3.5で画像直読み して突合(OCRが取れない文字を補完)
  3. テキストベースLLM(Swallow等)で語彙レベルの校正

画像+OCRテキストの同時入力は一見よさそうだが、OCRの誤認識にLLMが引きずられるリスクがある。それぞれ独立に処理して突合するほうが結果的に精度が高い。

ただし3のテキスト校正は、古い文書では「正しい原文を現代語に書き換えてしまう」リスクがある。LLMの校正結果を鵜呑みにせず、最終的に人間が原文画像と突合して判断する工程は省けない。LLMが絞り込んでくれた差分を人間が確認する、という分業が現実的なラインになる。