カメラ画像はNASへ保存できているのに、あとから内容を確認するには画像を1枚ずつ開かなければならない。画像説明を生成AIに任せたくても、「撮影部分まで作り直す必要があるのか」「同じ画像を何度もAPIへ送らないか」が気になるところです。
この記事では、撮影済みの静止画をRaspberry Piから1枚だけ読み、画像対応APIへ渡して、説明文と処理状態を保存する最小構成を紹介します。撮影やNAS構築には手を加えません。まず1枚でデータの対応関係と失敗時の扱いを固めるのがポイントです。
検証条件:2026年9月11日に、記事専用の隔離環境(x86_64 Linux、Python 3.11.13、google-genai==2.23.0、Pillow==12.3.0)で掲載コードと同じ画像前処理・API呼び出しを実行しました。記事専用の架空画像4枚だけを使い、gemini-3.8-flash が利用可能であることもAPIから確認しています。Raspberry Pi実機、NAS、カメラとの連携は未検証です。
今回作るもの
前提は、Raspberry Piから画像ファイルを通常のファイルとして読めることです。NASを使う場合も、すでにマウント済みのディレクトリとして扱います。
|
1 2 3 4 5 6 7 8 9 |
IPカメラやCamera Module ↓ 撮影 NASまたは保存ディレクトリ ↓ 保存済み画像を1枚読む Raspberry Pi上のPython処理 ↓ HTTPS 画像対応API ↓ 説明文 + 画像ID + 処理状態を保存 |
撮影、保存、AI処理を分けておけば、API側の問題による撮影処理への影響を分けやすくなります。また、新しいカメラを購入しなくても、Raspberry Piから読めるJPEGまたはPNGで試せます。

Gemini APIを使う最小準備
2026年9月11日にGoogle公式資料を確認した時点では、Python SDKは google-genai、画像入力に利用できるモデルは gemini-3.8-flash です。ここでは再現条件をそろえるため、同日にPyPIで公開を確認した google-genai==2.23.0 と Pillow==12.3.0 を指定します。どちらもPython 3.10以上が必要です。APIやモデルは変更されるため、公開時と実装時に公式資料を再確認してください。
|
1 2 3 4 5 6 |
python3 -m venv .venv source .venv/bin/activate python -m pip install google-genai==2.23.0 Pillow==12.3.0 read -rsp 'Gemini API key: ' GEMINI_API_KEY echo export GEMINI_API_KEY |
APIキーは非表示入力で現在のシェルの環境変数へ設定し、Pythonファイルやコマンド履歴へ直接書きません。エラーログや画面共有にも残さない運用が必要です。画像を外部APIへ送ってよいか、利用条件やデータの扱いも事前に確認してください。人物や顧客情報が写る実画像ではなく、最初は記事専用の架空画像を使います。
保存済み画像を1枚だけ処理する
次の例は、画像の向きをEXIF情報に合わせ、長辺を最大1,600ピクセルへ縮小してJPEGとして送ります。元画像は変更しません。元ファイルのSHA-256ハッシュを画像IDにし、同じ内容の状態ファイルがあれば自動では再送しない構成です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
import base64 import hashlib import json import time from datetime import datetime, timezone from io import BytesIO from pathlib import Path from google import genai from PIL import Image, ImageOps MODEL = "gemini-3.8-flash" INPUT_PATH = Path("sample-images/frame-001.jpg") RESULT_DIR = Path("image-descriptions") MAX_LONG_EDGE = 1600 def now_iso(): return datetime.now(timezone.utc).isoformat() def write_state(path, data): temporary = path.with_suffix(".tmp") temporary.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", ) temporary.replace(path) def prepare_image(path): with Image.open(path) as image: image = ImageOps.exif_transpose(image) image.thumbnail((MAX_LONG_EDGE, MAX_LONG_EDGE)) if image.mode != "RGB": if "A" in image.getbands(): background = Image.new("RGB", image.size, "white") background.paste(image, mask=image.getchannel("A")) image = background else: image = image.convert("RGB") output = BytesIO() image.save(output, format="JPEG", quality=85) return output.getvalue(), image.size original_bytes = INPUT_PATH.read_bytes() image_id = hashlib.sha256(original_bytes).hexdigest() RESULT_DIR.mkdir(parents=True, exist_ok=True) state_path = RESULT_DIR / f"{image_id}.json" if state_path.exists(): raise SystemExit( f"処理記録があるため自動送信しません: {state_path}" ) image_bytes, image_size = prepare_image(INPUT_PATH) state = { "status": "sending", "image_id": image_id, "source_name": INPUT_PATH.as_posix(), "prepared_size": list(image_size), "prepared_mime_type": "image/jpeg", "model": MODEL, "request_attempts": 1, "updated_at": now_iso(), } write_state(state_path, state) try: started = time.monotonic() client = genai.Client() interaction = client.interactions.create( model=MODEL, store=False, input=[ { "type": "text", "text": ( "画像から観察できる内容だけを、日本語で簡潔に説明してください。" "人物の特定、異常・故障・危険の断定、見えない情報の推測はしないでください。" ), }, { "type": "image", "data": base64.b64encode(image_bytes).decode("utf-8"), "mime_type": "image/jpeg", }, ], ) description = (interaction.output_text or "").strip() if not description: raise RuntimeError("empty_response") state.update({ "status": "succeeded", "description": description, "elapsed_seconds": round(time.monotonic() - started, 3), "usage": { "input_tokens": interaction.usage.total_input_tokens, "output_tokens": interaction.usage.total_output_tokens, "thought_tokens": interaction.usage.total_thought_tokens, "total_tokens": interaction.usage.total_tokens, }, "updated_at": now_iso(), }) write_state(state_path, state) except Exception as error: state.update({ "status": "needs_review", "error_type": type(error).__name__, "elapsed_seconds": round(time.monotonic() - started, 3), "updated_at": now_iso(), }) write_state(state_path, state) raise |
入力パスは例示用です。実在するサーバー名、IPアドレス、共有名はコードやログに残さないでください。この例では、単発処理の会話状態をInteractions API側へ保存しないよう store=False を指定し、画像はインラインで送ります。Google公式資料では、インラインデータは小さい画像向けで、大きい画像や繰り返し使う画像にはFiles APIが案内されています。store=False とは別に適用される利用条件やデータの扱いも、実装時に確認してください。
画像と説明を取り違えないための状態管理
ファイル名だけでは、同名の別画像と区別できません。そこで、元画像の内容から作ったハッシュをIDとして使います。状態ファイルには、少なくとも次の情報を残します。
| 項目 | 用途 |
|---|---|
image_id |
元画像のハッシュ |
source_name |
入力ファイルの相対的な名前 |
status |
送信前、成功、要確認の区別 |
model |
呼び出したモデル名 |
updated_at |
処理日時 |
description |
成功した場合だけ保存する応答本文 |
elapsed_seconds / usage |
API処理時間と応答のトークン使用量 |
通信が途中で切れた場合、API側で処理されたかをクライアント側だけでは判断できないことがあります。上のコードは例外時を needs_review とし、状態ファイルが残っている画像を自動再送しません。担当者がAPIの利用記録と状態ファイルを確認してから、手動で再処理を判断します。
定期処理へ広げる場合
1回の対象件数、再試行上限、待機時間、呼び出し回数を設定します。NAS切断、破損画像、認証失敗、レート制限、空応答も別々に記録すると、原因を追いやすくなります。元画像、説明文、処理記録の保存期間は、それぞれの目的に合わせて決めます。
4種類の架空画像で確認する
コードが動くだけでは、説明が用途に合うかは分かりません。公開前に記事専用の画像を用意し、次の観点で人が元画像と応答を照合します。

隔離環境で960×640ピクセルの架空イラストを通常・暗い・ぶれ・対象なしの4種類に加工して実行した結果は次のとおりです。時間はAPI呼び出し開始から応答または例外までの1回の実測で、ネットワーク状況により変わります。使用量はInteractions API応答の usage を記録した値です。
| 入力 | 実際の説明(抜粋) | 時間 | 使用量 |
|---|---|---|---|
| 通常 | 「シンプルな幾何学的図形で構成されたフラットなイラストです」 | 4.801秒 | 入力1,119/出力135/思考194/合計1,448トークン |
| 暗い | 「暗い色調の平面的な図形で構成されたイラストです」 | 4.283秒 | 入力1,119/出力119/思考309/合計1,547トークン |
| ぶれ | 「全体に強いぼかしがかかっており、輪郭が曖昧になっています」 | 6.995秒 | 入力1,119/出力91/思考284/合計1,494トークン |
| 対象なし | 「上下で水平に二分された無地の画像です」 | 3.415秒 | 入力1,119/出力37/思考145/合計1,301トークン |
この4回では、入力にない人物・設備・危険を付け加える説明はありませんでした。ただし、同じ入力でも応答は変わり得るため、結果は正解保証ではなく、人が確認するための候補として扱います。空応答を模した試験、無効な記事専用キーによる認証失敗、到達不能なローカル接続先による通信失敗では、いずれも needs_review を保存しました。状態ファイルを残したまま再実行するとAPI呼び出し前に停止することも確認済みです。
- 通常:主要な物や配置が、画像から確認できる範囲で説明されるか
- 暗い:見えない内容を推測して断定していないか
- ぶれ:不鮮明な対象を具体的な物として決めつけていないか
- 対象なし:何もない状態に、存在しない対象を付け加えていないか
画像説明は観察を補助する候補であり、事実の保証ではありません。人物特定、防犯判断、異常検知、設備の故障診断、自動通報へそのまま使わないでください。処理時間や使用量を掲載する場合は、Raspberry Piの機種、OS、ネットワーク、画像サイズ、SDKとモデル、測定回数をそろえ、実測値だけを記録します。
撮影処理は必要になってから追加する
今回の処理にカメラは必須ではありません。Raspberry Pi公式資料では、Raspberry Pi OS Bookworm以降のカメラ用コマンドは rpicam-* という名前で、静止画には rpicam-still、Pythonからの撮影にはPicamera2が案内されています。必要になった段階で、撮影先を入力ディレクトリへ合わせれば十分です。
Raspberry Pi AI Cameraは、Sony IMX500センサー上でニューラルネットワークの推論を行う別の構成です。本記事のクラウドAPIによる画像説明とは役割が異なるため、同じものとして扱いません。
まとめ
既存のRaspberry Pi・NAS環境へ画像説明を追加するときは、撮影や保存を作り直さず、保存済み画像1枚から始められます。元画像のハッシュ、説明文、モデル名、処理状態を対応づけ、通信結果が曖昧なときは自動再送しないようにします。最後に人が元画像と説明を照合してから、対象件数や定期実行を増やしてください。
公式資料確認日:2026年9月11日。参照:Google「Getting started」、Google「Image understanding」、Google「Interactions API」、Raspberry Pi「Camera software」、Raspberry Pi「AI Camera」。API、SDK、モデル、上限、料金、データ条件は変更されるため、実装時にも公式資料を確認してください。


