Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee82b161eb | ||
|
|
3c17405a6b | ||
|
|
915b5c9f45 | ||
|
|
a70499edfa | ||
|
|
1f2024e85f | ||
|
|
2e23ad5d2f |
14
docker-build-cmd.sh
Executable file
14
docker-build-cmd.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd /src
|
||||||
|
pip install --upgrade 'pyinstaller<6' 2>&1 | tail -3
|
||||||
|
pyinstaller --clean -y --onefile --noconsole --name sephiria_inv \
|
||||||
|
--add-data 'sephiria_inv/_artifacts.json;sephiria_inv' \
|
||||||
|
--hidden-import sephiria_inv --hidden-import sephiria_inv.gui \
|
||||||
|
--hidden-import sephiria_inv.recognizer --hidden-import sephiria_inv.artifacts \
|
||||||
|
--hidden-import sephiria_inv.window_capture --hidden-import sephiria_inv.capture \
|
||||||
|
--hidden-import sephiria_inv.screenshot --hidden-import sephiria_inv.slabs \
|
||||||
|
--hidden-import sephiria_inv.solver --hidden-import sephiria_inv.renderer \
|
||||||
|
--hidden-import mss --hidden-import mss.windows --hidden-import numpy \
|
||||||
|
--hidden-import pygetwindow --hidden-import PIL.ImageTk \
|
||||||
|
-p . run.py
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
Pillow>=9.0
|
Pillow>=9.0
|
||||||
requests>=2.25
|
requests>=2.25
|
||||||
mss>=6.0
|
mss>=6.0
|
||||||
|
numpy>=1.21,<2.0
|
||||||
|
pygetwindow>=0.0.9 ; sys_platform == "win32"
|
||||||
|
pywin32>=305 ; sys_platform == "win32"
|
||||||
|
|||||||
22
run-debug.bat
Normal file
22
run-debug.bat
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
@echo off
|
||||||
|
REM Run the exe with output captured. Window does not auto-close.
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
echo Running sephiria_inv.exe ...
|
||||||
|
echo.
|
||||||
|
sephiria_inv.exe > sephiria_inv_console.log 2>&1
|
||||||
|
set EC=%errorlevel%
|
||||||
|
echo.
|
||||||
|
echo === sephiria_inv.exe exited with code %EC% ===
|
||||||
|
echo.
|
||||||
|
echo --- sephiria_inv_console.log ---
|
||||||
|
if exist sephiria_inv_console.log type sephiria_inv_console.log
|
||||||
|
echo --- end console.log ---
|
||||||
|
echo.
|
||||||
|
echo --- sephiria_inv_startup.log ---
|
||||||
|
if exist sephiria_inv_startup.log type sephiria_inv_startup.log
|
||||||
|
echo --- end startup.log ---
|
||||||
|
echo.
|
||||||
|
echo Press any key to close this window.
|
||||||
|
pause > nul
|
||||||
|
endlocal
|
||||||
116
run.py
116
run.py
@@ -1,6 +1,116 @@
|
|||||||
"""PyInstaller entry shim."""
|
"""PyInstaller entry shim with aggressive startup diagnostics.
|
||||||
|
|
||||||
|
Writes a log entry the moment this module is loaded (before any project
|
||||||
|
imports). Logs to two locations:
|
||||||
|
1. Next to the exe (sys.executable directory) -- usually the user's
|
||||||
|
Downloads folder; trivially findable.
|
||||||
|
2. %LOCALAPPDATA%/sephiria_inv/startup.log
|
||||||
|
|
||||||
|
Either log existing tells us Python started. Neither existing means the
|
||||||
|
PyInstaller bootloader died before Python ran (DLL/runtime issue).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_log_paths():
|
||||||
|
paths = []
|
||||||
|
# 1. Next to the exe (most discoverable for the user)
|
||||||
|
try:
|
||||||
|
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||||
|
if exe_dir:
|
||||||
|
paths.append(os.path.join(exe_dir, "sephiria_inv_startup.log"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 2. LOCALAPPDATA / home
|
||||||
|
try:
|
||||||
|
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||||
|
folder = os.path.join(base, "sephiria_inv")
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
paths.append(os.path.join(folder, "startup.log"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _write_log(msg: str) -> list:
|
||||||
|
written = []
|
||||||
|
stamp = datetime.now().isoformat()
|
||||||
|
for path in _candidate_log_paths():
|
||||||
|
try:
|
||||||
|
with open(path, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(f"\n=== {stamp} ===\n{msg}\n")
|
||||||
|
written.append(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def _env_dump() -> str:
|
||||||
|
lines = [
|
||||||
|
f"python: {sys.version}",
|
||||||
|
f"executable: {sys.executable}",
|
||||||
|
f"argv: {sys.argv}",
|
||||||
|
f"frozen: {getattr(sys, 'frozen', False)}",
|
||||||
|
f"_MEIPASS: {getattr(sys, '_MEIPASS', '<none>')}",
|
||||||
|
f"cwd: {os.getcwd()}",
|
||||||
|
f"PATH head: {os.environ.get('PATH', '')[:200]}",
|
||||||
|
f"LOCALAPPDATA: {os.environ.get('LOCALAPPDATA', '<none>')}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# === MODULE-IMPORT BREADCRUMB ===
|
||||||
|
# This runs the moment Python loads the entry script. If this never appears
|
||||||
|
# in either log, the PyInstaller bootloader failed before Python started.
|
||||||
|
_BOOT_LOGS = _write_log("BOOT: run.py loaded\n" + _env_dump())
|
||||||
|
|
||||||
|
|
||||||
|
def _show_error(title: str, body: str) -> None:
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import scrolledtext
|
||||||
|
root = tk.Tk()
|
||||||
|
root.title(title)
|
||||||
|
root.geometry("780x460")
|
||||||
|
txt = scrolledtext.ScrolledText(root, wrap="word")
|
||||||
|
txt.pack(fill="both", expand=True)
|
||||||
|
txt.insert("1.0", body)
|
||||||
|
tk.Button(root, text="Close", command=root.destroy).pack(pady=4)
|
||||||
|
root.mainloop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _main() -> int:
|
||||||
|
try:
|
||||||
|
from sephiria_inv.__main__ import main
|
||||||
|
except BaseException:
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
logs = _write_log("IMPORT FAIL\n" + tb + "\n--env--\n" + _env_dump())
|
||||||
|
_show_error(
|
||||||
|
"sephiria_inv: import failed",
|
||||||
|
f"Failed to import sephiria_inv.__main__\n\nLogs: {logs}\n\n{tb}",
|
||||||
|
)
|
||||||
|
return 11
|
||||||
|
try:
|
||||||
|
return int(main() or 0)
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except BaseException:
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
logs = _write_log("RUNTIME FAIL\n" + tb)
|
||||||
|
_show_error(
|
||||||
|
"sephiria_inv: runtime error",
|
||||||
|
f"Crashed during main()\n\nLogs: {logs}\n\n{tb}",
|
||||||
|
)
|
||||||
|
return 12
|
||||||
|
|
||||||
from sephiria_inv.__main__ import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(_main())
|
||||||
|
|||||||
3901
sephiria_inv/_artifacts.json
Normal file
3901
sephiria_inv/_artifacts.json
Normal file
File diff suppressed because it is too large
Load Diff
58
sephiria_inv/artifacts.py
Normal file
58
sephiria_inv/artifacts.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Artifact catalog parsed from WhiteDog1004/sephiria's artifacts.json.
|
||||||
|
|
||||||
|
Each entry exposes the fields we actually need for icon matching and
|
||||||
|
rendering: value (canonical key), Korean label, tier, and the CDN image URL.
|
||||||
|
The full effect text / description is kept opaque — the matcher only cares
|
||||||
|
about the image, and effects are not applied to the optimizer (slab effects
|
||||||
|
only, per existing design).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib import resources
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Artifact:
|
||||||
|
value: str
|
||||||
|
ko_label: str
|
||||||
|
eng_label: str
|
||||||
|
tier: str # common / advanced / rare / legend / solid
|
||||||
|
image: str # CDN URL
|
||||||
|
level: int # max level (0 = unique / non-leveling)
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> List[Artifact]:
|
||||||
|
try:
|
||||||
|
text = resources.files(__package__).joinpath("_artifacts.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
data = json.loads(text)
|
||||||
|
out: List[Artifact] = []
|
||||||
|
for row in data:
|
||||||
|
if row.get("disabled"):
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
Artifact(
|
||||||
|
value=row["value"],
|
||||||
|
ko_label=row.get("label_kor") or row["value"],
|
||||||
|
eng_label=row.get("label_eng") or row["value"],
|
||||||
|
tier=row.get("tier") or "common",
|
||||||
|
image=row.get("image") or "",
|
||||||
|
level=int(row.get("level") or 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
ARTIFACTS: List[Artifact] = _load()
|
||||||
|
ARTIFACTS_BY_VALUE: Dict[str, Artifact] = {a.value: a for a in ARTIFACTS}
|
||||||
|
|
||||||
|
|
||||||
|
def get(value: str) -> Optional[Artifact]:
|
||||||
|
return ARTIFACTS_BY_VALUE.get(value)
|
||||||
File diff suppressed because it is too large
Load Diff
318
sephiria_inv/recognizer.py
Normal file
318
sephiria_inv/recognizer.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
"""Cell-level recognition over the inventory grid.
|
||||||
|
|
||||||
|
Pipeline given a cropped inventory image:
|
||||||
|
1. Slice into 6-col rows per generate_grid_config().
|
||||||
|
2. Per cell, classify: empty / slab / artifact / unknown.
|
||||||
|
- "empty" = low std-dev / dark uniform pixels
|
||||||
|
- "slab" = best NCC match across all slabs × 4 rotations
|
||||||
|
- "artifact"= best NCC match across all artifacts (no rotation)
|
||||||
|
- "unknown" = nothing matched above the confidence floor →
|
||||||
|
likely a merged "?" slab box, surfaced to the user.
|
||||||
|
|
||||||
|
NCC (normalized cross-correlation) is used instead of MAE because it's
|
||||||
|
invariant to brightness/contrast shifts — the in-game render has subtle
|
||||||
|
shader effects (bloom, vignette) that MAE penalizes harshly.
|
||||||
|
|
||||||
|
Templates are fetched via renderer.fetch_slab_image / fetch_artifact_image
|
||||||
|
on first call and cached on disk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .artifacts import ARTIFACTS
|
||||||
|
from .renderer import fetch_slab_image, fetch_artifact_image
|
||||||
|
from .slabs import GRID_COLS, SLABS, SLABS_BY_VALUE, generate_grid_config
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- types ----------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CellResult:
|
||||||
|
slot_id: str # "<row>-<col>"
|
||||||
|
row: int
|
||||||
|
col: int
|
||||||
|
kind: str # "empty" | "slab" | "artifact" | "unknown"
|
||||||
|
value: Optional[str] # slab/artifact value, or None
|
||||||
|
rotation: int # 0/1/2/3 for slabs; 0 otherwise
|
||||||
|
score: float # NCC in [-1, 1] — higher is better
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- template prep ----------
|
||||||
|
|
||||||
|
_TEMPLATE_SIZE = 64 # work at 64x64 — small enough to be fast, big enough to discriminate
|
||||||
|
|
||||||
|
|
||||||
|
def _on_dark(img: Image.Image) -> Image.Image:
|
||||||
|
"""Composite a possibly-transparent template onto a dark bag-slot color."""
|
||||||
|
if img.mode != "RGBA":
|
||||||
|
return img.convert("RGB")
|
||||||
|
bg = Image.new("RGBA", img.size, (38, 22, 42, 255))
|
||||||
|
bg.alpha_composite(img)
|
||||||
|
return bg.convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_feat(img: Image.Image) -> np.ndarray:
|
||||||
|
"""Resize to fixed size, grayscale, mean-subtract, unit-normalize. Returns 1-D float vector."""
|
||||||
|
g = img.convert("L").resize((_TEMPLATE_SIZE, _TEMPLATE_SIZE), Image.BILINEAR)
|
||||||
|
a = np.asarray(g, dtype=np.float32).reshape(-1)
|
||||||
|
a = a - a.mean()
|
||||||
|
n = np.linalg.norm(a)
|
||||||
|
if n < 1e-6:
|
||||||
|
return a # all zeros — uniform cell
|
||||||
|
return a / n
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Template:
|
||||||
|
kind: str # "slab" | "artifact"
|
||||||
|
value: str
|
||||||
|
rotation: int # for slabs
|
||||||
|
feat: np.ndarray
|
||||||
|
|
||||||
|
|
||||||
|
_TEMPLATE_CACHE: List[_Template] = []
|
||||||
|
_CACHE_BUILT = False
|
||||||
|
|
||||||
|
|
||||||
|
_LAST_LOAD_STATS: Dict[str, int] = {"slabs_ok": 0, "slabs_fail": 0,
|
||||||
|
"artifacts_ok": 0, "artifacts_fail": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_templates(*, include_artifacts: bool = True) -> List[_Template]:
|
||||||
|
"""Build (and cache) the full template list. Lazy because download is slow."""
|
||||||
|
global _CACHE_BUILT
|
||||||
|
if _CACHE_BUILT and _TEMPLATE_CACHE:
|
||||||
|
return _TEMPLATE_CACHE
|
||||||
|
out: List[_Template] = []
|
||||||
|
s_ok = s_fail = a_ok = a_fail = 0
|
||||||
|
# Slabs: 4 rotations for rotatable, 1 otherwise
|
||||||
|
for s in SLABS:
|
||||||
|
img = fetch_slab_image(s.image)
|
||||||
|
if img is None:
|
||||||
|
s_fail += 1
|
||||||
|
continue
|
||||||
|
s_ok += 1
|
||||||
|
base = _on_dark(img)
|
||||||
|
rotations = (0, 1, 2, 3) if s.rotate else (0,)
|
||||||
|
for r in rotations:
|
||||||
|
rotated = base if r == 0 else base.rotate(-90 * r, expand=False)
|
||||||
|
out.append(_Template("slab", s.value, r, _to_feat(rotated)))
|
||||||
|
if include_artifacts:
|
||||||
|
for a in ARTIFACTS:
|
||||||
|
img = fetch_artifact_image(a.image)
|
||||||
|
if img is None:
|
||||||
|
a_fail += 1
|
||||||
|
continue
|
||||||
|
a_ok += 1
|
||||||
|
base = _on_dark(img)
|
||||||
|
out.append(_Template("artifact", a.value, 0, _to_feat(base)))
|
||||||
|
_LAST_LOAD_STATS.update({"slabs_ok": s_ok, "slabs_fail": s_fail,
|
||||||
|
"artifacts_ok": a_ok, "artifacts_fail": a_fail})
|
||||||
|
_TEMPLATE_CACHE.clear()
|
||||||
|
_TEMPLATE_CACHE.extend(out)
|
||||||
|
_CACHE_BUILT = True
|
||||||
|
return _TEMPLATE_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
def warm_templates(*, include_artifacts: bool = True) -> int:
|
||||||
|
"""Force-download all icons. Returns total template count.
|
||||||
|
|
||||||
|
Call once from GUI before recognition to avoid stalls per cell.
|
||||||
|
"""
|
||||||
|
return len(_build_templates(include_artifacts=include_artifacts))
|
||||||
|
|
||||||
|
|
||||||
|
def load_stats() -> Dict[str, int]:
|
||||||
|
"""Return last template load counts: slabs_ok, slabs_fail, artifacts_ok, artifacts_fail."""
|
||||||
|
return dict(_LAST_LOAD_STATS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- cell classification ----------
|
||||||
|
|
||||||
|
def _is_empty(cell: Image.Image) -> bool:
|
||||||
|
"""Heuristic: empty slots are uniform color (any brightness).
|
||||||
|
|
||||||
|
Drops the dark-only assumption so HDR / bright-monitor captures with
|
||||||
|
pinkish slot backgrounds still detect as empty. Uniformity is the
|
||||||
|
actual invariant — empty slots have low std-dev whatever the hue.
|
||||||
|
"""
|
||||||
|
g = np.asarray(cell.convert("L"), dtype=np.float32)
|
||||||
|
rgb = np.asarray(cell.convert("RGB"), dtype=np.float32)
|
||||||
|
chan_std = float(rgb.reshape(-1, 3).std(axis=0).mean())
|
||||||
|
return bool(g.std() < 18.0 and chan_std < 22.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _inset(cell: Image.Image, ratio: float = 0.16) -> Image.Image:
|
||||||
|
"""Trim decorative borders / corner badges before template matching.
|
||||||
|
|
||||||
|
The in-game slot has chunky frame ornaments and a stack-count badge in
|
||||||
|
a corner. Templates are clean icons. Cropping ~16% off every side
|
||||||
|
aligns the comparable inner art and removes the badge area in most
|
||||||
|
games.
|
||||||
|
"""
|
||||||
|
w, h = cell.size
|
||||||
|
dx = int(w * ratio)
|
||||||
|
dy = int(h * ratio)
|
||||||
|
return cell.crop((dx, dy, w - dx, h - dy))
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(
|
||||||
|
cell: Image.Image,
|
||||||
|
templates: List[_Template],
|
||||||
|
*,
|
||||||
|
min_score: float = 0.35,
|
||||||
|
) -> Tuple[str, Optional[str], int, float]:
|
||||||
|
"""Return (kind, value, rotation, score)."""
|
||||||
|
if _is_empty(cell):
|
||||||
|
return "empty", None, 0, 1.0
|
||||||
|
inner = _inset(cell)
|
||||||
|
feat = _to_feat(inner)
|
||||||
|
# Stack template features into a matrix for one big dot-product
|
||||||
|
if not templates:
|
||||||
|
return "unknown", None, 0, 0.0
|
||||||
|
M = np.stack([t.feat for t in templates], axis=0) # (N, D)
|
||||||
|
scores = M @ feat # NCC since both are mean-subtracted unit norm
|
||||||
|
idx = int(np.argmax(scores))
|
||||||
|
best = float(scores[idx])
|
||||||
|
if best < min_score:
|
||||||
|
return "unknown", None, 0, best
|
||||||
|
t = templates[idx]
|
||||||
|
return t.kind, t.value, t.rotation, best
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_with_top(
|
||||||
|
cell: Image.Image,
|
||||||
|
templates: List[_Template],
|
||||||
|
*,
|
||||||
|
top_k: int = 3,
|
||||||
|
) -> Tuple[str, Optional[str], int, float, List[Tuple[str, str, int, float]]]:
|
||||||
|
"""Like _classify but also returns the top-k matches for debug dumps."""
|
||||||
|
if _is_empty(cell):
|
||||||
|
return "empty", None, 0, 1.0, []
|
||||||
|
if not templates:
|
||||||
|
return "unknown", None, 0, 0.0, []
|
||||||
|
feat = _to_feat(_inset(cell))
|
||||||
|
M = np.stack([t.feat for t in templates], axis=0)
|
||||||
|
scores = M @ feat
|
||||||
|
order = np.argsort(-scores)[:top_k]
|
||||||
|
top = [(templates[i].kind, templates[i].value, templates[i].rotation,
|
||||||
|
float(scores[i])) for i in order]
|
||||||
|
kind, value, rot, score = _classify(cell, templates)
|
||||||
|
return kind, value, rot, score, top
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- public API ----------
|
||||||
|
|
||||||
|
def recognize_image(
|
||||||
|
img: Image.Image,
|
||||||
|
bbox: Tuple[int, int, int, int],
|
||||||
|
*,
|
||||||
|
slot_num: int = 34,
|
||||||
|
include_artifacts: bool = True,
|
||||||
|
min_score: float = 0.35,
|
||||||
|
) -> List[CellResult]:
|
||||||
|
"""Slice img[bbox] into a 6-col grid and classify each cell.
|
||||||
|
|
||||||
|
bbox is in source-image pixel coords.
|
||||||
|
"""
|
||||||
|
L, T, R, B = bbox
|
||||||
|
crop = img.crop((L, T, R, B)).convert("RGB")
|
||||||
|
grid = generate_grid_config(slot_num)
|
||||||
|
if not grid:
|
||||||
|
return []
|
||||||
|
rows = len(grid)
|
||||||
|
cell_w = (R - L) // GRID_COLS
|
||||||
|
cell_h = (B - T) // rows
|
||||||
|
templates = _build_templates(include_artifacts=include_artifacts)
|
||||||
|
|
||||||
|
out: List[CellResult] = []
|
||||||
|
for row in grid:
|
||||||
|
y = row["rows"]
|
||||||
|
for x in range(row["cols"]):
|
||||||
|
cx0 = x * cell_w
|
||||||
|
cy0 = y * cell_h
|
||||||
|
cell = crop.crop((cx0, cy0, cx0 + cell_w, cy0 + cell_h))
|
||||||
|
kind, value, rot, score = _classify(cell, templates, min_score=min_score)
|
||||||
|
out.append(CellResult(f"{y}-{x}", y, x, kind, value, rot, score))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dump_debug(
|
||||||
|
img: Image.Image,
|
||||||
|
bbox: Tuple[int, int, int, int],
|
||||||
|
out_dir: str,
|
||||||
|
*,
|
||||||
|
slot_num: int = 34,
|
||||||
|
include_artifacts: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Save full screenshot, bbox crop, every cell crop and a top-3 match
|
||||||
|
report to out_dir. Returns the path to the report file. Used to iterate
|
||||||
|
on recognizer tuning from real captures.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
img.save(os.path.join(out_dir, "screenshot.png"))
|
||||||
|
L, T, R, B = bbox
|
||||||
|
crop = img.crop((L, T, R, B)).convert("RGB")
|
||||||
|
crop.save(os.path.join(out_dir, "bbox_crop.png"))
|
||||||
|
grid = generate_grid_config(slot_num)
|
||||||
|
if not grid:
|
||||||
|
return out_dir
|
||||||
|
rows = len(grid)
|
||||||
|
cell_w = (R - L) // GRID_COLS
|
||||||
|
cell_h = (B - T) // rows
|
||||||
|
templates = _build_templates(include_artifacts=include_artifacts)
|
||||||
|
stats = load_stats()
|
||||||
|
lines = [
|
||||||
|
f"bbox: {bbox}",
|
||||||
|
f"grid: {len(grid)} rows x {GRID_COLS} cols, slot_num={slot_num}",
|
||||||
|
f"cell px: {cell_w} x {cell_h}",
|
||||||
|
f"templates loaded: total={len(templates)} stats={stats}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
cells_dir = os.path.join(out_dir, "cells")
|
||||||
|
os.makedirs(cells_dir, exist_ok=True)
|
||||||
|
for row in grid:
|
||||||
|
y = row["rows"]
|
||||||
|
for x in range(row["cols"]):
|
||||||
|
cx0 = x * cell_w
|
||||||
|
cy0 = y * cell_h
|
||||||
|
cell = crop.crop((cx0, cy0, cx0 + cell_w, cy0 + cell_h))
|
||||||
|
cell.save(os.path.join(cells_dir, f"{y}-{x}.png"))
|
||||||
|
kind, value, rot, score, top = _classify_with_top(cell, templates)
|
||||||
|
top_s = ", ".join(f"{k}:{v}@r{r}={s:.3f}" for k, v, r, s in top)
|
||||||
|
lines.append(
|
||||||
|
f" {y}-{x}: kind={kind} value={value} rot={rot} score={score:.3f} | top: {top_s}"
|
||||||
|
)
|
||||||
|
report = os.path.join(out_dir, "report.txt")
|
||||||
|
with open(report, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def recognize_file(
|
||||||
|
path: str,
|
||||||
|
bbox: Tuple[int, int, int, int],
|
||||||
|
*,
|
||||||
|
slot_num: int = 34,
|
||||||
|
include_artifacts: bool = True,
|
||||||
|
min_score: float = 0.55,
|
||||||
|
) -> List[CellResult]:
|
||||||
|
img = Image.open(path)
|
||||||
|
return recognize_image(
|
||||||
|
img, bbox,
|
||||||
|
slot_num=slot_num,
|
||||||
|
include_artifacts=include_artifacts,
|
||||||
|
min_score=min_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def slab_values_from(results: List[CellResult]) -> List[str]:
|
||||||
|
"""Helper: just the slab values, ignoring artifacts/empty/unknown."""
|
||||||
|
return [r.value for r in results if r.kind == "slab" and r.value]
|
||||||
@@ -62,22 +62,18 @@ def _local_path(slab_image: str) -> str:
|
|||||||
return os.path.join(CACHE_DIR, os.path.basename(slab_image))
|
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]:
|
def _fetch_image(rel_or_url: str, timeout: float = 10.0) -> Optional[Image.Image]:
|
||||||
"""Return a PIL Image for the slab, downloading + caching if needed.
|
"""Fetch an image from the CDN. Accepts a full URL or a 'slabs/foo.png' path."""
|
||||||
|
|
||||||
Returns None if download fails — caller draws a placeholder.
|
|
||||||
"""
|
|
||||||
_ensure_cache_dir()
|
_ensure_cache_dir()
|
||||||
path = _local_path(slab_image)
|
path = _local_path(rel_or_url)
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
try:
|
try:
|
||||||
return Image.open(path).convert("RGBA")
|
return Image.open(path).convert("RGBA")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
import requests # lazy import; allow renderer use without network if cached
|
import requests
|
||||||
|
url = rel_or_url if rel_or_url.startswith("http") else f"{CDN_BASE}/{rel_or_url.lstrip('/')}"
|
||||||
url = f"{CDN_BASE}/{slab_image.lstrip('/')}"
|
|
||||||
r = requests.get(url, timeout=timeout)
|
r = requests.get(url, timeout=timeout)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
return None
|
return None
|
||||||
@@ -88,6 +84,16 @@ def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.I
|
|||||||
return None
|
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
|
# Font
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,122 +1,42 @@
|
|||||||
"""Recognize slabs from a screenshot of the in-game inventory.
|
"""Backward-compatible thin wrapper over the new recognizer.
|
||||||
|
|
||||||
Approach: template matching against the cached CDN images. Given a screenshot
|
The old API exposed `Recognition` (slot_id, value, score) and `recognize()`
|
||||||
and the inventory bounding box, we divide it into a grid and compare each cell
|
returning slabs only. Existing CLI code (`__main__.py`) and tests use that
|
||||||
against every slab template (resized to the cell). Mean absolute error in RGB
|
surface, so we keep it working by delegating to recognizer.py.
|
||||||
picks the best match; cells above a threshold are treated as empty.
|
|
||||||
|
|
||||||
This is a best-effort fallback. Accuracy depends heavily on the screenshot
|
New code should call `recognizer.recognize_image()` / `recognize_file()`
|
||||||
resolution and the slab images matching the in-game render style. The CDN
|
directly for richer (kind, rotation, artifact) results.
|
||||||
images are the same pixel-art assets the game uses, so accuracy is usually
|
|
||||||
fine when the screenshot is sharp.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from PIL import Image
|
from .recognizer import recognize_file
|
||||||
|
|
||||||
from .renderer import fetch_slab_image
|
|
||||||
from .slabs import GRID_COLS, SLABS, generate_grid_config
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Recognition:
|
class Recognition:
|
||||||
slot_id: str
|
slot_id: str
|
||||||
value: Optional[str] # None = empty
|
value: Optional[str] # slab value, or None if empty/unknown/artifact
|
||||||
score: float # lower = better match
|
score: float # NCC score in [-1, 1]; higher = better
|
||||||
|
|
||||||
|
|
||||||
def _mae(a: Image.Image, b: Image.Image) -> float:
|
|
||||||
"""Mean absolute error in RGB. Both images must be the same size."""
|
|
||||||
if a.size != b.size:
|
|
||||||
b = b.resize(a.size)
|
|
||||||
a_rgb = a.convert("RGB")
|
|
||||||
b_rgb = b.convert("RGB")
|
|
||||||
pa = list(a_rgb.getdata())
|
|
||||||
pb = list(b_rgb.getdata())
|
|
||||||
n = len(pa)
|
|
||||||
if n == 0:
|
|
||||||
return 1e9
|
|
||||||
total = 0
|
|
||||||
for (ar, ag, ab), (br, bg, bb) in zip(pa, pb):
|
|
||||||
total += abs(ar - br) + abs(ag - bg) + abs(ab - bb)
|
|
||||||
return total / (n * 3)
|
|
||||||
|
|
||||||
|
|
||||||
def _alpha_composite_on_dark(img: Image.Image) -> Image.Image:
|
|
||||||
"""Slab templates are RGBA on transparent. Composite onto dark BG for fairer compare."""
|
|
||||||
if img.mode != "RGBA":
|
|
||||||
return img.convert("RGB")
|
|
||||||
bg = Image.new("RGBA", img.size, (50, 30, 50, 255))
|
|
||||||
bg.alpha_composite(img)
|
|
||||||
return bg.convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def recognize(
|
def recognize(
|
||||||
screenshot_path: str,
|
screenshot_path: str,
|
||||||
bbox: Tuple[int, int, int, int],
|
bbox: Tuple[int, int, int, int],
|
||||||
slot_num: int = 34,
|
slot_num: int = 34,
|
||||||
empty_threshold: float = 35.0,
|
empty_threshold: float = 35.0, # ignored; kept for arg-compat
|
||||||
) -> List[Recognition]:
|
) -> List[Recognition]:
|
||||||
"""Recognize slabs in the inventory area of a screenshot.
|
"""Recognize slabs in the inventory area of a screenshot (slabs only)."""
|
||||||
|
cells = recognize_file(screenshot_path, bbox, slot_num=slot_num)
|
||||||
Args:
|
out: List[Recognition] = []
|
||||||
screenshot_path: Path to the game screenshot (PNG/JPG).
|
for c in cells:
|
||||||
bbox: (left, top, right, bottom) pixel coords of the inventory grid.
|
v = c.value if c.kind == "slab" else None
|
||||||
Must enclose only the slot grid, not the surrounding UI.
|
out.append(Recognition(c.slot_id, v, c.score))
|
||||||
slot_num: Total slot count (18..60). Used to compute row layout.
|
return out
|
||||||
empty_threshold: MAE above this counts as empty.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of Recognition entries, one per slot in row-major order.
|
|
||||||
"""
|
|
||||||
img = Image.open(screenshot_path).convert("RGB")
|
|
||||||
left, top, right, bottom = bbox
|
|
||||||
img = img.crop((left, top, right, bottom))
|
|
||||||
|
|
||||||
grid = generate_grid_config(slot_num)
|
|
||||||
if not grid:
|
|
||||||
return []
|
|
||||||
rows = len(grid)
|
|
||||||
cell_w = (right - left) // GRID_COLS
|
|
||||||
cell_h = (bottom - top) // rows
|
|
||||||
template_size = (min(cell_w, cell_h), min(cell_w, cell_h))
|
|
||||||
|
|
||||||
# Pre-load and downscale templates
|
|
||||||
templates: List[Tuple[str, Image.Image]] = []
|
|
||||||
for slab in SLABS:
|
|
||||||
t = fetch_slab_image(slab.image)
|
|
||||||
if t is None:
|
|
||||||
continue
|
|
||||||
t = _alpha_composite_on_dark(t).resize(template_size)
|
|
||||||
templates.append((slab.value, t))
|
|
||||||
|
|
||||||
results: List[Recognition] = []
|
|
||||||
for row in grid:
|
|
||||||
y = row["rows"]
|
|
||||||
for x in range(row["cols"]):
|
|
||||||
cx0 = x * cell_w
|
|
||||||
cy0 = y * cell_h
|
|
||||||
cell = img.crop((cx0, cy0, cx0 + cell_w, cy0 + cell_h)).resize(template_size)
|
|
||||||
best_value: Optional[str] = None
|
|
||||||
best_score = 1e9
|
|
||||||
for v, t in templates:
|
|
||||||
s = _mae(cell, t)
|
|
||||||
if s < best_score:
|
|
||||||
best_score = s
|
|
||||||
best_value = v
|
|
||||||
if best_score > empty_threshold:
|
|
||||||
results.append(Recognition(f"{y}-{x}", None, best_score))
|
|
||||||
else:
|
|
||||||
results.append(Recognition(f"{y}-{x}", best_value, best_score))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def recognized_values(recognitions: List[Recognition]) -> List[str]:
|
def recognized_values(recognitions: List[Recognition]) -> List[str]:
|
||||||
"""Helper: extract just the non-empty slab values."""
|
|
||||||
return [r.value for r in recognitions if r.value is not None]
|
return [r.value for r in recognitions if r.value is not None]
|
||||||
|
|||||||
163
sephiria_inv/window_capture.py
Normal file
163
sephiria_inv/window_capture.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""Game window enumeration + capture.
|
||||||
|
|
||||||
|
On Windows we use pygetwindow to list visible top-level windows by title
|
||||||
|
and pywin32 (PrintWindow) to grab a single window — that works even when
|
||||||
|
the window is partially covered or not focused, which a regular mss screen
|
||||||
|
grab does not.
|
||||||
|
|
||||||
|
On non-Windows we degrade gracefully: list_windows() returns [] and
|
||||||
|
capture_window() falls back to a full-screen grab via mss. This is mostly
|
||||||
|
so the GUI imports cleanly during dev on Linux — actual gameplay
|
||||||
|
recognition is a Windows-only feature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WindowInfo:
|
||||||
|
handle: int # HWND on Windows, 0 elsewhere
|
||||||
|
title: str
|
||||||
|
left: int
|
||||||
|
top: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
|
||||||
|
def _is_windows() -> bool:
|
||||||
|
return platform.system() == "Windows"
|
||||||
|
|
||||||
|
|
||||||
|
def list_windows() -> List[WindowInfo]:
|
||||||
|
"""Return visible top-level windows with a title. Windows-only; [] otherwise."""
|
||||||
|
if not _is_windows():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
import pygetwindow as gw # type: ignore
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
out: List[WindowInfo] = []
|
||||||
|
for w in gw.getAllWindows():
|
||||||
|
try:
|
||||||
|
if not w.title:
|
||||||
|
continue
|
||||||
|
if w.width <= 50 or w.height <= 50:
|
||||||
|
continue
|
||||||
|
if not w.visible:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(WindowInfo(
|
||||||
|
handle=int(w._hWnd) if hasattr(w, "_hWnd") else 0,
|
||||||
|
title=w.title,
|
||||||
|
left=w.left, top=w.top,
|
||||||
|
width=w.width, height=w.height,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_sephiria() -> Optional[WindowInfo]:
|
||||||
|
"""Best-effort: pick the first window whose title contains 'Sephiria' / '세피리아'."""
|
||||||
|
for w in list_windows():
|
||||||
|
t = w.title.lower()
|
||||||
|
if "sephiria" in t or "세피리아" in w.title:
|
||||||
|
return w
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def capture_window(info: WindowInfo) -> Image.Image:
|
||||||
|
"""Capture a single window into a PIL RGB image.
|
||||||
|
|
||||||
|
On Windows uses PrintWindow with PW_RENDERFULLCONTENT (works on hidden
|
||||||
|
windows / behind-other-windows). On other OSes falls back to an mss
|
||||||
|
region grab of the window's bounding rectangle on the primary monitor.
|
||||||
|
"""
|
||||||
|
if _is_windows() and info.handle:
|
||||||
|
try:
|
||||||
|
return _capture_window_win(info)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback: mss region grab
|
||||||
|
return _capture_region_mss(info.left, info.top, info.width, info.height)
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_window_win(info: WindowInfo) -> Image.Image:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
hwnd = info.handle
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
gdi32 = ctypes.windll.gdi32
|
||||||
|
|
||||||
|
# Use the actual client/window rect — pygetwindow's width/height can
|
||||||
|
# include shadow/DWM regions; GetWindowRect is more reliable.
|
||||||
|
rect = wintypes.RECT()
|
||||||
|
user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||||
|
width = rect.right - rect.left
|
||||||
|
height = rect.bottom - rect.top
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise RuntimeError("window has zero size")
|
||||||
|
|
||||||
|
hwndDC = user32.GetWindowDC(hwnd)
|
||||||
|
mfcDC = gdi32.CreateCompatibleDC(hwndDC)
|
||||||
|
bitmap = gdi32.CreateCompatibleBitmap(hwndDC, width, height)
|
||||||
|
gdi32.SelectObject(mfcDC, bitmap)
|
||||||
|
|
||||||
|
PW_RENDERFULLCONTENT = 0x00000002
|
||||||
|
ok = user32.PrintWindow(hwnd, mfcDC, PW_RENDERFULLCONTENT)
|
||||||
|
|
||||||
|
# Read pixels
|
||||||
|
class BITMAPINFOHEADER(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("biSize", wintypes.DWORD),
|
||||||
|
("biWidth", ctypes.c_long),
|
||||||
|
("biHeight", ctypes.c_long),
|
||||||
|
("biPlanes", wintypes.WORD),
|
||||||
|
("biBitCount", wintypes.WORD),
|
||||||
|
("biCompression", wintypes.DWORD),
|
||||||
|
("biSizeImage", wintypes.DWORD),
|
||||||
|
("biXPelsPerMeter", ctypes.c_long),
|
||||||
|
("biYPelsPerMeter", ctypes.c_long),
|
||||||
|
("biClrUsed", wintypes.DWORD),
|
||||||
|
("biClrImportant", wintypes.DWORD),
|
||||||
|
]
|
||||||
|
|
||||||
|
class BITMAPINFO(ctypes.Structure):
|
||||||
|
_fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wintypes.DWORD * 3)]
|
||||||
|
|
||||||
|
bmi = BITMAPINFO()
|
||||||
|
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
|
||||||
|
bmi.bmiHeader.biWidth = width
|
||||||
|
bmi.bmiHeader.biHeight = -height # top-down
|
||||||
|
bmi.bmiHeader.biPlanes = 1
|
||||||
|
bmi.bmiHeader.biBitCount = 32
|
||||||
|
bmi.bmiHeader.biCompression = 0 # BI_RGB
|
||||||
|
|
||||||
|
buffer = ctypes.create_string_buffer(width * height * 4)
|
||||||
|
gdi32.GetDIBits(mfcDC, bitmap, 0, height, buffer, ctypes.byref(bmi), 0)
|
||||||
|
|
||||||
|
gdi32.DeleteObject(bitmap)
|
||||||
|
gdi32.DeleteDC(mfcDC)
|
||||||
|
user32.ReleaseDC(hwnd, hwndDC)
|
||||||
|
|
||||||
|
img = Image.frombuffer("RGBA", (width, height), buffer, "raw", "BGRA", 0, 1)
|
||||||
|
return img.convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_region_mss(left: int, top: int, width: int, height: int) -> Image.Image:
|
||||||
|
import mss
|
||||||
|
with mss.mss() as sct:
|
||||||
|
region = {"left": left, "top": top, "width": width, "height": height}
|
||||||
|
shot = sct.grab(region)
|
||||||
|
img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
|
||||||
|
return img
|
||||||
Reference in New Issue
Block a user