MENU

問い合わせ


    【CAD図面PDFをExcelへ自動転記するツール開発 第5回】外部AI(Claude)の画像解析で図面の部屋名を読み取り、精度を底上げする

    前回(第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で」と指示しても形式が崩れることがあります。座標もおおよその値です。今回のコードの大半は、このブレへの対策と送信前の確認です。

    処理の流れは次のとおりです。

    手順やること使うモジュール
    1APIキーの確認→送信確認ダイアログ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実機では見た目が異なります。撮影時はダミーのキーを使い「キャンセル」を押したため、何も送信していません。

    「外部AI」を選んだ状態で表示された「図面画像を外部サービスへ送信します」の確認ダイアログ

    確認できていないこと(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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (2件)

      目次