← 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
A scripts/pil-mural-composite.py
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 →