Guarden / modules /pixel_art.py
Crocolil's picture
Upload folder using huggingface_hub
4e9208d verified
Raw
History Blame Contribute Delete
6.95 kB
"""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)