【徹底検証】Gemini 1.5 Proで名刺OCRしてみたら、JSON整形まで一発だった件

##  はじめに
名刺OCRって、結局「**精度悪い問題**」がついて回るじゃないですか。

- フォント小さい
- 縦書き横書き混在
- 役職と会社名がわかりにくい

今までOCRエンジンいろいろ試したけど、  
**日本語名刺**って特にレイアウトがカオスで、  
まともに取れた試しがありませんでした。

でも「LLMなら**読んで理解して、きれいに整形**までいけるんじゃね？」と思って、  
最新の**Gemini 1.5 Pro**にぶん投げたら…  

**まさかの大成功！！**  

これは**名刺OCRの革命**が始まるぞ…ということで、  
**構成＆コード＆デモURL**まで全部まとめました！

## 📸 まずはデモで触ってみて！

https://prod-portfolio-959125796595.asia-northeast1.run.app/samples/ocr-demo

画像アップするだけで、OCR＆JSON整形まで一発です。


## 📐 技術構成まとめ
|カテゴリ|技術|
|---|---|
|フロントエンド|Next.js 13（App Router）|
|バックエンド|Next.js API Route|
|LLMモデル|Vertex AI Gemini 1.5 Pro|
|デプロイ|Cloud Run（GitHub連携）|
|CI/CD|Cloud Build（GitHub Pushで自動デプロイ）|
|その他|Tailwind CSS|

## Before/After

### Before: 入力した画像
![ns007-01_0720.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3722068/f8213165-857c-409a-981a-a81bf1e9b9c9.jpeg)

### After： Geminiが吐き出したJSON
```json
{
  "name": "名刺 太郎",
  "kana": null,
  "title": "代表取締役",
  "organization": null,
  "license": null,
  "company": "株式会社ファースト",
  "location": "ファーストビル1F 1A",
  "adress": "〒513-1234 三重県鈴鹿市一番町1111-2",
  "postal": null,
  "tel": "059-123-4567",
  "mail": "meishi@firstinc.jp",
  "hobby": null,
  "homepage": "https://www.firstinc.jp"
}
```

## ここがポイント！
GeminiはOCR精度だけじゃなく、「文脈理解」して整形までしてくれる
役職っぽい文字は「title」に入れてくれる
電話番号はハイフン付きでちゃんと認識
読み取れなかった項目は「null」にしてくれる

## こんな場面で使える！
|シーン|	活用例|
|---|---|
|名刺管理|営業が名刺を撮影 → 自動登録|
|入力フォーム|ユーザーの入力自動化、CV率向上|
|OCR × AI教育|新人エンジニアのLLM教材に|

## 実装ステップ
### 1. Cloud Run & GitHub連携でCI/CD
まずはNext.jsアプリをCloud Runにデプロイ＆GitHub連携。
Pushしたら自動デプロイされる環境を作ります。

このへんは↓でまとめてます
https://qiita.com/dev-cat/items/0de2ca8168684fc21a69

### 2.Cloud Run上で、環境変数の設定
Cloud Runの「新しいリビジョンの編集とデプロイ」から設定するだけ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3722068/fc3e5aa8-b8fe-4f23-9a8e-0fc0b7b913d6.png)

### 3. Vertex AI API有効化
これを同じプロジェクトでやっておくと、
サービスアカウント認証が不要で直接使えます。

### 4. Next.js APIルートでGemini呼び出し
```/api/ocr/bc.tsx
import type { NextApiRequest, NextApiResponse } from 'next';
import { VertexAI, GenerateContentRequest } from '@google-cloud/vertexai';
import formidable from 'formidable';
import fs from 'fs';

export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method Not Allowed' });
  }

  const form = formidable({ multiples: false });

  await new Promise<void>((resolve, reject) => {
    form.parse(req, async (err, fields, files) => {
      if (err) {
        console.error('formidable parse error:', err);
        reject(err);
        return;
      }

      const file = Array.isArray(files.file) ? files.file[0] : files.file;
      if (!file) {
        console.error('File not found in form data');
        reject(new Error('File is required'));
        return;
      }

      const fileBuffer = fs.readFileSync(file.filepath);
      const base64Image = fileBuffer.toString('base64');

      const vertexAI = new VertexAI({
        project: process.env.GCP_PROJECT_ID,
        location: 'us-central1',
      });

      const model = vertexAI.getGenerativeModel({
        model: 'gemini-1.5-pro-001',
      });

      const request: GenerateContentRequest = {
        contents: [
          {
            role: 'user',
            parts: [
              {
                inlineData: {
                  mimeType: 'image/jpeg',
                  data: base64Image,
                },
              },
              {
                text: `
                以下の画像はとある日本人の名刺です。この名刺の画像データを読み取って、JSON文字列で出力してください。
                読み取れない場合はエラーとして下さい。

                [ルール]----------------------
                ・役職とは「課長」「部長」などの文字をいう。
                ・名前とかなの姓・名の間は空白であけること。
                ・組織名は複数行にまたがっている可能性がある。
                ・アウトプットのJson文字列は①人間が見やすいように成形すること②Json文字として有効であること
                ・電話番号は数字と-のみ読み取ること。
                ・Jsonテンプレートで指定されている項目のうち、読み取れなかった項目はnullで表現すること

                [Jsonテンプレート]----------------------
                {
                  "name": "%ここに氏名を入れてください%",
                  "kana": "%ここにふりがなを入れてください%",
                  "title": "%ここに役職を入れてください%",
                  "organization": "%ここに組織名を入れてください%",
                  "license": "%ここに資格名を入れてください%",
                  "company": "%ここに会社名を入れてください%",
                  "location": "%ここに事業所名を入れてください%",
                  "adress": "%ここに住所を入れてください%",
                  "postal": "%ここに郵便番号を入れてください%",
                  "tel": "%ここに電話番号を入れてください%",
                  "mail": "%ここにメールアドレスを入れてください%",
                  "hobby": "%ここに趣味を入れてください%",
                  "homepage": "%ここにホームページを入れてください%"
                }
                `,
              },
            ],
          },
        ],
      };

      try {
        const result = await model.generateContent(request);
        const response = await result.response;

        res.status(200).json({ result: response });
        resolve();
      } catch (error) {
        console.error('Vertex AI error:', error);
        res.status(500).json({ error: 'Failed to process image with Gemini' });
        reject(error);
      }
    });
  });
}
```
**プロンプトは、こちらの記事を参考にさせていただきました！！
https://qiita.com/kccs_hiroshi-tatsuwaki/items/f3277a157acf965c33e8

### 5. デモ用フロントエンド実装
```pages/samples/ocr-demo.tsx
import { useState, useRef } from 'react';
import ErrorModal from '../../components/ErrorModal';

export default function DemoPage() {
  const [selectedApi, setSelectedApi] = useState('businessCard');
  const [responseJson, setResponseJson] = useState<any>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      handleFileUpload(file);
    }
  };

  const handleFileUpload = async (file: File) => {
    setIsLoading(true);
    setErrorMessage(null);

    const formData = new FormData();
    formData.append('file', file);

    const apiEndpointMap = {
      businessCard: '/api/ocr/bc',
      license: '/api/ocr/dl',
      insurance: '/api/ocr/ins',
      mynumber: '/api/ocr/myno',
    };

    const apiEndpoint = apiEndpointMap[selectedApi];

    try {
      const res = await fetch(apiEndpoint, {
        method: 'POST',
        body: formData,
      });

      if (!res.ok) {
        const errorText = await res.text();  // ここでエラー詳細を読む
        throw new Error(`HTTP Error ${res.status} ${res.statusText}\n${errorText}`);
      }

      const data = await res.json();

      let rawText = data.result?.candidates?.[0]?.content?.parts?.[0]?.text || '';
      rawText = rawText.trim().replace(/^```json\n?|\n?```$/g, '');

      let parsedResult = {};
      if (rawText.startsWith('{') && rawText.endsWith('}')) {
        parsedResult = JSON.parse(rawText);
      } else {
        throw new Error(`Invalid JSON format received:\n${rawText}`);
      }

      setResponseJson(parsedResult);
    } catch (err: any) {
      console.error('OCR Processing Error:', err);

      // 例外オブジェクトを文字列化してモーダルに表示
      setErrorMessage(
        err instanceof Error ? err.message : String(err)
      );
    } finally {
      setIsLoading(false);
    }
  };

  const handleApiCall = () => {
    fileInputRef.current?.click();
  };

  return (
    <div className="p-6 font-sans bg-[#0d1117] text-[#f0f6fc] min-h-screen">
      <h1 className="text-2xl font-bold mb-4 text-[#ff00ff] drop-shadow-neon-pink">OCR Demo Page</h1>

      <div className="mb-4 flex flex-wrap items-center gap-3">
        <label className="font-bold text-[#0ff] drop-shadow-neon-blue">Select OCR Type:</label>
        <select
          className="p-1 bg-[#161b22] text-[#0ff] border border-[#0ff] shadow-[0_0_6px_#0ff]"
          value={selectedApi}
          onChange={(e) => setSelectedApi(e.target.value)}
        >
          <option value="businessCard">Business Card</option>
          <option value="license">Japanese Driver's License</option>
          <option value="insurance">Japanese Health Insurance Card</option>
          <option value="mynumber">Japanese My Number Card</option>
        </select>

        <button
          onClick={handleApiCall}
          disabled={isLoading}
          className={`w-44 h-9 font-bold uppercase tracking-wider flex items-center justify-center rounded-xl ${
            isLoading ? 'bg-[#330033] cursor-not-allowed shadow-[0_0_12px_#ff00ff]' : 'bg-[#ff00ff] cursor-pointer shadow-[0_0_24px_#ff00ff]'
          }`}
        >
          {isLoading ? (
            <>
              <div className="w-4 h-4 border-3 border-white border-t-transparent rounded-full animate-spin"></div>
              <span className="ml-2 text-white">Processing...</span>
            </>
          ) : (
            'Upload Photo'
          )}
        </button>
      </div>

      <input
        type="file"
        ref={fileInputRef}
        className="hidden"
        onChange={handleFileSelect}
      />

      <div className="flex flex-wrap gap-4 mt-4">
        <div className="flex-1 min-w-[300px] flex flex-col">
          <h2 className="font-bold text-[#0ff] drop-shadow-neon-blue">Parsed Data (From API Response)</h2>
          <div className="flex-1 overflow-y-auto bg-[#161b22] p-2 rounded border border-[#30363d] shadow-[0_0_6px_#0ff]">
            {responseJson &&
              Object.entries(responseJson).map(([key, value]) => (
                <div key={key} className="mb-2">
                  <label className="font-bold mr-2 text-[#c9d1d9]">{key}:</label>
                  <input
                    type="text"
                    value={String(value)}
                    readOnly
                    className="p-1 bg-[#161b22] text-[#0ff] border border-[#0ff] w-full"
                  />
                </div>
              ))}
          </div>
        </div>

        <div className="flex-1 min-w-[300px] flex flex-col">
          <h2 className="font-bold text-[#ff00ff] drop-shadow-neon-pink">Raw JSON (Full Response)</h2>
          <pre className="flex-1 overflow-y-auto bg-[#161b22] text-[#79c0ff] p-4 rounded border border-[#30363d] shadow-[0_0_6px_#79c0ff]">
            {JSON.stringify(responseJson, null, 2)}
          </pre>
        </div>
      </div>

      {/* Error Modal */}
      <ErrorModal
        isOpen={!!errorMessage}
        message={errorMessage || ''}
        onClose={() => setErrorMessage(null)}
      />
    </div>
  );
}
```

### 6. ついでにエラー用モーダルも実装
```components/ErrorModal.tsx
export default function ErrorModal({ isOpen, message, onClose }: { isOpen: boolean, message: string, onClose: () => void }) {
  if (!isOpen) return null;
  return (
    <div className="fixed inset-0 bg-black bg-opacity-60 flex justify-center items-center">
      <div className="bg-white p-6 rounded shadow-lg">
        <p className="mb-4 text-red-600">{message}</p>
        <button onClick={onClose} className="bg-gray-800 text-white px-4 py-2">Close</button>
      </div>
    </div>
  );
}
```

### 7. 開発環境の設定
1. google cloudへログイン
```shell
gcloud auth application-default login
```

2. プロジェクトIDの登録
```.env_local
GCP_PROJECT_ID={your project id}
```

### 8. ここまでで名刺OCRアプリが完成！

ここまで実装すれば、名刺画像をフロントから、アップロードするだけで
OCR→JSON整形→画面表示までがフルオートで動くようになります。

あとは、実際の業務フローに組み込んだり、
他の書類OCRにも応用したり、色々発展させていくことも可能です！

# まとめ
名刺OCRって今まで「OCRエンジン選び」で苦労してましたが、
Gemini 1.5 Proなら「理解＆整形」まで丸ごとお任せできます。

OCR革命くるなコレって感じなので、
ぜひ一度デモ触ってみてください！

✅ https://prod-portfolio-959125796595.asia-northeast1.run.app/samples/ocr-demo

### ***ぜひ「いいね」「ストック」「シェア」お願いします！***