技術約8分で読めます

学習なしでQwenと蔵本モデルで画像生成器を作る、写実は出ないが構図と色はプロンプトで変わった

いけさん目次

前に蔵本モデルの同期で画像を生成するUn-0の記事を書いた。
あれは結合行列とデコーダを学習した本物の生成モデルで、ImageNet-64の最大モデルはB200で640時間かけて学習されている。
自分で同じものを学習するのは、どう考えても手元のマシンでは無理だ。

ただ、Un-0の芯にある「結合した振動子が同期して、その位相場を画像として取り出す」という部分だけなら、学習なしのおもちゃでも触れるんじゃないかと思った。
そこで、自然文プロンプトをQwenにJSONへ変換させ、2D格子の蔵本モデルを回して、位相場を色に起こすだけのおもちゃの生成器を、M1 MaxのNumPyだけで書いた。

学習なしなのに、プロンプトを変えると出力は本当に変わるのか。
変わるとして、どのパラメータが効いて、どれが効かないのか。
そこを手元で確かめたかった。

これは画像生成モデルではない。
猫を描けと言っても猫は出ない。出るのは抽象的な模様や背景みたいなものだけだ。

検証環境

項目内容
マシンApple M1 Max / 64GB(CPUのみ、GPU不使用)
言語Python 3.13
主要ライブラリNumPy, SciPy, Pillow, imageio
LLMQwen3.7(Plus/Max)をOpenAI互換API経由で呼ぶ
格子サイズ128×128 振動子
時間発展200ステップ、近傍8結合、トーラス境界
出力512〜1000pxへ拡大してPNG / アニメーションはwebp

蔵本モデル自体はGPUを使うまでもない。
128×128の位相場を200ステップ回すだけなので、1枚あたり手元で1秒もかからない。

全体の流れ

役割分担はこうなっている。

flowchart TD
  A[自然文プロンプト] --> B[Qwen<br/>intent parser]
  B --> C[Generation Spec<br/>JSON]
  C --> D[2D蔵本モデル<br/>位相場の時間発展]
  D --> E[位相 / 同期度 / エッジ<br/>の各マップ]
  E --> F[パレットで着色]
  F --> G[PNG / webp]

Qwenは画像の潜在表現を出しているわけではない。
やらせているのは、自然文を「生成器の制御パラメータ」に翻訳することだけだ。
Un-0の用語に寄せるなら、テキストエンコーダというより、意味を生成パラメータに変換するエンコーダとして使っている。

Qwenにプロンプトをパラメータ化させる

Qwenに渡すシステムプロンプトはこれだけ。
「この生成器は物体を描けない。振動子場・位相マップ・波・パレット・乱れ・放射バイアスで抽象画像を作るだけだ」と前置きして、スキーマを固定する。

You are a parameter generator for a tiny procedural image generator
based on Kuramoto oscillators.
Convert the user's visual prompt into a compact JSON object.

The generator cannot draw realistic objects.
It can only create abstract images using oscillator fields, phase maps,
wave patterns, color palettes, turbulence, radial bias, and rendering.

Return JSON only. Do not include explanations.

Schema:
{
  "palette": "sunset" | "ocean" | "forest" | "fire" | "mono" | "pastel" | "cyberpunk" | "random",
  "mood": "calm" | "energetic" | "dark" | "bright" | "dreamy" | "chaotic",
  "composition": "horizontal" | "vertical" | "radial" | "diagonal" | "centered" | "random",
  "motion": "still" | "slow_wave" | "spiral" | "vortex" | "burst" | "flow",
  "symmetry": "none" | "low" | "medium" | "high",
  "turbulence": number between 0 and 1,
  "coupling": number between 0 and 1,
  "frequency_scale": number between 0 and 1,
  "radial_bias": number between 0 and 1,
  "edge_strength": number between 0 and 1,
  "contrast": number between 0 and 1,
  "saturation": number between 0 and 1,
  "brightness": number between 0 and 1,
  "abstraction": number between 0 and 1,
  "seed_hint": short English phrase
}

呼び出し側はOpenAI互換SDKで、エンドポイントとモデルは環境変数から読む。
接続先はModelScope経由のOpenAI互換APIを使った。

from openai import OpenAI

client = OpenAI(
    api_key=os.environ["QWEN_API_KEY"],
    base_url=os.environ["QWEN_BASE_URL"],
)
resp = client.chat.completions.create(
    model=os.environ["QWEN_MODEL"],
    messages=[
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": prompt},
    ],
    temperature=0.4,
)
spec = validate(json.loads(extract_json(resp.choices[0].message.content)))

返ってきたJSONは validate() で必ず通す。
enumは許可値以外なら既定値へ、数値は0〜1にクランプする。
LLMがたまに範囲外や余計なキーを返しても、生成器側が壊れないようにするためだ。

例えば「夕焼けの湖、静かな水面、淡い抽象画」を渡すと、こういうJSONが返る。

{
  "palette": "sunset",
  "mood": "calm",
  "composition": "horizontal",
  "motion": "slow_wave",
  "symmetry": "low",
  "turbulence": 0.15,
  "coupling": 0.75,
  "frequency_scale": 0.25,
  "radial_bias": 0.1,
  "edge_strength": 0.2,
  "contrast": 0.3,
  "saturation": 0.45,
  "brightness": 0.65,
  "abstraction": 0.85,
  "seed_hint": "sunset lake calm water pale abstract"
}

「夕焼け」がsunsetパレット、「静かな」がcalmと低い乱れ、「淡い」が彩度0.45・コントラスト0.3に落ちている。
このJSONはあくまで生成器の設定値であって、厳密な意味論ではない。

2D格子の蔵本モデル

蔵本モデルそのものの説明は前の記事に書いたので、ここでは実装だけ。
各ピクセルを1個の振動子とみなし、位相 θi\theta_i を近傍との結合で時間発展させる。

dθidt=ωi+Kj近傍wijsin(θjθi)\frac{d\theta_i}{dt} = \omega_i + K \sum_{j \in \text{近傍}} w_{ij}\,\sin(\theta_j - \theta_i)

ωi\omega_i が固有周波数、KK が結合強度、wijw_{ij} が近傍の重みだ。
全結合はやらず、上下左右と斜めの8近傍だけにする。 np.roll で位相場をずらして差分を取ると、境界が繋がったトーラスになる。 これは抽象模様にはむしろ都合がいい。

NEIGHBORS = [(-1,0,1.0),(1,0,1.0),(0,-1,1.0),(0,1,1.0),
             (-1,-1,0.7),(-1,1,0.7),(1,-1,0.7),(1,1,0.7)]

def step(theta, omega, K, dt=0.1):
    term = np.zeros_like(theta)
    wsum = 0.0
    for dy, dx, w in NEIGHBORS:
        shifted = np.roll(np.roll(theta, dy, axis=0), dx, axis=1)
        term += w * np.sin(shifted - theta)
        wsum += w
    term /= wsum
    return (theta + dt * (omega + K * term)) % (2*np.pi)

ここで一番はまった所を書いておく。
最初は構図(横並び・放射・渦など)を初期位相 θ\theta に書き込んでいた。
ところが、強い局所結合をかけると数ステップで位相が均されて、どのプロンプトでも同じような等方ブロブになってしまう。
初期位相に書いた大域構造は、結合がすぐに均してしまう。

残ったのは固有周波数場 ω\omega の方だった。
ω\omega は毎ステップ位相を駆動し続けるので、そこに空間的な勾配を入れておくと、進行波や縞として構造が持続する。
だから構図は初期位相ではなく ω\omega に書き込むよう直した。
横並びなら ω\omega にy方向の勾配、放射なら中心が速い勾配、という具合だ。

例外はらせんと渦で、これは位相の巻き(位相特異点)を初期位相に入れておくと、トポロジカルに保護されて最後まで残る。
このあたりは反応拡散系で見慣れたスパイラル波と同じ挙動だった。

位相場を色に起こす

位相をそのまま色相に割り当てると、ただの虹色になる。
下の左がそれで、位相 θ\thetaθ/2π\theta / 2\pi でHSVの色相に流しただけの状態だ。
構造はあるのに、彩度が飽和して情報が潰れている。

位相を色相に直接割り当てた虹色の状態。同心らせんの構造はあるが彩度が飽和して潰れている 同じ位相場を最終レンダラーで着色したもの。パレットと同期度・エッジで深みが出て深海の渦になっている

右が最終的なレンダラーの出力で、同じ位相場から作っている。
使うマップはこれだ。

  • 局所同期度 … 近傍で位相がどれだけ揃っているか。Ri=eiθlocalR_i = |\langle e^{i\theta}\rangle_\text{local}| を明るさに使う。揃った所は明るく、境界(ドメインウォール)は暗い筋になる
  • エッジ … 同期度の勾配を輪郭として足す。境界が発光した筋になる
  • パレット … 位相由来のスカラーで、5色の補間テーブルを引く。色相を一周させる代わりに、パレットの端から端を往復させて縞にする

同期度で明暗を作るのがポイントで、これで虹色が「深さのある模様」に変わる。

出力例

同じseed(初期乱数)で、プロンプトだけ変えたものを並べる。
Qwenが返したJSONの違いが、色だけでなく構図・動きまで変えているのが分かる。

夕焼けの湖。暖色の横うねりが穏やかに走る 深海の青い渦。暗い背景に同心円と中心の渦 燃える赤い花。中心から放射する炎のリング サイバーパンクの夜景。黒地にマゼンタとシアンの散乱 森の朝。緑の霧が中心から広がるターゲット波 不安定な夢。淡いパステルの霞で輪郭が溶けている

左上から、夕焼けの湖・深海の渦・燃える花・サイバーパンクの夜景・森の朝・淡い夢。
放射系のプロンプトは同心円やらせんになり、横向きのプロンプトは横うねりになる。
「ノイズ多め」と言えば乱れが上がって散らばる。
右下の淡い夢だけは狙い通り輪郭が溶けて、絵としては一番地味になった。

Qwenあり・なしの比較

Qwenをやめてキーワードマッチのfallbackにしたらどうなるか。
同じ「夕焼けの湖、静かな水面、淡い抽象画」で比べる。

Qwen版。夕焼けと解釈してsunsetパレットの暖色になる fallback版。淡いに引っ張られてpastelパレットになり夕焼け感が消える

左のQwen版はsunsetパレットで、彩度とコントラストを落として夕焼けの淡さを出している。
右のfallbackは「淡い」というキーワードにpastelルールが反応して、パレットがpastelに化けた。
その結果、夕焼けの暖色がまるごと消えている。
キーワードマッチは語をひとつ拾って上書きするだけなので、「夕焼け」と「淡い」を両立できない。
文全体のニュアンスをパラメータに落とす所で、LLMを噛ませる意味が出る。

seedを変える

specを固定して、初期乱数のseedだけ変えたのがこれだ(燃える花のspec)。

seed 1。炎の同心リング、細部の配置その1 seed 2。同じ性格だが細部が違う seed 3。同じ性格だが細部が違う seed 4。同じ性格だが細部が違う

どれも炎の同心リングという性格は保ったまま、リングの割れ方や粒の位置だけが変わる。
specが絵の性格を決め、seedがその実現を決める、という分担になっている。
拡散モデルのseedと役割は似ているが、こちらは学習が一切ない分、seedは純粋に初期位相の乱数でしかない。

同期の過程そのものを見せる

Un-0っぽさが一番出るのは、静止画ではなく時間発展の方だ。
下は燃える花のspecで、200ステップの同期過程をアニメーションにしたもの。

蔵本モデルの同期過程のアニメーション。ランダムなノイズから始まり、局所クラスタが育ち、最後に炎の同心リングへ揃っていく

t=0はただのランダムノイズだ。
そこから局所的に位相が揃いはじめ、ドメインが粗大化し、最後に中心から放射する同心リングへ落ち着く。
バラバラの振動子が結合に引っ張られて秩序を作る過程が、そのまま模様の生成過程になっている。
逐次デノイズのスケジュールを回す拡散モデルとは、絵の出来かたがだいぶ違う。

限界

Un-0そのものとは当然別物だ。
向こうは結合もデコーダも学習していて、クラス条件で犬や車を描き分ける。こっちは学習がゼロなので、出せるのは同期のパターンだけで、意味のある構図はどうやっても出ない。そこは最初から狙っていない。
それでも、プロンプトを変えれば構図や色は変わったし、同期が模様になる過程も手元で追えた。冒頭で確かめたかったのは、そこだった。