マルチステップ画像生成AIで、LLMが会話にない場面を描く問題に試した工夫
はじめに
会話を絵にしてXにシェアする、というアイデアの個人開発をしていました。ユーザーとAIの会話を読み込んで、その内容に合うアニメ調のイラストを自動生成するサービスです。技術スタックはReact Router 7 + Hono + Mastra + Replicate (Flux 1.1 Pro) です。
開発の中で何度もぶつかった問題があります。LLMが、会話に登場しない場面まで描いてしまうことです。
たとえば「今日は唐揚げを揚げて、ちょっと焦がしちゃった」のように料理の話しかしていない会話なのに、抽出される場面の中に「公園で散歩する」「本を読む」のようなものが混ざります。
このサービスは
- 会話から具体的な場面を抽出(moment extraction)
- それをイラストのレイアウトに落とす(scene composition)
- それを画像生成AIへの自然言語プロンプトに変換(flux prompt)
- 生成画像の余白を計測して、テキスト配置を決める(text layout)
という4ステップで動きます。1段目で「散歩する」を出されると、2段目以降は散歩シーンを前提に積み上がっていき、途中で逸脱を直す機会がありません。
そこで、LLMの逸脱を抑える方向で4つの工夫を試しました。
工夫1: 何を描くかではなく、何を描かないかを先に書く
最初にやったのは、moment extractionのプロンプトの一番上に禁止条項を置くことでした。
## 最重要ルール: 会話内容への忠実さ
- 会話で実際に話されている話題・状況・行動のみを描写すること
- 会話に登場しない場面や行動を創作しないこと
- ユーザーが料理の話をしていれば料理の場面を、
読書の話をしていれば読書の場面を描く
最初は「料理の場面を描いてください」のような推奨形で書いていました。しかしこれだと、LLMは描けそうな場面を物語的に補完してきます。「料理の場面 + 食卓を囲む友達 + 食後の散歩」のように、会話に登場しないシーンが混ざってしまうのです。
禁止形で「会話に登場しない場面や行動を創作しないこと」と書くと、出力が安定しました。LLMは描いていい場面を思いつくのは得意ですが、何を描いてはいけないかは明示しないと知らないようです。
書きたいタイプのプロンプトでは、許可よりも禁止を先に書く方針が効いている印象でした。
工夫2: Structured OutputとJSON出力例を二重に置く
各ステップはMastraのStructured OutputでZodスキーマを渡しています。moment extractionの出力はこんな定義です。
export const momentExtractionSchema = z.object({
characters: z.array(momentCharacterSchema).min(1).max(2),
keyMoments: z.array(keyMomentSchema).min(2).max(4),
relationship: z.string(),
emotionalTone: z.string(),
});
これだけでも型は守られます。keyMomentsは最低2、最大4個の配列として返ってきます。
しかし、型が守られているだけでは中身の質は守られません。action: "散歩する"もaction: "なんかいい感じの場面"も、型としては合法だからです。
そこで、プロンプト本文の中にJSONの出力例も書いておきます。
## 出力例
{
"keyMoments": [
{ "action": "焦げた唐揚げを見て笑う表情",
"whoDoesWhat": "ユーザーが鍋を覗く",
"visualAppeal": "high" },
{ "action": "焦げた部分を箸でつまんで困った顔",
"whoDoesWhat": "ユーザーのリアクション",
"visualAppeal": "high" }
],
...
}
Zodは形を守らせる役で、JSON例は意図をすり合わせる役です。両方が揃うと「料理の話なら料理の場面を、こういう粒度で書く」というのが伝わります。片方だけでは届きませんでした。
工夫3: 抽象的な指示はコードで具体に翻訳してから渡す
4ステップは順番に流れます。
moment extraction → scene composition → flux prompt → 画像生成 → text layout
各ステップは前段の出力をそのまま入力として受け取ります。1段目で禁止条項が効いて「会話に登場する場面だけ」が出ていれば、2段目以降はその場面しか見ないので逸脱しにくくなります。これが順方向の伝播です。
ただ、前段の出力をそのまま渡すだけだと、後段のAIが抽象的な値を毎回正しく解釈してくれるとは限りません。たとえばscene compositionのキャラ配置は"left" "right" "center"のような短い値ですが、これをtext layoutのAIに渡したとき、"left"をどう避ければいいかはAI次第になります。
そこで、コード側で前段の値を後段が機械的に従える形に翻訳してからプロンプトに埋め込む方針を取りました。たとえばtext layoutでは、sceneのキャラ位置をピクセル座標の禁止ゾーンに展開してからAIに渡します。
## キャラクターとの重なり防止(最重要)
キャラクターの位置に応じて、テキストを配置してはいけないゾーンがある:
- キャラが left → x: 0〜400 は配置禁止。テキストは右側(x: 700〜1100)に配置
- キャラが right → x: 800〜1100 は配置禁止。テキストは左側(x: 20〜400)に配置
- キャラが center → x: 300〜900 は配置禁止。テキストは左端(x: 20〜250)か右端(x: 950〜1100)に配置
- 複数キャラがいる場合は、最も空いているスペースにテキストを配置
"left"という抽象的な値を、x: 0〜400 は配置禁止 / x: 700〜1100 に配置という座標範囲に展開してから渡す。これで、テキストがキャラに被る頻度が下がりました。
LLMはあいまいな概念より、数値で書かれた範囲のほうが守りやすいようです。前段の出力をそのまま流すのではなく、コードで一度噛み砕いてから渡すのが、ステップを跨いで制約を伝えるときに効いている印象でした。
工夫4: 生成画像を計測して、AIに再相談する
ここまでで工夫1〜3で、scene上の想定をtext layoutに翻訳して渡すところまでできました。
とはいえ、工夫3で渡しているのは「scene上の想定キャラ位置」を元にした禁止ゾーンです。実際に画像生成AIが出力した画像で、scene通りの位置にキャラが配置される保証はありません。「left のキャラ」と書いていても、3〜4割は意図した位置に収まらないような状態でした。
そこで、実際に生成された画像を計測して、AIに再相談する工程を最後に足しました。
具体的には、生成画像をSharpで16×16のグリッドにダウンサンプルし、各セルの明度から「ここは余白か」を判定します。
const { data } = await sharp(imageBuffer)
.greyscale()
.resize(GRID_SIZE, GRID_SIZE, { fit: "fill" })
.raw()
.toBuffer({ resolveWithObject: true });
// 明度220以上のセルを safe としてマーク
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
row.push(data[r * GRID_SIZE + c] > 220);
}
}
その上で、イラストに隣接するセルは1マス分のバッファとしてsafeを取り消します。テキストが絵の縁ぎりぎりに置かれると読みにくいからです。
最後に、結果を「キャンバスを16×16のセルに区切った余白マップ」としてプロンプトに埋め込みます。. がテキストを置ける余白、# がキャラのいる領域です。たとえば画面の中央にキャラがいる画像なら、こんなマップになります。
0123456789012345
0 ................
1 ................
2 ........#####...
3 .......#######..
4 ......#########.
5 ......#########.
6 .......#######..
7 ........#####...
8 ................
9 ................
10 ................
11 ................
12 ................
13 ................
14 ................
15 ................
これを見ると、上下が大きく空いていて、中央にキャラの塊がある画像だな、というのが視覚的にわかります。AIに渡したときも同じように受け取ってもらえるようで、「上半分か下半分の中から . が連続している場所を選ぶ」のような判断がしやすくなりました。
ここでAIに「このマップを見て、テキストを置ける座標を返して」と聞き直します。
これは生成AIの曖昧さを事後検証ループで吸収するアプローチです。sceneを元にした想定の禁止ゾーン(工夫3)に、実画像から計測した余白マップ(工夫4)を重ねて渡すことで、テキスト配置の精度を出していきました。
まとめ
4つの工夫をまとめます。
- 何を描くかより、何を描かないかを先に書く(許可よりも禁止)
- Structured OutputとJSON出力例を二重に置く(形と意図の両面で絞る)
-
抽象的な指示はコードで具体に翻訳してから渡す(
"left"よりx: 0〜400の方が守られやすい) - 生成画像を計測して、AIに再相談する(曖昧さを事後検証ループで吸収する)
完全に押さえ込めたわけではありませんが、書きたいプロンプトを工夫するより先に「LLMが逸脱しにくい構造」を組んでおく方が、結果的に欲しい出力に近づいていく印象がありました。
おわりに
このサービスはリリース前で動かしていたのですが、gpt-image-2が出てきて、組んできた仕組みの存在意義が薄くなった気がしたので、ここで一区切りにすることにしました。
「会話 → moment → scene → prompt → 画像 → テキスト合成」というマルチステップは、汎用モデルが1リクエストで全部やる世界にゆっくり飲まれていく構図に見えています。それでも、今回の4つの工夫は汎用モデルに任せるときも外側でかぶせる薄い層として残せそうなので、ここに書き残しておきました。
同じように画像生成パイプラインで詰まっている方の参考になれば嬉しいです。最後まで読んでいただきありがとうございました!
Discussion