← 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']}")