Spaces:
Sleeping
Sleeping
File size: 18,883 Bytes
22a203c 0d1fbc6 22a203c | 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 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 | """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())
|