前回(第3回)では、図面PDFに埋め込まれた文字の中から、部屋名辞書とフォントサイズのルールで「部屋名らしき文字列」だけを青枠で絞り込めるようにしました。ただし、ここまでの仕組みはCADソフトが文字情報を埋め込んだPDFでしか動きません。紙の図面をスキャンしたPDFでは、文字が「絵」としてしか存在しないため、検出件数は0件になります。
第4回の今回は、スキャン図面・画像PDFの部屋名を、ローカルOCR(画像から文字を読み取る技術。今回はオープンソースのTesseractを使用)で読み取れるようにします。画像を社外に送らないローカル処理のまま、前回までの部屋名の絞り込み・ハイライト表示にそのままつなげます。結論から言うと、架空のサンプル図面では部屋名7件をすべて読み取れましたが、そこに至るまでに「日本語が細切れで返ってくる」という落とし穴がありました。その対処と、実際に測った精度・処理時間もあわせて紹介します。
今回作る機能と完成イメージ
今回できるようになることは次の3つです。
- 表示中のページに埋め込み文字が1件も無いときは、自動でOCRに切り替える(CAD由来の図面は従来どおり高速な文字抽出を使う)
- 画像化したページをTesseractで日本語OCRし、第2回と同じ形(文字列・座標・文字の大きさ)の単語リストに変換する
- OCRの結果を第3回の部屋名辞書で絞り込み、プレビュー上に赤枠・青枠でハイライトする
使ったパッケージは、Tesseractを呼び出すPythonライブラリの pytesseract 0.3.13(2024-08-16公開)と、画像を扱う Pillow 12.1.0(2026-01-02公開)です。Tesseract本体(検証は5.3.4)と日本語の学習データは、Pythonパッケージとは別にOSへインストールします。
仕組みの説明:埋め込み文字が無いページだけOCRに回す
処理の流れは次のとおりです。
| 手順 | やること | 使うモジュール |
|---|---|---|
| 1 | 埋め込み文字の抽出を試す | text_extractor.py(第2回) |
| 2 | 0件ならページを150dpiで画像化し、グレースケール化する | pdf_loader.py(第1回)+ocr_local.py |
| 3 | TesseractでOCRし、単語ごとの位置・信頼度を受け取る | ocr_local.py |
| 4 | 信頼度の低い語を捨て、細切れの断片を1語に連結する | ocr_local.py |
| 5 | 部屋名辞書で絞り込み、ハイライト表示する | room_matcher.py(第3回) |
「まず埋め込み文字、無ければOCR」の順にしたのは、速さと正確さが桁違いだからです。今回の検証環境では、埋め込み文字の抽出が1ページ約0.3ミリ秒で正確なのに対し、OCRは約0.22秒かかり読み間違いもありえます。
OCRの結果は第2回のExtractedWordと同じ型で返すため、第3回の絞り込みやハイライト表示を変更せずに再利用できます。
実装
ファイル構成(第4回時点の差分)
code/
├ app/
│ ├ core/
│ │ └ ocr_local.py …【今回追加】画像化・前処理・Tesseract OCR・断片の連結
│ └ ui/
│ └ main_window.py …【今回変更】埋め込み文字が0件のページはOCRへ自動フォールバック
├ tests/
│ └ test_ocr_local.py …【今回追加】pytest 24件(うち7件は実際にTesseractを呼ぶ)
├ tools/
│ ├ make_sample_pdf.py …【今回変更】--scanned で埋め込み文字の無い画像だけのPDFを作る
│ └ capture_screenshots.py …【今回変更】第4回用の画面を撮影
└ requirements.txt …【今回変更】pytesseract==0.3.13, Pillow==12.1.0 を追加
Tesseractで単語と位置を受け取る(app/core/ocr_local.py)
pytesseractには文字列だけを返すimage_to_string()もありますが、プレビューに枠を描くには位置が必要なので、単語ごとの位置と信頼度(0〜100)を返すimage_to_data()を使います。
# app/core/ocr_local.py(抜粋)
DEFAULT_OCR_LANG = "jpn"
DEFAULT_PSM = 11 # sparse text:まばらな文字列を順不同で探すモード
DEFAULT_MIN_CONFIDENCE = 40.0
DEFAULT_MERGE_GAP_RATIO = 1.0
def ocr_image_to_words(image, *, lang=DEFAULT_OCR_LANG, psm=DEFAULT_PSM, dpi=DEFAULT_DPI,
min_confidence=DEFAULT_MIN_CONFIDENCE,
merge_gap_ratio=DEFAULT_MERGE_GAP_RATIO,
scale=1.0, page_number=0) -> list[ExtractedWord]:
if not is_tesseract_available():
raise OcrError("Tesseract OCR本体が見つかりません。...")
config = f"--psm {psm} --dpi {dpi}"
try:
data = pytesseract.image_to_data(image, lang=lang, config=config,
output_type=pytesseract.Output.DICT)
except pytesseract.TesseractError as exc:
raise OcrError(f"OCR処理に失敗しました(言語データ'{lang}'...): {exc}") from exc
rows = [row for row in _parse_rows(data) if row.confidence >= min_confidence]
if merge_gap_ratio > 0:
rows = _merge_line_fragments(rows, merge_gap_ratio)
words = []
for row in rows:
bbox = (row.left / scale, row.top / scale,
(row.left + row.width) / scale, (row.top + row.height) / scale)
words.append(ExtractedWord(text=row.text, bbox=bbox,
font_size=row.height / scale, page_number=page_number))
return words
ポイントは3つです。
- PSM(ページの分割方法)は11を既定に:図面の部屋名は文章ではなく、罫線の間に短い語が散らばっています。PSM 3(Tesseractの既定)・6・11を同じサンプルで比べたところ、部屋名7件の検出は3で5件、6と11で7件でした。6と11は同じ結果だったため、「まばらな文字を順不同で探す」という図面の性質に合った11を既定にしています
- 座標はPDFの単位に戻す:ピクセル座標を画像化の倍率(
scale)で割り、第2回と同じPDF座標にします - フォントサイズは文字の高さで代用:第3回の「8〜40pt」の範囲チェックもこの概算値で行います
落とし穴:日本語が「キッ」「チン」と細切れで返ってくる
最初に上のコードを連結処理なしで動かしたところ、架空サンプル図面の部屋名7件のうち、辞書と完全一致したのは「居間」の1件だけでした。Tesseractの返した結果を見ると、文字自体は正しく読めているのに「キッ」「チン」、「寝室」「1」、「洗面」「所」のように細切れの「単語」になっていたのが原因です。日本語には単語を区切る空白が無いため、このような分割になることがあります。「寝室1」は「寝室」として一致してしまい、番号が落ちるという別の問題も起きていました。
そこで、Tesseractが同じ行と判定し、かつ隙間が文字1文字分程度以内の断片どうしを1語に連結する処理を入れました。
# app/core/ocr_local.py(抜粋)
def _merge_line_fragments(rows: list[_OcrRow], gap_ratio: float) -> list[_OcrRow]:
by_line: dict[tuple[int, int, int], list[_OcrRow]] = {}
for row in rows:
by_line.setdefault(row.line_key, []).append(row) # (block_num, par_num, line_num)
merged: list[_OcrRow] = []
for line_rows in by_line.values():
group: list[_OcrRow] = []
for row in sorted(line_rows, key=lambda r: r.left):
if group:
prev_right = max(r.right for r in group)
limit = min(row.height, _median_height(group)) * gap_ratio
if row.left - prev_right > limit: # 離れていれば別の語
merged.append(_combine(group))
group = []
group.append(row)
if group:
merged.append(_combine(group))
return merged
連結後の信頼度は、断片のうち最も低い値にしています。「どこか1文字でも怪しければ、その語全体を怪しい」とみなすためです。また「1」「所」のように一部の文字だけ縦長の枠で返ることがあったため、文字の高さは断片の中央値を使っています。この連結を入れた結果、同じサンプルで7件すべてが正しい表記(「寝室1」「キッチン」など)で候補に残るようになりました。
画像の前処理:ノイズ除去は選べるように、二値化はしない
OCRの前処理としてよく紹介されるのが、二値化(画像を白と黒の2色にする処理)とノイズ除去です。今回は両方を試したうえで、次のようにしました。
# app/core/ocr_local.py(抜粋)
def preprocess_for_ocr(image: Image.Image, *, denoise: bool = False) -> Image.Image:
gray = image.convert("L") # グレースケール化は常に行う
if denoise:
gray = gray.filter(ImageFilter.MedianFilter(3)) # ごま塩ノイズの除去
return gray
固定のしきい値(明るさ160)で二値化すると、傾けた画像やノイズ入りの画像で取りこぼしが増えました。Tesseractは内部で画像ごとにしきい値を自動で決めて二値化しているため、先に固定値で白黒にすると、その調整を邪魔してしまうようです。そのため二値化は入れていません。ノイズ除去(周囲9画素の中央値で置き換えるメディアンフィルタ)は、ごま塩状のノイズには大きく効く一方、ノイズの無い傾いた画像では逆効果になる場面があったため、既定では無効にして切り替えられるようにしています。
メイン画面:0件のときだけOCRへフォールバック(app/ui/main_window.py)
「3. 文字を検出」「4. 部屋名候補を絞り込み」の両ボタンから共通で呼ぶメソッドにフォールバックの判断をまとめました。
# app/ui/main_window.py(抜粋)
def _detect_words_for_current_page(self) -> tuple[list[ExtractedWord], bool] | None:
if self.preview.document is None:
return None
try:
words = extract_words(self.preview.document, self.preview.page_number)
except PdfLoadError as exc:
messagebox.showerror("文字抽出エラー", str(exc))
return None
if words:
return words, False # 埋め込み文字があればOCRしない
if not is_tesseract_available():
return [], False # Tesseract未インストールなら「0件」として扱う
self.status_var.set("埋め込み文字が無いため、OCRで読み取っています...")
self.update_idletasks()
try:
ocr_words = ocr_pdf_page(self.preview.document, self.preview.page_number)
except (OcrError, PdfLoadError) as exc:
messagebox.showerror("OCRエラー", str(exc))
return None
return ocr_words, True
OCRを使った場合はステータス行に「(OCRで検出)」と表示し、どちらの方法で読んだ結果か区別できるようにしています。
動作確認の方法
Linux開発環境で確認できたこと
pytest:合計82件がすべて成功(うちtests/test_ocr_local.pyが新規24件)。Tesseractを呼ばないテスト(座標の変換、信頼度による足切り、断片の連結、前処理)はimage_to_data()の戻り値を差し替えて検証しています。実際にOCRするテスト7件は、Tesseract本体か日本語学習データが無い環境では自動でスキップされ、その場合は75件成功・7件スキップになることも確認しましたruff check .:エラーなし- 検証環境:Linux(Intel Xeon 2.1GHz・4コアの仮想環境)、Tesseract 5.3.4、Ubuntuパッケージの日本語学習データ
精度と処理時間は、tools/make_sample_pdf.py --scannedで作った架空サンプル図面(2ページ、部屋名は計7件)で測りました。元の図面をいったん画像にしてから画像だけを貼り直したPDFなので、埋め込み文字は0件です。
| 条件 | 部屋名7件のうち正しく候補に残った数 |
|---|---|
| 断片の連結なし | 1件(「居間」のみ) |
| 断片の連結あり(今回の既定)・300/150/100dpiでスキャン相当 | 7件 |
| 同・72dpiの粗いスキャン相当 | 6件(「バルコニー」を「パルコニー」と誤読) |
| 同・画像を3度傾けた場合 | 6件(「居間」を取りこぼし) |
| 同・ごま塩ノイズ(画素の3%)を乗せた場合 | 1件(ノイズ除去を有効にすると7件) |
連結ありの条件では、部屋名ではない語が候補に入る誤検出は0件でした(連結なしでは「寝室1」が番号の落ちた「寝室」として候補に入るなど、表記の誤った候補が3件ありました)。処理時間は、A4の1ページを150dpi(約1240×1754ピクセル)で画像化してOCRするまでが中央値で約0.22秒、300dpiでは約0.69秒でした。
開発環境(Linux/Xvfb上・Ubuntu標準のTkテーマ)で、画像だけのサンプルPDFを読み込み「4. 部屋名候補だけを絞り込んでハイライト」を押した後の画面です(部屋名が1画面に収まるよう50%に縮小しています)。Windows実機ではウィンドウの装飾や既定フォントなど見た目が異なります。

Windows実機での確認が必要なこと(このリポジトリでは未確認)
- Windows版Tesseractのインストール(日本語の追加言語の選択)と、PATHの設定を含めた動作
- 実在のスキャン図面での精度。今回の数字は、きれいなフォントで描いた架空の図面を画像化したものでの結果で、実物のスキャン(紙の黄ばみ、かすれ、手書きの書き込み、縦書きの部屋名)ではこれより悪くなることが想定されます
- A3以上の大判図面や多ページPDFでの処理時間と、OCR中に画面が固まる時間の体感
つまずきやすい点・セキュリティ上の注意
- pip installだけではOCRできない:pytesseractはTesseract本体を呼び出す窓口にすぎません。本体と日本語学習データ(
jpn.traineddata)を別にインストールする必要があります。本体はあるのに日本語データが無い、という状態もよく起きるため、is_ocr_language_available("jpn")で両方を確認できるようにしました - WindowsではPATHが通っていないことがある:インストーラーで入れても
tesseractコマンドにPATHが通っていないと、このツールからは「見つからない」と判定されます。PATHを通すか、pytesseract.pytesseract.tesseract_cmdにインストール先を指定します - 「埋め込み文字が1件でもあればOCRしない」判定の限界:スキャン画像の上に、ファイル名や日付だけが文字として追記されたPDFでは、埋め込み文字が数件あるためOCRに切り替わりません。こうした図面が多い場合は、判定の条件を見直す必要があります
- 濁点・半濁点と傾きに弱い:「バ」と「パ」の取り違えは、粗いスキャンで実際に起きました。部屋名辞書は完全一致なので、1文字違うだけで候補から漏れます。スキャン時の解像度(今回の結果では150dpi以上)と、まっすぐ読み取ることを運用ルールにしておくと安全です
- ローカル完結で動く:OCRはこのPCの中で処理し、図面画像を外部に送りません(次回の外部AIとはここが異なります)
発注者向けメモ
この回で確認しておきたいこと・工数の勘所
古い図面(紙をスキャンしただけのもの)は文字情報が無いためOCR頼みになり、精度・処理時間ともに悪化しやすくなります。今回の検証でも、埋め込み文字の抽出は1ページ0.3ミリ秒ほどで正確なのに対し、OCRは1ページ0.2〜0.7秒かかり、条件によっては1文字の読み間違いで部屋名を取りこぼしました。対象図面のうち古いスキャン物がどれくらい含まれるかで、必要な工数(前処理の調整、辞書の拡充、人の確認の手間)が大きく変わります。
また、今回の「7件中7件」は架空のきれいな図面での結果で、実際のスキャン図面での精度は測ってみるまで分かりません。見積もりの前に、代表的な図面(特に状態の悪いもの)を開発会社に渡し、試しに読ませてもらうのが確実です。
- ☐ 対象図面のうち、スキャン画像のみのPDFがおおよそ何割あるか把握したか
- ☐ スキャン図面の状態(解像度、傾き、手書きの書き込み、縦書きの部屋名)の見本を用意したか
- ☐ 今後新たにスキャンする場合の条件(例:150dpi以上、まっすぐ置く)を運用ルールにできるか検討したか
- ☐ OCRで読めなかった部屋名は人が補う前提(第6回の確認画面)で業務フローを組んだか
開発会社への質問例
- 「当社のスキャン図面を何枚か使って、部屋名が何件中何件読めたかを事前に見せてもらえますか?」
- 「OCRと、図面に埋め込まれた文字の読み取りは、どのように切り替わりますか?」
- 「利用者のPCにTesseractを入れる作業は、誰がどのように行う想定ですか?(配布の方法と手順書の有無)」
まとめと次回予告
第4回では、埋め込み文字の無いスキャン図面・画像PDFに対して、Tesseractでローカルに日本語OCRを行うapp/core/ocr_local.pyを実装しました。日本語が細切れで返ってくる問題には断片の連結で対応し、架空サンプル図面では部屋名7件をすべて読み取れました。一方で、粗い解像度・傾き・ノイズでは取りこぼしが出ることも実測で確認できました。
次回(第5回)は「外部AIの画像解析で部屋名認識の精度を底上げする」です。画像を理解できる生成AIに図面画像を渡し、部屋名だけを構造化データで受け取るapp/core/ocr_ai.pyを作り、今回のローカルOCRと精度・処理時間・コスト・情報漏えいリスクを比べます。
この連載の記事一覧
この記事は連載「CAD図面PDFをExcelへ自動転記するツール開発」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【CAD図面PDFをExcelへ自動転記するツール開発 第0回】要件整理とPython開発環境、tkinterの最初の画面を作る
- 【CAD図面PDFをExcelへ自動転記するツール開発 第1回】PyMuPDFで図面PDFを画面にプレビュー表示する
- 【CAD図面PDFをExcelへ自動転記するツール開発 第2回】PyMuPDFで図面の文字を座標付きで抽出し、プレビューにハイライト表示する
- 【CAD図面PDFをExcelへ自動転記するツール開発 第3回】部屋名辞書とヒューリスティックで「部屋名らしき文字列」だけを絞り込む
- 【CAD図面PDFをExcelへ自動転記するツール開発 第4回】スキャン図面・画像PDFの部屋名をローカルOCR(Tesseract)で読み取る(この記事)
- 【CAD図面PDFをExcelへ自動転記するツール開発 第5回】外部AI(Claude)の画像解析で図面の部屋名を読み取り、精度を底上げする
- 【CAD図面PDFをExcelへ自動転記するツール開発 第6回】AI・OCRの読み取り結果を人が確認・修正する画面を作る
- 【CAD図面PDFをExcelへ自動転記するツール開発 第7回】確認済みの部屋名を指定のExcelフォーマットの決まった欄へ転記する
- 【CAD図面PDFをExcelへ自動転記するツール開発 第8回】部屋名とExcelの欄の対応付けを設定ファイル(YAML)で変えられるようにする
- 【CAD図面PDFをExcelへ自動転記するツール開発 第9回】複数の図面PDFをフォルダごとまとめて処理する(進捗表示・中止・失敗しても止まらない一括処理)
- 【CAD図面PDFをExcelへ自動転記するツール開発 第10回】ローカル完結モードと外部AIモードを切り替え、APIキーをWindowsの資格情報マネージャーに保存する
- 【CAD図面PDFをExcelへ自動転記するツール開発 第11回(最終回)】PyInstallerでWindows向けexeにまとめて配布し、精度の限界と確認のルールを整理する


コメント
コメント一覧 (1件)
[…] 前回(第4回)では、スキャン図面の部屋名をローカルOCR(Tesseract)で読み取れるようにしましたが、粗いスキャンや傾きでは取りこぼしが出ました。 […]