学習なしで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 |
| LLM | Qwen3.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個の振動子とみなし、位相 を近傍との結合で時間発展させる。
が固有周波数、 が結合強度、 が近傍の重みだ。
全結合はやらず、上下左右と斜めの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)
ここで一番はまった所を書いておく。
最初は構図(横並び・放射・渦など)を初期位相 に書き込んでいた。
ところが、強い局所結合をかけると数ステップで位相が均されて、どのプロンプトでも同じような等方ブロブになってしまう。
初期位相に書いた大域構造は、結合がすぐに均してしまう。
残ったのは固有周波数場 の方だった。
は毎ステップ位相を駆動し続けるので、そこに空間的な勾配を入れておくと、進行波や縞として構造が持続する。
だから構図は初期位相ではなく に書き込むよう直した。
横並びなら にy方向の勾配、放射なら中心が速い勾配、という具合だ。
例外はらせんと渦で、これは位相の巻き(位相特異点)を初期位相に入れておくと、トポロジカルに保護されて最後まで残る。
このあたりは反応拡散系で見慣れたスパイラル波と同じ挙動だった。
位相場を色に起こす
位相をそのまま色相に割り当てると、ただの虹色になる。
下の左がそれで、位相 を でHSVの色相に流しただけの状態だ。
構造はあるのに、彩度が飽和して情報が潰れている。
右が最終的なレンダラーの出力で、同じ位相場から作っている。
使うマップはこれだ。
- 局所同期度 … 近傍で位相がどれだけ揃っているか。 を明るさに使う。揃った所は明るく、境界(ドメインウォール)は暗い筋になる
- エッジ … 同期度の勾配を輪郭として足す。境界が発光した筋になる
- パレット … 位相由来のスカラーで、5色の補間テーブルを引く。色相を一周させる代わりに、パレットの端から端を往復させて縞にする
同期度で明暗を作るのがポイントで、これで虹色が「深さのある模様」に変わる。
出力例
同じseed(初期乱数)で、プロンプトだけ変えたものを並べる。
Qwenが返したJSONの違いが、色だけでなく構図・動きまで変えているのが分かる。
左上から、夕焼けの湖・深海の渦・燃える花・サイバーパンクの夜景・森の朝・淡い夢。
放射系のプロンプトは同心円やらせんになり、横向きのプロンプトは横うねりになる。
「ノイズ多め」と言えば乱れが上がって散らばる。
右下の淡い夢だけは狙い通り輪郭が溶けて、絵としては一番地味になった。
Qwenあり・なしの比較
Qwenをやめてキーワードマッチのfallbackにしたらどうなるか。
同じ「夕焼けの湖、静かな水面、淡い抽象画」で比べる。
左のQwen版はsunsetパレットで、彩度とコントラストを落として夕焼けの淡さを出している。
右のfallbackは「淡い」というキーワードにpastelルールが反応して、パレットがpastelに化けた。
その結果、夕焼けの暖色がまるごと消えている。
キーワードマッチは語をひとつ拾って上書きするだけなので、「夕焼け」と「淡い」を両立できない。
文全体のニュアンスをパラメータに落とす所で、LLMを噛ませる意味が出る。
seedを変える
specを固定して、初期乱数のseedだけ変えたのがこれだ(燃える花のspec)。
どれも炎の同心リングという性格は保ったまま、リングの割れ方や粒の位置だけが変わる。
specが絵の性格を決め、seedがその実現を決める、という分担になっている。
拡散モデルのseedと役割は似ているが、こちらは学習が一切ない分、seedは純粋に初期位相の乱数でしかない。
同期の過程そのものを見せる
Un-0っぽさが一番出るのは、静止画ではなく時間発展の方だ。
下は燃える花のspecで、200ステップの同期過程をアニメーションにしたもの。
t=0はただのランダムノイズだ。
そこから局所的に位相が揃いはじめ、ドメインが粗大化し、最後に中心から放射する同心リングへ落ち着く。
バラバラの振動子が結合に引っ張られて秩序を作る過程が、そのまま模様の生成過程になっている。
逐次デノイズのスケジュールを回す拡散モデルとは、絵の出来かたがだいぶ違う。
限界
Un-0そのものとは当然別物だ。
向こうは結合もデコーダも学習していて、クラス条件で犬や車を描き分ける。こっちは学習がゼロなので、出せるのは同期のパターンだけで、意味のある構図はどうやっても出ない。そこは最初から狙っていない。
それでも、プロンプトを変えれば構図や色は変わったし、同期が模様になる過程も手元で追えた。冒頭で確かめたかったのは、そこだった。