はじめに
「(自分だけに)ちょうどいいレシート家計簿」アプリを作成している者です。
前回の記事ではOCR技術の比較検討とRailwayへのデプロイについて書きました。今回はコア機能となるOCR機能の実装編です。
目指したゴール:
レシートを撮影 → 自動的に入力欄が埋まる
実際にやってみると、OCRだけでは不十分で 構造化してくれるAIとの組み合わせが必要 だったこと、そして プロンプトの改善が精度向上の鍵 だったことが分かりました。
同じように個人開発でOCR + AI連携に挑戦される方の参考になれば幸いです。
誤り等ございましたらぜひご指摘いただければ嬉しいです、よろしくお願いいたします!
今回の実装内容
技術スタック
フロントエンド: Expo (React Native)
バックエンド: Express.js (Railway)
データベース: Firebase Firestore
OCR: ML Kit Text Recognition
AI: OpenAI ChatGPT API (gpt-4o-mini)
データの流れ
[カメラで撮影]
↓
[ML Kit OCR で文字認識]
↓
[ChatGPT API で構造化]
↓
[入力画面に自動反映]
なぜOCRだけでは不十分なのか
前回の記事でML Kitを選定しましたが、実装を進める中で気づいたことがあります。
それは OCRの出力は「ただの文字の羅列」 ということです。
例えば、単純に撮影して読み込んだ場合は以下のような情報が取得されます。
ホゲMarket
東京都渋谷区...
2025/10/01
純情大地牛乳 ¥239
プリマディズニー ¥119
小計 ¥358
税込合計 ¥386
これを家計簿アプリで使うには、以下のような構造化されたデータに変換する必要があります:
{
"store": "ホゲMarket",
"date": "2025-10-01",
"items": [
{"name": "純情大地牛乳", "price": 239},
{"name": "プリマディズニー", "price": 119}
],
"total": 386
}
この「文字列の羅列」→「構造化されたJSON」の変換をやってくれるのがAI(今回はChatGPT API)です。
実装ステップ
ステップ1: ML Kit OCRの実装
まずは必要なパッケージをインストールします。
npx expo install @react-native-ml-kit/text-recognition
npx expo install expo-camera expo-media-library
カメラ撮影とOCR実行のコードはこんな感じです:
import TextRecognition, { TextRecognitionScript } from '@react-native-ml-kit/text-recognition';
// カメラで撮影
const photo = await cameraRef.current.takePictureAsync({
quality: 1.0, // 最高品質
skipProcessing: true, // 余計な画像処理をスキップ
});
// OCR実行(日本語認識)
const result = await TextRecognition.recognize(
photo.uri,
TextRecognitionScript.JAPANESE // ← これ重要!
);
console.log(result.text); // OCRで認識されたテキスト
ポイント: TextRecognitionScript.JAPANESEの指定
最初は指定せずにテストしていたのですが、日本語スクリプトを明示的に指定したところ認識精度が劇的に向上しました。
ステップ2: ChatGPT APIでの構造化処理
バックエンド側の実装 (Express.js)
// backend/index.js
app.post('/api/receipts/process-ocr', async (req, res) => {
const { ocrText } = req.body;
const prompt = `
以下のレシートのOCR結果から、構造化されたJSONデータを作成してください。
OCRテキスト:
${ocrText}
以下の形式で正確にJSONのみを返してください:
{
"store": "店名",
"date": "YYYY-MM-DD",
"items": [
{"name": "商品名", "price": 金額}
],
"total": 合計金額,
"confidence": "high/medium/low"
}
ルール:
- 商品名が不明な場合は価格から推定してください
- 価格情報は必ず数値で返してください
- 店名が不明な場合は"不明"としてください
- confidenceは認識できた情報量に基づいて判定してください
`;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
max_tokens: 500,
temperature: 0.1, // 一貫性重視
}),
});
const data = await response.json();
const structuredData = JSON.parse(data.choices[0].message.content);
res.json({ success: true, data: structuredData });
});
フロントエンド側の実装
// frontend/src/services/chatgpt.js
export const structureReceiptData = async (ocrText) => {
const response = await fetch(`${API_URL}/receipts/process-ocr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ocrText }),
});
const result = await response.json();
return result.data;
};
OCR画面での利用
// OCRScreen.js
const handleCapture = async () => {
// 1. 撮影
const photo = await cameraRef.current.takePictureAsync({...});
// 2. OCR実行
const result = await TextRecognition.recognize(photo.uri, TextRecognitionScript.JAPANESE);
// 3. ChatGPT APIで構造化
const structuredData = await structureReceiptData(result.text);
// 4. 入力画面へ遷移(データを渡す)
navigation.navigate('AddReceipt', {
structuredData: structuredData,
isFromOCR: true
});
};
自動入力画面での受け取り
// AddReceiptScreen.js
export default function AddReceiptScreen({ route }) {
const { structuredData, isFromOCR } = route.params || {};
const [storeName, setStoreName] = useState('');
const [totalAmount, setTotalAmount] = useState('');
const [items, setItems] = useState([]);
// OCRデータがある場合、初期値として設定
useEffect(() => {
if (structuredData) {
setStoreName(structuredData.store || '');
setTotalAmount(structuredData.total?.toString() || '');
setItems(structuredData.items || []);
// 通知表示
Alert.alert('OCR結果', 'データを自動入力しました。内容を確認してください。');
}
}, [structuredData]);
// ... 残りのコード
}
発生した問題と解決策
実際にレシートで試してみると、いくつか問題が発生しました。
問題1: 店舗名が「不明」になる
テストケース: ホゲMarketのレシート
期待値: store="ホゲMarket"
実際: store="不明"
原因:店舗名の探し方のヒントが不足
解決策:プロンプトに具体的な指示を追加
ルール:
- OCRテキストの上部(最初の数行)に店名が含まれることが多いです // ← 追加
問題2: 合計金額が小計になる
テストケース: ホゲMarketのレシート
小計: ¥358
税込合計: ¥386
期待値: total=386
実際: total=358 ← 小計が選ばれてしまった
原因:「最大の金額を使用」という指示では特定できていなかった
解決策:レシートの構造を考慮した指示に変更
ルール:
- 合計金額は「合計」「お会計」「計」などのキーワード付近の金額を優先してください // ← 追加
- 税込の最終金額を合計金額として使用してください(小計ではなく税込合計) // ← 追加
- 商品の個別価格は税抜きでも問題ありません
改善後のプロンプト
const prompt = `
以下のレシートのOCR結果から、構造化されたJSONデータを作成してください。
OCRテキスト:
${ocrText}
以下の形式で正確にJSONのみを返してください:
{
"store": "店名",
"date": "YYYY-MM-DD",
"items": [
{"name": "商品名", "price": 金額}
],
"total": 合計金額,
"confidence": "high/medium/low"
}
ルール:
- OCRテキストの上部(最初の数行)に店名が含まれることが多いです
- 店名が不明な場合は"不明"としてください
- 合計金額は「合計」「お会計」「計」などのキーワード付近の金額を優先してください
- 税込の最終金額を合計金額として使用してください(小計ではなく税込合計)
- 商品の個別価格は税抜きでも問題ありません
- 商品名が不明な場合は価格から推定してください
- 価格情報は必ず数値で返してください
- confidenceは認識できた情報量に基づいて判定してください
`;
実際のテスト結果
テストケース: ホゲMarketのレシート
| 項目 | 修正前 | 修正後 |
|---|---|---|
| 店舗名 | "不明" ❌ | "ホゲMarket" ✅ |
| 合計金額 | 358円(小計) ❌ | 386円(税込) ✅ |
| 商品明細 | 正確 ✅ | 正確 ✅ |
| 信頼度 | medium | high |
プロンプトを改善したことで、期待通りの結果が得られるようになりました
エラーハンドリングとフォールバック
ChatGPT APIが失敗した場合に備えて、3段階のフォールバック戦略を実装しました。
フォールバック戦略
- ChatGPT API成功 → 高精度な構造化データ
- ChatGPT API失敗 → クライアント側で基本的な抽出
- 完全失敗 → 手動入力モード
クライアント側フォールバック
const createClientFallback = (ocrText) => {
const lines = ocrText.split('\n');
const prices = [];
// ¥マーク + 数字のパターンで価格を抽出
lines.forEach(line => {
const priceMatches = line.match(/¥(\d{1,3}(?:,\d{3})*)/g);
if (priceMatches) {
priceMatches.forEach(match => {
const price = parseInt(match.replace(/[¥,]/g, ''));
if (price > 0 && price < 100000) {
prices.push(price);
}
});
}
});
// 商品リスト作成(最後の価格を合計とみなし、それ以外を商品に)
const items = prices.slice(0, -1).map((price, index) => ({
name: `商品${index + 1}`,
price: price
}));
return {
store: lines[0]?.trim() || '不明',
date: new Date().toISOString().split('T')[0],
items: items,
total: Math.max(...prices),
confidence: 'low',
isFallback: true // フォールバックで処理したことを示すフラグ
};
};
ユーザーへの通知
useEffect(() => {
if (structuredData && isFromOCR) {
const { confidence, isFallback } = structuredData;
let message = '';
if (isFallback) {
message = 'ChatGPT APIでの処理に失敗したため、基本的な情報のみを抽出しました。内容を確認してください。';
} else {
message = `ChatGPT APIで構造化しました(信頼度: ${confidence})。内容を確認してください。`;
}
Alert.alert('OCR結果', message);
}
}, [structuredData, isFromOCR]);
どんな状況でも最低限のデータは提供できるようにしたいと思いClaudeと相談、実装していきました。
まとめ
初めてML Kit OCR + ChatGPT APIでレシート自動入力機能を実装してみました。
実際に最低限使えるレベルまで実装して学んだことは以下の通りです
- OCRだけでは不十分 - 文字認識と構造化は別の問題
- プロンプトエンジニアリングが重要 - 具体的な指示で精度が劇的に向上
- フォールバック必須 - API失敗時も最低限の機能を提供
- 実際のレシートでのテストは不可欠 - 想定外のレイアウトは山ほどある
自分だけにちょうどいいレシート家計簿アプリを目指したので、不足している/蛇足的な機能もあるかもしれません。
微調整はしたもののUIは丸投げしてしまったため、もう少し自分で設計部分を深掘りしてから動き出せたらよかったなと感じました。
ワイヤーフレームなども作らず「とりあえずAIだけで進められる部分から進めて」いって「こうだといいな」以外は基本的に考えず…という感じでした。
ただし作成を始めるにあたって
- AIをたくさん活用すること
- 初めての技術を試すこと
も目標だったので、及第点だと思います!無理やり感は否めませんが…!
アプリにAIを組み込むことへのハードルが下がったのは大きな収穫でした。
今後も仕事だけをこなすのではなく、個人開発を楽しく続けていけることを目標にやっていきます。