MENU

問い合わせ


    【CAD図面PDFをExcelへ自動転記するツール開発 第6回】AI・OCRの読み取り結果を人が確認・修正する画面を作る

    前回(第5回)では、図面画像を外部AI(Claude)に送って部屋名を読み取る方式を追加しました。これで「埋め込み文字」「ローカルOCR」「外部AI」の3つの読み取り方式がそろいましたが、どの方式も読み違いや取りこぼしはゼロになりません。

    第6回は、AI・OCRの読み取り結果を、Excelへ転記する前に人がその場で確認・修正できる画面を作ります。結論から言うと、ポイントは次の3つです。

    • 部屋名の一覧と図面を並べ、一覧で選んだ部屋名を図面上で強調する
    • 修正・削除・追加の判定ロジックは画面から切り離し、pytestで検証する
    • 1件ずつ「確認済み」にしない限り確定できないようにする
    目次

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

    メイン画面に「5. 検出結果を確認・修正する」ボタンを追加し、押すと確認画面が開きます。

    • 左側:部屋名候補の一覧(確認済みか、辞書の代表表記、読み取り元、備考)
    • 右側:図面のプレビュー。候補は青枠、一覧で選んだ候補はオレンジの太枠、候補にならなかった文字は灰色の点線
    • 操作:名前の修正、「確認済みにする/未確認に戻す」、削除(誤検出)、漏れの手動追加
    • 「確定」:未確認が残っていれば警告して止める。すべて確認済みなら確認済みリストをメイン画面に返す

    確定したリストは、次回(第7回)でExcelへ転記する入力になります。

    仕組みの説明:画面は薄く、「一覧の編集ルール」はコア処理に置く

    確認画面そのものはtkinter(Python標準のGUIライブラリ)で作りますが、「何を修正済みとみなすか」「いつ確定してよいか」といったルールは app/core/review_model.py にまとめ、画面から切り離します。第0回から続けている方針で、ルール部分は画面を開かずにpytest(自動テスト)で確かめられます。

    操作一覧で起きること(ReviewList のメソッド)
    確認画面を開く部屋名候補を図面を読む順(上→下、左→右)に並べる。同じ文字の二重検出は1件にまとめる(from_candidates)
    名前を修正入力をチェックして名前を変え、辞書と照合し直す。人が直したので「確認済み」にする(rename)
    確認済み/未確認確認済みフラグを切り替える(set_confirmed)
    削除一覧から外し、削除件数を数える(remove)
    漏れを追加手入力の部屋名を「手動追加」として加える(add)
    確定未確認が残っていれば止める。同じ名前が複数あれば了承を求める(finalize)

    実装

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

    code/
    ├ app/
    │  ├ core/
    │  │  └ review_model.py         …【今回追加】確認用の一覧(修正・削除・追加・重複・確認済み・確定)
    │  └ ui/
    │     ├ review_dialog.py        …【今回追加】確認画面(一覧+図面プレビュー)
    │     └ main_window.py          …【今回変更】「5. 検出結果を確認・修正する」ボタンを追加
    ├ tests/
    │  └ test_review_model.py       …【今回追加】pytest 31件
    └ tools/
       └ capture_screenshots.py     …【今回変更】第6回用の画面を撮影

    新しいパッケージは追加していません(requirements.txt は第5回のまま)。

    読み取り方式によらず同じ形の一覧にする(app/core/review_model.py)

    第3回の部屋名辞書で絞り込んだ結果(RoomCandidate)は、埋め込み文字・ローカルOCR・外部AIのどれから来ても同じ形です。これを一覧の1行(ReviewItem)に変換します。読み取った元の文字列は残しておき、「人が直したか」を後から判別できるようにしています。

    # app/core/review_model.py(抜粋)
    @dataclass(frozen=True)
    class ReviewItem:
        item_id: int                  # 削除・追加しても振り直さない番号
        name: str                     # 現在の部屋名(人が修正した場合は修正後)
        detected_text: str | None     # 読み取った元の文字列(手動追加は None)
        bbox: BBox | None             # 図面上の位置(PDF座標)
        page_number: int
        source: str                   # "embedded" / "ocr" / "ai" / "manual"
        canonical_name: str | None    # 辞書の代表表記(辞書に無ければ None)
        confirmed: bool = False       # 人が確認済みか
    
        @property
        def is_edited(self) -> bool:
            return self.detected_text is not None and self.name != normalize_name(self.detected_text)

    一覧を作るときは、図面を読む順に並べ替え、同じページで同じ名前の枠が半分以上重なるものを1件にまとめます。OCRや外部AIが同じ文字を二重に返すことがあるためです。離れた位置にある同じ名前(1階と2階のトイレなど)はまとめず、後述の「重複」として人に判断してもらいます。

    # app/core/review_model.py(抜粋)
    @classmethod
    def from_candidates(cls, candidates, *, source, dictionary=None,
                        overlap_threshold=DEFAULT_OVERLAP_THRESHOLD) -> ReviewList:
        if source not in SOURCE_LABELS or source == SOURCE_MANUAL:
            raise ReviewError(f"読み取り方式が不正です: {source}")
        review = cls(dictionary)
        ordered = sorted(candidates, key=lambda c: (c.word.page_number, c.word.bbox[1], c.word.bbox[0]))
        for candidate in ordered:
            word = candidate.word
            name = normalize_name(word.text)          # 全角数字などをNFKCで半角にそろえる
            if review._find_overlapping(name, word.page_number, word.bbox, overlap_threshold) is not None:
                review._merged += 1                   # 同じ文字の二重検出は1件にまとめる
                continue
            review._append(name=name, detected_text=word.text, bbox=word.bbox,
                           page_number=word.page_number, source=source,
                           canonical_name=candidate.canonical_name)
        return review

    修正・追加の入力チェック:Excelで数式になる文字を弾く

    人が入力する名前は、空欄・長すぎる文字列・改行などをチェックします。特に注意したいのが先頭の「=」「+」「-」「@」です。今回使う予定のopenpyxl 3.1.5で試したところ、"=SUM(1,2)" のような文字列をセルに入れると数式として扱われました(data_type が f)。部屋名が数式として動く事故を防ぐため、確認画面の時点で受け付けないようにしています。

    # app/core/review_model.py(抜粋)
    MAX_NAME_LENGTH = 30
    FORMULA_PREFIXES = ("=", "+", "-", "@")
    
    
    def validate_name(text: str) -> str:
        name = normalize_name(text)
        if not name:
            raise ReviewError("部屋名を入力してください。")
        if len(name) > MAX_NAME_LENGTH:
            raise ReviewError(f"部屋名は{MAX_NAME_LENGTH}文字以内で入力してください。")
        if any(unicodedata.category(ch).startswith("C") for ch in name):
            raise ReviewError("部屋名に改行・タブなどの制御文字は使えません。")
        if name.startswith(FORMULA_PREFIXES):
            raise ReviewError("部屋名の先頭に「=」「+」「-」「@」は使えません(Excelで数式として扱われるため)。")
        return name
    
    
    def rename(self, item_id: int, new_name: str) -> ReviewItem:
        item = self.get(item_id)
        name = validate_name(new_name)
        updated = replace(item, name=name, canonical_name=self._match(name), confirmed=True)
        self._items[item_id] = updated
        return updated

    修正後の名前が辞書に無い場合(例:「洗面所」を社内の呼び方の「洗面脱衣室」に直した)も受け付けます。ただし一覧の備考に「辞書外」と出し、辞書の見直しのきっかけにします。

    未確認・重複が残っていれば確定させない

    確定の判定もコア処理側に置きます。未確認が1件でもあれば ReviewIncompleteError、同じ名前が複数あれば DuplicateRoomNameError にします。同じ名前をそのまま第7回に渡すと、Excelの同じ欄に二重に書き込むおそれがあるためです。

    # app/core/review_model.py(抜粋)
    def finalize(self, *, allow_duplicates: bool = False) -> list[ReviewedRoom]:
        unconfirmed = self.unconfirmed_ids()
        if unconfirmed:
            names = "、".join(self._items[i].name for i in unconfirmed)
            raise ReviewIncompleteError(f"未確認の部屋名が{len(unconfirmed)}件あります: {names}")
        duplicates = self.duplicate_ids()
        if duplicates and not allow_duplicates:
            names = "、".join(sorted({self._items[i].name for i in duplicates}))
            raise DuplicateRoomNameError(f"同じ部屋名が複数あります: {names}")
        return [ReviewedRoom(name=item.name, canonical_name=item.canonical_name,
                             page_number=item.page_number, bbox=item.bbox,
                             source=item.source, edited=item.is_edited)
                for item in self._items.values()]

    「すべて確認済みにする」という一括ボタンはあえて置いていません。一括ボタンがあると、忙しいときに中身を見ずに押せてしまい、確認画面を作った意味が薄れるためです。代わりに、1件を確認済みにすると次の未確認の行へ自動で移るようにして、手間を減らしています。

    確認画面:一覧で選ぶと図面上の位置を強調する(app/ui/review_dialog.py)

    画面は左に ttk.Treeview(表形式の一覧部品)、右に図面の Canvas を置きます。未確認の行は薄い黄色、重複の行は薄い赤で表示します。一覧で行を選ぶと、図面上の該当箇所をオレンジの太枠で囲みます。枠が表示範囲の外にあれば、その位置までスクロールします。

    📰 出典:tkinter.ttk — Tk のテーマ付きウィジェット(Python ドキュメント)

    # app/ui/review_dialog.py(抜粋)
    def _highlight_selected(self) -> None:
        """一覧で選んだ項目を、プレビュー上でオレンジの太枠にして画面内に表示する。"""
        self.canvas.delete(SELECTED_BOX_TAG)
        item_id = self._selected_id()
        if item_id is None:
            return
        item = self._review.get(item_id)
        if item.bbox is None or item.page_number != self._page_number:
            return
        self._draw_box(item.bbox, SELECTED_BOX_COLOR, 4, SELECTED_BOX_TAG, padding=6)
        self._scroll_into_view(item.bbox)

    逆方向も用意しました。図面上の青枠をクリックすると一覧の該当行が選ばれます(ReviewList.item_at() でクリック位置を含む項目を探します)。灰色の点線(候補にならなかった文字)をクリックすると、その文字と位置が「漏れを追加」欄に入ります。辞書に無いため候補から外れた部屋名を、打ち直さずに救えます。

    「確定」ボタンは、コア処理の例外を受けて画面の表示を分けるだけです。

    # app/ui/review_dialog.py(抜粋)
    def _on_finalize(self) -> None:
        try:
            rooms = self._review.finalize()
        except ReviewIncompleteError as exc:
            messagebox.showwarning(
                "未確認の項目があります", str(exc),
                detail="図面と見比べて「確認済み」にするか、誤検出なら削除してください。",
                parent=self,
            )
            first = self._review.unconfirmed_ids()[0]
            self._refresh_list(select_id=first)       # 最初の未確認の行へ移動する
            return
        except DuplicateRoomNameError as exc:
            if not messagebox.askyesno("同じ部屋名があります", f"{exc}\n\n1階と2階のトイレのように、...", parent=self):
                return
            rooms = self._review.finalize(allow_duplicates=True)
        self.result = rooms
        self.destroy()

    メイン画面から確認画面を開く(app/ui/main_window.py)

    「4. 部屋名候補だけを絞り込んでハイライト」を実行すると、結果を保持して「5.」ボタンが押せるようになります。確認画面はモーダル(閉じるまで元の画面を操作できない)で開き、確定した一覧を self.reviewed_rooms に保持します。PDFを選び直したり検出をやり直したりした場合は、前の確認結果を捨てます。

    # app/ui/main_window.py(抜粋)
    def _on_open_review(self) -> None:
        if self._last_detection is None or self.preview.document is None:
            return
        words, candidates, source, page_number = self._last_detection
        candidate_words = {id(c.word) for c in candidates}
        other_words = [w for w in words if id(w) not in candidate_words]   # 灰色の点線で表示する文字
        try:
            review = ReviewList.from_candidates(candidates, source=source, dictionary=self._room_dictionary)
            rooms = open_review_dialog(self, document=self.preview.document, review=review,
                                       page_number=page_number, other_words=other_words)
        except (ReviewError, PdfLoadError) as exc:
            messagebox.showerror("確認画面エラー", str(exc))
            return
        if rooms is None:
            self.status_var.set("確認をキャンセルしました(確定済みの一覧は変わっていません)。")
            return
        self.reviewed_rooms = rooms

    動作確認の方法

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

    • pytest:合計144件が成功(うち tests/test_review_model.py が新規31件。Tesseract 5.3.4+日本語データの環境)。主に次を検証しました
    • 3つの読み取り方式の結果が同じ形の一覧になること、図面を読む順に並ぶこと、二重検出が1件にまとまり、離れた同名は残ること
    • 修正で名前・辞書の照合結果・確認済みが更新されること、空欄・先頭の「=」など不正な入力は拒否され一覧が変わらないこと
    • 削除・手動追加・件数の集計、クリック位置から項目を探す処理
    • 未確認が残る・同名が重複する場合に確定できないこと
    • ruff check .:エラーなし
    • 画面の配線(Xvfb上):埋め込み文字のサンプルPDFとスキャン相当の画像PDF(ローカルOCR)の両方で、図面の青枠クリックで一覧が選ばれること、灰色の文字のクリックで追加欄に文字と位置が入ること、削除・不正な名前でのエラー表示・同名の了承、確定後にメイン画面へ確認済みリストが返ることを確かめました。外部AIの結果は、同じ一覧の形になることをpytestで確認しています

    開発環境(Linux/Xvfb上・Ubuntu標準のTkテーマ)での確認画面です。Windows実機では見た目が異なります。架空のサンプル図面で「居間」を確認済みにし、「洗面所」を「洗面脱衣室」に修正したあと、一覧で「寝室1」を選んだ状態です。図面上の「寝室1」がオレンジの太枠で強調されています。

    確認画面。左に部屋名候補の一覧、右に図面があり、一覧で選んだ寝室1が図面上でオレンジの枠で強調されている

    同じ状態で「確定」を押すと、未確認の2件を示す警告が出て確定されません。

    未確認の部屋名が2件あるため確定できないことを知らせる警告

    確認できていないこと

    • Windows実機での見た目・操作感(高DPI表示での文字や枠の大きさ、キーボード操作)
    • 部屋数の多い図面・複数ページにまたがる確認のしやすさ(今回は表示中の1ページ単位)
    • 実際の担当者が確認にかける時間

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

    • 確認は「表示中のページ」単位:検出と同じく1ページずつです。複数ページ・複数ファイルをまとめて確認する形は、一括処理(第9回)で改めて設計します
    • 手動追加した部屋名には位置が無いことがある:追加欄に直接入力した場合、図面上の位置は持ちません(備考に「位置なし」と表示)。Excel転記には名前があれば足りますが、後から「図面のどこか」をたどれない点は押さえておきます
    • 数式として扱われる文字に注意:先頭が「=」などの文字列はExcelで数式になりえます。読み取り結果も人の入力も、確認画面を通す設計にしておくと、チェックを1か所にまとめられます
    • 確認済みの印は正しさの保証ではない:人が見落とせば誤りは残ります

    発注者向けメモ

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

    AI・OCRの出力を無条件に信じず、必ず人が最終確認してからExcelへ転記する運用にすることが、誤転記による手戻りや取引先からの信頼低下を防ぐ一番の対策です。読み取りの精度をどれだけ上げても、誤りがゼロになることは期待できません。「どこで人が確認するか」を最初に決めておくと、後からの作り直しを避けられます。

    確認画面を作るかどうかで、開発の工数は大きく変わります。今回のように「一覧と図面を並べ、選んだ部屋名を強調し、修正・削除・追加ができる」画面は、読み取り処理そのものと同じくらいの手間がかかることもあります。逆に確認画面を省くと、確認はExcelを開いて図面と見比べる手作業になり、利用者側の作業時間として毎回かかり続けます。開発費で払うか、運用の手間で払うかの選択として考えると判断しやすくなります。

    誰が確認するかも、画面の作りに効きます。

    確認する人画面に求められること
    図面を読み慣れた設計・積算の担当者修正の操作が速いこと。件数が多くても疲れにくいこと
    事務担当者(図面に詳しくない)図面上の位置が一目で分かること。迷ったときに差し戻せる仕組み
    転記した人とは別の人(ダブルチェック)誰がいつ確認したかの記録。確認前後の差分が見えること

    今回のサンプルは1人がその場で確認する前提で、確認者名や日時の記録は持っていません。取引先に提出する書類など、誤りの影響が大きい用途なら、記録や承認の仕組みが追加で必要になるか相談してください。

    また、「全部まとめて確認済みにする」ボタンを付けるかどうかも運用ルールの問題です。便利な反面、確認が形だけになるおそれがあります。付ける場合は、誰が押したかを残すなどの歯止めとセットで検討するのがおすすめです。

    • ☐ 読み取り結果を、Excelへ書き込む前に人が確認する運用にするか決めたか
    • ☐ 確認する担当者(部署・スキル)と、1件あたりにかけられる時間の目安を決めたか
    • ☐ ダブルチェックや確認記録(誰が・いつ)が必要な書類かどうかを整理したか
    • ☐ 確認画面を作る費用と、画面なしで手作業確認を続ける手間を比べたか
    • ☐ 確認画面で直した名前(社内の呼び方など)を、辞書の改善に反映する担当を決めたか

    開発会社への質問例

    • 「AIやOCRの結果は、Excelに書き込まれる前に必ず人が確認する流れになっていますか?確認を飛ばして書き込める経路はありますか?」
    • 「確認画面で修正・削除・追加した内容は記録に残りますか?誰が確認したかを後から確認できますか?」
    • 「確認済みにし忘れた項目がある場合、どのように気付ける仕組みになっていますか?」

    まとめと次回予告

    第6回では、読み取った部屋名を人が確認・修正する確認画面(app/ui/review_dialog.py)と、その編集ルールをまとめた app/core/review_model.py を実装しました。3つの読み取り方式を同じ一覧にそろえる、選んだ部屋名を図面上で強調する、未確認が残れば確定させない、の3点がポイントです。

    次回(第7回)は「指定のExcelフォーマットの決まった欄へ転記する」です。今回確定した確認済みリストを、openpyxlで既存のExcelテンプレートの決まったセルへ書き込みます。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (2件)

      目次