MIT App Inventorによるテキスト認識を用いた家計簿機能

### 初めに
沢山の家計簿アプリが存在しますが，自分にマッチしたアプリってなかなか見つからないですよね...
自分で作ってしまえばよくね！ってことで根本的な機能を考えてみました！
非常にシンプルでレシートを認識して，支出ごとにカテゴライズします．

### アプリ概要
カメラでレシートを撮影し，Google Cloud Runに送信．Cloud Runではレシート画像からCloud Vision APIにてテキスト認識を行い，Geminiにテキストを渡して支出ごとにカテゴライズします．

### デザイナー画面
![スクリーンショット 2025-05-05 142522.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/7b749bf5-77f9-469b-8e28-2b28f200cf9d.png)

|機能|内容|
|:-:|:-:|
|カメラ|ボタン，写真撮影 |
|認識|ボタン，テキスト認識を行う |
|保存|ボタン，結果をTinyDBに保存する |
|表示|ボタン，TinyDBに保存されているデータを表示する|
|image1|撮影した画像を表示する|
|Label1|ラベル，認識結果もしくはTinyDBに保存したデータの表示|
|Camera1|写真撮影|
|Web1|CloudRunへのアクセス|
|KIO4_Base641|画像をbase64にエンコードする|
|TinyDB1|CloudRunからの結果の保存場所|

### blocks画面
#### 変数
**CloudRunURL**
CloudRunにアクセスするための1URL
![blocks.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/32a1568b-b292-40ae-bf10-38f330ef6856.png)

#### ボタン
**カメラ**
![blocks (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/7894b2c1-7576-46d6-a2b2-a34849aad762.png)

**認識**
画像をbase64に変換しCloudCunへ送信．レスポンスはLabel1に表示．
![blocks (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/795a66be-a230-451d-8183-d7d81c01a6a8.png)

**保存**
TinyDBにCloudRunの結果を保存．
![blocks (6).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/17987110-52e2-462a-a1ac-173ee8ca1ee4.png)

**表示**
TinyDBに保存した今までのCloudRunの出力をLabel1に表示．
![blocks (7).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/c2b88ac0-1656-4727-9747-c59aecdb4fe0.png)

#### when Camera1. After picture
レシート撮影後に画像を表示する．
![blocks (5).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/d3b66c99-3aa0-4ddb-b9c9-c29d46095c65.png)

#### when KIO4_Base64. After Image Base64
画像をBase64にエンコードして，Cloudrunに送信．
![blocks (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/564137dd-7f33-4484-8421-06c013f3864b.png)

#### when Web1. GotText
Cloud Runからのレスポンス．
ResponseContentから必要な情報のみを表示．
![blocks (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/ecaad84b-bbae-45b0-9bf5-e3f8e1537075.png)


### Google Cloud Runのコード
Cloud Vision APIを使用できるようにし，Cloud Runにコードを記述します．
生成AIに書いてもらいました～～！AIってほんとに優秀ですね．


```main.py
import base64
import json
import os
from googleapiclient import discovery
from flask import Flask, request, jsonify
import functions_framework
import requests

app = Flask(__name__)

# Google Cloud Vision API のエンドポイント
VISION_API_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate"

# Gemini API のエンドポイントとAPIキー
GEMINI_API_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
GEMINI_API_KEY = "XXXXXXXXXXXXXXXXXXXXXX" #ご自身のAPIKeyに書き換える

@app.route('/extract_text', methods=['POST']) # POSTメソッドに変更
def extract_text():
    """Base64エンコードされた画像を取得し、テキストを抽出し、Gemini APIでカテゴリ分けし、JSONで返す"""
    try:
        # リクエストからBase64エンコードされた画像データを取得
        data = request.get_json()
        base64_image = data.get('image')

        if not base64_image:
            return jsonify({"error": "imageデータがありません"}), 400

        # Google Cloud Vision API のクライアントを作成
        service = discovery.build(
            'vision', 'v1', credentials=None,
            cache_discovery=False,   # キャッシュを無効にする
            static_discovery=False   # 静的検出を無効にする
        )

        # リクエストの本文（JSON）を作成
        request_body_vision = {
            "requests": [
                {
                    "image": {
                        "content": base64_image
                    },
                    "features": [
                        {
                            "type": "TEXT_DETECTION"
                        }
                    ]
                }
            ]
        }

        # Google Cloud Vision API にリクエストを送信
        response_vision = service.images().annotate(body=request_body_vision).execute()

        # Vision API のレスポンスを処理
        extracted_text = ""
        if response_vision.get('responses'):
            text_annotations = response_vision["responses"][0].get("textAnnotations", [])
            # 全てのテキストを結合
            extracted_text = "\n".join([annotation.get('description', '') for annotation in text_annotations])
        else:
            return jsonify({"error": f"Vision APIリクエストエラー: {response_vision.get('error')}"}), 500

        # Gemini API へのリクエストを作成
        headers_gemini = {
            'Content-Type': 'application/json'
        }
        payload_gemini = {
            "contents": [{
                "parts": [
                    {"text": f"""これから家計簿を作成します。
レシートテキストを渡すのでそれをカテゴライズし、カテゴリーごとの金額を出力してください。カテゴライズできない購入品に関しては「その他」に分類してください。
また、購入日と店舗名、決済方法も出力してください。

### レシートテキスト
{extracted_text}
"""}
                ]
            }],
            "generationConfig": {
                "response_mime_type": "application/json",
                "response_schema": {
                    "type": "ARRAY",
                    "items": {
                        "type": "OBJECT",
                        "required":["購入日", "店舗名", "決済方法"],
                        "properties": {
                            "購入日": {
                                "type":"STRING",
                                "description":"購入日を出力"
                            },
                            "店舗名": {
                                "type":"STRING",
                                "description":"店舗名を出力"
                            },
                            "決済方法": {
                                "type":"STRING",
                                "description":"決済方法を出力"
                            },
                            "食費": {
                                "type":"INTEGER",
                                "description":"食費の合計を数値で出力"
                            },
                            "交通費": {
                                "type":"INTEGER",
                                "description":"交通費の合計を数値で出力"
                            },
                            "日用品": {
                                "type":"INTEGER",
                                "description":"日用品の合計を数値で出力"
                            },
                            "衣服": {
                                "type":"INTEGER",
                                "description":"衣服の合計を数値で出力"
                            },
                            "コンビニ": {
                                "type":"INTEGER",
                                "description":"コンビニの合計を数値で出力"
                            },
                            "その他": {
                                "type":"INTEGER",
                                "description":"分類できない支出の合計を数値で出力"
                            }
                        }
                    }
                }
            }
        }

        # Gemini API にリクエストを送信
        response_gemini = requests.post(
            f"{GEMINI_API_ENDPOINT}?key={GEMINI_API_KEY}",
            headers=headers_gemini,
            json=payload_gemini
        )
        response_gemini.raise_for_status() # エラーレスポンスの場合は例外を発生させる
        gemini_results = response_gemini.json()

        # Gemini API のレスポンスを処理
        if gemini_results.get('candidates') and gemini_results['candidates'][0].get('content') and gemini_results['candidates'][0]['content'].get('parts'):
            gemini_response_text = gemini_results['candidates'][0]['content']['parts'][0].get('text')
            try:
                categorized_data = json.loads(gemini_response_text)
                return jsonify({"extracted_text": extracted_text, "categorized_data": categorized_data})
            except json.JSONDecodeError:
                return jsonify({"error": f"Gemini APIのレスポンスをJSONとしてデコードできませんでした: {gemini_response_text}"}), 500
        else:
            return jsonify({"error": f"Gemini APIからの有効なレスポンスが見つかりませんでした: {gemini_results}"}), 500

    except Exception as e:
        return jsonify({"error": str(e)}), 500

@functions_framework.http
def main(request):
    """HTTPリクエストを処理する"""
    return extract_text()

```

```requirements.txt
functions-framework==3.*
requests
google-api-python-client
```

### 実行結果
ちゃんと機能していますね！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928359/c22d4d1a-b0c6-4229-b69c-c4d525ac479b.jpeg" width=50%>

### 終わりに
カテゴライズできたので，表にまとめられるようにしたいですね！