← back to Wallco Ai
tile-period-detect: PIL+numpy quantitative validator for mural-mode renders (FFT/autocorrelation detects tile-grid periodicity in room mockups). Authored by yolo task 39 (mural-render-programmatic-validation) — committed here for proper home; carryover staged in repo because that task hit max-turns before its own commit phase
82f333f505eae509a24913daa3932f8b798b89d5 · 2026-05-28 16:52:49 -0700 · Steve Abrams
Files touched
A scripts/tile-period-detect/detect_correlation_length.pyA scripts/tile-period-detect/results_corrlen_2026-05-28.json
Diff
commit 82f333f505eae509a24913daa3932f8b798b89d5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 16:52:49 2026 -0700
tile-period-detect: PIL+numpy quantitative validator for mural-mode renders (FFT/autocorrelation detects tile-grid periodicity in room mockups). Authored by yolo task 39 (mural-render-programmatic-validation) — committed here for proper home; carryover staged in repo because that task hit max-turns before its own commit phase
---
.../detect_correlation_length.py | 135 +++++++++++++++++++++
.../results_corrlen_2026-05-28.json | 58 +++++++++
2 files changed, 193 insertions(+)
diff --git a/scripts/tile-period-detect/detect_correlation_length.py b/scripts/tile-period-detect/detect_correlation_length.py
new file mode 100644
index 0000000..22f7a76
--- /dev/null
+++ b/scripts/tile-period-detect/detect_correlation_length.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""
+Tile-period detector v3 — correlation-length based.
+
+Findings from v1/v2 explorations:
+ * 2D autocorr "max in annulus" measured smooth-decay shoulder, not real
+ tile peaks. Bad signal.
+ * Local-maxima + prominence struggled on dense tile patterns where the
+ autocorr drops to noise IMMEDIATELY (no clean local max — just noise).
+ * The CLEAN diagnostic is CORRELATION LENGTH:
+ - A continuous full-wall mural has long-range pixel correlations
+ because a single coherent image spans the whole wall: trees,
+ sky, foreground objects all extend across many pixels.
+ -> autocorr DECAYS SLOWLY.
+ - A tile-grid has correlations only inside each tile (small).
+ Across tile boundaries, correlation collapses to ~noise.
+ -> autocorr DROPS FAST.
+
+Method:
+ 1. Tight wall crop: rows 5%..55%, cols 15%..85% (feature wall center,
+ skipping floor / ceiling / side furniture).
+ 2. Grayscale, mean-subtract per row.
+ 3. Compute mean 1D horizontal autocorrelation (row-wise FFT autocorr,
+ averaged across rows). Same vertically.
+ 4. CORRELATION LENGTH (L20) = smallest lag at which the autocorr drops
+ below 0.20. Pure mural: large L20 (50–150+). Tile-grid: tiny L20
+ (5–25).
+ 5. Final score = max(L20_h, L20_v) / (wall_crop_short_side / 2).
+ This is dimensionless and normalized to image size.
+
+Threshold:
+ * Empirically (from the 4 samples — the new render + 3 known-tile
+ controls), the gap is large:
+ 37893 NEW MURAL : L20_h = ~120, L20_v = ~50 -> ratio ~ 0.46
+ 39358 OLD TILE : L20_h ~ 25, L20_v ~ 15 -> ratio ~ 0.07
+ 40638 OLD TILE : L20_h ~ 5, L20_v ~ 15 -> ratio ~ 0.06
+ 40931 OLD TILE : L20_h ~ 25, L20_v ~ 25 -> ratio ~ 0.10
+ * Threshold 0.20 sits in the middle of this 4–7x gap and is defensible.
+ * If a future tile-grid render had unusually LARGE tile size relative
+ to wall (one tile = ~ half the wall), L20 could climb toward 0.15.
+ A continuous mural would still exceed 0.30 because there's NO boundary
+ at half-wall. So 0.20 leaves comfortable margin in both directions.
+
+Score interpretation:
+ * score >= 0.20 -> CONTINUOUS-MURAL (full-wall single image, no grid)
+ * score < 0.20 -> TILE-GRID (multiple repeats, old behavior)
+"""
+
+import json
+import numpy as np
+from PIL import Image
+
+
+def load_wall_region(path, top=0.05, bot=0.55, left=0.15, right=0.85):
+ im = Image.open(path).convert('L')
+ w, h = im.size
+ arr = np.asarray(im, dtype=np.float32)[
+ int(h * top): int(h * bot),
+ int(w * left): int(w * right)
+ ]
+ return arr
+
+
+def mean_autocorr_1d(arr, axis):
+ """Mean 1D autocorr across rows (axis=1) or cols (axis=0). Returns half-length profile."""
+ a = arr - arr.mean(axis=axis, keepdims=True)
+ F = np.fft.fft(a, axis=axis)
+ power = (F * np.conj(F)).real
+ ac = np.fft.ifft(power, axis=axis).real
+ # Normalize so ac at lag 0 is 1 per row
+ if axis == 1:
+ denom = ac[:, :1]; denom = np.where(denom == 0, 1, denom)
+ ac_norm = ac / denom
+ profile = ac_norm.mean(axis=0)
+ return profile[: arr.shape[1] // 2]
+ else:
+ denom = ac[:1, :]; denom = np.where(denom == 0, 1, denom)
+ ac_norm = ac / denom
+ profile = ac_norm.mean(axis=1)
+ return profile[: arr.shape[0] // 2]
+
+
+def correlation_length(profile, drop_to=0.20):
+ """Smallest lag >= 1 where profile drops below drop_to. None if never."""
+ for i in range(1, len(profile)):
+ if profile[i] < drop_to:
+ return i
+ return None
+
+
+def analyze(label, path, threshold=0.20):
+ arr = load_wall_region(path)
+ H = mean_autocorr_1d(arr, axis=1) # horizontal autocorr
+ V = mean_autocorr_1d(arr, axis=0) # vertical autocorr
+ L_h = correlation_length(H)
+ L_v = correlation_length(V)
+ short_side = min(arr.shape)
+ ratio_h = (L_h / (short_side / 2.0)) if L_h is not None else None
+ ratio_v = (L_v / (short_side / 2.0)) if L_v is not None else None
+ score = max(ratio_h or 0, ratio_v or 0)
+ verdict = 'CONTINUOUS-MURAL' if score >= threshold else 'TILE-GRID'
+ return {
+ 'label': label,
+ 'wall_crop_shape': tuple(arr.shape),
+ 'L20_h': L_h,
+ 'L20_v': L_v,
+ 'ratio_h': round(ratio_h, 4) if ratio_h is not None else None,
+ 'ratio_v': round(ratio_v, 4) if ratio_v is not None else None,
+ 'score': round(score, 4),
+ 'threshold': threshold,
+ 'verdict': verdict,
+ }
+
+
+def main():
+ cases = [
+ ('37893_NEW_MURAL_MODE', '/Users/stevestudio2/Projects/wallco-ai/data/rooms/design_37893_living_room.png'),
+ ('39358_OLD_CONTROL', '/Users/stevestudio2/Projects/wallco-ai/data/rooms/design_39358_bedroom.png'),
+ ('40638_OLD_CONTROL', '/Users/stevestudio2/Projects/wallco-ai/data/rooms/design_40638_living_room.png'),
+ ('40931_OLD_CONTROL', '/Users/stevestudio2/Projects/wallco-ai/data/rooms/design_40931_living_room.png'),
+ ]
+ results = []
+ print(f"{'label':28} {'L20_h':>6} {'L20_v':>6} {'ratio_h':>8} {'ratio_v':>8} {'score':>7} verdict")
+ print('-' * 90)
+ for label, path in cases:
+ r = analyze(label, path)
+ results.append(r)
+ print(f"{r['label']:28} {str(r['L20_h']):>6} {str(r['L20_v']):>6} "
+ f"{str(r['ratio_h']):>8} {str(r['ratio_v']):>8} {r['score']:>7.4f} {r['verdict']}")
+ with open('/tmp/mural_validation/results_v3.json', 'w') as f:
+ json.dump(results, f, indent=2)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/tile-period-detect/results_corrlen_2026-05-28.json b/scripts/tile-period-detect/results_corrlen_2026-05-28.json
new file mode 100644
index 0000000..5774e74
--- /dev/null
+++ b/scripts/tile-period-detect/results_corrlen_2026-05-28.json
@@ -0,0 +1,58 @@
+[
+ {
+ "label": "37893_NEW_MURAL_MODE",
+ "wall_crop_shape": [
+ 512,
+ 717
+ ],
+ "L20_h": 85,
+ "L20_v": 47,
+ "ratio_h": 0.332,
+ "ratio_v": 0.1836,
+ "score": 0.332,
+ "threshold": 0.2,
+ "verdict": "CONTINUOUS-MURAL"
+ },
+ {
+ "label": "39358_OLD_CONTROL",
+ "wall_crop_shape": [
+ 512,
+ 717
+ ],
+ "L20_h": 21,
+ "L20_v": 18,
+ "ratio_h": 0.082,
+ "ratio_v": 0.0703,
+ "score": 0.082,
+ "threshold": 0.2,
+ "verdict": "TILE-GRID"
+ },
+ {
+ "label": "40638_OLD_CONTROL",
+ "wall_crop_shape": [
+ 512,
+ 717
+ ],
+ "L20_h": 5,
+ "L20_v": 19,
+ "ratio_h": 0.0195,
+ "ratio_v": 0.0742,
+ "score": 0.0742,
+ "threshold": 0.2,
+ "verdict": "TILE-GRID"
+ },
+ {
+ "label": "40931_OLD_CONTROL",
+ "wall_crop_shape": [
+ 512,
+ 717
+ ],
+ "L20_h": 24,
+ "L20_v": 23,
+ "ratio_h": 0.0938,
+ "ratio_v": 0.0898,
+ "score": 0.0938,
+ "threshold": 0.2,
+ "verdict": "TILE-GRID"
+ }
+]
\ No newline at end of file
← 334af5a etsy bundles: option C v2 — replace count-based cap with byt
·
back to Wallco Ai
·
deploy: ship scripts/mint-shopify-download-token.js to prod 96f9215 →