← back to Wallco Ai
scripts/audit-room-seam.py
191 lines
#!/usr/bin/env python3
"""
Room-render seam auditor — wall-only seam discontinuity scan.
Differs from edges-agent (which scans tile PNGs): this takes a ROOM render
(photo of a wall + furniture) and isolates ONLY the wallpaper region before
checking for vertical/horizontal seam discontinuities + pattern repeat period.
Masks:
· top 10% (ceiling + crown molding)
· bottom 55% (desk + furniture)
· center-bottom keep-out (desk-lamp silhouette intruding into the wall)
Verdict thresholds (column or row edge-energy ratio vs median, smoothed):
· ≤ 2.2× — PASS
· 2.2–3.0× — WARN
· > 3.0× — FAIL
Plus a pattern-repetition guard: <200px period at autocorr>0.5 → WARN
(pattern repeats too tightly — looks like an obvious tile pattern not a wall).
"""
import sys, os, json, argparse
from PIL import Image, ImageDraw
import numpy as np
CEILING_FRAC = 0.10 # mask top 10%
FURNITURE_FRAC = 0.55 # mask bottom 55%
# Desk-lamp keep-out (typical office room layout from room-setting-generator):
LAMP_X_FRAC = (0.30, 0.65) # horizontal slice of the wall-bottom band
LAMP_Y_FRAC = (0.42, 0.55) # vertical slice (just above furniture mask)
def audit(path: str, out_dir: str = "/tmp", verbose: bool = True):
img = Image.open(path).convert("RGB")
W, H = img.size
if verbose: print(f"image: {W}x{H}")
wall_top = int(H * CEILING_FRAC)
wall_bot = int(H * (1 - FURNITURE_FRAC + 0.45)) # bottom of wall = top of furniture
# = H * 0.45 here; keep clean — same effect
wall_bot = int(H * 0.45)
wall = img.crop((0, wall_top, W, wall_bot))
ww, wh = wall.size
if verbose: print(f"wall crop: {ww}x{wh} (y={wall_top}..{wall_bot})")
arr = np.array(wall, dtype=np.float32)
gray = arr.mean(axis=2)
# Build a per-pixel KEEP mask (1=wallpaper, 0=furniture/lamp).
mask = np.ones_like(gray, dtype=np.float32)
lx0 = int(ww * LAMP_X_FRAC[0])
lx1 = int(ww * LAMP_X_FRAC[1])
ly0_in_wall = int(wh * 0.85) # bottom 15% of wall crop most-likely lamp top
mask[ly0_in_wall:, lx0:lx1] = 0.0
masked = gray * mask
# ── Vertical seam detection (column-wise edge energy, masked) ──
diff_x = np.abs(np.diff(masked, axis=1))
col_signal = diff_x.mean(axis=0)
col_signal = np.convolve(col_signal, np.ones(7)/7, mode='same')
med_col = float(np.median(col_signal))
col_peaks = []
last = -50
for i, v in enumerate(col_signal):
if v > med_col * 1.8 and i - last > 24:
col_peaks.append((i, float(v / med_col)))
last = i
# ── Horizontal seam detection (row-wise) ──
diff_y = np.abs(np.diff(masked, axis=0))
row_signal = diff_y.mean(axis=1)
row_signal = np.convolve(row_signal, np.ones(7)/7, mode='same')
med_row = float(np.median(row_signal))
row_peaks = []
last = -50
for i, v in enumerate(row_signal):
if v > med_row * 1.8 and i - last > 24:
row_peaks.append((i + wall_top, float(v / med_row)))
last = i
# ── Pattern periodicity (autocorrelation on a clean mid-strip) ──
strip_y = wh // 2
strip = gray[strip_y - 8: strip_y + 8].mean(axis=0)
strip -= strip.mean()
ac = np.correlate(strip, strip, mode='full')[len(strip)-1:]
ac = ac / max(ac[0], 1e-9)
period, period_strength = None, 0.0
for i in range(30, min(W - 1, 700)):
if ac[i] > ac[i-1] and ac[i] > ac[i+1] and ac[i] > 0.3:
period = i
period_strength = float(ac[i])
break
# ── Pattern-vs-seam disambiguation ──
# If detected peaks are EVENLY spaced (within 25% of each other), they're
# the pattern's natural row/column structure, not a seam. Real seams are
# one-offs: a single localized discontinuity, not a repeating set.
def is_pattern_structure(peaks):
if len(peaks) < 2: return False
positions = sorted(p[0] for p in peaks)
gaps = [positions[i+1] - positions[i] for i in range(len(positions) - 1)]
if not gaps: return False
mean_gap = sum(gaps) / len(gaps)
if mean_gap < 30: return False # too tight to be wallpaper rows
return all(abs(g - mean_gap) / mean_gap < 0.25 for g in gaps)
col_is_pattern = is_pattern_structure(col_peaks)
row_is_pattern = is_pattern_structure(row_peaks)
# ── Verdict ──
verdict = "PASS"
notes = []
worst_col = max([r for _, r in col_peaks], default=0.0)
worst_row = max([r for _, r in row_peaks], default=0.0)
if worst_col >= 3.0 and not col_is_pattern:
verdict = "FAIL"; notes.append(f"vertical seam {worst_col:.2f}× median col-energy")
elif worst_col >= 2.2 and not col_is_pattern:
if verdict == "PASS": verdict = "WARN"
notes.append(f"weak vertical discontinuity {worst_col:.2f}× median")
elif col_is_pattern:
notes.append(f"vertical peaks evenly spaced — pattern column structure, not a seam")
if worst_row >= 3.0 and not row_is_pattern:
verdict = "FAIL"; notes.append(f"horizontal seam {worst_row:.2f}× median row-energy")
elif worst_row >= 2.2 and not row_is_pattern:
if verdict == "PASS": verdict = "WARN"
notes.append(f"weak horizontal discontinuity {worst_row:.2f}× median")
elif row_is_pattern:
notes.append(f"horizontal peaks evenly spaced — pattern row structure, not a seam")
if period and period_strength > 0.5 and period < 200:
if verdict == "PASS": verdict = "WARN"
notes.append(f"tight repetition {period}px (strength {period_strength:.2f}) — pattern repeats {W//period}× across wall")
# ── Artifacts ──
base = os.path.splitext(os.path.basename(path))[0]
overlay = img.copy()
dr = ImageDraw.Draw(overlay)
# shade mask regions
shade = Image.new("RGBA", img.size, (0,0,0,0))
sdr = ImageDraw.Draw(shade)
sdr.rectangle([0, 0, W, wall_top], fill=(48, 100, 200, 90)) # ceiling mask
sdr.rectangle([0, wall_bot, W, H], fill=(48, 100, 200, 90)) # furniture mask
# lamp keep-out (translated to global y)
sdr.rectangle([lx0, wall_top + int(wh * 0.85), lx1, wall_bot], fill=(48, 100, 200, 90))
overlay = Image.alpha_composite(overlay.convert("RGBA"), shade).convert("RGB")
dr = ImageDraw.Draw(overlay)
for cx, r in col_peaks[:8]:
c = (220, 28, 28) if r >= 3.0 else (220, 180, 28)
dr.line([(cx, wall_top), (cx, wall_bot)], fill=c, width=2)
for ry, r in row_peaks[:8]:
c = (220, 28, 28) if r >= 3.0 else (220, 180, 28)
dr.line([(0, ry), (W, ry)], fill=c, width=2)
overlay_path = os.path.join(out_dir, f"{base}.audit-overlay.png")
overlay.save(overlay_path)
result = {
"ok": True, "path": path, "image": [W, H],
"wall_crop": {"x": [0, W], "y": [wall_top, wall_bot]},
"verdict": verdict,
"vertical": {
"median_col_energy": med_col, "worst_ratio": worst_col,
"peaks": [{"x": int(c), "ratio": round(r, 3)} for c, r in col_peaks]
},
"horizontal": {
"median_row_energy": med_row, "worst_ratio": worst_row,
"peaks": [{"y": int(y), "ratio": round(r, 3)} for y, r in row_peaks]
},
"periodicity": {"period_px": period, "strength": round(period_strength, 3)},
"notes": notes,
"overlay": overlay_path,
}
return result
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("path")
ap.add_argument("--json", action="store_true")
ap.add_argument("--out-dir", default="/tmp")
args = ap.parse_args()
r = audit(args.path, args.out_dir, verbose=not args.json)
if args.json:
print(json.dumps(r)) # single-line so fan-out (xargs concat) is parseable
else:
print(f"\n══ VERDICT: {r['verdict']} ══")
print(f" vertical: max {r['vertical']['worst_ratio']:.2f}× median ({len(r['vertical']['peaks'])} peaks)")
print(f" horizontal: max {r['horizontal']['worst_ratio']:.2f}× median ({len(r['horizontal']['peaks'])} peaks)")
pp = r['periodicity']
print(f" period: {pp['period_px']}px (strength {pp['strength']:.3f})")
if r['notes']:
for n in r['notes']: print(f" · {n}")
else:
print(" · clean wall region — no significant seams or tight repeat detected")
print(f" overlay: {r['overlay']}")