Zenn
🍹

領収書OCRをGeminiとChrome拡張で実装♪

2025/02/18に公開
2
3

はじめに

直近Googleから発表されたGemini 2.0 FlashのOCR精度が高いという情報を得ました。
実際弊社澤木のZenn記事によると、斜めに写った帳票もなんなく認識してくれている様子でした。
https://zenn.dev/acntechjp/articles/87b068fa6a79a6
以前何度か領収書OCRの仕組みを記事にしてきましたが、よりライトに使いやすくを考えて、今回はGemini 2.0 FlashとChrome拡張を組み合わせて領収書OCRの仕組みを実装してみました。
https://zenn.dev/acntechjp/articles/bc21a1bea374dd
https://zenn.dev/acntechjp/articles/a018d81a790895
https://zenn.dev/acntechjp/articles/fabecf397747a4
https://zenn.dev/acntechjp/articles/61540b2f4586d3

実装イメージ

Google Chromeで拡張機能を実行すると下記画面が開いて、領収書を撮影して、Gemini 2.0 Flashへ送ってOCRしてもらう実装をしました。

アーキテクチャ

実装したアーキテクチャは下記の通りとなります。

  1. 「領主書」を用意して、PCのカメラを使ってChrome拡張経由で撮影する
  2. 撮影したものを「Gemini 2.0 Flash」へ送信して、OCR結果を取得する
  3. OCR結果はCSVファイル形式になっており、CSVファイルとしてPCへ保存する
  4. 撮影した「領収書」もPCへ画像として保存する

実装方法

フォルダ構成

フォルダ構成は下記となります。

フォルダ構成
my-gemini-ocr-extension/
  ├── manifest.json
  ├── popup.html
  ├── popup.js
  ├── options.html
  └── options.js

manifest.json

Chrome拡張機能の定義で一般的にはパーミッション、起動するスクリプトなどを記載します。
今回は、定義情報や使用するHTMLを指定しています。

manifest.json
manifest.json
{
    "name": "Gemini OCR Extension",
    "description": "PCカメラで撮影し、Gemini 2.0 Flash APIでOCRを行うChrome拡張",
    "version": "1.0.0",
    "manifest_version": 3,
    "permissions": [
      "storage"
    ],
    "action": {
      "default_popup": "popup.html"
    },
    "options_ui": {
      "page": "options.html",
      "open_in_tab": true
    }
  }

popup.html / popup.js

拡張ポップアップ。ここでカメラ撮影UIやOCRリクエスト呼び出しを行います。

popup.html
popup.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>領収書OCR</title>
    <style>
      /* ポップアップ自体の幅をある程度固定しておく */
      body {
        width: 360px;  /* お好みで変更してください */
        margin: 12px;
        font-family: sans-serif;
      }
      h1, h2 {
        margin: 0.5em 0;
      }
      button {
        margin: 4px 0;
        padding: 6px 12px;
      }
      /* 動画/画像の枠のスタイル */
      #video, #capturedImage {
        display: block;
        width: 100%;
        max-height: 240px;
        object-fit: cover;
        border: 1px solid #ccc;
        margin-bottom: 8px;
        background: #eee;
      }
      /* テキストエリアの横幅を合わせる */
      #ocrResult {
        width: 100%;
        box-sizing: border-box;
        height: 100px;
      }
      .section {
        margin-bottom: 16px;
      }
      /* ボタングループのスタイル */
      .button-group {
        display: flex;
        gap: 8px;
        flex-wrap: wrap;
      }
      /* 撮り直しボタンの強調スタイル */
      #resetBtn {
        background-color: #f5f5f5;
        border: 1px solid #ddd;
      }
    </style>
  </head>
  <body>
    <h1>領収書 OCR</h1>
    <!-- カメラ映像/撮影画像エリア -->
    <div class="section">
      <video id="video" autoplay></video>
      <img id="capturedImage" alt="撮影画像プレビュー" style="display:none;" />
      <!-- 追加: Canvas要素をDOMに最初から配置 -->
      <canvas id="canvas" style="display:none;"></canvas>
    </div>
    <!-- ボタン群 - クラス追加とレイアウト調整 -->
    <div class="section button-group">
      <button id="captureBtn">撮影</button>
      <!-- 撮り直しボタン -->
      <button id="resetBtn" style="display:none;">撮り直し</button>
      <button id="ocrBtn" disabled>OCR解析</button>
      <button id="downloadImgBtn" disabled>画像を保存</button>
    </div>
    <!-- OCR結果表示 -->
    <div class="section">
      <h2>OCR結果</h2>
      <textarea id="ocrResult" placeholder="ここにCSVが表示されます"></textarea><br />
      <button id="downloadTxtBtn" disabled>CSVを保存</button>
    </div>
    <script src="popup.js"></script>
  </body>
</html>
popup.js
popup.js
// popup.js
let currentStream = null;

// 要素取得
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const captureBtn = document.getElementById('captureBtn');
const ocrBtn = document.getElementById('ocrBtn');
const downloadImgBtn = document.getElementById('downloadImgBtn');
const downloadTxtBtn = document.getElementById('downloadTxtBtn');
const capturedImage = document.getElementById('capturedImage');
const ocrResult = document.getElementById('ocrResult');
// 撮り直しボタン取得
const resetBtn = document.getElementById('resetBtn');

// カメラ起動関数(再利用するため関数化)
function startCamera() {
  // 既存のストリームがあれば停止
  if (currentStream) {
    currentStream.getTracks().forEach(track => track.stop());
  }
  
  // カメラを起動
  return navigator.mediaDevices.getUserMedia({ video: true })
    .then(stream => {
      currentStream = stream;
      video.srcObject = stream;
      video.style.display = 'block';
      
      // UI状態をリセット
      capturedImage.style.display = 'none';
      resetBtn.style.display = 'none';
      ocrBtn.disabled = true;
      downloadImgBtn.disabled = true;
      
      return stream;
    })
    .catch(err => {
      console.error('Camera access error:', err);
      alert('カメラへのアクセスに失敗しました: ' + err.message);
    });
}

// 1. 初期カメラ起動
startCamera();

// 2. 撮影(キャプチャ) → Canvasに描画 → 動画を非表示 + 画像プレビューを表示
captureBtn.addEventListener('click', () => {
  if (!video.srcObject) {
    alert('カメラが起動していません。');
    return;
  }
  
  // videoのサイズを取得しcanvasへ描画
  const width = video.videoWidth;
  const height = video.videoHeight;
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(video, 0, 0, width, height);
  
  // 撮影した画像をDataURLに変換してimgタグに表示
  const dataUrl = canvas.toDataURL('image/png');
  capturedImage.src = dataUrl;
  
  // カメラ映像を停止&非表示にする
  if (currentStream) {
    currentStream.getTracks().forEach(track => track.stop());
    currentStream = null;
  }
  video.style.display = 'none';
  video.srcObject = null;
  
  // 代わりに撮影画像表示
  capturedImage.style.display = 'block';
  
  // その他ボタン活性化
  ocrBtn.disabled = false;
  downloadImgBtn.disabled = false;
  // 追加: 撮り直しボタン表示
  resetBtn.style.display = 'block';
});

// 3. OCRボタン押下時に画像を送信 → 結果を取得
ocrBtn.addEventListener('click', async () => {
  if (!canvas) {
    alert('画像が撮影されていません。');
    return;
  }
  
  try {
    // OCR処理中の表示
    ocrBtn.disabled = true;
    ocrBtn.textContent = '処理中...';
    
    // CanvasからBase64を取得
    const dataUrl = canvas.toDataURL('image/png');
    const base64Data = dataUrl.split(',')[1]; // 先頭 "data:image/png;base64," を取り除く

    // APIキーをストレージから取得
    const { geminiApiKey } = await chrome.storage.local.get('geminiApiKey');
    if (!geminiApiKey) {
      alert('先にオプションページでAPIキーを設定してください。');
      return;
    }

    // ▼ ここでプロンプトを定義
    const promptText = `
    あなたは優秀な経理担当者です。受け取った領収書を画像解析して文字や金額を起こしてください。
## 重要事項
- わからない項目がある場合は、正直に「N/A」と記入してください。
- 1枚の画像に複数の領収書が含まれている場合は、それぞれの領収書ごとに別々のCSVを作成してください。
- 各値はダブルクォーテーションで囲ってください
- 回答はCSVのみで出力してください。
- 標準的でない形式や追加情報がある場合は、各行の注記として記載してください。

## 項目の説明
- 支払先会社名
- 発行日
- 支払金額税込
- 通貨
- 登録番号

## 出力形式
以下の項目をCSV形式で出力してください。

## 出力項目(優先順位順)
1. 支払先会社名
2. 発行日
3. 支払金額税込
4. 通貨
5. 登録番号
6. 注記

## 項目の出力項目の説明
1. 支払先会社名: 支払先の会社名。宛名や請求先ではないので注意してください。
2. 発行日: 領収書を発行した日付(YYYY-MM-DD形式で出力)
3. 支払金額税込: 税込みの合計金額(カンマ区切りで記入、小数点以下2桁まで)
   - 税抜き金額のみ記載の場合は、課税対象額を加算して計算してください
4. 通貨: 支払金額の通貨(例:JPY、USD、EUR)
5. 登録番号:税務署に認められた適格請求書発行事業者に発行される番号です。 すでに法人番号がある事業者の場合は「T+法人番号」が登録番号となります。 法人番号を持っていない事業者に関しては、「T+13桁の固有番号」が登録番号です。 (T0000000000000形式で出力)
6. 注記

【項目の出力例】
"支払先会社名","発行日","支払金額税込","通貨","登録番号","注記"
"株式会社テスト","2024-07-15","150000","JPY","T1234567890123","初回取引"
`.trim();

  // Gemini 2.0 Flash APIのエンドポイント
  // ※ APIキーをクエリパラメータに含める形にしています
  const apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + geminiApiKey;

  // リクエストボディ (Gemini 2.0 Flash APIの仕様に合わせて修正)
  const requestBody = {
    contents: [
      {
        parts: [
          {
            // Prompt部に先ほどの指示文をセット
            text: promptText
          },
          {
            // 続けて画像をインラインデータとして送信
            inline_data: {
              mime_type: "image/png",
              data: base64Data
            }
          }
        ]
      }
    ]
  };

  const ocrResponse = await fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(requestBody)
  });

  if (!ocrResponse.ok) {
    // レスポンスがエラーの場合
    const errorData = await ocrResponse.json().catch(() => ({}));
    console.error('OCR request failed:', errorData);
    throw new Error(`OCR request failed: ${ocrResponse.status}`);
  }

  const ocrData = await ocrResponse.json();

  let ocrText = '';
  if (ocrData.candidates && ocrData.candidates.length > 0) {
    const firstCandidate = ocrData.candidates[0];
    if (firstCandidate.content?.parts) {
      // parts配列を結合(複数パートがある場合はまとめて連結)
      ocrText = firstCandidate.content.parts.map(part => part.text).join('\n');
    }
  }

  // テキストエリアに出力
  ocrResult.value = ocrText;
  // CSVダウンロード用ボタンを有効に
  downloadTxtBtn.disabled = false;
  ocrBtn.textContent = 'OCR解析';
  ocrBtn.disabled = false;

  } catch (error) {
    console.error('OCR処理エラー:', error);
    alert('OCR処理中にエラーが発生しました: ' + error.message);
    ocrBtn.textContent = 'OCR解析';
    ocrBtn.disabled = false;
  }
});

// 4. 撮影画像の保存
downloadImgBtn.addEventListener('click', () => {
  if (!canvas) {
    alert('画像が撮影されていません。');
    return;
  }
  
  try {
    const dataUrl = canvas.toDataURL('image/png');
    const a = document.createElement('a');
    a.href = dataUrl;
    // タイムスタンプ付きファイル名
    const now = new Date();
    const timestamp = now.toISOString().replace(/[:.]/g, '-');
    a.download = `receipt_${timestamp}.png`;
    a.click();
  } catch (error) {
    console.error('画像保存エラー:', error);
    alert('画像の保存中にエラーが発生しました');
  }
});

// 5. OCR結果(CSV)の保存
downloadTxtBtn.addEventListener('click', () => {
  const content = ocrResult.value || '';
  if (!content.trim()) {
    alert('保存するデータがありません。');
    return;
  }
  
  try {
    const blob = new Blob([content], { type: 'text/csv' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    // タイムスタンプ付きファイル名
    const now = new Date();
    const timestamp = now.toISOString().replace(/[:.]/g, '-');
    a.download = `ocr_result_${timestamp}.csv`;
    a.click();
    URL.revokeObjectURL(url);
  } catch (error) {
    console.error('CSV保存エラー:', error);
    alert('CSVの保存中にエラーが発生しました');
  }
});

// 6. 撮り直しボタンの機能
resetBtn.addEventListener('click', () => {
  // 状態をリセット
  capturedImage.style.display = 'none';
  ocrBtn.disabled = true;
  downloadImgBtn.disabled = true;
  
  // カメラを再起動
  startCamera()
    .then(() => {
      console.log('カメラ再起動成功');
      resetBtn.style.display = 'none';
    })
    .catch(err => {
      console.error('カメラ再起動エラー:', err);
      alert('カメラの再起動に失敗しました。ページをリロードしてください。');
    });
});

// 7. ページ離脱時のクリーンアップ
window.addEventListener('beforeunload', () => {
  if (currentStream) {
    currentStream.getTracks().forEach(track => track.stop());
  }
});

options.html / options.js

拡張オプション設定ページ。ここでGemini 2.0 FlashのAPIキーなどユーザー固有の設定を保存します。

options.html
options.html
<!-- options.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Gemini OCR Extension - Options</title>
  </head>
  <body>
    <h1>Gemini 2.0 Flash API Key 設定</h1>
    <label for="apiKey">API Key:</label>
    <input type="text" id="apiKey" />
    <button id="saveKey">保存</button>

    <script src="options.js"></script>
  </body>
</html>
options.js
options.js
// options.js
document.getElementById('saveKey').addEventListener('click', () => {
    const key = document.getElementById('apiKey').value.trim();
    if (key) {
      chrome.storage.local.set({ geminiApiKey: key }, () => {
        alert('APIキーを保存しました');
      });
    }
  });

使い方

ここまで実装したソースコードを準備した上で、Chrome拡張として取り込んで設定していきましょう。

Chrome拡張追加方法

Chromeを開いてURLに下記を入力して「Enter」キーを打鍵します。

chrome://extensions/

「パッケージ化されていない拡張機能を読み込む」ボタンを押下します。

先ほどのソースコードが格納されているフォルダを選択します。
すると、下記のように実装したChrome拡張が表示されます。

初期設定1: GeminiのAPIキー

「詳細」ボタンを押下します。

「拡張機能のオプション」をクリックします。

GeminiのAPIキーを入力して、「保存」ボタンを押下します。

初期設定2: カメラの許可

「詳細」ボタンを押下します。

「サイトの設定」をクリックします。

「カメラ」の「許可する」を選択します。

起動

Chrome拡張の「Gemini OCR Extension」をクリックします。

こんな感じに起動します。

使ってみる

領収書を映しながら「撮影」ボタンを押下します。

撮影できたら「OCR解析」ボタンを押下します。

数秒後にOCR結果(CSV形式)が出力されます。

OCR結果(CSV形式)や画像は保存できます。

さいごに

いかがでしたか。この記事では、Gemini 2.0 FlashとChrome拡張を使って領収書OCRの仕組みを実装してみました。生成AIを使ってこのような仕組みを意外と簡単に実装できるので、みなさんもぜひ参考にしてみてください!

3
Accenture Japan (有志)

Discussion

Shinya.SawakiShinya.Sawaki

この記事コピペするだけで10分かからず動かせました。笑 ありがとうございます。
さらにちょっと機能追加して、個人利用用の給与明細OCRにしていただきましたっ

ログインするとコメントできます