はじめに
名刺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: 入力した画像
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の「新しいリビジョンの編集とデプロイ」から設定するだけ。
3. Vertex AI API有効化
これを同じプロジェクトでやっておくと、
サービスアカウント認証が不要で直接使えます。
4. Next.js APIルートでGemini呼び出し
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. デモ用フロントエンド実装
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. ついでにエラー用モーダルも実装
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. 開発環境の設定
- google cloudへログイン
gcloud auth application-default login
- プロジェクトIDの登録
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