Spaces:
Sleeping
Sleeping
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
|