九月の夜、公式の廃止表を上から順に読み直しておりました。Imagen の節まで来たところで手が止まりました。imagen-4.0-generate-001 の行が灰色に沈んでいて、停止日は 2026 年 8 月 17 日。そのページを開いた日から見て、すでにひと月以上前のことでした。
壁紙アプリの派生バリエーション生成は、ベース画像を渡して色違いを返してもらうだけの処理です。設計そのものは何も古びていません。古びていたのは、その処理を誰に頼むかという一点でした。
この記事は、色違いバリエーションのパイプラインを現行のモデルへ引き直したときの実装記録です。モデル ID・呼び出しの形・単価は、2026 年 9 月 20 日時点の公式ページで一つずつ確かめ直しております。最初にお伝えしたいのは、移行で本当に手が入ったのは呼び出しコードではなく、採算の前提のほう だったということです。
いま画像を返してくれるモデルは三つです
廃止表 (Last updated 2026-09-17 UTC)と価格表 を突き合わせると、2026 年 9 月時点で画像を出せる現行モデルは三つに整理できます。Imagen 系はすべて停止済みで、推奨の移行先は gemini-3.1-flash-image です。
ここで一つ、表をそのまま信じると事故になる箇所があります。廃止表の gemini-2.5-flash-image の行は、推奨の移行先を gemini-3.1-flash-image-preview と書いています。ところがその preview 自身が同じページの上の節で 2026 年 6 月 25 日に停止済みと記載されています。価格表の警告文のほうは「Gemini 3.1 Flash Image または Gemini 3.1 Flash Lite Image へ」と書いており、実務上はこちらが正解です。表の「推奨の移行先」列は、同じページの停止日と突き合わせてから写しています。 移行先として preview を書き写してしまうと、動かないエンドポイントへ向かって移行することになります。
Imagen 4 の停止は 2026 年 6 月 15 日のリリースノートで予告され、8 月 17 日に実行されました。私は以前、新規コンテンツのシード生成は Imagen 4、ユーザー文脈に合わせた派生は Gemini の画像モデル、という二本立てで運用しておりました。いまはシード生成も派生も Nano Banana 系に寄せています。二本立てをやめて困ったのは品質ではなく、「どちらに投げたかでプロンプトの書き方を変えていた」分岐が要らなくなって、コードが短くなったことくらいでした。
呼び出しの形が変わりました — Interactions API
現行の画像生成ガイド のサンプルは、generate_content ではなく Interactions API で書かれています。返ってくるのも candidates[0].content.parts を舐める形ではなく、interaction.output_image という 1 か所です。base64 で入り、base64 で返ります。
import base64
import os
from google import genai
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
MODEL_PREVIEW = "gemini-3.1-flash-lite-image" # 候補出し(安い・速い)
MODEL_FINAL = "gemini-3.1-flash-image" # 採用分の作り直し(高解像度)
def encode (path: str ) -> str :
with open (path, "rb" ) as f:
return base64.b64encode(f.read()).decode( "utf-8" )
def generate_preview (base_image_path: str , instruction: str ):
"""ベース画像 + 指示文を渡して、候補を 1 枚返してもらう"""
interaction = client.interactions.create(
model = MODEL_PREVIEW ,
input = [
{ "type" : "text" , "text" : instruction},
{
"type" : "image" ,
"data" : encode(base_image_path),
"mime_type" : "image/png" ,
},
],
response_format = {
"type" : "image" ,
"mime_type" : "image/png" ,
"aspect_ratio" : "9:16" , # 壁紙なので縦
"image_size" : "1K" , # 候補は 1K で十分
},
)
image = interaction.output_image
if image is None :
return None , interaction.id
return base64.b64decode(image.data), interaction.id
採用された 1 枚だけを高解像度で作り直すときは、previous_interaction_id を渡すとやりとりの続きとして扱われます。プロンプトを一から組み直さずに「解像度だけ上げる」指示が通るのは、二段構えを組むうえで効きます。
def upscale_adopted (previous_id: str , image_size: str = "2K" ):
"""候補として選ばれた 1 枚を、配信用の解像度で作り直す"""
interaction = client.interactions.create(
model = MODEL_FINAL ,
input = "この構図と色をそのまま保ったまま、配信用の解像度で描き直してください。被写体・色相・明度は変更しないでください。" ,
previous_interaction_id = previous_id,
response_format = {
"type" : "image" ,
"mime_type" : "image/png" ,
"aspect_ratio" : "9:16" ,
"image_size" : image_size,
},
)
image = interaction.output_image
return base64.b64decode(image.data) if image else None
もう一つ、以前の実装から落とした要素があります。temperature と top_p、top_k は 2026 年 7 月 21 日に非推奨となりました。停止日はまだ発表されていませんが、揺らぎの量をパラメータで調整する前提は、もう置けません。
多様性はサンプリングではなく、プロンプトの側で明示的に分岐させて作ります 。候補 3 枚なら、指示文も 3 通り用意します。
VARIATION_PROMPTS = {
"color" : [
"被写体と構図はそのままに、夕暮れの赤みがかった色調へ変えてください。" ,
"被写体と構図はそのままに、青みの強い薄暮の色調へ変えてください。" ,
"被写体と構図はそのままに、桃色に寄せたマジックアワーの色調へ変えてください。" ,
],
"time" : [
"被写体はそのままに、月明かりの夜景へ変えてください。" ,
"被写体はそのままに、夜明け前の薄明かりへ変えてください。" ,
"被写体はそのままに、真昼の硬い光へ変えてください。" ,
],
}
パラメータで散らしていた頃は、同じ指示を 3 回投げて「違うものが返ってくること」を期待しておりました。いま思えば、期待を仕様にしていたのだと思います。指示を 3 通り書き分けるほうが、返ってこなかったときにどこを直せばよいかも明快です。
壁紙アプリでの実装パイプライン全体像
ユーザーが壁紙詳細画面で「色違いを見る」を押した瞬間に動く流れを、段ごとに置きます。どこで失敗しうるかを併記しておくと、後から監視を足す場所が決まります。
意識したのは、生成リクエストを必ずバックエンド経由にすることです。クライアントから直接呼ぶ設計は、鍵の管理と利用量の制御の両方で破綻します。無料で配るアプリに鍵を焼き込む選択を、私は取りません。
段 6 と段 7 を分けたことが、このあとの採算の話に直結します。ユーザーが選ばなかった候補に高解像度の料金を払わない——ただそれだけの構造です。
解像度で採算が変わります — 公開単価から引いた二段構え
ここが移行で一番手が入った場所です。価格表の脚注は、出力画像がトークンとして課金されることと、解像度ごとの換算額を明記しています。Nano Banana 2 の Standard なら 1K が 1,120 トークンで $0.067、2K が 1,680 トークンで $0.101、4K が 2,520 トークンで $0.151 です。
壁紙は縦に長く、配信は 2K 以上が前提になります。つまりこのアプリにとって、画像 1 枚の単価はモデル名ではなく解像度で決まります 。候補も採用も一律に 2K で作ると何が起きるか、私のアプリの利用実績(Premium ユーザーの月間利用回数の中央値 18 回・1 回あたり候補 3 枚)で引いてみます。
円換算は 1 ドル 150 円で置いています。私のアプリの Premium は月 580 円です。表の一行目、つまり候補も採用も一律 2K という素直な組み方では、キャッシュを効かせたあとでも 548 円が API に消えます。粗利は 32 円。4K 配信に踏み込んだ二行目に至っては、月額を超えて赤字になります。
二段構えに変えると、同じ体験で 365 円に収まります。一律 2K と比べて 33% 減、4K 同士で比べれば 44% 減です。数字を出してみて、移行作業のうち本当に必要だったのは呼び出しコードの差し替えではなく、この一段の分割のほうだったと分かりました。
作り置きできるものは Batch に回すと、さらに半額になります。Nano Banana 2 の Batch は画像出力が 1M トークンあたり $30 で、1K なら 1 枚 $0.034。季節ごとのシード画像のように「今すぐでなくてよい」生成は Batch へ、ユーザーの操作に応じる生成は Standard へ、という振り分けで運用しています。
候補は安く、採用だけ高く。 この線引きだけは、単価表が改定されても持ち越すつもりでおります。
生成物を機械で受け入れ判定する — pHash と色相差
指示を 3 通りに分けても、3 枚のうち 1 枚が「ほぼベース画像のまま」で返ってくる事象は残ります。等倍で並べれば気づける差でも、サムネイルサイズでは運用側の目をすり抜けます。ユーザーの画面には候補が横に並ぶので、そのうち 1 枚が実質同じ絵だと体験としては失敗です。
そこで生成直後に知覚ハッシュ(pHash)で受け入れ判定を挟んでいます。ベース画像に近すぎる候補と、候補どうしで近すぎる組を機械的に落とします。
from io import BytesIO
from PIL import Image
import imagehash
# 手元のカタログで距離分布を取ってから決めた閾値
MIN_DIST_FROM_BASE = 8 # これ未満はベースとほぼ同一とみなす
MAX_DIST_FROM_BASE = 26 # これを超えると別の絵になっている
MIN_DIST_BETWEEN = 6 # 候補どうしに求める最小距離
def perceptual_hash (image_bytes: bytes ) -> imagehash.ImageHash:
image = Image.open(BytesIO(image_bytes)).convert( "RGB" )
# 出力解像度がぶれてもハッシュが揺れないよう先に正規化しておく
return imagehash.phash(image.resize(( 256 , 256 )))
def accept_variations (base_bytes: bytes , candidates: list[ bytes ]) -> list[ bytes ]:
base_hash = perceptual_hash(base_bytes)
accepted: list[tuple[imagehash.ImageHash, bytes ]] = []
for candidate in candidates:
candidate_hash = perceptual_hash(candidate)
distance = candidate_hash - base_hash
if distance < MIN_DIST_FROM_BASE or distance > MAX_DIST_FROM_BASE :
continue # 同一すぎる / 別物になりすぎ
if any (candidate_hash - accepted_hash < MIN_DIST_BETWEEN
for accepted_hash, _ in accepted):
continue # 既に採用した候補と近すぎる
accepted.append((candidate_hash, candidate))
return [image_bytes for _, image_bytes in accepted]
閾値の決め方について補足します。imagehash.phash の既定は 64bit なので、ハミング距離の取りうる範囲は 0〜64 です。手元の壁紙で「人が見て色違いと判断できる」ペアの距離を測ったところ、大半が 8〜24 の帯に入りました。被写体の輪郭まで変わってしまった失敗例は 26 を超えることが多く、この二つの実測から上下の閾値を置いています。数字は素材の傾向で動きますので、導入時はご自身のカタログで一度分布を取っていただければと思います。
判定は候補の段(段 5)で行いますので、落ちた候補には高解像度の料金がかかりません。受け入れ判定を二段構えの前に置くか後に置くかで、同じ仕組みでも請求額が変わります。
ひとつ正直に書いておきます。pHash はグレースケール化した DCT が土台なので、色相だけを動かしたバリエーションには鈍いのです。「色違い」軸のときだけは HSV の H チャンネルを併用しています。
import numpy as np
def mean_hue (image_bytes: bytes ) -> float :
hsv = Image.open(BytesIO(image_bytes)).convert( "HSV" ).resize(( 128 , 128 ))
return float (np.asarray(hsv)[:, :, 0 ].mean())
def hue_shift (base_bytes: bytes , candidate_bytes: bytes ) -> float :
"""H チャンネル平均の差。色違い軸の検証に使う(0〜255 スケール)"""
return abs (mean_hue(candidate_bytes) - mean_hue(base_bytes))
種別が color のときは hue_shift が 12 未満の候補も落とす、という条件を pHash 判定に足しています。色相だけが動いて構図が変わらないケースは pHash の距離が伸びにくく、この一行がないと素通りしてしまいます。
キャッシュ戦略で請求をさらに削る
ベース画像が同じで、同じ種別の指示を別のユーザーが投げてくることは現実に頻繁に起こります。季節の切り替わりには、同じ絵の同じ色違いが短い期間に集中します。
私の実装では、ベース画像 ID と種別、指示文の番号を組にしたキーで Cloudflare R2 に候補をキャッシュしています。命中率は 33% 前後。先の採算表の「キャッシュ命中 33% を掛けた後」の列は、この実測を掛けた値です。
無限に溜めるとストレージ料金のほうが重くなりますので、生成から 60 日アクセスのない画像は削除しています。R2 の lifecycle policy に任せていて、運用の手は入っていません。
採用後の高解像度画像は、キャッシュの扱いを分けています。候補は使い捨ててよいのですが、ユーザーが選んで端末に取り込んだ 1 枚は、後から再ダウンロードを求められます。候補は 60 日、採用分は無期限——このひと言の違いで、問い合わせの性質が変わりました。
停止済みのモデル ID が、自分のコードに残っていないか
Imagen 4 の停止に気づいたのが、動かなくなってからではなく廃止表を読み返していたときだった、というのが今回の反省です。いつ気づくかを運任せにしないために、リポジトリを走査してモデル ID を洗い出し、手元の廃止表と突き合わせるスクリプトを CI に置きました。
まず、廃止表を JSON に書き写します。値が null なら停止日の発表なし、日付が入っていればその日に止まります。ここは自動取得にせず、自分の目で更新する場所として残しています。
{
"gemini-3.1-flash-image" : null ,
"gemini-3.1-flash-lite-image" : null ,
"gemini-3-pro-image" : null ,
"gemini-3.8-flash" : null ,
"gemini-3.6-flash" : null ,
"gemini-2.5-flash-image" : "2026-10-02" ,
"gemini-3.1-flash-image-preview" : "2026-06-25" ,
"gemini-3-pro-image-preview" : "2026-06-25" ,
"imagen-4.0-generate-001" : "2026-08-17" ,
"imagen-4.0-ultra-generate-001" : "2026-08-17" ,
"imagen-4.0-fast-generate-001" : "2026-08-17"
}
走査する側です。モデル ID らしき文字列を拾い、表と照合して、停止済み・停止予定・表に無いものを列挙します。
#!/usr/bin/env python3
"""コードに残っているモデル ID を洗い出し、公式の廃止表と突き合わせます。
python3 model_id_audit.py <走査するディレクトリ>
終了コード 1 = 停止済み、停止予定、または表に無い ID が残っている状態。
"""
import json
import re
import sys
from collections import defaultdict
from datetime import date
from pathlib import Path
TABLE_PATH = Path( __file__ ).with_name( "model_shutdown_table.json" )
MODEL_RE = re.compile( r " \b(?: gemini | imagen | veo | lyria ) - [ 0-9 ][ 0-9A-Za-z. \- ] * \b " )
SKIP_DIRS = { ".git" , "node_modules" , "dist" , "build" , "__pycache__" , ".venv" }
SUFFIXES = { ".py" , ".ts" , ".tsx" , ".js" , ".jsx" , ".swift" , ".kt" , ".json" , ".yaml" , ".yml" , ".toml" }
SELF_FILES = {Path( __file__ ).name, TABLE_PATH .name}
def load_table () -> dict :
raw = json.loads( TABLE_PATH .read_text())
return {k: (date.fromisoformat(v) if v else None ) for k, v in raw.items()}
def scan (root: Path) -> dict :
found = defaultdict( list )
for path in root.rglob( "*" ):
if not path.is_file() or path.suffix not in SUFFIXES :
continue
if SKIP_DIRS & set (path.parts) or path.name in SELF_FILES :
continue
for lineno, line in enumerate (path.read_text( errors = "ignore" ).splitlines(), 1 ):
for model_id in MODEL_RE .findall(line):
found[model_id].append( f " { path.relative_to(root) } : { lineno } " )
return found
def verdict (model_id: str , table: dict , today: date) -> str :
if model_id not in table:
return "表に無い(自分で確かめる)"
shutdown = table[model_id]
if shutdown is None :
return "現行"
if shutdown <= today:
return f "停止済み( { shutdown } )"
return f "停止予定( { shutdown } ・残 { (shutdown - today).days } 日)"
def main () -> int :
root = Path(sys.argv[ 1 ] if len (sys.argv) > 1 else "." )
table, today, found = load_table(), date.today(), scan(root)
if not found:
print ( "モデル ID は見つかりませんでした" )
return 0
width = max ( len (k) for k in found)
bad = 0
for model_id in sorted (found):
state = verdict(model_id, table, today)
if state != "現行" :
bad += 1
mark = " " if state == "現行" else "!!"
print ( f " { mark } { model_id.ljust(width) } { len (found[model_id]) :>2 } 箇所 { state } " )
if state != "現行" :
for hit in found[model_id][: 3 ]:
print ( f " - { hit } " )
print ( f " \n 走査 { root.resolve() } / 要対応 { bad } 件" )
return 1 if bad else 0
if __name__ == "__main__" :
sys.exit(main())
移行前のコードを模した小さなツリー(MODEL = "gemini-3.1-flash-image"、LEGACY_MODEL = "gemini-2.5-flash-image"、SEED_MODEL = "imagen-4.0-generate-001" と、設定 JSON に残った gemini-3.1-flash-image-preview)へ 2026 年 9 月 20 日に走らせた出力がこちらです。
!! gemini-2.5-flash-image 1 箇所 停止予定(2026-10-02・残 12 日)
- src/variations.py:2
gemini-3.1-flash-image 1 箇所 現行
!! gemini-3.1-flash-image-preview 1 箇所 停止済み(2026-06-25)
- src/config.json:1
gemini-3.6-flash 1 箇所 現行
!! imagen-4.0-generate-001 1 箇所 停止済み(2026-08-17)
- src/variations.py:3
走査 /path/to/app / 要対応 3 件
拾えたもののうち、私にとって一番ありがたかったのは三行目でした。gemini-3.1-flash-image-preview は設定ファイルのフォールバック欄に書いてあっただけで、通常の経路では呼ばれません。呼ばれないからこそテストは緑のままで、本番で主系統が落ちた日にだけ、止まったエンドポイントへ落ちていく仕掛けになっていました。
「表に無い」という判定も残しています。見慣れない ID が増えたときに気づきたいからです——打ち間違いも、自分が写し違えた表も、同じ列に出てきます。
モデル ID は設定値ではなく、期限つきの在庫として数えています。 在庫なら、残り日数を見る習慣がつきます。
本番運用で踏んだ三つの罠
設計どおりに動かない場面は必ず出てきます。私がつまずいた箇所を三つ書き残します。
罠1: 候補が三枚とも同じ絵になる
指示文を 3 通りに分ける前は、同じ指示を 3 回投げて違う絵を期待しておりました。返ってきたのはほとんど同じ絵です。サンプリングパラメータが非推奨になったいま、この書き方に戻る道はありません。
指示が具体的すぎるときにも同じことが起きます。「夕焼けの色調で」とだけ書くと、モデルが持っている「夕焼け」が毎回呼び出されるためです。赤み・青み・桃色というように、色相の側から先に言葉を置くと安定して散ります。
罠2: 出力が空で返ってくる
暗い色調の壁紙や、人物が薄く写る壁紙で、画像が返らず文字だけが返ることがあります。最初は理由が分からず、例外も出ないまま候補が 2 枚になる現象に悩みました。
いまは interaction.output_image が None かどうかを最初に見て、None なら別の指示文へフォールバックする階層にしています。安全側のフィルタは緩めずに残しています。子どもが触る可能性のあるアプリだという前提と、ストアの審査で指摘を受けたくないという事情からです。
罠3: 採用後の作り直しで別の絵になる
previous_interaction_id を渡して解像度だけ上げるつもりが、構図がわずかに動くことがあります。ユーザーは候補として見た絵を選んでいますので、届いた絵が違えば、それは素直に失敗です。
対策は二つ置きました。指示文に「被写体・色相・明度は変更しないでください」と明示すること。そして作り直した絵と候補の絵を、先ほどの perceptual_hash で照合し、距離が 6 を超えたら候補の 1K をそのまま配信することです。解像度は落ちますが、選んだ絵が届きます。
どんな個人開発者にこの構成を勧めるか
向いているのは、すでにアプリの中に画像カタログがあり、ユーザーの操作をきっかけに派生を返したい方です。壁紙に限らず、レシピの盛り付け違い、同じ商品の色違い表示、書影や商品写真の季節違いなどにも、そのまま当てはまります。
向いていないのは、毎回ゼロから新しい絵を作って主役に据えるサービスです。その場合は候補段を挟む意味が薄く、Nano Banana Pro の 1 枚に寄せたほうが結果が安定します。二段構えは「選ばれなかった候補に高い金額を払わない」ための形ですので、選ぶ体験がないなら分ける理由もありません。
まず手を動かすなら、model_id_audit.py を自分のリポジトリへ一度走らせるところからにしてください。移行先を調べるより先に、いま何を指しているかを数えたほうが、判断が早く済みます。私も今回、そこから始めるべきでした。
お読みいただきありがとうございました。同じように期限つきの値を抱えている方の、点検の順番が一つ決まれば嬉しく思います。