← back to Wallco Ai
add tile-period-detect: validate mural-mode room renders
c4b01d5ae2fbfcd5badd8c9e510e207cdc018ec1 · 2026-05-28 13:19:02 -0700 · Steve Abrams
Programmatic detector that scores autocorrelation peaks on horizontal +
vertical projections of the feature wall band. Single-axis peak-baseline
ratio is noisy (furniture creates moderate peaks), but strong-peak count
on min(H, V) cleanly separates continuous murals (≤1) from repeating
tile-grids (≥2 with harmonic series).
Validated 2026-05-28 on test render #37893 (new patternType='mural')
vs 6 randomly-sampled kind='mural_panel' controls. Test min=1, controls
min∈[2,4]. Clean gap.
Report: ~/.claude/yolo-queue/output/39-mural-render-validation-2026-05-28.md
Files touched
A scripts/tile-period-detect/detect.py
Diff
commit c4b01d5ae2fbfcd5badd8c9e510e207cdc018ec1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 13:19:02 2026 -0700
add tile-period-detect: validate mural-mode room renders
Programmatic detector that scores autocorrelation peaks on horizontal +
vertical projections of the feature wall band. Single-axis peak-baseline
ratio is noisy (furniture creates moderate peaks), but strong-peak count
on min(H, V) cleanly separates continuous murals (≤1) from repeating
tile-grids (≥2 with harmonic series).
Validated 2026-05-28 on test render #37893 (new patternType='mural')
vs 6 randomly-sampled kind='mural_panel' controls. Test min=1, controls
min∈[2,4]. Clean gap.
Report: ~/.claude/yolo-queue/output/39-mural-render-validation-2026-05-28.md
---
scripts/tile-period-detect/detect.py | 172 +++++++++++++++++++++++++++++++++++
1 file changed, 172 insertions(+)
diff --git a/scripts/tile-period-detect/detect.py b/scripts/tile-period-detect/detect.py
new file mode 100644
index 0000000..0c10882
--- /dev/null
+++ b/scripts/tile-period-detect/detect.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+"""
+Tile-period detector for room-mural renders.
+
+Goal: programmatic answer to "does this render show a single full-wall mural
+or a repeating tile-grid?"
+
+Approach:
+ 1. Crop to the dominant feature wall (heuristic band of the upper image).
+ 2. Convert to grayscale, flatten lighting with a high-pass filter (subtract
+ a heavily-blurred copy).
+ 3. 1D autocorrelation along horizontal and vertical axes of the wall band,
+ after column-sum / row-sum projection.
+ 4. Score = max peak height in offset range [40 .. 380] px divided by the
+ median of the autocorrelation in that range. High score = repeating
+ structure (tile-grid). Low score = continuous mural.
+ 5. Combined score = max(horiz_score, vert_score). Tile-grid usually shows
+ at least one axis with strong periodicity.
+
+Threshold (chosen + justified inline): 2.5 — peaks must rise to 2.5x the
+median baseline before we call it a grid. Sweep the test+controls to see if
+the threshold cleanly separates.
+
+PIL + numpy only. $0. Read-only.
+"""
+
+import sys
+import os
+import json
+import numpy as np
+from PIL import Image, ImageFilter
+
+
+def load_wall_band(path):
+ """Load image, crop to upper feature-wall band, return grayscale ndarray."""
+ im = Image.open(path).convert("RGB")
+ w, h = im.size
+ # Heuristic wall band: roughly upper 60%, 80% wide, centered horizontally.
+ # For a 1024x1024 living-room render this captures the wall above the
+ # sofa/TV stand without leaning into floor or ceiling slivers.
+ x0 = int(w * 0.10)
+ x1 = int(w * 0.90)
+ y0 = int(h * 0.05)
+ y1 = int(h * 0.55)
+ band = im.crop((x0, y0, x1, y1))
+ return band, (x0, y0, x1, y1)
+
+
+def high_pass(gray_arr, sigma=25):
+ """Subtract a heavily-blurred copy to flatten lighting gradients."""
+ im = Image.fromarray(gray_arr)
+ blurred = im.filter(ImageFilter.GaussianBlur(radius=sigma))
+ hp = gray_arr.astype(np.float32) - np.asarray(blurred, dtype=np.float32)
+ return hp
+
+
+def autocorr_1d(signal):
+ """Normalized 1D autocorrelation via FFT."""
+ n = len(signal)
+ s = signal - signal.mean()
+ # Zero-pad to next power of 2 for clean FFT
+ nfft = 1 << (2 * n - 1).bit_length()
+ fft = np.fft.rfft(s, nfft)
+ power = fft * np.conj(fft)
+ ac = np.fft.irfft(power, nfft)[:n].real
+ if ac[0] > 0:
+ ac = ac / ac[0]
+ return ac
+
+
+def peak_baseline_score(ac, min_offset=40, max_offset=380):
+ """Find max peak in [min_offset, max_offset] divided by median baseline.
+ Also return the peak offset (px) for inspection."""
+ window = ac[min_offset:max_offset]
+ if len(window) == 0:
+ return 0.0, 0
+ # Median across the full window as baseline (use abs in case the AC dips
+ # negative — we care about the peak magnitude vs typical magnitude).
+ baseline = np.median(np.abs(window)) + 1e-9
+ peak_val = window.max()
+ peak_idx = int(np.argmax(window))
+ score = float(peak_val / baseline)
+ return score, min_offset + peak_idx
+
+
+def count_strong_peaks(ac, min_offset=40, max_offset=380, threshold=0.15):
+ """Count local maxima above threshold in normalized autocorrelation.
+ A tile-grid typically produces multiple harmonics (k*tile_pitch);
+ a mural produces zero or one weak bump."""
+ window = ac[min_offset:max_offset]
+ if len(window) < 3:
+ return 0, []
+ peaks = []
+ for i in range(1, len(window) - 1):
+ if window[i] > window[i-1] and window[i] > window[i+1] and window[i] > threshold:
+ peaks.append((min_offset + i, float(window[i])))
+ return len(peaks), peaks
+
+
+def analyze(path):
+ band, crop_box = load_wall_band(path)
+ gray = np.asarray(band.convert("L"), dtype=np.uint8)
+ hp = high_pass(gray, sigma=25)
+
+ # 1D projections — sum across the perpendicular axis to suppress single-
+ # object noise (TV, lamps) and keep periodic wallpaper structure.
+ horiz_signal = hp.sum(axis=0) # length = band width — horizontal AC
+ vert_signal = hp.sum(axis=1) # length = band height — vertical AC
+
+ ac_h = autocorr_1d(horiz_signal)
+ ac_v = autocorr_1d(vert_signal)
+
+ # Bound search to reasonable tile pitches. Wall band is ~800x500 px on a
+ # 1024x1024 render. A real tile would map to roughly 25%-50% of the wall
+ # width or height (a 24-27" pattern repeat on a typical 8-10ft wall).
+ h_max_offset = min(len(ac_h) - 5, 380)
+ v_max_offset = min(len(ac_v) - 5, 380)
+
+ h_score, h_peak_off = peak_baseline_score(ac_h, 40, h_max_offset)
+ v_score, v_peak_off = peak_baseline_score(ac_v, 40, v_max_offset)
+
+ h_peaks_n, h_peaks = count_strong_peaks(ac_h, 40, h_max_offset, threshold=0.15)
+ v_peaks_n, v_peaks = count_strong_peaks(ac_v, 40, v_max_offset, threshold=0.15)
+
+ combined = max(h_score, v_score)
+
+ return {
+ "path": path,
+ "crop_box": crop_box,
+ "horizontal": {
+ "peak_offset_px": h_peak_off,
+ "peak_baseline_score": round(h_score, 3),
+ "strong_peaks_n": h_peaks_n,
+ "strong_peaks": [(off, round(v, 3)) for off, v in h_peaks[:8]],
+ },
+ "vertical": {
+ "peak_offset_px": v_peak_off,
+ "peak_baseline_score": round(v_score, 3),
+ "strong_peaks_n": v_peaks_n,
+ "strong_peaks": [(off, round(v, 3)) for off, v in v_peaks[:8]],
+ },
+ "combined_score": round(combined, 3),
+ }
+
+
+def main():
+ paths = sys.argv[1:]
+ if not paths:
+ print("usage: detect.py IMAGE [IMAGE...]")
+ sys.exit(1)
+ results = []
+ for p in paths:
+ if not os.path.exists(p):
+ print(f"!! missing: {p}", file=sys.stderr)
+ continue
+ r = analyze(p)
+ results.append(r)
+ name = os.path.basename(p)
+ print(f"{name}")
+ print(f" H: score={r['horizontal']['peak_baseline_score']} "
+ f"peak@{r['horizontal']['peak_offset_px']}px "
+ f"strong_peaks={r['horizontal']['strong_peaks_n']}")
+ print(f" V: score={r['vertical']['peak_baseline_score']} "
+ f"peak@{r['vertical']['peak_offset_px']}px "
+ f"strong_peaks={r['vertical']['strong_peaks_n']}")
+ print(f" combined={r['combined_score']}")
+ print()
+ print(json.dumps(results, indent=2))
+
+
+if __name__ == "__main__":
+ main()
← 23b1207 wallco-etsy bundles: option C wired — target-size 12 (was 15
·
back to Wallco Ai
·
wallco.ai downloads route: /downloads/:slug/:token.zip token e1b59d8 →