File size: 15,700 Bytes
c53b38e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
#!/usr/bin/env python3
# app.py β€” SplatWorld on Hugging Face Spaces (Gradio, runs in a browser).
#
# The desktop explorer (explore_desktop.py) uses a live OpenCV window with
# mouse-drag surfing. Spaces have no display, so this version puts the same
# world behind sliders and buttons:
#
#   Surf    : radius = dive (core<->fire), morph sliders = drag, buttons = new face
#   Zoom    : scrub one identity's dive, or render the full Shepard-zoom video
#   Gallery : sample a grid of faces
#   About   : what this is and how it works
#
# Put splat_decoder.onnx (7.2 MB) next to this file. Without it the app still
# boots and shows mock noise, so the Space never hard-fails on startup.
#
#   python app.py            # launch locally at http://127.0.0.1:7860
#
# Loaded via onnxruntime (CPU); falls back to opencv's dnn, then to a mock.

import os, math, tempfile, threading
import numpy as np
import gradio as gr
from PIL import Image

LATENT   = 128
RSEED    = 7
DISPLAY  = 384          # on-screen render size (model is 96px, upscaled)
R_ESC    = 45.0
P_OCT    = 3.0
MODEL    = os.environ.get("SPLAT_MODEL", "splat_decoder.onnx")

def smooth(t):
    t = float(np.clip(t, 0.0, 1.0));  return t * t * (3 - 2 * t)

def shell_name(zn):
    if zn < 15:  return "core (a face)"
    if zn < 35:  return "ghost"
    if zn < 100: return "fire (the soup)"
    return "void"

# ------------------------------------------------------------------ decoder
class MockDecoder:
    def __init__(self, h=48):
        g = np.random.default_rng(99).standard_normal((3 * h * h, LATENT))
        self.W = (g / math.sqrt(LATENT)).astype(np.float32); self.h = h
    def __call__(self, zs):
        y = np.tanh(zs.astype(np.float32) @ self.W.T) * 0.5 + 0.5
        return y.reshape(len(zs), 3, self.h, self.h)

class Decoder:
    """onnxruntime -> opencv dnn (classic engine, thread-local) -> mock.

    OpenCV 5's new dnn engine asserts 'buf.u == m.u' when a Net built on one
    thread is run on another (which Gradio always does). onnxruntime avoids
    this entirely; if only OpenCV is available we build one Net per worker
    thread and prefer the classic engine."""
    def __init__(self, path=MODEL):
        self.backend = None; self.note = ""; self.path = path
        self._local = threading.local()
        self._engine = None
        if os.path.exists(path):
            try:
                import onnxruntime as ort
                self.sess = ort.InferenceSession(
                    path, providers=["CPUExecutionProvider"])
                self.iname = self.sess.get_inputs()[0].name
                self.oname = self.sess.get_outputs()[0].name
                self.backend = "onnxruntime"
            except Exception as e_ort:
                try:
                    import cv2 as cv
                    self._cv = cv
                    # prefer the classic engine; fall back to whatever exists
                    self._engine = getattr(cv.dnn, "ENGINE_CLASSIC", None)
                    self._net()                       # validate it builds
                    self.backend = "opencv-dnn"
                    self.note = ("onnxruntime not installed β€” using OpenCV dnn. "
                                 "For best results run:  pip install onnxruntime")
                except Exception as e_cv:
                    self.note = f"onnx load failed: onnxruntime=({e_ort}); opencv=({e_cv})"
        if self.backend is None:
            self.mock = MockDecoder(); self.backend = "mock"
            if not self.note:
                self.note = (f"'{path}' not found β€” showing mock noise. "
                             f"Add the model to see faces.")

    def _net(self):
        """A cv2 dnn Net local to the calling thread (dodges the cross-thread
        buffer assertion in OpenCV 5's new engine)."""
        n = getattr(self._local, "net", None)
        if n is None:
            cv = self._cv
            if self._engine is not None:
                try:
                    n = cv.dnn.readNetFromONNX(self.path, engine=self._engine)
                except TypeError:                     # OpenCV 4: no engine kw
                    n = cv.dnn.readNetFromONNX(self.path)
            else:
                n = cv.dnn.readNetFromONNX(self.path)
            self._local.net = n
        return n

    def __call__(self, zs):
        zs = np.ascontiguousarray(zs.astype(np.float32))
        if self.backend == "onnxruntime":
            return self.sess.run([self.oname], {self.iname: zs})[0]
        if self.backend == "opencv-dnn":
            net = self._net()
            net.setInput(zs, "z_latent")
            return net.forward("rendered_image").copy()
        return self.mock(zs)

DEC = Decoder()

def to_rgb(out_chw, size=DISPLAY):
    """(3,H,W) float [0,1] -> upscaled HxWx3 uint8 RGB via PIL bicubic."""
    im = np.transpose(np.clip(out_chw, 0, 1), (1, 2, 0))
    im = (im * 255).astype(np.uint8)
    return np.asarray(Image.fromarray(im).resize((size, size), Image.BICUBIC))

def decode_one(z, size=DISPLAY):
    return to_rgb(DEC(z[None])[0], size)

# ------------------------------------------------------------------- charts
def direction_bank(seed=RSEED):
    g = np.random.default_rng(seed).standard_normal((LATENT, LATENT))
    q, _ = np.linalg.qr(g)
    return q.T.astype(np.float32)
BANK = direction_bank()

def tangent_axes(d, e1, e2):
    """Three orthonormal directions in the tangent plane of unit vector d,
    so the morph sliders rotate the identity instead of rescaling |z|."""
    a = e1 - (e1 @ d) * d; a /= (np.linalg.norm(a) + 1e-9)
    b = e2 - (e2 @ d) * d - (e2 @ a) * a; b /= (np.linalg.norm(b) + 1e-9)
    e3 = BANK[2]
    c = e3 - (e3 @ d) * d - (e3 @ a) * a - (e3 @ b) * b
    c /= (np.linalg.norm(c) + 1e-9)
    return np.stack([a, b, c])

# =================================================================== SURF ===
def new_dir(seed):
    rng = np.random.default_rng(int(seed) & 0x7fffffff)
    d = rng.standard_normal(LATENT); d /= np.linalg.norm(d)
    return d.astype(np.float32)

def surf_render(state, radius, mx, my, mz):
    d = np.asarray(state["d"], np.float32)
    ax = tangent_axes(d, BANK[state["e"] % LATENT], BANK[(state["e"] + 1) % LATENT])
    d2 = d + mx * ax[0] + my * ax[1] + mz * ax[2]
    d2 /= (np.linalg.norm(d2) + 1e-9)
    z = (d2 * float(radius)).astype(np.float32)
    zn = float(np.linalg.norm(z))
    return decode_one(z), f"**|z| = {zn:5.1f}**   Β·   {shell_name(zn)}"

def surf_new_face(state):
    state = dict(state); state["d"] = new_dir(np.random.randint(1 << 30))
    img, info = surf_render(state, 8.0, 0.0, 0.0, 0.0)
    return state, img, info, 8.0, 0.0, 0.0, 0.0

def surf_reroll(state):
    state = dict(state); state["e"] = (state["e"] + 3) % LATENT
    return state, "_morph axes re-rolled β€” the sliders now push new directions_"

# =================================================================== ZOOM ===
class Journey:
    def __init__(self, wps): self.wp = wps
    def ident(self, k): return self.wp[k % len(self.wp)]
    def z(self, k, phi):
        K, N = self.ident(k), self.ident(k + 1)
        nK, nN = np.linalg.norm(K), np.linalg.norm(N)
        dK, dN = K / nK, N / nN
        if phi < 0.35:
            t = smooth(phi / 0.35); r = R_ESC + (nK - R_ESC) * t
            return (dK * r).astype(np.float32)
        elif phi < 0.65:
            return K.astype(np.float32)
        else:
            t = smooth((phi - 0.65) / 0.35); r = nK + (R_ESC - nK) * t
            om = math.acos(float(np.clip(dK @ dN, -1, 1)))
            d = dK if om < 1e-5 else \
                (math.sin((1-t)*om)*dK + math.sin(t*om)*dN) / math.sin(om)
            d /= np.linalg.norm(d)
            return (d * r).astype(np.float32)

def zoom_waypoints(seed, n=48):
    rng = np.random.default_rng(int(seed) & 0x7fffffff)
    return [rng.standard_normal(LATENT).astype(np.float32) * 0.6 for _ in range(n)]

def zoom_scrub(state, phi):
    jour = Journey(state["wps"])
    z = jour.z(0, float(phi))
    zn = float(np.linalg.norm(z))
    stage = ("descending into the face" if phi < 0.35 else
             "dwelling inside the face"  if phi < 0.65 else
             "rising back into the fire")
    return decode_one(z), f"phi {float(phi):.2f}  Β·  |z| {zn:5.1f}  Β·  {shell_name(zn)}  Β·  {stage}"

def zoom_new_ids(state):
    state = dict(state); state["wps"] = zoom_waypoints(np.random.randint(1 << 30))
    img, info = zoom_scrub(state, 0.0)
    return state, img, info, 0.0

def zoom_video(state, cycles, progress=gr.Progress()):
    """Render a short Shepard zoom to mp4: fall through several identities."""
    import imageio
    jour = Journey(state["wps"])
    fps, per = 24, 48
    frames = []
    total = int(cycles) * per
    for f in progress.tqdm(range(total), desc="rendering zoom"):
        u = f / per; k = int(u); phi = u - k
        frames.append(decode_one(jour.z(k, phi), size=256))
    path = os.path.join(tempfile.mkdtemp(), "splat_zoom.mp4")
    imageio.mimwrite(path, frames, fps=fps, codec="libx264",
                     quality=8, macro_block_size=None)
    return path

# ================================================================ GALLERY ===
def gallery(n, radius, seed, progress=gr.Progress()):
    rng = np.random.default_rng(int(seed) & 0x7fffffff)
    n = int(n)
    zs = (rng.standard_normal((n, LATENT)) * float(radius)).astype(np.float32)
    imgs = []
    for i in progress.tqdm(range(n), desc="sampling faces"):
        imgs.append(decode_one(zs[i], size=224))
    return imgs

# ================================================================== THEORY ==
ABOUT = """
## SplatWorld β€” 202,599 faces in a 7 MB wave field

This decoder doesn't store pixels. It maps a 128-D point **z** to **256 Gabor
wave packets** β€” each one a little oriented wave with a position, a size, an
orientation, a frequency, and a *complex* amplitude (a cosine weight and a sine
weight). The picture is the **sum of all 256 packets interfering**. A face is a
phase-locked standing wave.

**Fire vs face.** Near the origin (`|z| < 15`) the packets phase-lock: peaks and
troughs cancel everywhere except along an eyebrow or a cheekbone. Far out
(`|z| > 35`) there's no training data, so the decoder stops orchestrating β€” the
packets decorrelate into drifting "fire". Zoom rides the radius: dive from fire
into a face and back, forever (a Shepard tone for the eye).

**The space between faces.** Moving a feature from A to B is *transport*. In a
fixed additive basis the only way is to fade one atom out while fading another
in β€” mid-way both exist and their phases fight, and that fight *is* the fire.
This is 1990s technology: **eigenfaces** interpolated faces linearly in 1991 and
produced exactly these ghosts. The loophole: a complex atom can **translate by
rotating its phase** (a Fourier shift) instead of crossfading β€” phase-transport
leaves no ghost, and that's the direction this whole line of work points at.

**Honest notes.** 96Γ—96 is a VRAM limit, not a taste; hair and fine detail
struggle and samples skew toward a mean face. The "standing wave" language is a
faithful description of a Gabor renderer, not a claim of new physics. Trained on
CelebA (non-commercial research use β€” check the dataset's terms).

*Do not hype. Do not lie. Just show.*
"""

# =================================================================== BUILD ==
def build():
    with gr.Blocks(title="SplatWorld") as demo:
        gr.Markdown("# 🌊 SplatWorld\n"
                    "**202,599 CelebA faces compressed into a 7 MB wave-interference "
                    "field.** Dive through it below.")
        if DEC.backend == "mock":
            gr.Markdown(f"> ⚠️ {DEC.note}")
        else:
            gr.Markdown(f"<sub>model backend: <code>{DEC.backend}</code></sub>")

        with gr.Tabs():
            # ---- SURF ----
            with gr.Tab("Surf"):
                s_state = gr.State(value={"d": new_dir(RSEED), "e": 0})
                with gr.Row():
                    with gr.Column(scale=1):
                        s_img = gr.Image(label="face", height=DISPLAY, width=DISPLAY)
                        s_info = gr.Markdown()
                    with gr.Column(scale=1):
                        s_r = gr.Slider(1, 55, value=8, step=0.5,
                                        label="radius Β· dive core (a face) ↔ rise into the fire")
                        gr.Markdown("**morph** β€” nudge the identity along three directions:")
                        s_x = gr.Slider(-1.5, 1.5, value=0, step=0.02, label="morph A")
                        s_y = gr.Slider(-1.5, 1.5, value=0, step=0.02, label="morph B")
                        s_z = gr.Slider(-1.5, 1.5, value=0, step=0.02, label="morph C")
                        with gr.Row():
                            s_new = gr.Button("🎲 new face")
                            s_roll = gr.Button("↻ re-roll morph axes")
                        s_roll_info = gr.Markdown()
                inp = [s_state, s_r, s_x, s_y, s_z]
                for ctrl in (s_r, s_x, s_y, s_z):
                    ctrl.change(surf_render, inp, [s_img, s_info])
                s_new.click(surf_new_face, s_state,
                            [s_state, s_img, s_info, s_r, s_x, s_y, s_z])
                s_roll.click(surf_reroll, s_state, [s_state, s_roll_info])
                demo.load(surf_render, inp, [s_img, s_info])

            # ---- ZOOM ----
            with gr.Tab("Zoom"):
                z_state = gr.State(value={"wps": zoom_waypoints(RSEED)})
                with gr.Row():
                    with gr.Column():
                        z_img = gr.Image(label="one identity's dive",
                                         height=DISPLAY, width=DISPLAY)
                        z_info = gr.Markdown()
                    with gr.Column():
                        z_phi = gr.Slider(0, 0.999, value=0, step=0.005,
                                          label="phi Β· scrub: fire β†’ face β†’ fire")
                        z_new = gr.Button("🎲 new identities")
                        gr.Markdown("**Full Shepard zoom** (falls through many faces, "
                                    "the scale reset hidden in the fire):")
                        z_cycles = gr.Slider(2, 8, value=4, step=1,
                                             label="identities to fall through")
                        z_go = gr.Button("🎬 render zoom video")
                        z_vid = gr.Video(label="Shepard zoom")
                z_phi.change(zoom_scrub, [z_state, z_phi], [z_img, z_info])
                z_new.click(zoom_new_ids, z_state, [z_state, z_img, z_info, z_phi])
                z_go.click(zoom_video, [z_state, z_cycles], z_vid)
                demo.load(zoom_scrub, [z_state, z_phi], [z_img, z_info])

            # ---- GALLERY ----
            with gr.Tab("Gallery"):
                with gr.Row():
                    g_n = gr.Slider(4, 64, value=24, step=4, label="how many faces")
                    g_r = gr.Slider(0.2, 3.0, value=0.6, step=0.1, label="radius (spread)")
                    g_seed = gr.Number(value=7, label="seed", precision=0)
                g_go = gr.Button("πŸ–ΌοΈ sample faces")
                g_out = gr.Gallery(label="samples", columns=6, height=560)
                g_go.click(gallery, [g_n, g_r, g_seed], g_out)

            # ---- ABOUT ----
            with gr.Tab("About / Theory"):
                gr.Markdown(ABOUT)

    return demo

if __name__ == "__main__":
    demo = build()
    try:
        demo.launch(theme=gr.themes.Soft())     # Gradio 6: theme lives here
    except TypeError:
        demo.launch()                            # older Gradio: theme was on Blocks