[object Object]

← back to Wallco Ai

scripts/pil-mural-composite.py: FREE local single-panel mural room-mockup renderer (murals never tiled)

a54ea5831c6e3d8a71217cf26e01c03a4d54ab62 · 2026-05-30 09:52:01 -0700 · Steve Abrams

Sibling of pil-room-composite.py for SCENIC MURALS. Places the mural ONCE as a
full-bleed, floor-to-ceiling, edge-to-edge single panel (letterbox-crop to wall
aspect trimming dense foreground + perspective quad-warp + feathered mask +
bottom ambient-occlusion vignette) — NEVER tiled, per lib/mural-categories.js.
Built from a 4-lens design panel (graphic-designer compositor recipe +
interior-designer scale/setting + ui-ux presentation) + DTD verdict C; passed
graphic-designer validation loop (ship:yes after rim/AO/keystone fixes).
Generated + Mac2 wallco_rooms-inserted all 35 remaining murals (cactus-pine-scenic
4 + tree-mural 31). Prod-ship pending SSH access restore; photoreal :3075 upgrade
queued separately (DTD-C).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit a54ea5831c6e3d8a71217cf26e01c03a4d54ab62
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 30 09:52:01 2026 -0700

    scripts/pil-mural-composite.py: FREE local single-panel mural room-mockup renderer (murals never tiled)
    
    Sibling of pil-room-composite.py for SCENIC MURALS. Places the mural ONCE as a
    full-bleed, floor-to-ceiling, edge-to-edge single panel (letterbox-crop to wall
    aspect trimming dense foreground + perspective quad-warp + feathered mask +
    bottom ambient-occlusion vignette) — NEVER tiled, per lib/mural-categories.js.
    Built from a 4-lens design panel (graphic-designer compositor recipe +
    interior-designer scale/setting + ui-ux presentation) + DTD verdict C; passed
    graphic-designer validation loop (ship:yes after rim/AO/keystone fixes).
    Generated + Mac2 wallco_rooms-inserted all 35 remaining murals (cactus-pine-scenic
    4 + tree-mural 31). Prod-ship pending SSH access restore; photoreal :3075 upgrade
    queued separately (DTD-C).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/pil-mural-composite.py | 161 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 161 insertions(+)

diff --git a/scripts/pil-mural-composite.py b/scripts/pil-mural-composite.py
new file mode 100644
index 0000000..cb35646
--- /dev/null
+++ b/scripts/pil-mural-composite.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""
+pil-mural-composite.py — FREE LOCAL room-mockup renderer for MURALS.
+
+Sibling of pil-room-composite.py, but for SINGLE-PANEL SCENIC MURALS, which must
+NEVER be tiled (hard standing rule — see lib/mural-categories.js). Instead of
+repeating a tile across the wall, this places the mural ONCE as a full-bleed,
+floor-to-ceiling, edge-to-edge single panel on the wall quad — the way a scenic
+mural is actually installed.
+
+Design guidance baked in (2026-05-30 four-lens panel + DTD verdict C):
+  - graphic-designer: full-bleed wall placement, letterbox-crop to wall aspect
+    (trim the dense foreground), perspective quad-warp, subtle wall-light.
+  - interior-designer: edge-to-edge floor-to-ceiling; small/framed = the #1
+    cheapness tell.
+  - ui-ux-designer: present as a single scenic panel, no repeat.
+
+Usage:
+    python3 scripts/pil-mural-composite.py <design_id> [<design_id> ...]
+    python3 scripts/pil-mural-composite.py --category tree-mural
+    python3 scripts/pil-mural-composite.py --murals-all        # all mural cats needing rooms
+    python3 scripts/pil-mural-composite.py --out /tmp/preview <id>   # preview dir
+"""
+import sys, json, argparse, subprocess
+from pathlib import Path
+import numpy as np
+from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageChops
+
+ROOT = Path(__file__).resolve().parent.parent
+ROOMS_DIR = ROOT / 'data' / 'rooms'
+BASE_TEMPLATE = ROOMS_DIR / '_template_blank_living_room.png'
+# Wall quad (TL, TR, BR, BL on the 1024 base) — EXPANDED ~12px outward vs the
+# tile compositor's quad so the single mural panel butts FLUSH to the wall edges
+# (graphic-designer validation 2026-05-30: the inset quad left a lighter wall rim
+# that read as a pasted-sticker frame — the #1 fake tell). TR nudged +13px right
+# to correct the keystone the reviewer flagged on the right pillar.
+WALL_QUAD = [(76, 40), (936, 40), (906, 716), (92, 716)]
+WALL_BLEND = 0.9     # slight darken so the panel reads as wall, not a bright sticker
+EDGE_FEATHER = 7     # px gaussian blur on the mask — soft edge = adhered paper, not decal
+AO_BOTTOM_FRAC = 0.18  # darken the lower 18% of the panel = ambient occlusion toward furniture
+MURAL_CATEGORIES = ['monterey-mural', 'cactus-11ft-mural', 'cactus-accent-mural',
+                    'tree-mural', 'mural-scenic', 'cactus-pine-scenic']
+
+def psql(sql):
+    return subprocess.run(['psql', 'dw_unified', '-At', '-q', '-c', sql],
+                          capture_output=True, text=True).stdout.strip()
+
+def perspective_coeffs(src_pts, dst_pts):
+    A, B = [], []
+    for (xs, ys), (xd, yd) in zip(src_pts, dst_pts):
+        A.append([xd, yd, 1, 0, 0, 0, -xs*xd, -xs*yd])
+        A.append([0, 0, 0, xd, yd, 1, -ys*xd, -ys*yd])
+        B.append(xs); B.append(ys)
+    return np.linalg.solve(np.array(A, float), np.array(B, float)).tolist()
+
+def letterbox_fill(mural, target_w, target_h):
+    """Crop the mural to the wall aspect (single panel — NO tiling), keeping the
+    scenic focal zone. Bias the crop UPWARD so we keep sky + the focal subject and
+    trim the dense foreground base (graphic-designer + interior-designer guidance)."""
+    mw, mh = mural.size
+    target_aspect = target_w / target_h
+    mural_aspect = mw / mh
+    if mural_aspect < target_aspect:
+        # mural taller than wall → trim height; keep ~5% top sky, trim more from bottom
+        new_h = int(round(mw / target_aspect))
+        new_h = min(new_h, mh)
+        top = int(mh * 0.05)
+        top = min(top, mh - new_h)
+        mural = mural.crop((0, top, mw, top + new_h))
+    elif mural_aspect > target_aspect:
+        # mural wider than wall → trim sides equally (keep centered scene)
+        new_w = int(round(mh * target_aspect))
+        left = (mw - new_w) // 2
+        mural = mural.crop((left, 0, left + new_w, mh))
+    return mural.resize((target_w, target_h), Image.LANCZOS)
+
+def _src_path(design_id):
+    """Source PNG via PG local_path (truth for murals), basename-resolved into
+    data/generated as a fallback for path drift."""
+    lp = psql(f"SELECT local_path FROM all_designs WHERE id={int(design_id)} AND local_path IS NOT NULL LIMIT 1;")
+    if lp:
+        p = Path(lp)
+        if p.exists():
+            return p
+        alt = ROOT / 'data' / 'generated' / p.name
+        if alt.exists():
+            return alt
+    return None
+
+def composite_one(design_id, out_dir=None):
+    out_dir = out_dir or ROOMS_DIR
+    out_dir.mkdir(parents=True, exist_ok=True)
+    out_path = out_dir / f'design_{design_id}_living_room.png'
+    src = _src_path(design_id)
+    if not src:
+        raise FileNotFoundError(f'#{design_id}: source PNG not found (PG local_path + generated/ fallback)')
+    base = Image.open(BASE_TEMPLATE).convert('RGB')
+    mural = Image.open(src).convert('RGB')
+    wall_w = max(p[0] for p in WALL_QUAD) - min(p[0] for p in WALL_QUAD)
+    wall_h = max(p[1] for p in WALL_QUAD) - min(p[1] for p in WALL_QUAD)
+    # SINGLE PANEL — letterbox-fill, never tile.
+    panel = letterbox_fill(mural, wall_w, wall_h)
+    # Ambient-occlusion: darken the lower AO_BOTTOM_FRAC of the panel with a smooth
+    # vertical gradient so the mural reads as a lit surface receding behind the
+    # furniture, not a backlit screen (graphic-designer AO note).
+    ao = Image.new('L', (wall_w, wall_h), 255)
+    aod = ImageDraw.Draw(ao)
+    ao_start = int(wall_h * (1 - AO_BOTTOM_FRAC))
+    for y in range(ao_start, wall_h):
+        f = (y - ao_start) / max(1, wall_h - ao_start)
+        aod.line([(0, y), (wall_w, y)], fill=int(255 - 55 * f))   # down to ~78% at the base
+    panel = ImageChops.multiply(panel, Image.merge('RGB', (ao, ao, ao)))
+    src_pts = [(0, 0), (wall_w, 0), (wall_w, wall_h), (0, wall_h)]
+    coeffs = perspective_coeffs(src_pts, WALL_QUAD)
+    warped = panel.transform(base.size, Image.PERSPECTIVE, coeffs, Image.BICUBIC)
+    mask = Image.new('L', base.size, 0)
+    ImageDraw.Draw(mask).polygon(WALL_QUAD, fill=255)
+    if EDGE_FEATHER:
+        mask = mask.filter(ImageFilter.GaussianBlur(EDGE_FEATHER))   # feather → adhered, not decal
+    if WALL_BLEND != 1.0:
+        warped = ImageEnhance.Brightness(warped).enhance(WALL_BLEND)
+    out = base.copy()
+    out.paste(warped, (0, 0), mask)
+    out.save(out_path, 'PNG', optimize=True)
+    return out_path
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('ids', nargs='*', type=int)
+    ap.add_argument('--category')
+    ap.add_argument('--murals-all', action='store_true')
+    ap.add_argument('--out', type=Path, default=None)
+    args = ap.parse_args()
+
+    ids = list(args.ids)
+    cats = []
+    if args.murals_all:
+        cats = MURAL_CATEGORIES
+    elif args.category:
+        cats = [args.category]
+    for cat in cats:
+        rows = psql("SELECT id FROM all_designs WHERE category='%s' AND is_published=TRUE "
+                    "AND local_path IS NOT NULL AND NOT EXISTS (SELECT 1 FROM wallco_rooms r "
+                    "WHERE r.design_id=all_designs.id AND r.image_path IS NOT NULL) ORDER BY id;" % cat)
+        ids += [int(x) for x in rows.splitlines() if x.strip()]
+    ids = sorted(set(ids))
+    if not ids:
+        print('no mural ids need rooms'); sys.exit(0)
+    print(f'[pil-mural] {len(ids)} murals · SINGLE-PANEL (no tile) · out={args.out or ROOMS_DIR}')
+    ok = err = 0
+    import time; t0 = time.time()
+    for i, did in enumerate(ids, 1):
+        try:
+            p = composite_one(did, out_dir=args.out)
+            print(f'  [{i}/{len(ids)}] #{did} → {p.name}'); ok += 1
+        except Exception as e:
+            print(f'  [{i}/{len(ids)}] #{did} ✗ {e}'); err += 1
+    print(f'[done] ok={ok} err={err} · {time.time()-t0:.1f}s')
+
+if __name__ == '__main__':
+    main()

← e70294d Add colorway sub-category room-mockup driver (1395 living_ro  ·  back to Wallco Ai  ·  Mural rooms upgrade plan: prod-ship (SSH-gated) + photoreal 007b79b →