社内システム解説 | アイサン工業 請求書OCR

手書きの請求書を、
コンピュータが読み取る仕組み

紙に手書きされた請求書を、スキャンするだけで自動的に「表のデータ」に変換します。 人は、コンピュータが迷った所だけを確認すればよい — そんな仕組みを、実際の画像とイラストでご紹介します。

106/106枚すべて自動で位置合わせ成功
約0.28円1枚あたりの読み取りコスト
約9秒1枚を読み取る時間
紙の請求書がデジタルの表に変わるイメージ

なぜ作ったのか

紙と手入力の負担を、なくしたい

毎月たくさん届く手書きの請求書。これを人が1枚ずつ目で見て、表計算ソフトに打ち込む作業は、 時間がかかり、打ち間違いも起こります。このシステムは、その「読み取り」と「入力」をコンピュータに任せます。

🗂️

これまでの課題

手書きの請求書を人が読み、数字を1つずつ入力。枚数が多いほど時間と手間がかかり、転記ミスのリスクもありました。

🎯

このシステムの目標

ペーパーレスで、低コストに。コンピュータが下読みをして、人は「合っているかの確認」だけに集中できるようにします。

大切にしたこと

「分からない所を勝手に埋めない」こと。自信のない箇所はきちんと印を付け、人の目に回します。間違いを静かに混ぜ込まない設計です。

全体像

大きく分けて4つの流れ

スキャンした請求書は、ベルトコンベアのように次の4段階を通って、最後は確認・承認されたデータになります。

📄
取り込みPDFを画像に
🤖
読み取りAIが文字を認識
🔍
確認事務がチェック
🖋️
承認営業・経理・社長
このページでは、特に真ん中の 「読み取り」 —— コンピュータがどうやって手書き文字を表データに変えているのか —— を、順を追って詳しく見ていきます。
紙が処理されてデータになるパイプラインのイメージ

読み取りの仕組み

アルゴリズムを、ステップで見る

1枚の請求書が、データになるまで。各ステップに「実際の画像」または「イメージイラスト」を添えました。 画像はクリックすると拡大できます。各ステップの末尾には、エンジニア向けの技術メモも畳んであります。

1 取り込み | Ingest

スキャンしたPDFを、画像にするPDF → PNG @ 300dpi

複合機でスキャンした請求書は、まずPDFで届きます。これを1ページずつ、細部までくっきり見える高解像度(300dpi)の画像に変換します。 1枚のPDFに複数ページあれば、ページごとに「読み取り待ち」の行が作られ、順番に処理されていきます。

PDFの中身を検証ページ数を数える1ページ=1シート
技術メモ

ingest.pyIngestService.ingest_pdf()。PDFマジック %PDF- を検証し、PyMuPDFでページ数を取得。 ページ画像は遅延レンダリング(ワーカーが処理時に PDF_RENDER_DPI = 300 で描画)。 シートは status=queued で先に作成し、ワーカーが1件ずつ取り出して処理します(DBバックのキュー)。

スキャンされた元の請求書
実際のスキャン画像(元データ)— 用紙は90°寝た状態で届きます
2 位置合わせ | Registration

傾き・向きを直して、ピシッと揃える位置合わせ(似た形に整える変換)

スキャンするたびに、用紙は少しずつ傾いたり、寝た向きで入っていたりします。そこで、印刷された赤い枠を手がかりに用紙の四隅を見つけ、 毎回まったく同じ「基準の形」に回転・拡大縮小して揃えます。こうすると、「この場所には品名」「ここは金額」と、 後の工程が場所を正確に当てにできるようになります。

106枚すべて成功90°回して正位置に引き伸ばさない
技術メモ

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

四隅を検出 基準の形に整列した請求書
左:用紙の四隅を検出 / 右:基準の形に整列(行が横向きの正位置に)
3 赤を消す | Red-kill

印刷された枠を消し、手書きだけを残す赤色を除去して手書きを抽出

この請求書は、用紙の枠線や項目名が赤色で印刷されています。一方、人が書き込むのは黒や青のペン。 そこで「赤色だけを消す」と、印刷された枠や赤い印鑑(ハンコ)はきれいに消え、手書き文字だけが浮かび上がります。 これがこのシステムの一番のカギです。

赤い枠が消える印鑑も消える手書きだけ残る
技術メモ

preprocess.pyPreprocessService(実体は ocr_form_kit の RedKill 戦略)。 赤チャンネルを抑制して暗い非赤インクのみ残す handwriting_mask を生成。逆マスク(form_mask)は位置合わせに使用。 同じマスクから後段の「インク量」判定(占有・マーク・取り消し線)も計算します。

手書きを抽出した様子 手書きだけのマスク画像
左:手書きをハイライト / 右:赤を消して残った「手書きインク」だけの画像
4 枠を当てる | Area map

「どこに何が書いてあるか」の地図を重ねるテンプレート(項目マップ)

整列した用紙の上に、「ここは品名」「ここは個数」「ここは金額」…という項目の地図を重ねます。 この地図は、PowerPointのスライド上で人が一度だけ手作業で正確に作ったもの。用紙の様式は全社共通なので、一度作れば全ての請求書に使えます。 行は7行、列は7列(品名・納品・取付・個数・単価・販売金額・販売原価)です。

7行 × 7列PowerPointで作成全業者で共通
技術メモ

地図のSSOTは docs/area_def.pptxaisan template import-pptxdata/templates/master.json(正規化座標 [0,1])。 7列 = hinmei|nouhin|toritsuke|suryo|tanka|kingaku|genka。 内側の罫線は手書きで途切れ自動検出に向かないため、地図は手描き(一度作れば正確に固定)。 grid.py は診断用の罫線検出ヘルパー。

項目の地図を重ねた様子
整列した請求書に、項目の地図(色付きの枠)を重ねたところ
5 表をバラす | Composite

AIが間違えないよう、表を1行ずつに「バラす」コンポジット画像の生成

ここが工夫のしどころ。AIに表をそのまま渡すと、隣の行と混ざったり、行を二重に読んだりします。 そこで、7つの行を1本ずつ離れた帯(ストリップ)に分解し、上にアルファベットの列見出しを付けた、きれいな1枚の画像を作ります。 こうすると「どの文字がどの行・どの列か」が物理的に固定され、AIが取り違えようがなくなります。

同時に、納品・取付の ○印はAIに読ませず、インクの量から機械的に判定して画像に焼き込みます(後述のステップ7)。
技術メモ

composite.pybuild_composite。赤キル → 白地に手書きのみ転写 → テーブルを 7本の独立した ROW ストリップに展開(行の結合・重複を物理的に防止)+ Latin列見出しと区切り。 納品/取付は is_markedMARK_THRESHOLD=0.05)で決定し出力を上書き(マークのSSOT)。 実証:伊良皆-15 で「9行の崩れ → 5行のクリーン出力」に改善。1画像=1API呼び出し。

行ごとにバラした画像
AIに渡す「コンポジット画像」— 各行が離れた帯になり、英語の列見出しが付く
6 AIが読む | OCR

整えた画像を、AIが文字に起こすGemini 2.5 Flash / 構造化出力

こうしてきれいに整えた1枚を、文字認識AI(Google の Gemini)に渡します。 このAIは日本語の手書きに強く、しかも「必ず決まった形(表の項目名)で答えてね」と形式を縛れるので、 答えがそのまま表データになります。複数のAIを比べて、精度・速さ・コストのバランスが一番良いものを採用しています。

日本語手書きに強い答えの形を固定(JSON)10枚中10枚 安定
技術メモ

engines/gemini.pyresponse_mime_type=application/jsonresponse_schemaSHEET_JSON_SCHEMA)で 制約デコード(フェンス除去不要)。エンジンは OcrEngine Protocol の裏に差し替え可能で、 gemini-flash がベンチで勝者(claude-haiku は行の約9割を落とし、claude-sonnet はCLI経由で遅すぎ、docai は印刷文字も拾う)。 コストは約 46.5円/百万トークン

# AIが返す「決まった形」のデータ(イメージ) { "customer": "玉本 秋夫", "genba": "豊浜", "job_date": "25-12-06", "lines": [ { "hinmei": "ワタナベBKプレート#1", "suryo": "1", "kingaku": "42000", "genka": "29400" } ] }
AIの答えは、はじめから「表の形」で返ってくる
7 インクが真実 | 照合

「書いてあるか」は、AIではなくインクで判定突合(リコンサイル)と誤読防止

AIは時々、空欄に勝手に数字を埋めてしまうことがあります。そこでこのシステムは、「実際にインクが付いているか」を別の目で測り、 AIの答え合わせをします。インクの無いマスに書かれた値は消し、○印はインク量で機械的に判定。 横線で消された行(取り消し線)は「無効」として丸ごと外します。インクこそが真実、という考え方です。

○印 → インクで判定 空マスの値 → 消す 取り消し線 → 無効 個数×単価=金額 を検算
技術メモ

services.pyReconcileService。行ごとに 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 で集計除外。

インクの印を検出するイメージ
○印や記入の有無は、AIの推測ではなく「インクの量」で確実に判定する
8 人は確認だけ | 承認

あとは、人が「迷った所」を確認して承認事務の確認 → 営業・経理・社長の承認

システムは、自信のない行に「要確認」の印を付けます。人はすべてを見直す必要はなく、その印の付いた所だけを直せば済みます。 まず事務が内容を確認・修正し、続いて営業・経理・社長がそれぞれ承認のサインを入れて完了。 ハンコの押印も、インクから自動で読み取ります。

事務:確認・修正 営業:承認 経理:承認 社長:承認
技術メモ

役割は models.pyROLESeigyo/jimu/keiri/shacho)。 jimu は確認のみ、APPROVER_ROLES(営業・経理・社長)がサインオフ。 ステータスは queued→processing→ocr_done。サインオフ規則は ApprovalService、 メタ編集・再OCRは SheetService(DIの継ぎ目で薄いルータが委譲)。これらはWebアプリとして稼働中。

人が要確認の所だけ見るイメージ 承認の流れのイメージ
左:人は「要確認」の所だけ見ればよい / 右:事務 → 営業 → 経理 → 社長と承認

実例で見る

1枚の請求書が、データになるまで

実際のスキャン(伊良皆スキャン)を、上のステップで処理した結果です。元画像 → 整えた画像 → 抽出データ、の順でご覧ください。

① 元のスキャン

元のスキャン

② バラした画像

バラした画像

③ 抽出されたデータ

  • 業者アイサン工業
  • 客先玉本 秋夫
  • 現場豊浜
  • 番号303-106
  • 日付25年12月6日
  • 合計¥42,000
品名納品個数金額原価
ワタナベBKプレート#1142,00029,400

要確認の行:0件(そのまま承認へ)

○印はインク判定、合計は売上金欄から、要確認0件は検算が通ったことを意味します。画像はクリックで拡大できます。

なぜ正確なのか

3つの「賢い設計」

単にAIに丸投げするのではなく、AIが間違えやすい所を先回りで潰しています。実際のベンチマークでも、工夫前より明確に良い結果が出ています。

🧱

表をバラして渡す

行を1本ずつ分解して渡すことで、行の混ざり・二重読みを防止。印刷ラベルの混入は 15回 → 0回 に。

🩸

インクを真実にする

○印や記入の有無はAIに推測させず、インク量で機械判定。空欄への作り話(誤読)をシステムが打ち消します。

🔢

計算で答え合わせ

「個数 × 単価 = 金額」を検算し、合わない行に印。間違いを見逃さず、人の確認に正確に回します。

ベンチマーク結果(10枚・実データ): 工夫後(コンポジット)は工夫前(フルページ)に対し、 ラベル誤読 0回(旧15回)、登録番号の混入 0回(旧2回)、○印の検出 28個(旧19個)、 処理 8.6秒(旧12.3秒)、コスト ¥2.12(旧¥2.45)。すべての軸で改善しました。

開発の歩み

PoC から、稼働中のWebアプリへ

小さく作って検証しながら、段階的に育ててきました。すべて「まず動くCLI → 検証 → 次の段階」という進め方です。

完了

ステップ1〜3 | 土台づくり

画像処理(位置合わせ・赤キル・インク判定)と、項目マップ、複数AIエンジンを差し替え可能な構造(DI)で実装。1つの様式で全業者をカバー。

完了

ステップ4 | 精度の作り込み

「表をバラす(コンポジット)」方式を発明し本番採用。誤読フィルタ・取り消し線検出・○印のインク判定を追加。ベンチで全軸改善を確認。

完了・稼働中

ステップ5 | Webアプリ化&本番公開

ログイン → アップロード → 読み取りキュー → 事務の確認 → 営業・経理・社長の承認、までをWebで実現。本番サーバーで稼働中。

設計の柱

一貫した設計思想

項目や役割の定義は「1か所だけ」に置き(SSOT)、コードの整合性を自動チェック。AIエンジンは1行で差し替え可能。ネット不要のテストで常時検証。

技術詳細(エンジニア向け)

アーキテクチャと閾値

ここからは開発者向けの付録です。データフロー、主要な定数、AIエンジンの比較、モジュール構成をまとめます。 正典は docs/architecture.mddocs/STATUS.mddocs/bench_results.md

データフロー(CLIパイプライン)
PDF / PNG │ preprocess.py + registration.py ├─ load_bgr PDFを300dpiで描画 / PNG読込 ├─ register (位置合わせ) 赤枠検出 → 相似変換 → 正位置(90°) ├─ handwriting_mask 赤キル:黒/青の手書きだけ残す └─ region_ink_coverage セル毎のインク量 → OCCUPIED / EMPTY │ composite.py — 本番の入力画像 └─ build_composite 赤キル → 白地転写 → 7行に展開 → ○印をインクで確定 │ engines/* (OcrEngine Protocol) └─ gemini-flash.read 構造化JSON → SheetExtraction │ services.py ├─ ReconcileService インク vs OCR の突合 + 検算 └─ Repository submission → sheet → line_item (SQLite)
主要な定数・閾値
定数意味
PDF_RENDER_DPI300スキャンの描画解像度
PORT_W × PORT_H1400 × 2408基準フレーム(縦長)
MARGIN0.07フォーム外周の余白(7%)
OCCUPIED_THRESHOLD0.012行にインク>1.2% → 記入あり
MARK_THRESHOLD0.05○印セルにインク>5% → 印あり
CELL_INK_THRESHOLD0.006インク<0.6% → 値を null(誤読除去)
STRIKE_THRESHOLD0.12横線が幅の12%以上 → 取り消し線
~46.5 円/Mtok$0.30×155Geminiの概算単価
AIエンジンの比較(ベンチで gemini-flash を採用)
エンジン方式結果コスト
gemini-flash ★google-genai / 制約スキーマ10/10安定・最高リコール・約14s~¥0.28/枚
claude-haikuclaude -p サブプロセス行の約9割を落とす(実用不可)定額
claude-sonnetclaude -p サブプロセス9/10タイムアウト(遅すぎ)定額
docaiDocument AI REST印刷文字も拾う(列分割が必要)~¥0.23/枚

エンジンは OcrEngine Protocol の裏。FakeEngine でオフラインテスト。合成根 = factory.py + cli.py

モジュール構成(主要ファイル)
モジュール責務
domain.pySheetExtraction / LineItem、項目レジストリ(SSOT)、JSONスキーマ
preprocess.py赤キル、位置合わせ入口、インク量・○印・取り消し線・押印判定
registration.py赤枠検出 → 相似変換 → 正位置フレーム
composite.py本番のモデル入力画像(行を展開・○印確定)
engines/*.pyOcrEngine Protocol と各アダプタ(gemini/claude/docai/fake)
services.pyReconcileService / OcrService / BenchmarkService
sheet_service.py / approval_service.pyメタ編集・再OCR / サインオフ規則(DIの継ぎ目)
repository.py唯一のSQL層(テストは :memory:)

SSOT生成の継ぎ目:Python のレジストリ(domain.py/models.py)が唯一の出所。 scripts/gen-meta.pyweb/src/api/generated-meta.ts へ反映。 ゲートは scripts/verify.sh(オフライン pytest + 再生成 + tsc -b)。 役割や項目の一覧をTSで手書きしないこと。

動かしてみる(CLI)
# 各工程のデバッグ画像を出力(5_composite.png 含む) uv run aisan steps <img> # コンポジット画像 + ○印だけを出力 uv run aisan composite <img> # 1枚をOCR(既定: gemini-flash + composite) uv run aisan ocr <img> # ベンチ(フルページ vs コンポジット) uv run aisan bench --n 10 --engines gemini-flash --modes fullpage,composite # オフライン自己検証(ネット不要・FakeEngine) uv run pytest -q