"""Procedural pixel-art sprites for plants and the app favicon. Each sprite is built from a 16x16 grid: a 10-row plant "archetype" stacked on top of a 6-row pot. Rows are authored as 8-character halves and mirrored (`half + half[::-1]`) to get a symmetric 16-wide row, then the whole grid is upscaled with nearest-neighbour resizing for a crisp pixel-art look. """ import functools from pathlib import Path import pandas as pd from PIL import Image GROWTH_CSV = "data/growth_csv/growth_ds.csv" STATIC_DIR = Path("static") SPRITES_DIR = STATIC_DIR / "sprites" FAVICON_PATH = STATIC_DIR / "favicon.png" # ── Palette ────────────────────────────────────────────────────────────────── PALETTE = { ".": (0, 0, 0, 0), "G": (46, 125, 50, 255), # dark leaf green "g": (102, 187, 106, 255), # mid green "l": (197, 225, 165, 255), # light green / highlight "y": (255, 213, 79, 255), # yellow flower "o": (255, 152, 0, 255), # orange flower center "t": (121, 85, 72, 255), # trunk brown "S": (109, 76, 65, 255), # soil } POT_STYLES = { "terracotta": {"P": (216, 124, 90, 255), "Q": (173, 96, 68, 255), "R": (235, 165, 138, 255)}, "white": {"P": (245, 245, 245, 255), "Q": (210, 210, 210, 255), "R": (255, 255, 255, 255)}, "ceramic_blue": {"P": (84, 153, 199, 255), "Q": (55, 109, 148, 255), "R": (165, 214, 237, 255)}, "charcoal": {"P": (74, 74, 74, 255), "Q": (45, 45, 45, 255), "R": (115, 115, 115, 255)}, } POT_NAMES = list(POT_STYLES.keys()) # ── Plant archetypes (10 half-rows, 8 chars each) ─────────────────────────── PLANT_SPRITES = { "cactus": [ "........", "........", "......gg", "......gG", "..gg..gG", "..gG..gG", "..gg..gG", "......gG", "......gg", "......gl", ], "succulent": [ "........", ".......l", "......gl", ".....Ggl", "....gGgl", "...ggGgl", "..gggGgl", ".ggggGgl", "ggggggGl", "gggggggl", ], "fern": [ "........", "...gGg..", "..glGgl.", ".gGglGg.", "gGlgGgl.", "gglGglg.", ".gGlGg..", "...g.g..", "....g...", "....gg..", ], "flower": [ "......y.", ".....yoy", "......y.", ".......g", "..g....g", ".gG....g", "..g....g", ".......g", ".......g", "......gg", ], "palm": [ "..l...l.", ".gg...gg", "..gGGg..", "...g.g..", ".......t", ".......t", ".......t", ".......t", "......tt", "......tt", ], "trailing": [ "........", ".gGggGl.", "gGggGgl.", "Gggggl..", "gl......", "Gl......", "gl......", "Gl......", "gl......", "gl......", ], } ARCHETYPES = list(PLANT_SPRITES.keys()) # ── Pot (6 half-rows, 8 chars each) ───────────────────────────────────────── POT_BASE = [ "...SSSSS", "..RPPPPP", "..QPPPPP", "...QPPPP", "....QPPP", ".....QPP", ] # ── Genus -> (archetype, pot) mapping ─────────────────────────────────────── @functools.lru_cache(maxsize=1) def _growth_table() -> pd.DataFrame: return pd.read_csv(GROWTH_CSV) def _stable_hash(text: str) -> int: """Deterministic hash (stable across runs, unlike builtin hash() for str).""" h = 0 for ch in text: h = (h * 31 + ord(ch)) & 0xFFFFFFFF return h def genus_to_sprite_key(genus: str) -> tuple[str, str]: """Map a genus to a deterministic (plant archetype, pot style) pair.""" df = _growth_table() row = df[df["Genus"] == genus] if row.empty: h = _stable_hash(genus) archetype = ARCHETYPES[h % len(ARCHETYPES)] pot = POT_NAMES[(h // len(ARCHETYPES)) % len(POT_NAMES)] return archetype, pot growth = str(row["Growth"].iloc[0]).lower() soil = str(row["Soil"].iloc[0]).lower() sunlight = str(row["Sunlight"].iloc[0]).lower() if "sandy" in soil and "full" in sunlight: archetype = "cactus" if growth == "slow" else "succulent" elif "indirect" in sunlight and growth in ("slow", "moderate"): archetype = "fern" if ("well-drained" in soil or "loamy" in soil) else "trailing" elif growth == "fast" and "full" in sunlight: archetype = "flower" elif growth == "slow" and "well-drained" in soil: archetype = "palm" else: archetype = "fern" pot = {"sandy": "terracotta", "well-drained": "white", "loamy": "charcoal"}.get(soil, "ceramic_blue") return archetype, pot # ── Rendering ──────────────────────────────────────────────────────────────── def _build_image(half_rows: list[str], palette: dict) -> Image.Image: rows = [half + half[::-1] for half in half_rows] size = len(rows) img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) for y, row in enumerate(rows): for x, ch in enumerate(row): img.putpixel((x, y), palette.get(ch, (0, 0, 0, 0))) return img def render_sprite(genus: str, scale: int = 8) -> Image.Image: """Render the pixel-art (plant + pot) sprite for a genus.""" archetype, pot_style = genus_to_sprite_key(genus) half_rows = PLANT_SPRITES[archetype] + POT_BASE palette = {**PALETTE, **POT_STYLES[pot_style]} img = _build_image(half_rows, palette) return img.resize((img.width * scale, img.height * scale), Image.NEAREST) def _slugify(genus: str) -> str: return "".join(ch.lower() if ch.isalnum() else "_" for ch in genus) def get_sprite_path(genus: str) -> str: """Return the path to the (lazily rendered + cached) sprite for a genus.""" SPRITES_DIR.mkdir(parents=True, exist_ok=True) path = SPRITES_DIR / f"{_slugify(genus)}.png" if not path.exists(): render_sprite(genus).save(path) return str(path) def ensure_favicon() -> str: """Return the path to the (lazily rendered + cached) app favicon.""" STATIC_DIR.mkdir(parents=True, exist_ok=True) if not FAVICON_PATH.exists(): half_rows = PLANT_SPRITES["fern"] + POT_BASE palette = {**PALETTE, **POT_STYLES["terracotta"]} img = _build_image(half_rows, palette) img = img.resize((img.width * 4, img.height * 4), Image.NEAREST) img.save(FAVICON_PATH) return str(FAVICON_PATH)