AIで4コマ漫画を量産する:絵はAIに、吹き出しと日本語はコードで描く

AIで4コマ漫画を量産する:絵はAIに、吹き出しと日本語はコードで描く コラム
AIが描いた4コマ漫画の絵に、コードで吹き出しと日本語テキストを合成するワークフローのイメージ

発信用に4コマ漫画を量産したくなった。キャラを1体決めて、毎回セリフだけ差し替えれば、シリーズものを安く回せるはずだ。画像生成は gpt-image-2 を使う前提で、最初は素直に「4コマ漫画を描いて、各コマにこのセリフを入れて」とプロンプトに全部を任せた。

結論から言うと、その方針は早々に捨てた。この記事は、「絵はAI、吹き出しと日本語はコードで描く」という分業に行き着くまでの試行錯誤と、ハマったポイントの記録だ。エラーは出ないのに成果物が使い物にならない、いわゆる「失敗が見えない失敗」の一種でもある。

先に完成形を見せておく。下が実際にこの方式で作った1本だ。キャラと背景は AI が描き、吹き出し・トゲ・日本語はすべてコードで後から載せている。

コードで吹き出しと日本語を後描画して仕上げた4コマの実例。キャラと背景はgpt-image-2で生成し、セリフとトゲはPillowで描画している

文字化けもはみ出しもなく、トゲがちゃんと話者を指しているのが分かると思う。ここに至るまでの話をする。

AIに日本語と吹き出しを描かせると破綻する

まず分かったのは、現状の画像生成モデルに日本語の吹き出しを任せると、ほぼ確実に次のどれかが起きるということだ。

  • セリフが文字化けする(それっぽい架空の文字になる)
  • 文字が吹き出しからはみ出す、または枠に対して小さすぎる
  • コマ数が指定どおりにならない(4コマのつもりが3コマになる)
  • 吹き出しのトゲ(しっぽ)の向きや位置がおかしい

1枚絵としては可愛く描けるのに、テキストが入った瞬間に納品できない品質に落ちる。何度プロンプトを練り直しても、日本語のレンダリング精度そのものは上がらない。ここで方針を変えた。

方針:絵はAI、吹き出しと文字はコードで

割り切りはシンプルだ。

  • 絵(キャラと背景) はモデルの得意分野なので AI に描かせる
  • 吹き出しと日本語テキスト は座標もフォントもこちらが完全に制御できる Pillow(PIL)で後描画する

こうすると、文字化けは原理的に起きないし、はみ出しもコード側で防げる。AIには「吹き出しもテキストも描かないでくれ」と頼むのがコツになる。

絵を「吹き出しが置ける」構図で出す

吹き出しを後から載せるには、絵の中に吹き出しを置く余白が要る。そこで生成プロンプトに2つの指示を必ず入れた。

  • 吹き出しもテキストも描かない(NO speech bubbles, NO text, NO lettering
  • 画面上部35%を無地の壁の余白として空け、キャラは下半分に配置する

... Leave the TOP 35 percent of the image as empty plain wall
(clear negative space); place characters in the lower half.
NO speech bubbles, NO text, NO lettering of any kind.

この「上を空けておいて」が地味に効く。余白が確保できていれば、後からどんな長さのセリフでも安全に置ける。

なお、1枚で4コマを描かせるとコマ数が安定しなかったので、1コマ=1枚で生成して、あとから縦に連結する方式にした。コマごとの構図を個別に指示できるぶん、こちらの方が制御しやすい。

吹き出しをPillowで描く

吹き出しは角丸の長方形にした。楕円より、長方形のほうが底辺が直線になり、トゲを綺麗に生やせる。


from PIL import ImageDraw

FILL = (255, 252, 245)  # オフホワイト
OUT  = (70, 50, 38)     # 輪郭の焦げ茶

def draw_bubble(d: ImageDraw.ImageDraw, box, radius=34):
    d.rounded_rectangle(box, radius=radius, fill=FILL, outline=OUT, width=5)

問題は次のトゲだ。

トゲ(しっぽ)は「底辺から話者へ」生やす

最初の実装では、トゲを「吹き出しの内部の点」から「遠くの話者の座標」へ線で結んでいた。これが完全に間違いで、付け根が宙に浮いた変な三角形になった。

正しくは、吹き出しの底辺に口(base)を作り、そこから話者方向へ三角形を生やす。手順はこうだ。

1. 角丸長方形を塗り+輪郭で描く(前掲の draw_bubble

2. 底辺上に口の2点(base_left / base_right)、その下の話者寄りに尖端(tip)を取る

3. 三角形を塗りでつぶす(底辺の輪郭が三角形に覆われる)

4. 口の区間だけ塗り色で上書きして確実に消す

5. 口の両端から尖端へ、輪郭線を2本だけ引く


def draw_tail(d, box, sx):
    # 角丸長方形(draw_bubble で塗り+輪郭済み)の後に呼ぶ
    # sx: 話者の向き  -1=左, +1=右, 0=トゲなし
    x0, y0, x1, y1 = box
    bw = x1 - x0
    cx = (x0 + x1) / 2
    tx = min(max(cx + sx * bw * 0.22, x0 + 50), x1 - 50)  # 口の中心
    bl, br = (tx - 24, y1), (tx + 24, y1)                  # 口の2点(底辺上)
    tip = (tx + sx * 32, y1 + 60)                          # 尖端

    d.polygon([bl, br, tip], fill=FILL)                    # トゲを塗る(底辺の輪郭を覆う)
    d.line([(bl[0], y1), (br[0], y1)], fill=FILL, width=9) # 口の区間を塗りで消す
    d.line([bl, tip], fill=OUT, width=5)                   # トゲの輪郭2本
    d.line([br, tip], fill=OUT, width=5)

「口の区間を塗りで消す」のがポイントで、これを忘れると底辺の直線がトゲの入り口を横切ったままになり、いかにも合成という見た目になる。心の声を表すときは、三角形の代わりに小さい円を話者方向に3つ並べれば思考の吹き出しになる。

文字を枠内に自動フィットさせる

はみ出しを根絶するには、枠に収まるまでフォントサイズを下げるだけでいい。折り返しも自前で行う。


def fit(d, text, maxw, maxh):
    size = 48
    while size > 20:
        f = load_font(size)
        lines = wrap(d, text, f, maxw)           # maxw で折り返し
        lw = max(d.textlength(l, font=f) for l in lines)
        lh = (size + 12) * len(lines)
        if lw <= maxw and lh <= maxh:
            return f, lines
        size -= 2
    return load_font(20), wrap(d, text, load_font(20), maxw)

フォントは日本語が確実に出るものを指定する(macOS ならヒラギノ丸ゴなど)。これで、セリフがどれだけ長くても枠の中に必ず収まる。

パネルをキャッシュしてセリフ修正を無料にする

運用してみて一番効いたのがパネル画像のキャッシュだ。テキストはコード側で描いているので、セリフを直すだけなら絵を再生成する必要がない

そこで、生成したパネルは title_panel_N.png としてキャッシュに保存し、ファイルが既にあれば生成をスキップする。絵を作り直したいときだけ --regen を渡す。


need = regen or (regen_panel == i + 1) or (not path.exists())
if need:
    generate_panel(...)   # ここだけ API を叩く(課金される)

おかげで、セリフの位置やフォントの調整は何度やってもタダで即時。お金がかかるのは「絵を作る/作り直す」ときだけになった。エピソードの定義は JSON にして、scene(絵の指示)と bubbles(セリフと座標)を差し替えるだけで新作を量産できるようにした。

コストとレート制限のリアル

gpt-image-2 の low 画質・1024×1024 で生成している。実コストは概ね次のとおりだった。

  • 1枚あたり約 $0.005(4コマ1本=4枚で約 $0.02)

量産用途では十分に安い。ただし2つ、運用上の落とし穴があった。

1. レート制限(5枚/分)。 4枚を並列で投げ、さらにエピソードを連続実行したら 429 RateLimitError で落ちた。input-images per min: Limit 5 と明示されている。対策として、並列をやめて1枚ずつ約24秒間隔(≒2.5枚/分)で逐次生成する方式に変えた。保険として、429が出たら待機時間を段階的に延ばしながらリトライする処理も入れてある。急がないなら、最初からバーストさせないのが一番楽だ。

2. 残高はAPIから取れない。 「あと何枚作れるか」を出そうとして気づいたが、通常のAPIキーでは課金・残高系のエンドポイントは 403 Forbidden(管理者専用)で叩けない。残クレジットはダッシュボードで見るしかない。単価が分かっても「残高÷単価」の残高部分はプログラムから取得できない、というのは覚えておくと無駄な実装をせずに済む。

✅ まとめ

AIで日本語入りの図版(4コマに限らず、バナーやサムネも同じ)を量産するなら、テキストをAIに描かせないのが結局いちばん近道だった。

  • 絵はAI、吹き出しと文字はコード(Pillow)で描く
  • 絵は余白を空けた構図で出し、1コマ1枚で生成して連結する
  • トゲは底辺から話者へ生やし、口の区間は塗りで消す
  • 文字は枠に収まるまで縮小してはみ出しを根絶する
  • パネルをキャッシュしてセリフ修正をタダにする
  • 量産時はレート制限を逐次+間隔で回避、残高はAPIで取れない前提で組む

「モデルの苦手なところは諦めて、得意なところだけ使う」。生成AIを実務に乗せるときの、わりと普遍的な勘所だと思う。

コメント