Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| Slapstack Studio oracle — turns pixels or text into Gabor-atom LAYERS. | |
| Two paths, honestly separated: | |
| fit_image(img) CPU, minutes. Bet-5 recon loop (verified math): fit a | |
| GaborPacketImage to the image by MSE, export hard-open | |
| atoms in the Bet-6 field layout. This is exactly | |
| bet6_open._train_recon generalized to any image. | |
| sds_layer(text) GPU, minutes. Bet-5 run_sds loop from scratch on a | |
| fresh atom population, Stable Diffusion 2.1 as the | |
| score oracle. UNTESTED on GPU in this build environment | |
| (no CUDA, no SD weights here) — the loop is a line-for- | |
| line adaptation of the Bet-5 SDS loop that WAS verified | |
| on GPU, but treat the first run as a smoke test. Known | |
| open risk carries over: SD 2.1 mode-seeking | |
| oversaturation at high CFG. | |
| Atom layout (FIELDS): [x, y, theta, sigma_u, sigma_v, freq, phase, r, g, b] | |
| with amplitude folded into signed color and xy centered (canonical frame). | |
| """ | |
| import io | |
| import json | |
| import math | |
| import time | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from PIL import Image | |
| from bet5_gabor_sds import GaborPacketImage | |
| FIELDS = ["x", "y", "theta", "su", "sv", "f", "phase", "r", "g", "b"] | |
| # --------------------------------------------------------------------------- | |
| # shared: model -> Bet-6 atom array (port of bet6_open.atoms_from_model) | |
| # --------------------------------------------------------------------------- | |
| def atoms_from_model(m): | |
| keep = np.where(m.gates.hard_open().numpy())[0] | |
| arr = np.zeros((len(keep), len(FIELDS))) | |
| arr[:, 0:2] = torch.tanh(m.xy_raw).detach().numpy()[keep] | |
| arr[:, 0:2] -= arr[:, 0:2].mean(0) | |
| arr[:, 2] = m.theta.detach().numpy()[keep] | |
| arr[:, 3] = np.clip(np.exp(m.log_sigma_u.detach().numpy()[keep]), 5e-3, 2.0) | |
| arr[:, 4] = np.clip(np.exp(m.log_sigma_v.detach().numpy()[keep]), 5e-3, 2.0) | |
| arr[:, 5] = np.log1p(np.exp(m.freq_raw.detach().numpy()[keep])) | |
| arr[:, 6] = np.mod(m.phase.detach().numpy()[keep], 2 * math.pi) | |
| amp = m.amp.detach().numpy()[keep] | |
| arr[:, 7:10] = m.color.detach().numpy()[keep] * amp[:, None] | |
| return arr | |
| # --------------------------------------------------------------------------- | |
| # preview renderer — same formula the verified JS core uses | |
| # (pre[c] += color_c * env * carrier ; sigmoid(2*pre)) | |
| # --------------------------------------------------------------------------- | |
| def render_atoms(atoms, H=192): | |
| ys = np.linspace(-1, 1, H) | |
| X, Y = np.meshgrid(ys, ys) | |
| pre = np.zeros((3, H, H), np.float32) | |
| for a in atoms: | |
| dx, dy = X - a[0], Y - a[1] | |
| ct, st = math.cos(a[2]), math.sin(a[2]) | |
| u = ct * dx + st * dy | |
| v = -st * dx + ct * dy | |
| env = np.exp(-0.5 * ((u / a[3]) ** 2 + (v / a[4]) ** 2)) | |
| car = np.cos(2 * np.pi * a[5] * u + a[6]) | |
| ec = (env * car).astype(np.float32) | |
| for c in range(3): | |
| pre[c] += a[7 + c] * ec | |
| return 1 / (1 + np.exp(-2 * pre)) | |
| def preview_png_bytes(atoms, H=192): | |
| img = (render_atoms(atoms, H).transpose(1, 2, 0) * 255).astype(np.uint8) | |
| buf = io.BytesIO() | |
| Image.fromarray(img).save(buf, "PNG") | |
| return buf.getvalue() | |
| # --------------------------------------------------------------------------- | |
| # path 1: image -> layer (CPU, the Bet-5 recon loop) | |
| # --------------------------------------------------------------------------- | |
| def fit_image(pil_img, n_atoms=140, iters=400, size=96, seed=0, | |
| l0_weight=1e-3, gate_warmup=60, progress=None): | |
| """Fit a Gabor packet layer to an image by MSE. CPU, deterministic.""" | |
| dev = torch.device("cpu") | |
| torch.manual_seed(seed) | |
| pil_img = pil_img.convert("RGB") | |
| tgt = torch.from_numpy( | |
| np.asarray(pil_img.resize((size, size)), dtype=np.float32).copy() | |
| ).permute(2, 0, 1) / 255.0 | |
| m = GaborPacketImage(n_atoms, seed=seed) | |
| # bg_bias is DROPPED by atoms_from_model on export, so it must not be | |
| # learned: atoms alone must carry the image, background stays sigmoid- | |
| # neutral. (Letting it train and then dropping it leaves compensating | |
| # haze smeared across the frame — found by the coverage battery.) | |
| m.bg_bias.requires_grad_(False) | |
| m.bg_bias.zero_() | |
| m.train() | |
| opt = torch.optim.Adam([p for p in m.parameters() if p.requires_grad], | |
| lr=1e-2) | |
| t0 = time.time() | |
| log = [] | |
| for it in range(iters): | |
| opt.zero_grad() | |
| mse = F.mse_loss(m.render(size, size, dev), tgt) | |
| loss = mse + l0_weight * m.gates.l0().sum() / n_atoms | |
| loss.backward() | |
| if it < gate_warmup and m.gates.logits.grad is not None: | |
| m.gates.logits.grad.zero_() | |
| opt.step() | |
| if it % max(1, iters // 10) == 0 or it == iters - 1: | |
| psnr = -10 * math.log10(max(mse.item(), 1e-12)) | |
| log.append({"it": it, "mse": float(mse.item()), "psnr_db": psnr}) | |
| if progress: | |
| progress(it / iters, f"fit {it}/{iters} psnr {psnr:.1f} dB") | |
| atoms = atoms_from_model(m) | |
| ledger = { | |
| "path": "fit_image", "status": "verified-CPU", | |
| "n_atoms_model": n_atoms, "n_atoms_open": int(len(atoms)), | |
| "iters": iters, "size": size, "seed": seed, | |
| "final_psnr_db": log[-1]["psnr_db"], "seconds": round(time.time() - t0, 1), | |
| "log": log, | |
| } | |
| return atoms, ledger | |
| # --------------------------------------------------------------------------- | |
| # path 2: text -> layer (GPU, the Bet-5 SDS loop from scratch) | |
| # --------------------------------------------------------------------------- | |
| def sds_layer(prompt, negative_prompt="blurry, low quality, deformed", | |
| n_atoms=192, iters=900, render_size=256, cfg=30.0, seed=0, | |
| l0_weight=3e-3, gate_warmup=150, | |
| t_min=0.02, t_max_start=0.98, t_max_end=0.50, | |
| sd_model="sd2-community/stable-diffusion-2-1-base", | |
| progress=None): | |
| """Text -> Gabor layer via score distillation. Line-for-line adaptation | |
| of the verified Bet-5 SDS loop, run FROM SCRATCH (no init atoms) and | |
| without camera jitter (a layer is a single canonical view). | |
| HONESTY: this function has NOT been executed in the build environment | |
| (no GPU, no SD weights). The first run on a GPU Space is a smoke test.""" | |
| if not torch.cuda.is_available(): | |
| raise RuntimeError( | |
| "text->layer needs a GPU (Stable Diffusion score distillation). " | |
| "This Space is on CPU hardware: use image->layer instead, or " | |
| "duplicate the Space onto GPU hardware.") | |
| from diffusers import StableDiffusionPipeline, DDPMScheduler | |
| device = torch.device("cuda") | |
| torch.manual_seed(seed) | |
| dtype = torch.float16 | |
| pipe = StableDiffusionPipeline.from_pretrained( | |
| sd_model, torch_dtype=dtype, safety_checker=None, | |
| requires_safety_checker=False) | |
| pipe.to(device) | |
| vae, unet, tok, te = pipe.vae, pipe.unet, pipe.tokenizer, pipe.text_encoder | |
| for mod in (vae, unet, te): | |
| mod.requires_grad_(False) | |
| sched = DDPMScheduler.from_pretrained(sd_model, subfolder="scheduler") | |
| alphas = sched.alphas_cumprod.to(device) | |
| T = sched.config.num_train_timesteps | |
| def embed(text): | |
| ids = tok(text, padding="max_length", max_length=tok.model_max_length, | |
| truncation=True, return_tensors="pt").input_ids.to(device) | |
| return te(ids)[0] | |
| with torch.no_grad(): | |
| emb = torch.cat([embed(negative_prompt), embed(prompt)]) | |
| m = GaborPacketImage(n_atoms, seed=seed) | |
| m.train().to(device) | |
| opt = torch.optim.Adam(m.parameters(), lr=1e-2) | |
| t0 = time.time() | |
| log = [] | |
| for it in range(iters): | |
| opt.zero_grad() | |
| img = m.render(render_size, render_size, device, chunk=64) | |
| x = img[None] * 2 - 1 | |
| if render_size != 512: | |
| x = F.interpolate(x, (512, 512), mode="bilinear", | |
| align_corners=False) | |
| latents = vae.encode(x.to(dtype)).latent_dist.sample() \ | |
| * vae.config.scaling_factor | |
| latents = latents.float() | |
| frac = it / max(1, iters - 1) | |
| t_max = t_max_start + (t_max_end - t_max_start) * frac | |
| t = torch.randint(int(t_min * T), int(t_max * T), (1,), device=device) | |
| noise = torch.randn_like(latents) | |
| noisy = sched.add_noise(latents, noise, t) | |
| with torch.no_grad(): | |
| eps = unet(torch.cat([noisy] * 2).to(dtype), torch.cat([t] * 2), | |
| encoder_hidden_states=emb).sample.float() | |
| eps_un, eps_tx = eps.chunk(2) | |
| eps_hat = eps_un + cfg * (eps_tx - eps_un) | |
| w = (1 - alphas[t]).view(-1, 1, 1, 1) | |
| grad = (w * (eps_hat - noise)).detach() | |
| sds_loss = (grad * latents).sum() / latents.numel() | |
| loss = sds_loss + l0_weight * m.gates.l0().sum() / n_atoms | |
| loss.backward() | |
| if it < gate_warmup and m.gates.logits.grad is not None: | |
| m.gates.logits.grad.zero_() | |
| torch.nn.utils.clip_grad_norm_(list(m.parameters()), 1.0) | |
| opt.step() | |
| if it % max(1, iters // 20) == 0 or it == iters - 1: | |
| log.append({"it": it, "sds": float(sds_loss.item()), | |
| "t_max": t_max}) | |
| if progress: | |
| progress(it / iters, f"sds {it}/{iters}") | |
| m.eval().cpu() | |
| atoms = atoms_from_model(m) | |
| ledger = { | |
| "path": "sds_layer", "status": "UNTESTED-GPU (adapted from verified Bet-5 loop)", | |
| "prompt": prompt, "negative_prompt": negative_prompt, | |
| "n_atoms_model": n_atoms, "n_atoms_open": int(len(atoms)), | |
| "iters": iters, "render_size": render_size, "cfg": cfg, "seed": seed, | |
| "sd_model": sd_model, "seconds": round(time.time() - t0, 1), | |
| "known_risk": "SD2.1 mode-seeking oversaturation at high CFG (Bet-5 ledger)", | |
| "log": log, | |
| } | |
| return atoms, ledger | |