0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude CodeからGemini Developer APIを使用した画像生成

0
Last updated at Posted at 2026-08-24

2026-08-23_満月に吠える黒チワワ.jpg

画像生成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から画像を取り出す」だけなので、urllibjsonbase64 で足ります。全部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 と打てば両方まとめて切り替わるようにしてあります。

解像度は 512px1K2K4K から選びます512pxgemini-3.1-flash-image 専用)。8K はありません。しかも大文字のKでないと受け付けられません——公式ページにも「Lowercase parameters (e.g., 1k) will be rejected」と書かれています。

課金の起点

画像が返ってきたときにかかります。送った時点ではありません。

  • 文章の書き方が悪くて断られた場合画像が返りませんでした)は、私が試した範囲では残高が減りませんでした
  • 400429 で弾かれた場合も減りません。画像が作られていないからです
  • 通信が途中で切れた場合(タイムアウト)は分かりません。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アカウントで入るかを先に決めてください。ここで入ったアカウントに請求が行きます。仕事用と個人用を使い分けている人は、ブラウザのプロフィールを切り替えてから開くと事故が減ります。

  1. Create API key」ボタンを押す
  2. 「新しいキーを作成する」という小窓が出る。⭐ 「キー名の設定」も「インポートしたプロジェクトを選択」も初期値のままで大丈夫です。キー名は後で見分けるための名前、プロジェクトはキーを入れるGoogle側の入れ物の名前で、どちらも動きには関係しません
  3. そのまま右下の「キーを作成」を押す
  4. 出てきた文字列をコピーする。⛔ 小窓を閉じる前にコピーしてください(後から全文を表示できません)

ここで、私が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 で断られます

  1. AI Studio の左下の「Settings」(歯車)→「Plan information」を開く
  2. 使うプロジェクトの「Set up Billing」を押す
  3. 前払い(Prepay)か後払い(Postpay)を選ぶ。⚠ どちらが出るかはアカウントによります。画面に出たほうを選んでください
  4. 前払いなら、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の形も違います(あちらは contentsparts、こちらは inputresponse_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 512pxgemini-3.1-flash-image 専用)・1K2K4K大文字のK
aspect_ratio 公式に出ているのは 1:1 16:9 5:4 9:16gemini-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_typeimage/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") と同じ意味です。

エラーの英文を画面に出す

さっき書いた 429400 の英文は、このままでは見えません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_ratio4: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枚渡すほうが速いし正確でした

2026-08-24_砂浜のチワワ_画像入力テスト.jpg

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行で取れます
  • JPEGFFD8 で始まり、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文字の新しい形式。そのまま使える。長さで正誤を判定しない
400Supported 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 のパスが返ってくるので、そのまま開けます。

明日からできること

  1. python3 -V を打って、3.7以上かを確かめる
  2. AI Studio で「Create API key」を押し、出た文字列を ~/.gemini_api_key に置く(⭐ AIza で始まらなくても正常です)
  3. Settings → Plan information → Set up Billing で支払いを通し、前払いなら少額から残高を入れる
  4. 記事の最後の1ファイルgemini.py として保存し、まず --dry-run を付けて打つ(ここでは1円もかかりません)
  5. --dry-run を外して1枚作る(約5円)
  6. できた out.jpg--image out.jpg で渡して、背景だけ変えてみる

画面から作るのと違って、PCのフォルダに画像が貯まっていくのが気持ちいいです。同じ文章で解像度だけ変えて比べる、といったことも数秒でできるようになりました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?