2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

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

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

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

まさかの大成功!!

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

📸 まずはデモで触ってみて!

画像アップするだけで、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

After: Geminiが吐き出した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

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へログイン
gcloud auth application-default login
  1. プロジェクト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

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?