Aluode/SplatWorld
0
1#!/usr/bin/env python32# app.py — SplatWorld: walk around inside a 7 MB wave-interference face field.3#4# One decoder (splat_decoder.onnx) maps a 128-D point z to 256 Gabor wave5# packets. Their interference IS the image. A face is a phase-locked standing6# wave; the "fire" you see between faces is those same waves decorrelating.7# This app is the instrument you explore that world with.8#9# python app.py # launch (needs splat_decoder.onnx here)10# python app.py --gallery 64 # headless: dump 64 faces to ./gallery/11# python app.py --selftest # no window; check the math12#13# ---- GLOBAL KEYS -----------------------------------------------------------14# 1 ZOOM mode 2 SURF mode 3 ATLAS info15# H theory (cycles pages) B bump a face gallery to disk16# R record video on/off S save this frame Q quit17#18# ---- ZOOM (automated Shepard dive, hands-free) -----------------------------19# wheel / UP,DOWN zoom speed (negative = reverse)20# LEFT / RIGHT skip to previous / next identity SPACE pause21# The camera falls down one identity's ray until the face resolves, dwells,22# then rises back into the fire and turns toward the next identity. The scale23# reset is hidden in the fire (a visual Shepard tone) so the dive never ends.24#25# ---- SURF (free flight, no charts, no TAB) ---------------------------------26# drag morph the identity (rotate your ray through face-space)27# wheel dive toward the core (a face) or rise into the fire28# SPACE jump to a fresh random face29# N re-roll the drag plane (a new pair of morph directions)30# Radius = how phase-locked the waves are (core=face, fire=soup).31# Direction = which identity. They are decoupled on purpose.32#33# Honest notes:34# - render is native model res (96px) upscaled with cubic to the window;35# it is not true super-resolution.36# - SURF's drag spans a 2-plane of the 128-D tangent space at a time; press N37# for a fresh plane. It is a hang-glider, not a spaceship — that is the fun.38 39import argparse, glob, math, os, sys, time40import numpy as np41 42LATENT = 12843RSEED = 744WIN = 76845R_ESC = 45.0 # escape radius: deep fire46P_OCT = 3.0 # octaves of zoom per identity cycle47WRAP_W = 0.1248DESC, DWEND = 0.35, 0.6549EPS = 1e-950 51def smooth(t):52 t = np.clip(t, 0.0, 1.0)53 return t * t * (3 - 2 * t)54 55def shell_name(zn):56 if zn < 15: return "core"57 if zn < 35: return "ghost"58 if zn < 100: return "fire"59 return "void"60 61# ----------------------------------------------------------------- decoders62class OnnxDecoder:63 def __init__(self, path="splat_decoder.onnx"):64 if not os.path.exists(path):65 raise FileNotFoundError(66 f"{path} not found. Put the 7 MB splat_decoder.onnx next to this "67 f"file (see the README for where to get it).")68 self.backend = None69 # Prefer onnxruntime: OpenCV 5's dnn importer cannot parse this graph's70 # ConstantOfShape (dynamic batch) node and dies on batched forwards.71 try:72 import onnxruntime as ort73 self.sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])74 self.iname = self.sess.get_inputs()[0].name75 self.oname = self.sess.get_outputs()[0].name76 self.backend = "onnxruntime"77 print("decoder backend: onnxruntime")78 except Exception as e:79 import cv2 as cv80 self.net = None81 for eng in (getattr(cv.dnn, "ENGINE_CLASSIC", None),82 getattr(cv.dnn, "ENGINE_AUTO", None)):83 if eng is None:84 continue85 try:86 self.net = cv.dnn.readNetFromONNX(path, engine=eng); break87 except TypeError:88 break89 except Exception:90 continue91 if self.net is None:92 self.net = cv.dnn.readNetFromONNX(path)93 self.backend = "opencv-dnn"94 print(f"decoder backend: opencv-dnn (onnxruntime unavailable: {e})")95 print(" -> on OpenCV 5, install onnxruntime for full/batched support:")96 print(" python -m pip install onnxruntime")97 def __call__(self, zs):98 zs = np.ascontiguousarray(zs.astype(np.float32))99 if self.backend == "onnxruntime":100 return self.sess.run([self.oname], {self.iname: zs})[0]101 self.net.setInput(zs, "z_latent")102 return self.net.forward("rendered_image").copy() # (N,3,H,W) in [0,1]103 104class MockDecoder:105 """A stand-in so --selftest runs without the real model or a GPU."""106 def __init__(self, h=48):107 g = np.random.default_rng(99).standard_normal((3 * h * h, LATENT))108 self.W = (g / math.sqrt(LATENT)).astype(np.float32)109 self.h = h110 def __call__(self, zs):111 y = np.tanh(zs.astype(np.float32) @ self.W.T) * 0.5 + 0.5112 return y.reshape(len(zs), 3, self.h, self.h)113 114# ================================================================= ZOOM ====115def prior_waypoints(rng, n=64):116 return [rng.standard_normal(LATENT).astype(np.float32) * 0.6 for _ in range(n)]117 118def atlas_waypoints(rng, n=64, outdir="atlas"):119 """Real core faces from a dumped atlas, if one exists."""120 import csv121 picks = []122 for mf in sorted(glob.glob(f"{outdir}/shard_*_meta.csv")):123 tag = mf.split("shard_")[1].split("_")[0]124 try:125 zs = np.load(f"{outdir}/shard_{tag}_z.npy")126 except OSError:127 continue128 with open(mf) as f:129 for i, row in enumerate(csv.DictReader(f)):130 if row["strategy"] == "prior" and float(row["param"]) <= 1.0:131 picks.append(zs[i].astype(np.float32))132 if len(picks) > 4 * n:133 break134 if not picks:135 return prior_waypoints(rng, n)136 rng.shuffle(picks)137 return picks[:n]138 139class Journey:140 """z(cycle,phi): fire -> down identity K's ray -> dwell -> back to fire."""141 def __init__(self, waypoints):142 self.wp = waypoints143 def ident(self, k):144 return self.wp[k % len(self.wp)]145 def z(self, k, phi):146 K, N = self.ident(k), self.ident(k + 1)147 nK, nN = np.linalg.norm(K), np.linalg.norm(N)148 dK, dN = K / nK, N / nN149 if phi < DESC:150 t = smooth(phi / DESC)151 r = R_ESC + (nK - R_ESC) * t152 return (dK * r).astype(np.float32)153 elif phi < DWEND:154 return K.astype(np.float32)155 else:156 t = smooth((phi - DWEND) / (1.0 - DWEND))157 r = nK + (R_ESC - nK) * t158 om = math.acos(float(np.clip(dK @ dN, -1, 1)))159 if om < 1e-5:160 d = dK161 else:162 d = (math.sin((1 - t) * om) * dK + math.sin(t * om) * dN) / math.sin(om)163 d /= np.linalg.norm(d)164 return (d * r).astype(np.float32)165 166def voices(u):167 """Zoom coord u (cycles) -> [(k, phi, scale, weight)]; blends across wraps."""168 k = int(math.floor(u)); phi = u - k; out = []169 if phi < WRAP_W:170 a = smooth((phi + WRAP_W) / (2 * WRAP_W))171 out.append((k, phi, 2.0 ** (P_OCT * phi), a))172 out.append((k - 1, phi + 1.0, 2.0 ** (P_OCT * (phi + 1)), 1 - a))173 elif phi > 1.0 - WRAP_W:174 a = smooth((phi - (1.0 - WRAP_W)) / (2 * WRAP_W))175 out.append((k, phi, 2.0 ** (P_OCT * phi), 1 - a))176 out.append((k + 1, phi - 1.0, 2.0 ** (P_OCT * (phi - 1)), a))177 else:178 out.append((k, phi, 2.0 ** (P_OCT * phi), 1.0))179 return out180 181def compose_zoom(dec, jour, u, drift_rho=0.0):182 import cv2 as cv183 vs = voices(u)184 zs = np.stack([jour.z(k, phi) for k, phi, _, _ in vs])185 outs = dec(zs)186 acc = None187 for (k, phi, s, w), out in zip(vs, outs):188 im = np.transpose(out, (1, 2, 0)).astype(np.float32)189 h = im.shape[0]190 M = cv.getRotationMatrix2D((h / 2, h / 2), drift_rho * 360, s)191 warped = cv.warpAffine(im, M, (h, h), flags=cv.INTER_CUBIC,192 borderMode=cv.BORDER_REFLECT)193 acc = warped * w if acc is None else acc + warped * w194 big = cv.resize(acc, (WIN, WIN), interpolation=cv.INTER_CUBIC)195 zn = float(np.linalg.norm(zs[0]))196 return np.clip(big, 0, 1), zn, vs[0][0], vs[0][1]197 198# ================================================================= SURF ====199def tangent_axes(d, e1, e2):200 """Two orthonormal directions in the tangent plane of unit vector d,201 built by projecting fixed globals e1,e2 off d (so dragging rotates the202 identity, it never just rescales |z|)."""203 a = e1 - (e1 @ d) * d204 a /= (np.linalg.norm(a) + EPS)205 b = e2 - (e2 @ d) * d - (e2 @ a) * a206 b /= (np.linalg.norm(b) + EPS)207 return a, b208 209DRAG_GAIN = 0.004210WHEEL_STEP = 1.5211SURF_SM = 0.25212 213class SurfFree:214 """Free flight: a unit direction (identity) + a radius (phase-lock depth)."""215 def __init__(self, rng):216 self.rng = rng217 g = rng.standard_normal((LATENT, LATENT))218 q, _ = np.linalg.qr(g)219 self.bank = q.T.astype(np.float32)220 self.e1, self.e2 = self.bank[0], self.bank[1]221 self.reseed()222 def reseed(self):223 d = self.rng.standard_normal(LATENT)224 self.td = (d / np.linalg.norm(d)).astype(np.float32)225 self.tr = 8.0 # start just inside a face226 self.d = self.td.copy(); self.r = self.tr227 def reroll_plane(self):228 i = int(self.rng.integers(0, LATENT - 1))229 self.e1, self.e2 = self.bank[i], self.bank[(i + 1) % LATENT]230 def drag(self, dx, dy, fine=False):231 a, b = tangent_axes(self.td, self.e1, self.e2)232 g = DRAG_GAIN * (0.2 if fine else 1.0)233 nd = self.td + g * (dx * a - dy * b)234 self.td = (nd / np.linalg.norm(nd)).astype(np.float32)235 def wheel(self, notches):236 self.tr = float(np.clip(self.tr + notches * WHEEL_STEP, 1.0, 55.0))237 def tick(self):238 self.d += SURF_SM * (self.td - self.d)239 self.d /= (np.linalg.norm(self.d) + EPS)240 self.r += SURF_SM * (self.tr - self.r)241 def z(self):242 return (self.d * self.r).astype(np.float32)243 244def compose_surf(dec, surf):245 import cv2 as cv246 out = dec(surf.z()[None])[0]247 im = np.transpose(out, (1, 2, 0)).astype(np.float32)248 big = cv.resize(im, (WIN, WIN), interpolation=cv.INTER_CUBIC)249 return np.clip(big, 0, 1), float(np.linalg.norm(surf.z()))250 251# ================================================================ THEORY ===252THEORY = [253 ("WHAT IS THIS", [254 "SplatWorld: 202,599 CelebA faces living inside one",255 "7.2 MB decoder. It does NOT store pixels.",256 "",257 "A tiny MLP maps a 128-D point z to 256 Gabor 'atoms'.",258 "Each atom = position, size (sigma), orientation (theta),",259 "frequency, and a complex (a,b) amplitude per color:",260 "the cosine weight and the sine weight of a little wave.",261 "",262 "The picture is the SUM of 256 vibrating wave packets.",263 "A face is not painted. It is an interference pattern.",264 "Trained at 96x96 (a VRAM limit), 30k steps, ~5.7M params.",265 ]),266 ("FIRE vs FACE (how it works)", [267 "Core (|z| < 15): the atoms PHASE-LOCK. Peaks and troughs",268 "line up to cancel everywhere except along an eyebrow or a",269 "cheekbone. A standing wave. That is a face.",270 "",271 "Fire (|z| > 35): no training data lives out here, so the",272 "decoder stops orchestrating. The atoms DECORRELATE, their",273 "envelopes wander and phases drift. That soup is the fire.",274 "",275 "ZOOM rides the radius |z|: dive from fire -> ghost -> face,",276 "then back out. The scale reset hides in the fire, so the",277 "dive loops forever (a Shepard tone for the eye).",278 ]),279 ("THE SPACE BETWEEN (the theory)", [280 "Between two faces you see splats and soup. Why?",281 "Moving a feature from A to B is TRANSPORT. In a fixed",282 "additive basis, transport means: fade one atom out while",283 "fading another in. Mid-way both exist and their phases",284 "fight -> interference. That fight IS the fire.",285 "",286 "This is 1990s tech: eigenfaces did face-space by linear",287 "transfer and got exactly these ghostly double-exposures.",288 "",289 "The loophole: a complex (a,b) atom can TRANSLATE by rotating",290 "its phase (a Fourier shift) instead of crossfading. Phase-",291 "transport leaves no ghost. That is the direction worth chasing.",292 "",293 "Squeeze faces together (raise beta) -> smooth morphs but",294 "identities collapse to a mean. Push apart (low beta) -> crisp",295 "faces, vast fire between. The gap is a tug-of-war.",296 ]),297 ("CONTROLS", [298 "1 zoom 2 surf 3 atlas H theory B gallery",299 "R record S save frame Q quit",300 "",301 "ZOOM: wheel = speed, arrows = skip identity, SPACE = pause",302 "SURF: drag = morph identity, wheel = dive(core)<->rise(fire),",303 " SPACE = new face, N = re-roll the drag plane",304 "",305 "ATLAS is a separate surveyor (splat_atlas.py). Run --dump",306 "once to bake thousands of thumbnails, then --browse to click",307 "through them (n/p flip pages). See the README.",308 ]),309]310 311def draw_panel(img, title, lines):312 import cv2 as cv313 ov = img.copy()314 cv.rectangle(ov, (0, 0), (WIN, WIN), (12, 12, 16), -1)315 cv.addWeighted(ov, 0.82, img, 0.18, 0, img)316 y = 64317 cv.putText(img, title, (44, y), cv.FONT_HERSHEY_DUPLEX, 1.0,318 (0, 255, 255), 1, cv.LINE_AA)319 y += 20320 cv.line(img, (44, y), (WIN - 44, y), (0, 120, 120), 1, cv.LINE_AA)321 y += 34322 for ln in lines:323 cv.putText(img, ln, (44, y), cv.FONT_HERSHEY_PLAIN, 1.15,324 (210, 210, 210), 1, cv.LINE_AA)325 y += 30326 cv.putText(img, "H = next page any mode key = leave",327 (44, WIN - 28), cv.FONT_HERSHEY_PLAIN, 1.0,328 (120, 200, 120), 1, cv.LINE_AA)329 return img330 331# =============================================================== GALLERY ===332def dump_gallery(dec, n=64, outdir="gallery", radius=0.6, seed=None):333 import cv2 as cv334 os.makedirs(outdir, exist_ok=True)335 rng = np.random.default_rng(seed)336 zs = (rng.standard_normal((n, LATENT)) * radius).astype(np.float32)337 outs = dec(zs)338 tiles = []339 for i, out in enumerate(outs):340 im = (np.transpose(out, (1, 2, 0)) * 255).clip(0, 255).astype(np.uint8)341 im = cv.cvtColor(im, cv.COLOR_RGB2BGR)342 big = cv.resize(im, (256, 256), interpolation=cv.INTER_CUBIC)343 cv.imwrite(f"{outdir}/face_{i:03d}.png", big)344 tiles.append(cv.resize(im, (96, 96), interpolation=cv.INTER_CUBIC))345 cols = int(math.ceil(math.sqrt(n)))346 rows = int(math.ceil(n / cols))347 sheet = np.zeros((rows * 96, cols * 96, 3), np.uint8)348 for i, t in enumerate(tiles):349 r, c = divmod(i, cols)350 sheet[r*96:(r+1)*96, c*96:(c+1)*96] = t351 cv.imwrite(f"{outdir}/contact_sheet.png", sheet)352 return outdir, n353 354# =============================================================== SELFTEST ==355def selftest():356 ok = True357 def check(name, cond, note=""):358 nonlocal ok; ok &= bool(cond)359 print(f" [{'PASS' if cond else 'FAIL'}] {name} {note}")360 361 rng = np.random.default_rng(0)362 363 # zoom path is C0 across the whole cycle incl. the waypoint hand-off364 j = Journey(prior_waypoints(rng, 8))365 us = np.linspace(0.001, 2.999, 900); prev = None; mx = 0.0366 for u in us:367 k = int(u); z = j.z(k, u - k)368 if prev is not None: mx = max(mx, float(np.linalg.norm(z - prev)))369 prev = z370 check("zoom path C0", mx < 2.0, f"max step {mx:.3f}")371 check("dwell is identity K", np.allclose(j.z(0, 0.5), j.ident(0), atol=1e-6))372 gap = np.linalg.norm(j.z(0, 1 - 1e-9) - j.z(1, 0.0))373 check("z C0 across wrap", gap < 1e-3, f"gap {gap:.2e}")374 for u in (0.05, 0.5, 0.95, 1.0, 1.03):375 ws = sum(w for _, _, _, w in voices(u))376 check(f"voice weights sum to 1 @u={u}", abs(ws - 1) < 1e-6, f"{ws:.6f}")377 378 # surf: tangent axes are orthonormal and perpendicular to the ray;379 # dragging rotates the identity but keeps it a unit vector; wheel clamps380 s = SurfFree(np.random.default_rng(1))381 a, b = tangent_axes(s.td, s.e1, s.e2)382 check("tangent axes orthonormal",383 abs(a @ a - 1) < 1e-5 and abs(b @ b - 1) < 1e-5 and abs(a @ b) < 1e-5)384 check("tangent axes perp to ray", abs(a @ s.td) < 1e-5 and abs(b @ s.td) < 1e-5)385 d0 = s.td.copy(); s.drag(120, -40)386 check("drag rotates identity", not np.allclose(s.td, d0, atol=1e-4))387 check("identity stays unit", abs(np.linalg.norm(s.td) - 1) < 1e-5)388 s.wheel(1000); check("wheel clamps radius", s.tr == 55.0, f"{s.tr}")389 s.wheel(-1000); check("wheel clamps low", s.tr == 1.0, f"{s.tr}")390 [s.tick() for _ in range(40)]391 check("shown ray converges", np.allclose(s.d, s.td, atol=1e-3))392 393 # compositors render sane frames with the mock decoder394 dec = MockDecoder()395 fr, zn, k, phi = compose_zoom(dec, j, 0.97)396 check("zoom frame shape", fr.shape == (WIN, WIN, 3), str(fr.shape))397 check("zoom frame in range", 0 <= fr.min() and fr.max() <= 1.0)398 fr2, zn2 = compose_surf(dec, s)399 check("surf frame shape", fr2.shape == (WIN, WIN, 3), str(fr2.shape))400 check("shell classifier", shell_name(5) == "core" and shell_name(50) == "fire")401 402 # gallery dumps files + a contact sheet403 import tempfile404 gd = tempfile.mkdtemp()405 outdir, n = dump_gallery(dec, n=9, outdir=gd)406 check("gallery wrote faces", os.path.exists(f"{gd}/face_000.png") and n == 9)407 check("gallery contact sheet", os.path.exists(f"{gd}/contact_sheet.png"))408 409 check("theory pages present", len(THEORY) == 4 and all(t[1] for t in THEORY))410 print("selftest:", "ALL PASS" if ok else "FAILURES ABOVE")411 return 0 if ok else 1412 413# =================================================================== LIVE ==414def try_launch_atlas():415 """If an atlas exists, open its browser; else print how to build one."""416 if glob.glob("atlas/sheet_*.png"):417 import subprocess418 try:419 subprocess.Popen([sys.executable, "splat_atlas.py", "--browse"])420 return "launched splat_atlas.py --browse"421 except Exception as e:422 return f"could not launch atlas browser: {e}"423 return "no atlas yet -> run: python splat_atlas.py --dump --gb 2"424 425def live():426 import cv2 as cv427 dec = OnnxDecoder()428 rng = np.random.default_rng()429 wps = atlas_waypoints(rng)430 jour = Journey(wps)431 surf = SurfFree(rng)432 433 st = {"mode": "zoom", "wheel": 0}434 u, vel, paused = 0.0, 0.10, False435 theory_page = 0436 atlas_msg = ""437 writer = None438 win = "SplatWorld [1]zoom [2]surf [3]atlas [H]theory [B]gallery [Q]quit"439 cv.namedWindow(win, cv.WINDOW_NORMAL)440 441 state = {"btn": 0, "px": 0, "py": 0}442 def on_mouse(ev, x, y, flags, _):443 if ev == cv.EVENT_MOUSEWHEEL:444 st["wheel"] = 1 if flags > 0 else -1445 elif ev in (cv.EVENT_LBUTTONDOWN, cv.EVENT_RBUTTONDOWN):446 state["btn"] = 1 if ev == cv.EVENT_LBUTTONDOWN else 2447 state["px"], state["py"] = x, y448 elif ev in (cv.EVENT_LBUTTONUP, cv.EVENT_RBUTTONUP):449 state["btn"] = 0450 elif ev == cv.EVENT_MOUSEMOVE and state["btn"] and st["mode"] == "surf":451 surf.drag(x - state["px"], y - state["py"], fine=(state["btn"] == 2))452 state["px"], state["py"] = x, y453 cv.setMouseCallback(win, on_mouse)454 455 tprev = time.time()456 print("SplatWorld flying. 1=zoom 2=surf 3=atlas H=theory B=gallery Q=quit")457 while True:458 now = time.time(); dt = min(0.1, now - tprev); tprev = now459 wheel = st["wheel"]; st["wheel"] = 0460 461 if st["mode"] == "zoom":462 if wheel: vel += 0.02 * wheel463 if not paused: u += vel * dt464 fr, zn, k, phi = compose_zoom(dec, jour, u, drift_rho=0.02 * u)465 hud = (f"ZOOM identity {k} phi {phi:.2f} |z| {zn:5.1f} "466 f"[{shell_name(zn)}] vel {vel:+.2f}")467 elif st["mode"] == "surf":468 if wheel: surf.wheel(wheel)469 surf.tick()470 fr, zn = compose_surf(dec, surf)471 hud = f"SURF |z| {zn:5.1f} [{shell_name(zn)}] drag=morph wheel=dive"472 else: # atlas info screen473 fr = np.zeros((WIN, WIN, 3), np.float32)474 zn = 0.0; hud = "ATLAS"475 476 im = cv.cvtColor((fr * 255).astype(np.uint8), cv.COLOR_RGB2BGR)477 478 if st["mode"] == "atlas":479 draw_panel(im, "ATLAS (separate surveyor)", [480 "The atlas bakes thousands of thumbnails to disk so you",481 "can eyeball the whole latent space and click any tile",482 "back to a full-res face + its z.",483 "",484 " python splat_atlas.py --dump --gb 2 (build it once)",485 " python splat_atlas.py --browse (n/p flip pages)",486 " python splat_atlas.py --analyze (departure curves)",487 "",488 f"status: {atlas_msg or 'press A to open the browser if built'}",489 ])490 elif st["mode"] == "theory":491 draw_panel(im, *THEORY[theory_page])492 else:493 cv.putText(im, hud, (12, WIN - 14), cv.FONT_HERSHEY_PLAIN,494 1.15, (0, 255, 0), 1, cv.LINE_AA)495 496 if writer is not None:497 writer.write(im)498 cv.imshow(win, im)499 key = cv.waitKeyEx(1)500 if key == -1:501 continue502 k = key & 0xFF503 if k == ord('q'):504 break505 elif k == ord('1'): st["mode"] = "zoom"506 elif k == ord('2'): st["mode"] = "surf"507 elif k == ord('3'): st["mode"] = "atlas"508 elif k == ord('h'):509 if st["mode"] == "theory":510 theory_page = (theory_page + 1) % len(THEORY)511 else:512 st["mode"] = "theory"; theory_page = 0513 elif k == ord('a') and st["mode"] == "atlas":514 atlas_msg = try_launch_atlas(); print(atlas_msg)515 elif k == ord('b'):516 outdir, n = dump_gallery(dec, n=64)517 print(f"bumped {n} faces -> ./{outdir}/ (+ contact_sheet.png)")518 elif k == ord('r'):519 if writer is None:520 fn = f"splatworld_{int(time.time())}.mp4"521 writer = cv.VideoWriter(fn, cv.VideoWriter_fourcc(*"mp4v"),522 30, (WIN, WIN))523 print("recording ->", fn)524 else:525 writer.release(); writer = None; print("recording stopped")526 elif k == ord('s'):527 fn = f"frame_{int(time.time())}.png"; cv.imwrite(fn, im); print("saved", fn)528 elif k == ord(' '):529 if st["mode"] == "zoom": paused = not paused530 elif st["mode"] == "surf": surf.reseed()531 elif k == ord('n') and st["mode"] == "surf":532 surf.reroll_plane(); print("surf plane re-rolled")533 elif key in (2490368,) and st["mode"] == "zoom": vel += 0.02 # UP534 elif key in (2621440,) and st["mode"] == "zoom": vel -= 0.02 # DOWN535 elif key in (2555904,) and st["mode"] == "zoom": u = math.floor(u) + 1.0536 elif key in (2424832,) and st["mode"] == "zoom": u = max(0.0, math.floor(u) - 1.0)537 538 if writer is not None:539 writer.release()540 cv.destroyAllWindows()541 542if __name__ == "__main__":543 ap = argparse.ArgumentParser(description="SplatWorld explorer")544 ap.add_argument("--selftest", action="store_true")545 ap.add_argument("--gallery", type=int, default=None, metavar="N",546 help="headless: dump N faces to ./gallery/ and exit")547 a = ap.parse_args()548 if a.selftest:549 sys.exit(selftest())550 elif a.gallery is not None:551 outdir, n = dump_gallery(OnnxDecoder(), n=a.gallery)552 print(f"wrote {n} faces -> ./{outdir}/ (+ contact_sheet.png)")553 else:554 live()555 