MENU

問い合わせ


    【CAD図面PDFをExcelへ自動転記するツール開発 第3回】部屋名辞書とヒューリスティックで「部屋名らしき文字列」だけを絞り込む

    前回(第2回)では、CAD図面PDFに埋め込まれた文字を座標・フォントサイズ付きで取り出し、検出できた単語をすべて赤枠でハイライト表示するところまで作りました。ただし前回時点では、部屋名の「居間」「寝室1」も、寸法値の「W=3640」も、区別なく同じように赤枠で囲われる状態でした。

    第3回の今回は、この「検出した単語すべて」の中から、部屋名らしき文字列だけを絞り込みます。部屋名の辞書(表記ゆれ込み)と、文字サイズのヒューリスティック(経験則に基づく簡易判定)を組み合わせ、寸法値・符号・注記などのノイズを除外していきます。絞り込んだ結果は、前回の赤枠に重ねて青枠で表示し、「検出はされたが部屋名候補ではないもの」と「部屋名候補」を1画面で見比べられるようにします。

    目次

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

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

    • 検出した単語(ExtractedWordのリスト)の中から、部屋名辞書に完全一致するものだけを絞り込むapp/core/room_matcher.pyを実装する
    • 「部屋名候補だけを絞り込んでハイライト」ボタンを押すと、検出した単語すべての赤枠に重ねて、部屋名候補だけに青枠が表示される

    Excelへの転記・OCR対応はまだ実装しません。今回のゴールは、あくまで「どの文字列を部屋名として扱ってよいか」を判定するルールを作るところまでです。

    仕組みの説明:辞書+ヒューリスティックで絞り込む

    部屋名候補の判定は、次の2段階で行います。

    段階やること除外されるものの例
    1. フォントサイズの足切り極端に小さい・大きいフォントサイズの単語を除外する凡例・注記の小さい文字、タイトル・見出しの大きい文字
    2. 辞書との完全一致正規化した文字列が、部屋名辞書(app/config/rooms.yaml)の表記に完全一致するかを調べる寸法値(「W=3640」)、符号・記号だけの文字列、辞書に無い表記

    「なぜ辞書との完全一致にしたのか」が今回の設計判断のポイントです。似たような表記(例えば「サンルーム」「多目的室」)まで緩く拾ってしまうと、誤って部屋名として抽出してしまうリスクが上がります。この連載の方針(第3回・plan.md)でも「検出結果は必ず人が確認できる」ことを前提にしていますが、絞り込みの段階でノイズが多いと、確認する人の負担が増えてしまいます。そこで今回は、辞書に無い表記は候補に含めないという保守的な方針にしました。辞書に何を登録するかは業務側の知識が必要になるため、この点は「発注者向けメモ」で改めて扱います。

    表記ゆれ・番号付きパターンへの対応

    部屋名は「寝室1」「洋室2」のように、同じ種類の部屋が複数ある場合に番号が付くことがよくあります。また「キッチン」「台所」「ダイニングキッチン」のように、同じ部屋種別でも複数の表記が使われます。これらを1つずつ辞書に書き出すと管理が大変になるため、辞書は次のような形式にしました。

    # app/config/rooms.yaml(抜粋)
    rooms:
      - canonical: 居間
        aliases: [居間, リビング, リビングルーム, LDK, LDK]
        allow_numbered_suffix: false
      - canonical: 寝室
        aliases: [寝室, ベッドルーム, ベットルーム, 主寝室]
        allow_numbered_suffix: true
      - canonical: キッチン
        aliases: [キッチン, 台所, ダイニングキッチン, DK, DK]
        allow_numbered_suffix: false

    canonicalは代表表記(将来Excelのセル対応で使う想定の正式名称)、aliasesは辞書に完全一致で許容する表記ゆれのリストです。allow_numbered_suffix: trueにした部屋種別だけ、末尾に数字が付いた表記(「寝室1」「寝室23」等)も一致とみなします。キッチン・トイレのように通常1部屋しか無い部屋種別はfalseにしており、「キッチン1」のような表記は一致しません(もし今後そうした図面が出てきたら、辞書側の設定を変えるだけで対応できます)。

    比較の前には、文字列をUnicode正規化(NFKC)します。これにより「DK」(全角)と「DK」(半角)、「寝室1」(全角数字)と「寝室1」(半角数字)のような表記の違いを吸収できます。

    実装

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

    code/
    ├ app/
    │  ├ config/
    │  │  └ rooms.yaml              …【今回追加】部屋名辞書
    │  ├ core/
    │  │  └ room_matcher.py         …【今回追加】部屋名候補への絞り込みロジック
    │  └ ui/
    │     ├ main_window.py          …【今回変更】「部屋名候補だけを絞り込んでハイライト」ボタンを追加
    │     └ preview_canvas.py       …【今回変更】部屋名候補の枠(青枠)の描画を追加
    ├ tests/
    │  └ test_room_matcher.py       …【今回追加】pytest 26件
    └ requirements.txt              …【今回変更】PyYAML==6.0.3 を有効化

    コア処理:部屋名候補への絞り込み(app/core/room_matcher.py)

    辞書の読み込みと照合部分を抜粋します。

    # app/core/room_matcher.py(抜粋)
    @dataclass(frozen=True)
    class RoomDictionaryEntry:
        canonical: str
        aliases: tuple[str, ...]
        allow_numbered_suffix: bool
    
    
    @dataclass(frozen=True)
    class RoomDictionary:
        entries: tuple[RoomDictionaryEntry, ...]
    
        def match(self, text: str) -> tuple[str, str] | None:
            normalized = _normalize(text)
            if not normalized:
                return None
            for entry in self.entries:
                for alias in entry.aliases:
                    alias_normalized = _normalize(alias)
                    if normalized == alias_normalized:
                        return entry.canonical, alias
                    if entry.allow_numbered_suffix and _matches_numbered_suffix(normalized, alias_normalized):
                        return entry.canonical, alias
            return None
    
    
    def _normalize(text: str) -> str:
        return unicodedata.normalize("NFKC", text).strip()
    
    
    def _matches_numbered_suffix(normalized_text: str, normalized_alias: str) -> bool:
        if not normalized_text.startswith(normalized_alias):
            return False
        suffix = normalized_text[len(normalized_alias):]
        return bool(suffix) and suffix.isascii() and suffix.isdigit()

    絞り込み本体のfilter_room_candidates()は、フォントサイズの足切りと辞書照合を順に行うだけのシンプルな作りです。

    # app/core/room_matcher.py(抜粋)
    DEFAULT_MIN_FONT_SIZE = 8.0
    DEFAULT_MAX_FONT_SIZE = 40.0
    
    
    def filter_room_candidates(
        words: list[ExtractedWord],
        dictionary: RoomDictionary | None = None,
        *,
        min_font_size: float = DEFAULT_MIN_FONT_SIZE,
        max_font_size: float = DEFAULT_MAX_FONT_SIZE,
    ) -> list[RoomCandidate]:
        dict_ = dictionary if dictionary is not None else load_room_dictionary()
    
        candidates: list[RoomCandidate] = []
        for word in words:
            if not (min_font_size <= word.font_size <= max_font_size):
                continue
            matched = dict_.match(word.text)
            if matched is None:
                continue
            canonical, alias = matched
            candidates.append(RoomCandidate(word=word, canonical_name=canonical, matched_alias=alias))
        return candidates

    フォントサイズの既定範囲は8〜40ptにしました。図面上の部屋名ラベルは実務上おおむねこの範囲に収まることが多いという想定です。前回text_extractor.pyがフォントサイズを特定できなかった場合の既定値(0.0)も、この範囲外として自動的に除外されます(サイズが分からない単語は安全側に倒して候補にしない、という判断です)。この閾値は呼び出し側で変更できるようにしてあり、実際の図面で調整が必要になった場合も関数の引数を変えるだけで対応できます。

    寸法値・記号がなぜ除外されるか

    「数値のみ(寸法)」「記号のみ」を判定する専用の除外ロジックは、実はコアの絞り込み処理には入れていません。辞書に「W=3640」のような表記を登録していない以上、辞書との完全一致チェックだけで自然に除外されるためです。ただし「なぜ除外されたか」を人に説明しやすいように、日本語の文字(ひらがな・カタカナ・漢字)を含まない文字列かどうかを判定する補助関数looks_like_dimension_or_symbol()も用意しました(絞り込みの判断そのものには使わず、除外理由の説明用の関数です。将来のレビュー画面(第6回)で「なぜ除外されたか」を表示する際に使う想定です)。

    GUI部品:部屋名候補の青枠(app/ui/preview_canvas.py)

    前回作った赤枠(show_word_boxes())と同じ座標変換の考え方で、部屋名候補用の青枠を描くshow_room_candidates()を追加しました。赤枠を消さずに青枠だけを重ねて描くことで、「検出はされたが部屋名候補ではないもの」との違いが分かるようにしています。

    # app/ui/preview_canvas.py(抜粋)
    ROOM_BOX_OUTLINE_COLOR = "#1f5fbf"
    ROOM_BOX_OUTLINE_WIDTH = 3
    ROOM_BOX_TAG = "room_box"
    ROOM_BOX_PADDING = 3
    
    def show_room_candidates(self, candidates: list[RoomCandidate]) -> None:
        self._room_candidates = list(candidates)
        self._draw_room_candidates()
    
    def _draw_room_candidates(self) -> None:
        self.canvas.delete(ROOM_BOX_TAG)
        if self._document is None or not self._room_candidates:
            return
        scale = (DEFAULT_DPI / 72.0) * self._zoom
        for candidate in self._room_candidates:
            word = candidate.word
            if word.page_number != self._page_number:
                continue
            x0, y0, x1, y1 = word.bbox
            self.canvas.create_rectangle(
                x0 * scale - ROOM_BOX_PADDING, y0 * scale - ROOM_BOX_PADDING,
                x1 * scale + ROOM_BOX_PADDING, y1 * scale + ROOM_BOX_PADDING,
                outline=ROOM_BOX_OUTLINE_COLOR, width=ROOM_BOX_OUTLINE_WIDTH, tags=ROOM_BOX_TAG,
            )

    青枠は赤枠より少し太く、かつROOM_BOX_PADDINGぶん外側に描くことで、二重枠として見分けやすくしています。前回同様、ページ再描画・拡大縮小のたびに_draw_room_candidates()も呼び直すようにしたため、操作をしても枠がずれません。

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

    「4. 部屋名候補だけを絞り込んでハイライト」ボタンを追加しました。部屋名辞書はアプリ起動時に1回だけ読み込んでおき、ボタンを押すたびに使い回します。

    # app/ui/main_window.py(抜粋)
    def _on_filter_room_candidates(self) -> None:
        if self.preview.document is None or self._room_dictionary is None:
            return
        try:
            words = extract_words(self.preview.document, self.preview.page_number)
        except PdfLoadError as exc:
            messagebox.showerror("文字抽出エラー", str(exc))
            return
        candidates = filter_room_candidates(words, self._room_dictionary)
        self.preview.show_word_boxes(words)
        self.preview.show_room_candidates(candidates)
        self.status_var.set(
            f"このページの単語{len(words)}件中、{len(candidates)}件を部屋名候補として絞り込みました(青枠)。"
        )

    辞書ファイル(rooms.yaml)が壊れているなど、読み込みに失敗した場合はアプリ起動時にエラーダイアログを出し、このボタンを無効化したまま起動する作りにしています。

    動作確認の方法

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

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

    • pytest:合計58件(うちtests/test_room_matcher.pyが新規26件)がすべて成功。部屋名辞書の読み込み(正常系・辞書ファイルが無い/壊れている異常系)、表記ゆれ・番号付きパターンの一致、全角/半角の正規化、寸法値・記号・辞書に無い語が候補から除外されること、フォントサイズが範囲外の単語が除外されることなどを検証しています
    • ruff check .:エラーなし
    • pip index versions pyyamlでPyYAML 6.0.3が2025-09-25公開であることを確認(記事公開日2026-01-24より前)
    • ヘッドレス確認:前回までと同様tkinter・PyMuPDF・PyYAML入りのXvfb上にメイン画面を生成し、架空のサンプルPDF(「居間」「寝室1」「キッチン」「洗面所」「W=3640」等を含む1ページ)をpreview.load_pdf()で読み込ませたうえで、実際のボタンハンドラ_on_filter_room_candidates()を呼び出し、単語7件のうち部屋名候補4件(居間・寝室1・キッチン・洗面所)だけがCanvas上にroom_boxタグの矩形として描画されること、ページ送り・拡大縮小をしても件数が変わらないことを確認しました

    このリポジトリの開発環境(Linux/Xvfb上・Ubuntu標準のTkテーマ)で、「部屋名候補だけを絞り込んでハイライト」ボタンを実行した後の画面です。「居間」「寝室1」は赤枠に重ねて青い二重枠になっており、寸法値「W=3640」やページ見出しの文字は赤枠のままで青枠が付いていないことが確認できます。Windows実機ではウィンドウの装飾や既定フォントなど見た目が異なります。

    「部屋名候補だけを絞り込んでハイライト」実行後の画面。「居間」「寝室1」に赤枠と青枠の二重枠が表示され、「W=3640」やページ見出しの文字には赤枠のみが表示されている。ステータス行に「このページの単語7件中、4件を部屋名候補として絞り込みました(青枠)」と表示されている

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

    • 実在のCAD図面PDFで、辞書に登録した表記が実際に何割くらいカバーできるか(今回のテストはPyMuPDFで生成した単純なサンプルのみ)
    • フォントサイズの閾値(8〜40pt)が、実務で使われる図面の縮尺・フォント設定に対して妥当か
    • 青枠・赤枠の二重表示が、実際の図面画像の上で見やすいか(配色・線の太さの調整余地があるか)

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

    • 辞書に無い表記は「見えない」:部屋名として拾えるのは辞書に登録した表記だけです。今回のサンプルでは「サンルーム」のような辞書に無い語を除外する動作をpytestで確認していますが、これは裏を返すと「サンルームという部屋があっても今の設定では検出されない」ということです。運用を始める前に、対象図面で使われている表記を洗い出し、辞書に反映しておく必要があります
    • 漢数字の番号には対応していない:「寝室1」のような半角/全角数字の接尾辞には対応していますが、「寝室一」のような漢数字表記は一致しません。これも実際の図面を見て必要であれば追加対応する想定です
    • 完全一致ゆえの副作用:「洋室担当者」のように、辞書の表記を含むだけで前後に別の文字が続く文字列は一致しません(意図した動作です)。逆に言うと、部屋名の前後に空白なく別の文字がくっついて1つの単語として抽出されてしまうケース(前回の記事で触れた「単語の区切りは空白文字ベース」という制約)では、部屋名の部分だけを取り出せず候補から漏れる可能性があります
    • 辞書ファイルの取り扱い:app/config/rooms.yamlはコードの一部としてGitで管理する設定ファイルであり、顧客の個別情報は含めていません。もし特定の取引先専用の言い回しを追加する場合も、辞書には「部屋の種類を表す一般的な言葉」だけを書き、物件名・住所などは書かないようにしてください

    発注者向けメモ

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

    「部屋名の呼び方」は現場ごとに揺れます。「洋室1」と呼ぶ設計事務所もあれば、「寝室1」「ベッドルーム1」と表記するところもあります。同じ会社の図面でも、担当者やCADソフトの初期設定によって表記がバラつくことは珍しくありません。今回作ったrooms.yamlは、あくまで一般的によく使われる表記を並べた出発点であり、実際にどこまでの表記を登録するかは、業務側で対象図面を棚卸しした上で決める必要があります。

    具体的には、次のような整理を発注前・開発初期にしておくと、見積もりと実際の精度のズレを減らせます。

    1. 過去の図面PDFを何十枚か集め、部屋名としてどんな表記が使われているかを一覧化する(「リビング」「LDK」「洋室1」など、表記のバリエーションを数える)
    2. 表記のうち「これは部屋名として扱ってほしい」ものと「扱わなくてよい」ものを業務側で判断する(例えば「サービスルーム」「フリースペース」のような曖昧な部屋名をどう扱うかは、業務ルール次第で変わります)
    3. 辞書の追加・修正は誰が行うか(開発会社に都度依頼するのか、YAMLファイルを直接編集できる担当者を社内に置くのか)を決めておく。今回の実装ではYAML1ファイルに部屋種別を追記するだけで拡張できるようにしてあるため、簡単な追加であれば技術者でなくても手順さえ分かれば対応できる可能性があります

    辞書に登録されていない部屋名は候補として拾われず、逆に辞書の範囲を広げすぎると誤検出が増えます。この兼ね合いは一度作って終わりではなく、実際に運用しながら辞書を育てていく前提で計画しておくとよいでしょう。「最初の納品ですべての表記を完璧に網羅する」よりも、「運用しながら辞書を更新できる体制を作る」方が現実的です。

    • ☐ 対象図面で実際に使われている部屋名の表記パターンを一覧化したか
    • ☐ 辞書に登録する/しない表記の判断基準(誰が決めるか)を決めたか
    • ☐ 辞書ファイルの追加・修正を誰が行う運用にするかを開発会社とすり合わせたか
    • ☐ 「辞書に無い部屋名は検出されない」という前提を、実際に転記作業を行う担当者に共有したか

    開発会社への質問例

    • 「部屋名の辞書に登録する表記は、どの図面を基準に決めていますか?」
    • 「辞書に無い部屋名が出てきた場合、検出されないだけで終わるのか、それとも何らかの形で気づける仕組みになっていますか?」
    • 「辞書の追加・修正は、開発を依頼せず自社で行えるようにできますか?」

    まとめと次回予告

    第3回では、部屋名辞書(app/config/rooms.yaml)と文字サイズのヒューリスティックを組み合わせて、検出した単語の中から部屋名候補だけを絞り込むapp/core/room_matcher.pyを実装しました。辞書との完全一致を基本方針にすることで、誤検出を抑えつつ、番号付きパターンや表記ゆれにも対応できる作りにしています。プレビュー画面では、前回の赤枠(検出した単語すべて)に重ねて、絞り込んだ部屋名候補を青枠で表示できるようになりました。

    ここまでは、CADソフトが最初から文字情報として埋め込んでいる「ベクター文字入り」の図面PDFを前提にしてきました。次回(第4回)は、スキャンされた古い図面や画像化されたPDFのように、そもそも文字情報が埋め込まれていない図面に対応します。ローカルOCR(Tesseract)を使って画像から日本語の文字を読み取るapp/core/ocr_local.pyを実装する予定です。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (2件)

      目次