レシート読み取りアプリ完成　複数枚読み取り対応、枚数制限なし！

# 開発の背景
最近、家計簿アプリを使っていますが、レシートの入力は面倒だなと感じていました。そこで、レシートを撮影するだけで内容を読み取ってくれるアプリがあれば便利だと思い、開発を始めました。

複数枚のレシートをまとめて読み取る機能を実装しました！しかも、枚数制限はありません！
Claude 3.5は5枚まで、GPT-4は10枚までですが、このアプリはなんと無制限です！

# 技術スタック
* Google AI Studio: Googleが提供する機械学習プラットフォーム。今回は、ここでGeminiのAPIトークンを取得しました。
* Gemini: Googleが開発した最新の画像認識AIモデル。レシートの画像からテキストを抽出するために使用しました。
* Python: プログラミング言語。アプリのロジックを実装するために使用しました。
* Gradio: PythonのUIライブラリ。簡単にアプリのインターフェースを作成できました。
# 使い方
* Google AI StudioでGeminiのAPIトークンを取得します。
* 上記のPythonコードをコピーして、API_TOKEN_1 に取得したトークンを貼り付けます。
* コードを実行すると、Gradioのインターフェースが起動します。
* 複数枚のレシートの画像をまとめてアップロードし、プロンプトテキストに "read it" と入力して実行します。
* Geminiがレシートの内容を読み取り、テキストとして出力します。
* 各レシートの読み取り結果は --- で区切られます。
# 今後の展望
まだ開発途中の段階ですが、将来的には以下のような機能を追加したいと考えています。
* 読み取ったテキストから自動的に家計簿アプリに入力する機能
* レシートの種類（スーパー、コンビニなど）を自動判別する機能
# まとめ
今回、Google AI StudioとGeminiを使ってレシート読み取りアプリを開発しました。複数枚のレシートをまとめて処理できるだけでなく、枚数制限がないことが大きな特徴です。Claude 3.5やGPT-4よりも多くのレシートを一度に処理できます。まだ改善の余地はありますが、実用的なレベルに達していると思います。ぜひ試してみてください。

```python

import tkinter as tk
from tkinter import scrolledtext
import requests
import json
from dotenv import load_dotenv
import os
import threading
import time
import base64
from PIL import Image
import io
import gradio as gr
import time

load_dotenv()
API_TOKEN_1 = os.getenv("API_TOKEN_1")

# ログを書き込む関数
def log_result(image_name, result):
    with open("process_log.txt", "a", encoding="utf-8") as log_file:
        log_file.write(f"Image: {image_name}, Result: {result}\n")

def generate_response_with_image(prompt, encoded_image, api_token, max_retries=3, initial_delay=1):
    url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent'
    headers = {'Content-Type': 'application/json'}

    data = {
        "contents": [{
            "parts": [
                {"text": prompt},
                {
                    "inline_data": {
                        "mime_type": "image/jpeg",
                        "data": encoded_image
                    }
                }
            ]
        }]
    }
    
    params = {'key': api_token}

    for attempt in range(max_retries):
        try:
            response = requests.post(url, headers=headers, params=params, data=json.dumps(data))
            response.raise_for_status()
            result = response.json()
            
            if 'candidates' in result and result['candidates']:
                candidate = result['candidates'][0]
                if 'content' in candidate and 'parts' in candidate['content']:
                    return candidate['content']['parts'][0].get('text', "Error: No text in response")
                else:
                    return "Error: Unexpected response structure"
            else:
                return "Error: No candidates in response"
        
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 429:
                if attempt < max_retries - 1:
                    delay = initial_delay * (2 ** attempt)  # Exponential backoff
                    print(f"Rate limit exceeded. Retrying in {delay} seconds...")
                    time.sleep(delay)
                    continue
                else:
                    return "Error: Rate limit exceeded. Max retries reached."
            else:
                return f"HTTP Error: {str(e)}"
        except requests.RequestException as e:
            return f"Network Error: {str(e)}"
        except json.JSONDecodeError:
            return "Error: Invalid JSON response"
        except KeyError as e:
            return f"Error: Missing key in response - {str(e)}"
        except Exception as e:
            return f"Unexpected Error: {str(e)}"

def encode_image_to_base64(image):
    """画像を Base64 エンコードする"""
    buffered = io.BytesIO()
    image.save(buffered, format="JPEG")
    return base64.b64encode(buffered.getvalue()).decode('utf-8')

def process_images(images, prompt_text):
    results = []
    
    if not images:
        return "Error: No images provided"
    
    if not prompt_text:
        return "Error: No prompt text provided"
    
    for image in images:
        try:
            # PILを使用して画像を開く
            with Image.open(image.name) as img:
                # 画像をBase64エンコード
                encoded_image = encode_image_to_base64(img)
                # レスポンスを生成
                response_text = generate_response_with_image(prompt_text, encoded_image, API_TOKEN_1)
                results.append(response_text)
                log_result(image.name, response_text)  # 結果をログに保存
        except IOError:
            error_message = f"Error: Unable to open image {image.name}"
            results.append(error_message)
            log_result(image.name, error_message)  # エラーメッセージをログに保存
        except Exception as e:
            error_message = f"Error processing image {image.name}: {str(e)}"
            results.append(error_message)
            log_result(image.name, error_message)  # エラーメッセージをログに保存
    
    return "\n\n".join(results)

# Gradio インターフェースのセットアップ
iface = gr.Interface(
    fn=process_images,
    inputs=[
        gr.File(label="画像をアップロード", file_count="multiple"),
        gr.Textbox(label="プロンプトテキスト", placeholder="read it")
    ],
    outputs=gr.Textbox(label="生成されたレスポンス")
)

iface.launch(share=False)  # 公開リンクを作成するために share=True を追加

```