大宮盆栽デイズ - Omiya Bonsai Days -

冗談めかす埼玉のファインマン

(!=盆栽)【改訂版】macOS「写真」のキャプションを Ollama & Llama 3.2-Vision で作成

macOS のアプリ「写真」の機能である、「キャプション(写真説明)」を使っていませんでした。今回、Ollama と、Llama 3.2-Vision を用いたローカルLLM で自動生成する、Python スクリプトを作成しましたので、ペーストします。スクリプトは生成AI とともに作成しました。また、@GOROman さん、そして、@7shi さんの X 投稿を参考にさせていただきました。ありがとうございます。



注意点

既にキャプションを作成していた場合、上書きされてオリジナルが消失します。気をつけてください既にキャプションがある場合、処理をスキップするようにしました。

サンプル

ある日の天ぷらの写真。

アプリ「ターミナル」で表示される、キャプション情報。「警告」も表示されますが、あまり気にしなくて良いでしょう。

アプリ「写真」のキャプション情報。

マシンスペック


mac Mini M2 16GB

負荷の具合

Load Average.どうも、処理をさせていると、Google日本語入力の変換に遅延が発生します。

感想

かなり便利。一枚の画像ファイルのキャプション付にだいたい 2~3分間かかります。わたしの「写真」には二万枚ほどの画像ファイルがあるので、最低でも、四万分間必要で、これはだいたい四週間ぐらいなようです。一ヶ月ぐらいか。知りたい写真をキーワード検索できるようになるはずなので、きっと便利さが増すと思います。github へのアップロードの方法を充分にわからないので、こちらの「はてな」でアップしました。

実践

実際に行います。ollama はインストールしておいてください。llama3.2-vision と、aya-expanse を pull します。

ollama pull llama3.2-vision
ollama pull aya-expanse

macOS「写真」を取り扱うときに使うようです。

pip install photoscript

以下、python スクリプト「photo_caption_generator.py」

# 必要なライブラリをインポート
import ollama  # AIモデルを使用するためのライブラリ
import os  # ファイルシステム操作のためのライブラリ
import shutil  # 高水準のファイル操作のためのライブラリ
import io  # 入出力操作のためのライブラリ
from PIL import Image  # 画像処理のためのライブラリ
from photoscript import PhotosLibrary  # macOSの「写真」アプリを操作するためのライブラリ
import time  # 時間関連の操作のためのライブラリ
from typing import Optional  # 型ヒントのためのライブラリ

# 定数の定義
TEMP_DIR = "/tmp/photo_exports"  # 一時ファイルを保存するディレクトリ
VISION_MODEL = "llama3.2-vision"  # 画像認識用のAIモデル名
TRANSLATION_MODEL = "aya-expanse"  # 翻訳用のAIモデル名
MAX_CAPTION_BYTES = 500  # キャプションの最大バイト数(全角約250文字分)
MAX_RETRIES = 3  # 最大リトライ回数
RETRY_DELAY = 5  # リトライ間の待機時間(秒)

# 一時ディレクトリが存在しない場合は作成
os.makedirs(TEMP_DIR, exist_ok=True)

def generate_caption(image_path: str) -> str:
    """
    指定された画像のキャプションを英語で生成します。

    Args:
        image_path (str): 画像ファイルのパス

    Returns:
        str: 生成された英語のキャプション
    """
    # 画像をバイトデータとして読み込む
    with open(image_path, "rb") as image_file:
        image_data = image_file.read()
    
    # PILを使用して画像を開き、JPEGとして保存し直す
    # これは、画像形式を統一し、AIモデルへの入力を標準化するために行います
    image = Image.open(io.BytesIO(image_data))
    buffer = io.BytesIO()
    image.save(buffer, format="JPEG")
    image_bytes = buffer.getvalue()

    # ollamaのAIモデルを使用して画像の内容を説明するキャプションを生成
    response = ollama.chat(
        model=VISION_MODEL,
        messages=[{
            "role": "user",
            "content": "Briefly describe the content of this image.",
            "images": [image_bytes],
        }],
    )
    # 生成されたキャプションを返す
    return response["message"]["content"]

def translate_to_japanese(text: str) -> str:
    """
    指定されたテキストを日本語に翻訳します。

    Args:
        text (str): 翻訳する英語のテキスト

    Returns:
        str: 日本語に翻訳されたテキスト
    """
    # ollamaの翻訳モデルを使用して英語のテキストを日本語に翻訳
    response = ollama.chat(
        model=TRANSLATION_MODEL,
        messages=[{
            "role": "user",
            "content": f"以下の文を日本語に簡潔に翻訳してください:\n{text}",
        }],
    )
    return response["message"]["content"]

def truncate_caption(caption: str) -> str:
    """
    キャプションを指定された最大バイト数に切り詰めます。

    Args:
        caption (str): 元のキャプション

    Returns:
        str: 切り詰められたキャプション
    """
    # キャプションをUTF-8でエンコード
    encoded = caption.encode('utf-8')
    # キャプションが最大バイト数以下の場合はそのまま返す
    if len(encoded) <= MAX_CAPTION_BYTES:
        return caption
    
    # 最大バイト数までキャプションを切り詰める
    truncated = encoded[:MAX_CAPTION_BYTES]
    # UTF-8の文字境界を考慮して、不完全な文字を削除
    while truncated[-1] & 0b11000000 == 0b10000000:
        truncated = truncated[:-1]
    
    # 切り詰めたキャプションをデコードし、末尾に「...」を追加して返す
    return truncated.decode('utf-8', 'ignore') + "..."

def get_existing_caption(photo) -> str:
    """
    写真の既存のキャプションを取得します。

    Args:
        photo: 写真オブジェクト

    Returns:
        str: 既存のキャプション(存在しない場合は空文字列)
    """
    # 'caption'属性があればそれを、なければ'description'属性を返す
    # どちらもない場合は空文字列を返す
    return getattr(photo, 'caption', '') or getattr(photo, 'description', '')

def set_caption(photo, caption: str) -> None:
    """
    写真にキャプションを設定します。

    Args:
        photo: 写真オブジェクト
        caption (str): 設定するキャプション
    """
    # 'caption'または'description'属性にキャプションを設定
    for attr in ['caption', 'description']:
        if hasattr(photo, attr):
            setattr(photo, attr, caption)
            return
    print(f"警告: 写真にキャプションを設定できません。")

def save_photo(photo) -> None:
    """
    写真の変更を保存します。

    Args:
        photo: 写真オブジェクト
    """
    # 'save'メソッドがあれば呼び出す
    if hasattr(photo, 'save'):
        photo.save()
    else:
        print(f"警告: 写真の変更を保存できません。")

def get_photo_filename(photo) -> Optional[str]:
    """
    写真のファイル名を取得します。エラーが発生した場合はリトライします。

    Args:
        photo: 写真オブジェクト

    Returns:
        Optional[str]: 写真のファイル名(取得できない場合はNone)
    """
    for attempt in range(MAX_RETRIES):
        try:
            return photo.filename
        except Exception as e:
            if attempt < MAX_RETRIES - 1:
                print(f"写真情報の取得に失敗しました。リトライします... (試行回数: {attempt + 1})")
                time.sleep(RETRY_DELAY)
            else:
                print(f"写真情報の取得に失敗しました: {e}")
                return None

def process_photo(photo):
    """
    1枚の写真を処理します。

    Args:
        photo: 写真オブジェクト
    """
    # 既存のキャプションを取得
    existing_caption = get_existing_caption(photo)
    if existing_caption.strip():
        print("写真は既存のキャプションがあるためスキップします。")
        return

    # 写真のファイル名を取得
    filename = get_photo_filename(photo)
    if not filename:
        return

    # 一時ディレクトリを作成
    photo_temp_dir = os.path.join(TEMP_DIR, os.path.splitext(filename)[0])
    os.makedirs(photo_temp_dir, exist_ok=True)

    try:
        # 写真をエクスポート
        exported_file = export_photo(photo, photo_temp_dir)
        # キャプションを生成して翻訳
        caption = generate_and_translate_caption(exported_file)
        # キャプションを設定
        set_caption(photo, caption)
        # 変更を保存
        save_photo(photo)
        # 処理結果を表示
        print(f"写真: {filename}")
        print(f"キャプション: {caption}\n")
    except Exception as e:
        print(f"写真の処理中にエラーが発生しました: {e}")
    finally:
        # 一時ディレクトリを削除
        shutil.rmtree(photo_temp_dir, ignore_errors=True)

def export_photo(photo, temp_dir: str) -> str:
    """
    写真を一時ディレクトリにエクスポートします。

    Args:
        photo: 写真オブジェクト
        temp_dir (str): エクスポート先の一時ディレクトリ

    Returns:
        str: エクスポートされたファイルのパス

    Raises:
        Exception: エクスポートに失敗した場合
    """
    export_result = photo.export(temp_dir)
    if not export_result:
        raise Exception(f"写真のエクスポートに失敗しました: {photo.filename}")
    
    exported_file = export_result[0]
    if not os.path.exists(exported_file):
        raise Exception(f"エクスポートされたファイルが見つかりません: {exported_file}")
    
    return exported_file

def generate_and_translate_caption(image_path: str) -> str:
    """
    画像のキャプションを生成し、日本語に翻訳します。

    Args:
        image_path (str): 画像ファイルのパス

    Returns:
        str: 生成・翻訳・切り詰められたキャプション
    """
    caption = generate_caption(image_path)
    translated_caption = translate_to_japanese(caption)
    return truncate_caption(translated_caption)

def process_photos():
    """
    「写真」アプリ内のすべての写真に対してキャプション生成と翻訳を行います。
    """
    # 「写真」アプリのライブラリを初期化
    photos_lib = PhotosLibrary()
    # ライブラリ内のすべての写真を取得
    photos = photos_lib.photos()

    # 各写真に対して処理を行う
    for photo in photos:
        process_photo(photo)

    print("すべての写真の処理が完了しました。")

# このスクリプトが直接実行された場合にのみ、process_photos関数を実行
if __name__ == "__main__":
    process_photos()

>|python|
# 必要なライブラリをインポート
import ollama # AIモデルを使用するためのライブラリ
import os # ファイルシステム操作のためのライブラリ
import shutil # 高水準のファイル操作のためのライブラリ
import io # 入出力操作のためのライブラリ
from PIL import Image # 画像処理のためのライブラリ
from photoscript import PhotosLibrary # macOSの「写真」アプリを操作するためのライブラリ

# 定数の定義
TEMP_DIR = "/tmp/photo_exports" # 一時ファイルを保存するディレクト
VISION_MODEL = "llama3.2-vision" # 画像認識用のAIモデル名
TRANSLATION_MODEL = "aya-expanse" # 翻訳用のAIモデル名
MAX_CAPTION_BYTES = 500 # キャプションの最大バイト数(全角約250文字分)

# 一時ディレクトリが存在しない場合は作成
# exist_ok=Trueは、ディレクトリが既に存在する場合にエラーを発生させないためのオプション
os.makedirs(TEMP_DIR, exist_ok=True)

def generate_caption(image_path):
"""
指定された画像のキャプションを英語で生成します。

Args:
image_path (str): 画像ファイルのパス

Returns:
str: 生成された英語のキャプション
"""
# 画像をバイトデータとして読み込む
with open(image_path, "rb") as image_file:
image_data = image_file.read()

# PILを使用して画像を開き、JPEGとして保存し直す
# これは、画像形式を統一し、AIモデルへの入力を標準化するために行います
image = Image.open(io.BytesIO(image_data))
buffer = io.BytesIO()
image.save(buffer, format="JPEG")
image_bytes = buffer.getvalue()

# ollamaのAIモデルを使用して画像の内容を説明するキャプションを生成
response = ollama.chat(
model=VISION_MODEL,
messages=[{
"role": "user",
"content": "Briefly describe the content of this image.",
"images": [image_bytes],
}],
)
# 生成されたキャプションを返す
return response["message"]["content"]

def translate_to_japanese(text):
"""
指定されたテキストを日本語に翻訳します。

Args:
text (str): 翻訳する英語のテキスト

Returns:
str: 日本語に翻訳されたテキスト
"""
# ollamaの翻訳モデルを使用して英語のテキストを日本語に翻訳
response = ollama.chat(
model=TRANSLATION_MODEL,
messages=[{
"role": "user",
"content": f"以下の文を日本語に簡潔に翻訳してください:\n{text}",
}],
)
return response["message"]["content"]

def truncate_caption(caption):
"""
キャプションを指定された最大バイト数に切り詰めます。

Args:
caption (str): 元のキャプション

Returns:
str: 切り詰められたキャプション
"""
# キャプションをUTF-8エンコード
encoded = caption.encode('utf-8')
# キャプションが最大バイト数以下の場合はそのまま返す
if len(encoded) <= MAX_CAPTION_BYTES:
return caption

# 最大バイト数までキャプションを切り詰める
truncated = encoded[:MAX_CAPTION_BYTES]
# UTF-8の文字境界を考慮して、不完全な文字を削除
while truncated[-1] & 0b11000000 == 0b10000000:
truncated = truncated[:-1]

# 切り詰めたキャプションをデコードし、末尾に「...」を追加して返す
return truncated.decode('utf-8', 'ignore') + "..."

def process_photos():
"""
「写真」アプリ内のすべての写真に対してキャプション生成と翻訳を行います。
"""
# 「写真」アプリのライブラリを初期化
photos_lib = PhotosLibrary()
# ライブラリ内のすべての写真を取得
photos = photos_lib.photos()

# 各写真に対して処理を行う
for photo in photos:
photo_temp_dir = ""
try:
# 写真ごとに一意のサブディレクトリを作成
photo_temp_dir = os.path.join(TEMP_DIR, os.path.splitext(photo.filename)[0])
os.makedirs(photo_temp_dir, exist_ok=True)

# サブディレクトリが正しく作成されたか確認
if not os.path.exists(photo_temp_dir):
raise Exception(f"ディレクトリの作成に失敗しました: {photo_temp_dir}")

# 写真を一時ディレクトリにエクスポート
export_result = photo.export(photo_temp_dir)

# エクスポートが成功したか確認
if not export_result:
raise Exception(f"写真のエクスポートに失敗しました: {photo.filename}")

# エクスポートされたファイルのパスを取得
exported_file = export_result[0] # エクスポート結果の最初のファイルを使用

# エクスポートされたファイルが存在するか確認
if not os.path.exists(exported_file):
raise Exception(f"エクスポートされたファイルが見つかりません: {exported_file}")

# 英語でキャプションを生成
caption = generate_caption(exported_file)

# 生成されたキャプションを日本語に翻訳
translated_caption = translate_to_japanese(caption)

# 翻訳されたキャプションを切り詰める
truncated_caption = truncate_caption(translated_caption)

# 既存のキャプションと新しいキャプションを組み合わせて切り詰める
existing_caption = ""
if hasattr(photo, 'caption'):
existing_caption = photo.caption or ""
elif hasattr(photo, 'description'):
existing_caption = photo.description or ""

combined_caption = existing_caption + "\n" + truncated_caption
final_caption = truncate_caption(combined_caption)

# 更新されたキャプションを写真のメタデータに設定
if hasattr(photo, 'caption'):
photo.caption = final_caption
elif hasattr(photo, 'description'):
photo.description = final_caption
else:
print(f"警告: 写真 {photo.filename} にキャプションを設定できません。適切な属性が見つかりません。")

# 個々の写真オブジェクトに対して変更を保存
if hasattr(photo, 'save'):
photo.save()
else:
print(f"警告: 写真 {photo.filename} の変更を保存できません。saveメソッドが見つかりません。")

# 処理結果を表示
print(f"写真: {photo.filename}")
print(f"キャプション: {final_caption}\n")

except Exception as e:
# エラーが発生した場合はメッセージを表示
print(f"写真 {photo.filename} の処理中にエラーが発生しました: {e}")

finally:
# 処理が終了したら、一時ディレクトリを削除
if photo_temp_dir and os.path.exists(photo_temp_dir):
shutil.rmtree(photo_temp_dir)

print("すべての写真の処理が完了しました。")

# このスクリプトが直接実行された場合にのみ、process_photos関数を実行
if __name__ == "__main__":
process_photos()

<