前回(第4回)では、スキャン図面の部屋名をローカルOCR(Tesseract)で読み取れるようにしましたが、粗いスキャンや傾きでは取りこぼしが出ました。
第5回は、外部AIの画像解析(図面の画像を生成AIに見せ、部屋名だけを答えてもらう仕組み)を、もう1つの「読み取り方式」として追加します。使うのはAnthropic社のClaude APIです。要点は「画像と指示文を送る」「崩れたJSONにも強い読み取り」「送る前に必ず確認を取る」の3つです。なお、検証環境にAPIキーが無いため、実際のAPIでの精度比較は未実施です。代わりに、ローカルOCRとの使い分けの観点を整理します。
今回作る機能と完成イメージ
- 画面に「読み取り方式」の選択欄を追加する。既定は「ローカル」(社外に何も送らない)
- 「外部AI」を選んだときだけ、ページ画像をClaude APIへ送り、部屋名と位置をJSONで受け取る
- 送信の直前に毎回「図面画像を外部サービスへ送信します」と確認する
- 結果は第3回の部屋名辞書で照合し、赤枠・青枠で表示する
パッケージはAnthropic公式のPython SDK anthropic 0.77.0(2026-01-29公開)、既定のモデルは画像入力に対応したClaude Haiku 4.5(claude-haiku-4-5-20251001)で、環境変数で差し替えられます。モデル名・料金は執筆時点のものです。公式ドキュメントで最新を確認してください。
📰 出典:Models overview(Claude Docs)
仕組みの説明:OCRは「文字を読む」、生成AIは「部屋名を選んで答える」
OCRは画像の文字を機械的に読むだけで、それが部屋名かどうかは判断しません(第3回の辞書に任せていました)。生成AIは図面全体を見て「部屋名はどれか」まで判断して答えるため、OCRが苦手な図面での底上げが期待できます。クラウドのOCR特化サービス(Google Cloud Vision、Azure AI Document Intelligenceなど)も文字を読むのは得意ですが、部屋名かどうかの判断はしません。
その代わり、生成AIの出力は自由な文章で、「JSONで」と指示しても形式が崩れることがあります。座標もおおよその値です。今回のコードの大半は、このブレへの対策と送信前の確認です。
処理の流れは次のとおりです。
| 手順 | やること | 使うモジュール |
|---|---|---|
| 1 | APIキーの確認→送信確認ダイアログ | ocr_ai.py・main_window.py |
| 2 | ページを画像化し、長辺1568ピクセル以内に縮小 | pdf_loader.py(第1回)+ocr_ai.py |
| 3 | 画像と指示文を送り、応答のJSONを座標付きの単語に変換 | ocr_ai.py |
| 4 | 部屋名辞書で照合し、ハイライト表示 | room_matcher.py(第3回) |
実装
ファイル構成(第5回時点の差分)
code/
├ app/
│ ├ core/
│ │ └ ocr_ai.py …【今回追加】画像の縮小・リクエスト作成・応答JSONのパース・エラー処理
│ └ ui/
│ └ main_window.py …【今回変更】読み取り方式の選択欄、送信確認ダイアログ
├ tests/
│ └ test_ocr_ai.py …【今回追加】pytest 31件(実際のAPIは呼ばない)
├ tools/
│ └ capture_screenshots.py …【今回変更】第5回用の画面(送信確認ダイアログ)を撮影
├ .env.example …【今回追加】環境変数名の一覧(値は書かない)
└ requirements.txt …【今回変更】anthropic==0.77.0 を追加
APIキーとモデルIDは環境変数から読む(app/core/ocr_ai.py)
APIキーは環境変数 ANTHROPIC_API_KEY から読み、未設定なら通信の前に対処方法が分かるメッセージで止めます。
# app/core/ocr_ai.py(抜粋)
ENV_API_KEY = "ANTHROPIC_API_KEY"
ENV_MODEL = "MADORI_OCR_AI_MODEL"
DEFAULT_AI_MODEL = "claude-haiku-4-5-20251001" # 執筆時点のモデルID
def get_api_key(environ: dict[str, str] | None = None) -> str:
env = os.environ if environ is None else environ
key = env.get(ENV_API_KEY, "").strip()
if not key:
raise AiApiKeyMissingError(
f"外部AIのAPIキーが設定されていません。環境変数 {ENV_API_KEY} にAPIキーを設定してから"
"アプリを起動し直してください(キーはコードや設定ファイルに書かないでください)。"
"外部AIを使わない場合は、読み取り方式を「ローカル」に戻してください。"
)
return key
def resolve_model(environ: dict[str, str] | None = None) -> str:
env = os.environ if environ is None else environ
return env.get(ENV_MODEL, "").strip() or DEFAULT_AI_MODEL
リポジトリには値の無い .env.example だけを置きます。キーの保存先は第10回でWindowsの資格情報マネージャー(keyring)に移します。
画像を縮小してから送る理由
公式ドキュメントによると、大きすぎる画像はAPI側で縮小されます。そうなると返ってくる座標の基準が変わるため、送る前に自分で長辺1568ピクセル以内に縮小し、縮小率を座標の変換に反映させます。形式は細い文字がつぶれにくいPNGです。
📰 出典:Vision(Claude Docs)
# app/core/ocr_ai.py(抜粋)
DEFAULT_MAX_LONG_EDGE = 1568
def prepare_image(image: Image.Image, *, base_scale: float,
max_long_edge: int = DEFAULT_MAX_LONG_EDGE) -> PreparedImage:
rgb = image.convert("RGB")
ratio = min(1.0, max_long_edge / max(rgb.width, rgb.height))
if ratio < 1.0:
new_size = (max(1, round(rgb.width * ratio)), max(1, round(rgb.height * ratio)))
rgb = rgb.resize(new_size, Image.Resampling.LANCZOS)
buffer = io.BytesIO()
rgb.save(buffer, format="PNG")
png_base64 = base64.standard_b64encode(buffer.getvalue()).decode("ascii")
# 送った画像のピクセル座標 ÷ scale = PDF座標(ポイント)
return PreparedImage(png_base64=png_base64, width=rgb.width, height=rgb.height,
scale=base_scale * ratio)
A4・150dpi(1240×1754ピクセル)の図面は、約1108×1568ピクセルに縮小して送ります。
画像と「部屋名だけをJSONで」という指示を送る
Base64(画像を文字列にしたもの)の画像ブロックと指示文を1つのメッセージで送ります。公式ドキュメントの推奨に従い、画像を先に置いています。
# app/core/ocr_ai.py(抜粋)
ROOM_EXTRACTION_PROMPT = """\
この画像は建物の間取り図(平面図)です。図面に書かれた文字のうち、部屋名だけをJSONで返してください。
ルール:
- 部屋名とは「居間」「寝室1」「キッチン」「洗面所」「バルコニー」のように、部屋・空間の名前を表すラベルです。
- 寸法(例: 3640、W=3640)、方位、縮尺、図面タイトル、注記、記号は含めないでください。
- 図面に書かれている表記をそのまま返し、番号(寝室1の「1」など)も省略しないでください。読めない文字を推測で補わないでください。
- bboxは画像左上を原点としたピクセル座標 [左, 上, 右, 下] です。画像サイズは幅{width}px・高さ{height}pxです。
- 部屋名が1つも無い場合は {{"rooms": []}} を返してください。
- 説明文やコードブロックは付けず、次の形式のJSONだけを出力してください。
{{"rooms": [{{"name": "居間", "bbox": [120, 80, 180, 110]}}]}}
"""
def build_request(prepared: PreparedImage, *, model: str,
max_tokens: int = DEFAULT_MAX_TOKENS) -> dict[str, Any]:
prompt = ROOM_EXTRACTION_PROMPT.format(width=prepared.width, height=prepared.height)
return {
"model": model,
"max_tokens": max_tokens,
"messages": [{
"role": "user",
"content": [
{"type": "image",
"source": {"type": "base64", "media_type": "image/png",
"data": prepared.png_base64}},
{"type": "text", "text": prompt},
],
}],
}
指示文には、除外するもの(寸法・注記など)と「番号を省略しない」「推測で補わない」を明記しました。第4回の「寝室1が寝室になる」番号落ちを防ぐためです。送信内容を組み立てる関数を分けたのは、APIを呼ばずにpytestで検証するためです。
応答のJSONを崩れに強い方法で読み取る
「JSONだけを出力して」と指示しても、前置きの文章やコードブロック付きで返ることがあります。そこで応答から最初のJSONを探して取り出し、1件ずつ確かめます。
# app/core/ocr_ai.py(抜粋)
def parse_ai_response(text: str, *, image_width: int, image_height: int,
scale: float, page_number: int = 0) -> list[ExtractedWord]:
data = _load_json_object(text) # 前置き・コードフェンス付きでも取り出す
rooms = data.get("rooms")
if not isinstance(rooms, list):
raise AiResponseFormatError('AIの応答に "rooms" のリストがありません。')
words: list[ExtractedWord] = []
for item in rooms:
if not isinstance(item, dict):
continue
name = item.get("name")
if not isinstance(name, str) or not name.strip():
continue
bbox = _parse_bbox(item.get("bbox"), image_width, image_height) # 4つの数値か・画像内か
if bbox is None:
continue
x0, y0, x1, y1 = (value / scale for value in bbox)
words.append(ExtractedWord(text=name.strip(), bbox=(x0, y0, x1, y1),
font_size=y1 - y0, page_number=page_number))
return words
方針は「全体が崩れていればエラー、1件だけおかしければその1件だけ捨てる」です。はみ出した座標は画像の範囲に切り詰めます。戻り値は第2回・第4回と同じ ExtractedWord なので、表示処理はそのまま使えます。
APIエラーを種類ごとに分かりやすく伝える
SDKはエラーの種類ごとに例外クラスを分けています。「待てば直るのか、設定を直すべきか」が伝わるよう、種類ごとに日本語のメッセージへ変換しました。
# app/core/ocr_ai.py(抜粋)
try:
response = client.messages.create(**request)
except anthropic.AuthenticationError as exc: # 401:APIキーが無効
raise AiOcrError(f"APIキーが無効です。環境変数 {ENV_API_KEY} の値を確認してください。") from exc
except anthropic.NotFoundError as exc: # 404:モデルIDの誤りなど
raise AiOcrError(f"モデル '{used_model}' が見つかりません。...") from exc
except anthropic.RateLimitError as exc: # 429:利用上限
raise AiOcrError("APIの利用上限(レート制限)に達しました。...") from exc
except anthropic.APIStatusError as exc: # その他のHTTPエラー
raise AiOcrError(f"外部AIの呼び出しに失敗しました(HTTP {exc.status_code}): {exc.message}") from exc
except anthropic.APIConnectionError as exc: # 通信エラー・タイムアウト
raise AiOcrError("外部AIに接続できませんでした。...") from exc
一時的な通信エラーや混雑はSDKが自動で再試行します(今回は最大2回・60秒で打ち切り)。応答が途中で打ち切られた場合(stop_reason が max_tokens)も形式エラーにします。
📰 出典:Errors(Claude Docs)
画面:既定はローカル、送信前に必ず確認する(app/ui/main_window.py)
「読み取り方式」のラジオボタンを追加しました。既定は「ローカル」で第4回までと同じ動きです。「外部AI」のときだけ次の処理をします。
# app/ui/main_window.py(抜粋)
def _detect_words_with_ai(self) -> tuple[list[ExtractedWord], str] | None:
try:
api_key = get_api_key() # キーが無ければ、確認ダイアログの前に止める
except AiOcrError as exc:
messagebox.showerror("外部AIの設定エラー", str(exc))
return None
model = resolve_model()
if not messagebox.askokcancel(
SEND_CONFIRMATION_TITLE, # 「図面画像を外部サービスへ送信します」
SEND_CONFIRMATION_TITLE,
detail=build_send_confirmation_message(model),
icon="warning",
default="cancel", # Enterキーだけで送信されないように
):
self.status_var.set("外部AIへの送信をキャンセルしました(何も送信していません)。")
return None
self.status_var.set("外部AIで読み取っています(通信中)...")
self.update_idletasks()
try:
result = ai_ocr_pdf_page(self.preview.document, self.preview.page_number,
client=self._create_ai_client(api_key), model=model)
except (AiOcrError, PdfLoadError) as exc:
messagebox.showerror("外部AIエラー", str(exc))
return None
return result.words, "ai"
外部AIの結果も部屋名辞書で照合してから青枠にします。AIが辞書に無い語(読み違いや推測で補った語)を返すと赤枠だけが残り、人が気付けます。座標はおおよその値のため、外部AIの結果に限り文字サイズでの足切りは外しました。
動作確認の方法
Linux開発環境で確認できたこと
pytest:合計113件が成功(うちtests/test_ocr_ai.pyが新規31件)。実際のAPIは呼ばず、ダミーのクライアントで次を検証しました- 送信内容:画像がBase64のPNGで入ること、指示文に「部屋名だけをJSONで」と画像サイズが入ること、A4のページが長辺1568ピクセルに縮小されること
- 応答の読み取り:正常なJSON、前置き付き、コードフェンス付き、途中で切れたもの、キー違い、一部だけ不正な値
- エラー:APIキー未設定なら通信前に止まること、401・403・404・429・500・通信エラーの各メッセージ
ruff check .:エラーなし- 画面の配線(Xvfb上):キー未設定ならエラーで止まり確認ダイアログも出ないこと、「キャンセル」なら送信関数が呼ばれないこと、「OK」ならダミー応答の部屋名が赤枠・青枠で表示されること
開発環境(Linux/Xvfb上・Ubuntu標準のTkテーマ)で「外部AI」を選び「4. 部屋名候補だけを絞り込んでハイライト」を押したときの確認ダイアログです。Windows実機では見た目が異なります。撮影時はダミーのキーを使い「キャンセル」を押したため、何も送信していません。

確認できていないこと(APIキーが必要なため、この記事の環境では未実施)
- 実際のAPIでの精度(何件中何件正しいか、座標のずれ)、処理時間、消費トークン数
- Windows実機での見た目と、プロキシ環境下での接続
試すときのために、結果の AiOcrResult にはモデル名・入出力のトークン数・処理時間を記録しています。料金はトークン数に公式の単価を掛けて見積もれます。
ローカルOCRと外部AIの比較の観点
外部AIは未測定のため、比べる観点を整理します。ローカルOCRの数字は第4回の実測(架空サンプル図面)です。
| 観点 | ローカルOCR(第4回) | 外部AI(今回) |
|---|---|---|
| 精度 | きれいな図面で7件中7件。粗いスキャン・傾きで取りこぼし | 文脈で部屋名を選べる分、OCRが苦手な図面で効く可能性。読み違い・座標のずれはありうる |
| コスト | 利用料なし | 画像・指示文・応答の量(トークン数)に応じて課金 |
| 処理時間 | 1ページ約0.2秒(A4・150dpi) | 通信と混雑状況に左右される |
| 情報漏えいリスク | 図面はPCの外に出ない | 図面が社外に送られる。扱いは規約次第 |
比べるときは、自社の代表的な図面(特に状態の悪いスキャン)で、両方の正解数・処理時間・料金を並べるのが確実です。
つまずきやすい点・セキュリティ上の注意
- APIキーをコード・Gitに載せない:漏れると第三者に利用料金を使われるおそれがあります。漏れた疑いがあればすぐ管理画面で無効化します
- 「JSONだけ」と頼んでも崩れる前提で読む:前置き・コードブロック・途中切れを想定します
- 座標はおおよその値:公式ドキュメントでも位置の出力はおおよそとされています。第6回の確認画面で人が確かめる前提です
- 送信は利用者が毎回確認する:既定ボタンを「キャンセル」にし、Enterの押し間違いで送らないようにしました。一括処理(第9回)では確認方法を改めて設計します
発注者向けメモ
この回で確認しておきたいこと・工数の勘所
外部AIを使うと、顧客の建物情報を含む図面を社外のサービスに送信することになります。精度や費用より先に、送ってよいかの判断が必要です。顧客との契約や秘密保持契約(NDA)で第三者への提供が制限されていないかを確認し、迷う場合は法務担当や弁護士に相談してください。
送信データの扱い(保存期間、AIの学習に使われるかなど)は、サービスや契約の種類で異なり、改定もありえます。提供元の公式の利用規約・プライバシーポリシーで、利用時点の内容を確認してください。
📰 出典:Commercial Terms of Service(Anthropic)
OCR特化サービスを選べば部屋名の判断は自前のルール(辞書など)で作り込み、生成AIを選べば出力の崩れ対策と結果確認が要る、というように、どちらを選ぶかで開発の中身も変わります。
- ☐ 図面を社外のサービスへ送ってよいか、顧客との契約・秘密保持の取り決めを確認したか
- ☐ 利用するAIサービスの規約で、送信データの保存・学習利用の扱いを確認したか
- ☐ 外部AIを「使わない」運用(ローカルのみ)も選べるようにしておくか決めたか
- ☐ 代表的な図面で、ローカルOCRと外部AIの精度・処理時間・料金を比べる試験を依頼したか
- ☐ APIキーの管理者と、利用料金の上限・請求先を決めたか
開発会社への質問例
- 「図面画像はどこへ送られ、送信先ではどのように保存・利用されますか?根拠となる規約の該当箇所も教えてください」
- 「外部AIを使わない設定にした場合、図面が社外へ送られないことはどのように確認できますか?」
- 「当社の図面で、ローカルOCRと外部AIの精度・1ページあたりの料金を比べた結果を見せてもらえますか?」
まとめと次回予告
第5回では、図面画像をClaude APIに送り部屋名をJSONで受け取る app/core/ocr_ai.py を実装しました。画像の事前縮小、崩れに強いJSONの読み取り、種類別のエラーメッセージ、既定はローカルのまま送信前に必ず確認する画面がポイントです。実際の精度は、APIキーのある環境で自社の図面を使って確かめてください。
次回(第6回)は「検出結果を人がその場で確認・修正できる画面にする」です。読み取った部屋名を図面上の位置と並べて確認し、誤りをクリックで直せる画面を作ります。
この連載の記事一覧
この記事は連載「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にまとめて配布し、精度の限界と確認のルールを整理する


コメント
コメント一覧 (2件)
[…] 前回(第5回)では、図面画像を外部AI(Claude)に送って部屋名を読み取る方式を追加しました。これで「埋め込み文字」「ローカルOCR」「外部AI」の3つの読み取り方式がそろいましたが、どの方式も読み違いや取りこぼしはゼロになりません。 […]
[…] 次回(第5回)は「外部AIの画像解析で部屋名認識の精度を底上げする」です。画像を理解できる生成AIに図面画像を渡し、部屋名だけを構造化データで受け取るapp/core/ocr_ai.pyを作り、今回のローカルOCRと精度・処理時間・コスト・情報漏えいリスクを比べます。 […]