Nano Banana 2.1をOfoxで使う:画像を生成・保存・編集する方法
Nano Banana 2.1をOfox経由で呼び出し、画像の保存と連続編集を行う手順。Pythonコードと実際のAPI応答で、履歴の扱いと制限を確認します。
2026年10月13日更新。 Ofoxは、このガイドの発行元であり、ここで説明するリクエストに使ったAPIゲートウェイでもあります。この記事はAIの支援を受けて作成し、実際のAPI・ツールの結果と照合したうえで、公開前に別のAIによる独立したレビューを受けています。以下で取り上げる6件の画像リクエストは、Ofox経由のGeminiネイティブプロトコルを使って実際に実行したものです。掲載するプログラムは、ご自身でリクエストを実行するための例であり、あらゆる経路や設定をテスト済みだと示すものではありません。
Nano Banana 2.1のOfoxモデルIDは google/gemini-nano-banana-2.1 です。ここで比較に使う旧モデルのIDは google/gemini-3.1-flash-image で、Nano Banana 2とも呼ばれています。リクエストを移行する際に、旧モデルのIDをそのまま使わないでください。Ofoxの公開モデルページでは、2.1がGeminiプロトコルとOpenAIプロトコルに対応し、プロバイダーがVertexであることを確認できます。このガイドで動作を確認したリクエストは、OpenAI互換の経路ではなく、GeminiネイティブのgenerateContent経路を使っています。
ポスターを生成して、返された画像データをディスクに保存し、必要に応じてモデルのレスポンス全体を引き継ぎながら2回編集します。別の方法として、手元にある画像をアップロードする手順も紹介します。編集やレイアウトの判断について詳しく知りたい場合は、ポスター編集ガイドを参照してください。
最初のリクエストの前に用意するもの
モデルを利用できるOfox APIキー、十分なアカウント残高、Python 3、requestsパッケージが必要です。依存パッケージをインストールし、シェルでキーを設定します。
python -m pip install requests
export OFOX_API_KEY="YOUR_OFOX_API_KEY"
キーをソースファイル、スクリーンショット、共有ログに含めないでください。動作確認済みのリクエストで使ったAPIエンドポイントは次のとおりです。
POST https://api.ofox.ai/gemini/v1beta/models/google/gemini-nano-banana-2.1:generateContent
x-goog-api-key: YOUR_OFOX_API_KEY
以下のリクエストでは、テキストと画像の両方をレスポンス形式として指定し、アスペクト比を正方形、画像サイズを1Kに設定しています。Googleの画像生成ドキュメントでは、1K、2K、4Kの選択肢が説明されています。ただし、この記事でゲートウェイ経由の実行を確認したのは1Kのみです。ドキュメントの記載を、ご利用の経路で全サイズをテストした証拠と見なさないでください。このモデルでは、512はドキュメントに記載されたサイズの選択肢ではありません。
Pythonプログラム全体
これをnano21.pyとして保存します。標準の実行では、画像生成リクエストを1回送信します。--chainを付けると、そのリクエストの後にテストで使った2つの編集プロンプトを正確に送信します。--upload PATHを付けると、指定したローカル画像と見出し編集プロンプトを使って、新しいリクエストを開始します。このプロンプトを意図どおりに使うには、元のポスター画像を指定してください。この2つのオプションは同時に使えません。
import argparse
import base64
import binascii
import json
import mimetypes
import os
from pathlib import Path
import sys
import requests
URL = (
"https://api.ofox.ai/gemini/v1beta/models/"
"google/gemini-nano-banana-2.1:generateContent"
)
PROMPTS = [
"Create a square minimalist event poster on warm cream paper. Large exact headline: ORCHARD MARKET. Smaller exact line: SATURDAY 10 AM. Draw three red apples in a blue ceramic bowl below the text. No other text, no logos, no border.",
"Change only the headline from ORCHARD MARKET to ORCHARD FAIR. Keep SATURDAY 10 AM exactly unchanged. Preserve the three red apples, blue bowl, cream background, composition and all other pixels as closely as possible.",
"Now change only the bowl from blue to dark green. Preserve ORCHARD FAIR, SATURDAY 10 AM, the three red apples and the layout.",
]
EXTENSIONS = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}
def send(contents, api_key):
body = {
"contents": contents,
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": {
"aspectRatio": "1:1",
"imageSize": "1K",
},
},
}
try:
# Deliberately one attempt: image requests are not automatically retried.
response = requests.post(
URL,
headers={"x-goog-api-key": api_key},
json=body,
timeout=(15, 180), # connection timeout, then read timeout
)
except requests.RequestException as exc:
raise RuntimeError(f"Request failed before a usable response: {exc}") from exc
try:
data = response.json()
except ValueError as exc:
raise RuntimeError(
f"HTTP {response.status_code}; non-JSON response: "
f"{response.text[:1000]}"
) from exc
if not response.ok:
error = data.get("error", data) if isinstance(data, dict) else data
raise RuntimeError(
f"HTTP {response.status_code}; API error: "
f"{json.dumps(error, ensure_ascii=False)[:2000]}"
)
if not isinstance(data, dict):
raise RuntimeError("Successful HTTP response was not a JSON object.")
return data
def save_response(data, output_dir, turn_number):
candidates = data.get("candidates") or []
if not candidates or not isinstance(candidates[0], dict):
raise RuntimeError(f"No candidate returned: {json.dumps(data)[:2000]}")
candidate = candidates[0]
finish_reason = candidate.get("finishReason")
if finish_reason not in (None, "STOP"):
raise RuntimeError(
f"Generation did not finish normally ({finish_reason}): "
f"{json.dumps(candidate, ensure_ascii=False)[:2000]}"
)
# Keep this original object intact for the next turn. In particular, do not
# rebuild its parts from printed text and saved image files.
content = candidate.get("content")
if not isinstance(content, dict):
raise RuntimeError("First candidate has no content object.")
output_dir.mkdir(parents=True, exist_ok=True)
image_count = 0
for part in content.get("parts", []):
if "text" in part and part["text"]:
print(f"text: {part['text']}")
inline = part.get("inlineData")
if not inline:
continue
mime = inline.get("mimeType", "").split(";", 1)[0].lower()
if not mime.startswith("image/"):
continue
encoded = inline.get("data")
if not isinstance(encoded, str):
raise RuntimeError("Image part has no base64 string.")
try:
image_bytes = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise RuntimeError("Image part contains invalid base64.") from exc
if not image_bytes:
raise RuntimeError("Image part decoded to zero bytes.")
image_count += 1
extension = EXTENSIONS.get(mime, ".bin")
path = output_dir / f"turn-{turn_number}-image-{image_count}{extension}"
path.write_bytes(image_bytes)
print(f"saved image: {path} ({mime})")
if not image_count:
raise RuntimeError(
"Response contained no image inlineData; inspect the returned "
"candidate and any text instead of treating text as an image."
)
if "usageMetadata" in data:
print("usageMetadata:", json.dumps(data["usageMetadata"]))
return content
def uploaded_image_part(path):
mime, _ = mimetypes.guess_type(path.name)
if mime not in EXTENSIONS:
raise ValueError("Upload a .png, .jpg, .jpeg, or .webp image.")
image_bytes = path.read_bytes()
if not image_bytes:
raise ValueError("Upload file is empty.")
return {
"inlineData": {
"mimeType": mime,
"data": base64.b64encode(image_bytes).decode("ascii"),
}
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--chain", action="store_true")
parser.add_argument("--upload", type=Path)
parser.add_argument("--output-dir", type=Path, default=Path("outputs"))
args = parser.parse_args()
if args.chain and args.upload:
parser.error("--chain and --upload are separate workflows")
api_key = os.environ.get("OFOX_API_KEY")
if not api_key:
parser.error("Set OFOX_API_KEY in the environment first")
if args.upload:
if not args.upload.is_file():
parser.error(f"Image file does not exist: {args.upload}")
# A new conversation with an uploaded image, not the earlier
# generation/edit conversation.
contents = [{
"role": "user",
"parts": [
uploaded_image_part(args.upload),
{"text": PROMPTS[1]},
],
}]
result = send(contents, api_key)
save_response(result, args.output_dir, 1)
return
contents = []
selected_prompts = PROMPTS if args.chain else PROMPTS[:1]
for turn_number, prompt in enumerate(selected_prompts, start=1):
contents.append({"role": "user", "parts": [{"text": prompt}]})
result = send(contents, api_key)
model_content = save_response(result, args.output_dir, turn_number)
# Preserve the full returned content, including inline image data,
# text, and any signatures on parts, for the next request.
contents.append(model_content)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, RuntimeError) as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
1回だけ呼び出すには、python nano21.pyを実行します。3ターンすべて実行するには、python nano21.py --chainを実行します。すでにディスク上にあるポスターを新しい会話で編集するには、python nano21.py --upload outputs/turn-1-image-1.pngを実行します。これらは上記ファイルを実行するPythonのコマンド例であり、Ofox専用のCLIコマンドではありません。
画像を含むレスポンスでは、saved image: outputs/turn-1-image-1.png (image/png)のような行に加え、返されたテキストとusageMetadataが表示される想定です。この行は出力形式の例であり、このスクリプトを別途実行した際の記録ではありません。動作確認したポスター生成リクエストはHTTP 200でSTOPとなり、1024 × 1024のPNG画像が含まれていました。また、入力トークン54、報告されたテキスト出力トークン80、画像出力トークン1,120、推論トークン639が記録されていました。生のレスポンスには表示可能なテキストパートがありませんでしたが、TEXT出力トークンは80と報告されていました。この数値は返された使用量メタデータとして扱い、表示された文章の量とは見なさないでください。実際に返されるテキスト、メタデータ、所要時間、画像は異なる場合があります。
このコードは、最初の候補に含まれるすべての画像inlineDataパートを保存します。最初のパートが画像だと決めつける処理にはなっていません。レスポンスのMIMEタイプに応じてファイル拡張子を選び、認識できない画像MIMEタイプの場合はPNGと誤表示せず.binとして保存します。HTTPエラーは可能な場合にJSONとして解析し、画像がない場合は成功ではなく失敗として扱います。意図的に自動再試行は行いません。タイムアウト後に無条件で生成を繰り返すと、別の画像が生成され、追加料金が発生する可能性があります。リクエストが失敗した場合は、再送信するかどうか決める前に、ステータス、エラーJSON、アカウントの状態を確認してください。GoogleのトラブルシューティングガイドはGemini側のエラー分類を理解するのに役立ちますが、Ofoxゲートウェイから返された内容は、それ自体の情報として確認してください。
編集履歴にはレスポンス全体が必要
保存したPNGは生成物であり、会話履歴の代わりにはなりません。--chainの処理では、次のリクエストに、前のユーザーターン、それに続くモデルのcandidates[0].content全体、そして新しいユーザーターンを含めます。このコンテンツには、テキスト、インライン画像データ、パート単位の思考署名が含まれることがあります。画像ファイルと表示されたテキストだけから履歴を組み立て直すと、後続リクエストの処理に必要なデータを失うおそれがあります。
そのため、アップロード経路は意図的に別にしています。--uploadは、ローカルファイルのMIMEタイプとBase64データを含むinlineDataパートを、指示と一緒に送信します。アップロードしたファイルを過去のモデルターンとして扱ったり、過去のターンの署名を再現したりするものではありません。ほかの商品画像を保持する編集については、マスクを使って製品を保持する方法とシーン参照1枚と2枚の比較を参照してください。
「変更するのは〜だけ」という指示は強い編集指示ですが、ピクセルを固定する機能ではありません。3段階のテストでは、2.1と旧モデルの両方が、最初のポスターに指定した英語の見出しと時刻を生成しました。編集時には、どちらも見出しをORCHARD FAIRに変更し、SATURDAY 10 AM、赤いリンゴ3個、青いボウルを維持しました。3ターン目では、どちらもボウルを濃い緑色にし、指定した文言を維持しました。どちらもラスタ画像であり、編集可能なレイヤーは確認できませんでした。また、どちらも「枠なし」という指示にかかわらず、ポスターを外側の余白や影がある紙の上に配置しました。特に文字、製品の細部、レイアウトが重要な場合は、編集結果を採用する前に実際に返された画像を確認してください。
このテストでは、各モデルにつき3段階のチェーンを1回ずつ実行しました。プロンプトは共通で、設定は1K、1:1とし、シードや推論レベルは指定していません。6件すべてのリクエストがHTTP 200でSTOPとなり、1024 × 1024のPNG画像が返されました。これは信頼性を繰り返し評価したテストでも、総合的な品質ランキングでもありません。また、モデルごとに異なるデフォルト設定が使われている可能性があります。以下の画像は、Playgroundの実行画面のスクリーンショットではなく、APIリクエストで得られた結果です。
| 手順 | Nano Banana 2.1 | 旧Nano Banana 2 |
|---|---|---|
| ポスター | ![]() | ![]() |
| 見出しの編集 | ![]() | ![]() |
| ボウルの編集 | ![]() | ![]() |
関連ファイルは比較用キットのダウンロードから入手できます。既存の連携を移行する場合は、2.1と2の比較で、観察された挙動と、より広い移行上の判断を分けて確認できます。
公開モデルページと認証済みAPIの実行は別

英語画面のスクリーンショットには、公開モデル情報とPlaygroundへの入口が表示されています。Playgroundで画像を生成した証拠ではありません。
モデルページには、プロンプト欄、添付機能、後続入力のための操作項目を備えたPlaygroundが埋め込まれています。Playgroundの利用にはログインが必要です。また、アスペクト比の説明では、指定した比率はベストエフォートとされています。そのため、UIで比率を選んでも、すべての結果でその比率が保証されるとは限りません。このガイドでは、認証済みのOfox Agentインターフェースをテストしたとは主張しておらず、Agentが確実に2.1を選択するとも述べていません。再現可能な連携が必要な場合は、上記のとおり正確なモデルIDを指定し、APIレスポンスを確認してください。
移行時に確認する制限と料金
google/gemini-3.1-flash-imageからgoogle/gemini-nano-banana-2.1へのIDの変更は、単なる表記の置き換えではありません。レスポンスの解析処理、画像サイズの選択、出力トークンの上限、テキストや思考出力に関する前提を確認してください。測定したポスター生成リクエストでは、旧モデルのポスターレスポンスに報告されていなかったテキスト出力トークンと推論トークンが、2.1では報告されました。ある項目が報告されていないからといって、その使用量がゼロだとは限りません。同じプロンプトを使っても、使用量やデフォルト設定が一致するとは限りません。レスポンス全体を保持し、旧モデルの課金計算の前提に頼らず、返された使用量を記録してください。
現在の公開カタログのスナップショットでは、2.1の料金は入力トークン100万個あたり$1.50、テキスト出力トークン100万個あたり$7.50、画像出力トークン100万個あたり$30と記載されています。ウェブ検索の料金は、別のリクエスト単位の区分です。旧モデルには異なる料金が記載されています。これらはカタログ上の情報であり、請求書で確認された料金でも、生成画像1枚あたりの固定料金でもありません。Googleの料金ページにも、2.1の1K出力は画像トークン1,120個と記載されていますが、4K画像のトークン数については公開されているGoogle資料の間で違いがあります。ここではゲートウェイ経由の4K実行は行っていません。バッチ処理の料金を予測したり、画像トークン単価が安ければリクエスト全体も安くなると想定したりする前に、料金分析を確認してください。
Googleのモデルドキュメントでは、複数の画像参照などの機能が説明されており、画像生成ガイドでは画像設定が解説されています。公式ドキュメントに記載された機能は、Ofoxのこの経路ですべての設定、参照画像の数、解像度を実際に試したという意味ではありません。特に、このVertexをプロバイダーとするモデルで動作すると確認していないシード、温度、topPの調整方法を、使えるものとしてこのリクエストに追加しないでください。
最後に、生成の失敗と利用権は別の問題です。画像が空のレスポンスやリクエストエラーが発生した場合は、実際のJSONとステータスを確認してください。画像が返されないエラーのガイドでは、その手順を詳しく説明しています。公開や商用利用にあたっては、提供する画像を使う権利があることを確認し、適用されるGeminiの利用規約に従ってください。SynthIDに関する情報は、目に見える透かし、所有権、独占性、権利侵害がないこと、機密データの保護を保証するものではありません。








