Spaces:
Running on T4
Running on T4
GitHub Actions commited on
Commit ·
22a203c
1
Parent(s): 057afe6
Deploy from GitHub commit 1d59f596c1ba09e37dee8698f4e940a2a4bd3311
Browse files- app.py +109 -6
- verify_n1_sim.py +422 -0
- verify_n2_sim.py +76 -0
- verify_n3_sim.py +120 -0
app.py
CHANGED
|
@@ -332,6 +332,89 @@ def _encode_shade(relative: np.ndarray, lo: float, hi: float) -> np.ndarray:
|
|
| 332 |
return np.round((np.clip(relative, lo, hi) - lo) * (255.0 / span)).clip(0, 255).astype(np.uint8)
|
| 333 |
|
| 334 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
# ---------------------------------------------------------------------------
|
| 336 |
# B1 — Shadow Map Extraction
|
| 337 |
# Luminance-based shade map; returns (encoded_uint8, (lo, hi)) so the frontend
|
|
@@ -379,10 +462,17 @@ def build_shade_map(
|
|
| 379 |
# large lighting gradients survive untouched.
|
| 380 |
med_k = max(9, int(min(h, w) / 40)) | 1
|
| 381 |
filled = cv2.medianBlur(np.clip(filled, 0, 255).astype(np.uint8), med_k).astype(np.float32)
|
|
|
|
|
|
|
|
|
|
| 382 |
sigma = max(8.0, min(h, w) / 28.0)
|
| 383 |
smooth = cv2.GaussianBlur(filled, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
| 384 |
relative = smooth / median_lum
|
| 385 |
relative[mask == 0] = 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
lo, hi = _adaptive_shade_range(relative, mask)
|
| 387 |
return _encode_shade(relative, lo, hi), (lo, hi)
|
| 388 |
|
|
@@ -559,18 +649,31 @@ def fill_enclosed_islands(
|
|
| 559 |
"""
|
| 560 |
h, w = mask.shape[:2]
|
| 561 |
inv = (mask == 0).astype(np.uint8)
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
out = mask.copy()
|
| 564 |
for comp_id in range(1, count):
|
| 565 |
x, y, cw, ch, area = stats[comp_id]
|
| 566 |
if area > max_area:
|
| 567 |
continue
|
| 568 |
-
if x =
|
| 569 |
continue
|
| 570 |
-
comp = labels == comp_id
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
return out
|
| 575 |
|
| 576 |
|
|
|
|
| 332 |
return np.round((np.clip(relative, lo, hi) - lo) * (255.0 / span)).clip(0, 255).astype(np.uint8)
|
| 333 |
|
| 334 |
|
| 335 |
+
def _dominant_period(field: np.ndarray, axis: int) -> int:
|
| 336 |
+
"""N3 — dominant repeat pitch of a (high-frequency) field along an axis,
|
| 337 |
+
or 0 if it is not strongly periodic. Mean-abs self-difference over
|
| 338 |
+
candidate lags on a <=320px decimated copy; a repeating pattern has a deep
|
| 339 |
+
minimum at its pitch (measured ~0.4x median) while aperiodic shadow fields
|
| 340 |
+
stay near 1.0x."""
|
| 341 |
+
h, w = field.shape[:2]
|
| 342 |
+
full = w if axis == 1 else h
|
| 343 |
+
scale = max(1, int(np.ceil(full / 320)))
|
| 344 |
+
ds = field[::scale, ::scale]
|
| 345 |
+
n = ds.shape[1] if axis == 1 else ds.shape[0]
|
| 346 |
+
lag_lo = max(10, int(n * 0.05))
|
| 347 |
+
lag_hi = int(n * 0.5)
|
| 348 |
+
if lag_hi - lag_lo < 4:
|
| 349 |
+
return 0
|
| 350 |
+
diffs = []
|
| 351 |
+
for lag in range(lag_lo, lag_hi):
|
| 352 |
+
if axis == 1:
|
| 353 |
+
d = np.mean(np.abs(ds[:, lag:] - ds[:, :-lag]))
|
| 354 |
+
else:
|
| 355 |
+
d = np.mean(np.abs(ds[lag:] - ds[:-lag]))
|
| 356 |
+
diffs.append(d)
|
| 357 |
+
diffs_np = np.asarray(diffs)
|
| 358 |
+
best = float(diffs_np.min())
|
| 359 |
+
med = float(np.median(diffs_np)) + 1e-6
|
| 360 |
+
if best / med > 0.55:
|
| 361 |
+
return 0
|
| 362 |
+
idx = int(np.nonzero(diffs_np <= best * 1.15)[0][0])
|
| 363 |
+
# A smooth aperiodic field (a lone shadow blob) is most self-similar at
|
| 364 |
+
# the smallest lag — a monotone profile, not periodicity. Require a true
|
| 365 |
+
# interior dip: the profile must RISE (anti-phase) before the minimum.
|
| 366 |
+
if idx == 0 or float(np.max(diffs_np[:idx])) < best * 1.5:
|
| 367 |
+
return 0
|
| 368 |
+
return (lag_lo + idx) * scale
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _suppress_periodic_shading(field: np.ndarray) -> np.ndarray:
|
| 372 |
+
"""N3 — strip repeating tile/grout shading from the lighting field.
|
| 373 |
+
|
| 374 |
+
The fine median removes thin grout lines, but tile-scale brightness washes
|
| 375 |
+
(every tile of the old floor shaded the same way) are LARGER than contact
|
| 376 |
+
shadows, so no scale-based filter can separate them — they bled through as
|
| 377 |
+
ghost diagonal banding on the replacement floor. Periodicity separates
|
| 378 |
+
them: the wash repeats at the old floor's tile pitch, real shadows don't.
|
| 379 |
+
The periodic part of the high-frequency residual is estimated by averaging
|
| 380 |
+
period-shifted copies (coherent for the pattern, diluted ~1/N for
|
| 381 |
+
shadows) and subtracted.
|
| 382 |
+
"""
|
| 383 |
+
h, w = field.shape[:2]
|
| 384 |
+
sigma = max(8.0, min(h, w) / 14.0)
|
| 385 |
+
base = cv2.GaussianBlur(field, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
| 386 |
+
resid = field - base
|
| 387 |
+
for axis in (1, 0):
|
| 388 |
+
period = _dominant_period(resid, axis)
|
| 389 |
+
if not period:
|
| 390 |
+
continue
|
| 391 |
+
n = w if axis == 1 else h
|
| 392 |
+
shifts = [s for s in (-2 * period, -period, period, 2 * period) if abs(s) < int(n * 0.9)]
|
| 393 |
+
if len(shifts) < 2:
|
| 394 |
+
continue
|
| 395 |
+
acc = resid.copy()
|
| 396 |
+
cnt = np.ones_like(resid)
|
| 397 |
+
for s in shifts:
|
| 398 |
+
shifted = np.roll(resid, s, axis=axis)
|
| 399 |
+
valid = np.ones_like(resid)
|
| 400 |
+
# roll wraps content across the border; mask the wrapped strip out
|
| 401 |
+
if axis == 1:
|
| 402 |
+
if s > 0:
|
| 403 |
+
valid[:, :s] = 0
|
| 404 |
+
else:
|
| 405 |
+
valid[:, s:] = 0
|
| 406 |
+
else:
|
| 407 |
+
if s > 0:
|
| 408 |
+
valid[:s, :] = 0
|
| 409 |
+
else:
|
| 410 |
+
valid[s:, :] = 0
|
| 411 |
+
acc += shifted * valid
|
| 412 |
+
cnt += valid
|
| 413 |
+
periodic = acc / cnt
|
| 414 |
+
resid = resid - periodic
|
| 415 |
+
return base + resid
|
| 416 |
+
|
| 417 |
+
|
| 418 |
# ---------------------------------------------------------------------------
|
| 419 |
# B1 — Shadow Map Extraction
|
| 420 |
# Luminance-based shade map; returns (encoded_uint8, (lo, hi)) so the frontend
|
|
|
|
| 462 |
# large lighting gradients survive untouched.
|
| 463 |
med_k = max(9, int(min(h, w) / 40)) | 1
|
| 464 |
filled = cv2.medianBlur(np.clip(filled, 0, 255).astype(np.uint8), med_k).astype(np.float32)
|
| 465 |
+
# N3 — tile-pitch washes survive the median (they are larger than it);
|
| 466 |
+
# remove them by periodicity instead of scale.
|
| 467 |
+
filled = _suppress_periodic_shading(filled)
|
| 468 |
sigma = max(8.0, min(h, w) / 28.0)
|
| 469 |
smooth = cv2.GaussianBlur(filled, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
| 470 |
relative = smooth / median_lum
|
| 471 |
relative[mask == 0] = 1.0
|
| 472 |
+
# N3 — soften shadow depth slightly: reflection sheens and residual
|
| 473 |
+
# extraction noise read as stains on light replacement floors; a mild
|
| 474 |
+
# gamma keeps shadow placement while taking the "dirt" edge off.
|
| 475 |
+
relative = np.where(relative < 1.0, np.power(relative, 0.85), relative)
|
| 476 |
lo, hi = _adaptive_shade_range(relative, mask)
|
| 477 |
return _encode_shade(relative, lo, hi), (lo, hi)
|
| 478 |
|
|
|
|
| 649 |
"""
|
| 650 |
h, w = mask.shape[:2]
|
| 651 |
inv = (mask == 0).astype(np.uint8)
|
| 652 |
+
kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
| 653 |
+
# N2 — sever hairline bridges before labelling: segmentation noise often
|
| 654 |
+
# connects a small island to a large neighbouring hole (e.g. the blue
|
| 655 |
+
# accent tile to the doormat in the room-4 reference photo) through a
|
| 656 |
+
# 1-2px chain, making it inherit that hole's area and protection and
|
| 657 |
+
# escape the fill.
|
| 658 |
+
inv_open = cv2.morphologyEx(inv, cv2.MORPH_OPEN, kern)
|
| 659 |
+
count, labels, stats, _ = cv2.connectedComponentsWithStats(inv_open, connectivity=8)
|
| 660 |
out = mask.copy()
|
| 661 |
for comp_id in range(1, count):
|
| 662 |
x, y, cw, ch, area = stats[comp_id]
|
| 663 |
if area > max_area:
|
| 664 |
continue
|
| 665 |
+
if x <= 1 or y <= 1 or x + cw >= w - 1 or y + ch >= h - 1:
|
| 666 |
continue
|
| 667 |
+
comp = (labels == comp_id).astype(np.uint8)
|
| 668 |
+
# restore the 1px ring the opening ate, staying inside the real hole
|
| 669 |
+
comp_full = cv2.bitwise_and(cv2.dilate(comp, kern), inv).astype(bool)
|
| 670 |
+
if protect is not None and comp_full.any():
|
| 671 |
+
# N2 — veto only when a meaningful share of the island is a
|
| 672 |
+
# protected class; a couple of stray protected pixels from a noisy
|
| 673 |
+
# boundary must not rescue an accent tile from the fill.
|
| 674 |
+
if float(protect[comp_full].mean()) > 0.10:
|
| 675 |
+
continue
|
| 676 |
+
out[comp_full] = 1
|
| 677 |
return out
|
| 678 |
|
| 679 |
|
verify_n1_sim.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""N1 — horizontal band seams on periodic (geometric) tile assets.
|
| 2 |
+
|
| 3 |
+
Reproduces the canvas-engine texture path on a steep look-down floor
|
| 4 |
+
(rooms 4/5 of the June-10 test set) for three texture preparations:
|
| 5 |
+
|
| 6 |
+
raw : tile the source as-is (what "wrap" mode does)
|
| 7 |
+
committed : makeSeamless v2 masked-shift (current organic path)
|
| 8 |
+
snapped : proposed period-snap crop — detect the pattern's x/y period by
|
| 9 |
+
autocorrelation, crop to an integer number of periods, and let
|
| 10 |
+
it wrap exactly. Falls back to masked-shift when no strong
|
| 11 |
+
period exists (wood, stone), so organic sources are untouched.
|
| 12 |
+
|
| 13 |
+
Pass criteria:
|
| 14 |
+
1. checkered.jpeg classifies "organic" (it is — 2.5 x 1.65 periods).
|
| 15 |
+
2. Period-snap finds a period; the cropped tile re-classifies as "wrap".
|
| 16 |
+
3. Seam-spike score (max row-to-row jump / median) of the snapped render
|
| 17 |
+
is at or below the committed render's, and shows no spike rows.
|
| 18 |
+
4. rustic-wood.jpg (organic) finds NO period -> falls back unchanged.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import numpy as np
|
| 23 |
+
from PIL import Image
|
| 24 |
+
|
| 25 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 26 |
+
TILES = os.path.join(HERE, "..", "..", "frontend", "viz2d-demo", "src", "assets", "tiles")
|
| 27 |
+
OUT = os.path.join(HERE, "verify_out")
|
| 28 |
+
os.makedirs(OUT, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------- engine ports
|
| 32 |
+
def detect_wrap_mode(img):
|
| 33 |
+
"""Exact port of detectWrapMode (canvas-engine.ts)."""
|
| 34 |
+
h, w, _ = img.shape
|
| 35 |
+
if w < 4 or h < 4:
|
| 36 |
+
return "organic", np.inf, np.inf
|
| 37 |
+
|
| 38 |
+
def col_diff(xa, xb):
|
| 39 |
+
return float(np.mean(np.abs(img[:, xa].astype(np.float64) - img[:, xb].astype(np.float64))))
|
| 40 |
+
|
| 41 |
+
def row_diff(ya, yb):
|
| 42 |
+
return float(np.mean(np.abs(img[ya].astype(np.float64) - img[yb].astype(np.float64))))
|
| 43 |
+
|
| 44 |
+
mid_x, mid_y = w >> 1, h >> 1
|
| 45 |
+
internal = max((col_diff(mid_x, mid_x + 1) + row_diff(mid_y, mid_y + 1)) / 2, 1.5)
|
| 46 |
+
seam_x = col_diff(w - 1, 0) / internal
|
| 47 |
+
seam_y = row_diff(h - 1, 0) / internal
|
| 48 |
+
mode = "wrap" if (seam_x < 3 and seam_y < 3) else "organic"
|
| 49 |
+
return mode, seam_x, seam_y
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def make_seamless(img):
|
| 53 |
+
"""Exact port of makeSeamless v2 (masked shift)."""
|
| 54 |
+
h, w, _ = img.shape
|
| 55 |
+
half_w, half_h = w >> 1, h >> 1
|
| 56 |
+
band_x = max(2, round(w * 0.12))
|
| 57 |
+
band_y = max(2, round(h * 0.12))
|
| 58 |
+
|
| 59 |
+
def edge(n, band):
|
| 60 |
+
i = np.arange(n, dtype=np.float64)
|
| 61 |
+
t = np.minimum(np.minimum(i, n - 1 - i) / band, 1.0)
|
| 62 |
+
return t * t * (3 - 2 * t)
|
| 63 |
+
|
| 64 |
+
m = np.outer(edge(h, band_y), edge(w, band_x))[..., None]
|
| 65 |
+
shifted = np.roll(img, (-half_h, -half_w), axis=(0, 1)).astype(np.float64)
|
| 66 |
+
return (img.astype(np.float64) * m + shifted * (1 - m)).astype(np.uint8)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def find_period(img, axis):
|
| 70 |
+
"""Proposed: dominant pattern period along an axis (x: axis=1, y: axis=0).
|
| 71 |
+
|
| 72 |
+
Mean-abs-diff between the image and itself shifted by each candidate lag;
|
| 73 |
+
a strongly periodic texture has a deep minimum at the period. Searches a
|
| 74 |
+
<=320px downsample, then refines at full resolution. Returns the full-res
|
| 75 |
+
period or None.
|
| 76 |
+
"""
|
| 77 |
+
raw = np.mean(img.astype(np.float64), axis=2)
|
| 78 |
+
h, w = raw.shape
|
| 79 |
+
# High-pass: subtract a 16px low-pass reconstruction so the photo's
|
| 80 |
+
# lighting gradient doesn't put a floor under the diff at true alignment.
|
| 81 |
+
lp = np.asarray(Image.fromarray(raw).resize((16, 16), Image.BILINEAR).resize((w, h), Image.BILINEAR))
|
| 82 |
+
gray = raw - lp
|
| 83 |
+
full = gray.shape[1] if axis == 1 else gray.shape[0]
|
| 84 |
+
scale = max(1, int(np.ceil(full / 320)))
|
| 85 |
+
ds = gray[::scale, ::scale]
|
| 86 |
+
n = ds.shape[1] if axis == 1 else ds.shape[0]
|
| 87 |
+
|
| 88 |
+
lags = np.arange(max(12, int(n * 0.18)), int(n * 0.80))
|
| 89 |
+
if len(lags) < 4:
|
| 90 |
+
return None
|
| 91 |
+
diffs = []
|
| 92 |
+
for lag in lags:
|
| 93 |
+
if axis == 1:
|
| 94 |
+
d = np.mean(np.abs(ds[:, lag:] - ds[:, :-lag]))
|
| 95 |
+
else:
|
| 96 |
+
d = np.mean(np.abs(ds[lag:] - ds[:-lag]))
|
| 97 |
+
diffs.append(d)
|
| 98 |
+
diffs = np.asarray(diffs)
|
| 99 |
+
|
| 100 |
+
med = np.median(diffs) + 1e-6
|
| 101 |
+
best = diffs.min()
|
| 102 |
+
# Generous gate: the post-crop wrap re-classification is the real safety
|
| 103 |
+
# net; this only needs to exclude clearly aperiodic textures (wood ~0.85+,
|
| 104 |
+
# stone ~0.96 vs checker ~0.46).
|
| 105 |
+
if best / med > 0.65:
|
| 106 |
+
return None
|
| 107 |
+
# fundamental period: smallest lag within 15% of the global minimum
|
| 108 |
+
idx = np.nonzero(diffs <= best * 1.15)[0][0]
|
| 109 |
+
coarse = int(lags[idx]) * scale
|
| 110 |
+
|
| 111 |
+
# refine at full resolution
|
| 112 |
+
lo = max(8, coarse - scale - 2)
|
| 113 |
+
hi = min(full - 1, coarse + scale + 2)
|
| 114 |
+
best_lag, best_d = None, np.inf
|
| 115 |
+
for lag in range(lo, hi + 1):
|
| 116 |
+
if axis == 1:
|
| 117 |
+
d = np.mean(np.abs(gray[:, lag:] - gray[:, :-lag]))
|
| 118 |
+
else:
|
| 119 |
+
d = np.mean(np.abs(gray[lag:] - gray[:-lag]))
|
| 120 |
+
if d < best_d:
|
| 121 |
+
best_d, best_lag = d, lag
|
| 122 |
+
return best_lag
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def best_crop(img, k_periods, period, axis):
|
| 126 |
+
"""Micro-search +/-5px around k*period for the crop length L that tiles
|
| 127 |
+
best. The crop [0, L) wraps perfectly iff line L (the true continuation
|
| 128 |
+
in the source) matches line 0 — so that is the diff to minimize."""
|
| 129 |
+
n = img.shape[1] if axis == 1 else img.shape[0]
|
| 130 |
+
target = k_periods * period
|
| 131 |
+
win = max(4, min(16, period // 4)) # window beats flat-cell degeneracy
|
| 132 |
+
cands = []
|
| 133 |
+
for cand in range(max(8, target - 5), min(n - win, target + 5) + 1):
|
| 134 |
+
if axis == 1:
|
| 135 |
+
d = np.mean(np.abs(img[:, cand:cand + win].astype(np.float64)
|
| 136 |
+
- img[:, 0:win].astype(np.float64)))
|
| 137 |
+
else:
|
| 138 |
+
d = np.mean(np.abs(img[cand:cand + win].astype(np.float64)
|
| 139 |
+
- img[0:win].astype(np.float64)))
|
| 140 |
+
cands.append((cand, d))
|
| 141 |
+
if not cands:
|
| 142 |
+
return target
|
| 143 |
+
dmin = min(d for _, d in cands)
|
| 144 |
+
# among near-ties, prefer the length closest to exactly k periods
|
| 145 |
+
near = [c for c, d in cands if d <= dmin * 1.15 + 1e-6]
|
| 146 |
+
return min(near, key=lambda c: abs(c - target))
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def seam_vs_period_pair(img, crop_len, k, period, axis):
|
| 150 |
+
"""Wrap-seam diff relative to the texture's own k-period-pair diff.
|
| 151 |
+
|
| 152 |
+
The wrap seam joins content k periods apart, so the fair yardstick is two
|
| 153 |
+
interior lines k periods apart in the ORIGINAL image: they match in
|
| 154 |
+
structure (grout geometry) but differ by per-tile surface noise and
|
| 155 |
+
accumulated photo-perspective drift — the achievable floor for this seam.
|
| 156 |
+
"""
|
| 157 |
+
a = img.astype(np.float64)
|
| 158 |
+
if axis == 0:
|
| 159 |
+
a = a.transpose(1, 0, 2) # treat rows as columns; jog becomes vertical
|
| 160 |
+
n = a.shape[1]
|
| 161 |
+
dist = min(k * period, n - 1)
|
| 162 |
+
a0 = max(0, (n - 1 - dist) // 2) # internal pair, centered, same distance
|
| 163 |
+
win = max(2, min(8, period // 8))
|
| 164 |
+
jog = max(2, round(period * 0.04)) # photo-perspective drift tolerance
|
| 165 |
+
|
| 166 |
+
def pair_diff(xa, xb):
|
| 167 |
+
# min over a small perpendicular jog: a slightly skewed lattice still
|
| 168 |
+
# tiles acceptably (the jog reads as installation tolerance, not a seam)
|
| 169 |
+
best = np.inf
|
| 170 |
+
pa = a[:, xa:xa + win]
|
| 171 |
+
for dy in range(-jog, jog + 1):
|
| 172 |
+
if dy >= 0:
|
| 173 |
+
d = np.mean(np.abs(pa[dy:] - a[: a.shape[0] - dy, xb:xb + win]))
|
| 174 |
+
else:
|
| 175 |
+
d = np.mean(np.abs(pa[:dy] - a[-dy:, xb:xb + win]))
|
| 176 |
+
best = min(best, d)
|
| 177 |
+
return best
|
| 178 |
+
|
| 179 |
+
seam = pair_diff(crop_len, 0)
|
| 180 |
+
# floor = median over several internal pairs at the same distance — a
|
| 181 |
+
# single pair can land in unrepresentatively flat or busy content
|
| 182 |
+
starts = range(0, n - dist - win, max(1, (n - dist - win) // 8 or 1))
|
| 183 |
+
floors = [pair_diff(s + dist, s) for s in starts] or [pair_diff(a0 + dist, a0)]
|
| 184 |
+
floor_ = float(np.median(floors))
|
| 185 |
+
return seam / max(floor_, 1e-6)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def period_snap(img):
|
| 189 |
+
"""Proposed prepare step: crop to integer periods if strongly periodic and
|
| 190 |
+
the seam is no worse than the texture's own period-pair noise floor;
|
| 191 |
+
otherwise fall back to masked shift."""
|
| 192 |
+
h, w, _ = img.shape
|
| 193 |
+
px = find_period(img, axis=1)
|
| 194 |
+
py = find_period(img, axis=0)
|
| 195 |
+
if px and py:
|
| 196 |
+
kx, ky = w // px, h // py
|
| 197 |
+
# the continuation line (k*period) must exist in the source to verify
|
| 198 |
+
# the wrap; an exact-multiple image keeps one period fewer
|
| 199 |
+
while kx > 1 and kx * px >= w:
|
| 200 |
+
kx -= 1
|
| 201 |
+
while ky > 1 and ky * py >= h:
|
| 202 |
+
ky -= 1
|
| 203 |
+
if kx * px < w and ky * py < h \
|
| 204 |
+
and kx >= 1 and ky >= 1 and kx * px >= 0.4 * w and ky * py >= 0.4 * h:
|
| 205 |
+
cw = best_crop(img, kx, px, axis=1)
|
| 206 |
+
ch = best_crop(img, ky, py, axis=0)
|
| 207 |
+
rx = seam_vs_period_pair(img, cw, kx, px, axis=1)
|
| 208 |
+
ry = seam_vs_period_pair(img, ch, ky, py, axis=0)
|
| 209 |
+
if rx < 1.5 and ry < 1.5:
|
| 210 |
+
return img[:ch, :cw], ("snap", px, py, rx, ry)
|
| 211 |
+
return make_seamless(img), ("fallback", px, py, None, None)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def build_mips(img):
|
| 215 |
+
mips = [img.astype(np.float64)]
|
| 216 |
+
cur = img.astype(np.float64)
|
| 217 |
+
while cur.shape[0] > 1 or cur.shape[1] > 1:
|
| 218 |
+
h, w, _ = cur.shape
|
| 219 |
+
nh, nw = max(1, h >> 1), max(1, w >> 1)
|
| 220 |
+
y0 = np.minimum(2 * np.arange(nh), h - 1)
|
| 221 |
+
y1 = np.minimum(2 * np.arange(nh) + 1, h - 1)
|
| 222 |
+
x0 = np.minimum(2 * np.arange(nw), w - 1)
|
| 223 |
+
x1 = np.minimum(2 * np.arange(nw) + 1, w - 1)
|
| 224 |
+
cur = (cur[y0][:, x0] + cur[y0][:, x1] + cur[y1][:, x0] + cur[y1][:, x1]) / 4
|
| 225 |
+
mips.append(cur)
|
| 226 |
+
return mips
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def sample_bilinear_wrap(level, x, y):
|
| 230 |
+
h, w, _ = level.shape
|
| 231 |
+
x0 = np.clip(np.floor(x), 0, w - 1).astype(np.int64)
|
| 232 |
+
y0 = np.clip(np.floor(y), 0, h - 1).astype(np.int64)
|
| 233 |
+
x1 = (x0 + 1) % w
|
| 234 |
+
y1 = (y0 + 1) % h
|
| 235 |
+
fx = (x - np.floor(x))[..., None]
|
| 236 |
+
fy = (y - np.floor(y))[..., None]
|
| 237 |
+
p00, p10 = level[y0, x0], level[y0, x1]
|
| 238 |
+
p01, p11 = level[y1, x0], level[y1, x1]
|
| 239 |
+
return (p00 * (1 - fx) * (1 - fy) + p10 * fx * (1 - fy)
|
| 240 |
+
+ p01 * (1 - fx) * fy + p11 * fx * fy)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def render_floor(tex, img_w=900, img_h=700, repeat_w=180.0):
|
| 244 |
+
"""Steep look-down floor like rooms 4/5: asymmetric trapezoid -> deep plane."""
|
| 245 |
+
th, tw, _ = tex.shape
|
| 246 |
+
repeat_h = repeat_w * (th / tw)
|
| 247 |
+
plane_w, plane_h = 900.0, 1600.0
|
| 248 |
+
|
| 249 |
+
# image trapezoid (slight asymmetry = synthetic-VP shear) -> plane rect
|
| 250 |
+
src = np.array([[260, 120], [610, 120], [900, 700], [0, 700]], np.float64)
|
| 251 |
+
dst = np.array([[0, 0], [plane_w, 0], [plane_w, plane_h], [0, plane_h]], np.float64)
|
| 252 |
+
A = []
|
| 253 |
+
for (sx, sy), (dx_, dy_) in zip(src, dst):
|
| 254 |
+
A.append([sx, sy, 1, 0, 0, 0, -dx_ * sx, -dx_ * sy])
|
| 255 |
+
A.append([0, 0, 0, sx, sy, 1, -dy_ * sx, -dy_ * sy])
|
| 256 |
+
b = dst.reshape(-1)
|
| 257 |
+
hvec = np.linalg.solve(np.asarray(A), b)
|
| 258 |
+
H = np.append(hvec, 1).reshape(3, 3)
|
| 259 |
+
|
| 260 |
+
xs, ys = np.meshgrid(np.arange(img_w, dtype=np.float64), np.arange(img_h, dtype=np.float64))
|
| 261 |
+
|
| 262 |
+
def to_plane(px, py):
|
| 263 |
+
zz = H[2, 0] * px + H[2, 1] * py + H[2, 2]
|
| 264 |
+
return ((H[0, 0] * px + H[0, 1] * py + H[0, 2]) / zz,
|
| 265 |
+
(H[1, 0] * px + H[1, 1] * py + H[1, 2]) / zz)
|
| 266 |
+
|
| 267 |
+
fx, fy = to_plane(xs, ys)
|
| 268 |
+
fx1, fy1 = to_plane(xs + 1, ys)
|
| 269 |
+
fx2, fy2 = to_plane(xs, ys + 1)
|
| 270 |
+
|
| 271 |
+
# floor mask: inside the plane rect
|
| 272 |
+
mask = (fx >= 0) & (fx < plane_w) & (fy >= 0) & (fy < plane_h)
|
| 273 |
+
|
| 274 |
+
u = np.mod(fx / repeat_w, 1.0)
|
| 275 |
+
v = np.mod(fy / repeat_h, 1.0)
|
| 276 |
+
tcx, tcy = (fx / repeat_w) * tw, (fy / repeat_h) * th
|
| 277 |
+
du = np.hypot((fx1 / repeat_w) * tw - tcx, (fy1 / repeat_h) * th - tcy)
|
| 278 |
+
dv = np.hypot((fx2 / repeat_w) * tw - tcx, (fy2 / repeat_h) * th - tcy)
|
| 279 |
+
footprint = np.maximum(np.maximum(du, dv), 1e-3)
|
| 280 |
+
lod = np.log2(footprint) + 0.5
|
| 281 |
+
|
| 282 |
+
mips = build_mips(tex)
|
| 283 |
+
max_l = len(mips) - 1
|
| 284 |
+
l0 = np.clip(np.floor(lod), 0, max_l).astype(np.int64)
|
| 285 |
+
f = np.clip(lod - l0, 0, 1)
|
| 286 |
+
|
| 287 |
+
out = np.zeros((img_h, img_w, 3), np.float64)
|
| 288 |
+
for lev in range(max_l + 1):
|
| 289 |
+
sel = mask & (l0 == lev)
|
| 290 |
+
if not sel.any():
|
| 291 |
+
continue
|
| 292 |
+
a = mips[lev]
|
| 293 |
+
sa = sample_bilinear_wrap(a, u[sel] * a.shape[1], v[sel] * a.shape[0])
|
| 294 |
+
fb = f[sel][..., None]
|
| 295 |
+
if lev < max_l:
|
| 296 |
+
bl = mips[lev + 1]
|
| 297 |
+
sb = sample_bilinear_wrap(bl, u[sel] * bl.shape[1], v[sel] * bl.shape[0])
|
| 298 |
+
out[sel] = sa + (sb - sa) * fb
|
| 299 |
+
else:
|
| 300 |
+
out[sel] = sa
|
| 301 |
+
return out.astype(np.uint8), mask
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def sharpness_profile(render, mask):
|
| 305 |
+
"""Per-row mean |horizontal gradient| inside the floor. Crossfade ghost
|
| 306 |
+
bands collapse local contrast, so they show up as dips in this profile.
|
| 307 |
+
Comparing per-row against the raw render cancels the natural LOD falloff."""
|
| 308 |
+
g = np.mean(render.astype(np.float64), axis=2)
|
| 309 |
+
grad = np.abs(np.diff(g, axis=1))
|
| 310 |
+
both = mask[:, :-1] & mask[:, 1:]
|
| 311 |
+
rows = np.nonzero(both.sum(axis=1) > 200)[0]
|
| 312 |
+
prof = np.array([np.mean(grad[y, both[y]]) for y in rows])
|
| 313 |
+
return rows, prof
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# -------------------------------------------------------------------- run
|
| 317 |
+
def load(name):
|
| 318 |
+
return np.asarray(Image.open(os.path.join(TILES, name)).convert("RGB"))
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def main():
|
| 322 |
+
ok = True
|
| 323 |
+
|
| 324 |
+
checker = load("checkered.jpeg")
|
| 325 |
+
mode, sx, sy = detect_wrap_mode(checker)
|
| 326 |
+
print(f"checkered.jpeg : {checker.shape[1]}x{checker.shape[0]} mode={mode} "
|
| 327 |
+
f"seamX={sx:.1f}x seamY={sy:.1f}x (threshold 3x)")
|
| 328 |
+
if mode != "organic":
|
| 329 |
+
print(" !! expected organic"); ok = False
|
| 330 |
+
|
| 331 |
+
snapped, info = period_snap(checker)
|
| 332 |
+
tag, px, py, csx, csy = info
|
| 333 |
+
print(f"period-snap : {tag} periodX={px} periodY={py} "
|
| 334 |
+
f"crop={snapped.shape[1]}x{snapped.shape[0]} "
|
| 335 |
+
f"crop-seamX={csx if csx is None else f'{csx:.1f}x'} "
|
| 336 |
+
f"crop-seamY={csy if csy is None else f'{csy:.1f}x'}")
|
| 337 |
+
if tag != "snap":
|
| 338 |
+
print(" !! period-snap did not engage on the checker"); ok = False
|
| 339 |
+
|
| 340 |
+
healed = make_seamless(checker)
|
| 341 |
+
|
| 342 |
+
# Real-photo renders: saved for visual / golden-image comparison (the
|
| 343 |
+
# photo's per-tile texture variance defeats simple numeric seam metrics).
|
| 344 |
+
base_repeat = 180.0
|
| 345 |
+
snap_repeat = base_repeat * (snapped.shape[1] / checker.shape[1])
|
| 346 |
+
for name, tex, rep in [("raw", checker, base_repeat),
|
| 347 |
+
("committed", healed, base_repeat),
|
| 348 |
+
("snapped", snapped, snap_repeat)]:
|
| 349 |
+
render, _ = render_floor(tex, repeat_w=rep)
|
| 350 |
+
Image.fromarray(render).save(os.path.join(OUT, f"n1_{name}.png"))
|
| 351 |
+
print(f"photo renders saved to verify_out/n1_*.png (visual check)")
|
| 352 |
+
|
| 353 |
+
# ---- synthetic certification: ground truth is known exactly ----------
|
| 354 |
+
# Perfect checker, period 64, sized to a NON-integer period count so it
|
| 355 |
+
# classifies organic. The ideal result is the exact 5-period crop tiled
|
| 356 |
+
# raw; the snapped pipeline must reproduce it pixel-for-pixel.
|
| 357 |
+
per = 64
|
| 358 |
+
sw, sh = per * 5 + 37, per * 4 + 21
|
| 359 |
+
yy, xx = np.mgrid[0:sh, 0:sw]
|
| 360 |
+
cells = ((xx // (per // 2)) + (yy // (per // 2))) % 2
|
| 361 |
+
rng = np.random.default_rng(7)
|
| 362 |
+
noise = rng.normal(0, 4, (sh, sw))
|
| 363 |
+
synth = np.stack([np.where(cells, 205, 120) + noise,
|
| 364 |
+
np.where(cells, 200, 90) + noise,
|
| 365 |
+
np.where(cells, 190, 60) + noise], axis=2).clip(0, 255).astype(np.uint8)
|
| 366 |
+
|
| 367 |
+
smode, ssx, ssy = detect_wrap_mode(synth)
|
| 368 |
+
print(f"synthetic checker: {sw}x{sh} period={per} mode={smode} "
|
| 369 |
+
f"seam=({ssx:.1f}x,{ssy:.1f}x)")
|
| 370 |
+
if smode != "organic":
|
| 371 |
+
print(" !! synthetic checker should classify organic"); ok = False
|
| 372 |
+
|
| 373 |
+
s_snap, s_info = period_snap(synth)
|
| 374 |
+
print(f"synthetic snap : {s_info[0]} periodX={s_info[1]} periodY={s_info[2]} "
|
| 375 |
+
f"crop={s_snap.shape[1]}x{s_snap.shape[0]}")
|
| 376 |
+
if s_info[0] != "snap":
|
| 377 |
+
print(" !! period-snap did not engage on synthetic checker"); ok = False
|
| 378 |
+
else:
|
| 379 |
+
if s_info[1] % per != 0 or s_info[2] % per != 0:
|
| 380 |
+
print(f" !! found period not a multiple of {per}"); ok = False
|
| 381 |
+
|
| 382 |
+
ideal = synth[:(sh // per) * per, :(sw // per) * per]
|
| 383 |
+
rep_snap = 180.0 * (s_snap.shape[1] / sw)
|
| 384 |
+
rep_ideal = 180.0 * (ideal.shape[1] / sw)
|
| 385 |
+
r_snap, m1 = render_floor(s_snap, repeat_w=rep_snap)
|
| 386 |
+
r_ideal, m2 = render_floor(ideal, repeat_w=rep_ideal)
|
| 387 |
+
r_healed, _ = render_floor(make_seamless(synth), repeat_w=180.0)
|
| 388 |
+
Image.fromarray(r_snap).save(os.path.join(OUT, "n1_synth_snapped.png"))
|
| 389 |
+
Image.fromarray(r_ideal).save(os.path.join(OUT, "n1_synth_ideal.png"))
|
| 390 |
+
Image.fromarray(r_healed).save(os.path.join(OUT, "n1_synth_committed.png"))
|
| 391 |
+
|
| 392 |
+
m = m1 & m2
|
| 393 |
+
d_snap = float(np.mean(np.abs(r_snap[m].astype(float) - r_ideal[m].astype(float))))
|
| 394 |
+
d_healed = float(np.mean(np.abs(r_healed[m].astype(float) - r_ideal[m].astype(float))))
|
| 395 |
+
print(f"synthetic render : snapped-vs-ideal={d_snap:.2f} "
|
| 396 |
+
f"committed-vs-ideal={d_healed:.2f} (mean abs px)")
|
| 397 |
+
if d_snap > 3.0:
|
| 398 |
+
print(" !! snapped render deviates from ideal"); ok = False
|
| 399 |
+
if d_healed < d_snap:
|
| 400 |
+
print(" !! masked-shift unexpectedly beats period-snap"); ok = False
|
| 401 |
+
|
| 402 |
+
# no-regression: organic wood must NOT engage period-snap
|
| 403 |
+
wood = load("rustic-wood.jpg")
|
| 404 |
+
_, winfo = period_snap(wood)
|
| 405 |
+
print(f"rustic-wood.jpg : period-snap -> {winfo[0]} "
|
| 406 |
+
f"(periodX={winfo[1]} periodY={winfo[2]})")
|
| 407 |
+
if winfo[0] != "fallback":
|
| 408 |
+
print(" !! wood should fall back to masked-shift"); ok = False
|
| 409 |
+
|
| 410 |
+
# informational: how do the other catalog tiles classify?
|
| 411 |
+
for name in ["floor-natural-stone.jpg", "basalt-outside-wal.jpg", "mosaic-tile.jpg"]:
|
| 412 |
+
t = load(name)
|
| 413 |
+
m, a, b = detect_wrap_mode(t)
|
| 414 |
+
_, i2 = period_snap(t)
|
| 415 |
+
print(f"{name:24s}: mode={m:7s} seam=({a:.1f}x,{b:.1f}x) snap={i2[0]} px={i2[1]} py={i2[2]}")
|
| 416 |
+
|
| 417 |
+
print("\n" + ("ALL N1 CHECKS PASSED" if ok else "N1 CHECKS FAILED"))
|
| 418 |
+
return 0 if ok else 1
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
if __name__ == "__main__":
|
| 422 |
+
raise SystemExit(main())
|
verify_n2_sim.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""N2 — fill_enclosed_islands must absorb accent-tile islands even when
|
| 2 |
+
segmentation noise bridges them to a large protected hole or sprinkles a few
|
| 3 |
+
protected pixels over them (the room-4 blue accent tile escaped both ways).
|
| 4 |
+
|
| 5 |
+
Cases:
|
| 6 |
+
1. plain enclosed island -> filled (regression)
|
| 7 |
+
2. island bridged (1px chain) to big mat hole -> filled (N2 fix)
|
| 8 |
+
3. island 30% covered by protect (chair leg) -> kept (guard)
|
| 9 |
+
4. island with 2 stray protected pixels (~1%) -> filled (N2 fix)
|
| 10 |
+
5. hole touching the image border -> kept (guard)
|
| 11 |
+
6. large hole (over max_area) -> kept (guard)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
# --- extract the real implementation from app.py ---------------------------
|
| 18 |
+
src = open("app.py").read()
|
| 19 |
+
ns = {"np": np, "cv2": cv2}
|
| 20 |
+
start = src.index("def fill_enclosed_islands")
|
| 21 |
+
end = src.index("\ndef ", start + 10)
|
| 22 |
+
exec(compile(src[start:end], "app.py", "exec"), ns)
|
| 23 |
+
fill_enclosed_islands = ns["fill_enclosed_islands"]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
h, w = 400, 600
|
| 28 |
+
mask = np.ones((h, w), np.uint8)
|
| 29 |
+
protect = np.zeros((h, w), np.uint8)
|
| 30 |
+
|
| 31 |
+
# 1. plain island, 20x20
|
| 32 |
+
mask[50:70, 50:70] = 0
|
| 33 |
+
|
| 34 |
+
# 2. island 16x16 + 1px bridge to a big protected mat hole (90x40)
|
| 35 |
+
mask[200:240, 150:240] = 0 # mat hole
|
| 36 |
+
protect[200:240, 150:240] = 1 # mat is occluder-protected
|
| 37 |
+
mask[210:226, 260:276] = 0 # accent island nearby
|
| 38 |
+
mask[217, 240:260] = 0 # 1px bridge connecting them
|
| 39 |
+
|
| 40 |
+
# 3. island 20x20 with 30% protect coverage (a real object remnant)
|
| 41 |
+
mask[300:320, 60:80] = 0
|
| 42 |
+
protect[300:320, 60:66] = 1 # 6/20 columns = 30%
|
| 43 |
+
|
| 44 |
+
# 4. island 20x20 with 2 stray protected pixels
|
| 45 |
+
mask[80:100, 400:420] = 0
|
| 46 |
+
protect[85, 405] = 1
|
| 47 |
+
protect[92, 412] = 1
|
| 48 |
+
|
| 49 |
+
# 5. border-touching hole
|
| 50 |
+
mask[0:30, 500:540] = 0
|
| 51 |
+
|
| 52 |
+
# 6. big hole, over max_area
|
| 53 |
+
mask[280:360, 380:560] = 0 # 80x180 = 14400
|
| 54 |
+
|
| 55 |
+
out = fill_enclosed_islands(mask, protect, max_area=2000)
|
| 56 |
+
|
| 57 |
+
checks = [
|
| 58 |
+
("1 plain island filled", bool((out[52:68, 52:68] == 1).all())),
|
| 59 |
+
("2 bridged island filled", bool((out[212:224, 262:274] == 1).all())),
|
| 60 |
+
("2 mat hole kept", bool((out[205:235, 155:235] == 0).all())),
|
| 61 |
+
("3 protected island kept", bool((out[302:318, 62:78] == 0).all())),
|
| 62 |
+
("4 stray-pixel island filled", bool((out[82:98, 402:418] == 1).all())),
|
| 63 |
+
("5 border hole kept", bool((out[2:28, 502:538] == 0).all())),
|
| 64 |
+
("6 big hole kept", bool((out[285:355, 385:555] == 0).all())),
|
| 65 |
+
]
|
| 66 |
+
ok = True
|
| 67 |
+
for name, passed in checks:
|
| 68 |
+
print(f" [{'PASS' if passed else 'FAIL'}] {name}")
|
| 69 |
+
ok &= passed
|
| 70 |
+
|
| 71 |
+
print("\n" + ("ALL N2 CHECKS PASSED" if ok else "N2 CHECKS FAILED"))
|
| 72 |
+
return 0 if ok else 1
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
raise SystemExit(main())
|
verify_n3_sim.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""N3 — shade map must drop the old floor's repeating tile wash (ghost
|
| 2 |
+
diagonal banding, room 4) while keeping real lighting: gradients and contact
|
| 3 |
+
shadows. Runs the REAL build_shade_map from app.py on a synthetic floor whose
|
| 4 |
+
components are known exactly, and compares against the pre-N3 pipeline.
|
| 5 |
+
|
| 6 |
+
Floor luminance = gradient * shadow * periodic tile wash * grout lines.
|
| 7 |
+
- wash leakage : correlation of the decoded shade with the wash component
|
| 8 |
+
-> must drop >= 60% vs the pre-N3 pipeline
|
| 9 |
+
- shadow keep : correlation with the shadow component
|
| 10 |
+
-> must stay >= 75% of the pre-N3 pipeline's
|
| 11 |
+
- gradient keep : decoded left/right brightness ratio within 15% of truth
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
# --- extract the real implementations from app.py ---------------------------
|
| 18 |
+
src = open("app.py").read()
|
| 19 |
+
ns = {"np": np, "cv2": cv2}
|
| 20 |
+
for fn in ["_adaptive_shade_range", "_encode_shade", "_dominant_period",
|
| 21 |
+
"_suppress_periodic_shading", "build_shade_map"]:
|
| 22 |
+
start = src.index(f"def {fn}")
|
| 23 |
+
end = src.index("\ndef ", start + 10)
|
| 24 |
+
# build_shade_map is followed by another def inside the same block scan
|
| 25 |
+
exec(compile(src[start:end], "app.py", "exec"), ns)
|
| 26 |
+
|
| 27 |
+
build_shade_map = ns["build_shade_map"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def build_shade_map_pre_n3(img_np, surface_mask):
|
| 31 |
+
"""The pre-N3 pipeline (median + Gaussian only), for the baseline."""
|
| 32 |
+
mask = surface_mask.astype(np.uint8)
|
| 33 |
+
luminance = (img_np[:, :, 0].astype(np.float32) * 0.299
|
| 34 |
+
+ img_np[:, :, 1].astype(np.float32) * 0.587
|
| 35 |
+
+ img_np[:, :, 2].astype(np.float32) * 0.114)
|
| 36 |
+
h, w = mask.shape[:2]
|
| 37 |
+
median_lum = float(np.median(luminance[mask > 0]))
|
| 38 |
+
filled = luminance.copy()
|
| 39 |
+
filled[mask == 0] = median_lum
|
| 40 |
+
med_k = max(9, int(min(h, w) / 40)) | 1
|
| 41 |
+
filled = cv2.medianBlur(np.clip(filled, 0, 255).astype(np.uint8), med_k).astype(np.float32)
|
| 42 |
+
sigma = max(8.0, min(h, w) / 28.0)
|
| 43 |
+
smooth = cv2.GaussianBlur(filled, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
| 44 |
+
relative = smooth / median_lum
|
| 45 |
+
relative[mask == 0] = 1.0
|
| 46 |
+
lo, hi = ns["_adaptive_shade_range"](relative, mask)
|
| 47 |
+
return ns["_encode_shade"](relative, lo, hi), (lo, hi)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def decode(shade, rng):
|
| 51 |
+
lo, hi = rng
|
| 52 |
+
return lo + shade.astype(np.float32) / 255.0 * (hi - lo)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def masked_corr(a, b, m):
|
| 56 |
+
av = a[m] - a[m].mean()
|
| 57 |
+
bv = b[m] - b[m].mean()
|
| 58 |
+
den = np.sqrt((av ** 2).sum() * (bv ** 2).sum()) + 1e-9
|
| 59 |
+
return float((av * bv).sum() / den)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def main():
|
| 63 |
+
H, W = 700, 900
|
| 64 |
+
yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
|
| 65 |
+
|
| 66 |
+
# known components
|
| 67 |
+
gradient = 0.85 + 0.5 * (xx / W) # window on the right
|
| 68 |
+
shadow = 1.0 - 0.35 * np.exp(-(((xx - 250) / 90) ** 2 + ((yy - 420) / 60) ** 2))
|
| 69 |
+
period = 200
|
| 70 |
+
wash = 1.0 + 0.16 * np.sign(np.sin(2 * np.pi * (xx + yy) / period)
|
| 71 |
+
* np.sin(2 * np.pi * (xx - yy) / period))
|
| 72 |
+
wash = cv2.GaussianBlur(wash, (0, 0), 9) # soft tile shading
|
| 73 |
+
grout = np.where((np.mod(xx + yy, period) < 6) | (np.mod(xx - yy, period) < 6), 0.75, 1.0)
|
| 74 |
+
|
| 75 |
+
lum = 150.0 * gradient * shadow * wash * grout
|
| 76 |
+
img = np.repeat(np.clip(lum, 0, 255)[..., None], 3, axis=2).astype(np.uint8)
|
| 77 |
+
mask = np.ones((H, W), np.uint8)
|
| 78 |
+
mask[: H // 6] = 0 # a wall strip, exercises inpaint
|
| 79 |
+
|
| 80 |
+
ok = True
|
| 81 |
+
res = {}
|
| 82 |
+
for name, fn in [("pre-N3", build_shade_map_pre_n3), ("N3", build_shade_map)]:
|
| 83 |
+
shade, rng = fn(img, mask)
|
| 84 |
+
rel = decode(shade, rng)
|
| 85 |
+
m = mask.astype(bool)
|
| 86 |
+
wash_c = masked_corr(rel, wash, m)
|
| 87 |
+
shadow_c = masked_corr(rel, shadow, m)
|
| 88 |
+
left = rel[m & (xx < W * 0.25)].mean()
|
| 89 |
+
right = rel[m & (xx > W * 0.75)].mean()
|
| 90 |
+
grad_ratio = left / right
|
| 91 |
+
true_ratio = gradient[m & (xx < W * 0.25)].mean() / gradient[m & (xx > W * 0.75)].mean()
|
| 92 |
+
res[name] = (wash_c, shadow_c, grad_ratio)
|
| 93 |
+
print(f"[{name:6s}] wash-corr={wash_c:.3f} shadow-corr={shadow_c:.3f} "
|
| 94 |
+
f"gradient L/R={grad_ratio:.3f} (truth {true_ratio:.3f})")
|
| 95 |
+
|
| 96 |
+
wash_drop = 1 - abs(res["N3"][0]) / max(abs(res["pre-N3"][0]), 1e-6)
|
| 97 |
+
shadow_keep = abs(res["N3"][1]) / max(abs(res["pre-N3"][1]), 1e-6)
|
| 98 |
+
print(f"wash leakage drop = {wash_drop * 100:.0f}% (need >= 60%)")
|
| 99 |
+
print(f"shadow retention = {shadow_keep * 100:.0f}% (need >= 75%)")
|
| 100 |
+
if wash_drop < 0.60:
|
| 101 |
+
print(" !! periodic wash still leaking"); ok = False
|
| 102 |
+
if shadow_keep < 0.75:
|
| 103 |
+
print(" !! real shadow lost"); ok = False
|
| 104 |
+
true_ratio = gradient[mask.astype(bool) & (xx < W * 0.25)].mean() / \
|
| 105 |
+
gradient[mask.astype(bool) & (xx > W * 0.75)].mean()
|
| 106 |
+
if abs(res["N3"][2] - true_ratio) > 0.15 * true_ratio:
|
| 107 |
+
print(" !! lighting gradient distorted"); ok = False
|
| 108 |
+
|
| 109 |
+
# period detector sanity on aperiodic field: pure shadow must NOT register
|
| 110 |
+
aper = cv2.GaussianBlur((shadow * 40).astype(np.float32), (0, 0), 3)
|
| 111 |
+
aper_hp = aper - cv2.GaussianBlur(aper, (0, 0), 50)
|
| 112 |
+
if ns["_dominant_period"](aper_hp, 1) or ns["_dominant_period"](aper_hp, 0):
|
| 113 |
+
print(" !! aperiodic shadow field misdetected as periodic"); ok = False
|
| 114 |
+
|
| 115 |
+
print("\n" + ("ALL N3 CHECKS PASSED" if ok else "N3 CHECKS FAILED"))
|
| 116 |
+
return 0 if ok else 1
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
raise SystemExit(main())
|