移行の準備をしていて、手が止まった瞬間がありました。
呼び出し箇所の棚卸しは終わっていました。停止対象のモデルIDを叩いているスクリプトも、月末バッチも、洗い出せていました。あとは新しいGAモデルに差し替えれば済む——そう思っていたところで、壁紙アプリのアセットディレクトリを開いてしまったのです。
そこには、停止するモデルで生成した画像が数百枚ありました。すでに配信済みのもの、これから新しい端末解像度に合わせて書き出し直す予定のもの。私は、そのうちの何枚が「同じプロンプトを新モデルに投げれば同じ絵柄で作り直せる」のか、まったく答えられませんでした。
移行とは、コードのモデルIDを書き換えることではありませんでした。過去に作ったアセットの再生成可能性を、停止日より前に確定させることでした。
呼び出し箇所の移行が終わっても、資産の移行は終わっていない
停止告知への対応は、たいてい二段階で語られます。呼び出し箇所を洗い出し、移行先モデルを決め、差し替える。ここまでは8月17日の停止に備えて呼び出し箇所を洗い出す手順で扱った領域です。
けれど画像生成は、テキスト生成と決定的に違う点があります。出力そのものが長期保管される資産になる、という点です。
テキスト生成なら、モデルが変わっても同じ入力から「意味的に等価な」出力を得られれば実務上は困りません。画像は違います。壁紙アプリで「同じシリーズの5枚」として配信している画像のうち1枚だけを新モデルで作り直すと、色調も筆致も揃いません。並べた瞬間に破綻が見えます。
つまり私が直面していたのは、こういう構造でした。
| 対象 | 停止日までにやること | 停止日を過ぎたら |
| コードの呼び出し箇所 | モデルIDを差し替える | 差し替えれば動く(いつでも回復可能) |
| 生成済みアセット | 再生成可能かを判定する | 判定手段そのものが消える |
右下のセルが本質です。停止後は、旧モデルを呼べません。だから「旧モデルの出力と新モデルの出力がどれくらい似ているか」を測ることが、原理的に不可能になります。判定は停止日より前にしか行えない。この非対称性に気づいたとき、優先順位が入れ替わりました。
何を記録していなかったか — provenance の欠落
判定をしようとして、最初の壁にぶつかりました。手元のアセットには、何のモデルで、どんなプロンプトから作ったのかが残っていなかったのです。
ファイル名は wp_aurora_03.png のような通し番号。生成スクリプトはプロンプトを変数に持ったまま実行され、成功したら画像だけを保存して終了していました。私自身、当時は「またすぐ作り直せる」と思っていたので、記録する動機がなかったのです。モデルが消える日が来るとは想像していませんでした。
この後悔は、そのまま設計要件になりました。生成の瞬間にしか手に入らない情報があります。それを取りこぼすと、あとから復元できません。
台帳(provenance ledger)に最低限残すべき項目は、次の6つに落ち着きました。
| 項目 | なぜ必要か |
model_id | 停止対象かどうかの一次判定に使う。エイリアス(-latest)ではなく解決後の実体を記録する |
prompt / negative_prompt | 再生成の唯一の入力。1文字の差で絵柄が変わるため原文のまま保存する |
params | アスペクト比・枚数・safety設定など、出力を左右する全パラメータ |
output_sha256 | アセットと台帳行の紐付け。ファイル名の変更に耐える |
embedding | 新旧出力の類似度を測る基準ベクトル。停止後も残る「旧モデルの痕跡」 |
generated_at | モデルの微更新期間を跨いだ差分の切り分けに使う |
5行目の embedding が、この設計の要です。停止後に旧モデルは呼べませんが、旧モデルが出力した画像そのものは手元に残ります。その画像を gemini-embedding-2 で埋め込みベクトルに変換して保存しておけば、停止後であっても「新モデルの出力がどれだけ元の絵柄から離れたか」を測り続けられます。消えるのはモデルであって、出力の痕跡ではありません。
既存アセットへ台帳を後付けする
理想は生成時に記録することです。ですが、すでに数百枚が記録なしで存在しています。ここは諦めて、復元できる情報だけで台帳を起こす方針にしました。個人開発では、過去の自分が残さなかったものを現在の自分が引き受けるしかありません。
プロンプトは、当時のスクリプトとGitの履歴から可能な範囲でサルベージしました。埋め込みは、いま画像さえあれば計算できます。次のスクリプトで、アセットディレクトリを走査して台帳の初期行を作ります。
# build_ledger.py — 既存アセットから provenance 台帳の初期行を作る
import hashlib
import json
import sqlite3
from pathlib import Path
from google import genai
client = genai.Client() # GEMINI_API_KEY を環境変数から読む
ASSETS = Path("assets/wallpapers")
DB = Path("provenance.sqlite3")
SCHEMA = """
CREATE TABLE IF NOT EXISTS asset (
output_sha256 TEXT PRIMARY KEY,
path TEXT NOT NULL,
model_id TEXT,
prompt TEXT,
params_json TEXT,
embedding BLOB,
generated_at TEXT,
verdict TEXT DEFAULT 'unjudged'
);
"""
def sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def embed_image(path: Path) -> list[float]:
"""gemini-embedding-2 で画像を埋め込む。画像とテキストが同一空間に載る。"""
uploaded = client.files.upload(file=str(path))
res = client.models.embed_content(
model="gemini-embedding-2",
contents=uploaded,
config={"task_type": "RETRIEVAL_DOCUMENT"},
)
return res.embeddings[0].values
def main() -> None:
conn = sqlite3.connect(DB)
conn.executescript(SCHEMA)
# 当時のスクリプトからサルベージしたプロンプト表(無いものは None のまま)
salvaged = json.loads(Path("salvaged_prompts.json").read_text(encoding="utf-8"))
for path in sorted(ASSETS.glob("*.png")):
digest = sha256_of(path)
row = conn.execute(
"SELECT 1 FROM asset WHERE output_sha256 = ?", (digest,)
).fetchone()
if row:
continue # 冪等: 同じ画像は二度入れない
meta = salvaged.get(path.name, {})
vec = embed_image(path)
conn.execute(
"INSERT INTO asset (output_sha256, path, model_id, prompt, params_json,"
" embedding, generated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
digest,
str(path),
meta.get("model_id"),
meta.get("prompt"),
json.dumps(meta.get("params", {}), ensure_ascii=False),
json.dumps(vec).encode("utf-8"),
meta.get("generated_at"),
),
)
conn.commit()
print(f"ledger += {path.name} ({digest[:12]})")
conn.close()
if __name__ == "__main__":
main()
output_sha256 を主キーにしているのは、ファイル名のリネームや配置換えに台帳を耐えさせるためです。私のアセットは配信先ごとにディレクトリを移動することがあり、パスを主キーにすると台帳が一日で腐ります。
冪等性を最初から入れているのにも理由があります。埋め込みAPIの呼び出しは有料で、しかも枚数に比例します。手元の587枚に対して初回の台帳構築は約9分、埋め込みコストは1ドルにも届きませんでした。安いのですが、スクリプトを途中で止めて再実行するたびに全件を投げ直すのは、単純に無駄です。
プロンプトがサルベージできなかった行は prompt IS NULL のまま残しました。この行こそが、後で最も重要な意味を持ちます。
再生成可能性を測る — cos類似度 0.86 の境界
台帳が揃ったら、判定に入ります。手順はシンプルです。
- 台帳から
prompt IS NOT NULL の行を取り出す
- 同じプロンプトとパラメータを、移行先の新モデルに投げる
- 出てきた画像を
gemini-embedding-2 で埋め込む
- 台帳に保存してある旧モデル出力の埋め込みとの cos 類似度を計算する
- しきい値で
regenerable / frozen を判定し、台帳の verdict を更新する
# judge_regenerability.py — 新モデル出力と旧アセットの距離で判定する
import json
import sqlite3
import numpy as np
from google import genai
client = genai.Client()
NEW_MODEL = "gemini-3-image" # 移行先の GA モデル
THRESHOLD = 0.86
def cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
def regenerate(prompt: str, params: dict) -> bytes:
res = client.models.generate_images(
model=NEW_MODEL,
prompt=prompt,
config={
"number_of_images": 1,
"aspect_ratio": params.get("aspect_ratio", "9:16"),
},
)
return res.generated_images[0].image.image_bytes
def judge(conn: sqlite3.Connection) -> None:
rows = conn.execute(
"SELECT output_sha256, prompt, params_json, embedding FROM asset"
" WHERE prompt IS NOT NULL AND verdict = 'unjudged'"
).fetchall()
for digest, prompt, params_json, emb_blob in rows:
old_vec = np.array(json.loads(emb_blob.decode("utf-8")))
image_bytes = regenerate(prompt, json.loads(params_json))
new_vec = np.array(embed_bytes(image_bytes))
score = cosine(old_vec, new_vec)
verdict = "regenerable" if score >= THRESHOLD else "frozen"
conn.execute(
"UPDATE asset SET verdict = ? WHERE output_sha256 = ?", (verdict, digest)
)
conn.commit()
print(f"{digest[:12]} score={score:.3f} -> {verdict}")
# プロンプト不明の行は、入力が無い時点で再生成不能として確定させる
conn.execute(
"UPDATE asset SET verdict = 'frozen' WHERE prompt IS NULL AND verdict = 'unjudged'"
)
conn.commit()
しきい値 0.86 は、天から降ってきた数字ではありません。私の壁紙アセット587枚のうち、プロンプトが判明していた412枚で実測した分布から決めました。
| cos類似度の帯 | 件数 | 目視した印象 |
| 0.92 以上 | 131 | 並べても違和感なし。シリーズ内で混在させられる |
| 0.86 〜 0.92 | 168 | 細部は違うが、色調と構図は保たれる。単体差し替えなら許容 |
| 0.78 〜 0.86 | 84 | 別の絵になっている。同一シリーズには入れられない |
| 0.78 未満 | 29 | 主題すら変わることがある。抽象度の高いプロンプトに集中 |
0.86 と 0.78 のあいだで、目視の判断がはっきり割れました。0.86 を境に置いたのは、「同一シリーズに混ぜられるか」という私の配信上の要件に一致したからです。この数字は、みなさんのアセットの性質によって動きます。しきい値を借用せず、必ず自分の資産で分布を取ってから決めてください。私の場合、判定の全工程は412枚に対して約38分、新モデルでの再生成コストは実費で数百円の規模でした。停止日を過ぎたら二度と払えない、安い保険です。
そして、プロンプトの残っていなかった175枚。これらは類似度を測るまでもなく frozen です。入力が失われている以上、どんなモデルでも復元できません。台帳を作らなければ、この175枚が「作り直せると思い込んでいる資産」としてパイプラインの中に紛れ続けていたはずです。
凍結資産を「再生成しないもの」として扱う二層パイプライン
判定が終わると、資産は二種類に分かれます。ここからの設計が実務の肝です。
私が採ったのは、生成と派生を分離する構成でした。
| 層 | 入力 | 使うモデル | frozen アセットの扱い |
| 生成層 | プロンプト | 新GAモデル | 実行しない(台帳の verdict で除外) |
| 派生層 | 既存の画像ファイル | 使わない(画像処理のみ) | 通常どおり実行する |
壁紙アプリで新しい端末解像度に対応するとき、私はこれまで「元のプロンプトから新しい解像度で生成し直す」やり方を採っていました。生成モデルに任せたほうが、ただの拡大よりディテールが自然に出るからです。端末解像度ぴったりの書き出しパイプラインは、その前提で組んでいました。
しかし frozen アセットには、この道が使えません。だから派生層では、生成を諦めて画像処理に切り替えます。既存の PNG を高品質にリサイズし、必要ならクロップして、モデルを一切通さずに書き出す。ディテールの自然さは少し落ちます。それでも、シリーズの一貫性が壊れるほうが、私のアプリではずっと痛いのです。この判断は、絵柄の統一が価値の中心にある壁紙アプリだからこそ成り立ちます。単発のヒーロー画像なら、迷わず新モデルで作り直すでしょう。
パイプラインの分岐は、台帳を一行引くだけで済みます。
def build_variant(digest: str, target_size: tuple[int, int], conn) -> Path:
(verdict, path, prompt, params_json) = conn.execute(
"SELECT verdict, path, prompt, params_json FROM asset WHERE output_sha256 = ?",
(digest,),
).fetchone()
if verdict == "regenerable":
# 生成層: 新モデルで解像度に合わせて作り直す
params = json.loads(params_json)
params["aspect_ratio"] = to_aspect_ratio(target_size)
return save(regenerate(prompt, params), digest, target_size)
# 派生層: モデルを通さず、既存ファイルから画像処理だけで作る
return resize_without_model(Path(path), target_size, digest)
verdict を見て分岐する、たったこれだけです。しかしこの一行の条件式が成立するために、停止日より前に判定を終わらせておく必要がありました。逆に言えば、判定さえ済んでいれば、停止日はただのカレンダー上の一日として通り過ぎていきます。
公式ドキュメントに書かれていない運用上の勘所
実際に回してみて分かったことを、いくつか共有します。
エイリアスのまま記録すると台帳が無意味になります。 -latest 系のエイリアスを model_id に書いてしまうと、あとから「その画像を作ったのは実際どのモデルだったのか」が永久に分かりません。生成レスポンスから解決後の実体モデル名を取り、それを記録してください。私は初回の台帳で3枚だけエイリアス表記が混じっており、判定対象から外さざるを得ませんでした。
埋め込みは task_type を揃えないと類似度が歪みます。 旧アセットを RETRIEVAL_DOCUMENT で埋め込み、新出力を RETRIEVAL_QUERY で埋め込むと、同じ画像同士でも cos 類似度が有意に下がります。比較目的なら両側を同一の task_type に揃えるのが正解です。この点は埋め込みの task_type 不一致で検索精度が落ちる話と根が同じで、私も一度これで判定を丸ごとやり直しました。
判定は停止日ぎりぎりに回さないでください。 新モデルへの再生成リクエストが412件、しかも移行期はレート制限に他の移行組も殺到します。私は日を分けて3回に割り、1回あたり150件前後で走らせました。停止1週間前に全件を一気に、という計画は破綻しやすいはずです。
frozen は失敗ではありません。 判定を始めた当初、私は frozen の枚数を減らそうとしてプロンプトのサルベージに時間をかけすぎました。けれど再生成できないと分かっているアセットは、それが分かっているだけで十分に扱えます。危険なのは、再生成できると誤認したまま停止日を迎え、パイプラインが黙って別の絵を吐き出すことのほうです。
停止日までに、何を確定させておくか
コードのモデルIDは、停止日を過ぎてからでも直せます。落ちたら直せばいい。けれどアセットの再生成可能性だけは、旧モデルが生きているあいだにしか測れません。この非対称性が、優先順位のすべてを決めます。
いま画像生成モデルに依存したパイプラインをお持ちなら、まず一枚だけでいいので試してみてください。手元のアセットを1つ選び、そのプロンプトを新しいGAモデルに投げ、出てきた画像を並べてみる。それが「同じシリーズに混ぜられるか」を自分の目で見る。そこから逆算して、台帳に何を残すべきかが見えてきます。
私自身、まだ移行の途中です。frozen と判定した175枚をどう扱うか——将来のリニューアルで作り直すのか、そのまま保ち続けるのか——結論は出ていません。ただ、どの175枚なのかを知っている状態と、知らない状態では、次に打てる手がまったく違います。台帳を作ってよかったと思うのは、たぶんこの一点に尽きます。
お読みいただきありがとうございました。