Spaces:
Running
Running
| """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()) | |