はじめに
EMUYN LLC が開発・無料提供する語源学習アプリのために、2800枚以上の語幹イラストを用意する必要があった。アプリは無料で提供するのでコストはできるだけ抑えたい。アイコン用の小さな挿絵なので高解像度は必要なく、意図した内容で数を揃えることが目標だった。
対象は2つのアプリだ。
- ETYMO! — 英単語を語源から理解する学習アプリ。Etymology からのネーミングです。語根・接頭辞・接尾辞の分解と語源イラストで、単語を「意味のネットワーク」として記憶できる。1672枚のイラストを使用
- METYMO! — 医療系英単語に特化した語源学習アプリ。Medical etymology の意味です。解剖・症状・手術名など臨床用語を語根から体系的に押さえられる。1150枚のイラストを使用
Gemini API の画像生成エンドポイントは従量課金。2800枚以上 × 単価でも一定額に収まるが、単語の語幹の意味を直感的に表す適切な図柄を出すにはプロンプトを調整して繰り返し生成する必要があり、再生成が重なると青天井になる。それを避けたかった。
Google AI Plus (年額 ¥9,000) も契約していたので、Web UI (gemini.google.com) を Playwright で自動操縦することにした。Web UI を使えば課金は年額固定で、生成枚数を気にせず回せる。
「Playwright で Web UI を自動操縦すればバッチ処理できるのでは?」が出発点だった。
さらに、1回の生成で4枚を取り出せる 2x2 グリッド手法を確立することで、1枚あたりの待ち時間を実質 1/4 に短縮した。
なぜ API ではなく Web UI か
| API (imagen/gemini) | Google AI Plus (年額 ¥9,000) | |
|---|---|---|
| 費用 | 生成枚数課金 | 年額固定 |
| 試行錯誤 | 再生成のたびに課金が増える | 何度回しても定額 |
| 上限 | なし(課金が増えるだけ) | 日次クォータあるが実用上余裕 |
| 自動化 | SDK で簡単 | Playwright が必要 |
試行錯誤が多いプロジェクトほど API の従量課金は読みにくい。Google AI Plus を既に契約しているなら Web UI 活用が素直な選択だ。「自動化が難しい」という欠点は、後述の常駐ワーカーで解決する。
2x2 グリッド手法とは
通常は1プロンプト → 1画像。でも Gemini は「4つのセルに分けた2x2のグリッド画像を1枚生成して」という指示を理解できる。
┌──────────┬──────────┐
│ │ │
│ 題材 A │ 題材 B │
│ │ │
├──────────┼──────────┤
│ │ │
│ 題材 C │ 題材 D │
│ │ │
└──────────┴──────────┘
これを生成後、Python で中央線(50/50)で分割して4ファイルに保存する。
実際の生成物はこんな見た目だ(4語幹を1バッチで生成):
1回の生成リクエストで4枚取得できるので待ち時間が実質 1/4。2822枚なら 706 リクエストで済む(単発なら 2822 リクエスト)。
なお、日次クォータについては今回一度も上限に達しなかった。1日1000枚以上でも問題なく処理できた。主な恩恵はクォータ節約よりも純粋な時間の短縮だ。
システム構成
Python がオーケストレータ、Node.js ワーカーが Playwright で Web UI を叩く。ブラウザは1回だけ起動して全ジョブを処理し、起動コストを吸収する。
実装
1. ログインセッションの永続化 (login.mjs)
ブラウザプロファイルを browser-profile/ に保存する。一度ログインしてしまえば以降は自動化でも認証済み状態が続く。
// headed で起動してユーザーが手動ログイン
const context = await chromium.launchPersistentContext('browser-profile', {
headless: false,
viewport: { width: 1280, height: 900 },
});
ワーカー起動時に /app へアクセスして signin にリダイレクトされたら即 exit 2 する(未ログイン早期検知)。
2. 常駐ワーカー (gemini-image-worker.mjs)
// 起動したら READY を stdout に出力し、stdin のジョブを待つ
send('READY');
for await (const raw of rl) {
if (raw.trim() === 'QUIT') break;
const [, id, promptFile] = raw.split('\t');
const { status, path: fp } = await runJob(id, promptFile);
send(`DONE\t${id}\t${status}\t${fp}`);
}
1ジョブの処理は:
-
/appを開き直して新規チャットにリセット(前ジョブの DL ボタンと混在しないように) - 「画像を作成」ツールを ON
- テキストボックスにプロンプトを
fill() - 「プロンプトを送信」ボタンをクリック
- 送信成立チェック: テキストボックスからプロンプトが消えるのを12秒確認(消えなければ例外→リトライ)
- 「フルサイズの画像をダウンロード」ボタンを待機(最大600秒)
- ダウンロードして
out/に保存
3. Pythonオーケストレータ (grid_batch.py)
グリッドプロンプトの組み立て:
DEFAULT_STYLE = (
"2x2 grid of four separate simple flat vector icons. "
"Four equal cells divided by thin light-gray lines. Plain white background. "
"NO text, NO letters, NO numbers anywhere in the image. "
"Each cell is a single clear subject centered on white, drawn exactly as described for that cell. "
"Flat vector, thick clean outlines, soft warm colors, minimal detail. "
"Keep the same line-art style and color palette across all four cells."
)
POS_LABELS = ["Top-left", "Top-right", "Bottom-left", "Bottom-right"]
# 4未満バッチでGeminiが残セルを質問して止まる事故を防ぐフィラー
FILLERS = ["a plain green apple", "a plain yellow star",
"a plain blue circle", "a plain red heart"]
def build_grid_prompt(style: str, cell_prompts: list) -> str:
lines = [style, ""]
for i, label in enumerate(POS_LABELS):
instr = cell_prompts[i] if i < len(cell_prompts) else FILLERS[i]
lines.append(f"{label}: {instr}")
return "\n".join(lines)
実際に生成するプロンプト例:
2x2 grid of four separate simple flat vector icons.
Four equal cells divided by thin light-gray lines. Plain white background.
NO text, NO letters, NO numbers anywhere in the image.
...
Top-left: a human heart muscle, anatomical but simplified
Top-right: a knee joint with cartilage, cross-section view
Bottom-left: the liver organ, smooth brown shape
Bottom-right: a plain green apple ← フィラー(3件しかない場合)
2x2 → 4ファイルの分割:
def split_2x2(img_path: Path, names: list, out_dir: Path, trim_px: int = 6) -> dict:
im = Image.open(img_path)
w, h = im.size
hw, hh = w // 2, h // 2
t = trim_px
positions = {
"TL": (t, t, hw - t, hh - t),
"TR": (hw + t, t, w - t, hh - t),
"BL": (t, hh + t, hw - t, h - t),
"BR": (hw + t, hh + t, w - t, h - t),
}
for pos, name in zip(["TL", "TR", "BL", "BR"], names):
cell = autocrop(im.crop(positions[pos]))
cw, ch = cell.size
# セルが極端に小さい = 描画崩れ → _review/ に退避
if (cw < hw * 0.35) or (ch < hh * 0.35):
cell.save(review_dir / f"{name}__REVIEW.jpg", quality=95)
else:
cell.save(out_dir / f"{name}.jpg", quality=95)
再開可能な設計:
# 既存の .jpg は完了とみなして skip → 中断しても再実行で続きから
done = {f.stem for f in out_dir.glob("*.jpg")}
remaining = [s for s in subjects if s["name"] not in done]
ネット断や Gemini のタイムアウトが起きても、python grid_batch.py jobs.json を再実行するだけで続きから処理できる。
4. ジョブJSONの書き方
{
"out_dir": "C:/path/to/output",
"style": "2x2 grid ... (省略時は DEFAULT_STYLE が入る)",
"subjects": [
{"name": "apple", "prompt": "a red apple, single object on white"},
{"name": "moon", "prompt": "a crescent moon, yellow, simple"},
{"name": "anchor", "prompt": "a ship anchor, navy blue, bold lines"},
{"name": "flame", "prompt": "an orange flame, stylized, warm colors"}
]
}
name が出力ファイル名になる。style を省略すると DEFAULT_STYLE (フラットベクター系)が使われる。
5. 実行方法
# 事前にログイン (初回のみ)
node GenerativeAiExt/login.mjs
# 一括生成 (バックグラウンドで)
python grid_batch.py jobs.json &
# または MAX_BATCHES で小分けに試す
MAX_BATCHES=3 python grid_batch.py jobs.json
6. 後処理も一気に通す
実際の運用では生成だけでなく、後処理まで含めて Claude Code に指示して一括実行していた。
生成(grid_batch.py)
→ 2x2 分割・余白トリミング(trim_margins.py)
→ 解像度調整・WebP 変換
生成物を確認したらそのまま次のバッチへ、という流れが自然にできた。
variants モード (描画崩れの救済):
「プロンプトは正しいが、今回の生成だけたまたま崩れた」場合は、同じプロンプトを4セルに埋めて4案同時生成し、最良の1枚を選ぶ。
python grid_batch.py --variants anchor "a ship anchor, navy blue" --out ./output
# → output/anchor_v1.jpg ... anchor_v4.jpg が生成される
注意事項
Playwright ブラウザには触らない
Playwright が制御しているブラウザをマウスやキーボードで操作すると、クリック座標がズレたり入力が競合したりして、以降のジョブが全滅する。
むしろheaded モードで動かすのがおすすめだ。生成過程がリアルタイムで目視確認でき、トラブルにすぐ気づける。Playwright のブラウザウィンドウ以外の通常作業はまったく支障ない。実行前に login.mjs でログインを済ませておき、実行中は Playwright のウィンドウだけ触らなければいい。
4件未満のバッチだと Gemini が質問して止まる
3件しか指示しないと、Gemini が「4つ目のセルは何を描けばいいですか?」と聞き返して生成が止まる。フィラーで必ず4セル埋めると回避できる。
FILLERS = ["a plain green apple", "a plain yellow star",
"a plain blue circle", "a plain red heart"]
保存時は names に含まれないフィラー名を無視すれば、余計なファイルは作られない。
タイムアウトを短くするとゴミが完了扱いになる
旧バージョンで 300 秒タイムアウトを設定していたとき、ネットまたはPC休止の影響で 5 分超かかった際に、プレースホルダー状態のゴミ画像(628×438)を「完了」として保存してしまい、再実行でスキップされた。タイムアウトは念のため 600 秒に延ばし、タイムアウト時は FAIL として出力ファイルを作らないようにした。
送信ボタンを押しても送信が成立しないことがある
ページの状態によってボタンが反応しないことがある。このまま DL ボタンを待つと 600 秒の無駄待ちになる。送信後にテキストボックスからプロンプトが消えるかを12秒チェックし、消えなければ例外を出してリトライに回す。
for (let i = 0; i < 24; i++) {
await page.waitForTimeout(500);
const cur = (await textbox.innerText()).trim();
if (!cur.includes(promptSnippet)) { submitted = true; break; }
}
if (!submitted) throw new Error('送信が成立しませんでした');
成果
metymo/etymo で生成した内訳:
| アプリ | カテゴリ | 枚数 |
|---|---|---|
| metymo | anatomy (器官・骨格) | 364 |
| metymo | descriptor (抽象・形容) | 603 |
| metymo | prefix (接頭辞) | 98 |
| metymo | suffix (接尾辞) | 85 |
| etymo | 語根・接辞イラスト | 1672 |
| 合計 | 2822 |
cardi (心臓)・hepat (肝臓)・gastr (胃)・nephr (腎臓) をまとめて1バッチで生成するといった具合だ。1バッチ(4枚)あたり約25〜50秒(実測。Gemini の負荷・プロンプト量で変動)。2822枚 ÷ 4 = 約706バッチ × 約40秒でコア生成時間は約8時間。単発で同枚数を回すと30時間以上かかる計算だ。
半分以上のイラストは矢印を1〜2本加える程度でほぼそのまま採用できた。残りは再生成させ、再度チェック。採用できない約1割は手作業でプロンプトを練り直して再生成する必要があった。ただ、専用のレビューアプリを用意してここも効率化した(詳細は割愛)。破綻セルは自動で _review/ に退避されるため、後からまとめて再生成できる。最終的に不良0で完走した。
まとめ
Google AI Plus の Web UI を Playwright で自動操縦し、4題材を2x2のグリッド画像として1リクエストにまとめて生成後に分割する。これだけで待ち時間を1/4にでき、1日1000枚以上も現実的なスループットになる。わずか年額 ¥9,000 のプランだが API 従量課金より使いやすく、語幹ごとに何度描き直しても課金が増えない点も助かった。
Python のオーケストレータと Node.js の常駐 Playwright ワーカーに分離した構成で、再開可能な設計と破綻セル自動退避を組み込んでおくと、長時間バッチが安心して回せる。

