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
This commit is contained in:
222
sephiria_inv/renderer.py
Normal file
222
sephiria_inv/renderer.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user