room-visualizer / golden_render.py
GitHub Actions
Deploy from GitHub commit 0b4a46d6249523704b10c03464426d0ab07f81cd
0d1fbc6
Raw
History Blame Contribute Delete
13.2 kB
"""R0-1 — deterministic golden render: bundle x tile -> PNG.
A faithful Python port of the CURRENT frontend composite (canvas-engine.ts):
texture prep (wrap detection -> period-snap -> masked-shift fallback), mip
pyramid + trilinear with per-pixel footprint LOD, shade-map decode, homography-
mapped light vector + gloss-gated specular, colour cast, soft highlight clip,
confidence-map alpha. Texture-prep and sampling primitives are imported from
verify_n1_sim so this stays in lockstep with the certified implementations.
Usage:
python golden_render.py <bundle.json[.gz]> <tile-image> <out.png>
The output is resized to max-dim 720 so goldens stay small and stable.
"""
import base64
import gzip
import json
import sys
import numpy as np
from PIL import Image
from verify_n1_sim import (
build_mips,
detect_wrap_mode,
flatten_luminance,
make_seamless,
period_snap,
sample_bilinear_wrap,
)
OUT_MAX_DIM = 720
def load_bundle(path):
if path.endswith(".gz"):
with gzip.open(path, "rt") as f:
return json.load(f)
return json.load(open(path))
def estimate_gloss(tex):
"""Port of estimateGloss (canvas-engine.ts): mean 4px luminance gradient."""
lum = tex[:, :, 0] * 0.299 + tex[:, :, 1] * 0.587 + tex[:, :, 2] * 0.114
a = lum[::4, 4::4]
b = lum[::4, :-4:4][:, : a.shape[1]]
mean_grad = float(np.mean(np.abs(a - b))) if a.size else 0.0
return float(np.clip(1 - mean_grad / 24, 0, 1))
def soft_clip(v):
"""Port of softClipByte: linear below 220, rational shoulder above."""
knee, rng = 220.0, 35.0
t = v - knee
return np.where(v <= knee, v, knee + (t * rng) / (t + rng))
def apply_shade(sample, m):
"""Port of applyShade (canvas-engine.ts) — N4 highlight-preserving shading.
Dimming (m <= 1) stays a physical multiply. Brightening near-white texels
by multiply clips at 255 and erases pale-tile detail (the white-tile
washout), so for m > 1 blend toward a screen-style lift by texel
brightness: dark texels keep the multiply, bright texels brighten by
closing their gap to white — grout/figuring contrast survives by
construction because the result stays below 255.
"""
mult = sample * m
screen = 255.0 - (255.0 - sample) / np.maximum(m, 1e-6)
t = sample / 255.0
w = t * t * (3.0 - 2.0 * t)
return np.where(m <= 1.0, mult, mult * (1.0 - w) + screen * w)
def prepare_texture(tile_path):
tex = np.asarray(Image.open(tile_path).convert("RGB"))
h, w, _ = tex.shape
mode, _, _ = detect_wrap_mode(tex)
repeat_scale = 1.0
lay_mode = "repeat"
if mode != "wrap":
prepared, info = period_snap(tex)
if info[0] == "snap":
repeat_scale = prepared.shape[1] / w
tex = prepared
else:
tex = make_seamless(tex)
# R2-3 v1 — truly organic materials (healed, not periodic) lay as
# procedural cells; periodic/authored tiles keep the plain repeat.
lay_mode = "cells"
# R2-3b — strip the photo's baked lighting (mirrors canvas-engine.ts):
# snapped patterns stop showing tonal seams at every repeat; healed
# organic textures stop reading as patchwork across cells.
tex = flatten_luminance(tex)
return tex, repeat_scale, lay_mode
_M32 = np.uint64(0xFFFFFFFF)
def hash01(a, b):
"""Port of hash01 (canvas-engine.ts) — bit-exact with Math.imul/>>> JS
semantics so both engines lay identical cells."""
a = (np.asarray(a, np.int64) & 0xFFFFFFFF).astype(np.uint64)
b = (np.asarray(b, np.int64) & 0xFFFFFFFF).astype(np.uint64)
h = ((a * np.uint64(374761393)) & _M32) ^ ((b * np.uint64(668265263)) & _M32)
h = ((h ^ (h >> np.uint64(13))) * np.uint64(1274126177)) & _M32
return ((h ^ (h >> np.uint64(16))) >> np.uint64(8)).astype(np.float64) / 16777216.0
def procedural_cells(rx, ry, repeat_w, repeat_h, grout_frac, tw, th, footprint, pattern="straight"):
"""Port of the R2-3 v1 cell logic (canvas-engine.ts composite):
per-cell toroidal grain offset, tone jitter, pattern-driven bond
(straight = random running bond, brick = fixed half offset, grid/diamond
= aligned), and an anti-aliased seam mask. Returns (u, v, tone, blend)."""
row = np.floor(ry / repeat_h).astype(np.int64)
if pattern in ("grid", "diamond"):
stagger = np.zeros(len(row), np.float64)
elif pattern == "brick":
stagger = (row & 1) * 0.5
else:
stagger = hash01(row, np.full_like(row, 0x9E37))
sx = rx + stagger * repeat_w
col = np.floor(sx / repeat_w).astype(np.int64)
lu = sx / repeat_w - col
lv = ry / repeat_h - row
# R2-3b — grid/diamond tiles are factory prints (real tiles repeat their
# print): content stays aligned per cell; organic grain keeps the
# per-cell toroidal window. Mirrors canvas-engine.ts.
if pattern in ("grid", "diamond"):
u = np.mod(lu, 1.0)
v = np.mod(lv, 1.0)
else:
u = np.mod(lu + hash01(col, row), 1.0)
v = np.mod(lv + hash01(col + 0x55, row - 0x21), 1.0)
tone = 0.94 + 0.12 * hash01(col - 0x13, row + 0x77)
half_u = grout_frac * 0.5
half_v = half_u * (repeat_w / repeat_h)
aa_u = np.maximum(footprint / tw, 1e-4)
aa_v = np.maximum(footprint / th, 1e-4)
du_b = np.minimum(lu, 1.0 - lu)
dv_b = np.minimum(lv, 1.0 - lv)
b_u = np.clip((du_b - half_u) / aa_u + 0.5, 0.0, 1.0)
b_v = np.clip((dv_b - half_v) / aa_v + 0.5, 0.0, 1.0)
return u, v, tone, 1.0 - np.minimum(b_u, b_v)
def apply_reflection(texel, base, xs, ys, h, w):
"""Port of the R4-2 gloss reflection (canvas-engine.ts composite):
mirror the above-floor scene about the per-column contact line, faded
with distance and weighted by reflected brightness squared."""
ys_f = ys.astype(np.float64)
top = np.full(w, np.inf)
np.minimum.at(top, xs, ys_f)
floor_flags = np.zeros((h, w), bool)
floor_flags[ys, xs] = True
ty = top[xs]
ry_refl = np.floor(2.0 * ty - ys_f).astype(np.int64)
valid = np.isfinite(ty) & (ry_refl >= 0) & (ry_refl < h)
ry_c = np.clip(ry_refl, 0, h - 1)
src_floor = floor_flags[ry_c, xs]
refl = base[ry_c, xs]
lum = (refl[:, 0] * 0.299 + refl[:, 1] * 0.587 + refl[:, 2] * 0.114) / 255.0
fade = np.maximum(0.0, 1.0 - (ys_f - ty) / (h * 0.35))
k = np.where(valid & ~src_floor, 0.22 * fade * lum * lum, 0.0)
k = np.where(k > 0.003, k, 0.0)
return texel * (1.0 - k[:, None]) + refl * k[:, None]
def render(bundle_path, tile_path, finish="matte"):
d = load_bundle(bundle_path)
w, h = d["width"], d["height"]
base = np.asarray(
Image.open(__import__("io").BytesIO(base64.b64decode(d["pixels"]))).convert("RGB")
).astype(np.float64)
seg = max(d["segments"], key=lambda s: len(s["mask"]))
mask_idx = np.frombuffer(base64.b64decode(seg["mask"]), dtype=np.uint32)
mask = np.zeros(w * h, bool)
mask[mask_idx] = True
mask = mask.reshape(h, w)
H = np.asarray(seg["homography"], np.float64).reshape(3, 3)
plane = seg.get("plane") or {}
plane_w = max(plane.get("width", w), 1)
plane_h = max(plane.get("height", h), 1)
plane_cx = plane.get("x", 0) + plane_w / 2
plane_cy = plane.get("y", 0) + plane_h / 2
rot_deg = plane.get("defaultRotation") or 0.0
rad = np.deg2rad(rot_deg)
cos, sin = np.cos(-rad), np.sin(-rad)
shade_map = (
np.frombuffer(base64.b64decode(seg["shadeMap"]), np.uint8).reshape(h, w).astype(np.float64)
if seg.get("shadeMap")
else None
)
shade_lo, shade_hi = seg.get("shadeRange") or (0.55, 1.35)
conf = (
np.frombuffer(base64.b64decode(seg["confidenceMap"]), np.uint8).reshape(h, w).astype(np.float64) / 255.0
if seg.get("confidenceMap")
else None
)
ct = seg.get("colorTemperature") or {}
if "cast" in ct:
ct = ct["cast"]
col = np.array([ct.get("r", 1.0), ct.get("g", 1.0), ct.get("b", 1.0)])
lv = seg.get("lightVector")
tex, repeat_scale, lay_mode = prepare_texture(tile_path)
# R4-2b — catalog finish overrides the texture-smoothness estimate (T9):
# matte gets no sheen however smooth the print; gloss keeps a baseline.
# Mirrors canvas-engine.ts.
gloss_est = estimate_gloss(np.asarray(Image.open(tile_path).convert("RGB")).astype(np.float64))
gloss = 0.0 if finish == "matte" else max(0.5, gloss_est) if finish == "gloss" else gloss_est
# R2-3 v1 — derived seam tone + width; mirrors canvas-engine.ts (same
# every-16th-pixel mean, same no-physical-metadata fraction).
grout_col = tex.reshape(-1, 3)[::16].astype(np.float64).mean(axis=0) * 0.45
grout_frac = 0.012
th, tw, _ = tex.shape
mips = build_mips(tex)
max_l = len(mips) - 1
# R1-3 — mirror of canvas-engine.ts: metric plane scale when present
# (pixel-ish or metre plane units alike — backend gates mpu hard),
# heuristic fallback otherwise (info.scale = 1 in goldens).
DEFAULT_TILE_M = 0.6
mpu = plane.get("metersPerUnit")
repeat_w = 0.0
if mpu and mpu > 0:
repeat_w = (DEFAULT_TILE_M / mpu) * repeat_scale
if not (np.isfinite(repeat_w) and repeat_w > 0):
repeat_w = 0.0
if not repeat_w:
repeat_w = max(48.0, min(plane_w, plane_h) * 0.22) * repeat_scale
repeat_h = repeat_w * (th / tw)
ys, xs = np.nonzero(mask)
xs_f, ys_f = xs.astype(np.float64), ys.astype(np.float64)
def to_plane(px, py):
z = H[2, 0] * px + H[2, 1] * py + H[2, 2]
z = np.where(np.abs(z) < 1e-6, 1e-6, z)
return (
(H[0, 0] * px + H[0, 1] * py + H[0, 2]) / z,
(H[1, 0] * px + H[1, 1] * py + H[1, 2]) / z,
)
fx, fy = to_plane(xs_f, ys_f)
fx1, fy1 = to_plane(xs_f + 1, ys_f)
fx2, fy2 = to_plane(xs_f, ys_f + 1)
def rot(ax, ay):
dx = ax - plane_cx
dy = ay - plane_cy
return dx * cos - dy * sin, dx * sin + dy * cos
rx, ry = rot(fx, fy)
rx1, ry1 = rot(fx1, fy1)
rx2, ry2 = rot(fx2, fy2)
tcx, tcy = (rx / repeat_w) * tw, (ry / repeat_h) * th
du = np.hypot((rx1 / repeat_w) * tw - tcx, (ry1 / repeat_h) * th - tcy)
dv = np.hypot((rx2 / repeat_w) * tw - tcx, (ry2 / repeat_h) * th - tcy)
footprint = np.maximum(np.maximum(du, dv), 1e-3)
lod = np.log2(footprint) + 0.5
if lay_mode == "cells":
u, v, cell_tone, grout_blend = procedural_cells(
rx, ry, repeat_w, repeat_h, grout_frac, tw, th, footprint
)
else:
u = np.mod(rx / repeat_w, 1.0)
v = np.mod(ry / repeat_h, 1.0)
l0 = np.clip(np.floor(lod), 0, max_l).astype(np.int64)
frac = np.clip(lod - l0, 0, 1)
sample = np.zeros((len(xs), 3), np.float64)
for lev in range(max_l + 1):
sel = l0 == lev
if not sel.any():
continue
a = mips[lev]
sa = sample_bilinear_wrap(a, u[sel] * a.shape[1], v[sel] * a.shape[0])
if lev < max_l:
b = mips[lev + 1]
sb = sample_bilinear_wrap(b, u[sel] * b.shape[1], v[sel] * b.shape[0])
sample[sel] = sa + (sb - sa) * frac[sel][:, None]
else:
sample[sel] = sa
if lay_mode == "cells":
sample = (
sample * cell_tone[:, None] * (1.0 - grout_blend[:, None])
+ grout_col[None, :] * grout_blend[:, None]
)
shade = (
shade_lo + (shade_map[ys, xs] / 255.0) * (shade_hi - shade_lo)
if shade_map is not None
else np.full(len(xs), 1.0)
)
specular = np.zeros(len(xs))
if lv:
lvx, lvy = lv.get("x", 0.0), lv.get("y", 0.0)
a = to_plane(np.array([w * 0.5]), np.array([h * 0.75]))
step = min(w, h) * 0.05
b = to_plane(np.array([w * 0.5 + lvx * step]), np.array([h * 0.75 + lvy * step]))
dxv, dyv = b[0][0] - a[0][0], b[1][0] - a[1][0]
ln = np.hypot(dxv, dyv)
if ln > 1e-6:
lvx, lvy = dxv / ln, dyv / ln
dfx = (fx - plane_cx) / (plane_w * 0.5)
dfy = (fy - plane_cy) / (plane_h * 0.5)
dlen = np.hypot(dfx, dfy)
ok = dlen > 0.01
dot = np.where(ok, (dfx * lvx + dfy * lvy) / np.maximum(dlen, 1e-9), 0.0)
specular = 0.12 * gloss * np.maximum(0, dot) ** 4
texel = soft_clip(apply_shade(sample, shade[:, None] * col[None, :]) + specular[:, None] * 255.0)
if finish == "gloss":
texel = apply_reflection(texel, base, xs, ys, h, w)
alpha = conf[ys, xs][:, None] if conf is not None else np.ones((len(xs), 1))
out = base.copy()
out[ys, xs] = np.clip(texel * alpha + base[ys, xs] * (1 - alpha), 0, 255)
img = Image.fromarray(out.astype(np.uint8))
scale = min(OUT_MAX_DIM / max(img.size), 1.0)
if scale < 1.0:
img = img.resize((round(img.width * scale), round(img.height * scale)), Image.BILINEAR)
return img
def main():
if len(sys.argv) != 4:
print(__doc__)
return 2
render(sys.argv[1], sys.argv[2]).save(sys.argv[3])
print(f"saved {sys.argv[3]}")
return 0
if __name__ == "__main__":
raise SystemExit(main())