🤖

📸 レシート読み取りLINE Botの作り方 - Google Cloud Vision APIで家計簿を自動化

に公開

はじめに

こんにちは!今回は、レシート画像を送るだけで金額を自動で読み取って家計簿に記録してくれるLINE Botを作ってみました。

「レシートの金額を手入力するのが面倒...」「家計簿アプリは続かない...」

そんな悩みを解決する、実用的なOCR機能付きLINE Botの実装方法を解説していきます。

🎯 今回作るもの

  • レシート画像送信: LINEで写真を送るだけ
  • 自動金額抽出: Google Cloud Vision APIでOCR処理
  • 智能解析: 合計金額を自動判定
  • 家計簿記録: そのまま支出として記録

🛠️ 使用技術

  • LINE Messaging API: チャットボット
  • Google Cloud Vision API: OCR処理
  • Node.js + TypeScript: バックエンド
  • Express.js: Webフレームワーク
  • Sharp: 画像処理

📋 前提条件

  • Node.js v16以上
  • LINEアカウント
  • Google Cloudアカウント
  • 基本的なJavaScript/TypeScriptの知識

🚀 STEP1: プロジェクトセットアップ

パッケージインストール

mkdir line-ocr-bot
cd line-ocr-bot
npm init -y

# 必要なパッケージをインストール
npm install express @line/bot-sdk @google-cloud/vision sharp dotenv
npm install -D typescript @types/node @types/express ts-node nodemon

TypeScript設定

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

🔑 STEP2: Google Cloud Vision API設定

1. Google Cloudプロジェクト作成

  1. Google Cloud Consoleにアクセス
  2. 新しいプロジェクトを作成
  3. Vision APIを有効化

2. サービスアカウント作成

  1. 「IAMと管理」→「サービスアカウント」
  2. 「サービスアカウントを作成」
  3. 役割: 「Cloud Vision API ユーザー」を付与
  4. JSONキーをダウンロード

3. 環境変数設定

.env
# LINE Bot設定
CHANNEL_ACCESS_TOKEN=your_line_channel_access_token
CHANNEL_SECRET=your_line_channel_secret

# Google Cloud設定
GOOGLE_APPLICATION_CREDENTIALS=./path/to/service-account.json
GOOGLE_CLOUD_PROJECT_ID=your_project_id

# サーバー設定
PORT=3000

📸 STEP3: OCRサービス実装

レシートの読み取り精度を上げるには、画像の前処理が重要です。ここでは、OCR精度を最大化するための画像処理ロジックを実装します。

画像処理クラスの実装

まず、画像を最適化するためのクラスを作成します。このクラスでは以下の処理を行います:

  • 画像サイズの最適化: OCRに適した解像度に調整
  • 画像品質の向上: ノイズ除去、コントラスト調整
  • 向きの自動修正: EXIF情報を使った回転補正
src/services/ImageProcessor.ts
import Sharp from 'sharp';

export class ImageProcessor {
  // OCR用に画像を最適化
  async optimizeForOCR(imageBuffer: Buffer, mode: 'light' | 'full' = 'full'): Promise<Buffer> {
    const sharp = Sharp(imageBuffer);
    
    if (mode === 'light') {
      // 軽量処理: リサイズとシャープ化のみ
      return await sharp
        .resize(1200, 1600, { fit: 'inside', withoutEnlargement: true })
        .sharpen()
        .jpeg({ quality: 90 })
        .toBuffer();
    }
    
    // フル処理: 高精度OCR用
    return await sharp
      .resize(1600, 2000, { fit: 'inside', withoutEnlargement: true })
      .greyscale()
      .normalize()
      .sharpen({ sigma: 0.5, flat: 1, jagged: 2 })
      .threshold(128)
      .jpeg({ quality: 95 })
      .toBuffer();
  }

  // 画像の向きを自動修正
  async autoRotateImage(imageBuffer: Buffer): Promise<Buffer> {
    return await Sharp(imageBuffer)
      .rotate() // EXIF情報に基づく自動回転
      .toBuffer();
  }

  // 画像品質診断
  async diagnoseImageQuality(imageBuffer: Buffer): Promise<{
    width: number;
    height: number;
    size: number;
    quality: 'good' | 'fair' | 'poor';
  }> {
    const metadata = await Sharp(imageBuffer).metadata();
    const { width = 0, height = 0, size = 0 } = metadata;
    
    let quality: 'good' | 'fair' | 'poor' = 'poor';
    
    if (width >= 800 && height >= 600) {
      quality = 'good';
    } else if (width >= 400 && height >= 300) {
      quality = 'fair';
    }
    
    return { width, height, size, quality };
  }
}

export const imageProcessor = new ImageProcessor();

OCRサービスクラスの実装

次に、Google Cloud Vision APIを使ってテキストを抽出するサービスを作成します。このクラスでは以下の機能を提供します:

  • API接続管理: 認証情報の管理と接続確立
  • エラー処理: 失敗時の自動リトライ機能
  • 回転対応: 複数の向きでOCRを試行
src/services/OCRService.ts
import { ImageAnnotatorClient } from '@google-cloud/vision';
import { imageProcessor } from './ImageProcessor';

export class OCRService {
  private client: ImageAnnotatorClient | null;
  private isEnabled: boolean;

  constructor() {
    this.isEnabled = process.env.OCR_ENABLED !== 'false';
    
    try {
      if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
        this.client = new ImageAnnotatorClient();
        console.log('✅ Google Cloud Vision API initialized');
      } else {
        console.log('⚠️ Google Cloud Vision API credentials not found');
        this.isEnabled = false;
        this.client = null;
      }
    } catch (error) {
      console.log('❌ Failed to initialize Google Cloud Vision API:', error);
      this.isEnabled = false;
      this.client = null;
    }
  }

  // メイン処理: 画像からテキストを抽出
  async extractTextFromImage(imageBuffer: Buffer): Promise<string> {
    if (!this.isEnabled || !this.client) {
      throw new Error('OCR service is not available');
    }

    try {
      console.log('🖼️ Starting OCR processing...');

      // 1. 画像の向きを自動修正
      const rotatedImage = await imageProcessor.autoRotateImage(imageBuffer);
      
      // 2. OCR用に最適化
      const optimizedImage = await imageProcessor.optimizeForOCR(rotatedImage);

      // 3. 品質診断
      const quality = await imageProcessor.diagnoseImageQuality(optimizedImage);
      console.log('📊 Image quality:', quality);

      // 4. OCR実行
      const [result] = await this.client.textDetection({
        image: { content: optimizedImage },
        imageContext: {
          languageHints: ['ja', 'en'] // 日本語と英語
        }
      });

      const detections = result.textAnnotations;
      if (!detections || detections.length === 0) {
        console.log('⚠️ No text detected, trying different orientations...');
        return await this.tryDifferentOrientations(imageBuffer);
      }

      console.log('✅ OCR completed successfully');
      return detections[0].description || '';
      
    } catch (error) {
      console.error('OCR Error:', error);
      throw new Error(`Failed to extract text: ${error instanceof Error ? error.message : 'Unknown error'}`);
    }
  }

  // 複数の向きでOCRを試行
  private async tryDifferentOrientations(imageBuffer: Buffer): Promise<string> {
    const rotationAngles = [90, 180, 270];
    
    for (const angle of rotationAngles) {
      try {
        console.log(`📐 Trying rotation: ${angle}°`);
        
        const Sharp = (await import('sharp')).default;
        const rotatedBuffer = await Sharp(imageBuffer).rotate(angle).toBuffer();
        const optimizedImage = await imageProcessor.optimizeForOCR(rotatedBuffer);
        
        const [result] = await this.client!.textDetection({
          image: { content: optimizedImage },
          imageContext: { languageHints: ['ja', 'en'] }
        });
        
        const detections = result.textAnnotations;
        if (detections && detections.length > 0 && detections[0].description) {
          console.log(`✅ Text detected with ${angle}° rotation`);
          return detections[0].description;
        }
        
      } catch (error) {
        console.error(`❌ Failed OCR with ${angle}° rotation`);
        continue;
      }
    }
    
    throw new Error('No text detected in any orientation');
  }
}

export const ocrService = new OCRService();

🧠 STEP4: レシート解析ロジック

OCRでテキストを抽出した後、レシートの構造を理解して正確な合計金額を特定する必要があります。これが最も難しい部分で、複数のアルゴリズムを組み合わせて実装していきます。

レシート解析の課題

レシートには以下のような課題があります:

  • 店舗ごとに異なるフォーマット
  • 複数の金額(小計、税額、合計など)
  • OCRの読み取りエラー

解析アルゴリズムの設計

これらの課題を解決するため、複数のアプローチを組み合わせます:

  1. キーワードベース検索: 「合計」「総額」などの周辺を探索
  2. 位置ベース分析: レシート下部の金額を重視
  3. 金額の大きさ分析: 通常は最大金額が合計
  4. 計算検証: 小計+税額=合計の関係をチェック
src/services/ReceiptAnalyzer.ts
interface ParsedAmount {
  amount: number;
  currency: string;
  originalText: string;
}

interface ReceiptAnalysisResult {
  totalAmount: ParsedAmount | null;
  confidence: number;
  allAmounts: ParsedAmount[];
  storeName: string | null;
  items: string[];
}

export class ReceiptAnalyzer {
  
  // メイン解析メソッド
  parseReceipt(ocrText: string): ReceiptAnalysisResult {
    console.log('🔍 Starting receipt analysis...');
    
    const lines = ocrText.split('\n').filter(line => line.trim().length > 0);
    
    // 1. 全ての金額を抽出
    const allAmounts = this.extractAllAmounts(ocrText);
    console.log(`💰 Found ${allAmounts.length} amounts`);
    
    // 2. 合計金額を特定
    const totalAmount = this.identifyTotalAmount(ocrText, allAmounts, lines);
    
    // 3. 店舗名を抽出
    const storeName = this.extractStoreName(lines);
    
    // 4. 商品アイテムを抽出
    const items = this.extractItems(lines);
    
    // 5. 信頼度を計算
    const confidence = this.calculateConfidence(totalAmount, allAmounts);
    
    return {
      totalAmount,
      confidence,
      allAmounts,
      storeName,
      items
    };
  }
  
  // 全金額抽出
  private extractAllAmounts(text: string): ParsedAmount[] {
    const amounts: ParsedAmount[] = [];
    
    // 日本円パターン
    const patterns = [
      /[¥¥]\s*([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{1,2})?)/g,  // ¥1,000
      /([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{1,2})?)\s*/g,     // 1,000円
      /([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{1,2})?)\s*\*/g,     // 1000* (レシート形式)
    ];
    
    for (const pattern of patterns) {
      const matches = Array.from(text.matchAll(pattern));
      for (const match of matches) {
        const amountStr = match[1];
        const amount = this.parseNumber(amountStr);
        if (amount > 0 && amount < 1000000) { // 現実的な範囲
          amounts.push({
            amount,
            currency: 'JPY',
            originalText: match[0].trim()
          });
        }
      }
    }
    
    return this.removeDuplicates(amounts);
  }
  
  // 合計金額特定(複数アルゴリズム)
  private identifyTotalAmount(text: string, amounts: ParsedAmount[], lines: string[]): ParsedAmount | null {
    const candidates: Array<ParsedAmount & { score: number }> = [];
    
    // アルゴリズム1: キーワードベース
    const keywordCandidates = this.findTotalByKeywords(text, amounts);
    candidates.push(...keywordCandidates.map(c => ({ ...c, score: 0.9 })));
    
    // アルゴリズム2: 位置ベース(最後の方の行)
    const positionCandidates = this.findTotalByPosition(lines, amounts);
    candidates.push(...positionCandidates.map(c => ({ ...c, score: 0.6 })));
    
    // アルゴリズム3: 金額の大きさ(通常最大値が合計)
    const maxAmount = amounts.reduce((max, current) => 
      current.amount > max.amount ? current : max, amounts[0]);
    if (maxAmount) {
      candidates.push({ ...maxAmount, score: 0.7 });
    }
    
    // 最高スコアの候補を選択
    candidates.sort((a, b) => b.score - a.score);
    return candidates[0] || null;
  }
  
  // キーワードによる合計金額検索
  private findTotalByKeywords(text: string, amounts: ParsedAmount[]): ParsedAmount[] {
    const totalKeywords = [
      '合計', '小計', '総額', '計',
      'total', 'subtotal', 'sum',
      '税込', 'including tax'
    ];
    
    const candidates: ParsedAmount[] = [];
    
    for (const keyword of totalKeywords) {
      // キーワード周辺の金額を探す
      const pattern = new RegExp(`${keyword}.*?([0-9,]+)`, 'gi');
      const matches = Array.from(text.matchAll(pattern));
      
      for (const match of matches) {
        const amountNum = this.parseNumber(match[1]);
        const foundAmount = amounts.find(a => Math.abs(a.amount - amountNum) < 0.01);
        if (foundAmount) {
          candidates.push(foundAmount);
        }
      }
    }
    
    return candidates;
  }
  
  // 位置による合計金額検索
  private findTotalByPosition(lines: string[], amounts: ParsedAmount[]): ParsedAmount[] {
    const candidates: ParsedAmount[] = [];
    const lastLines = lines.slice(-3); // 最後の3行
    
    for (const line of lastLines) {
      for (const amount of amounts) {
        if (line.includes(amount.originalText)) {
          candidates.push(amount);
        }
      }
    }
    
    return candidates;
  }
  
  // 店舗名抽出
  private extractStoreName(lines: string[]): string | null {
    const topLines = lines.slice(0, 3);
    
    for (const line of topLines) {
      const trimmed = line.trim();
      if (trimmed.length >= 2 && 
          !trimmed.match(/^[\d\s¥$,.-]+$/) &&
          !trimmed.match(/receipt|領収書/i)) {
        return trimmed;
      }
    }
    
    return null;
  }
  
  // 商品アイテム抽出
  private extractItems(lines: string[]): string[] {
    return lines
      .filter(line => {
        const trimmed = line.trim();
        return trimmed.length >= 2 && 
               trimmed.length <= 30 &&
               !trimmed.match(/^[\d\s¥$,.-]+$/) &&
               !trimmed.match(/合計|小計|total|tax/i);
      })
      .slice(0, 5); // 最大5個
  }
  
  // ヘルパーメソッド
  private parseNumber(str: string): number {
    const cleaned = str.replace(/[,\s]/g, '');
    return parseFloat(cleaned) || 0;
  }
  
  private removeDuplicates(amounts: ParsedAmount[]): ParsedAmount[] {
    const seen = new Set<string>();
    return amounts.filter(amount => {
      const key = `${amount.amount}-${amount.currency}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  }
  
  private calculateConfidence(totalAmount: ParsedAmount | null, allAmounts: ParsedAmount[]): number {
    if (!totalAmount) return 0;
    if (allAmounts.length === 0) return 0.3;
    if (allAmounts.length >= 3) return 0.8;
    return 0.6;
  }
}

export const receiptAnalyzer = new ReceiptAnalyzer();

🤖 STEP5: LINE Bot統合

これまでに作成したOCRサービスとレシート解析ロジックを、LINE Botと統合します。ユーザーが画像を送信したときの処理フローを実装していきます。

処理フローの設計

LINE Botでの画像処理は以下の流れで進みます:

  1. 画像受信: ユーザーからの画像メッセージを受け取り
  2. 進捗通知: 処理中であることをユーザーに通知
  3. 画像取得: LINE APIから実際の画像データを取得
  4. OCR処理: 画像からテキストを抽出
  5. レシート解析: 金額や店舗情報を特定
  6. 結果通知: 解析結果をユーザーに返信

メイン処理の実装

src/index.ts
import express from 'express';
import { Client, middleware } from '@line/bot-sdk';
import { ocrService } from './services/OCRService';
import { receiptAnalyzer } from './services/ReceiptAnalyzer';

const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN!,
  channelSecret: process.env.CHANNEL_SECRET!
};

const client = new Client(config);
const app = express();

// メッセージイベント処理
async function handleEvent(event: any) {
  if (event.type === 'message') {
    if (event.message.type === 'image') {
      return await handleImageMessage(event);
    } else if (event.message.type === 'text') {
      return await handleTextMessage(event);
    }
  }
  return Promise.resolve(null);
}

// 画像メッセージ処理
async function handleImageMessage(event: any) {
  // この関数では、ユーザーから送信された画像を処理します
  // LINE APIから画像データを取得 → OCR処理 → 結果分析 → ユーザーに通知
  try {
    const messageId = event.message.id;
    
    // 処理開始メッセージ
    await client.replyMessage(event.replyToken, {
      type: 'text',
      text: '📸 レシートを読み取り中です...\nしばらくお待ちください。'
    });

    // LINE APIから画像データを取得
    const imageStream = await client.getMessageContent(messageId);
    const imageBuffer = await streamToBuffer(imageStream);

    // OCR処理
    const ocrText = await ocrService.extractTextFromImage(imageBuffer);
    console.log('📝 OCR Result:', ocrText);

    // レシート解析
    const analysis = receiptAnalyzer.parseReceipt(ocrText);
    
    // 結果メッセージ作成
    let resultMessage = '✅ レシート読み取り完了!\n\n';
    
    if (analysis.totalAmount) {
      resultMessage += `💰 合計金額: ${analysis.totalAmount.amount.toLocaleString()}円\n`;
      resultMessage += `🏪 店舗: ${analysis.storeName || '不明'}\n`;
      resultMessage += `📊 信頼度: ${Math.round(analysis.confidence * 100)}%\n\n`;
      
      if (analysis.items.length > 0) {
        resultMessage += `🛒 商品:\n${analysis.items.slice(0, 3).join('\n')}\n\n`;
      }
      
      resultMessage += `記録しますか?\n「記録」と返信してください。`;
      
      // セッションに保存(実装に応じて)
      // await saveToSession(event.source.userId, analysis);
      
    } else {
      resultMessage += '❌ 金額を読み取れませんでした。\n';
      resultMessage += '明るい場所で、レシート全体が写るように撮影してもう一度お試しください。';
      
      if (analysis.allAmounts.length > 0) {
        resultMessage += `\n\n🔍 検出された金額:\n`;
        resultMessage += analysis.allAmounts.slice(0, 3)
          .map(a => `${a.amount.toLocaleString()}`)
          .join('\n');
      }
    }

    // プッシュメッセージで結果送信
    await client.pushMessage(event.source.userId, {
      type: 'text',
      text: resultMessage
    });

  } catch (error) {
    console.error('Image processing error:', error);
    
    await client.pushMessage(event.source.userId, {
      type: 'text',
      text: '❌ 画像処理でエラーが発生しました。\n\n' +
            '以下をご確認ください:\n' +
            '• レシートが鮮明に写っているか\n' +
            '• 画像が大きすぎないか\n' +
            '• 光の反射で文字が見えないか\n\n' +
            'もう一度お試しください。'
    });
  }
}

// テキストメッセージ処理
async function handleTextMessage(event: any) {
  // テキストメッセージへの対応(ヘルプ、確認コマンドなど)
  const messageText = event.message.text.trim();
  
  if (messageText === '記録' || messageText === 'OK') {
    // セッションから解析結果を取得して記録処理
    // 実装は家計簿ロジックに依存
    return client.replyMessage(event.replyToken, {
      type: 'text',
      text: '✅ 家計簿に記録しました!'
    });
  }
  
  if (messageText === 'ヘルプ' || messageText === 'help') {
    return client.replyMessage(event.replyToken, {
      type: 'text',
      text: '📸 レシート読み取りBot\n\n' +
            '【使い方】\n' +
            '1. レシートの写真を送信\n' +
            '2. 自動で金額を読み取り\n' +
            '3. 「記録」で家計簿に保存\n\n' +
            '【コツ】\n' +
            '• 明るい場所で撮影\n' +
            '• レシート全体を写す\n' +
            '• 平らに置いて撮影'
    });
  }
  
  // デフォルトメッセージ
  return client.replyMessage(event.replyToken, {
    type: 'text',
    text: '📸 レシートの写真を送ってください!\n\n' +
          'ヘルプが必要な場合は「ヘルプ」と送信してください。'
  });
}

// ユーティリティ関数: ストリームをバッファに変換
function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
  // LINE APIから取得する画像ストリームをBufferに変換する関数
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    stream.on('data', (chunk) => chunks.push(chunk));
    stream.on('end', () => resolve(Buffer.concat(chunks)));
    stream.on('error', reject);
  });
}

// Webhook エンドポイント
app.post('/webhook', middleware(config), (req, res) => {
  Promise
    .all(req.body.events.map(handleEvent))
    .then((result) => res.json(result))
    .catch((err) => {
      console.error(err);
      res.status(500).end();
    });
});

app.get('/', (req, res) => {
  res.send('Receipt OCR Bot is running! 📸');
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`🚀 Server running on port ${port}`);
});

⚡ STEP6: 実行と動作確認

開発サーバー起動

npm run dev

ngrokでローカル公開

ngrok http 3000

LINE Webhook設定

  1. LINE Developers コンソール
  2. Webhook URL: https://your-ngrok-url.ngrok.io/webhook
  3. 「Verify」で動作確認

⚠️ 注意点
Webhook URLの末尾には /webhook を忘れずに追加して下さい。

🧪 テストとデバッグ

実際に運用してみると、様々な課題が見つかります。ここでは、OCR精度の向上とデバッグ方法について解説します。

OCR精度向上のコツ

レシートの種類や撮影条件によって読み取り精度が変わるため、複数のアプローチを試すことが重要です。

1. 画像前処理の最適化

// コントラスト調整
.normalize()
.gamma(1.2)

// ノイズ除去
.median(3)

// シャープネス調整
.sharpen({ sigma: 0.5 })

2. 複数パターンでの試行

読み取りに失敗した場合、異なる前処理を試すことで成功率を上げられます。

// 異なる前処理パターンで複数回試行
const processVariants = ['original', 'enhanced', 'threshold'];
for (const variant of processVariants) {
  const result = await tryOCR(imageBuffer, variant);
  if (result.confidence > 0.8) return result;
}

3. エラーハンドリング強化

メイン処理が失敗した場合のフォールバック処理を用意します。

try {
  return await ocrService.extractTextFromImage(imageBuffer);
} catch (error) {
  // フォールバック処理として軽量OCRを試行
  return await ocrService.extractTextFromImageLight(imageBuffer);
}

🎨 UI/UX改善アイデア

LINE Botの表現力を高めるため、リッチメッセージを活用してより見やすい結果表示を実装してみましょう。

リッチメッセージによる結果表示

解析結果を単純なテキストではなく、構造化されたメッセージで表示することで、ユーザビリティが大幅に向上します。

// 解析結果をリッチメッセージで表示
const flexMessage = {
  type: 'flex',
  altText: 'レシート解析結果',
  contents: {
    type: 'bubble',
    body: {
      type: 'box',
      layout: 'vertical',
      contents: [
        {
          type: 'text',
          text: '📸 レシート解析結果',
          weight: 'bold',
          size: 'lg'
        },
        {
          type: 'text',
          text: `💰 ${analysis.totalAmount?.amount.toLocaleString()}`,
          size: 'xl',
          color: '#ff6b6b'
        }
      ]
    },
    footer: {
      type: 'box',
      layout: 'horizontal',
      contents: [
        {
          type: 'button',
          action: {
            type: 'message',
            label: '記録する',
            text: '記録'
          },
          style: 'primary'
        }
      ]
    }
  }
};

📊 パフォーマンス最適化

実用的なBotにするためには、処理速度の最適化が重要です。ここでは、私が行ったレスポンス時間を短縮するテクニックを紹介します。

処理時間短縮のテクニック

OCR処理は時間がかかるため、軽量処理を先に試して、失敗した場合のみ重い処理を実行するアプローチが効果的だと考えます。

// 軽量OCRと詳細OCRの使い分け
async function smartOCR(imageBuffer: Buffer): Promise<string> {
  try {
    // まず軽量処理を試行
    return await ocrService.extractTextFromImageLight(imageBuffer);
  } catch (error) {
    // 失敗した場合のみ詳細処理
    return await ocrService.extractTextFromImage(imageBuffer);
  }
}

メモリ使用量最適化

大きな画像ファイルを扱う際は、メモリ使用量に注意が必要です。ストリーミング処理で効率的にメモリを使用しましょう。

// ストリーミング処理でメモリ効率化
async function processImageStream(messageId: string): Promise<string> {
  const stream = await client.getMessageContent(messageId);
  
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    let totalSize = 0;
    
    stream.on('data', (chunk) => {
      totalSize += chunk.length;
      if (totalSize > 10 * 1024 * 1024) { // 10MB制限
        reject(new Error('Image too large'));
        return;
      }
      chunks.push(chunk);
    });
    
    stream.on('end', async () => {
      try {
        const buffer = Buffer.concat(chunks);
        const result = await ocrService.extractTextFromImage(buffer);
        resolve(result);
      } catch (error) {
        reject(error);
      }
    });
  });
}

🚀 デプロイとプロダクション運用

開発環境で動作確認ができたら、本番環境での運用を考える必要があります。ここでは、実際にサービスとして提供する際の重要なポイントを解説します。

本番環境での考慮事項

プロダクション環境では、開発環境では発生しない様々な問題が起こる可能性があります。

1. タイムアウト対策

OCR処理が長時間かかる場合に備えて、適切なタイムアウト処理を実装します。

// AbortController でタイムアウト制御
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);

try {
  const result = await ocrService.extractTextFromImage(
    imageBuffer, 
    controller.signal
  );
  clearTimeout(timeoutId);
  return result;
} catch (error) {
  if (error.name === 'AbortError') {
    throw new Error('Processing timeout');
  }
  throw error;
}

2. コスト最適化

Google Cloud Vision APIは従量課金なので、無駄なAPI呼び出しを減らすことが重要です。

// OCR APIコールを最小限に
const cache = new Map<string, string>();

async function cachedOCR(imageHash: string, imageBuffer: Buffer): Promise<string> {
  if (cache.has(imageHash)) {
    return cache.get(imageHash)!;
  }
  
  const result = await ocrService.extractTextFromImage(imageBuffer);
  cache.set(imageHash, result);
  return result;
}

3. エラー監視

本番環境では、詳細なログ出力により問題の早期発見と対処が可能になります。

// 詳細ログ出力
console.log('OCR Processing:', {
  timestamp: new Date().toISOString(),
  userId: event.source.userId,
  imageSize: imageBuffer.length,
  processingTime: Date.now() - startTime
});

📈 さらなる改善アイデア

レベル1(基本改善)

  • 複数画像対応: 複数のレシートを一度に処理
  • 支出カテゴリ自動分類: 店舗名から自動でカテゴリ判定
  • 金額確認機能: 読み取り結果の手動修正

レベル2(中級機能)

  • レシート保存: 処理した画像をクラウドストレージに保存
  • OCR精度統計: 読み取り精度の分析とアルゴリズム改善
  • 多言語対応: 海外レシートの読み取り

レベル3(上級機能)

  • AI学習機能: ユーザーの修正データから学習
  • リアルタイム処理: カメラ映像からリアルタイム読み取り
  • 家族共有: 複数ユーザーでの家計簿共有

まとめ

今回は、Google Cloud Vision APIを使ったレシート読み取りLINE Botを実装しました。

実装のポイント

画像前処理: OCR精度向上のための最適化
智能解析: 複数アルゴリズムによる合計金額特定
エラーハンドリング: 様々な失敗パターンへの対応
ユーザビリティ: 直感的で使いやすいインターフェース

得られた知見

  • OCRの精度は画像品質に大きく依存する
  • レシートの形式は店舗により大きく異なる
  • ユーザーのフィードバックが精度向上のカギ

実際に運用してみると、OCRの読み取り精度向上やユーザビリティの改善など、まだまだ改善の余地があることがわかりました。


参考リンク

何か質問があれば、お気軽にコメントしてください!🙌

Discussion