文字認識できないPDFに困ったので、Docker + NiceGUIで日本語OCR Webアプリを作った【OCR2TEXT_r002】
はじめに
仕事で資料をやり取りしていると、「文書をPDFでください」とお願いすることがあります。
PDFなら、そのまま文字をコピーしたり、検索したり、必要ならテキスト化できますが、実際にもらったPDFを開いてみると、見た目は普通の日本語文書なのに文字を選択できないこと多くないですか? pdfならOKと考えていることが多いですね。そして、Pythonでテキストを抽出しても空でした。
最初はスキャンPDFだと思ったのですが、調べてみると少し違いました。ページの中に文字情報がなく、文字の形が線や輪郭として描画されているPDFでした。
数ページなら目で確認できますが、数十ページになると厳しいです。表もありますし、後から検索できるPDFにもしておきたい。そこで、OSに依存しないDocker環境で起動できて、ブラウザからGUIで操作できる日本語OCRアプリを作ることにしました。
最初はOCRを実行してテキストが取れれば十分だと思っていましたが、実際に使うと次の問題が出てきました。
- 39ページ程度でもOCRにかなり時間がかかる
- OCR中に画面が止まったように見える
- 処理途中で失敗すると最初からやり直しになる
- OCR処理済みPDFを作るために、もう一度OCRすると時間が倍になる
- 表を単純な文字列として読むと行・列が崩れる
- 長いOCR結果を1画面に全部表示すると確認しづらい
- 保存先を用途に合わせて選びたい
このあたりを一つずつ直していったものが OCR2TEXT_r002 です。
細かな表や複雑な帳票の解析はまだ課題が残っています。ただ、一般的な日本語文書のOCR、ページごとの確認、罫線表の抽出、OCR処理済みPDFの作成までなら、ひとまず実用に耐えられるところまで来ました。
今後の拡張も考えて、redisとceleryを使っています。
また、Qt6版も作成中です。
ダウンロード
ソース一式は次からダウンロードできます。
ZIPファイル : https://godo-tys.jp/downloads/japanese_pdf_ocr_nicegui_r002.zip
ファイル名は次です。
japanese_pdf_ocr_nicegui_r002.zip
作ったもの
ブラウザからPDFを1ファイル選び、OCR Jobを作成します。
処理が終わると、次の形式で結果を取得できます。
- TXT
- Markdown
- JSON
- OCR処理済みPDF
- 表CSV
- 表Markdown
- 表HTML
- 一括ZIP
PDFは 1 Job = 1 PDF としています。
複数PDFを一度に選べるUIにすると、どのPDFが現在のJobなのか分かりにくくなります。そこでr002では、1回のOCRで1つのPDFだけを扱うようにしました。
A.pdf
↓
OCR Job A
↓
完了
↓
A.pdfを削除
↓
B.pdf
↓
OCR Job B
複数PDFの一括処理を追加する場合も、1 Jobへまとめるのではなく、PDFごとに独立Jobを作る方が扱いやすいと考えています。
構成
Web UIにはNiceGUIを使っています。OCR処理はCelery workerへ分離し、Redisをbrokerとして使います。
Docker Composeでは、主に次の3サービスを動かします。
redis
ocr-web
ocr-worker
ocr-web がNiceGUI、ocr-worker がCeleryです。
OCRをNiceGUIのプロセス内で直接実行しないようにしたことで、長いPDFでもWeb画面が固まりにくくなりました。
PDFを全部OCRしない
OCRは時間がかかるので、すべてのPDFを同じ方法で処理するのは無駄があります。
まずページに文字層があるか確認します。
PDF Page
|
+-- 文字層あり ------> PyMuPDFで直接抽出
|
+-- 文字層なし ------> 画像化してOCR
文書種別は次の4種類に分けています。
text
vector_outline
scan
mixed
今回困ったPDFは vector_outline に近いものでした。
見た目は文字なのですが、PDF内部には文字コードがありません。文字の輪郭が大量の描画要素として入っています。この場合は通常の get_text() では取れないので、画像化してOCRします。
220 dpiの高速モード
最初は300 dpiを標準にしていました。
ところがA4を300 dpiで画像化すると、1ページでもかなり大きくなります。数十ページをCPUでPaddleOCRへ流すと、それなりに時間がかかります。
今回のように元がきれいなPDFなら、220 dpiでも十分読めることが多かったため、現在は次の3つを選べるようにしています。
高速 220 dpi
高品質 300 dpi
カスタム
スキャン品質が悪い資料では300 dpiの方がよい場合もありますが、普段使いは220 dpiから試すようにしています。
OCRを2回しない
途中で大きく変えたところです。
最初は、
PaddleOCR
↓
テキスト出力
↓
Tesseractでもう一度OCR
↓
OCR処理済みPDF
としていました。
これでは39ページなら、実質78ページ分のOCRを行うことになります。
r002では、最初のOCRで得た文字とbboxをそのまま使います。
OCR
|
+-- text
+-- confidence
+-- bbox
|
v
PyMuPDFで透明文字層を重ねる
|
v
OCR処理済みPDF
そのため、OCR処理済みPDFを作るための再OCRはありません。
PDFを開いた見た目は元のままで、文字検索やコピーができるようになります。
長い処理はCeleryへ
数十ページのPDFをWebリクエストの中で処理すると、画面側から見ると止まっているのか動いているのか分かりません。
そこでOCR処理をCeleryへ移しました。
NiceGUI側は、
PDFアップロード
Job作成
状態表示
キャンセル
再開
結果確認
を担当します。
重い処理はworker側です。
PDF Render
OCR
表解析
成果物生成
画面にはページ単位で進捗を表示します。
Page 12 / 39
OCR処理中
████████░░░░░ 31%
ページ単位でcheckpointを保存する
長いPDFでは、途中でworkerが停止したり、1ページだけOCRに失敗したりすることがあります。
そこで、1ページ終わるごとに結果を保存します。
Page 1 -> checkpoint
Page 2 -> checkpoint
Page 3 -> checkpoint
...
例えば19ページまで完了してworkerを止めた場合、再開すると20ページ目から続けます。
Page 1 - 19 SKIP
Page 20 OCR再開
Page 21 OCR
...
39ページ目で失敗したから1ページ目からやり直す、ということはありません。
失敗ページだけを再試行する機能も入れています。
キャンセルは強制killしない
キャンセル時にCelery workerを強制終了すると、OCRライブラリや書き込み途中のファイルを巻き込む可能性があります。
そのため、Jobにキャンセル要求を保存し、ページ境界で止めるようにしています。
キャンセル
↓
cancel_requested = true
↓
現在ページ終了
↓
CANCELLED
checkpointは残るので、その後に途中から再開できます。
ブラウザを再読込してもJobを戻す
OCR中にブラウザを閉じてもJob自体はworker側で動いています。
Job IDを保存しているため、ブラウザを開き直したときに状態を復元できます。
ブラウザを閉じる
↓
Celeryは処理継続
↓
ブラウザを再表示
↓
Job復元
Job IDを直接入力して復元することもできます。
OCR結果はページ単位で表示
最初はOCRした全文を1つのテキスト欄へ表示していました。
数十ページになると、これがかなり見づらくなります。
そこで現在は、
Page 1
Page 2
Page 3
...
とページを切り替え、選択ページのOCR結果だけを表示します。
同じページ番号を、
- OCR結果
- レイアウト確認
- 検出表
で共通利用しています。
表示ページ = Page 12
|
+-- OCR結果 Page 12
+-- レイアウト Page 12
+-- 検出表 Page 12
表側を操作したらレイアウトが別ページになっていた、といった状態を避けるためです。
表解析
表は、ページ全体のOCR文字列からスペースで列を推測するのではなく、罫線を使っています。
ページ画像
|
+-- 横罫線を検出
+-- 縦罫線を検出
|
v
セル候補
|
+-- row
+-- col
+-- rowspan
+-- colspan
|
v
OCR済み文字bboxをセルへ割当
結果は、
CSV
Markdown
HTML
へ出力します。
表解析はまだ発展途上
ここは正直に書いておきます。
罫線がはっきりした一般的な表ならかなり使えますが、次のような表はまだ苦手です。
- 細かな結合セルが多い
- 罫線が薄い、または途中で切れている
- セルの中に図がある
- 表とフロー図が混在している
- 帳票のレイアウトが複雑
Excelへ完全復元するような精度にはまだ届いていません。
それでも、本文OCRが主目的で、表についてはCSV化のたたき台が欲しいという用途なら十分使える場面が増えてきました。
ソースオープンですので、どなたか表解析の精度を上げていただけると助かります。
保存方法
保存は3種類に分けています。
サーバー保存(自動)
OCR完了時に、Dockerへマウントした保存先へ自動保存します。
.env で保存ルートを指定します。
OCR_EXPORT_HOST_DIR=/home/tys/ocr_results
Web画面では、その配下のサブフォルダを指定できます。
[pdfフォルダー]/2026-09
個別ダウンロード
必要な成果物だけ取得できます。
TXT
Markdown
JSON
OCR処理済みPDF
一括ZIP
保存先を指定して保存
対応環境では保存場所を指定できます。
OCR処理済みPDFを保存
すべての成果物を保存
保存先指定が利用できない環境では、同じ操作から通常ダウンロードへ切り替えます。
利用者側はブラウザのAPIを意識する必要はありません。
PaddlePaddleのCPU実行で詰まったところ
途中で次のエラーが出ました。
(Unimplemented) ConvertPirAttribute2RuntimeAttribute not support
[pir::ArrayAttribute<pir::DoubleAttribute>]
CPU / oneDNN周辺で発生したため、r002ではPaddlePaddleを次に固定しています。
paddlepaddle==3.2.2
paddleocr==3.7.0
paddlex==3.7.2
CPU版は、まず安定性優先でoneDNNをOFFにしています。
OCR_DEVICE=cpu
OCR_ENABLE_MKLDNN=0
ここは環境によって変わる可能性があるので、将来的にGPU版を作る場合は別構成にした方がよさそうです。
インストール
Ubuntu 24.04 / WSL2上のDocker Engineで使っています。
ZIPを展開します。
unzip japanese_pdf_ocr_nicegui_r002.zip
cd japanese_pdf_ocr_nicegui_r002
環境ファイルを作ります。
cp .env.example .env
必要なら保存先を変更します。
OCR_EXPORT_HOST_DIR=/home/[USERNAME]/ocr_results
起動します。
./scripts/rebuild_r002.sh
ブラウザで開きます。
http://localhost:8080
品質ゲート
以前、Ruffのformatやimport順で止まったことがあったため、現在の再構築スクリプトは、いきなり稼働中コンテナを止めないようにしています。
test image build
↓
ruff format
↓
compileall
↓
ruff format --check
↓
ruff check
↓
pytest
↓
PASSした場合だけ既存コンテナ停止
↓
Redis / Worker / Web更新
品質ゲートで失敗しただけで、先に動いていたWebアプリまで止まるのを避けるためです。
テストだけ実行する場合は次です。
./scripts/test.sh
現在の到達点
最初は「文字の読めないPDFをテキストにしたい」だけでした。
作っている途中で、長時間処理、再開、表、保存、OCR処理済みPDFなど、実際に使うために必要なものがかなり見えてきました。
r002では、次の用途ならかなり使いやすくなったと感じています。
- 文字層のない日本語PDFをテキスト化したい
- 数十ページのPDFを放置してOCRしたい
- 処理途中で止まっても続きを実行したい
- OCR結果をページごとに確認したい
- 元の見た目を残したOCR処理済みPDFが欲しい
- 比較的単純な表をCSVやMarkdownへ出したい
一方で、細かな表や複雑な帳票についてはまだ改善の余地があります。
まずは「普通の文書PDFを、あとで検索・再利用できる形へ直す」用途を中心に使っていく予定です。
参考リンク
- NiceGUI
https://nicegui.io/ - PaddleOCR
https://www.paddleocr.ai/ - Celery
https://docs.celeryq.dev/ - Redis
https://redis.io/docs/latest/ - PyMuPDF
https://pymupdf.readthedocs.io/ - Tesseract OCR
https://tesseract-ocr.github.io/ - OpenCV
https://docs.opencv.org/ - Docker
https://docs.docker.com/

