SplatWorld / splat_atlas.py
Aluode's picture
Upload 8 files
c53b38e verified
Raw
History Blame Contribute Delete
14.9 kB
#!/usr/bin/env python3
# splat_atlas.py — automatic latent-space surveyor for splat_decoder.onnx
#
# Burns a few GB of CIFAR-size (32x32) thumbnails to disk, systematically,
# and keeps the z of EVERY thumbnail (float16) — so any dot in the atlas can
# be re-rendered at full model resolution, or used as a surf starting point.
#
# python splat_atlas.py --dump --gb 2 # survey -> ./atlas/
# python splat_atlas.py --browse # click a tile -> full res + z
# python splat_atlas.py --analyze # departure curves, csv + png
# python splat_atlas.py --selftest
#
# Survey strategies (fixed seed, resumable by shard):
# prior z ~ N(0, r) at radii r in RADII — the on-manifold -> splat-soup
# transition you found by hand, sampled densely at every shell
# ray straight-line walks outward along fixed directions, r = 0..MAX —
# "going the same direction, the splats get stronger", measured
# walk long random walks of small steps (your surf+TAB accumulation,
# automated): step ~ N(0, 0.15), thousands of steps, save each
# slerp great-circle interpolations between random prior points —
# does the manifold stay face-like BETWEEN faces?
#
# Storage: atlas/shard_XXXX_img.npy uint8 (N,32,32,3)
# atlas/shard_XXXX_z.npy float16 (N,128)
# atlas/shard_XXXX_meta.csv strategy,param,low,mid,fine per row
# atlas/sheet_XXXX.png 32x32 contact sheet per shard (eyeball)
#
# Analysis written by --analyze: band energies vs radius per strategy — the
# fine-band curve is the quantitative version of "splats appear and grow".
import argparse, csv, glob, math, os, sys, time
import numpy as np
LATENT = 128
RSEED = 7
THUMB = 32
SHARD_N = 4096 # images per shard (12.6 MB img + 1 MB z)
RADII = [0.3, 0.6, 1.0, 1.5, 2.2, 3.2, 4.6, 6.8, 10.0, 15.0]
RAY_MAX = 20.0
RAY_STEPS = 64
WALK_STEP = 0.15
BATCH = 64
OUTDIR = "atlas"
# ------------------------------------------------------------- decoders
class OnnxDecoder:
def __init__(self, path="splat_decoder.onnx"):
self.backend = None
# onnxruntime first: OpenCV 5's dnn importer can't parse this graph's
# ConstantOfShape (dynamic batch) node, which BATCH=64 dumps rely on.
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:
import cv2 as cv
self.net = None
for eng in (getattr(cv.dnn, "ENGINE_CLASSIC", None),
getattr(cv.dnn, "ENGINE_AUTO", None)):
if eng is None:
continue
try:
self.net = cv.dnn.readNetFromONNX(path, engine=eng); break
except TypeError:
break
except Exception:
continue
if self.net is None:
self.net = cv.dnn.readNetFromONNX(path)
self.backend = "opencv-dnn"
print(f"atlas decoder: opencv-dnn (onnxruntime unavailable: {e}); "
f"on OpenCV 5 run 'python -m pip install onnxruntime'")
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]
self.net.setInput(zs, "z_latent")
return self.net.forward("rendered_image").copy() # (N,3,H,W) in [0,1]
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)
# ------------------------------------------------------------- z generators
def gen_survey(rng):
"""Yield (strategy, param, z) forever, interleaving strategies."""
dirs = None
while True:
# prior shells
for r in RADII:
z = rng.standard_normal(LATENT) * r
yield ("prior", r, z)
# one ray step-set: a fresh direction, full outward walk
d = rng.standard_normal(LATENT); d /= np.linalg.norm(d)
for i in range(RAY_STEPS):
r = RAY_MAX * (i + 1) / RAY_STEPS
yield ("ray", r, d * r)
# a random-walk burst: 64 consecutive steps of one walk
if dirs is None or rng.random() < 0.02:
walk_z = rng.standard_normal(LATENT) * 0.3
dirs = True
for _ in range(64):
walk_z = walk_z + rng.standard_normal(LATENT) * WALK_STEP
yield ("walk", float(np.linalg.norm(walk_z)), walk_z.copy())
# slerp between two prior points
a = rng.standard_normal(LATENT) * 0.6
b = rng.standard_normal(LATENT) * 0.6
na, nb = a / np.linalg.norm(a), b / np.linalg.norm(b)
om = math.acos(float(np.clip(na @ nb, -1, 1)))
for t in np.linspace(0, 1, 16):
if om < 1e-4:
z = (1 - t) * a + t * b
else:
z = (math.sin((1 - t) * om) * a + math.sin(t * om) * b) / math.sin(om)
yield ("slerp", float(t), z)
# ------------------------------------------------------------- band stats
def band3(gray):
"""(low, mid, fine) from float gray [0,1], numpy-only box blurs."""
def box(im, r):
k = 2 * r + 1
c = np.cumsum(np.cumsum(np.pad(im, ((1, 0), (1, 0))), 0), 1)
o = (c[k:, k:] - c[:-k, k:] - c[k:, :-k] + c[:-k, :-k]) / (k * k)
return np.pad(o, r, mode='edge')
b1, b2 = box(gray, 6), box(gray, 2)
return (float(b1.mean()), float((b2 - b1).std()),
float((gray - box(gray, 1)).std()))
def to_thumbs(out):
"""(N,3,H,W) float -> (N,32,32,3) uint8 via area-ish mean pooling."""
n, c, h, w = out.shape
f = max(1, h // THUMB)
hh = (h // f) * f
t = out[:, :, :hh, :hh].reshape(n, c, h // f, f, h // f, f).mean((3, 5))
if t.shape[2] != THUMB: # final resize if not exact
import cv2 as cv
t = np.stack([cv.resize(np.transpose(x, (1, 2, 0)), (THUMB, THUMB))
for x in t])
else:
t = np.transpose(t, (0, 2, 3, 1))
return (t * 255).clip(0, 255).astype(np.uint8)
# ------------------------------------------------------------- dump
def sheet(imgs, cols=64):
rows = int(math.ceil(len(imgs) / cols))
g = np.zeros((rows * THUMB, cols * THUMB, 3), np.uint8)
for i, im in enumerate(imgs):
r, c = divmod(i, cols)
g[r*THUMB:(r+1)*THUMB, c*THUMB:(c+1)*THUMB] = im[..., ::-1] # BGR png
return g
def dump(gb, dec):
os.makedirs(OUTDIR, exist_ok=True)
per_img = THUMB * THUMB * 3 + LATENT * 2 # uint8 thumb + f16 z
n_total = int(gb * 1e9 / per_img)
done = sorted(glob.glob(f"{OUTDIR}/shard_*_img.npy"))
start_shard = len(done)
n_have = start_shard * SHARD_N
print(f"target {n_total} images ({gb} GB); have {n_have}; "
f"{SHARD_N} per shard")
rng = np.random.default_rng(RSEED + start_shard) # resumable-ish
gen = gen_survey(rng)
t0 = time.time()
shard = start_shard
while n_have < n_total:
imgs = np.zeros((SHARD_N, THUMB, THUMB, 3), np.uint8)
zs = np.zeros((SHARD_N, LATENT), np.float16)
meta = []
for b0 in range(0, SHARD_N, BATCH):
batch = [next(gen) for _ in range(min(BATCH, SHARD_N - b0))]
zb = np.stack([z for _, _, z in batch]).astype(np.float32)
out = dec(zb)
th = to_thumbs(out)
for j, (strat, par, z) in enumerate(batch):
i = b0 + j
imgs[i] = th[j]; zs[i] = z.astype(np.float16)
g = th[j].mean(2).astype(np.float32) / 255.0
lo, mi, fi = band3(g)
meta.append([strat, f"{par:.4f}", f"{lo:.4f}",
f"{mi:.4f}", f"{fi:.4f}"])
tag = f"{shard:04d}"
np.save(f"{OUTDIR}/shard_{tag}_img.npy", imgs)
np.save(f"{OUTDIR}/shard_{tag}_z.npy", zs)
with open(f"{OUTDIR}/shard_{tag}_meta.csv", "w", newline="") as f:
w = csv.writer(f); w.writerow(["strategy","param","low","mid","fine"])
w.writerows(meta)
import cv2 as cv
cv.imwrite(f"{OUTDIR}/sheet_{tag}.png", sheet(imgs))
n_have += SHARD_N; shard += 1
rate = n_have / max(1e-9, time.time() - t0)
print(f"shard {tag}: {n_have}/{n_total} ({rate:.0f} img/s, "
f"eta {(n_total-n_have)/max(rate,1e-9)/60:.1f} min)")
print("dump complete.")
# ------------------------------------------------------------- browse
def browse(dec):
import cv2 as cv
sheets = sorted(glob.glob(f"{OUTDIR}/sheet_*.png"))
if not sheets:
print("no atlas found — run --dump first"); return
idx = 0
win = "ATLAS (click tile = full res + z | n/p sheet | q quit)"
cv.namedWindow(win, cv.WINDOW_NORMAL)
state = {"click": None}
cv.setMouseCallback(win, lambda ev, x, y, fl, _:
state.update(click=(x, y)) if ev == cv.EVENT_LBUTTONDOWN else None)
while True:
tag = sheets[idx].split("sheet_")[1].split(".")[0]
sh = cv.imread(sheets[idx])
cv.imshow(win, sh)
k = cv.waitKey(30) & 0xFF
if k == ord('q'): break
elif k == ord('n'): idx = (idx + 1) % len(sheets)
elif k == ord('p'): idx = (idx - 1) % len(sheets)
if state["click"]:
x, y = state["click"]; state["click"] = None
cols = sh.shape[1] // THUMB
ti = (y // THUMB) * cols + (x // THUMB)
zs = np.load(f"{OUTDIR}/shard_{tag}_z.npy")
if ti < len(zs):
z = zs[ti].astype(np.float32)
out = dec(z[None])[0]
im = (np.transpose(out, (1, 2, 0)) * 255).clip(0, 255).astype(np.uint8)
im = cv.cvtColor(im, cv.COLOR_RGB2BGR)
cv.imshow("full res", cv.resize(im, (512, 512),
interpolation=cv.INTER_CUBIC))
np.save("picked_z.npy", z)
print(f"tile {tag}/{ti} |z|={np.linalg.norm(z):.2f} "
f"-> picked_z.npy (surf/probe can start here)")
cv.destroyAllWindows()
# ------------------------------------------------------------- analyze
def analyze():
rows = []
for f in sorted(glob.glob(f"{OUTDIR}/shard_*_meta.csv")):
with open(f) as fh:
rows += list(csv.DictReader(fh))
if not rows:
print("no metadata — run --dump first"); return
print(f"{len(rows)} images.")
# bin ray+prior by radius, report mean fine-band energy
bins = {}
for r in rows:
if r["strategy"] not in ("ray", "prior"): continue
key = (r["strategy"], round(float(r["param"]) * 2) / 2)
bins.setdefault(key, []).append(float(r["fine"]))
print(f"{'strategy':8} {'radius':>7} {'n':>6} {'fine-band':>10}")
curve = {}
for (s, rad), v in sorted(bins.items()):
print(f"{s:8} {rad:7.1f} {len(v):6d} {np.mean(v):10.4f}")
curve.setdefault(s, []).append((rad, np.mean(v)))
# draw departure curve png with cv2 (no matplotlib dependency)
import cv2 as cv
W, H = 640, 360
img = np.full((H, W, 3), 24, np.uint8)
allpts = [p for c in curve.values() for p in c]
mx_r = max(p[0] for p in allpts); mx_e = max(p[1] for p in allpts) + 1e-9
colors = {"prior": (80, 200, 80), "ray": (80, 160, 255)}
for s, pts in curve.items():
pts = sorted(pts)
pix = [(int(30 + r / mx_r * (W - 60)),
int(H - 30 - e / mx_e * (H - 60))) for r, e in pts]
for a, b in zip(pix, pix[1:]):
cv.line(img, a, b, colors.get(s, (200, 200, 200)), 2, cv.LINE_AA)
cv.putText(img, s, pix[-1], cv.FONT_HERSHEY_PLAIN, 1,
colors.get(s, (200, 200, 200)), 1)
cv.putText(img, "fine-band energy vs |z| (manifold departure curve)",
(30, 20), cv.FONT_HERSHEY_PLAIN, 1, (220, 220, 220), 1)
cv.imwrite(f"{OUTDIR}/departure_curve.png", img)
print(f"wrote {OUTDIR}/departure_curve.png")
# ------------------------------------------------------------- selftest
def selftest():
global OUTDIR, SHARD_N
ok = True
def check(name, cond, note=""):
nonlocal ok; ok &= bool(cond)
print(f" [{'PASS' if cond else 'FAIL'}] {name} {note}")
import tempfile
OUTDIR = tempfile.mkdtemp(); SHARD_N = 256
dec = MockDecoder()
# generator produces all four strategies with sane shapes
g = gen_survey(np.random.default_rng(0))
seen = {}
for _ in range(600):
s, p, z = next(g); seen[s] = seen.get(s, 0) + 1
if len(z) != LATENT: ok = False
check("survey covers strategies",
all(k in seen for k in ("prior", "ray", "walk", "slerp")), str(seen))
dump(gb=(256 * (THUMB*THUMB*3 + 256)) / 1e9, dec=dec) # exactly 1 shard
imgs = np.load(f"{OUTDIR}/shard_0000_img.npy")
zs = np.load(f"{OUTDIR}/shard_0000_z.npy")
check("shard shapes", imgs.shape == (256, 32, 32, 3)
and zs.shape == (256, LATENT), f"{imgs.shape} {zs.shape}")
# z roundtrip: stored z re-renders to (nearly) the stored thumbnail
out = dec(zs[:8].astype(np.float32))
th = to_thumbs(out)
err = np.abs(th.astype(int) - imgs[:8].astype(int)).mean()
check("z -> thumb roundtrip", err < 2.0, f"mean|d| {err:.3f} (f16 z)")
check("sheet exists", os.path.exists(f"{OUTDIR}/sheet_0000.png"))
# meta rows align
with open(f"{OUTDIR}/shard_0000_meta.csv") as f:
n = sum(1 for _ in f) - 1
check("meta rows", n == 256, str(n))
analyze()
check("departure curve", os.path.exists(f"{OUTDIR}/departure_curve.png"))
# budget math: 2 GB at 3.3 KB/img ~ 600k images
n = int(2e9 / (THUMB*THUMB*3 + LATENT*2))
check("2 GB budget ~ 600k imgs", 550_000 < n < 650_000, str(n))
print("selftest:", "ALL PASS" if ok else "FAILURES ABOVE")
return 0 if ok else 1
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--dump", action="store_true")
ap.add_argument("--browse", action="store_true")
ap.add_argument("--analyze", action="store_true")
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--gb", type=float, default=2.0)
a = ap.parse_args()
if a.selftest: sys.exit(selftest())
elif a.analyze: analyze()
elif a.browse: browse(OnnxDecoder())
elif a.dump: dump(a.gb, OnnxDecoder())
else: print(__doc__ or "use --dump / --browse / --analyze / --selftest")