room-visualizer / verify_n1_sim.py
GitHub Actions
Deploy from GitHub commit 0b4a46d6249523704b10c03464426d0ab07f81cd
0d1fbc6
Raw
History Blame Contribute Delete
18.9 kB
"""N1 — horizontal band seams on periodic (geometric) tile assets.
Reproduces the canvas-engine texture path on a steep look-down floor
(rooms 4/5 of the June-10 test set) for three texture preparations:
raw : tile the source as-is (what "wrap" mode does)
committed : makeSeamless v2 masked-shift (current organic path)
snapped : proposed period-snap crop — detect the pattern's x/y period by
autocorrelation, crop to an integer number of periods, and let
it wrap exactly. Falls back to masked-shift when no strong
period exists (wood, stone), so organic sources are untouched.
Pass criteria:
1. checkered.jpeg classifies "organic" (it is — 2.5 x 1.65 periods).
2. Period-snap finds a period; the cropped tile re-classifies as "wrap".
3. Seam-spike score (max row-to-row jump / median) of the snapped render
is at or below the committed render's, and shows no spike rows.
4. rustic-wood.jpg (organic) finds NO period -> falls back unchanged.
"""
import os
import numpy as np
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
TILES = os.path.join(HERE, "..", "..", "frontend", "viz2d-demo", "src", "assets", "tiles")
OUT = os.path.join(HERE, "verify_out")
os.makedirs(OUT, exist_ok=True)
# ---------------------------------------------------------------- engine ports
def detect_wrap_mode(img):
"""Exact port of detectWrapMode (canvas-engine.ts)."""
h, w, _ = img.shape
if w < 4 or h < 4:
return "organic", np.inf, np.inf
def col_diff(xa, xb):
return float(np.mean(np.abs(img[:, xa].astype(np.float64) - img[:, xb].astype(np.float64))))
def row_diff(ya, yb):
return float(np.mean(np.abs(img[ya].astype(np.float64) - img[yb].astype(np.float64))))
mid_x, mid_y = w >> 1, h >> 1
internal = max((col_diff(mid_x, mid_x + 1) + row_diff(mid_y, mid_y + 1)) / 2, 1.5)
seam_x = col_diff(w - 1, 0) / internal
seam_y = row_diff(h - 1, 0) / internal
mode = "wrap" if (seam_x < 3 and seam_y < 3) else "organic"
return mode, seam_x, seam_y
def make_seamless(img):
"""Exact port of makeSeamless v2 (masked shift)."""
h, w, _ = img.shape
half_w, half_h = w >> 1, h >> 1
band_x = max(2, round(w * 0.12))
band_y = max(2, round(h * 0.12))
def edge(n, band):
i = np.arange(n, dtype=np.float64)
t = np.minimum(np.minimum(i, n - 1 - i) / band, 1.0)
return t * t * (3 - 2 * t)
m = np.outer(edge(h, band_y), edge(w, band_x))[..., None]
shifted = np.roll(img, (-half_h, -half_w), axis=(0, 1)).astype(np.float64)
return (img.astype(np.float64) * m + shifted * (1 - m)).astype(np.uint8)
def find_period(img, axis):
"""Proposed: dominant pattern period along an axis (x: axis=1, y: axis=0).
Mean-abs-diff between the image and itself shifted by each candidate lag;
a strongly periodic texture has a deep minimum at the period. Searches a
<=320px downsample, then refines at full resolution. Returns the full-res
period or None.
"""
raw = np.mean(img.astype(np.float64), axis=2)
h, w = raw.shape
# High-pass: subtract a 16px low-pass reconstruction so the photo's
# lighting gradient doesn't put a floor under the diff at true alignment.
lp = np.asarray(Image.fromarray(raw).resize((16, 16), Image.BILINEAR).resize((w, h), Image.BILINEAR))
gray = raw - lp
full = gray.shape[1] if axis == 1 else gray.shape[0]
scale = max(1, int(np.ceil(full / 320)))
ds = gray[::scale, ::scale]
n = ds.shape[1] if axis == 1 else ds.shape[0]
lags = np.arange(max(12, int(n * 0.18)), int(n * 0.80))
if len(lags) < 4:
return None
diffs = []
for lag in lags:
if axis == 1:
d = np.mean(np.abs(ds[:, lag:] - ds[:, :-lag]))
else:
d = np.mean(np.abs(ds[lag:] - ds[:-lag]))
diffs.append(d)
diffs = np.asarray(diffs)
med = np.median(diffs) + 1e-6
best = diffs.min()
# Generous gate: the post-crop wrap re-classification is the real safety
# net; this only needs to exclude clearly aperiodic textures (wood ~0.85+,
# stone ~0.96 vs checker ~0.46).
if best / med > 0.65:
return None
# fundamental period: smallest lag within 15% of the global minimum
idx = np.nonzero(diffs <= best * 1.15)[0][0]
coarse = int(lags[idx]) * scale
# refine at full resolution
lo = max(8, coarse - scale - 2)
hi = min(full - 1, coarse + scale + 2)
best_lag, best_d = None, np.inf
for lag in range(lo, hi + 1):
if axis == 1:
d = np.mean(np.abs(gray[:, lag:] - gray[:, :-lag]))
else:
d = np.mean(np.abs(gray[lag:] - gray[:-lag]))
if d < best_d:
best_d, best_lag = d, lag
return best_lag
def best_crop(img, k_periods, period, axis):
"""Micro-search +/-5px around k*period for the crop length L that tiles
best. The crop [0, L) wraps perfectly iff line L (the true continuation
in the source) matches line 0 — so that is the diff to minimize."""
n = img.shape[1] if axis == 1 else img.shape[0]
target = k_periods * period
win = max(4, min(16, period // 4)) # window beats flat-cell degeneracy
cands = []
for cand in range(max(8, target - 5), min(n - win, target + 5) + 1):
if axis == 1:
d = np.mean(np.abs(img[:, cand:cand + win].astype(np.float64)
- img[:, 0:win].astype(np.float64)))
else:
d = np.mean(np.abs(img[cand:cand + win].astype(np.float64)
- img[0:win].astype(np.float64)))
cands.append((cand, d))
if not cands:
return target
dmin = min(d for _, d in cands)
# among near-ties, prefer the length closest to exactly k periods
near = [c for c, d in cands if d <= dmin * 1.15 + 1e-6]
return min(near, key=lambda c: abs(c - target))
def seam_vs_period_pair(img, crop_len, k, period, axis):
"""Wrap-seam diff relative to the texture's own k-period-pair diff.
The wrap seam joins content k periods apart, so the fair yardstick is two
interior lines k periods apart in the ORIGINAL image: they match in
structure (grout geometry) but differ by per-tile surface noise and
accumulated photo-perspective drift — the achievable floor for this seam.
"""
a = img.astype(np.float64)
if axis == 0:
a = a.transpose(1, 0, 2) # treat rows as columns; jog becomes vertical
n = a.shape[1]
dist = min(k * period, n - 1)
a0 = max(0, (n - 1 - dist) // 2) # internal pair, centered, same distance
win = max(2, min(8, period // 8))
jog = max(2, round(period * 0.04)) # photo-perspective drift tolerance
def pair_diff(xa, xb):
# min over a small perpendicular jog: a slightly skewed lattice still
# tiles acceptably (the jog reads as installation tolerance, not a seam)
best = np.inf
pa = a[:, xa:xa + win]
for dy in range(-jog, jog + 1):
if dy >= 0:
d = np.mean(np.abs(pa[dy:] - a[: a.shape[0] - dy, xb:xb + win]))
else:
d = np.mean(np.abs(pa[:dy] - a[-dy:, xb:xb + win]))
best = min(best, d)
return best
seam = pair_diff(crop_len, 0)
# floor = median over several internal pairs at the same distance — a
# single pair can land in unrepresentatively flat or busy content
starts = range(0, n - dist - win, max(1, (n - dist - win) // 8 or 1))
floors = [pair_diff(s + dist, s) for s in starts] or [pair_diff(a0 + dist, a0)]
floor_ = float(np.median(floors))
return seam / max(floor_, 1e-6)
def period_snap(img):
"""Proposed prepare step: crop to integer periods if strongly periodic and
the seam is no worse than the texture's own period-pair noise floor;
otherwise fall back to masked shift."""
h, w, _ = img.shape
px = find_period(img, axis=1)
py = find_period(img, axis=0)
if px and py:
kx, ky = w // px, h // py
# the continuation line (k*period) must exist in the source to verify
# the wrap; an exact-multiple image keeps one period fewer
while kx > 1 and kx * px >= w:
kx -= 1
while ky > 1 and ky * py >= h:
ky -= 1
if kx * px < w and ky * py < h \
and kx >= 1 and ky >= 1 and kx * px >= 0.4 * w and ky * py >= 0.4 * h:
cw = best_crop(img, kx, px, axis=1)
ch = best_crop(img, ky, py, axis=0)
rx = seam_vs_period_pair(img, cw, kx, px, axis=1)
ry = seam_vs_period_pair(img, ch, ky, py, axis=0)
if rx < 1.5 and ry < 1.5:
return img[:ch, :cw], ("snap", px, py, rx, ry)
return make_seamless(img), ("fallback", px, py, None, None)
def flatten_luminance(img):
"""R2-3b — remove the source photo's baked lighting so the texture behaves
like albedo: divide by a heavily-blurred (toroidal) luminance field
normalized to the texture mean. The blur wraps, so the correction is
itself seamless; only the lighting gradient is equalized, grain/veining
survives. Mirror of flattenLuminance (canvas-engine.ts) — keep in
lockstep."""
h, w, _ = img.shape
lum = (
img[..., 0] * 0.299 + img[..., 1] * 0.587 + img[..., 2] * 0.114
).astype(np.float64)
def box_wrap(a, r, axis):
win = 2 * r + 1
out = np.zeros_like(a)
for d in range(-r, r + 1):
out += np.roll(a, -d, axis=axis)
return out / win
rx = max(2, w // 4)
ry = max(2, h // 4)
for _ in range(2):
lum = box_wrap(lum, rx, axis=1)
lum = box_wrap(lum, ry, axis=0)
gain = np.clip(lum.mean() / np.maximum(lum, 1.0), 0.6, 1.6)
out = np.clip(np.round(img.astype(np.float64) * gain[..., None]), 0, 255)
return out.astype(np.uint8)
def build_mips(img):
mips = [img.astype(np.float64)]
cur = img.astype(np.float64)
while cur.shape[0] > 1 or cur.shape[1] > 1:
h, w, _ = cur.shape
nh, nw = max(1, h >> 1), max(1, w >> 1)
y0 = np.minimum(2 * np.arange(nh), h - 1)
y1 = np.minimum(2 * np.arange(nh) + 1, h - 1)
x0 = np.minimum(2 * np.arange(nw), w - 1)
x1 = np.minimum(2 * np.arange(nw) + 1, w - 1)
cur = (cur[y0][:, x0] + cur[y0][:, x1] + cur[y1][:, x0] + cur[y1][:, x1]) / 4
mips.append(cur)
return mips
def sample_bilinear_wrap(level, x, y):
h, w, _ = level.shape
x0 = np.clip(np.floor(x), 0, w - 1).astype(np.int64)
y0 = np.clip(np.floor(y), 0, h - 1).astype(np.int64)
x1 = (x0 + 1) % w
y1 = (y0 + 1) % h
fx = (x - np.floor(x))[..., None]
fy = (y - np.floor(y))[..., None]
p00, p10 = level[y0, x0], level[y0, x1]
p01, p11 = level[y1, x0], level[y1, x1]
return (p00 * (1 - fx) * (1 - fy) + p10 * fx * (1 - fy)
+ p01 * (1 - fx) * fy + p11 * fx * fy)
def render_floor(tex, img_w=900, img_h=700, repeat_w=180.0):
"""Steep look-down floor like rooms 4/5: asymmetric trapezoid -> deep plane."""
th, tw, _ = tex.shape
repeat_h = repeat_w * (th / tw)
plane_w, plane_h = 900.0, 1600.0
# image trapezoid (slight asymmetry = synthetic-VP shear) -> plane rect
src = np.array([[260, 120], [610, 120], [900, 700], [0, 700]], np.float64)
dst = np.array([[0, 0], [plane_w, 0], [plane_w, plane_h], [0, plane_h]], np.float64)
A = []
for (sx, sy), (dx_, dy_) in zip(src, dst):
A.append([sx, sy, 1, 0, 0, 0, -dx_ * sx, -dx_ * sy])
A.append([0, 0, 0, sx, sy, 1, -dy_ * sx, -dy_ * sy])
b = dst.reshape(-1)
hvec = np.linalg.solve(np.asarray(A), b)
H = np.append(hvec, 1).reshape(3, 3)
xs, ys = np.meshgrid(np.arange(img_w, dtype=np.float64), np.arange(img_h, dtype=np.float64))
def to_plane(px, py):
zz = H[2, 0] * px + H[2, 1] * py + H[2, 2]
return ((H[0, 0] * px + H[0, 1] * py + H[0, 2]) / zz,
(H[1, 0] * px + H[1, 1] * py + H[1, 2]) / zz)
fx, fy = to_plane(xs, ys)
fx1, fy1 = to_plane(xs + 1, ys)
fx2, fy2 = to_plane(xs, ys + 1)
# floor mask: inside the plane rect
mask = (fx >= 0) & (fx < plane_w) & (fy >= 0) & (fy < plane_h)
u = np.mod(fx / repeat_w, 1.0)
v = np.mod(fy / repeat_h, 1.0)
tcx, tcy = (fx / repeat_w) * tw, (fy / repeat_h) * th
du = np.hypot((fx1 / repeat_w) * tw - tcx, (fy1 / repeat_h) * th - tcy)
dv = np.hypot((fx2 / repeat_w) * tw - tcx, (fy2 / repeat_h) * th - tcy)
footprint = np.maximum(np.maximum(du, dv), 1e-3)
lod = np.log2(footprint) + 0.5
mips = build_mips(tex)
max_l = len(mips) - 1
l0 = np.clip(np.floor(lod), 0, max_l).astype(np.int64)
f = np.clip(lod - l0, 0, 1)
out = np.zeros((img_h, img_w, 3), np.float64)
for lev in range(max_l + 1):
sel = mask & (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])
fb = f[sel][..., None]
if lev < max_l:
bl = mips[lev + 1]
sb = sample_bilinear_wrap(bl, u[sel] * bl.shape[1], v[sel] * bl.shape[0])
out[sel] = sa + (sb - sa) * fb
else:
out[sel] = sa
return out.astype(np.uint8), mask
def sharpness_profile(render, mask):
"""Per-row mean |horizontal gradient| inside the floor. Crossfade ghost
bands collapse local contrast, so they show up as dips in this profile.
Comparing per-row against the raw render cancels the natural LOD falloff."""
g = np.mean(render.astype(np.float64), axis=2)
grad = np.abs(np.diff(g, axis=1))
both = mask[:, :-1] & mask[:, 1:]
rows = np.nonzero(both.sum(axis=1) > 200)[0]
prof = np.array([np.mean(grad[y, both[y]]) for y in rows])
return rows, prof
# -------------------------------------------------------------------- run
def load(name):
return np.asarray(Image.open(os.path.join(TILES, name)).convert("RGB"))
def main():
ok = True
checker = load("checkered.jpeg")
mode, sx, sy = detect_wrap_mode(checker)
print(f"checkered.jpeg : {checker.shape[1]}x{checker.shape[0]} mode={mode} "
f"seamX={sx:.1f}x seamY={sy:.1f}x (threshold 3x)")
if mode != "organic":
print(" !! expected organic"); ok = False
snapped, info = period_snap(checker)
tag, px, py, csx, csy = info
print(f"period-snap : {tag} periodX={px} periodY={py} "
f"crop={snapped.shape[1]}x{snapped.shape[0]} "
f"crop-seamX={csx if csx is None else f'{csx:.1f}x'} "
f"crop-seamY={csy if csy is None else f'{csy:.1f}x'}")
if tag != "snap":
print(" !! period-snap did not engage on the checker"); ok = False
healed = make_seamless(checker)
# Real-photo renders: saved for visual / golden-image comparison (the
# photo's per-tile texture variance defeats simple numeric seam metrics).
base_repeat = 180.0
snap_repeat = base_repeat * (snapped.shape[1] / checker.shape[1])
for name, tex, rep in [("raw", checker, base_repeat),
("committed", healed, base_repeat),
("snapped", snapped, snap_repeat)]:
render, _ = render_floor(tex, repeat_w=rep)
Image.fromarray(render).save(os.path.join(OUT, f"n1_{name}.png"))
print(f"photo renders saved to verify_out/n1_*.png (visual check)")
# ---- synthetic certification: ground truth is known exactly ----------
# Perfect checker, period 64, sized to a NON-integer period count so it
# classifies organic. The ideal result is the exact 5-period crop tiled
# raw; the snapped pipeline must reproduce it pixel-for-pixel.
per = 64
sw, sh = per * 5 + 37, per * 4 + 21
yy, xx = np.mgrid[0:sh, 0:sw]
cells = ((xx // (per // 2)) + (yy // (per // 2))) % 2
rng = np.random.default_rng(7)
noise = rng.normal(0, 4, (sh, sw))
synth = np.stack([np.where(cells, 205, 120) + noise,
np.where(cells, 200, 90) + noise,
np.where(cells, 190, 60) + noise], axis=2).clip(0, 255).astype(np.uint8)
smode, ssx, ssy = detect_wrap_mode(synth)
print(f"synthetic checker: {sw}x{sh} period={per} mode={smode} "
f"seam=({ssx:.1f}x,{ssy:.1f}x)")
if smode != "organic":
print(" !! synthetic checker should classify organic"); ok = False
s_snap, s_info = period_snap(synth)
print(f"synthetic snap : {s_info[0]} periodX={s_info[1]} periodY={s_info[2]} "
f"crop={s_snap.shape[1]}x{s_snap.shape[0]}")
if s_info[0] != "snap":
print(" !! period-snap did not engage on synthetic checker"); ok = False
else:
if s_info[1] % per != 0 or s_info[2] % per != 0:
print(f" !! found period not a multiple of {per}"); ok = False
ideal = synth[:(sh // per) * per, :(sw // per) * per]
rep_snap = 180.0 * (s_snap.shape[1] / sw)
rep_ideal = 180.0 * (ideal.shape[1] / sw)
r_snap, m1 = render_floor(s_snap, repeat_w=rep_snap)
r_ideal, m2 = render_floor(ideal, repeat_w=rep_ideal)
r_healed, _ = render_floor(make_seamless(synth), repeat_w=180.0)
Image.fromarray(r_snap).save(os.path.join(OUT, "n1_synth_snapped.png"))
Image.fromarray(r_ideal).save(os.path.join(OUT, "n1_synth_ideal.png"))
Image.fromarray(r_healed).save(os.path.join(OUT, "n1_synth_committed.png"))
m = m1 & m2
d_snap = float(np.mean(np.abs(r_snap[m].astype(float) - r_ideal[m].astype(float))))
d_healed = float(np.mean(np.abs(r_healed[m].astype(float) - r_ideal[m].astype(float))))
print(f"synthetic render : snapped-vs-ideal={d_snap:.2f} "
f"committed-vs-ideal={d_healed:.2f} (mean abs px)")
if d_snap > 3.0:
print(" !! snapped render deviates from ideal"); ok = False
if d_healed < d_snap:
print(" !! masked-shift unexpectedly beats period-snap"); ok = False
# no-regression: organic wood must NOT engage period-snap
wood = load("rustic-wood.jpg")
_, winfo = period_snap(wood)
print(f"rustic-wood.jpg : period-snap -> {winfo[0]} "
f"(periodX={winfo[1]} periodY={winfo[2]})")
if winfo[0] != "fallback":
print(" !! wood should fall back to masked-shift"); ok = False
# informational: how do the other catalog tiles classify?
for name in ["floor-natural-stone.jpg", "basalt-outside-wal.jpg", "mosaic-tile.jpg"]:
t = load(name)
m, a, b = detect_wrap_mode(t)
_, i2 = period_snap(t)
print(f"{name:24s}: mode={m:7s} seam=({a:.1f}x,{b:.1f}x) snap={i2[0]} px={i2[1]} py={i2[2]}")
print("\n" + ("ALL N1 CHECKS PASSED" if ok else "N1 CHECKS FAILED"))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())