[object Object]

← 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

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 →