room-visualizer-api / backend /scripts /generate_catalog_images.py
Johnntirs's picture
Room Visualizer backend (Docker)
0cdb4e8
Raw
History Blame Contribute Delete
6.97 kB
"""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())