CoolFace
Apppublic

Aluode/SplatWorld

sourceHugging Facecc-by-nc-4.0updated 2mo agoView on Hugging Face
0likes
explore_desktop.py555 linesDownload Raw Back to root
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