linebotへ送信

linebotへ送信

実行環境
M1 MacbookAir 16GB

Ollamaで修正されたテキストをLINE botに送信するため
既存のLINE Notifyの処理と合わせて、修正された文章をLINE botに送信する処理を実装

LINE botにメッセージを送信するには、LINE Messaging APIを利用する

LINE botにメッセージを送信するためのモジュールを作る

touch line_bot_sender.py

中身は

# line_bot_sender.py
import requests
import json

class LineBotSender:
    def __init__(self, config_file_path):
        self.config = self._load_config(config_file_path)
        self.channel_access_token = self.config["line_bot_channel_access_token"]
        self.user_id = self.config["line_bot_user_id"]

    def _load_config(self, config_file_path):
        with open(config_file_path, 'r') as file:
            return json.load(file)

    def send_message(self, message):
        url = "https://api.line.me/v2/bot/message/push"
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.channel_access_token}"
        }
        payload = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(url, headers=headers, json=payload)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

として保存

config.json にLINE botの情報を追加
{
    "line_notify_token": "YOUR_LINE_NOTIFY_TOKEN",
    "ollama_model": "elyza:jp8b",
    "line_bot_channel_access_token": "YOUR_LINE_BOT_CHANNEL_ACCESS_TOKEN",
    "line_bot_user_id": "TARGET_USER_ID"
}

linebot useridがわからなかったため検索

開発者が自分自身のユーザーIDを取得する

を参考に

LINE Developersコンソール

基本情報のプロバイダーIDを入れてみる

次にOllamaで修正されたテキストをLINE botに送信するために、メインスクリプトにLineBotSenderを組み込む

import sounddevice as sd
from module.module_whisper import FasterWhisperModel
from module.module_recorder import Recorder
import time
from line_notify import LineNotify  # 作成したLineNotifyモジュールをインポート
from ollama_text_correction import OllamaTextCorrector  # Ollamaによる修正モジュールをインポート

def main():
    recorder = Recorder()
    fasterWhispermodel = FasterWhisperModel()

    # 入力された音声テキストを格納するリスト
    recognized_texts = []

    # LINE Notifyのモジュールを初期化(config.jsonからトークンを読み込む)
    line_notify = LineNotify("config.json")
    
    # Ollamaのテキスト修正モジュールを初期化
    text_corrector = OllamaTextCorrector("config.json")

    while True:
        start_time = time.time()  # 処理開始時刻を記録
        audio_data = recorder.speech2audio()

        # 処理が10秒間行われなかった場合はループを抜ける
        if time.time() - start_time >= 10:
            print("10秒間音声が入力されなかったため、ループを終了します。")
            break

        if audio_data is None:
            print("無音状態が続いたため、ループを終了します。")
            break  # 無音でループを抜ける
        
        # 音声をテキストに変換
        text = fasterWhispermodel.audio2text(audio_data)
        
        # Ollamaでテキストを構成
        corrected_text = text_corrector.correct_text(text)
        
        if corrected_text:  # Noneが返された場合はスキップ
            recognized_texts.append(corrected_text)
            print(corrected_text)

    # ループ終了後に、入力した音声テキストを改行付きで一覧表示
    if recognized_texts:
        message = "\n".join(recognized_texts)
        print("\n入力された音声テキスト一覧:")
        print(message)

        # LINE Notifyでメッセージを送信
        line_notify.send(f"入力された音声テキスト一覧:\n{message}")
    else:
        print("入力メッセージはありませんでした")

if __name__ == "__main__":
    main()

を書き換えるとプロンプト対処があとで困るので
これをコピーし main2.pyとしてコードを記述

import sounddevice as sd
from module.module_whisper import FasterWhisperModel
from module.module_recorder import Recorder
import time
from line_notify import LineNotify  # 作成したLineNotifyモジュールをインポート
from ollama_text_correction import OllamaTextCorrector  # Ollamaによる修正モジュールをインポート
from line_bot_sender import LineBotSender  # LINE bot送信用のモジュールをインポート

def main():
    recorder = Recorder()
    fasterWhispermodel = FasterWhisperModel()

    # 入力された音声テキストを格納するリスト
    recognized_texts = []

    # LINE Notifyのモジュールを初期化(config.jsonからトークンを読み込む)
    line_notify = LineNotify("config.json")
    
    # Ollamaのテキスト修正モジュールを初期化
    text_corrector = OllamaTextCorrector("config.json")
    
    # LINE bot送信用のモジュールを初期化
    line_bot_sender = LineBotSender("config.json")

    while True:
        start_time = time.time()  # 処理開始時刻を記録
        audio_data = recorder.speech2audio()

        # 処理が10秒間行われなかった場合はループを抜ける
        if time.time() - start_time >= 10:
            print("10秒間音声が入力されなかったため、ループを終了します。")
            break

        if audio_data is None:
            print("無音状態が続いたため、ループを終了します。")
            break  # 無音でループを抜ける
        
        # 音声をテキストに変換
        text = fasterWhispermodel.audio2text(audio_data)
        
        # Ollamaでテキストを構成
        corrected_text = text_corrector.correct_text(text)
        
        if corrected_text:  # Noneが返された場合はスキップ
            recognized_texts.append(corrected_text)
            print(corrected_text)

    # ループ終了後に、入力した音声テキストを改行付きで一覧表示
    if recognized_texts:
        message = "\n".join(recognized_texts)
        print("\n入力された音声テキスト一覧:")
        print(message)

        # LINE Notifyでメッセージを送信
        line_notify.send(f"入力された音声テキスト一覧:\n{message}")
        
        # LINE botで修正されたテキストを送信
        line_bot_sender.send_message(f"修正された音声テキスト:\n{message}")
    else:
        print("入力メッセージはありませんでした")

if __name__ == "__main__":
    main()

これで実行したら

入力された音声テキスト一覧:
本日の天候
Traceback (most recent call last):
  File "/Users/snowpool/aw10s/linebot/main2.py", line 63, in <module>
    main()
  File "/Users/snowpool/aw10s/linebot/main2.py", line 58, in main
    line_bot_sender.send_message(f"修正された音声テキスト:\n{message}")
  File "/Users/snowpool/aw10s/linebot/line_bot_sender.py", line 32, in send_message
    raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")
Exception: Error sending message to LINE bot: 400, {"message":"The property, 'to', in the request body is invalid (line: -, column: -)"}

となった

config.jsonに
  "channel_secret": "YOUR_CHANNEL_SECRET",

を追加する

次に
line_bot_sender.pyを修正する
line_bot_sender.py ファイルで、
send_message メソッド内のリクエストボディに ‘to’ フィールドを追加し、
config.jsonから読み込んだ user_id を設定

    def send_message(self, message):
        url = "https://api.line.me/v2/bot/message/push"
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.channel_access_token}"
        }
        payload = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(url, headers=headers, json=payload)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

def send_message(self, message):
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {self.channel_access_token}'
    }
    data = {
        "to": self.user_id,  # 追加
        "messages": [
            {
                "type": "text",
                "text": message
            }
        ]
    }
    response = requests.post(self.api_endpoint, headers=headers, json=data)
    if response.status_code != 200:
        raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

に変更

line_bot_sender.py のクラス内で user_id を読み込むように変更

# line_bot_sender.py
import requests
import json

class LineBotSender:
    def __init__(self, config_path):
        with open(config_path, 'r') as file:
            config = json.load(file)
        self.channel_access_token = config.get('channel_access_token')
        self.channel_secret = config.get('channel_secret')
        self.user_id = config.get('user_id')  # 追加
        self.api_endpoint = 'https://api.line.me/v2/bot/message/push'  # push APIを使用


    def _load_config(self, config_file_path):
        with open(config_file_path, 'r') as file:
            return json.load(file)
    def send_message(self, message):
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        data = {
            "to": self.user_id,  # 追加
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(self.api_endpoint, headers=headers, json=data)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

というようにした

これだけだとまだwebhook設定ができていないのでエラーになる

現在の天気が雨でない場合に次の1時間の天気予報をチェックするようにする

現在の天気が雨でない場合に次の1時間の天気予報をチェックするようにする

# weather_check.py
import location_utils2
import requests

def get_weather_data(api_key, latitude, longitude):
    # OpenWeather One Call API の URL
    url = f"https://api.openweathermap.org/data/3.0/onecall?lat={latitude}&lon={longitude}&exclude=minutely,daily&appid={api_key}&units=metric"
    response = requests.get(url)
    return response.json()

def check_for_rain(weather_data):
    hourly_forecast = weather_data.get('hourly', [])[:1]  # 次の1時間の予報だけをチェック
    for hour in hourly_forecast:
        for weather in hour['weather']:
            if weather['main'] == 'Rain':
                return True
    return False

# APIキーを設定
api_key = ''


# location_utils2から緯度と経度と天気をを取得
latitude, longitude, _ , weather_description = location_utils2.get_current_location_and_address()

# 天気データを取得
weather_data = get_weather_data(api_key, latitude, longitude)

# 雨が予報されているかどうかをチェック
if check_for_rain(weather_data):
    print("Alert: Rain is expected within the next hour!")
else:
    print("No rain expected in the next hour.")

のコードだと
雨が降っていても一時間後に雨なのかを調べてしまう

なので現在地の天気が雨でないのなら実行するようにする

なおこの判定は

# location_utils2.py
import requests
from geopy.geocoders import Nominatim

def get_current_location_and_address():
    # APIキーとZIPコードは予め設定しておく必要があります。
    API_key = ""
    zip_place = "郵便番号,JP"
    
    # OpenWeatherMap API URL
    url = f"https://api.openweathermap.org/data/2.5/weather?zip={zip_place}&units=metric&lang=ja&appid={API_key}"
    
    # データの取得
    response = requests.get(url)
    jsondata = response.json()
    
    # 緯度と経度の取得
    latitude = jsondata['coord']['lat']
    longitude = jsondata['coord']['lon']
    
    #天気の取得
    # weather_description = jsondata['weather'][0]['description']
    weather_description = jsondata['weather'][0]['main']

    
    # 住所の取得(オプショナル)
    geolocator = Nominatim(user_agent="geoapiExercises")
    location = geolocator.reverse((latitude, longitude), language='ja')
    address = location.address if location else None
    
    return latitude, longitude, address, weather_description

で郵便番号をもとに
現在地の緯度経度と天気を取得している

この時に
Mainだと英語で取得になるが
Descriptionだと日本語で詳細の天気になる

ただ詳細な天気だと小雨とか判定が面倒なので
とにかく雨であれば
main==rainの判定にできるので
Mainを判定基準にした

変更後のコードは

import location_utils2
import requests

def get_weather_data(api_key, latitude, longitude):
    url = f"https://api.openweathermap.org/data/3.0/onecall?lat={latitude}&lon={longitude}&exclude=minutely,daily&appid={api_key}&units=metric"
    response = requests.get(url)
    return response.json()

def check_for_rain(weather_data):
    hourly_forecast = weather_data.get('hourly', [])[:1]
    for hour in hourly_forecast:
        for weather in hour['weather']:
            if weather['main'] == 'Rain':
                return True
    return False

api_key = ''

latitude, longitude, _, weather_description = location_utils2.get_current_location_and_address()

# 現在の天気が雨でない場合にのみ次の1時間の雨の予報をチェック
if weather_description != 'Rain':
    weather_data = get_weather_data(api_key, latitude, longitude)
    if check_for_rain(weather_data):
        print("Alert: Rain is expected within the next hour!")
    else:
        print("No rain expected in the next hour.")
else:
    print("It is currently raining.")

次に音声を流れるように変更

今回も音声再生はpygameを使う

pygameライブラリを使って音声ファイルを再生しています。
まず、pygame.mixerを初期化し、音声ファイルを読み込んで再生します。
pygame.mixer.music.play()関数で音声を再生し、
while pygame.mixer.music.get_busy()ループを使って
音声が再生されている間は待機します。
このスクリプトは、雨が予報されていない場合にのみ、次の1時間の雨の予報をチェックし、
予報されていた場合に指定された音声ファイルを再生します

コードは

import location_utils2
import requests
import pygame  # Import pygame for audio playback

def get_weather_data(api_key, latitude, longitude):
    url = f"https://api.openweathermap.org/data/3.0/onecall?lat={latitude}&lon={longitude}&exclude=minutely,daily&appid={api_key}&units=metric"
    response = requests.get(url)
    return response.json()

def check_for_rain(weather_data):
    hourly_forecast = weather_data.get('hourly', [])[:1]
    for hour in hourly_forecast:
        for weather in hour['weather']:
            if weather['main'] == 'Rain':
                return True
    return False

api_key = ''

latitude, longitude, _, weather_description = location_utils2.get_current_location_and_address()

# 現在の天気が雨でない場合にのみ次の1時間の雨の予報をチェック
if weather_description != 'Rain':
    weather_data = get_weather_data(api_key, latitude, longitude)
    if check_for_rain(weather_data):
        print("Alert: Rain is expected within the next hour!")
        pygame.mixer.init()  # Initialize the mixer module
        pygame.mixer.music.load('voice.wav')  # Load your sound file
        pygame.mixer.music.play()  # Play the sound
        while pygame.mixer.music.get_busy():  # Wait for music to finish playing
            pygame.time.Clock().tick(10)
    else:
        print("No rain expected in the next hour.")
else:
    print("It is currently raining.")

でOK

音声の部分は

python create_voice.py voice.txt 192.168.1.69:50021

で作成した音声を使用

次は顔認識したら実行するようにする

以前の
Weatheer や mail_voice などで使ったkao.pyとxmlファイルを使えばできるはず

ということでモジュール化する

weather_alert.py

として

# weather_alert.py

import location_utils2
import requests
import pygame

def get_weather_data(api_key, latitude, longitude):
    url = f"https://api.openweathermap.org/data/3.0/onecall?lat={latitude}&lon={longitude}&exclude=minutely,daily&appid={api_key}&units=metric"
    response = requests.get(url)
    return response.json()

def check_for_rain(weather_data):
    hourly_forecast = weather_data.get('hourly', [])[:1]
    for hour in hourly_forecast:
        for weather in hour['weather']:
            if weather['main'] == 'Rain':
                return True
    return False

def check_and_alert(api_key):
    latitude, longitude, _, weather_description = location_utils2.get_current_location_and_address()

    if weather_description != 'Rain':
        weather_data = get_weather_data(api_key, latitude, longitude)
        if check_for_rain(weather_data):
            print("Alert: Rain is expected within the next hour!")
            pygame.mixer.init()
            pygame.mixer.music.load('voice.wav')
            pygame.mixer.music.play()
            while pygame.mixer.music.get_busy():
                pygame.time.Clock().tick(10)
        else:
            print("No rain expected in the next hour.")
    else:
        print("It is currently raining.")

として保存

他で呼び出す時には

    api_key = ''
    check_and_alert(api_key)

というように加えればいい

なので
以前作成した kao.pyの中での処理を変更すればOK

その前に
config.iniを作成し
待機時間とAPIキーを記述しておく

とりあえず mail_voiceのものを使うので

cp ../mail_voice/*.xml .
cp ../mail_voice/config.ini .

でコピー

config.iniを編集する

[Settings]
detection_interval = 1800 #30分

[API_KEYS]
OPENWEATHER_API_KEY = 

としておく

次に kao.pyの編集

 cp ../mail_voice/kao.py .

の後に編集する

import weather_alert

でインポート

# 設定を変数に格納
api_key = config['API_KEYS']['OPENWEATHER_API_KEY']
detection_interval = int(config['Settings']['detection_interval'])

そして天気の取得処理を追加

        if lastTime is None or time.perf_counter() - lastTime > detection_interval:
            # 検出時刻更新
            lastTime = time.perf_counter()
            weather_alert.check_and_alert(api_key)

とする

これで実行したら

  File "/Users/snowpool/aw10s/rain_alert/kao.py", line 12, in <module>
    detection_interval = int(config['Settings']['detection_interval'])
ValueError: invalid literal for int() with base 10: '1800 #30分'

となった

原因はコメント
これが原因で
config[‘Settings’][‘detection_interval’] の値が 1800 #30分 という文字列であり、
これを整数に変換しようとしてエラーが発生していることです。

int() 関数は整数に変換できない文字列を受け取るとエラーを発生させます。
問題の原因は、config[‘Settings’][‘detection_interval’] の値が正しい整数値ではなく、
その後にコメントが付いているためです。
Pythonでは、int() 関数は文字列中の数字のみを整数として解釈します。
とのこと

interval_string = config['Settings']['detection_interval']
interval_without_comment = interval_string.split('#')[0].strip()
detection_interval = int(interval_without_comment)

でコメントを消すこともできるらしいが
とりあえず余計なコメントは削除した

これで実行したら

Hello from the pygame community. https://www.pygame.org/contribute.html
Traceback (most recent call last):
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/geopy/geocoders/base.py", line 368, in _call_geocoder
    result = self.adapter.get_json(url, timeout=timeout, headers=req_headers)
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/geopy/adapters.py", line 472, in get_json
    resp = self._request(url, timeout=timeout, headers=headers)
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/geopy/adapters.py", line 500, in _request
    raise AdapterHTTPError(
geopy.adapters.AdapterHTTPError: Non-successful status code 403

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/rain_alert/kao.py", line 41, in <module>
    weather_alert.check_and_alert(api_key)
  File "/Users/snowpool/aw10s/rain_alert/weather_alert.py", line 21, in check_and_alert
    latitude, longitude, _, weather_description = location_utils2.get_current_location_and_address()
  File "/Users/snowpool/aw10s/rain_alert/location_utils2.py", line 28, in get_current_location_and_address
    location = geolocator.reverse((latitude, longitude), language='ja')
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/geopy/geocoders/nominatim.py", line 372, in reverse
    return self._call_geocoder(url, callback, timeout=timeout)
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/geopy/geocoders/base.py", line 388, in _call_geocoder
    res = self._adapter_error_handler(error)
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/geopy/geocoders/base.py", line 411, in _adapter_error_handler
    raise exc_cls(str(error)) from error
geopy.exc.GeocoderInsufficientPrivileges: Non-successful status code 403

となる

geopy ライブラリがジオコーディングサービスへのアクセスに失敗
が原因とのこと

住所の表示は不要で
緯度経度があれば問題ないため
住所表示機能を削除したら無事に動いた

とりあえず今回もコードを公開するのと同時に
Voicevoxのスクリプトも一緒にリポジトリに入れて公開する

機能の統合

機能の統合

メールからURLを抽出する

gmail_url_extractor.py
でURLを取得し
image_downloader.py
で画像を取得

ocr_list.py
で vision api でOCR

この中で
line_notify.py
を使うことで
OCRした結果をワードリストに一致したものと画像をLINEで送信

とりあえずここまで作成したらgithubで公開

残る機能は
Yolov8のモデルを作成し画像認識させること
キーワードリストを効率的に作成するため
レシートをOCRしたものから
店名
価格
商品名
日付
を取り出しCSVファイルに保存すること

CSVファイルを元にDBを作成し
在庫管理と連携するようにして無駄な買い物を減らすこと

とりあえずまずは
ocr_list.py

gmail_url_extractor.py
でURLを取得し
image_downloader.py
で画像を取得

この画像に対してOCRすればOK

ただし
そのままソースを変えるとテストできなくなるので
別のファイルを作成することにする

なお今日は
おいしい牛乳
が割引らしいのでリストに加える

{
  "keywords": [  
    "麻婆豆腐",
    "キッチンタオル",
    "ほんだし",
    "ぶなしめじ",
    "レタス",
    "キャベツ",
    "おいしい牛乳"

  ]
}

そしてOCRするファイルを作成する

touch image_ocr_notifier.py

中身は

# example_usage.py

from gmail_url_extractor import get_first_unread_email_url
from image_downloader import download_and_merge_images
from google.cloud import vision
import io
import json

def load_settings(file_path='settings.json'):
    with open(file_path, 'r', encoding='utf_8') as settings_json:
        return json.load(settings_json)

def detect_text(image_path):
    """OCRで画像からテキストを抽出"""
    client = vision.ImageAnnotatorClient()
    with io.open(image_path, 'rb') as image_file:
        content = image_file.read()

    image = vision.Image(content=content)
    response = client.document_text_detection(image=image)
    full_text_annotation = response.full_text_annotation

    if response.error.message:
        raise Exception(
            '{}\nFor more info on error messages, check: '
            'https://cloud.google.com/apis/design/errors'.format(
                response.error.message))

    return full_text_annotation.text

def search_words(text, keywords):
    """抽出したテキストからキーワードを検索"""
    hitwords = []
    for keyword in keywords:
        if keyword in text:
            hitwords.append(keyword)
    return hitwords

def main():
    # 設定を読み込む
    settings = load_settings()
    
    # GmailからURLを取得
    url = get_first_unread_email_url('【Shufoo!】お気に入り店舗新着チラシお知らせメール')  # '特売情報'はメールの件名に含まれるキーワード
    
    if url:
        print(f"Processing URL: {url}")
        # 画像をダウンロードしてOCRを実行
        output_path = download_and_merge_images('config.json', url)
        
        if output_path:
            extracted_text = detect_text(output_path)
            hitwords = search_words(extracted_text, settings["keywords"])
            
            if hitwords:
                message = "特売リスト: " + ", ".join(hitwords)
                send_line_notify(message, output_path)
            else:
                print("マッチしたキーワードはありませんでした。")
    else:
        print("未読メールが見つかりませんでした。")

if __name__ == '__main__':
    main()

実行したけど

Processing URL: https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc

となるだけ

おそらくxpathが変更になっているので確認

/html/body/div[1]/div[3]/div[1]/div/div[2]/div[3]
/html/body/div[1]/div[3]/div[1]/div/div[2]/div[3]

同じだが動作していない

image_downloader.pyの動作確認

ログの追加: image_downloader.pyの中にログを追加して、どこで処理が失敗しているかを特定
print(f'Checking image source: {src}')  # ログ追加

となるように

def get_images_from_container(driver, base_xpath):
    """指定されたXPathから画像URLを取得する"""
    image_urls = []
    try:
        container = WebDriverWait(driver, 10).until(
            EC.presence_of_element_located((By.XPATH, base_xpath))
        )
        images = container.find_elements(By.TAG_NAME, 'img')
        
        for img in images:
            src = img.get_attribute('src')
            print(f'Checking image source: {src}')  # ログ追加
            if 'index/img' in src:
                image_urls.append(src)
                print(f'Found image: {src}')
    except Exception as e:
        print(f'Error finding images: {e}')
    return image_urls

というように
変更

また
CR処理のデバッグ: detect_text関数において、Cloud Vision APIのレスポンスが正常であるか確認

image_ocr_notifier.py
の中に

# OCRの結果をデバッグ表示 
full_text_annotation = response.full_text_annotation print("Extracted Text:", full_text_annotation.text)

を追加するので

def detect_text(image_path):
    """OCRで画像からテキストを抽出"""
    client = vision.ImageAnnotatorClient()
    with io.open(image_path, 'rb') as image_file:
        content = image_file.read()

    image = vision.Image(content=content)
    response = client.document_text_detection(image=image)
    if response.error.message:
        raise Exception(
            '{}\nFor more info on error messages, check: '
            'https://cloud.google.com/apis/design/errors'.format(
                response.error.message))
    
    # OCRの結果をデバッグ表示
    full_text_annotation = response.full_text_annotation
    print("Extracted Text:", full_text_annotation.text)

    return full_text_annotation.text

しかし動作しない

/html/body/div[1]/div[3]/div[1]/div/div[2]/div[2]/div[2]/div[1]

ではなく

/html/body/div[1]/div[3]/div[1]/div/div[2]/div[2]/div[2]

<div id="cv_1" class="ChirashiView" style="position: absolute; left: 0px; top: -30px; z-index: 1; opacity: 1; cursor: url(&quot;https://www.shufoo.net/site/chirashi_viewer_js/js/../images/openhand_8_8.cur&quot;), default; transition: opacity 200ms ease-in-out;"><div class="ChirashiView_tempDiv" style="position: absolute; overflow: hidden; width: 750px; height: 603px; left: 0px; top: 0px; z-index: 100;"></div><div class="ChirashiContainer" style="position: absolute; left: 0px; top: 0px; width: 750px; height: 603px; z-index: 0; opacity: 1;"><div class="inDiv" style="position: absolute; left: 0px; top: 0px; z-index: 1;"><div id="-2_-2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -1004px; top: -977.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 512px;"></div><div id="-1_-2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -492px; top: -977.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 512px;"></div><div id="0_-2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 20px; top: -977.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 512px;"></div><div id="1_-2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 532px; top: -977.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 198px; height: 512px;"></div><div id="2_-2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 1044px; top: -977.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; height: 512px;"></div><div id="-2_-1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -1004px; top: -465.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 512px;"></div><div id="-1_-1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -492px; top: -465.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 512px;"></div><div id="0_-1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 20px; top: -465.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 512px;"></div><div id="1_-1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 532px; top: -465.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 198px; height: 512px;"></div><div id="2_-1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 1044px; top: -465.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; height: 512px;"></div><div id="-2_0" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -1004px; top: 46.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 510px;"></div><div id="-1_0" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -492px; top: 46.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 510px;"></div><div id="0_0" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 20px; top: 46.5px; width: 512px; height: 512px;"><img draggable="false" src="https://ipqcache2.shufoo.net/c/2024/08/08/25295137072090/index/img/0_100_0.jpg" style="border: 0px; padding: 0px; margin: 0px; width: 512px; height: 510px;"></div><div id="1_0" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 532px; top: 46.5px; width: 512px; height: 512px;"><img draggable="false" src="https://ipqcache2.shufoo.net/c/2024/08/08/25295137072090/index/img/0_100_1.jpg" style="border: 0px; padding: 0px; margin: 0px; width: 198px; height: 510px;"></div><div id="2_0" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 1044px; top: 46.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; height: 510px;"></div><div id="-2_1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -1004px; top: 558.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px;"></div><div id="-1_1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -492px; top: 558.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px;"></div><div id="0_1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 20px; top: 558.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px;"></div><div id="1_1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 532px; top: 558.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 198px;"></div><div id="2_1" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 1044px; top: 558.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px;"></div><div id="-2_2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -1004px; top: 1070.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px;"></div><div id="-1_2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: -492px; top: 1070.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px;"></div><div id="0_2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 20px; top: 1070.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 512px;"></div><div id="1_2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 532px; top: 1070.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px; width: 198px;"></div><div id="2_2" style="position: absolute; opacity: 1; transition: opacity 200ms ease-out; left: 1044px; top: 1070.5px; width: 512px; height: 512px;"><img draggable="false" src="https://www.shufoo.net/site/chirashi_viewer_js/images/transparent.png" style="border: 0px; padding: 0px; margin: 0px;"></div></div><div class="linkDiv" style="position: absolute; left: 0px; top: 0px; z-index: 2;"></div></div></div>

のソース部分が正解らしい

config.jsonの中身を

  "base_xpath_images": "/html/body/div[1]/div[3]/div[1]/div/div[2]/div[2]/div[2]"

とxpathを変更することで
とりあえず画像の取得はできたが

 File "/Users/snowpool/aw10s/store_adversting_list/image_ocr_notifier.py", line 67, in <module> main() File "/Users/snowpool/aw10s/store_adversting_list/image_ocr_notifier.py", line 60, in main send_line_notify(message, output_path) NameError: name 'send_line_notify' is not defined

となる

    # # OCRの結果をデバッグ表示
    # full_text_annotation = response.full_text_annotation
    # print("Extracted Text:", full_text_annotation.text)

というようにコメントアウト

from line_notify import send_line_notify

が抜けていたので
これを追記

なぜかGPTでソースを生成すると
モジュールのインポートが削除されることがよくある

無事に処理ができたがログに

WARNING: All log messages before absl::InitializeLog() is called are written to STDERR I0000 00:00:1723322571.066745 14856910 config.cc:230] gRPC experiments enabled: call_status_override_on_cancellation, event_engine_dns, event_engine_listener, http2_stats_fix, monitoring_experiment, pick_first_new, trace_record_callops, work_serializer_clears_time_cache Sent images/combined_image_20240811_054251.jpg: 200 {"status":200,"message":"ok"} 

と出る

GPTによれば

WARNING: All log messages before absl::InitializeLog() is called are written to STDERR

このメッセージは、abseil (Absl) ライブラリが初期化される前にログメッセージが標準エラー出力(STDERR)に書き込まれることを示しています。これは、Google CloudのPythonクライアントライブラリやgRPCなどのバックエンドでAbseilライブラリが使用されている場合によく見られます。この警告は通常、以下の点で心配する必要はありません:

初期化前ログの出力: Abseilが初期化される前に生成されるログメッセージが一時的にSTDERRに出力されるという技術的な情報を示しています。
影響なし: このメッセージは一般的にはアプリケーションの動作に影響を与えないため、無視しても問題ありません

I0000 00:00:1723322571.066745 14856910 config.cc:230] gRPC experiments enabled: call_status_override_on_cancellation, event_engine_dns, event_engine_listener, http2_stats_fix, monitoring_experiment, pick_first_new, trace_record_callops, work_serializer_clears_time_cache

このメッセージは、gRPCライブラリの内部で実験的な機能が有効になっていることを示す情報ログです。これも通常、開発者が特に指定しない限り、デフォルトで有効になっている設定です。これらの実験的な機能は、以下のような効果を持ちます:
* 実験的機能の有効化: gRPCの内部機能が特定の実験的な設定で動作していることを示します。
* 通常の動作: 通常、gRPCの標準的な利用に影響はなく、情報提供のためのログです。

これらのログメッセージは、技術的な情報や警告を提供するためのものです。特にabsl::InitializeLog()やgRPCに関する警告は、ログの設定やライブラリの内部動作に関するもので、アプリケーションの正常動作には通常影響を与えません

とのこと

チラシ詳細の取得

チラシ詳細の取得

Shufooで直リンクすれば
Gmail処理は不要

https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc
杏林堂
キーワード
日替

https://www.shufoo.net/pntweb/shopDetail/197728/?cid=nmail_pc
ユーコープ
キーワード
ユーコープのお買い得!

https://www.shufoo.net/pntweb/shopDetail/15782/?cid=nmail_pc
ぴあご

というように
それぞれみたいチラシのURLとキーワードがペアになっている
ならばこれをJSONとかにして当てはめることができるはず

もしかしたらxpathも同じかもしれないので

cp clik_allget_image.py piago.py

でコピーして
ぴあごとコープで試す

どうやらxpathも同じ
ただし coopのチラシがOCRの精度が良くない

とりあえずぴあごのチラシのリンクはできたけど
画像のダウンロードができていない

とりあえず杏林堂は毎日テストできるので
先に杏林堂のチラシでLINE送信を試す

def wait_for_page_load(driver, timeout=30):
    WebDriverWait(driver, timeout).until(
        EC.presence_of_element_located((By.XPATH, '//img'))  # ページに画像が表示されるまで待機
    )

を追加してみる

import os
import time
import requests
from PIL import Image
from io import BytesIO
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.safari.service import Service as SafariService
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from datetime import datetime

def open_link_in_safari(url):
    # Safariドライバーを使用してブラウザを起動
    service = SafariService()
    driver = webdriver.Safari(service=service)
    driver.get(url)
    time.sleep(3)  # リンクを開いた後に3秒間待機
    return driver

def click_date_element(driver, base_xpath):
    try:
        # コンテナ内の日付要素を探してクリック
        container = WebDriverWait(driver, 10).until(
            EC.presence_of_element_located((By.XPATH, base_xpath))
        )
        links = container.find_elements(By.XPATH, ".//a[contains(@title, '日替')]")

        for link in links:
            if '日替' in link.get_attribute('title'):
                link.click()
                print(f'Clicked on link with title: {link.get_attribute("title")}')
                time.sleep(3)  # クリックした後に3秒間待機
                return

        print('No link found with title containing: 日替')
    except Exception as e:
        print(f'Error clicking on element: {e}')

def get_images_from_container(driver, base_xpath):
    image_urls = []
    try:
        # コンテナ内の画像要素を探す
        container = WebDriverWait(driver, 10).until(
            EC.presence_of_element_located((By.XPATH, base_xpath))
        )
        images = container.find_elements(By.TAG_NAME, 'img')
        
        for img in images:
            src = img.get_attribute('src')
            # 特定の条件に基づいて画像をフィルタリング
            if 'index/img' in src:
                image_urls.append(src)
                print(f'Found image: {src}')
    except Exception as e:
        print(f'Error finding images: {e}')
    return image_urls

def download_images(image_urls):
    images = []
    for i, url in enumerate(image_urls):
        response = requests.get(url)
        if response.status_code == 200:
            image = Image.open(BytesIO(response.content))
            images.append(image)
            print(f'Downloaded image_{i}.jpg')
        else:
            print(f'Failed to download {url}')
    return images

def merge_images(images, output_path):
    widths, heights = zip(*(img.size for img in images))

    total_height = sum(heights)
    max_width = max(widths)

    combined_image = Image.new('RGB', (max_width, total_height))

    y_offset = 0
    for img in images:
        combined_image.paste(img, (0, y_offset))
        y_offset += img.height

    combined_image.save(output_path)
    print(f'Saved combined image as {output_path}')

def main():
    url = 'https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc'
    driver = open_link_in_safari(url)
    # 特定のリンクをクリックする
    base_xpath_click = '/html/body/div[1]/div[3]/div[1]/div/div[4]/div/div/div/div/div/div/ul'
    click_date_element(driver, base_xpath_click)
    
    # 画像を取得してダウンロードする
    base_xpath_images = '/html/body/div[1]/div[3]/div[1]/div/div[2]/div[2]'
    image_urls = get_images_from_container(driver, base_xpath_images)
    driver.quit()
    
    if image_urls:
        images = download_images(image_urls)
        if images:
            # 現在の日付を取得してフォーマット
            current_date = datetime.now().strftime('%Y%m%d')
            # カレントディレクトリにimagesフォルダを作成
            output_dir = 'images'
            os.makedirs(output_dir, exist_ok=True)  # ディレクトリが存在しない場合は作成
            output_path = os.path.join(output_dir, f'combined_image_{current_date}.jpg')
            merge_images(images, output_path)

if __name__ == '__main__':
    main()

import os
import time
import requests
from PIL import Image
from io import BytesIO
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.safari.service import Service as SafariService
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from datetime import datetime

def open_link_in_safari(url):
    # Safariドライバーを使用してブラウザを起動
    service = SafariService()
    driver = webdriver.Safari(service=service)
    driver.get(url)
    wait_for_page_load(driver)  # ページの読み込みを待機
    return driver

def wait_for_page_load(driver, timeout=30):
    """
    ページに画像が表示されるまで待機する関数。
    """
    try:
        WebDriverWait(driver, timeout).until(
            EC.presence_of_element_located((By.XPATH, '//img'))
        )
        print("Page loaded successfully.")
    except Exception as e:
        print(f"Error waiting for page to load: {e}")

def click_date_element(driver, base_xpath):
    try:
        # コンテナ内の日付要素を探してクリック
        container = WebDriverWait(driver, 30).until(
            EC.presence_of_element_located((By.XPATH, base_xpath))
        )
        links = container.find_elements(By.XPATH, ".//a[contains(@title, '日替')]")

        for link in links:
            if '日替' in link.get_attribute('title'):
                link.click()
                print(f'Clicked on link with title: {link.get_attribute("title")}')
                wait_for_page_load(driver)  # クリック後のページ読み込みを待機
                return

        print('No link found with title containing: 日替')
    except Exception as e:
        print(f'Error clicking on element: {e}')

def get_images_from_container(driver, base_xpath):
    image_urls = []
    try:
        # コンテナ内の画像要素を探す
        container = WebDriverWait(driver, 30).until(
            EC.presence_of_element_located((By.XPATH, base_xpath))
        )
        images = container.find_elements(By.TAG_NAME, 'img')
        
        for img in images:
            src = img.get_attribute('src')
            # 特定の条件に基づいて画像をフィルタリング
            if 'index/img' in src:
                image_urls.append(src)
                print(f'Found image: {src}')
    except Exception as e:
        print(f'Error finding images: {e}')
    return image_urls

def download_images(image_urls):
    images = []
    for i, url in enumerate(image_urls):
        response = requests.get(url)
        if response.status_code == 200:
            image = Image.open(BytesIO(response.content))
            images.append(image)
            print(f'Downloaded image_{i}.jpg')
        else:
            print(f'Failed to download {url}')
    return images

def merge_images(images, output_path):
    widths, heights = zip(*(img.size for img in images))

    total_height = sum(heights)
    max_width = max(widths)

    combined_image = Image.new('RGB', (max_width, total_height))

    y_offset = 0
    for img in images:
        combined_image.paste(img, (0, y_offset))
        y_offset += img.height

    combined_image.save(output_path)
    print(f'Saved combined image as {output_path}')

def main():
    url = 'https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc'
    driver = open_link_in_safari(url)
    # 特定のリンクをクリックする
    base_xpath_click = '/html/body/div[1]/div[3]/div[1]/div/div[4]/div/div/div/div/div/div/ul'
    click_date_element(driver, base_xpath_click)
    
    # 画像を取得してダウンロードする
    base_xpath_images = '/html/body/div[1]/div[3]/div[1]/div/div[2]/div[2]'
    image_urls = get_images_from_container(driver, base_xpath_images)
    driver.quit()
    
    if image_urls:
        images = download_images(image_urls)
        if images:
            # 現在の日付を取得してフォーマット
            current_date = datetime.now().strftime('%Y%m%d')
            # カレントディレクトリにimagesフォルダを作成
            output_dir = 'images'
            os.makedirs(output_dir, exist_ok=True)  # ディレクトリが存在しない場合は作成
            output_path = os.path.join(output_dir, f'combined_image_{current_date}.jpg')
            merge_images(images, output_path)

if __name__ == '__main__':
    main()

としてみる

Jsonファイルを元に購入リストがあるか判定

Jsonファイルを元に購入リストがあるか判定

 vim ocr_list.py   

でファイルを作成

import json
settings_json = open('settings.json', 'r', encoding='utf_8')
settings = json.load(settings_json)

# 公式サイトからpdfリンク一覧取得
def get_urls():
  import requests
  from bs4 import BeautifulSoup

  params = { settings['url_params_name']: settings['url_params_value'] }
  load_url = settings['url']
  html = requests.get(load_url, params=params)
  soup = BeautifulSoup(html.text, 'html.parser')

  flyer_list = soup.find_all('table')
  url_list = []
  for flyer in flyer_list:
    # 日付
    date = flyer.find('div', {'class': 'sale'}).find('a').get_text(strip=True).replace(' ', '').replace('(', '(').replace(')', ')')
    
    # PDF(表)
    omote_url = flyer.find('a', {'title': 'PDFオモテ'})['href']
    omote = {}
    omote['date'] = date
    omote['url'] = settings['url_stem'] + omote_url.replace('../', '')
    url_list.append(omote)

    # PDF(裏)
    if flyer.find('a', {'title': 'PDFウラ'}):
      ura_url = flyer.find('a', {'title': 'PDFウラ'})['href'] 
      ura = {}
      ura['date'] = date
      ura['url'] = settings['url_stem'] + ura_url.replace('../', '')
      url_list.append(ura)

  return url_list

# 未解析のチラシURLを取得
def get_new_urls(url_list):
  # urls.txt読込
  old_urls = []
  with open('urls.txt', 'r') as f:
    old_urls = f.read().splitlines()

  new_urls = []
  urls_text = []
  count = 0
  for url_info in url_list:
    urls_text.append(url_info['url'] + '\n')

    if url_info['url'] not in old_urls:
      # 新規
      url_info['number'] = count
      new_urls.append(url_info)
      count += 1
  
  # urls.txt書込
  f = open('urls.txt', 'w')
  f.writelines(urls_text)
  f.close()

  return new_urls

# 未解析のpdfをDL
def dl_pdfs(new_url_list):
  import urllib.request
  import time

  pdf_list = []
  for url_info in new_url_list:
    # 表
    file_name = f'pdf/{url_info["number"]}.pdf'
    urllib.request.urlretrieve(url_info['url'], file_name)
    url_info['pdf_path'] = file_name

    time.sleep(2)

    pdf_list.append(url_info)

  return pdf_list

# PDFをJPGに変換
def pdf_to_jpeg(path):
  import os
  from pathlib import Path
  from pdf2image import convert_from_path

  # poppler/binを環境変数PATHに追加する
  poppler_dir = Path(__file__).parent.absolute() / 'lib/poppler/bin'
  os.environ['PATH'] += os.pathsep + str(poppler_dir)

  image_paths = []

  pdf_path = Path(path)
  # PDF -> Image に変換(150dpi)
  pages = convert_from_path(str(pdf_path), 150)

  # 画像ファイルを1ページずつ保存
  image_dir = Path('./jpg')
  for i, page in enumerate(pages):
    file_name = pdf_path.stem + '_{:02d}'.format(i + 1) + '.jpeg'
    image_path = image_dir / file_name
    # JPEGで保存
    page.save(str(image_path), 'JPEG')
    image_paths.append(image_path)

  return image_paths

# 複数チラシをJPGに変換
def pdfs_to_jpeg(pdf_list):
  jpg_list = []
  for pdf_info in pdf_list:
    jpg_info = pdf_info
    # 表
    omote_image_paths = pdf_to_jpeg(pdf_info['pdf_path'])
    jpg_info['image_paths'] = omote_image_paths

    jpg_list.append(jpg_info)

  return jpg_list

# OCR
def detect_text(image_paths):
  from google.cloud import vision
  import io
  client = vision.ImageAnnotatorClient()

  all_text = ''

  for image_path in image_paths:
    with io.open(image_path, 'rb') as image_file:
      content = image_file.read()

    image = vision.Image(content=content)
    
    # pylint: disable=no-member
    response = client.text_detection(image=image)
    texts = response.text_annotations

    for text in texts:
      all_text += str(text.description)

    if response.error.message:
      raise Exception(
        '{}\nFor more info on error messages, check: '
        'https://cloud.google.com/apis/design/errors'.format(
          response.error.message))

  return all_text

# キーワード検索
def search_words(all_text):
  hitwords = []
  for keyword in settings["keywords"]:
    if keyword in all_text:
      hitwords.append(keyword)

  return hitwords

# キーワードに引っかかったチラシ取得
def get_target_flyers(jpg_list):
  result = []
  for jpg_info in jpg_list:
    all_text = detect_text(jpg_info['image_paths'])
    hitwords = search_words(all_text)

    if len(hitwords) != 0:
      hit = jpg_info
      hit['hitwords'] = hitwords
      result.append(hit)

  return result

# Slack通知
def slack_notice(results):
  import slackweb
  slack = slackweb.Slack(url=settings['slack_webhook_url'])
  for result in results:
    text = f'{result["date"]} チラシ掲載商品:{",".join(result["hitwords"])}\n<{result["url"]}|チラシを見る>'
    slack.notify(text=text)

### FlyerOCR ###
import shutil
import os
os.makedirs('pdf/', exist_ok=True)
os.makedirs('jpg/', exist_ok=True)

url_list = get_urls()
new_url_list = get_new_urls(url_list)
pdf_list = dl_pdfs(new_url_list)
jpg_list = pdfs_to_jpeg(pdf_list)
results = get_target_flyers(jpg_list)
slack_notice(results)

shutil.rmtree('pdf/')
shutil.rmtree('jpg/')

のコードを書き換える

text_detection

画像内のテキスト要素を検出するのに適しており、一般的なOCRタスクに使用される

これを
document_text_detection
を使い
文書のスキャンや複雑なレイアウトを持つ画像に対して適しており、より詳細なテキスト情報を取得できるようにする

# OCR
def detect_text(image_paths):
    from google.cloud import vision
    import io
    client = vision.ImageAnnotatorClient()

    all_text = ''

    for image_path in image_paths:
        with io.open(image_path, 'rb') as image_file:
            content = image_file.read()

        image = vision.Image(content=content)

        # document_text_detectionを使用
        response = client.document_text_detection(image=image)
        # FullTextAnnotationを使用して文書全体のテキストを取得
        full_text_annotation = response.full_text_annotation

        # テキストの抽出
        all_text += full_text_annotation.text

        if response.error.message:
            raise Exception(
                '{}\nFor more info on error messages, check: '
                'https://cloud.google.com/apis/design/errors'.format(
                    response.error.message))

    return all_text

とりあえず実行できるか試すので

# 例として実行
if __name__ == "__main__":
    image_paths = ["images/combined_image_20240802.jpg"]
    extracted_text = detect_text(image_paths)
    print(extracted_text)

この結果はそのままだとターミナル表示なので
テキストファイルに保存する

全文の中で検出成功しているのは
麻婆豆腐
キッチンタオル

全文は

8/
2
金曜日
本日限定!
とろける
バラエティパック
1000
T
スライスカード
サイズ
創品
創業祭特に
$10
ポイント
焼そば
うどん
$10
ポイント
129 168 159
プレーンヨーグル
ビヒダス
400
イチビキ
EE97
128
上級
ヒラス
-10
139円
創業祭特価
CRUNKY
Ghana
ON 161
198
213
30
128 98 9869
136
105円
645
8/2はおやつの日
BLACKENT
ADREN
おやつフェア カメ
強力小麦を
POTAH
198
64
191
78
B
カメリヤス
強力小麦粉」
298
321
ロリエ
きれいガード
ソフィ
$20
198
PARA 2139
税込74円]
かに
麻婆豆腐
168
8x4 パウダースプレー
88
181
ジョイコン
2
100-498
100 100
260 root
1,180
OPIE
ボディフィット
ガード
8
50
270
378
171
CARE
UF.O.
3
UFO
パワフル吸収
キッチンタオル
くらしりえね
ミネピア
「キッチンタオル
$149
10% 163
18 U
3759
8
300
$20
248
10272
$100
8398
ロール
ダブルメロン
38 萬
14952371
698
798
580
530
2.836
58
458
10 2.066
オフェルミン
2080
=.098
767
7=1080]
10 305P
TONA 415

となっているが
画像と重なっている文字は読み取りが苦手みたい
単純に解像度の問題かもしれないが

とりあえず
麻婆豆腐
キッチンタオル
はできたので
これをjsonファイルに書き込んでリストと一致するか実験する

settings.jsonの中身を

{
  "keywords": [  
    "麻婆豆腐",
    "キッチンタオル",
    "keyword3"
  ]
}

にする

次にキーワードと一致したもののみ変数に格納する
これをlineで送るようにする

とりあえずコードを変更

import json
settings_json = open('settings.json', 'r', encoding='utf_8')
settings = json.load(settings_json)

# OCR
def detect_text(image_paths):
    from google.cloud import vision
    import io
    client = vision.ImageAnnotatorClient()

    all_text = ''

    for image_path in image_paths:
        with io.open(image_path, 'rb') as image_file:
            content = image_file.read()

        image = vision.Image(content=content)

        # document_text_detectionを使用
        response = client.document_text_detection(image=image)
        # FullTextAnnotationを使用して文書全体のテキストを取得
        full_text_annotation = response.full_text_annotation

        # テキストの抽出
        all_text += full_text_annotation.text

        if response.error.message:
            raise Exception(
                '{}\nFor more info on error messages, check: '
                'https://cloud.google.com/apis/design/errors'.format(
                    response.error.message))

    return all_text

# キーワード検索
def search_words(all_text):
  hitwords = []
  for keyword in settings["keywords"]:
    if keyword in all_text:
      hitwords.append(keyword)

  return hitwords

# キーワードに引っかかったチラシ取得
def get_target_flyers(jpg_list):
  result = []
  for jpg_info in jpg_list:
    all_text = detect_text(jpg_info['image_paths'])
    hitwords = search_words(all_text)

    if len(hitwords) != 0:
      hit = jpg_info
      hit['hitwords'] = hitwords
      result.append(hit)

  return result


# 例として実行
if __name__ == "__main__":
    image_paths = ["images/combined_image_20240802.jpg"]
    extracted_text = detect_text(image_paths)
    print(extracted_text)

import json
from google.cloud import vision
import io

# 設定ファイルの読み込み
settings_json = open('settings.json', 'r', encoding='utf_8')
settings = json.load(settings_json)

# OCRで画像からテキストを抽出
def detect_text(image_paths):
    client = vision.ImageAnnotatorClient()

    all_text = ''

    for image_path in image_paths:
        with io.open(image_path, 'rb') as image_file:
            content = image_file.read()

        image = vision.Image(content=content)

        # document_text_detectionを使用して文書全体のテキストを取得
        response = client.document_text_detection(image=image)
        full_text_annotation = response.full_text_annotation

        # テキストの抽出
        all_text += full_text_annotation.text

        if response.error.message:
            raise Exception(
                '{}\nFor more info on error messages, check: '
                'https://cloud.google.com/apis/design/errors'.format(
                    response.error.message))

    return all_text

# キーワード検索
def search_words(all_text):
    hitwords = []
    for keyword in settings["keywords"]:
        if keyword in all_text:
            hitwords.append(keyword)

    return hitwords

# 例として実行
if __name__ == "__main__":
    image_paths = ["images/combined_image_20240802.jpg"]
    extracted_text = detect_text(image_paths)
    hitwords = search_words(extracted_text)
    
    # ヒットしたキーワードのみを表示
    if hitwords:
        print("マッチしたキーワード:", ", ".join(hitwords))
    else:
        print("マッチしたキーワードはありませんでした。")

に変えてみる

これで実行すると
マッチしたキーワード: 麻婆豆腐, キッチンタオル
となる

あとはキーワードにマッチした画像も一緒にLINEで送信したいので
ファイルパスを取得するようにする

そもそもの流れを復習すると
Gmailで最新のチラシのリンクを開く

日替のチラシがあるなら画像をダウンロードし統合して1つのファイルにする
clik_allget_image.py

OCRしてリストに一致しているものを取り出す
LINEで送信
line_notify.py

となっている

ただshufoo限定で店舗ごとにユニークアドレスとなっているのなら
Gmailから開く処理は不要となる

画像データの水増し

画像データの水増し

Yolov8で使うファイルの水増しをする

回転以外で行うことは

1. 反転(Flipping): 画像を水平または垂直に反転させることで、新たな画像を生成します。物体が画像中で異なる方向に存在する場合の学習が可能になります。
2. スケーリング(Scaling): 画像のサイズを変更します。これにより、モデルが異なる解像度の画像に対しても頑健になります。
3. クロッピング(Cropping): 画像から一部を切り取ることで、モデルが部分的な視覚情報からも物体を識別できるようになります。ランダムクロッピングは特に有効です。
4. 色調の変更(Color Modification): 色の明度、コントラスト、彩度を変化させることで、異なる照明条件下での物体の見え方に対する耐性を高めます。
5. ノイズの追加: 画像にランダムなノイズを加えることで、モデルがノイズに対して頑健になります。例えばガウシアンノイズなどがあります。
6. ぼかし(Blurring): ガウシアンぼかし、平均ぼかしなどを画像に適用し、モデルが解像度の低い画像や細部がぼやけた画像にも対応できるようにします。
7. 画像のエラスティック変形(Elastic Transformations): 画像を局所的に引き伸ばしたり圧縮したりすることで、自然界で発生するさまざまな変形に対応します。
8. ランダムイラジ(Jittering): 色彩や輝度などの小さなランダムな変更を加えて、画像の見た目を微妙に変化させます。

なのでこれらを行うpythonスクリプトを作成する

pip install Pillow numpy

で必要なライブラリのインストール

import os
import numpy as np
from PIL import Image, ImageEnhance, ImageOps
import random

def rotate_image(image, degrees):
    return image.rotate(degrees, expand=True)

def color_change(image):
    enhancer = ImageEnhance.Color(image)
    factor = random.uniform(0.5, 1.5)  # 色の強さを変更
    return enhancer.enhance(factor)

def flip_image(image):
    if random.choice([True, False]):
        return image.transpose(Image.FLIP_LEFT_RIGHT)
    else:
        return image

def scale_image(image):
    scale_factor = random.uniform(0.75, 1.25)
    width, height = image.size
    new_width = int(width * scale_factor)
    new_height = int(height * scale_factor)
    return image.resize((new_width, new_height), Image.ANTIALIAS)

def add_noise(image):
    # PIL Imageをnumpy arrayに変換
    array = np.asarray(image)
    noise = np.random.randint(0, 50, array.shape, dtype='uint8')
    # ノイズを加えた後、再びPIL Imageに変換
    image = Image.fromarray(np.clip(array + noise, 0, 255).astype('uint8'), 'RGB')
    return image

def process_images(directory):
    for filename in os.listdir(directory):
        filepath = os.path.join(directory, filename)
        if filepath.lower().endswith(('.png', '.jpg', '.jpeg')):
            with Image.open(filepath) as img:
                for angle in range(0, 360, 45):
                    new_img = rotate_image(img, angle)
                    new_img = color_change(new_img)
                    new_img = flip_image(new_img)
                    new_img = scale_image(new_img)
                    new_img = add_noise(new_img)
                    new_img.save(os.path.join(directory, f'{filename[:-4]}_rot{angle}_mod.png'))

# 画像が保存されているディレクトリを指定してください
directory_path = 'path_to_your_images'
process_images(directory_path)


これらを行うコード

これを

vim augment_images.py

で作成して保存

ちなみに

#ドライブをマウント
from google.colab import drive
drive.mount('/content/drive')

の後に

cd /content/drive/MyDrive/InventoryControl/daily_necessities/baskulin
ls

でバスクリン画像の一覧が表示された

ということはおそらく各種ファイルを転送し
その後にスクリプトで増やせばいけるはず

コードを修正しimageフォルダ内を対象としたが
これだと回転以外の要素も全て行っている
そうではなく
それぞれの処理をしたファイルを生成して欲しいので
コードを変更する

rm -f image/*

で一度ファイルを削除し

cp data_bak/Baskulin1.jpg image

でバックアップから復元

pip install opencv-python-headless scipy

そしてコードを

import os
import numpy as np
from PIL import Image, ImageEnhance, ImageOps, ImageFilter
import random
import cv2  # OpenCVを使用

def rotate_image(image, degrees, directory, filename):
    new_img = image.rotate(degrees, expand=True)
    new_img.save(os.path.join(directory, f'{filename[:-4]}_rot{degrees}.png'))

def flip_image(image, directory, filename):
    # 水平反転
    new_img = image.transpose(Image.FLIP_LEFT_RIGHT)
    new_img.save(os.path.join(directory, f'{filename[:-4]}_fliph.png'))
    # 垂直反転
    new_img = image.transpose(Image.FLIP_TOP_BOTTOM)
    new_img.save(os.path.join(directory, f'{filename[:-4]}_flipv.png'))

def scale_image(image, directory, filename):
    scale_factor = random.uniform(0.75, 1.25)
    new_width = int(image.width * scale_factor)
    new_height = int(image.height * scale_transform)
    new_img = image.resize((new_width, new_height), Image.ANTIALIAS)
    new_img.save(os.path.join(directory, f'{filename[:-4]}_scaled.png'))

def crop_image(image, directory, filename):
    start_x = random.randint(0, int(image.width * 0.1))
    start_y = random.randint(0, int(image.height * 0.1))
    end_x = start_x + int(image.width * 0.8)
    end_y = start_y + int(image.height * 0.8)
    new_img = image.crop((start_x, start_y, end_x, end_y))
    new_img.save(os.path.join(directory, f'{filename[:-4]}_cropped.png'))

def color_change(image, directory, filename):
    factor = random.uniform(0.5, 1.5)
    enhancer = ImageEnhance.Color(image)
    new_img = enhancer.enhance(factor)
    new_img.save(os.path.join(directory, f'{filename[:-4]}_colorchg.png'))

def add_noise(image, directory, filename):
    array = np.array(image)
    noise = np.random.normal(0, 25, image.size)
    new_img = Image.fromarray(np.clip(array + noise[:, :, None], 0, 255).astype('uint8'), 'RGB')
    new_img.save(os.path.join(directory, f'{filename[:-4]}_noise.png'))

def blur_image(image, directory, filename):
    new_img = image.filter(ImageFilter.GaussianBlur(radius=5))
    new_img.save(os.path.join(directory, f'{filename[:-4]}_blurred.png'))

def elastic_transform(image, directory, filename):
    alpha = image.width * 2
    sigma = image.width * 0.08
    random_state = np.random.RandomState(None)
    shape = image.size[::-1]
    dx = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    dy = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    x, y = np.meshgrid(np.arange(shape[0]), np.arange(shape[1]), indexing="ij")
    indices = np.reshape(x + dx, (-1, 1)), np.reshape(y + dy, (-1, 1))
    distored_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')
    new_img = Image.fromarray(distored_image.astype('uint8'), 'RGB')
    new_img.save(os.tmp.join(directory, f'{filename[:-4]}_elastic.png'))

def jitter_image(image, directory, filename):
    new_img = ImageEnhance.Brightness(image).enhance(random.uniform(0.5, 1.5))
    new_img = ImageEnhance.Contrast(new_img).enhance(random.uniform(0.5, 1.5))
    new_img.save(os.path.join(directory, f'{filename[:-4]}_jittered.png'))

def process_images(directory):
    for filename in os.listdir(directory):
        filepath = os.path.join(directory, filename)
        if filepath.lower().endswith(('.png', '.jpg', '.jpeg')):
            with Image.open(filepath) as img:
                for angle in [0, 45, 90, 135, 180, 225, 270, 315]:
                    rotate_image(img, angle, directory, filename)
                flip_image(img, directory, filename)
                scale_image(img, directory, filename)
                crop_image(img, directory, filename)
                color_change(img, directory, filename)
                add_noise(img, directory, filename)
                blur_image(img, directory, filename)
                elastic_transform(img, directory, filename)
                jitter_image(img, directory, filename)

directory_path = 'images'
process_images(directory_path)

へ変更し

python augment_images.py

で実行

しかし

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 85, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 76, in process_images
    scale_image(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 22, in scale_image
    new_height = int(image.height * scale_transform)
NameError: name 'scale_transform' is not defined

となる

となるため

def scale_image(image, directory, filename):
    scale_factor = random.uniform(0.75, 1.25)  # スケーリングファクターをランダムに選択
    new_width = int(image.width * scale_factor)  # 正しい変数名で幅を計算
    new_height = int(image.height * scale_factor)  # 正しい変数名で高さを計算
    new_img = image.resize((new_width, new_height), Image.ANTIALIAS)  # 画像をリサイズ
    new_img.save(os.path.join(directory, f'{filename[:-4]}_scaled.png'))  # 保存

にコードを変更

今度は

/Users/snowpool/aw10s/inventory/augment_images.py:23: DeprecationWarning: ANTIALIAS is deprecated and will be removed in Pillow 10 (2023-07-01). Use LANCZOS or Resampling.LANCZOS instead.
  new_img = image.resize((new_width, new_height), Image.ANTIALIAS)  # 画像をリサイズ
Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 86, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 82, in process_images
    elastic_transform(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 56, in elastic_transform
    dx = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
NameError: name 'gaussian_filter' is not defined

となる

再度コードを変更

1. ANTIALIAS を LANCZOS に修正する
Pillowライブラリで ANTIALIAS が非推奨になったため、これを LANCZOS に置き換えることで解決します。以下が修正後の scale_image 関数です:


def scale_image(image, directory, filename):
    scale_factor = random.uniform(0.75, 1.25)
    new_width = int(image.width * scale_factor)
    new_height = int(image.height * scale_factor)
    new_img = image.resize((new_width, new_height), Image.LANCZOS)  # LANCZOSを使用
    new_img.save(os.path.join(directory, f'{filename[:-4]}_scaled.png'))


gaussian_filter の未定義問題を解決する
gaussian_filter 関数が未定義というエラーを修正するために、scipy.ndimage から gaussian_filter をインポートする必要があります。また、map_coordinates 関数も同様にインポートする必要があります。以下が修正後の elastic_transform 関数と、必要なインポート文です

がGPTの答えだったので

from scipy.ndimage import gaussian_filter, map_coordinates

def elastic_transform(image, directory, filename):
    alpha = image.width * 2
    sigma = image.width * 0.08
    random_state = np.random.RandomState(None)
    shape = image.size[::-1]
    dx = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    dy = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    x, y = np.meshgrid(np.arange(shape[0]), np.arange(shape[1]), indexing="ij")
    indices = (np.reshape(x + dx, (-p, 1)), np.reshape(y + dy, (-1, 1)))
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')
    new_img = Image.fromarray(distorted_image.astype('uint8'), 'RGB')
    new_img.save(os.path.join(directory, f'{filename[:-4]}_elastic.png'))

これを実行したら

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 89, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 85, in process_images
    elastic_transform(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 62, in elastic_transform
    indices = (np.reshape(x + dx, (-p, 1)), np.reshape(y + dy, (-1, 1)))
NameError: name 'p' is not defined. Did you mean: 'np'?

となるのでこれを修正

このログでGPTで調べる

lastic_transform 関数内で未定義の変数 p を使用していることがわかります。これはおそらく -1 を意図していたものと思われます。さらに、indices の設定に関するエラーも修正する必要があります。これを修正して、適切なインデックスの形状に合わせましょう。
以下は修正後の elastic_transform 関数
from scipy.ndimage import gaussian_filter, map_coordinates

def elastic_transform(image, directory, filename):
    alpha = image.width * 2
    sigma = image.width * 0.08
    random_state = np.random.RandomState(None)
    shape = image.size[::-1]
    dx = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    dy = gaussian_filter((random_yes.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    x, y = np.meshgrid(np.arange(shape[0]), np.arange(shape[1]), indexing="ij")
    indices = np.reshape(np.array([x + dx, y + dy]), (2, -1))
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')
    new_img = Image.fromarray(distorted_image.astype('uint8'), 'RGB')
    new_img.save(os.path.join(directory, f'{filename[:-4]}_elastic.png'))

今度は

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 90, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 84, in process_images
    add_noise(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 44, in add_noise
    new_img = Image.fromarray(np.clip(array + noise[:, :, None], 0, 255).astype('uint8'), 'RGB')
ValueError: operands could not be broadcast together with shapes (4080,3072,3) (3072,4080,1) 

というエラーになるため
コードを

def add_noise(image, directory, filename):
    array = np.array(image)
    # 正しい形状のノイズを生成
    noise = np.random.normal(0, 25, array.shape)
    # ノイズを加えた画像を作成
    noisy_array = np.clip(array + noise, 0, 255).astype('uint8')
    new_img = Image.fromarray(noisy_array, 'RGB')
    new_img.save(os.path.join(directory, f'{filename[:-4]}_noise.png'))

へ変更

このエラーは、add_noise 関数内で、ノイズを加えようとした際に配列の形状が一致しないため発生しています。具体的には、画像データ array と生成したノイズ noise の形状が異なっています

再度実行すると

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 94, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 90, in process_images
    elastic_transform(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 64, in elastic_transform
    dy = gaussian_filter((random_yes.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
NameError: name 'random_yes' is not defined

となる

これは単純にタイプミスで
random_yes を random_state に修正

今度は

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 94, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 90, in process_images
    elastic_transform(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 67, in elastic_transform
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/scipy/ndimage/_interpolation.py", line 440, in map_coordinates
    raise RuntimeError('invalid shape for coordinate array')
RuntimeError: invalid shape for coordinate array

となる

from scipy.ndimage import gaussian_filter, map_coordinates

def elastic_transform(image, directory, filename):
    alpha = image.width * 2
    sigma = image.width * 0.08
    random_state = np.random.RandomState(None)
    shape = image.size[::-1]  # width, height

    # dx, dy を生成
    dx = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    dy = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha

    x, y = np.meshgrid(np.arange(shape[0]), np.arange(shape[1]), indexing="ij")
    
    # 正しい形状でインデックスを作成
    indices = np.vstack((x.ravel() + dx.ravel(), y.ravel() + dy.ravel()))  # 2xN の形状

    # 座標変換を適用
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')

    # 新しい画像を生成
    new_img = Image.fromarray(distorted_image.reshape(image.size).astype('uint8'), 'RGB')
    new_img.save(os.path.join(directory, f'{filename[:-4]}_elastic.png'))

というように修正

indices の配列を2行(x座標、y座標)の形状に整形しています。np.vstack を使用して2つの1次元配列を縦に積み重ね、期待される形状を作成しています。
distorted_image の生成時に reshape を適用して画像の元の形状を復元しています。

なお画像認識で文字の反転は不要なので削除する

# flip_image(img, directory, filename) # この行を削除
として、flip_image 関数自体を削除するのではなく、その呼び出しをコメントアウトまたは削除しています。将来的に反転が必要になった場合に簡単に再導入できるようにするため

今度は

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 104, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 100, in process_images
    elastic_transform(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 75, in elastic_transform
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/scipy/ndimage/_interpolation.py", line 440, in map_coordinates
    raise RuntimeError('invalid shape for coordinate array')
RuntimeError: invalid shape for coordinate array

となる

なので変更

重要な修正点
* np.meshgrid を使用して x, y の座標を生成する際、indexing='ij' の指定を取り除き、順序を逆にして np.arange(shape[1]), np.arange(shape[0]) としています。これにより、x と y の座標を正しく取得します。
* indices の形状を (2, M) に正しく設定しています。vstack で縦に積み重ね、ピクセルの y 座標と x 座標を合わせています。

とのこと

from scipy.ndimage import gaussian_filter, map_coordinates

def elastic_transform(image, directory, filename):
    alpha = image.width * 2
    sigma = image.width * 0.08
    random_state = np.random.RandomState(None)
    shape = image.size[::-1]  # (width, height)

    # dx, dy を生成
    dx = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    dy = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha

    x, y = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))  # (height, width) 形式で生成されたグリッド
    indices = np.vstack((y.ravel() + dy.ravel(), x.ravel() + dx.ravel()))  # y座標、x座標の順に並べる

    # 座標変換を適用
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')
    new_img = Image.fromarray(distorted_image.astype('uint8'), 'RGB')
    new_img.save(os.path.join(directory, f'{filename[:-4]}_elastic.png'))

へ変更し保存

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 98, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 94, in process_images
    elastic_transform(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 71, in elastic_transform
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/scipy/ndimage/_interpolation.py", line 440, in map_coordinates
    raise RuntimeError('invalid shape for coordinate array')
RuntimeError: invalid shape for coordinate array

となる

from scipy.ndimage import gaussian_filter, map_coordinates
import numpy as np
from PIL import Image

def elastic_transform(image, directory, filename):
    alpha = image.width * 2  # 変形の強さを設定
    sigma = image.width * 0.08  # ガウスフィルタの標準偏差を設定
    random_state = np.random.RandomState(None)
    shape = image.size  # PIL Imageのsizeは(width, height)

    # ランダム変位を生成
    dx = gaussian_filter((random_state.rand(*shape[::-1]) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    dy = gaussian_filter((random_state.rand(*shape[::-1]) * 2 - 1), sigma, mode="constant", cval=0) * alpha

    # グリッドを生成
    x, y = np.meshgrid(np.arange(shape[0]), np.arange(shape[1]), indexing='ij')
    indices = np.vstack((y.flatten() + dy.flatten(), x.flatten() + dx.flatten()))  # 2xNの形状

    # 座標変換を適用
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')

    # 新しい画像を作成
    new_img = Image.fromarray(distorted_image.reshape(shape[::-1]).astype('uint8'), 'RGB')
    new_img.save(os.path.join(directory, f'{filename[:-4]}_elastic.png'))

へコード変更

shape = image.sizeで取得したサイズ(width, height)は、numpyの操作に合わせてshape[::-1]((height, width))を使っています。
indices配列を生成する際、np.vstackを使ってy座標とx座標を適切に並べて(2, M)形状にしています。
map_coordinatesの引数に渡すindicesが(2, M)形状であることを確認してください。この形状が要求されるため、それに従っています。

今度は

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 103, in <module>
    process_images(directory_path)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 99, in process_images
    elastic_transform(img, directory, filename)
  File "/Users/snowpool/aw10s/inventory/augment_images.py", line 74, in elastic_transform
    distorted_image = map_coordinates(np.array(image), indices, order=1, mode='reflect')
  File "/Users/snowpool/.pyenv/versions/3.10.6/lib/python3.10/site-packages/scipy/ndimage/_interpolation.py", line 440, in map_coordinates
    raise RuntimeError('invalid shape for coordinate array')
RuntimeError: invalid shape for coordinate array

今度は

from scipy.ndimage import gaussian_filter, map_coordinates
import numpy as np
from PIL import Image

def elastic_transform(image, directory, filename):
    alpha = image.width * 2  # 変形の強さを設定
    sigma = image.width * 0.08  # ガウスフィルタの標準偏差を設定
    random_state = np.random.RandomState(None)
    shape = image.size[::-1]  # PIL Imageのsizeは(width, height)、numpyでのshapeは(height, width)

    # ランダム変位を生成
    dx = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha
    dy = gaussian_filter((random_state.rand(*shape) * 2 - 1), sigma, mode="constant", cval=0) * alpha

    # グリッドを生成
    x, y = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))  # (height, width) 形式で生成されたグリッド
    indices = np.vstack((x.flatten() + dx.flatten(), y.flatten() + dy.flatten()))  # x座標、y座標の順に並べる

    # 座標変換を適用
    distorted_array = map_coordinates(np.array(image), indices, order=1, mode='reflect')
    distorted_image = Image.fromarray(distorted_array.reshape(shape).astype('uint8'), 'RGB')
    distorted_image.save(os.path.join(directory, f'{filename[:-4]}_elastic.png'))

indicesの生成を再確認し、正しい形状(2, M)が生成されていることを保証します。
map_coordinatesへの入力としてindicesが(2, M)形状であることを再確認します。これにはx.flatten()とy.flatten()を正しく配置しています。

何度やっても画像のエラスティック変形でエラーになるので
機能を削除

import os
import numpy as np
from PIL import Image, ImageEnhance, ImageOps, ImageFilter
import random

def rotate_image(image, degrees, directory, filename):
    new_img = image.rotate(degrees, expand=True)
    new_img.save(os.path.join(directory, f'{filename[:-4]}_rot{degrees}.png'))

def scale_image(image, directory, filename):
    scale_factor = random.uniform(0.75, 1.25)
    new_width = int(image.width * scale_factor)
    new_height = int(image.height * scale_factor)
    new_img = image.resize((new_width, new_height), Image.LANCZOS)
    new_img.save(os.path.join(directory, f'{filename[:-4]}_scaled.png'))

def crop_image(image, directory, filename):
    start_x = random.randint(0, int(image.width * 0.1))
    start_y = random.randint(0, int(image.height * 0.1))
    end_x = start_x + int(image.width * 0.8)
    end_y = start_y + int(image.height * 0.8)
    new_img = image.crop((start_x, start_y, end_x, end_y))
    new_img.save(os.path.join(directory, f'{filename[:-4]}_cropped.png'))

def color_change(image, directory, filename):
    factor = random.uniform(0.5, 1.5)
    enhancer = ImageEnhance.Color(image)
    new_img = enhancer.enhance(factor)
    new_img.save(os.path.join(directory, f'{filename[:-4]}_colorchg.png'))

def add_noise(image, directory, filename):
    img_array = np.array(image)
    noise = np.random.normal(0, 25, img_array.shape)
    noisy_array = np.clip(img_array + noise, 0, 255).astype('uint8')
    new_img = Image.fromarray(noisy_array, 'RGB')
    new_img.save(os.path.join(directory, f'{filename[:-4]}_noise.png'))

def blur_image(image, directory, filename):
    new_img = image.filter(ImageFilter.GaussianBlur(radius=5))
    new_img.save(os.path.join(directory, f'{filename[:-4]}_blurred.png'))

def process_images(directory):
    for filename in os.listdir(directory):
        filepath = os.path.join(directory, filename)
        if filepath.lower().endswith(('.png', '.jpg', '.jpeg')):
            with Image.open(filepath) as img:
                for angle in [0, 45, 90, 135, 180, 225, 270, 315]:
                    rotate_image(img, angle, directory, filename)
                scale_image(img, directory, filename)
                crop_image(img, directory, filename)
                color_change(img, directory, filename)
                add_noise(img, directory, filename)
                blur_image(img, directory, filename)
                # elastic_transform 呼び出しを削除しました

# ディレクトリパスを指定
directory_path = 'images'
process_images(directory_path)

これでようやく成功
元画像に対し12の画像が作成される

とりあえず成功したので

cp data_bak/Baskulin*.jpg image 

でバックアップから画像を戻し

python augment_images.py

で画像を増やす

roboflow Workspaceでアノテーションその1

roboflow Workspaceでアノテーション

https://axross-recipe.com/recipes/1469
を参考に行う

https://blog.roboflow.com/train-yolov8-obb-model/
のチュートリアルも参考にする

https://blog.roboflow.com
へアクセスし
GoogleIDなどで、Sign InすればOK

その場合
Continue with Google をクリック

ユーザ名を入力すると
プランが2つ出る
Free の無料か
Starter traialの月額249$
のどっちかになるので

とりあえずはFree にする

これでCreate workspace をクリック

次に
Invite teammates.
Add collaborators to help with labeling, upload data, train models, and more.
チームメイトを招待します。
ラベル付け、データのアップロード、モデルのトレーニングなどを支援するコラボレーターを追加します。

とあるけど
とりあえずSkipでOK

これでプロジェクトの作成画面になるが
デフォルトだとライセンスが
CC BY 4.0
になっている

Project Name は
anotation_test
とした

Annotation Group
注釈グループ
が識別のため必要らしい

とりあえずバスクリンなので
Baskulin

Project Typeには
Object Detection
を選択

これで
Create Project をクリック

次に画像ファイル
もしくは画像フォルダを選択する

ここでバスクリンを撮影したフォルダを指定するが
せっかくなので
画像の水増しをする

まず
Pixcel 8 で撮影したバスクリンの画像を
Google Photo からダウンロード

4枚の写真になっているので
これを水増しする

なお複数の写真をダウンロードすると
圧縮ファイルになっているのでこれを解凍する

vim generate_images.py

で内容を

import sys
import os
from PIL import Image

# コマンドライン引数から画像ファイル名を取得
if len(sys.argv) != 2:
    print("Usage: python generate_images.py imagefile.png")
    sys.exit(1)

image_file = sys.argv[1]

# 画像を読み込む
try:
    image = Image.open(image_file)
except IOError:
    print(f"Could not open the image file {image_file}")
    sys.exit(1)

# ファイル名と拡張子を分離し、ディレクトリ名を決定
file_name, file_extension = os.path.splitext(os.path.basename(image_file))
directory_name = file_name

# ディレクトリが存在しない場合は作成
if not os.path.exists(directory_name):
    os.makedirs(directory_name)

# 画像の変形と保存を行う関数
def save_images(image, prefix, transform, count=100):
    for i in range(count):
        filename = f'{prefix}{i+1:03}{file_extension}'
        filepath = os.path.join(directory_name, filename)
        transformed_image = image.transpose(transform)
        transformed_image.save(filepath)

# 各変換を適用して画像を保存
save_images(image, f'{file_name}_', Image.FLIP_TOP_BOTTOM, 100)
save_images(image, f'{file_name}_rot90_', Image.ROTATE_90, 100)
save_images(image, f'{file_name}_rot270_', Image.ROTATE_270, 100)

という画像を回転させてコピーするスクリプトを作成

次に写真をコピーしておく

cp ~/Downloads/Photos-001\ \(1\)/PXL_20240504_1732* .

ファイル名を変えた方が楽なので
ターミナルで

i=1
for file in PXL_20240504_173232354.jpg PXL_20240504_173242547.jpg PXL_20240504_173237123.jpg PXL_20240504_173253414.jpg; do
  mv "$file" "basclin$i.jpg"
  i=$((i + 1))
done

を実行

すると

basclin1.jpg
basclin2.jpg
basclin3.jpg
basclin4.jpg

というようにファイル名が変わる

よくみたらスペルミスなので

i=1
for file in basclin*.jpg; do
  mv "$file" "Baskulin$i.jpg"
  i=$((i + 1))
done

で修正

これでファイル名が

Baskulin1.jpg
Baskulin2.jpg
Baskulin3.jpg
Baskulin4.jpg

となったので

python generate_images.py Baskulin1.jpg
python generate_images.py Baskulin2.jpg
python generate_images.py Baskulin3.jpg
python generate_images.py Baskulin4.jpg

でファイルを量産

これで各ファイルごとのフォルダができたので
これを1つのフォルダにまとめる

ls -d */ | grep Bas


Basと書かれたディレクトリのみ表示できるので

move_files.sh

というスクリプトを作成

#!/bin/bash

# 移動先のディレクトリを作成(存在しない場合)
mkdir -p baskulin

# Baskulinで始まる全ディレクトリのファイルを baskulin ディレクトリに移動
for dir in Baskulin*/; do
    # ディレクトリ内のファイルを baskulin に移動
    mv "$dir"* baskulin/
done

echo "All files have been moved to the 'baskulin' directory."

として保存

chmod +x move_files.sh

で実行権限付与

./move_files.sh

で実行すれば全てのファイルが
1つのフォルダに移動される

そしてこのフォルダをアップロード

これでsave and continueをクリック

なお100枚以上に増やしたけど
同じ画像の場合は全て1つのものとしてみなすため
意味がなかった

回転させたりした場合は
異なる画像としてカウントされる

これでデータがアップできたので
次はアノテーション

メール読み上げを設定ファイルからに変更

メール読み上げを設定ファイルからに変更

config.ini

を作成し

[Settings]
server_ip = 192.168.1.69:50021
label_id = GmailのラベルID
detection_interval = 1200
notification_sound = notice.wav
pdf_notification_sound = notice_pdf.wav

とする

configparser モジュールを使用して、INIファイルから設定を読みこむ

kao.py

import configparser

を追加

# configparserを使用してINIファイルを読み込む
config = configparser.ConfigParser()
config.read('config.ini')

# 設定を変数に格納
server_ip = config['Settings']['server_ip']
label_id = config['Settings']['label_id']
detection_interval = int(config['Settings']['detection_interval'])

        if lastTime is None or time.perf_counter() - lastTime > 1200:
を
        if lastTime is None or time.perf_counter() - lastTime > detection_interval:

というように設定値の定数へ変更

次にサーバーIPも設定ファイルから読み込むように変更する

email_processor,py

のコードを変更

import configparser

# 設定ファイルの読み込み
config = configparser.ConfigParser()
config.read('config.ini')
server_ip = config['Settings']['server_ip']
label_id = config['Settings']['label_id']
notification_sound = config['Settings']['notification_sound']
pdf_notification_sound = config['Settings']['pdf_notification_sound']

を追記

音声を生成する部部の

    command_json = [
        "curl", "-s", "-X", "POST",
        "192.168.1.69:50021/audio_query?speaker=1",
        "--get", "--data-urlencode", f"text={text}"
    ]
    command_audio = [
        "curl", "-s", "-H", "Content-Type: application/json", "-X", "POST",
        "-d", "@query.json", "192.168.1.69:50021/synthesis?speaker=1"
    ]
でサーバーIPの部分を
    command_json = [
        "curl", "-s", "-X", "POST",
        f"http://{server_ip}/audio_query?speaker=1",
        "--get", "--data-urlencode", f"text={text}"
    ]
    command_audio = [
        "curl", "-s", "-H", "Content-Type: application/json", "-X", "POST",
        "-d", "@query.json", f"http://{server_ip}/synthesis?speaker=1"
    ]

へ変更

また再生する音声を

        playsound('notice.wav')

から

playsound(notification_sound)

へ変更

                        playsound('notice_pdf.wav')

                        playsound(pdf_notification_sound)

へ変更

これで
Voicevox のdocker サーバーIP
お知らせの音声
顔認識してメールを読み上げる時間の指定
Gmailで読み上げるラベルの指定
を設定ファイルで行えるように変更完了

あとは
GithubへSSHでアップできるようにする

顔を認識したらメールを読み上げる

顔を認識したらメールを読み上げる

import fitz
from playsound import playsound
import subprocess
import pygame
import time

# テキストから音声を生成して再生する関数
def generate_and_play_audio_from_text(text):
    command_json = [
        "curl", "-s", "-X", "POST",
        "192.168.1.69:50021/audio_query?speaker=1",
        "--get", "--data-urlencode", f"text={text}"
    ]
    command_audio = [
        "curl", "-s", "-H", "Content-Type: application/json", "-X", "POST",
        "-d", "@query.json", "192.168.1.69:50021/synthesis?speaker=1"
    ]
    with open('query.json', 'w') as file:
        subprocess.run(command_json, stdout=file)
    with open('audio_output.wav', 'wb') as file:
        subprocess.run(command_audio, stdout=file)
    pygame.init()
    pygame.mixer.init()
    sound = pygame.mixer.Sound("audio_output.wav")
    sound.play()
    while pygame.mixer.get_busy():
        time.sleep(0.1)

# PDFファイルから文字数をカウントする関数
def count_pdf_characters(file_path):
    doc = fitz.open(file_path)
    text = ""
    for page in doc:
        text += page.get_text()
    return len(text)

# メールの処理を行う関数
def process_email(service, label_id):
    from gmail_utils import gmail_get_latest_unread_message_body
    from pdf_downloader import find_preview_link, download_pdf

    body, urls = gmail_get_latest_unread_message_body(service, label_id)
    if body:
        playsound('notice.wav')
        generate_and_play_audio_from_text(body)
        if urls:
            for url in urls:
                preview_url = find_preview_link(url)
                if preview_url:
                    download_pdf(preview_url, "downloaded_file.pdf")
                    char_count = count_pdf_characters("downloaded_file.pdf")
                    if char_count >= 100:
                        playsound('notice_pdf.wav')
                else:
                    print("プレビューリンクが見つかりませんでした。")
        else:
            print("メールにURLが見つかりませんでした。")
    else:
        print("未読メールはありません。")

というようにモジュールにして保存
ファイル名は

email_processor.py

とした

次に
Kao,pyで顔認識したらメールを読み上げるようにする

git clone https://github.com/Snowpooll/face_weather.git

で以前作成した顔認識したら天気を知らせるの中にあるものを

cp ../../face_weather/haarcascade_* .

でコピーして使う

import cv2
import time
from email_processor import process_email
from gmail_utils import gmail_init

# Haar Cascade分類器の読み込み
face_cascade = cv2.CascadeClassifier('haarcascade_frontalface_default.xml')

# Webカメラの設定
cap = cv2.VideoCapture(0)  # 0番目のカメラを使用する場合

# Gmailサービスの初期化
service = gmail_init()
label_id = "ラベルID"  # 例として特定のラベルIDを設定

# 最後の顔検出時刻
lastTime = None

# メインループ
while True:
    # カメラからのフレームの取得
    ret, frame = cap.read()
    
    # フレームのグレースケール化
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    
    # 顔の検出
    faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
    
    # 検出された顔に対する処理
    for (x, y, w, h) in faces:
        # 検出自の処理(検出から1分たったら再度イベント動かす)
        if lastTime is None or time.perf_counter() - lastTime > 60:
            # 検出時刻更新
            lastTime = time.perf_counter()
            # メール処理関数を呼び出し
            process_email(service, label_id)

# 後処理
cap.release()
cv2.destroyAllWindows()

で実行したら成功したが
間隔を一分にしたため
メールばかり読み上げになる

なので感覚を20分ぐらいに変更する

        # 検出自の処理(検出から20分たったら再度イベント動かす)
        if lastTime is None or time.perf_counter() - lastTime > 1200:

へ変更した

とりあえず動作確認は取れたので
次に設定ファイルを作成し
コードのメンテをしやすくする

とりあえず
docker サーバーIP
gmailのラベル
次の検出までの待ち時間
は設定ファイルにまとめておき
これを編集するだけでできるようにする

ダウンロード機能の修正とモジュール化

ダウンロード機能の修正とモジュール化

import fitz  # PyMuPDF
from playsound import playsound
from gmail_utils import gmail_init, gmail_get_latest_unread_message_body
from pdf_downloader import find_preview_link, download_pdf

def save_email_body_to_text(body, filename="email_body.txt"):
    with open(filename, "w", encoding="utf-8") as file:
        file.write(body)

def count_pdf_characters(file_path):
    doc = fitz.open(file_path)
    text = ""
    for page in doc:
        text += page.get_text()
    return len(text)

def main():
    # Gmail API サービスを初期化
    service = gmail_init()
    
    # ラベル ID を指定して最新の未読メール本文とURLを取得
    body, urls = gmail_get_latest_unread_message_body(service, "ラベルID")
    
    # 未読メールがある場合は音声ファイルを再生
    if body:
        playsound('notice.wav')

        if urls:
            for url in urls:
                print(f"プレビューリンクを検索するURL: {url}")
                # プレビューリンクを取得
                preview_url = find_preview_link(url)
                if preview_url:
                    print(f"プレビューリンク: {preview_url}")
                    # プレビューリンクからPDFファイルのダウンロードを試みる
                    download_pdf(preview_url, file_path="downloaded_file.pdf")
                    
                    # PDFファイルから文字数をカウント
                    char_count = count_pdf_characters("downloaded_file.pdf")
                    print(f"PDF内の文字数: {char_count}")
                    
                    # 文字数が100文字以上の場合は別の音声ファイルを再生
                    if char_count >= 100:
                        playsound('notice_pdf.wav')

                else:
                    print("プレビューリンクが見つかりませんでした。")
        else:
            print("メールにURLが見つかりませんでした。")

        # メール本文をテキストファイルに保存
        save_email_body_to_text(body)
    else:
        print("未読メールはありません。")

if __name__ == "__main__":
    main()

としたらリンクがエラーになる

from gmail_utils import gmail_init, gmail_get_latest_unread_message_body
from pdf_downloader import find_preview_link, download_pdf

def save_email_body_to_text(body, filename="email_body.txt"):
    with open(filename, "w", encoding="utf-8") as file:
        file.write(body)

def main():
    # Gmail API サービスを初期化
    service = gmail_init()
    
    # ラベル ID を指定して最新の未読メール本文とURLを取得
    body, urls = gmail_get_latest_unread_message_body(service, "ラベルID")
    if urls:
        for url in urls:
            print(f"プレビューリンクを検索するURL: {url}")
            # プレビューリンクを取得
            preview_url = find_preview_link(url)
            if preview_url:
                print(f"プレビューリンク: {preview_url}")
                # プレビューリンクからPDFファイルのダウンロードを試みる
                download_pdf(preview_url, file_path="downloaded_file.pdf")
            else:
                print("プレビューリンクが見つかりませんでした。")
    else:
        print("メールにURLが見つかりませんでした。")

    # メール本文をテキストファイルに保存
    save_email_body_to_text(body)

if __name__ == "__main__":
    main()

だと問題ない

とりあえず

import fitz  # PyMuPDF
from playsound import playsound
from gmail_utils import gmail_init, gmail_get_latest_unread_message_body
from pdf_downloader import find_preview_link, download_pdf
import subprocess
import pygame
import time

def generate_and_play_audio_from_text(text):
    # JSONファイルを作成するためのcurlコマンド
    command_json = [
        "curl", "-s", "-X", "POST",
        "192.168.1.69:50021/audio_query?speaker=1",
        "--get", "--data-urlencode", f"text={text}"
    ]

    # 音声ファイルを作成するためのcurlコマンド
    command_audio = [
        "curl", "-s", "-H", "Content-Type: application/json", "-X", "POST",
        "-d", "@query.json", "192.168.1.69:50021/synthesis?speaker=1"
    ]

    # JSONファイルと音声ファイルを作成
    with open('query.json', 'w') as file:
        subprocess.run(command_json, stdout=file)
    with open('audio_output.wav', 'wb') as file:
        subprocess.run(command_audio, stdout=file)

    # Pygameで音声ファイルを再生
    pygame.init()
    pygame.mixer.init()
    sound = pygame.mixer.Sound("audio_output.wav")
    sound.play()
    while pygame.mixer.get_busy():
        time.sleep(0.1)

def count_pdf_characters(file_path):
    doc = fitz.open(file_path)
    text = ""
    for page in doc:
        text += page.get_text()
    return len(text)

def main():
    # Gmail API サービスを初期化
    service = gmail_init()
    
    # ラベル ID を指定して最新の未読メール本文とURLを取得
    body, urls = gmail_get_latest_unread_message_body(service, "ラベルID")
    
    # 未読メールがある場合は音声ファイルを再生
    if body:
        playsound('notice.wav')
        generate_and_play_audio_from_text(body)
        

        if urls:
            for url in urls:
                print(f"プレビューリンクを検索するURL: {url}")
                # プレビューリンクを取得
                preview_url = find_preview_link(url)
                if preview_url:
                    print(f"プレビューリンク: {preview_url}")
                    # プレビューリンクからPDFファイルのダウンロードを試みる
                    download_pdf(preview_url, file_path="downloaded_file.pdf")
                    
                    # PDFファイルから文字数をカウント
                    char_count = count_pdf_characters("downloaded_file.pdf")
                    print(f"PDF内の文字数: {char_count}")
                    
                    # 文字数が100文字以上の場合は別の音声ファイルを再生
                    if char_count >= 100:
                        playsound('notice_pdf.wav')

                else:
                    print("プレビューリンクが見つかりませんでした。")
        else:
            print("メールにURLが見つかりませんでした。")
    else:
        print("未読メールはありません。")

if __name__ == "__main__":
    main()

とすることで
メールの読み上げと
PDFのダウンロードができた
また長文のPDFに関してはPDFを見るように促すようにした

あとは顔を認識したら起動するようにする

まずはこれをモジュールにする

import fitz
from playsound import playsound
import subprocess
import pygame
import time

# テキストから音声を生成して再生する関数
def generate_and_play_audio_from_text(text):
    command_json = [
        "curl", "-s", "-X", "POST",
        "192.168.1.69:50021/audio_query?speaker=1",
        "--get", "--data-urlencode", f"text={text}"
    ]
    command_audio = [
        "curl", "-s", "-H", "Content-Type: application/json", "-X", "POST",
        "-d", "@query.json", "192.168.1.69:50021/synthesis?speaker=1"
    ]
    with open('query.json', 'w') as file:
        subprocess.run(command_json, stdout=file)
    with open('audio_output.wav', 'wb') as file:
        subprocess.run(command_audio, stdout=file)
    pygame.init()
    pygame.mixer.init()
    sound = pygame.mixer.Sound("audio_output.wav")
    sound.play()
    while pygame.mixer.get_busy():
        time.sleep(0.1)

# PDFファイルから文字数をカウントする関数
def count_pdf_characters(file_path):
    doc = fitz.open(file_path)
    text = ""
    for page in doc:
        text += page.get_text()
    return len(text)

# メールの処理を行う関数
def process_email(service, label_id):
    from gmail_utils import gmail_get_latest_unread_message_body
    from pdf_downloader import find_preview_link, download_pdf

    body, urls = gmail_get_latest_unread_message_body(service, label_id)
    if body:
        playsound('notice.wav')
        generate_and_play_audio_from_text(body)
        if urls:
            for url in urls:
                preview_url = find_preview_link(url)
                if preview_url:
                    download_pdf(preview_url, "downloaded_file.pdf")
                    char_count = count_pdf_characters("downloaded_file.pdf")
                    if char_count >= 100:
                        playsound('notice_pdf.wav')
                else:
                    print("プレビューリンクが見つかりませんでした。")
        else:
            print("メールにURLが見つかりませんでした。")
    else:
        print("未読メールはありません。")

として

email_processor.py

として保存

次に顔を検出したらこれを呼び出すようにする