紙に手書きされた請求書を、スキャンするだけで自動的に「表のデータ」に変換します。 人は、コンピュータが迷った所だけを確認すればよい — そんな仕組みを、実際の画像とイラストでご紹介します。
なぜ作ったのか
毎月たくさん届く手書きの請求書。これを人が1枚ずつ目で見て、表計算ソフトに打ち込む作業は、 時間がかかり、打ち間違いも起こります。このシステムは、その「読み取り」と「入力」をコンピュータに任せます。
手書きの請求書を人が読み、数字を1つずつ入力。枚数が多いほど時間と手間がかかり、転記ミスのリスクもありました。
ペーパーレスで、低コストに。コンピュータが下読みをして、人は「合っているかの確認」だけに集中できるようにします。
「分からない所を勝手に埋めない」こと。自信のない箇所はきちんと印を付け、人の目に回します。間違いを静かに混ぜ込まない設計です。
全体像
スキャンした請求書は、ベルトコンベアのように次の4段階を通って、最後は確認・承認されたデータになります。
読み取りの仕組み
1枚の請求書が、データになるまで。各ステップに「実際の画像」または「イメージイラスト」を添えました。 画像はクリックすると拡大できます。各ステップの末尾には、エンジニア向けの技術メモも畳んであります。
複合機でスキャンした請求書は、まずPDFで届きます。これを1ページずつ、細部までくっきり見える高解像度(300dpi)の画像に変換します。 1枚のPDFに複数ページあれば、ページごとに「読み取り待ち」の行が作られ、順番に処理されていきます。
ingest.py の IngestService.ingest_pdf()。PDFマジック %PDF- を検証し、PyMuPDFでページ数を取得。
ページ画像は遅延レンダリング(ワーカーが処理時に PDF_RENDER_DPI = 300 で描画)。
シートは status=queued で先に作成し、ワーカーが1件ずつ取り出して処理します(DBバックのキュー)。
スキャンするたびに、用紙は少しずつ傾いたり、寝た向きで入っていたりします。そこで、印刷された赤い枠を手がかりに用紙の四隅を見つけ、 毎回まったく同じ「基準の形」に回転・拡大縮小して揃えます。こうすると、「この場所には品名」「ここは金額」と、 後の工程が場所を正確に当てにできるようになります。
registration.py。赤い枠(form mask)から最小外接矩形でフォーム四角を検出 →
相似変換(similarity)で整列。getPerspectiveTransform(台形補正)は使いません — フラットベッドスキャンに遠近歪みは無く、
estimateAffinePartial2D(LMEDS) で scale_x == scale_y(≈0.921)を保ちます。
基準フレームは PORT_W=1400 × PORT_H=2408、余白 MARGIN=0.07、最後に ROTATE_90_CW。
この請求書は、用紙の枠線や項目名が赤色で印刷されています。一方、人が書き込むのは黒や青のペン。 そこで「赤色だけを消す」と、印刷された枠や赤い印鑑(ハンコ)はきれいに消え、手書き文字だけが浮かび上がります。 これがこのシステムの一番のカギです。
preprocess.py の PreprocessService(実体は ocr_form_kit の RedKill 戦略)。
赤チャンネルを抑制して暗い非赤インクのみ残す handwriting_mask を生成。逆マスク(form_mask)は位置合わせに使用。
同じマスクから後段の「インク量」判定(占有・マーク・取り消し線)も計算します。
整列した用紙の上に、「ここは品名」「ここは個数」「ここは金額」…という項目の地図を重ねます。 この地図は、PowerPointのスライド上で人が一度だけ手作業で正確に作ったもの。用紙の様式は全社共通なので、一度作れば全ての請求書に使えます。 行は7行、列は7列(品名・納品・取付・個数・単価・販売金額・販売原価)です。
地図のSSOTは docs/area_def.pptx → aisan template import-pptx →
data/templates/master.json(正規化座標 [0,1])。
7列 = hinmei|nouhin|toritsuke|suryo|tanka|kingaku|genka。
内側の罫線は手書きで途切れ自動検出に向かないため、地図は手描き(一度作れば正確に固定)。
grid.py は診断用の罫線検出ヘルパー。
ここが工夫のしどころ。AIに表をそのまま渡すと、隣の行と混ざったり、行を二重に読んだりします。 そこで、7つの行を1本ずつ離れた帯(ストリップ)に分解し、上にアルファベットの列見出しを付けた、きれいな1枚の画像を作ります。 こうすると「どの文字がどの行・どの列か」が物理的に固定され、AIが取り違えようがなくなります。
composite.py の build_composite。赤キル → 白地に手書きのみ転写 →
テーブルを 7本の独立した ROW ストリップに展開(行の結合・重複を物理的に防止)+ Latin列見出しと区切り。
納品/取付は is_marked(MARK_THRESHOLD=0.05)で決定し出力を上書き(マークのSSOT)。
実証:伊良皆-15 で「9行の崩れ → 5行のクリーン出力」に改善。1画像=1API呼び出し。
こうしてきれいに整えた1枚を、文字認識AI(Google の Gemini)に渡します。 このAIは日本語の手書きに強く、しかも「必ず決まった形(表の項目名)で答えてね」と形式を縛れるので、 答えがそのまま表データになります。複数のAIを比べて、精度・速さ・コストのバランスが一番良いものを採用しています。
engines/gemini.py。response_mime_type=application/json + response_schema(SHEET_JSON_SCHEMA)で
制約デコード(フェンス除去不要)。エンジンは OcrEngine Protocol の裏に差し替え可能で、
gemini-flash がベンチで勝者(claude-haiku は行の約9割を落とし、claude-sonnet はCLI経由で遅すぎ、docai は印刷文字も拾う)。
コストは約 46.5円/百万トークン。
AIは時々、空欄に勝手に数字を埋めてしまうことがあります。そこでこのシステムは、「実際にインクが付いているか」を別の目で測り、 AIの答え合わせをします。インクの無いマスに書かれた値は消し、○印はインク量で機械的に判定。 横線で消された行(取り消し線)は「無効」として丸ごと外します。インクこそが真実、という考え方です。
services.py の ReconcileService。行ごとに is_occupied(0.012)・is_struck(0.12) を判定。
インク無セル(CELL_INK_THRESHOLD=0.006)の値はSSOTで null 化(誤読フィルタ)。
検算は profile の式 {suryo}*{tanka}=={kingaku}(tol 1.0)。
needs_human = (occupied かつ not parsed) または (math が False)。取り消し行は canceled で集計除外。
システムは、自信のない行に「要確認」の印を付けます。人はすべてを見直す必要はなく、その印の付いた所だけを直せば済みます。 まず事務が内容を確認・修正し、続いて営業・経理・社長がそれぞれ承認のサインを入れて完了。 ハンコの押印も、インクから自動で読み取ります。
役割は models.py の ROLES(eigyo/jimu/keiri/shacho)。
jimu は確認のみ、APPROVER_ROLES(営業・経理・社長)がサインオフ。
ステータスは queued→processing→ocr_done。サインオフ規則は ApprovalService、
メタ編集・再OCRは SheetService(DIの継ぎ目で薄いルータが委譲)。これらはWebアプリとして稼働中。
実例で見る
実際のスキャン(伊良皆スキャン)を、上のステップで処理した結果です。元画像 → 整えた画像 → 抽出データ、の順でご覧ください。
| 品名 | 納品 | 個数 | 金額 | 原価 |
|---|---|---|---|---|
| ワタナベBKプレート#1 | ○ | 1 | 42,000 | 29,400 |
要確認の行:0件(そのまま承認へ)
○印はインク判定、合計は売上金欄から、要確認0件は検算が通ったことを意味します。画像はクリックで拡大できます。
なぜ正確なのか
単にAIに丸投げするのではなく、AIが間違えやすい所を先回りで潰しています。実際のベンチマークでも、工夫前より明確に良い結果が出ています。
行を1本ずつ分解して渡すことで、行の混ざり・二重読みを防止。印刷ラベルの混入は 15回 → 0回 に。
○印や記入の有無はAIに推測させず、インク量で機械判定。空欄への作り話(誤読)をシステムが打ち消します。
「個数 × 単価 = 金額」を検算し、合わない行に印。間違いを見逃さず、人の確認に正確に回します。
開発の歩み
小さく作って検証しながら、段階的に育ててきました。すべて「まず動くCLI → 検証 → 次の段階」という進め方です。
画像処理(位置合わせ・赤キル・インク判定)と、項目マップ、複数AIエンジンを差し替え可能な構造(DI)で実装。1つの様式で全業者をカバー。
「表をバラす(コンポジット)」方式を発明し本番採用。誤読フィルタ・取り消し線検出・○印のインク判定を追加。ベンチで全軸改善を確認。
ログイン → アップロード → 読み取りキュー → 事務の確認 → 営業・経理・社長の承認、までをWebで実現。本番サーバーで稼働中。
項目や役割の定義は「1か所だけ」に置き(SSOT)、コードの整合性を自動チェック。AIエンジンは1行で差し替え可能。ネット不要のテストで常時検証。
技術詳細(エンジニア向け)
ここからは開発者向けの付録です。データフロー、主要な定数、AIエンジンの比較、モジュール構成をまとめます。
正典は docs/architecture.md・docs/STATUS.md・docs/bench_results.md。
| 定数 | 値 | 意味 |
|---|---|---|
PDF_RENDER_DPI | 300 | スキャンの描画解像度 |
PORT_W × PORT_H | 1400 × 2408 | 基準フレーム(縦長) |
MARGIN | 0.07 | フォーム外周の余白(7%) |
OCCUPIED_THRESHOLD | 0.012 | 行にインク>1.2% → 記入あり |
MARK_THRESHOLD | 0.05 | ○印セルにインク>5% → 印あり |
CELL_INK_THRESHOLD | 0.006 | インク<0.6% → 値を null(誤読除去) |
STRIKE_THRESHOLD | 0.12 | 横線が幅の12%以上 → 取り消し線 |
~46.5 円/Mtok | $0.30×155 | Geminiの概算単価 |
| エンジン | 方式 | 結果 | コスト |
|---|---|---|---|
| gemini-flash ★ | google-genai / 制約スキーマ | 10/10安定・最高リコール・約14s | ~¥0.28/枚 |
| claude-haiku | claude -p サブプロセス | 行の約9割を落とす(実用不可) | 定額 |
| claude-sonnet | claude -p サブプロセス | 9/10タイムアウト(遅すぎ) | 定額 |
| docai | Document AI REST | 印刷文字も拾う(列分割が必要) | ~¥0.23/枚 |
エンジンは OcrEngine Protocol の裏。FakeEngine でオフラインテスト。合成根 = factory.py + cli.py。
| モジュール | 責務 |
|---|---|
| domain.py | SheetExtraction / LineItem、項目レジストリ(SSOT)、JSONスキーマ |
| preprocess.py | 赤キル、位置合わせ入口、インク量・○印・取り消し線・押印判定 |
| registration.py | 赤枠検出 → 相似変換 → 正位置フレーム |
| composite.py | 本番のモデル入力画像(行を展開・○印確定) |
| engines/*.py | OcrEngine Protocol と各アダプタ(gemini/claude/docai/fake) |
| services.py | ReconcileService / OcrService / BenchmarkService |
| sheet_service.py / approval_service.py | メタ編集・再OCR / サインオフ規則(DIの継ぎ目) |
| repository.py | 唯一のSQL層(テストは :memory:) |
SSOT生成の継ぎ目:Python のレジストリ(domain.py/models.py)が唯一の出所。
scripts/gen-meta.py が web/src/api/generated-meta.ts へ反映。
ゲートは scripts/verify.sh(オフライン pytest + 再生成 + tsc -b)。
役割や項目の一覧をTSで手書きしないこと。