PixelModel-v3 / model.py
TobiasLogic's picture
PixelModel v3: SIREN+FiLM CPPN, 919K params, beats v1 FID
63a1291 verified
Raw
History Blame Contribute Delete
14 kB
"""PixelModel v3 - architecture + PNG weight codec.
A text-to-image CPPN whose entire weight set is stored as pixels in a PNG.
v3 changes relative to v1/v2 (see README):
1. Learned word-embedding table (mean-pooled) replaces the 0-param hash embed.
2. SIREN (sine) decoder with the real SIREN init instead of tanh.
3. Wider/deeper decoder (128-256 wide, 4-5 layers).
4. FiLM conditioning (per-layer gamma/beta from z) replaces concat conditioning.
5. PNG weight-encoding scheme extended to the new param count.
6/7. Fully batched coordinate decoding (no per-pixel python loop).
The PNG codec is deliberately identical in spirit to v1: one weight per pixel,
16-bit precision spread across the R (high byte) and G (low byte) channels,
B reserved, values linearly mapped from a fixed [-2, 2] range.
"""
from __future__ import annotations
import json
import math
import os
import time
from dataclasses import dataclass, asdict, field
import numpy as np
try:
import torch
import torch.nn as nn
import torch.nn.functional as F
except Exception:
torch = None
nn = object
WEIGHT_RANGE = (-2.0, 2.0)
PAD_ID = 0
UNK_ID = 1
@dataclass
class ModelConfig:
vocab_size: int = 8192
embed_dim: int = 64
max_tokens: int = 20
text_hidden: int = 256
z_dim: int = 192
decoder_width: int = 256
num_sine_layers: int = 4
num_freq: int = 8
w0_first: float = 30.0
w0_hidden: float = 30.0
weight_range: tuple = WEIGHT_RANGE
@property
def coord_dim(self) -> int:
return 2 + 4 * self.num_freq
def to_json(self) -> dict:
d = asdict(self)
d["weight_range"] = list(self.weight_range)
d["coord_dim"] = self.coord_dim
return d
@staticmethod
def from_json(d: dict) -> "ModelConfig":
keys = {f for f in ModelConfig.__dataclass_fields__}
clean = {k: v for k, v in d.items() if k in keys}
if "weight_range" in clean:
clean["weight_range"] = tuple(clean["weight_range"])
return ModelConfig(**clean)
import re as _re
_TOKEN_RE = _re.compile(r"[a-z0-9]+")
def simple_tokenize(text: str) -> list[str]:
"""Lowercase, split on non-alphanumeric runs. Deterministic and reversible
enough for a bag-of-words embedding."""
return _TOKEN_RE.findall(text.lower())
def build_vocab(captions, vocab_size: int, min_freq: int = 1) -> dict:
"""captions: iterable of strings -> {token: id}. Ids 0/1 are PAD/UNK."""
from collections import Counter
counter = Counter()
for cap in captions:
counter.update(simple_tokenize(cap))
vocab = {"<pad>": PAD_ID, "<unk>": UNK_ID}
for tok, freq in counter.most_common():
if len(vocab) >= vocab_size:
break
if freq < min_freq:
break
if tok not in vocab:
vocab[tok] = len(vocab)
return vocab
def load_vocab(path: str) -> dict:
with open(path) as f:
return json.load(f)
def encode_caption(text: str, vocab: dict, max_tokens: int) -> np.ndarray:
"""text -> int32 array of length max_tokens (PAD-filled, UNK-mapped)."""
ids = [vocab.get(t, UNK_ID) for t in simple_tokenize(text)][:max_tokens]
out = np.full(max_tokens, PAD_ID, dtype=np.int32)
out[:len(ids)] = ids
return out
def fourier_features(coords, num_freq: int):
"""coords: (..., 2) in [-1, 1] -> (..., 2 + 4*num_freq).
One explicit batched tensor op over the whole pixel set; no python loop
over pixels. Frequencies are the octaves pi * 2^k, k = 0..num_freq-1.
"""
freqs = (2.0 ** torch.arange(num_freq, device=coords.device, dtype=coords.dtype)) * math.pi
scaled = coords.unsqueeze(-1) * freqs
sin = torch.sin(scaled)
cos = torch.cos(scaled)
enc = torch.cat([coords, sin.flatten(-2), cos.flatten(-2)], dim=-1)
return enc
class FiLMSineLayer(nn.Module):
"""Linear -> FiLM(gamma, beta) -> sin(w0 * .).
FiLM is applied *after* the linear and *before* the sine, so the
conditioning warps the pre-activation the same way a learned bias would,
while the SIREN frequency scaling still governs the activation spectrum.
"""
def __init__(self, in_features: int, out_features: int, w0: float, is_first: bool):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.w0 = w0
self.is_first = is_first
self.linear = nn.Linear(in_features, out_features)
self.siren_init()
def siren_init(self):
with torch.no_grad():
if self.is_first:
bound = 1.0 / self.in_features
else:
bound = math.sqrt(6.0 / self.in_features) / self.w0
self.linear.weight.uniform_(-bound, bound)
if self.linear.bias is not None:
self.linear.bias.uniform_(-bound, bound)
def forward(self, x, gamma, beta):
h = self.linear(x)
h = gamma * h + beta
return torch.sin(self.w0 * h)
class PixelModelV3(nn.Module):
def __init__(self, cfg: ModelConfig):
super().__init__()
self.cfg = cfg
self.embed = nn.Embedding(cfg.vocab_size, cfg.embed_dim, padding_idx=PAD_ID)
nn.init.normal_(self.embed.weight, std=0.05)
with torch.no_grad():
self.embed.weight[PAD_ID].zero_()
self.text_fc1 = nn.Linear(cfg.embed_dim, cfg.text_hidden)
self.text_fc2 = nn.Linear(cfg.text_hidden, cfg.z_dim)
self.film = nn.Linear(cfg.z_dim, cfg.num_sine_layers * 2 * cfg.decoder_width)
nn.init.zeros_(self.film.weight)
nn.init.zeros_(self.film.bias)
layers = []
in_dim = cfg.coord_dim
for i in range(cfg.num_sine_layers):
is_first = (i == 0)
w0 = cfg.w0_first if is_first else cfg.w0_hidden
layers.append(FiLMSineLayer(in_dim, cfg.decoder_width, w0=w0, is_first=is_first))
in_dim = cfg.decoder_width
self.sine_layers = nn.ModuleList(layers)
self.head = nn.Linear(cfg.decoder_width, 3)
with torch.no_grad():
bound = math.sqrt(6.0 / cfg.decoder_width) / cfg.w0_hidden
self.head.weight.uniform_(-bound, bound)
nn.init.zeros_(self.head.bias)
self.num_freq = cfg.num_freq
self.grad_checkpoint = False
def encode_text(self, token_ids):
"""token_ids: (B, L) long -> z: (B, z_dim)."""
emb = self.embed(token_ids)
mask = (token_ids != PAD_ID).float().unsqueeze(-1)
summed = (emb * mask).sum(dim=1)
count = mask.sum(dim=1).clamp(min=1.0)
pooled = summed / count
h = torch.sin(self.text_fc1(pooled))
z = self.text_fc2(h)
return z
def film_params(self, z):
"""z: (B, z_dim) -> list of (gamma, beta), each (B, 1, width)."""
raw = self.film(z)
raw = raw.view(-1, self.cfg.num_sine_layers, 2, self.cfg.decoder_width)
out = []
for i in range(self.cfg.num_sine_layers):
gamma = 1.0 + raw[:, i, 0, :].unsqueeze(1)
beta = raw[:, i, 1, :].unsqueeze(1)
out.append((gamma, beta))
return out
def decode(self, coords, z, return_stats: bool = False):
"""coords: (B, N, 2) in [-1, 1]; z: (B, z_dim) -> rgb (B, N, 3).
The whole pixel set N is decoded as one batched tensor op per layer.
"""
x = fourier_features(coords, self.num_freq)
films = self.film_params(z)
use_ckpt = self.grad_checkpoint and self.training and not return_stats and x.requires_grad
stats = []
for layer, (gamma, beta) in zip(self.sine_layers, films):
if use_ckpt:
x = torch.utils.checkpoint.checkpoint(layer, x, gamma, beta, use_reentrant=False)
else:
x = layer(x, gamma, beta)
if return_stats:
stats.append((float(x.mean()), float(x.std())))
rgb = torch.sigmoid(self.head(x))
if return_stats:
return rgb, stats
return rgb
def forward(self, token_ids, coords, return_stats: bool = False):
z = self.encode_text(token_ids)
return self.decode(coords, z, return_stats=return_stats)
def make_coord_grid(height: int, width: int, device=None, dtype=None):
"""-> (H*W, 2) coordinate grid in [-1, 1]. One tensor op, no python loop."""
ys = torch.linspace(-1.0, 1.0, height, device=device, dtype=dtype)
xs = torch.linspace(-1.0, 1.0, width, device=device, dtype=dtype)
gy, gx = torch.meshgrid(ys, xs, indexing="ij")
return torch.stack([gx, gy], dim=-1).reshape(-1, 2)
def param_breakdown(model: "PixelModelV3") -> dict:
"""Per-parameter-tensor breakdown, in the exact order used by the PNG codec."""
breakdown = []
total = 0
for name, p in model.named_parameters():
n = int(p.numel())
breakdown.append({"name": name, "shape": list(p.shape), "params": n})
total += n
return {"total_parameters": total, "param_breakdown": breakdown}
def png_dims_for(n_params: int) -> tuple[int, int]:
"""Roughly-square PNG that holds n_params pixels (one weight per pixel)."""
width = int(math.ceil(math.sqrt(n_params)))
height = int(math.ceil(n_params / width))
return width, height
def flatten_params(model: "PixelModelV3") -> np.ndarray:
chunks = [p.detach().cpu().float().reshape(-1).numpy() for p in model.parameters()]
return np.concatenate(chunks)
def load_flat_params(model: "PixelModelV3", flat: np.ndarray) -> None:
i = 0
for p in model.parameters():
n = p.numel()
chunk = torch.from_numpy(np.ascontiguousarray(flat[i:i + n])).to(p.dtype).view_as(p)
p.data.copy_(chunk)
i += n
if i != flat.shape[0]:
raise ValueError(f"param/flat size mismatch: consumed {i}, have {flat.shape[0]}")
def weights_to_png(flat: np.ndarray, path: str, weight_range=WEIGHT_RANGE):
"""Write a float weight vector to a PNG. Returns (width, height, n_clamped)."""
from PIL import Image
n = int(flat.size)
width, height = png_dims_for(n)
total = width * height
padded = np.zeros(total, dtype=np.float64)
padded[:n] = flat.astype(np.float64)
lo, hi = weight_range
n_clamped = int(np.count_nonzero((padded[:n] < lo) | (padded[:n] > hi)))
clamped = np.clip(padded, lo, hi)
u16 = np.round((clamped - lo) / (hi - lo) * 65535.0).astype(np.uint16)
img = np.zeros((height, width, 3), dtype=np.uint8)
img[..., 0] = (u16 >> 8).astype(np.uint8).reshape(height, width)
img[..., 1] = (u16 & 0xFF).astype(np.uint8).reshape(height, width)
Image.fromarray(img, "RGB").save(path, optimize=True)
return width, height, n_clamped
def png_to_weights(path: str, n_params: int, weight_range=WEIGHT_RANGE) -> np.ndarray:
"""Decode a PNG back into the first n_params weight values (float32)."""
from PIL import Image
arr = np.asarray(Image.open(path).convert("RGB"))
hi_byte = arr[..., 0].astype(np.uint16)
lo_byte = arr[..., 1].astype(np.uint16)
u16 = ((hi_byte << 8) | lo_byte).reshape(-1)[:n_params]
lo, hi = weight_range
vals = u16.astype(np.float64) / 65535.0 * (hi - lo) + lo
return vals.astype(np.float32)
def save_model_png(model: "PixelModelV3", png_path: str, config_path: str | None = None,
verbose: bool = True) -> dict:
"""Write model.png (and optionally config.json). Returns a stats dict."""
flat = flatten_params(model)
n = int(flat.size)
t0 = time.time()
width, height, n_clamped = weights_to_png(flat, png_path, model.cfg.weight_range)
write_s = time.time() - t0
size_bytes = os.path.getsize(png_path)
info = param_breakdown(model)
info.update({
"png_width": width,
"png_height": height,
"png_pixels": width * height,
"png_bytes": size_bytes,
"png_write_seconds": round(write_s, 4),
"weights_clamped": n_clamped,
"config": model.cfg.to_json(),
})
if config_path is not None:
with open(config_path, "w") as f:
json.dump(info, f, indent=2)
if verbose:
print(f"[png] total params : {n:,}")
print(f"[png] resolution : {width} x {height} ({width*height:,} pixels)")
print(f"[png] file size on disk : {size_bytes/1024:.1f} KiB ({size_bytes/1e6:.3f} MB)")
print(f"[png] write time : {write_s*1000:.1f} ms")
if n_clamped:
frac = 100.0 * n_clamped / n
print(f"[png] WARNING clamped : {n_clamped:,} weights ({frac:.3f}%) outside {model.cfg.weight_range}")
if size_bytes > 4_000_000:
print(f"[png] WARNING size : {size_bytes/1e6:.2f} MB is larger than expected")
return info
def load_model_png(png_path: str, cfg: ModelConfig, map_location="cpu",
verbose: bool = True) -> "PixelModelV3":
"""Rebuild the model architecture from cfg, then fill weights from the PNG."""
model = PixelModelV3(cfg)
n_params = sum(p.numel() for p in model.parameters())
t0 = time.time()
flat = png_to_weights(png_path, n_params, cfg.weight_range)
load_flat_params(model, flat)
model.to(map_location)
model.eval()
if verbose:
print(f"[png] loaded {n_params:,} weights from {png_path} in {(time.time()-t0)*1000:.1f} ms")
return model
def load_config(config_path: str) -> ModelConfig:
with open(config_path) as f:
info = json.load(f)
cfg_dict = info.get("config", info)
return ModelConfig.from_json(cfg_dict)
def roundtrip_error(model: "PixelModelV3", tmp_png: str) -> float:
"""Max abs weight error after a PNG encode/decode round trip."""
before = flatten_params(model)
weights_to_png(before, tmp_png, model.cfg.weight_range)
after = png_to_weights(tmp_png, before.size, model.cfg.weight_range)
return float(np.max(np.abs(before - after)))