"""Generate clean placeholder product images for the furniture catalog. Renders one transparent-background PNG per catalog item into ``static/catalog/``. These are stand-ins for real product photography. Because they carry an alpha channel they composite cleanly onto room photos, which is what the mock image provider relies on. Run: python scripts/generate_catalog_images.py """ from __future__ import annotations import json from pathlib import Path from PIL import Image, ImageDraw, ImageFont BACKEND_DIR = Path(__file__).resolve().parent.parent CATALOG_JSON = BACKEND_DIR / "data" / "catalog.json" OUT_DIR = BACKEND_DIR / "static" / "catalog" CANVAS = 640 # A style tag maps to a base colour. The first matching tag on an item wins. PALETTE: dict[str, tuple[int, int, int]] = { "wood": (181, 137, 95), "rustic": (160, 90, 55), "luxury": (124, 92, 200), "velvet": (120, 70, 190), "leather": (139, 94, 60), "metal": (150, 158, 168), "industrial": (90, 99, 110), "glass": (150, 190, 215), "scandinavian": (210, 192, 168), "mid-century": (190, 110, 70), "modern": (110, 120, 135), "minimal": (165, 172, 182), "boho": (190, 135, 85), "gold": (198, 162, 70), "natural": (120, 150, 95), } DEFAULT_COLOR = (130, 140, 150) def pick_color(tags: list[str]) -> tuple[int, int, int]: for t in tags: if t in PALETTE: return PALETTE[t] return DEFAULT_COLOR def shade(color: tuple[int, int, int], factor: float) -> tuple[int, int, int]: return tuple(max(0, min(255, int(c * factor))) for c in color) # type: ignore[return-value] def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: for name in ("arial.ttf", "DejaVuSans.ttf", "segoeui.ttf"): try: return ImageFont.truetype(name, size) except OSError: continue return ImageFont.load_default() def contact_shadow(draw: ImageDraw.ImageDraw, cx: int, base_y: int, half_w: int) -> None: """Soft elliptical shadow under a piece to ground it in the scene.""" draw.ellipse( [cx - half_w, base_y - 18, cx + half_w, base_y + 18], fill=(0, 0, 0, 60), ) # --- per-category silhouettes ------------------------------------------------ def draw_bed(d, c, s): d.rounded_rectangle([90, 360, 560, 470], radius=18, fill=c) # mattress base d.rounded_rectangle([90, 300, 150, 470], radius=14, fill=s) # headboard d.rounded_rectangle([170, 320, 300, 380], radius=20, fill=shade(c, 1.25)) # pillow d.rounded_rectangle([320, 320, 450, 380], radius=20, fill=shade(c, 1.25)) # pillow d.rectangle([110, 470, 130, 510], fill=s) # legs d.rectangle([520, 470, 540, 510], fill=s) def draw_sofa(d, c, s): d.rounded_rectangle([110, 250, 540, 360], radius=24, fill=s) # backrest d.rounded_rectangle([110, 330, 540, 470], radius=20, fill=c) # seat base d.rounded_rectangle([110, 320, 180, 470], radius=20, fill=shade(c, 0.9)) # arm d.rounded_rectangle([470, 320, 540, 470], radius=20, fill=shade(c, 0.9)) # arm d.line([320, 340, 320, 460], fill=s, width=4) # cushion split d.rectangle([140, 470, 160, 505], fill=shade(s, 0.8)) d.rectangle([490, 470, 510, 505], fill=shade(s, 0.8)) def draw_table(d, c, s): d.rounded_rectangle([120, 300, 520, 345], radius=10, fill=c) # top for x in (150, 480): d.rectangle([x, 345, x + 18, 480], fill=s) # legs for x in (250, 380): d.rectangle([x, 345, x + 14, 480], fill=shade(s, 0.85)) def draw_chair(d, c, s): d.rounded_rectangle([250, 220, 320, 400], radius=14, fill=s) # backrest d.rounded_rectangle([240, 380, 410, 430], radius=12, fill=c) # seat for x in (250, 390): d.rectangle([x, 430, x + 16, 510], fill=shade(s, 0.85)) # legs def draw_storage(d, c, s): d.rounded_rectangle([200, 180, 440, 500], radius=10, fill=c) # body d.line([320, 180, 320, 500], fill=s, width=4) # door split for y in (260, 340, 420): # shelf hints d.line([200, y, 440, y], fill=shade(s, 0.8), width=2) d.ellipse([300, 330, 314, 344], fill=s) # knobs d.ellipse([326, 330, 340, 344], fill=s) def draw_decor(d, c, s, item_id: str): if "lamp" in item_id: d.rectangle([312, 250, 328, 500], fill=s) # pole d.polygon([(270, 250), (370, 250), (350, 180), (290, 180)], fill=c) # shade d.ellipse([270, 490, 370, 515], fill=shade(s, 0.8)) # base elif "plant" in item_id: d.polygon([(280, 500), (360, 500), (350, 410), (290, 410)], fill=shade(c, 0.7)) # pot d.ellipse([250, 250, 390, 430], fill=c) # foliage elif "rug" in item_id: d.ellipse([130, 360, 510, 470], fill=c) # flat rug d.ellipse([180, 385, 460, 445], outline=shade(c, 0.7), width=6) elif "mirror" in item_id: d.ellipse([230, 180, 410, 360], outline=s, width=18) # frame d.ellipse([248, 198, 392, 342], fill=shade(c, 1.3)) # glass elif "art" in item_id: for i, x in enumerate((180, 290, 400)): col = shade(c, 0.8 + 0.2 * i) d.rounded_rectangle([x, 240, x + 80, 380], radius=6, fill=col) else: # pouf / generic d.ellipse([230, 360, 410, 470], fill=c) d.ellipse([230, 345, 410, 420], fill=shade(c, 1.1)) def render_item(item: dict) -> None: img = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0)) d = ImageDraw.Draw(img) color = pick_color(item["style_tags"]) s = shade(color, 0.78) cat = item["category"] contact_shadow(d, CANVAS // 2, 505, 230) if cat == "bed": draw_bed(d, color, s) elif cat == "sofa": draw_sofa(d, color, s) elif cat == "table": draw_table(d, color, s) elif cat == "chair": draw_chair(d, color, s) elif cat == "storage": draw_storage(d, color, s) else: draw_decor(d, color, s, item["id"]) # label band name = item["name"] dims = item["dimensions"] label = f"{name}" sub = f'{dims["width"]}x{dims["depth"]}x{dims["height"]} cm' f1, f2 = _font(30), _font(22) d.text((CANVAS // 2, 545), label, font=f1, fill=(40, 44, 52, 255), anchor="mm") d.text((CANVAS // 2, 580), sub, font=f2, fill=(110, 116, 126, 255), anchor="mm") out = OUT_DIR / f"{item['id']}.png" img.save(out) def main() -> int: OUT_DIR.mkdir(parents=True, exist_ok=True) items = json.loads(CATALOG_JSON.read_text(encoding="utf-8")) for item in items: render_item(item) print(f"Rendered {len(items)} catalog images -> {OUT_DIR}") return 0 if __name__ == "__main__": raise SystemExit(main())