MENU

問い合わせ


    【CAD図面PDFをExcelへ自動転記するツール開発 第2回】PyMuPDFで図面の文字を座標付きで抽出し、プレビューにハイライト表示する

    前回(第1回)では、CAD図面PDFの部屋名をExcelへ自動転記するWindows向けツールの第一歩として、選んだPDFのページをPyMuPDFで画像化し、画面のCanvas上にプレビュー表示するところまで作りました。ページ送り・拡大縮小はできますが、この時点では「人が図面を目で見て確認できる」だけで、部屋名を自動で読み取る処理はまだありません。

    第2回の今回は、図面PDFに埋め込まれた文字そのものを取り出します。CADソフトから書き出したPDFの多くは、線や文字が座標データ(ベクターデータ)として保存されており、文字も「画像」ではなく「実際の文字情報」として埋め込まれています。この文字情報を位置(バウンディングボックス)とフォントサイズ付きで抽出し、前回のプレビュー画面の上に検出枠として重ねて表示するところまでが今回のゴールです。部屋名かどうかの判定はまだ行わず、「文字として認識できたもの」をすべてハイライトします。

    目次

    今回作る機能と完成イメージ

    今回できることは次の2つです。

    • 表示中の図面ページに埋め込まれた文字を、1単語ずつ座標・フォントサイズ付きで取得する
    • 「文字を検出してハイライト」ボタンを押すと、検出できた単語の位置に赤い矩形(検出枠)がプレビュー画像の上に重なって表示される

    「居間」「寝室1」のような部屋名らしき文字列と、「W=3640」のような寸法値らしき文字列は今回はまだ区別せず、両方とも同じように検出枠で囲われます。ノイズを除外して部屋名候補だけに絞り込む処理は、次回(第3回)で辞書とヒューリスティックを使って実装します。

    仕組みの説明:PDFから「文字の位置」をどう取り出すか

    前回、PDFのページを画像化するのにget_pixmap()を使いましたが、PyMuPDFにはページの中身を「文字情報」として取り出すget_text()もあります。option引数で形式を切り替えられ、今回はそのうち2つを組み合わせます。

    取り出し方得られるもの弱点
    get_text("words")単語ごとの位置(バウンディングボックス)と文字列の一覧フォントサイズは含まれない
    get_text("dict")「ブロック→行→span(同じ書式が続く区間)」という階層構造。spanにはフォントサイズが入っているspanは空白で単語ごとに区切られているとは限らない(例:「寝室1 洋室2」のように空白区切りの2単語が1つのspanに収まることがある)

    この2つには長所と短所があるため、今回は両方を組み合わせました。get_text("words")が返す各単語には(block_no, line_no, word_no)という番号が付いており、これは同じページのget_text("dict")が返すblocks[block_no]["lines"][line_no]にそのまま対応します(どちらも内部的には同じ「テキストページ」というデータから作られているため)。そこで、単語のx座標と行内の各spanのx座標を比べ、最も重なりが大きいspanのフォントサイズを採用します。

    用語かんたんな意味
    バウンディングボックス(bbox)文字や単語を囲む最小の四角形の座標。「どこに書かれているか」を表す
    span(スパン)PDF内部で、同じフォント・同じサイズが連続する文字の区間

    今回実装した処理の流れは次のとおりです。

    1. page.get_text("words")で、単語ごとの位置と文字列の一覧を取得する。単語の区切りはPyMuPDFの既定(空白文字)に従う
    2. 単語が1件もなければ空リストを返す。文字が埋め込まれていない(スキャン画像だけの)ページである可能性が高く、その対応(ローカルOCR)は第4回で扱う
    3. page.get_text("dict")で、ページ内の文字を階層構造として取得する
    4. 単語ごとに、対応する行の中でx座標が最も重なるspanを探し、そのフォントサイズを単語のフォントサイズとして採用する
    5. 抽出した単語(文字列・bbox・フォントサイズ・ページ番号)をプレビュー画面(Canvas)に渡す。Canvas側は、画像化のときに使ったのと同じ倍率をbboxに掛けて、検出枠をピクセル座標に変換して描画する

    1〜4は前回同様GUIに依存しない「コア処理」としてapp/core/text_extractor.pyに実装し、5だけがGUI(app/ui/preview_canvas.py)側の処理です。

    実装

    ファイル構成(第2回時点の差分)

    code/
    ├ app/
    │  ├ core/
    │  │  ├ pdf_loader.py           … 【今回変更】fitz.Pageを取得するget_page()を追加
    │  │  └ text_extractor.py       … 【今回追加】単語・座標・フォントサイズを取得するコア処理
    │  └ ui/
    │     ├ main_window.py          … 【今回変更】「文字を検出してハイライト」ボタンを追加
    │     └ preview_canvas.py       … 【今回変更】検出枠(矩形)の描画を追加
    ├ tests/
    │  └ test_text_extractor.py     … 【今回追加】
    ├ requirements.txt              … 変更なし(PyMuPDFは第1回で追加済み)
    └ pytest.ini

    コア処理:ページから単語を抽出する(app/core/text_extractor.py)

    # app/core/text_extractor.py(抜粋)
    from __future__ import annotations
    
    from dataclasses import dataclass
    
    from app.core.pdf_loader import PdfDocument
    
    
    @dataclass(frozen=True)
    class ExtractedWord:
        text: str
        bbox: tuple[float, float, float, float]  # (x0, y0, x1, y1)。PDF座標系・ポイント単位
        font_size: float
        page_number: int
    
    
    def extract_words(document: PdfDocument, page_number: int) -> list[ExtractedWord]:
        page = document.get_page(page_number)
    
        word_tuples = page.get_text("words")
        if not word_tuples:
            return []
    
        text_dict = page.get_text("dict")
        blocks = text_dict.get("blocks", [])
    
        words: list[ExtractedWord] = []
        for x0, y0, x1, y1, text, block_no, line_no, _word_no in word_tuples:
            font_size = _find_font_size(blocks, block_no, line_no, word_x0=x0, word_x1=x1)
            words.append(ExtractedWord(text=text, bbox=(x0, y0, x1, y1), font_size=font_size, page_number=page_number))
        return words
    
    
    def _find_font_size(blocks, block_no, line_no, *, word_x0, word_x1) -> float:
        try:
            spans = blocks[block_no]["lines"][line_no]["spans"]
        except (IndexError, KeyError):
            return 0.0
    
        best_size, best_overlap = 0.0, -1.0
        for span in spans:
            span_x0, _, span_x1, _ = span["bbox"]
            overlap = min(word_x1, span_x1) - max(word_x0, span_x0)
            if overlap > best_overlap:
                best_overlap, best_size = overlap, span["size"]
        return best_size

    ページ番号の範囲チェックは、PdfDocumentに今回追加したget_page()にそのまま任せています。不正なページ番号を渡すと前回と同じPdfLoadErrorが送出されます。

    # app/core/pdf_loader.py(今回追加した部分の抜粋)
    def get_page(self, page_number: int) -> fitz.Page:
        """指定ページのfitz.Pageを返す(text_extractor.py等、コア処理間の連携用)。"""
        self._validate_page_number(page_number)
        return self._document.load_page(page_number)

    GUI部品:検出枠のハイライト表示(app/ui/preview_canvas.py)

    前回作ったPreviewCanvasに、検出した単語を受け取って矩形を描画するshow_word_boxes()を追加しました。座標変換が今回のポイントです。前回、ページ画像化には(dpi / 72.0) * zoomという倍率を使いました。検出枠のbboxはPDF座標(ポイント単位)なので、同じ倍率を掛けるだけで画像上のピクセル位置とずれずに重ねられます。

    # app/ui/preview_canvas.py(抜粋)
    def show_word_boxes(self, words: list[ExtractedWord]) -> None:
        self._word_boxes = list(words)
        self._draw_word_boxes()
    
    def _draw_word_boxes(self) -> None:
        self.canvas.delete(WORD_BOX_TAG)
        if self._document is None or not self._word_boxes:
            return
        scale = (DEFAULT_DPI / 72.0) * self._zoom
        for word in self._word_boxes:
            if word.page_number != self._page_number:
                continue
            x0, y0, x1, y1 = word.bbox
            self.canvas.create_rectangle(
                x0 * scale, y0 * scale, x1 * scale, y1 * scale,
                outline=WORD_BOX_OUTLINE_COLOR, width=WORD_BOX_OUTLINE_WIDTH, tags=WORD_BOX_TAG,
            )

    _draw_word_boxes()は、ページを再描画する_render_current_page()からも呼び出すようにしました。これにより、検出枠を表示した状態で拡大縮小をしても、新しい倍率に合わせて枠が自動的に再計算されます。保持している単語リストのうち現在のページ番号と一致するものだけを描画するため、別ページの検出結果が誤って重なることもありません(ページを送った直後は新しいページの検出結果をまだ持っていないため枠は一旦消えます。ページ送りに合わせて自動で検出をやり直す処理は、検出結果を人が確認・修正する第6回でまとめて扱う予定です)。

    メイン画面への配線(app/ui/main_window.py)

    「文字を検出してハイライト」ボタンを追加し、押すと表示中のページに対してextract_words()を呼び出します。

    # app/ui/main_window.py(抜粋)
    def _on_detect_text(self) -> None:
        if self.preview.document is None:
            return
        try:
            words = extract_words(self.preview.document, self.preview.page_number)
        except PdfLoadError as exc:
            messagebox.showerror("文字抽出エラー", str(exc))
            return
        self.preview.show_word_boxes(words)
        if words:
            self.status_var.set(f"このページで{len(words)}件の単語を検出しました(赤枠で表示)。")
        else:
            self.status_var.set("このページからは埋め込み文字が検出できませんでした(スキャン画像の可能性があります)。")

    extract_words()が空リストを返した場合は、例外にせず「検出できなかった」というメッセージを表示するだけにしています。テキストが埋め込まれていないスキャン画像の図面を開いたときに起きる正常なケースだからです(対応は第4回のローカルOCR)。

    動作確認の方法

    前回同様、Linux開発環境で確認できたことと、Windows実機での確認が必要なことを分けて書きます。

    Linux開発環境で確認できたこと

    • pytest:合計32件(うちtests/test_text_extractor.pyが新規9件)がすべて成功。部屋名(「居間」「寝室1」「洋室2」)と寸法値らしき文字列(「W=3640」)を混在させたサンプルPDFで、文字列・座標・フォントサイズの取得、空白区切りの単語分割、文字なしページでの空リスト、範囲外ページでのPdfLoadError、複数ページ間で単語が混ざらないことを検証しています。テスト用PDFはPyMuPDF自身でその場で生成した架空のサンプルです
    • ruff check .:エラーなし
    • ヘッドレス確認:前回と同様tkinter入り環境のXvfb上にメイン画面を生成し、架空のサンプルPDFをpreview.load_pdf()で読み込ませ、実際のボタンハンドラ_on_detect_text()を呼び出して、Canvas上にword_boxタグの矩形が単語数分描画されること、拡大操作をしても件数が変わらず例外が出ないこと、clear_word_boxes()で矩形が消えることを確認しました

    前回(第1回)の続きとして、まずサンプルPDFの1ページ目をプレビュー表示した状態がこちらです(前回と同じ画面です)。

    サンプルPDFの1ページ目をプレビュー表示した状態。まだ検出枠は表示されていない

    ここで「3. 表示中のページの文字を検出してハイライト」ボタンを押すと、実際のボタンハンドラ_on_detect_text()が呼ばれ、次のように検出した単語(「居間」「寝室1」や見出し文字など)の周りに赤枠が表示されます。

    「文字を検出してハイライト」実行後の画面。「居間」「寝室1」やページ上部の見出し文字などの単語の周りに赤い検出枠が表示され、ステータス行に「このページで7件の単語を検出しました」と表示されている

    ※いずれも開発環境(Linux/Xvfb上・Ubuntu標準のTkテーマ)で確認した画面です。Windows実機ではウィンドウの装飾や既定フォントなど見た目が異なります。

    Windows実機での確認が必要なこと(このリポジトリでは未確認)

    • 実際の図面画像の上に、検出枠が文字とずれずに重なって表示されるか(ヘッドレス確認は矩形の個数・タグの確認であり、目で見た一致の確認ではありません)
    • 実在のCAD図面で、想定どおりの粒度で文字が取得できるか(今回はPyMuPDFで生成した単純なサンプルのみで検証)
    • 検出枠が多いページでのCanvas描画の速度

    つまずきやすい点・セキュリティ上の注意

    • 日本語を描画するテスト用PDFのフォント指定:テストコードでは、page.insert_text()で日本語の部屋名を描画する際に、PyMuPDF組み込みのCJK(中国語・日本語・韓国語)フォントfontname="japan"を明示的に指定しています。既定のフォント(fontname="helv")は日本語のグリフ(字形データ)を持たないため、そのまま渡すとget_text()で取り出しても文字化けした文字列しか得られません。実際のCAD図面PDFはCADソフト側が適切な日本語フォントを埋め込むため通常は問題になりませんが、自作のテストPDFで日本語を扱う場合は要注意です
    • フォントサイズの対応付けはあくまで近似:1つの単語が複数のspanにまたがる複雑なレイアウト(一部だけ強調書式になっている等)では、意図した通りのフォントサイズを取れない可能性があります
    • 単語の区切りは空白文字ベース:部屋名が独立したテキストとして挿入されている図面ではこの分割で十分ですが、部屋名と寸法値が空白なく1つのテキストとして挿入されている図面では期待通りに分割されません。対処は第3回で改めて検討します
    • 図面ファイルの取り扱い:図面PDFには顧客の建物情報が含まれることがあります。この連載では、実在の図面ではなくPyMuPDFで生成した架空のテスト用PDFのみを使っています

    発注者向けメモ

    この回で確認しておきたいこと・工数の勘所

    CAD由来のPDFは、文字が最初から「ベクター文字」として埋め込まれていることが多く、その場合は今回作った文字抽出処理だけで、OCRよりも速く精度高く部屋名候補を拾えます。一方、図面が一度スキャンされて画像化されたPDFでは、今回の処理は文字を1つも検出できません(第4回のローカルOCRが必要になります)。

    つまり、自社が転記対象にしたい図面PDFが「ベクター文字入りかスキャン画像か」を、開発を依頼する前に確認しておくと見積もりの精度が上がります。 PDFをAdobe AcrobatやPDF閲覧ソフトで開き、文字部分をドラッグして選択できるか試すだけで見分けられます。選択できれば(青くハイライトされれば)ベクター文字入り、選択できず図面全体が1枚の画像のように扱われる場合はスキャン画像の可能性が高いです。同じ物件でも作成年代やCADソフトによって両方が混在することがあるため、サンプルを何パターンか開発会社に渡し判定してもらうのが確実です。

    ベクター文字入りの図面が中心であれば、OCRに頼る処理の割合が減り、開発工数・処理時間・誤読リスクのいずれも下がりやすくなります。逆にスキャン画像の古い図面が多い場合は、OCR(第4回)や外部AI画像解析(第5回)への依存度が上がり、開発コストに加えて情報漏えいリスクの検討(契約・秘密保持義務の確認)も必要になる、と見込んでおくとよいでしょう。

    • ☐ 転記対象の図面PDFが、ベクター文字入りかスキャン画像か(またはその混在か)を確認したか
    • ☐ 文字が選択できる図面の割合を大まかに把握したか
    • ☐ スキャン画像の図面が多い場合、OCR・外部AIモードの追加コストを開発会社とすり合わせたか
    • ☐ 検出した文字の精度確認(目視チェック)を誰がどのタイミングで行うか検討し始めたか

    開発会社への質問例

    • 「うちの図面PDFは、文字をドラッグして選択できるものとできないものが混ざっていますが、開発工数はどのくらい変わりますか?」
    • 「文字の座標・フォントサイズをどう使って部屋名を絞り込む設計にしていますか?」
    • 「検出漏れ・誤検出があった場合、どの段階で人が気づいて直せる仕組みになっていますか?」

    まとめと次回予告

    第2回では、PyMuPDFのget_text("words")とget_text("dict")を組み合わせて、ページ内の単語を座標・フォントサイズ付きで取得するapp/core/text_extractor.pyを実装し、前回のプレビュー画面(app/ui/preview_canvas.py)に検出枠として重ねて表示できるようにしました。コア処理は引き続きGUIに依存しない形で実装し、pytestで自動検証できる状態を保っています。

    次回(第3回)は、今回すべてハイライトしていた単語の中から、部屋名らしい文字列だけを絞り込むルールを作ります。部屋名の辞書(YAML)と、文字サイズ・図面内の位置を使ったヒューリスティックで、寸法線の数値や符号などのノイズを除外していきます。

    この連載の記事一覧

    この記事は連載「CAD図面PDFをExcelへ自動転記するツール開発」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (1件)

      目次