Files
sephiria_inv_program/sephiria_inv/renderer.py
Claude 2e23ad5d2f v0.3.0: game-window picker + NCC recognition + artifacts + ?-merged
- window_capture.py: enumerate top-level windows (pygetwindow) and
  capture a specific one via PrintWindow PW_RENDERFULLCONTENT (works
  on non-focused windows). Linux falls back to mss region grab.
- recognizer.py: replace MAE matcher with NCC over numpy vectors.
  Each rotatable slab generates 4 templates (0/90/180/270). Adds 248
  artifact templates and an empty-cell heuristic (low mean/std-dev).
  Cells below confidence floor are tagged "unknown" — likely merged
  "?" boxes.
- gui.py: new ScreenshotFrame with [게임 창 선택] button → window
  picker dialog → bbox crop → recognize → editable preview grid with
  per-cell CellEditor that handles slab / artifact / merged(?) / empty.
  Merged cells let user pick which two slabs got combined + a level.
- artifacts.py + bundled _artifacts.json (248 entries from
  WhiteDog1004/sephiria) for matching and rendering.
- renderer.py: factored CDN fetch into _fetch_image; added
  fetch_artifact_image().
- requirements.txt: + numpy, pygetwindow (Win), pywin32 (Win).
- docker-build-cmd.sh: upgrade PyInstaller to 5.x inside cdrx
  container so numpy DLL manifest reads work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:36:49 +09:00

229 lines
7.0 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_image(rel_or_url: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Fetch an image from the CDN. Accepts a full URL or a 'slabs/foo.png' path."""
_ensure_cache_dir()
path = _local_path(rel_or_url)
if os.path.exists(path):
try:
return Image.open(path).convert("RGBA")
except Exception:
pass
try:
import requests
url = rel_or_url if rel_or_url.startswith("http") else f"{CDN_BASE}/{rel_or_url.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
def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Return a PIL Image for the slab. Caches under CACHE_DIR."""
return _fetch_image(slab_image, timeout=timeout)
def fetch_artifact_image(url: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Return a PIL Image for an artifact (full URL from artifacts.json)."""
return _fetch_image(url, timeout=timeout)
# ---------------------------------------------------------------------------
# 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