GitHub Actions commited on
Commit
22a203c
·
1 Parent(s): 057afe6

Deploy from GitHub commit 1d59f596c1ba09e37dee8698f4e940a2a4bd3311

Browse files
Files changed (4) hide show
  1. app.py +109 -6
  2. verify_n1_sim.py +422 -0
  3. verify_n2_sim.py +76 -0
  4. 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
- count, labels, stats, _ = cv2.connectedComponentsWithStats(inv, connectivity=8)
 
 
 
 
 
 
 
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 == 0 or y == 0 or x + cw >= w or y + ch >= h:
569
  continue
570
- comp = labels == comp_id
571
- if protect is not None and protect[comp].any():
572
- continue
573
- out[comp] = 1
 
 
 
 
 
 
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())