Files
sephiria_inv_program/sephiria_inv/renderer.py
tkrmagid 3cb8140cfa Sephiria inventory optimizer v0.1.0
- Slab catalog and effect handlers ported from WhiteDog1004/sephiria
- Hill-climbing solver maximizes effect sum on slab-occupied cells
- PIL renderer outputs PNG with effects overlay; downloads + caches slab
  images from img.sephiria.wiki on demand
- Tkinter GUI for picking slabs by tier; CLI also available
- Screenshot recognizer (template matching, beta)
- build.bat / build.sh for portable single-file builds via PyInstaller
2026-05-13 22:12:49 +09:00

223 lines
6.6 KiB
Python

"""Render a Solution to a PNG image.
The renderer draws a grid mirroring the in-game inventory: each cell is a
fixed-size square; cells with slabs show the slab image (downloaded from the
CDN on first run and cached locally). Effect numbers from `compute_effects`
are overlaid on each cell.
"""
from __future__ import annotations
import os
from io import BytesIO
from typing import Dict, Optional
from PIL import Image, ImageDraw, ImageFont
from .slabs import (
GRID_COLS,
Placement,
SLABS_BY_VALUE,
compute_effects,
)
from .solver import Solution
CACHE_DIR = os.path.join(
os.environ.get("LOCALAPPDATA")
or os.environ.get("XDG_CACHE_HOME")
or os.path.expanduser("~/.cache"),
"sephiria_inv",
"slabs",
)
CDN_BASE = "https://img.sephiria.wiki"
CELL = 96 # pixels per slot
PAD = 12
HEADER = 56
FOOTER = 40
BG_COLOR = (29, 16, 27)
CELL_BG = (50, 30, 50)
CELL_BORDER = (88, 60, 90)
POS_COLOR = (132, 220, 132)
NEG_COLOR = (240, 110, 110)
ZERO_COLOR = (160, 160, 160)
IGNORE_COLOR = (160, 160, 160)
TEXT_COLOR = (236, 226, 232)
# ---------------------------------------------------------------------------
# Asset cache
# ---------------------------------------------------------------------------
def _ensure_cache_dir() -> None:
os.makedirs(CACHE_DIR, exist_ok=True)
def _local_path(slab_image: str) -> str:
# slab.image is like "slabs/foo.png"; flatten to filename
return os.path.join(CACHE_DIR, os.path.basename(slab_image))
def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Return a PIL Image for the slab, downloading + caching if needed.
Returns None if download fails — caller draws a placeholder.
"""
_ensure_cache_dir()
path = _local_path(slab_image)
if os.path.exists(path):
try:
return Image.open(path).convert("RGBA")
except Exception:
pass
try:
import requests # lazy import; allow renderer use without network if cached
url = f"{CDN_BASE}/{slab_image.lstrip('/')}"
r = requests.get(url, timeout=timeout)
if r.status_code != 200:
return None
with open(path, "wb") as f:
f.write(r.content)
return Image.open(BytesIO(r.content)).convert("RGBA")
except Exception:
return None
# ---------------------------------------------------------------------------
# Font
# ---------------------------------------------------------------------------
def _load_font(size: int) -> ImageFont.ImageFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\malgun.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
for c in candidates:
if os.path.exists(c):
try:
return ImageFont.truetype(c, size)
except Exception:
continue
return ImageFont.load_default()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def render_solution(
sol: Solution,
*,
download: bool = True,
title: Optional[str] = None,
) -> Image.Image:
"""Render the solution to a PIL Image."""
grid = sol.grid
rows = len(grid)
width = PAD * 2 + GRID_COLS * CELL
height = PAD * 2 + HEADER + rows * CELL + FOOTER
img = Image.new("RGB", (width, height), BG_COLOR)
draw = ImageDraw.Draw(img)
title_font = _load_font(22)
cell_font = _load_font(20)
eff_font = _load_font(28)
foot_font = _load_font(16)
# Title
txt = title or "Sephiria Inventory Optimizer"
draw.text((PAD, PAD), txt, fill=TEXT_COLOR, font=title_font)
draw.text(
(PAD, PAD + 26),
f"slots {sol.slot_num} · placed {len(sol.placements)} · score {sol.score}",
fill=(180, 180, 180),
font=foot_font,
)
# Effects map
effects, flags = compute_effects(sol.placements, grid)
by_slot: Dict[str, Placement] = {p.slot_id: p for p in sol.placements}
grid_top = PAD + HEADER
for row_cfg in grid:
y = row_cfg["rows"]
for x in range(row_cfg["cols"]):
cx0 = PAD + x * CELL
cy0 = grid_top + y * CELL
cx1 = cx0 + CELL - 4
cy1 = cy0 + CELL - 4
# cell background
draw.rounded_rectangle(
(cx0, cy0, cx1, cy1), radius=8, fill=CELL_BG, outline=CELL_BORDER
)
slot_id = f"{y}-{x}"
p = by_slot.get(slot_id)
if p is not None:
slab = SLABS_BY_VALUE.get(p.value)
placed = False
if slab is not None and download:
img_s = fetch_slab_image(slab.image)
if img_s is not None:
img_s = img_s.resize((CELL - 16, CELL - 16))
if p.rotation:
# PIL rotates counter-clockwise; sephiria rotation
# is 90°-CW per step.
img_s = img_s.rotate(-90 * p.rotation, expand=False)
img.paste(img_s, (cx0 + 6, cy0 + 6), img_s)
placed = True
if not placed and slab is not None:
# text fallback
draw.text(
(cx0 + 6, cy0 + 6),
slab.ko_label,
fill=TEXT_COLOR,
font=cell_font,
)
# effect overlay (top-right)
v = effects.get(slot_id, 0)
if flags.get(slot_id) == "ignore":
label = ""
color = IGNORE_COLOR
else:
label = f"{v:+d}" if v else "0"
color = (
POS_COLOR if v > 0 else NEG_COLOR if v < 0 else ZERO_COLOR
)
tw = draw.textlength(label, font=eff_font)
draw.text(
(cx1 - tw - 6, cy0 + 4),
label,
fill=color,
font=eff_font,
)
# Footer
draw.text(
(PAD, height - FOOTER + 8),
"Generated by sephiria_inv_program · data from img.sephiria.wiki",
fill=(120, 120, 120),
font=foot_font,
)
return img
def save_solution(sol: Solution, out_path: str, **kwargs) -> str:
img = render_solution(sol, **kwargs)
img.save(out_path)
return out_path