画像生成AIは、ブラウザの画面から使うのが一番手軽です。私もずっとそうしていました。
ただ、画面から作っていると困ることが3つ出てきます。1枚ずつしか作れない。作った画像がPCに残らない。同じ条件で少しずつ変えながら何枚も試す、ということができない。
そこで、Gemini の画像生成をコマンドから呼べるようにしました。この記事を読み終えると、追加インストールなしのPython 1ファイルで、文章(=いわゆるプロンプト)から画像を作れて、さらにPCに保存した画像1枚を渡して作り変えられるようになります。1枚あたり約5円です。
完成した1ファイル(224行)は記事の最後にそのまま載せています。途中の説明を飛ばして、先にそれを動かしてもらってもかまいません。
作るもの
| インプット | アウトプット |
|---|---|
| 文章(+元にする画像1枚・省略可) | JPEGファイル 1枚 |
前提の確かめ方
私の環境は macOS 12(Intel iMac)の Python 3.9.6 です。まず自分のところで打ってみてください。
python3 -V # → Python 3.9.6 のように出れば大丈夫(3.7以上なら動きます)
which python3 # → /usr/bin/python3 など。場所はどこでも構いません
Windows でも動きます。使っているのは Python に最初から入っているものだけなので、OSに依存する書き方はしていません。ただし私は Mac でしか確かめていないので、パスの書き方(~/ など)は読み替えてください。
追加インストールはありません。pip install は1回も出てきません。Google が配っている Python 用のライブラリもありますが、今回は使いません。やることは「JSONをPOSTして、返ってきたJSONから画像を取り出す」だけなので、urllib と json と base64 で足ります。全部Pythonに最初から入っています。
先にお金の話をします
一番不安なところだと思うので、先に書きます。私も最初はここが気になって、なかなか手が動きませんでした。
| 解像度 | 使うモデル | 1枚 | 日本円(1ドル150円で計算) |
|---|---|---|---|
| 1K | gemini-3.1-flash-lite-image |
$0.0336 | 約5円 |
| 2K | gemini-3.1-flash-image |
$0.101 | 約15円 |
| 4K | gemini-3.1-flash-image |
$0.151 | 約23円 |
数字は Gemini API の料金ページ が出どころです。値上げや値下げがあるので、この記事の数字は目安として見て、実際に払う前に一度リンク先を開いてください。
解像度とモデルは別々に指定します。自動では選ばれません。2Kにしたければ image_size を "2K" に、model を "gemini-3.1-flash-image" に、両方書き換えます。あとで載せるコードでは --size 2K と打てば両方まとめて切り替わるようにしてあります。
解像度は 512px・1K・2K・4K から選びます(512px は gemini-3.1-flash-image 専用)。8K はありません。しかも大文字のKでないと受け付けられません——公式ページにも「Lowercase parameters (e.g., 1k) will be rejected」と書かれています。
課金の起点
画像が返ってきたときにかかります。送った時点ではありません。
- ⛔ 文章の書き方が悪くて断られた場合(
画像が返りませんでした)は、私が試した範囲では残高が減りませんでした - ⛔
400や429で弾かれた場合も減りません。画像が作られていないからです - ⚠ 通信が途中で切れた場合(タイムアウト)は分かりません。Gemini 側で画像ができていれば取られる可能性があります。私は180秒待つ設定にしていますが、実際は10〜20秒で返ってきます。この差があるので、タイムアウトで切れたことは一度もありません
使いすぎが怖い人へ
私は前払いを選びました。先に入れた金額を使い切ったら止まります。カードから自動でチャージされ続けることはありません。残高がゼロになると、次の呼び出しが 429 で返ってくるだけです。
私は2,000円入れました。1Kで1枚約5円なので、だいたい400枚ぶんです。⚠ 前払いの最低額は日本円で2,000円 でした。
⚠ 入れた残高が期限切れで消えるかどうかは、私は確かめていません。少額から始めるのが安全だと思います。
いくら使ったかは ai.studio/projects の画面で見られます。私は最初の数日、1枚作るたびに開いて確かめていました。
PCに保存した画像を渡しても、値段はほとんど変わりません
ここは私も意外でした。約0.06円 です。出力の5円に比べれば誤差の範囲でした。
計算の内訳を書いておきます。「トークン」というのは、AIが文章や画像を数えるときの単位です。文章なら単語のかたまり1つ、画像ならタイル1枚が何トークン、という数え方をします。
- 入力の単価は、1Kのモデル(
gemini-3.1-flash-lite-image)で 100万トークンあたり $0.25 - 画像はタイルに切って数えます。1タイル258トークン
- ⚠ タイルの大きさは768×768の固定ではありません。公式の書き方はこうです——短いほうの辺を1.5で割って小数を切り捨てた値をタイルの一辺とし、縦横それぞれをその値で割って切り上げ、掛け合わせた数がタイル数
- 1200×896 の写真なら、896÷1.5=597。1200÷597=2.01→3、896÷597=1.50→2。3×2=6タイル
- 6×258=1,548トークン。1,548 ÷ 100万 × $0.25 × 150円 = 約0.06円
384ピクセル以下の小さい画像なら1タイル(258トークン) で終わります。
1. APIキーを取る
https://aistudio.google.com/apikey を開きます。
どのGoogleアカウントで入るかを先に決めてください。ここで入ったアカウントに請求が行きます。仕事用と個人用を使い分けている人は、ブラウザのプロフィールを切り替えてから開くと事故が減ります。
- 「Create API key」ボタンを押す
- 「新しいキーを作成する」という小窓が出る。⭐ 「キー名の設定」も「インポートしたプロジェクトを選択」も初期値のままで大丈夫です。キー名は後で見分けるための名前、プロジェクトはキーを入れるGoogle側の入れ物の名前で、どちらも動きには関係しません
- そのまま右下の「キーを作成」を押す
- 出てきた文字列をコピーする。⛔ 小窓を閉じる前にコピーしてください(後から全文を表示できません)
ここで、私が10分ほど止まった落とし穴があります。
キーが AIza で始まるとは限りません。
ネットの記事を読むと「AIza で始まる文字列がAPIキーです」と書いてあることが多いのですが、私が発行したキーは AQ. で始まる53文字 でした。どちらの形式でも動きます。
| 形式 | 長さ | 扱い |
|---|---|---|
AQ. で始まる |
53文字 | 新しい形式。私が2026年8月に発行したのはこちら |
AIza で始まる |
約39文字 | 以前の形式。今も使えます |
⚠ 長さでキーの正しさを判定しないでください。どちらが出るかは選べませんし、今後また変わるかもしれません。画面がキーを出したなら発行は成功しています。
キーをファイルに置く
# ⚠ 「ここにキー本体」の部分を、コピーしたキーに置き換えてください
echo 'ここにキー本体' > ~/.gemini_api_key
# 中身を確かめる(貼り間違いと余計な改行のチェック)
cat ~/.gemini_api_key
wc -c ~/.gemini_api_key # → 54 くらい(キー53文字+改行1文字)
# 自分だけが読めるようにする
chmod 600 ~/.gemini_api_key
ls -l ~/.gemini_api_key # → -rw------- になっていれば成功
シングルクォート(')で囲んでください。キーには . や _ や - が入ります。ダブルクォートだと種類によってはシェルが解釈してしまうことがあるので、シングルクォートのほうが安全です。
chmod 600 は「自分だけが読み書きできる、他の人は開けない」という設定です。⚠ やらなくても動きます。ただしAPIキーは他人に使われるとその人の生成ぶんも自分の請求になるので、付けておいて損はありません。
⭐ 改行が1つ入るのは正常です。コード側で .strip() して落としています。
キーをPythonのコードに直接書かないでください。あとでコードを人に見せたくなったときに、書き換える手間が増えます。ファイルから読む形にしておけば、コードはそのまま渡せます。
2. 支払いを通す
キーを取っただけでは画像は作れません。課金設定をしないと 403 で断られます。
- AI Studio の左下の「Settings」(歯車)→「Plan information」を開く
- 使うプロジェクトの「Set up Billing」を押す
- 前払い(Prepay)か後払い(Postpay)を選ぶ。⚠ どちらが出るかはアカウントによります。画面に出たほうを選んでください
- 前払いなら、ai.studio/projects で残高を入れる
画像生成に無料枠はありません。文章の生成には無料枠がありますが、画像は1枚目からお金がかかります。
⚠ キーが使えることの確認だけなら無料枠で通ってしまいます。私は「モデルの一覧が取れたから準備完了」と思って進み、画像を作ろうとして初めて弾かれました。
残高ゼロは 429 で返ってきます
ここが2つ目の落とし穴です。
429 Too Many Requests
Your prepayment credits are depleted.
429 は普通「呼び出しすぎ」の合図です。だから最初にこれを見たとき、私は「連打しすぎたかな」と思って1分待ちました。もちろん待っても直りません。残高がゼロなだけでした。
英文を読めば prepayment credits are depleted(前払いの残高が尽きた)と書いてあります。429 が出たら、待つ前に英文を読んでください。
⚠ Pythonの urllib は、この英文をそのままでは見せてくれません。例外の中に隠れています。取り出し方は次の章で書きます。
3. 文章から1枚作る
送り先はこれです。
POST https://generativelanguage.googleapis.com/v1beta/interactions
2つ、気になる点を先に潰しておきます。
interactions は、よく見かける generateContent とは別のURLです。文章の生成でよく紹介されるのは .../models/gemini-x:generateContent のほうで、送るJSONの形も違います(あちらは contents と parts、こちらは input と response_format)。画像を作るときはこの interactions を使います。
v1beta という名前は気になりますが、これが現行の入口です。⚠ 名前のとおり仕様が変わる可能性はあるので、しばらく動かしていなかったら公式ページを見直してください。
送るJSONはこういう形です。
{
"model": "gemini-3.1-flash-lite-image",
"input": [
{"type": "text", "text": "a black chihuahua howling at the full moon"}
],
"response_format": {
"type": "image",
"mime_type": "image/jpeg",
"aspect_ratio": "4:3",
"image_size": "1K"
}
}
このJSONは、Pythonでは辞書(dict)とほぼ同じ形で書けます。{ と }、"キー": 値、[ で囲んだ配列。そのまま json.dumps() に渡せば送れる文字列になります。
指定できる値
| 項目 | 書ける値 |
|---|---|
image_size |
512px(gemini-3.1-flash-image 専用)・1K・2K・4K。大文字のK
|
aspect_ratio |
公式に出ているのは 1:1 16:9 5:4 9:16。gemini-3.1-flash-image はさらに 1:4 4:1 1:8 8:1
|
mime_type(受け取り) |
🔴 image/jpeg だけ
|
⚠ aspect_ratio は、公式ページに全部の一覧が載っていません。上の値は本文中に出てくるものを拾ったものです。⭐ 私は一覧に無い 4:3 を指定しましたが、弾かれずに 1200×896 が返ってきました。載っていない値も通ることがあるので、使いたい比率があるなら --dry-run で組み立てを確かめてから1枚だけ試すのが早いです。
文章は英語で書いています。⚠ 日本語で通るかどうかは私は確かめていません。ネット上には日本語でも通るという話がありますが、実際に打っていないので、この記事では英語の例だけ載せます。
3つ目の落とし穴:PNGは受け取れません
400 Bad Request
Invalid value at 'response_format.mime_type'. Supported values: 'image/jpeg'
mime_type に image/png を送ると400で断られます。受け取れるのはJPEGだけです。PNGが欲しい場合は、受け取ってから自分で変換することになります。私は素直にJPEGのまま使っています。
⚠ この mime_type は「受け取る形式」です。次の章で出てくる「送る画像の mime_type」とは別のものなので、混ぜないでください(私はここで一度つまずきました)。
送るところ
import json
import urllib.request
from pathlib import Path
ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/interactions"
# Path.home() はホームディレクトリ。/ はパスの連結(割り算ではありません)
API_KEY = (Path.home() / ".gemini_api_key").read_text(encoding="utf-8").strip()
body = {
"model": "gemini-3.1-flash-lite-image",
"input": [{"type": "text", "text": "a black chihuahua howling at the full moon"}],
"response_format": {
"type": "image",
"mime_type": "image/jpeg", # image/png は 400 で断られる
"aspect_ratio": "4:3",
"image_size": "1K", # 大文字の K
},
}
req = urllib.request.Request(
ENDPOINT,
data=json.dumps(body).encode("utf-8"),
headers={"x-goog-api-key": API_KEY, "Content-Type": "application/json"},
method="POST",
)
APIキーは Authorization ヘッダではなく x-goog-api-key ヘッダに入れます。ここは他のサービスと違うところなので、そのまま真似してください。
Path.home() / ".gemini_api_key" の / は割り算ではありません。pathlib ではスラッシュがパスの区切りとして使えます。os.path.join(os.path.expanduser("~"), ".gemini_api_key") と同じ意味です。
エラーの英文を画面に出す
さっき書いた 429 や 400 の英文は、このままでは見えません。urllib は例外を投げるだけで、中身は自分で読み出す必要があります。
import urllib.error
try:
with urllib.request.urlopen(req, timeout=180) as res:
payload = json.loads(res.read().decode("utf-8"))
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")[:800] # ← ここに英文が入っている
print(f"{e.code}:呼び出しに失敗しました")
print(detail)
raise
except urllib.error.URLError as e:
print(f"ネットにつながりませんでした:{e.reason}")
raise
e.read() が肝です。これを書かないと HTTP Error 429: Too Many Requests という1行しか見えず、「呼び出しすぎ」だと思い込むことになります。⭐ 私が1分待ってしまったのは、まさにこれを書いていなかったからです。
timeout=180 は3分ですが、実際は10〜20秒で返ってきます。打ってからしばらく何も起きないので不安になりますが、そういうものです。
返ってきた中身から画像を取り出す
payload の中身は大きいので、まず自分の目で形を見てください。
print(json.dumps(payload, ensure_ascii=False)[:500])
画像のbase64は入れ子の奥のほうに入っています。⚠ 私は階層を決め打ちしませんでした。payload["output"][0]["data"] のように書くと、返し方が1段変わっただけで黙って壊れるからです。代わりに、木をたどって「画像らしいもの」を探す形にしました。
import base64
def find_image_data(node):
"""入れ子の中を歩いて、画像の base64 を探す"""
if isinstance(node, dict):
data = node.get("data")
if isinstance(data, str) and data:
if "image" in str(node.get("mime_type", "")) or node.get("type") == "image":
return data
for value in node.values():
found = find_image_data(value)
if found:
return found
elif isinstance(node, list):
for item in node:
found = find_image_data(item)
if found:
return found
return None
data = find_image_data(payload)
Path("out.jpg").write_bytes(base64.b64decode(data))
この関数は自分自身を呼びます(再帰)。やっていることは単純で、「辞書なら中の値を全部見る、配列なら中身を全部見る、data があって画像っぽかったらそれを返す」を繰り返しているだけです。
base64 は、画像のような数値の並びを、文字だけで表す書き方です。JSONには文字しか入れられないので、画像は必ずこの形で行き来します。b64decode すると元の画像の中身に戻ります。
保存先は Path("out.jpg") なので、ターミナルでいまいるフォルダに出ます。どこに出たか分からなくなったら Path("out.jpg").resolve() を表示すると絶対パスが見えます。
これで python3 gemini.py "a black chihuahua howling at the full moon" と打つと out.jpg ができます。1200×896、約5円でした(aspect_ratio は 4:3 を指定しています)。
4. PCに保存した画像を渡して作り変える
ここからが本題です。文章だけでなく、PCに保存してある画像を1枚渡して「これを元に作り変えて」と頼めます。
やることは拍子抜けするほど簡単で、input の配列に2件目を足すだけ です。
{
"model": "gemini-3.1-flash-lite-image",
"input": [
{"type": "text", "text": "the same dog, sitting on a sandy beach at sunrise"},
{"type": "image", "data": "<base64にした文字列>", "mime_type": "image/jpeg"}
],
"response_format": {
"type": "image",
"mime_type": "image/jpeg",
"aspect_ratio": "4:3",
"image_size": "1K"
}
}
<base64にした文字列> のところは手で貼るものではありません。100万文字を超えます。コードで作ります。
raw = Path("元にする画像.jpg").read_bytes()
encoded = base64.b64encode(raw).decode("ascii")
body["input"].append(
{"type": "image", "data": encoded, "mime_type": "image/jpeg"}
)
⚠ この append は、urllib.request.Request(...) を作る前に書いてください。Request を作った時点で送る中身が確定するので、後から足しても反映されません。
⭐ 日本語のファイル名もそのまま通ります。私は 2026-08-23_満月に吠える黒チワワ.jpg を渡しています。
渡し方は3通りあります
| 渡し方 | 書き方 | 向いている場面 |
|---|---|---|
| base64で埋め込む | {"type": "image", "data": "...", "mime_type": "..."} |
PCに保存したファイルを1枚渡す。今回はこれ |
| URLを渡す | {"type": "image", "uri": "https://example.com/a.jpg", "mime_type": "image/jpeg"} |
ネット上の公開画像。⚠ 私は試していません |
| 先にアップロードしてURIを渡す | 上と同じ形。URIはアップロードすると返ってきます | 大きい画像・同じ画像を何度も使う。⚠ 私は試していません |
⛔ 下の2つは私が動かしていないので、この記事では手順を書きません。PCに保存したファイルを渡すだけなら1つ目で足ります。
制限
| 項目 | 値 |
|---|---|
| 送れる形式 |
image/png image/jpeg image/webp image/heic image/heif
|
| 1回に送れる枚数 | 3,600枚まで |
| base64で埋め込むときの上限 | リクエスト全体で20MB |
⚠ 「3,600枚」と「20MB」は、先に効くほうが効きます。実際には20MBのほうが先に来ます。1枚1MBの写真なら20枚も入りません。3,600枚という数字は、URLやアップロード済みのURIで渡す場合の上限だと思って読んでください。
⚠ 20MBは「base64にしたあとの大きさ」で見るのが安全です。base64にすると元のファイルの約1.33倍に増えます。私の876KBの写真は、base64にすると1,196,184文字(約1.14MB)になりました。
入口と出口で通る形式が違います
受け取れるのはJPEGだけでしたが、送るほうはPNGも通ります。
私は最初、受け取り用に作った「JPEGだけ」という設定を送るほうにも使い回してしまい、PCに保存したPNGを渡せずに悩みました。別のものとして分けて持ってください。あとで載せるコードでは、受け取り側は response_format の中に直接書き、送る側は INPUT_MIME_BY_EXT という別の表にしてあります。
実際にやってみた結果
夜の崖の上で満月に吠えている黒チワワの絵を作り、その絵を渡して「同じ犬を、朝の砂浜に座らせて」と頼みました。
出てきた絵では、首輪の青と星の飾り、水彩の紙のざらつき、白い縁まで引き継がれていました。変わったのは背景と姿勢だけです。犬の顔つきも耳の形も同じでした。
「同じ犬」と言葉で説明しようとすると、毛の色や耳の形や首輪の飾りを全部書かないといけません。それでも同じにはなりません。画像を1枚渡すほうが速いし正確でした。
5. 課金される前に止める
ここが、私がこのスクリプトで一番時間をかけたところです。
1枚5円というのは、失敗しても痛くない額です。ただ、打ち間違いで課金されるのは気分が悪い。ファイル名を間違えただけで5円が消えるのは避けたい。
そこで2つ入れました。
--dry-run を付けたら送らない
送る中身と金額を画面に出して、そこで終わります。
python3 gemini.py "the same dog on a beach" --image 満月のチワワ.jpg --dry-run
--- 送る画像 ---
枚数 :1枚
ファイル:2026-08-23_満月に吠える黒チワワ.jpg
形式 :image/jpeg
大きさ:1200×896 ピクセル(876KB)
かかるお金:約5円($0.0336)
1つコツがあります。送るJSONをそのまま画面に出すと、base64の文字列で画面が埋まって何も読めなくなります。実測で1,196,184文字ありました。なので表示用にコピーを作って、base64のところだけ差し替えています。
def masked_body(body):
shown = json.loads(json.dumps(body)) # 一度文字にして戻す=深いコピー
for item in shown.get("input", []):
if isinstance(item.get("data"), str):
item["data"] = f"<base64 {len(item['data']):,}文字・伏せています>"
return shown
json.loads(json.dumps(body)) は、copy.deepcopy の代わりです。中身がJSONにできるものだけなので、これで足ります(import copy が1行減ります)。⚠ 元の body を書き換えてしまうと、そのあと本当に送るときに base64 が消えているので、コピーを取るところは省けません。
item.get("data") は、キーが無ければ None を返します。テキストの件({"type": "text", ...})には data が無いので None が返り、isinstance(None, str) が偽になって素通りします。
画像の確認は、呼び出しより先に全部やる
存在しないファイル、対応していない形式、大きすぎるファイル。この3つはPOSTする前に判定できます。先に判定しておけば、1円もかからずに止まります。
INPUT_MIME_BY_EXT = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".heic": "image/heic", ".heif": "image/heif",
}
MAX_BASE64_CHARS = 20 * 1024 * 1024
def read_input_image(path):
p = Path(path).expanduser()
if not p.exists():
raise ValueError(f"画像が見つかりません:{p}")
mime = INPUT_MIME_BY_EXT.get(p.suffix.lower())
if mime is None:
oks = "・".join(sorted(set(INPUT_MIME_BY_EXT)))
raise ValueError(f"送れない形式です:{p.suffix}\n → 送れるのは {oks}")
data = base64.b64encode(p.read_bytes()).decode("ascii")
if len(data) > MAX_BASE64_CHARS:
raise ValueError(f"大きすぎます:base64にすると {len(data)/1024/1024:.1f}MB(上限20MB)")
return {"data": data, "mime": mime}
⚠ len(data) は base64 にしたあとの文字数です。base64 の文字はすべて半角1バイトなので、文字数=バイト数として比べて問題ありません。
エラーの文言は日本語にしました。自分が3か月後に読むからです。
「1200×896 ピクセル」を追加インストールなしで読む
--dry-run の表示に画像の縦横を出したかったのですが、⛔ Pillow を入れると「追加インストールなし」が崩れます。
そこで、ファイルの先頭を自分で読みました。画像の幅と高さは、ファイルの決まった位置に書いてあります。
-
PNG:先頭8バイトが決まった並びで、その後ろの16〜24バイト目に幅と高さが4バイトずつ入っている。
struct.unpack(">II", head[16:24])の1行で取れます -
JPEG:
FFD8で始まり、FFに続く印を順にたどって、幅と高さを持つ印(SOF)に当たったらそこから読む。PNGより手間がかかります
コードは最後の1ファイルに入れてあります。⭐ HEIC と WebP は読めません。読めなかったときは「幅と高さは読めません」と出して先へ進むだけにしました。送れるかどうかとは関係ないからです。
通しで動く1ファイル
ここまでの断片を全部つないだものです。このまま gemini.py として保存すれば動きます。224行あります。
python3 gemini.py "a black chihuahua howling at the full moon" --dry-run
python3 gemini.py "a black chihuahua howling at the full moon"
python3 gemini.py "the same dog, sitting on a sandy beach at sunrise" --image out.jpg
gemini.py(クリックで開く・224行)
#!/usr/bin/env python3
"""Gemini に文章(と画像1枚)を送って、画像を1枚受け取る。
追加インストールは要りません。標準ライブラリだけで動きます。
使い方: python3 gemini.py "英語の文章" [--image 元にする画像] [--dry-run]
"""
import argparse
import base64
import json
import struct
import sys
import urllib.error
import urllib.request
from pathlib import Path
ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/interactions"
KEY_PATH = Path.home() / ".gemini_api_key"
# 解像度ごとに使うモデルと値段(1ドル150円で計算)
PRICES = {
"1K": {"model": "gemini-3.1-flash-lite-image", "usd": 0.0336, "yen": 5},
"2K": {"model": "gemini-3.1-flash-image", "usd": 0.101, "yen": 15},
"4K": {"model": "gemini-3.1-flash-image", "usd": 0.151, "yen": 23},
}
# 送れる画像の形式。⛔ 受け取り側(JPEGだけ)と混ぜないこと
INPUT_MIME_BY_EXT = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".heic": "image/heic", ".heif": "image/heif",
}
MAX_BASE64_CHARS = 20 * 1024 * 1024 # リクエスト全体の上限が20MB
# ---------- 画像の幅と高さを読む(Pillow なしで) ----------
def png_size(path):
with open(path, "rb") as f:
head = f.read(24)
if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n":
return None
return struct.unpack(">II", head[16:24])
SOF = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
def jpeg_size(path):
with open(path, "rb") as f:
if f.read(2) != b"\xff\xd8": # JPEG は必ず FFD8 で始まる
return None
while True:
b = f.read(1)
if not b:
return None
if b != b"\xff":
continue
marker = f.read(1)
while marker == b"\xff":
marker = f.read(1)
if not marker:
return None
m = marker[0]
if m == 0xD8 or m == 0x01 or 0xD0 <= m <= 0xD7:
continue
raw = f.read(2)
if len(raw) < 2:
return None
length = struct.unpack(">H", raw)[0]
if m in SOF:
body = f.read(5)
if len(body) < 5:
return None
h, w = struct.unpack(">HH", body[1:5])
return w, h
f.seek(length - 2, 1)
def image_size(path):
return png_size(path) or jpeg_size(path)
# ---------- 送る中身を組み立てる ----------
def read_input_image(path):
"""--image の画像を確かめて base64 にする。⛔ 呼び出しより先に全部止める"""
p = Path(path).expanduser()
if not p.exists():
raise ValueError(f"画像が見つかりません:{p}")
mime = INPUT_MIME_BY_EXT.get(p.suffix.lower())
if mime is None:
oks = "・".join(sorted(set(INPUT_MIME_BY_EXT)))
raise ValueError(f"送れない形式です:{p.suffix or '(拡張子なし)'}\n → 送れるのは {oks}")
data = base64.b64encode(p.read_bytes()).decode("ascii")
if len(data) > MAX_BASE64_CHARS:
raise ValueError(f"大きすぎます:base64にすると {len(data)/1024/1024:.1f}MB(上限20MB)")
return {"path": p, "mime": mime, "bytes": p.stat().st_size,
"size_px": image_size(p), "data": data}
def build_request(prompt, size, aspect, image=None):
items = [{"type": "text", "text": prompt}]
if image:
items.append({"type": "image", "data": image["data"], "mime_type": image["mime"]})
return {
"model": PRICES[size]["model"],
"input": items,
"response_format": {
"type": "image",
"mime_type": "image/jpeg", # ⛔ image/png は 400 で断られる
"aspect_ratio": aspect,
"image_size": size, # 大文字の K
},
}
def masked_body(body):
"""画面に出す用。base64 をそのまま出すと100万文字で埋まる"""
shown = json.loads(json.dumps(body))
for item in shown.get("input", []):
if isinstance(item.get("data"), str):
item["data"] = f"<base64 {len(item['data']):,}文字・伏せています>"
return shown
# ---------- 返ってきた中身から画像を取り出す ----------
def find_image_data(node):
if isinstance(node, dict):
data = node.get("data")
if isinstance(data, str) and data:
if "image" in str(node.get("mime_type", "")) or node.get("type") == "image":
return data
for value in node.values():
found = find_image_data(value)
if found:
return found
elif isinstance(node, list):
for item in node:
found = find_image_data(item)
if found:
return found
return None
def explain_http_error(e, detail):
if e.code in (401, 403):
return "APIキーが違うか、支払いの設定が済んでいません"
if e.code == 429:
low = detail.lower()
if "prepayment" in low or "credits are depleted" in low:
return ("前払いの残高がゼロです(呼び出しすぎ、ではありません)\n"
" → https://ai.studio/projects で残高を足してください")
return "短い時間に呼び出しすぎです。1分待ってもう一度"
if e.code == 400:
return "送っている中身が違います(下の英文に、どの項目かが書いてあります)"
return "呼び出しに失敗しました"
def main():
ap = argparse.ArgumentParser(description="Gemini に文章を送って画像を1枚受け取る")
ap.add_argument("prompt", help="Gemini に送る文章")
ap.add_argument("--image", help="元にする画像を1枚(jpg/jpeg/png/webp/heic/heif)")
ap.add_argument("--size", default="1K", choices=["1K", "2K", "4K"])
ap.add_argument("--aspect", default="4:3")
ap.add_argument("--out", default="out.jpg")
ap.add_argument("--dry-run", action="store_true", help="送らずに中身と金額を見る")
args = ap.parse_args()
# ⛔ 画像の確認は呼び出しより先。ここで落ちれば1円もかからない
src = None
if args.image:
try:
src = read_input_image(args.image)
except ValueError as e:
sys.exit(str(e))
body = build_request(args.prompt, args.size, args.aspect, src)
price = PRICES[args.size]
if args.dry_run:
print("--- 送る中身(呼び出しません)---")
print(json.dumps(masked_body(body), ensure_ascii=False, indent=2))
print("--- 送る画像 ---")
if src:
px = f"{src['size_px'][0]}×{src['size_px'][1]} ピクセル" if src["size_px"] else "幅と高さは読めません"
print(f"枚数 :1枚\nファイル:{src['path'].name}\n形式 :{src['mime']}")
print(f"大きさ:{px}({src['bytes']/1024:,.0f}KB)")
else:
print("枚数 :0枚(文章だけ)")
print(f"かかるお金:約{price['yen']}円(${price['usd']})")
return
if not KEY_PATH.exists():
sys.exit(f"APIキーが見つかりません:{KEY_PATH}")
api_key = KEY_PATH.read_text(encoding="utf-8").strip()
req = urllib.request.Request(
ENDPOINT,
data=json.dumps(body).encode("utf-8"),
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=180) as res:
payload = json.loads(res.read().decode("utf-8"))
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")[:800]
sys.exit(f"{e.code}:{explain_http_error(e, detail)}\n--- 返ってきた英文 ---\n{detail}")
except urllib.error.URLError as e:
sys.exit(f"ネットにつながりませんでした:{e.reason}")
data = find_image_data(payload)
if not data:
sys.exit("画像が返りませんでした。文章を変えて試してください")
out = Path(args.out)
out.write_bytes(base64.b64decode(data))
px = image_size(out)
print(f"できました:{out.resolve()}" + (f"({px[0]}×{px[1]} ピクセル)" if px else ""))
if __name__ == "__main__":
main()
まとめ:私がつまずいた3つ
| 見えたもの | 実際の原因 | どうしたか |
|---|---|---|
キーが AIza で始まらない |
⛔ これは異常ではない |
AQ. で始まる53文字の新しい形式。そのまま使える。長さで正誤を判定しない
|
400 + Supported values: 'image/jpeg'
|
mime_type にPNGを送っていた |
受け取りはJPEGだけ。送るほうは5形式OK。別のものとして分ける |
429 Too Many Requests |
呼び出しすぎではなく前払い残高がゼロ | 待たずに英文を読む。⭐ そのためには e.read() を書いておく必要がある |
どれも、Gemini の画像生成を初めて呼べば当たると思います。特に3つ目は、429 という番号を見た瞬間に「待とう」と判断してしまうので、時間を無駄にしやすいです。
Claude Code から打たせる
ここまでは自分でターミナルに打つ前提で書きました。ただ、私が普段やっているのはこのファイルを Claude Code に打たせるほうです。
理由は3つあります。
1つ目。英語の文章を自分で組み立てなくて済みます。 私は「満月に吠える黒いチワワを、水彩の絵本っぽく」と日本語で頼むだけです。Claude が a black chihuahua howling at the full moon, watercolor storybook illustration のような英語に直して打ってくれます。⭐ 上のチワワの絵は、実際にこの頼み方で出てきたものです。
2つ目。--dry-run を必ず先に通させられます。 「まず --dry-run で見せて、いいと言うまで本番を打たないで」と最初に頼んでおくと、送る中身と金額が画面に出たところで一度止まります。⛔ 打ち間違いのまま5円が飛ぶことがなくなりました。この記事で --dry-run を作った理由の半分はこれです。
3つ目。ファイル名を渡すのが楽です。 --image に渡すパスは日本語混じりで長くなりがちですが、「さっき作ったチワワの絵を元にして」と言えば、Claude が保存先のフォルダから探して打ってくれます。
具体的には、Claude Code にこう頼んでいます。
~/Library/Scripts/gemini.py を使って画像を1枚作って。
題材は「満月に吠える黒いチワワ、水彩の絵本風」。
英語の文章はそっちで組み立てていい。
まず --dry-run を付けて、送る中身と金額を見せて。
私が「はい」と言うまで本番は打たないで。
⚠ 「私が『はい』と言うまで打たないで」は毎回書いてください。書かないと、そのまま本番まで走ってしまうことがあります。⭐ お金がかかるコマンドを AI に打たせるときは、止まる場所を自分で指定するのが安全です。
--dry-run の出力を見て問題なければ「はい、本番を打って」と返すだけです。できた JPEG のパスが返ってくるので、そのまま開けます。
明日からできること
-
python3 -Vを打って、3.7以上かを確かめる -
AI Studio で「Create API key」を押し、出た文字列を
~/.gemini_api_keyに置く(⭐AIzaで始まらなくても正常です) - Settings → Plan information → Set up Billing で支払いを通し、前払いなら少額から残高を入れる
-
記事の最後の1ファイルを
gemini.pyとして保存し、まず--dry-runを付けて打つ(ここでは1円もかかりません) -
--dry-runを外して1枚作る(約5円) - できた
out.jpgを--image out.jpgで渡して、背景だけ変えてみる
画面から作るのと違って、PCのフォルダに画像が貯まっていくのが気持ちいいです。同じ文章で解像度だけ変えて比べる、といったことも数秒でできるようになりました。

