Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
915b5c9f45 | ||
|
|
a70499edfa | ||
|
|
1f2024e85f | ||
|
|
2e23ad5d2f | ||
|
|
e388c965bc | ||
|
|
610e979fbb |
18
README.md
18
README.md
@@ -29,6 +29,16 @@
|
|||||||
python -m sephiria_inv
|
python -m sephiria_inv
|
||||||
```
|
```
|
||||||
|
|
||||||
|
GUI 는 두 가지 탭으로 구성됩니다.
|
||||||
|
|
||||||
|
**스크린샷 탭 (권장)**
|
||||||
|
1. 게임을 인벤토리 화면 상태로 띄워둔다
|
||||||
|
2. `화면 캡처` 버튼을 누른다 (또는 `이미지 열기…` 로 미리 찍어둔 PNG)
|
||||||
|
3. 인벤토리 격자의 좌상단/우하단을 한 번씩 클릭해 영역을 지정한다
|
||||||
|
4. 자동으로 템플릿 매칭이 돌면서 각 셀의 석판을 인식한다
|
||||||
|
5. 오인식된 셀이 있으면 클릭해서 직접 교체하고 `이 구성으로 계산` 을 누른다
|
||||||
|
|
||||||
|
**수동 선택 탭**
|
||||||
좌측 카탈로그에서 보유한 석판 옆 `+` 버튼으로 개수를 올리고
|
좌측 카탈로그에서 보유한 석판 옆 `+` 버튼으로 개수를 올리고
|
||||||
`최적 배치 계산`을 누르면 됩니다. 결과 이미지는 `이미지 저장…` 으로
|
`최적 배치 계산`을 누르면 됩니다. 결과 이미지는 `이미지 저장…` 으로
|
||||||
PNG 저장이 가능합니다.
|
PNG 저장이 가능합니다.
|
||||||
@@ -44,15 +54,15 @@ python -m sephiria_inv --cli \
|
|||||||
-s harvesting:2 -s binary_star -s thorn -s sheen -s base \
|
-s harvesting:2 -s binary_star -s thorn -s sheen -s base \
|
||||||
--slots 24 --seed 7 -o layout.png
|
--slots 24 --seed 7 -o layout.png
|
||||||
|
|
||||||
# 스크린샷에서 인식 (베타)
|
# 스크린샷에서 인식 (CLI - bbox 수동 지정)
|
||||||
python -m sephiria_inv --cli \
|
python -m sephiria_inv --cli \
|
||||||
--screenshot ./game.png --bbox 320,180,1024,720 \
|
--screenshot ./game.png --bbox 320,180,1024,720 \
|
||||||
--slots 34 -o layout.png
|
--slots 34 -o layout.png
|
||||||
```
|
```
|
||||||
|
|
||||||
`--bbox` 는 인벤토리 격자 영역의 픽셀 좌표(left,top,right,bottom)입니다.
|
CLI 에서는 `--bbox left,top,right,bottom` 으로 격자 영역을 직접 줘야 합니다.
|
||||||
정확도는 스크린샷 해상도/UI 스타일에 따라 다릅니다 — 잘못 인식되는 셀이
|
GUI 에서는 두 번 클릭으로 같은 일을 할 수 있습니다. 인식 정확도는 스크린샷
|
||||||
있으면 GUI에서 보정해 주세요.
|
해상도/UI 스타일에 따라 다르므로 잘못된 셀은 GUI 에서 클릭해 교정하세요.
|
||||||
|
|
||||||
## 포터블 EXE 빌드
|
## 포터블 EXE 빌드
|
||||||
|
|
||||||
|
|||||||
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,2 +1,6 @@
|
|||||||
Pillow>=10.0
|
Pillow>=9.0
|
||||||
requests>=2.31
|
requests>=2.25
|
||||||
|
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)
|
||||||
43
sephiria_inv/capture.py
Normal file
43
sephiria_inv/capture.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Screen capture utility.
|
||||||
|
|
||||||
|
`capture_screen()` grabs the current screen via mss and returns a PIL RGB
|
||||||
|
image. Bounding-box selection of the inventory area is done in the GUI by
|
||||||
|
two clicks (top-left + bottom-right) — far more reliable than heuristic
|
||||||
|
auto-detection across the many possible game UI scales and DPI settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GridBBox:
|
||||||
|
left: int
|
||||||
|
top: int
|
||||||
|
right: int
|
||||||
|
bottom: int
|
||||||
|
|
||||||
|
def as_tuple(self) -> Tuple[int, int, int, int]:
|
||||||
|
return (self.left, self.top, self.right, self.bottom)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_screen(monitor: int = 1) -> Image.Image:
|
||||||
|
"""Capture the full screen of the given monitor as a PIL RGB image.
|
||||||
|
|
||||||
|
monitor=0 = all monitors combined, 1 = primary on most systems. Falls
|
||||||
|
back to monitor 0 if the requested index is missing.
|
||||||
|
"""
|
||||||
|
import mss # lazy: keep mss optional for renderer/CLI-only use
|
||||||
|
|
||||||
|
with mss.mss() as sct:
|
||||||
|
try:
|
||||||
|
mon = sct.monitors[monitor]
|
||||||
|
except IndexError:
|
||||||
|
mon = sct.monitors[0]
|
||||||
|
shot = sct.grab(mon)
|
||||||
|
img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
|
||||||
|
return img
|
||||||
@@ -1,50 +1,743 @@
|
|||||||
"""Tkinter GUI for picking slabs and rendering the optimal layout.
|
"""Tkinter GUI for the Sephiria inventory optimizer.
|
||||||
|
|
||||||
Layout (left-to-right):
|
Two input modes via Notebook:
|
||||||
1. Tier-grouped slab catalog with [+] [−] count controls
|
- "게임창 캡처": list visible Windows top-level windows, pick the Sephiria
|
||||||
2. Current basket summary
|
one, capture its full client area (PrintWindow, works even when not
|
||||||
3. Slot count slider + Solve button + result preview
|
focused), click two corners of the bag area, then run the recognizer
|
||||||
|
(NCC + 4 rotations + artifacts + empty/unknown). Mis-recognized cells
|
||||||
|
can be fixed by click. Unknown ("?") cells likely correspond to merged
|
||||||
|
slab boxes — clicking one opens a popup that lets the user describe
|
||||||
|
which two slabs got merged.
|
||||||
|
- "수동 선택": existing tier-grouped +/- input.
|
||||||
|
|
||||||
The preview is rendered to a temp PNG via renderer and shown via PhotoImage.
|
Both modes feed a basket → solver → renderer pipeline shown on the right.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from PIL import Image, ImageTk
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
from .renderer import render_solution
|
from .artifacts import ARTIFACTS, ARTIFACTS_BY_VALUE
|
||||||
from .slabs import SLABS, SLABS_BY_VALUE, TIER_LABEL, TIER_ORDER
|
from .recognizer import CellResult, recognize_image, warm_templates
|
||||||
|
from .renderer import fetch_artifact_image, fetch_slab_image, render_solution
|
||||||
|
from .slabs import (
|
||||||
|
GRID_COLS,
|
||||||
|
SLABS,
|
||||||
|
SLABS_BY_VALUE,
|
||||||
|
TIER_LABEL,
|
||||||
|
TIER_ORDER,
|
||||||
|
generate_grid_config,
|
||||||
|
)
|
||||||
from .solver import solve
|
from .solver import solve
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- helpers ----------
|
||||||
|
|
||||||
|
def _slab_thumb(value: str, size: int = 48) -> Optional[ImageTk.PhotoImage]:
|
||||||
|
slab = SLABS_BY_VALUE.get(value)
|
||||||
|
if not slab:
|
||||||
|
return None
|
||||||
|
img = fetch_slab_image(slab.image)
|
||||||
|
if img is None:
|
||||||
|
return None
|
||||||
|
return ImageTk.PhotoImage(img.resize((size, size)))
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_thumb(value: str, size: int = 48) -> Optional[ImageTk.PhotoImage]:
|
||||||
|
a = ARTIFACTS_BY_VALUE.get(value)
|
||||||
|
if not a:
|
||||||
|
return None
|
||||||
|
img = fetch_artifact_image(a.image)
|
||||||
|
if img is None:
|
||||||
|
return None
|
||||||
|
return ImageTk.PhotoImage(img.resize((size, size)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- window-picker dialog ----------
|
||||||
|
|
||||||
|
class WindowPicker(tk.Toplevel):
|
||||||
|
"""List visible windows; the chosen WindowInfo ends up on .selected."""
|
||||||
|
|
||||||
|
def __init__(self, parent: tk.Tk) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title("게임 창 선택")
|
||||||
|
self.transient(parent)
|
||||||
|
self.selected = None
|
||||||
|
|
||||||
|
from .window_capture import list_windows, find_sephiria
|
||||||
|
windows = list_windows()
|
||||||
|
|
||||||
|
wrap = ttk.Frame(self, padding=8)
|
||||||
|
wrap.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
if not windows:
|
||||||
|
ttk.Label(wrap, text=(
|
||||||
|
"현재 OS 에서 창 목록을 가져올 수 없습니다.\n"
|
||||||
|
"(Windows 전용 기능 - 리눅스/맥에서는 전체 화면 캡처를 쓰세요)"
|
||||||
|
), justify="left").pack(pady=8)
|
||||||
|
ttk.Button(wrap, text="닫기", command=self.destroy).pack()
|
||||||
|
self.grab_set()
|
||||||
|
return
|
||||||
|
|
||||||
|
ttk.Label(wrap, text="아래 목록에서 세피리아 게임 창을 더블클릭하세요:") \
|
||||||
|
.pack(anchor="w")
|
||||||
|
|
||||||
|
tree_frame = ttk.Frame(wrap)
|
||||||
|
tree_frame.pack(fill="both", expand=True, pady=6)
|
||||||
|
cols = ("title", "size")
|
||||||
|
self.tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=14)
|
||||||
|
self.tree.heading("title", text="창 이름")
|
||||||
|
self.tree.heading("size", text="크기")
|
||||||
|
self.tree.column("title", width=440)
|
||||||
|
self.tree.column("size", width=120, anchor="center")
|
||||||
|
sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
|
||||||
|
self.tree.configure(yscrollcommand=sb.set)
|
||||||
|
self.tree.pack(side="left", fill="both", expand=True)
|
||||||
|
sb.pack(side="right", fill="y")
|
||||||
|
|
||||||
|
self._windows = windows
|
||||||
|
# auto-promote Sephiria-matching entries
|
||||||
|
auto = find_sephiria()
|
||||||
|
sel_idx = None
|
||||||
|
for i, w in enumerate(windows):
|
||||||
|
self.tree.insert(
|
||||||
|
"", "end", iid=str(i),
|
||||||
|
values=(w.title, f"{w.width}×{w.height}"),
|
||||||
|
)
|
||||||
|
if auto and w.handle == auto.handle:
|
||||||
|
sel_idx = i
|
||||||
|
if sel_idx is not None:
|
||||||
|
self.tree.selection_set(str(sel_idx))
|
||||||
|
self.tree.see(str(sel_idx))
|
||||||
|
|
||||||
|
self.tree.bind("<Double-Button-1>", lambda _e: self._confirm())
|
||||||
|
|
||||||
|
btns = ttk.Frame(wrap)
|
||||||
|
btns.pack(fill="x", pady=(6, 0))
|
||||||
|
ttk.Button(btns, text="선택", command=self._confirm).pack(side="left")
|
||||||
|
ttk.Button(btns, text="취소", command=self.destroy).pack(side="right")
|
||||||
|
|
||||||
|
self.grab_set()
|
||||||
|
self.focus_set()
|
||||||
|
|
||||||
|
def _confirm(self) -> None:
|
||||||
|
sel = self.tree.selection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
self.selected = self._windows[int(sel[0])]
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- crop dialog (two-click bbox) ----------
|
||||||
|
|
||||||
|
class CropDialog(tk.Toplevel):
|
||||||
|
MAX_DISPLAY = 1100
|
||||||
|
|
||||||
|
def __init__(self, parent: tk.Tk, img: Image.Image) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title("가방 영역 선택 — 좌상단과 우하단을 차례로 클릭")
|
||||||
|
self.transient(parent)
|
||||||
|
self.img_full = img
|
||||||
|
self.bbox: Optional[Tuple[int, int, int, int]] = None
|
||||||
|
self._clicks: List[Tuple[int, int]] = []
|
||||||
|
|
||||||
|
w, h = img.size
|
||||||
|
self.scale = min(1.0, self.MAX_DISPLAY / max(w, h))
|
||||||
|
disp = img.resize((int(w * self.scale), int(h * self.scale)))
|
||||||
|
self.tk_img = ImageTk.PhotoImage(disp)
|
||||||
|
|
||||||
|
ttk.Label(
|
||||||
|
self, justify="left",
|
||||||
|
text=(
|
||||||
|
"가방(인벤토리) 격자의 좌상단을 먼저 클릭하고, 우하단을 다시 클릭하세요.\n"
|
||||||
|
"그리드 안쪽 첫 칸의 좌상단 모서리와 마지막 칸의 우하단 모서리로 맞추는 게 정확합니다."
|
||||||
|
),
|
||||||
|
).pack(padx=8, pady=(8, 4))
|
||||||
|
|
||||||
|
self.canvas = tk.Canvas(
|
||||||
|
self, width=disp.size[0], height=disp.size[1], highlightthickness=0
|
||||||
|
)
|
||||||
|
self.canvas.pack(padx=8, pady=4)
|
||||||
|
self.canvas.create_image(0, 0, anchor="nw", image=self.tk_img)
|
||||||
|
self.canvas.bind("<Button-1>", self._click)
|
||||||
|
|
||||||
|
bar = ttk.Frame(self)
|
||||||
|
bar.pack(fill="x", padx=8, pady=(4, 8))
|
||||||
|
ttk.Button(bar, text="처음부터", command=self._reset).pack(side="left")
|
||||||
|
ttk.Button(bar, text="취소", command=self._cancel).pack(side="right")
|
||||||
|
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||||
|
self.grab_set()
|
||||||
|
self.focus_set()
|
||||||
|
|
||||||
|
def _click(self, e: tk.Event) -> None:
|
||||||
|
self._clicks.append((e.x, e.y))
|
||||||
|
self.canvas.create_oval(
|
||||||
|
e.x - 5, e.y - 5, e.x + 5, e.y + 5, outline="#ff5050", width=2
|
||||||
|
)
|
||||||
|
if len(self._clicks) == 2:
|
||||||
|
(x1, y1), (x2, y2) = self._clicks
|
||||||
|
lx, ty = min(x1, x2), min(y1, y2)
|
||||||
|
rx, by = max(x1, x2), max(y1, y2)
|
||||||
|
self.canvas.create_rectangle(lx, ty, rx, by, outline="#50ff80", width=2)
|
||||||
|
inv = 1.0 / self.scale
|
||||||
|
self.bbox = (int(lx * inv), int(ty * inv), int(rx * inv), int(by * inv))
|
||||||
|
self.after(120, self.destroy)
|
||||||
|
|
||||||
|
def _reset(self) -> None:
|
||||||
|
self._clicks.clear()
|
||||||
|
self.canvas.delete("all")
|
||||||
|
self.canvas.create_image(0, 0, anchor="nw", image=self.tk_img)
|
||||||
|
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.bbox = None
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- cell editor (slab / artifact / merged / empty) ----------
|
||||||
|
|
||||||
|
class CellEditor(tk.Toplevel):
|
||||||
|
"""Modal editor. Result on .result: dict {kind, value, rotation, merged}."""
|
||||||
|
|
||||||
|
def __init__(self, parent: tk.Tk, current: Optional[CellResult]) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title("셀 수정")
|
||||||
|
self.transient(parent)
|
||||||
|
self.result: Optional[dict] = None
|
||||||
|
cur_kind = current.kind if current else "empty"
|
||||||
|
cur_val = current.value if current else None
|
||||||
|
|
||||||
|
wrap = ttk.Frame(self, padding=8)
|
||||||
|
wrap.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
ttk.Label(wrap, text="이 셀의 종류:").pack(anchor="w")
|
||||||
|
self.kind_var = tk.StringVar(value=cur_kind if cur_kind in ("slab", "artifact", "empty", "merged") else "empty")
|
||||||
|
kind_row = ttk.Frame(wrap)
|
||||||
|
kind_row.pack(fill="x", pady=(0, 6))
|
||||||
|
for label, key in [("석판", "slab"), ("아티팩트", "artifact"),
|
||||||
|
("합쳐진(?)", "merged"), ("빈칸", "empty")]:
|
||||||
|
ttk.Radiobutton(kind_row, text=label, value=key,
|
||||||
|
variable=self.kind_var, command=self._render).pack(side="left", padx=2)
|
||||||
|
|
||||||
|
self.body = ttk.Frame(wrap)
|
||||||
|
self.body.pack(fill="both", expand=True)
|
||||||
|
self._slab_buttons: List[ttk.Button] = []
|
||||||
|
self._artifact_buttons: List[ttk.Button] = []
|
||||||
|
self._chosen_value: Optional[str] = cur_val
|
||||||
|
self._chosen_kind: str = self.kind_var.get()
|
||||||
|
# merged sub-state
|
||||||
|
self._merged_a = tk.StringVar(value="")
|
||||||
|
self._merged_b = tk.StringVar(value="")
|
||||||
|
self._merged_lvl = tk.IntVar(value=1)
|
||||||
|
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
bar = ttk.Frame(self)
|
||||||
|
bar.pack(fill="x", padx=8, pady=(0, 8))
|
||||||
|
ttk.Button(bar, text="확인", command=self._ok).pack(side="left")
|
||||||
|
ttk.Button(bar, text="취소", command=self.destroy).pack(side="right")
|
||||||
|
|
||||||
|
self.grab_set()
|
||||||
|
self.focus_set()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
for c in self.body.winfo_children():
|
||||||
|
c.destroy()
|
||||||
|
k = self.kind_var.get()
|
||||||
|
if k == "slab":
|
||||||
|
self._build_slab_picker()
|
||||||
|
elif k == "artifact":
|
||||||
|
self._build_artifact_picker()
|
||||||
|
elif k == "merged":
|
||||||
|
self._build_merged_picker()
|
||||||
|
else:
|
||||||
|
ttk.Label(self.body, text="이 칸은 빈칸으로 처리됩니다.").pack(pady=20)
|
||||||
|
|
||||||
|
def _build_slab_picker(self) -> None:
|
||||||
|
sub = ttk.Frame(self.body)
|
||||||
|
sub.pack(fill="both", expand=True)
|
||||||
|
cnv = tk.Canvas(sub, height=320, highlightthickness=0)
|
||||||
|
sb = ttk.Scrollbar(sub, orient="vertical", command=cnv.yview)
|
||||||
|
inner = ttk.Frame(cnv)
|
||||||
|
inner.bind("<Configure>", lambda e: cnv.configure(scrollregion=cnv.bbox("all")))
|
||||||
|
cnv.create_window((0, 0), window=inner, anchor="nw")
|
||||||
|
cnv.configure(yscrollcommand=sb.set)
|
||||||
|
cnv.pack(side="left", fill="both", expand=True)
|
||||||
|
sb.pack(side="right", fill="y")
|
||||||
|
by_tier: Dict[str, List] = {}
|
||||||
|
for s in SLABS:
|
||||||
|
by_tier.setdefault(s.tier, []).append(s)
|
||||||
|
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
|
||||||
|
ttk.Label(inner, text=TIER_LABEL.get(tier, tier),
|
||||||
|
font=("TkDefaultFont", 10, "bold")).pack(anchor="w", pady=(8, 2))
|
||||||
|
grid = ttk.Frame(inner)
|
||||||
|
grid.pack(fill="x")
|
||||||
|
for i, s in enumerate(by_tier[tier]):
|
||||||
|
b = ttk.Button(grid, text=s.ko_label, width=8,
|
||||||
|
command=lambda v=s.value: self._pick_slab(v))
|
||||||
|
b.grid(row=i // 6, column=i % 6, padx=2, pady=2, sticky="w")
|
||||||
|
|
||||||
|
def _build_artifact_picker(self) -> None:
|
||||||
|
sub = ttk.Frame(self.body)
|
||||||
|
sub.pack(fill="both", expand=True)
|
||||||
|
ttk.Label(sub, text=(
|
||||||
|
f"아티팩트 {len(ARTIFACTS)}종. 한글명으로 검색:"
|
||||||
|
)).pack(anchor="w")
|
||||||
|
sv = tk.StringVar()
|
||||||
|
ttk.Entry(sub, textvariable=sv).pack(fill="x", pady=2)
|
||||||
|
list_frame = ttk.Frame(sub)
|
||||||
|
list_frame.pack(fill="both", expand=True)
|
||||||
|
lb = tk.Listbox(list_frame, height=14)
|
||||||
|
lb.pack(side="left", fill="both", expand=True)
|
||||||
|
sb = ttk.Scrollbar(list_frame, orient="vertical", command=lb.yview)
|
||||||
|
sb.pack(side="right", fill="y")
|
||||||
|
lb.configure(yscrollcommand=sb.set)
|
||||||
|
|
||||||
|
items = sorted(ARTIFACTS, key=lambda a: (TIER_ORDER.get(a.tier, 99), a.ko_label))
|
||||||
|
|
||||||
|
def refresh(*_a):
|
||||||
|
lb.delete(0, "end")
|
||||||
|
q = sv.get().strip()
|
||||||
|
for a in items:
|
||||||
|
if not q or q in a.ko_label or q in a.value:
|
||||||
|
lb.insert("end", f"[{TIER_LABEL.get(a.tier, a.tier)}] {a.ko_label}")
|
||||||
|
|
||||||
|
sv.trace_add("write", refresh)
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
def on_pick(_e=None):
|
||||||
|
sel = lb.curselection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
label = lb.get(sel[0])
|
||||||
|
# match by Korean label
|
||||||
|
ko = label.split("] ", 1)[1] if "] " in label else label
|
||||||
|
for a in ARTIFACTS:
|
||||||
|
if a.ko_label == ko:
|
||||||
|
self._chosen_value = a.value
|
||||||
|
self._chosen_kind = "artifact"
|
||||||
|
return
|
||||||
|
lb.bind("<<ListboxSelect>>", on_pick)
|
||||||
|
|
||||||
|
def _build_merged_picker(self) -> None:
|
||||||
|
sub = ttk.Frame(self.body, padding=4)
|
||||||
|
sub.pack(fill="both", expand=True)
|
||||||
|
ttk.Label(sub, text=(
|
||||||
|
"합쳐진 박스(?)에 어떤 두 석판이 들어갔는지 고르고, 누적 레벨을 지정하세요.\n"
|
||||||
|
"선택한 정보로 결과 이미지에 합쳐진 형태가 표시됩니다."
|
||||||
|
), justify="left").pack(anchor="w", pady=(0, 6))
|
||||||
|
|
||||||
|
slab_values = [s.value for s in SLABS]
|
||||||
|
slab_labels = {s.value: f"{s.ko_label} ({s.value})" for s in SLABS}
|
||||||
|
|
||||||
|
def labelled(v: str) -> str:
|
||||||
|
return slab_labels.get(v, v)
|
||||||
|
|
||||||
|
rowA = ttk.Frame(sub); rowA.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(rowA, text="첫 번째 석판:", width=14).pack(side="left")
|
||||||
|
ttk.OptionMenu(rowA, self._merged_a, "",
|
||||||
|
*[""] + [labelled(v) for v in slab_values]).pack(side="left", fill="x", expand=True)
|
||||||
|
|
||||||
|
rowB = ttk.Frame(sub); rowB.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(rowB, text="두 번째 석판:", width=14).pack(side="left")
|
||||||
|
ttk.OptionMenu(rowB, self._merged_b, "",
|
||||||
|
*[""] + [labelled(v) for v in slab_values]).pack(side="left", fill="x", expand=True)
|
||||||
|
|
||||||
|
rowL = ttk.Frame(sub); rowL.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(rowL, text="누적 레벨:", width=14).pack(side="left")
|
||||||
|
ttk.Spinbox(rowL, from_=1, to=10, textvariable=self._merged_lvl,
|
||||||
|
width=6).pack(side="left")
|
||||||
|
|
||||||
|
def _pick_slab(self, value: str) -> None:
|
||||||
|
self._chosen_value = value
|
||||||
|
self._chosen_kind = "slab"
|
||||||
|
|
||||||
|
def _ok(self) -> None:
|
||||||
|
k = self.kind_var.get()
|
||||||
|
if k == "slab":
|
||||||
|
if not self._chosen_value:
|
||||||
|
messagebox.showinfo("안내", "석판을 선택하세요.")
|
||||||
|
return
|
||||||
|
self.result = {"kind": "slab", "value": self._chosen_value,
|
||||||
|
"rotation": 0, "merged": None}
|
||||||
|
elif k == "artifact":
|
||||||
|
if not self._chosen_value:
|
||||||
|
messagebox.showinfo("안내", "아티팩트를 선택하세요.")
|
||||||
|
return
|
||||||
|
self.result = {"kind": "artifact", "value": self._chosen_value,
|
||||||
|
"rotation": 0, "merged": None}
|
||||||
|
elif k == "merged":
|
||||||
|
def _val_from_label(lbl: str) -> Optional[str]:
|
||||||
|
if not lbl:
|
||||||
|
return None
|
||||||
|
if "(" in lbl and lbl.endswith(")"):
|
||||||
|
return lbl[lbl.rfind("(") + 1: -1]
|
||||||
|
return None
|
||||||
|
a = _val_from_label(self._merged_a.get())
|
||||||
|
b = _val_from_label(self._merged_b.get())
|
||||||
|
if not a or not b:
|
||||||
|
messagebox.showinfo("안내", "두 석판을 모두 선택하세요.")
|
||||||
|
return
|
||||||
|
self.result = {"kind": "merged", "value": None, "rotation": 0,
|
||||||
|
"merged": {"a": a, "b": b, "level": int(self._merged_lvl.get())}}
|
||||||
|
else:
|
||||||
|
self.result = {"kind": "empty", "value": None, "rotation": 0, "merged": None}
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- screenshot/game-window frame ----------
|
||||||
|
|
||||||
|
class ScreenshotFrame(ttk.Frame):
|
||||||
|
def __init__(self, master, on_confirmed) -> None:
|
||||||
|
super().__init__(master, padding=6)
|
||||||
|
self.on_confirmed = on_confirmed
|
||||||
|
self.slot_var = master.winfo_toplevel().slot_var # type: ignore[attr-defined]
|
||||||
|
self.image: Optional[Image.Image] = None
|
||||||
|
self.bbox: Optional[Tuple[int, int, int, int]] = None
|
||||||
|
self.cells: List[CellResult] = []
|
||||||
|
# per-slot override: dict slot_id -> {kind, value, rotation, merged}
|
||||||
|
self.overrides: Dict[str, dict] = {}
|
||||||
|
self._thumbs: List[ImageTk.PhotoImage] = []
|
||||||
|
self._templates_warmed = False
|
||||||
|
|
||||||
|
ctl = ttk.Frame(self)
|
||||||
|
ctl.pack(fill="x")
|
||||||
|
ttk.Button(ctl, text="🎮 게임 창 선택…", command=self._pick_window).pack(side="left")
|
||||||
|
ttk.Button(ctl, text="🖥 전체 화면 캡처", command=self._capture_screen).pack(side="left", padx=4)
|
||||||
|
ttk.Button(ctl, text="📂 파일 열기…", command=self._open_file).pack(side="left", padx=4)
|
||||||
|
ttk.Button(ctl, text="🔁 영역 재지정", command=self._reselect_bbox).pack(side="left", padx=4)
|
||||||
|
ttk.Button(ctl, text="✅ 이 구성으로 계산", command=self._confirm).pack(side="right")
|
||||||
|
|
||||||
|
self.status = ttk.Label(
|
||||||
|
self,
|
||||||
|
text=(
|
||||||
|
"1) [게임 창 선택] 으로 Sephiria 창을 고르거나, [전체 화면 캡처]/[파일 열기] 로 이미지를 가져옵니다.\n"
|
||||||
|
"2) 가방 격자의 좌상단/우하단을 두 번 클릭해 영역을 지정합니다.\n"
|
||||||
|
"3) 자동 인식 후 잘못된 셀은 클릭해서 종류/석판/아티팩트/합쳐진(?) 으로 교정하세요."
|
||||||
|
),
|
||||||
|
wraplength=520, justify="left",
|
||||||
|
)
|
||||||
|
self.status.pack(fill="x", pady=(6, 4))
|
||||||
|
|
||||||
|
self.preview_canvas = tk.Canvas(self, height=440, highlightthickness=0)
|
||||||
|
self.preview_canvas.pack(fill="both", expand=True)
|
||||||
|
self.preview_inner = ttk.Frame(self.preview_canvas)
|
||||||
|
self.preview_canvas.create_window((0, 0), window=self.preview_inner, anchor="nw")
|
||||||
|
self.preview_inner.bind(
|
||||||
|
"<Configure>",
|
||||||
|
lambda e: self.preview_canvas.configure(
|
||||||
|
scrollregion=self.preview_canvas.bbox("all")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----- input modes -----
|
||||||
|
def _pick_window(self) -> None:
|
||||||
|
dlg = WindowPicker(self.winfo_toplevel())
|
||||||
|
self.winfo_toplevel().wait_window(dlg)
|
||||||
|
if not dlg.selected:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from .window_capture import capture_window
|
||||||
|
self.image = capture_window(dlg.selected)
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("창 캡처 실패", str(e))
|
||||||
|
return
|
||||||
|
self.status["text"] = f"창 캡처 완료: {dlg.selected.title} ({self.image.size[0]}×{self.image.size[1]})"
|
||||||
|
self._pick_bbox_and_recognize()
|
||||||
|
|
||||||
|
def _capture_screen(self) -> None:
|
||||||
|
try:
|
||||||
|
from .capture import capture_screen
|
||||||
|
top = self.winfo_toplevel()
|
||||||
|
top.withdraw(); top.update()
|
||||||
|
try:
|
||||||
|
self.image = capture_screen(monitor=1)
|
||||||
|
finally:
|
||||||
|
top.deiconify()
|
||||||
|
except Exception as e:
|
||||||
|
self.winfo_toplevel().deiconify()
|
||||||
|
messagebox.showerror("캡처 실패", str(e))
|
||||||
|
return
|
||||||
|
self._pick_bbox_and_recognize()
|
||||||
|
|
||||||
|
def _open_file(self) -> None:
|
||||||
|
path = filedialog.askopenfilename(
|
||||||
|
title="스크린샷 선택",
|
||||||
|
filetypes=[("Images", "*.png *.jpg *.jpeg *.webp"), ("All", "*.*")],
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.image = Image.open(path).convert("RGB")
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("열기 실패", str(e))
|
||||||
|
return
|
||||||
|
self._pick_bbox_and_recognize()
|
||||||
|
|
||||||
|
def _reselect_bbox(self) -> None:
|
||||||
|
if self.image is None:
|
||||||
|
messagebox.showinfo("안내", "먼저 캡처 또는 파일 열기를 해주세요.")
|
||||||
|
return
|
||||||
|
self._pick_bbox_and_recognize()
|
||||||
|
|
||||||
|
# ----- pipeline -----
|
||||||
|
def _pick_bbox_and_recognize(self) -> None:
|
||||||
|
assert self.image is not None
|
||||||
|
dlg = CropDialog(self.winfo_toplevel(), self.image)
|
||||||
|
self.winfo_toplevel().wait_window(dlg)
|
||||||
|
if not dlg.bbox:
|
||||||
|
return
|
||||||
|
self.bbox = dlg.bbox
|
||||||
|
self.overrides.clear()
|
||||||
|
self.status["text"] = "템플릿 준비 + 셀 인식 중…"
|
||||||
|
self.update_idletasks()
|
||||||
|
threading.Thread(target=self._recognize_thread, daemon=True).start()
|
||||||
|
|
||||||
|
def _recognize_thread(self) -> None:
|
||||||
|
try:
|
||||||
|
if not self._templates_warmed:
|
||||||
|
# First call may download a lot — keep artifacts on (user wants them)
|
||||||
|
warm_templates(include_artifacts=True)
|
||||||
|
self._templates_warmed = True
|
||||||
|
slot_num = int(round(self.slot_var.get()))
|
||||||
|
cells = recognize_image(
|
||||||
|
self.image, self.bbox,
|
||||||
|
slot_num=slot_num, include_artifacts=True,
|
||||||
|
)
|
||||||
|
self.after(0, self._show_cells, cells)
|
||||||
|
except Exception as e:
|
||||||
|
self.after(0, lambda: messagebox.showerror("인식 실패", str(e)))
|
||||||
|
|
||||||
|
def _show_cells(self, cells: List[CellResult]) -> None:
|
||||||
|
self.cells = cells
|
||||||
|
for c in self.preview_inner.winfo_children():
|
||||||
|
c.destroy()
|
||||||
|
self._thumbs.clear()
|
||||||
|
|
||||||
|
slot_num = int(round(self.slot_var.get()))
|
||||||
|
grid = generate_grid_config(slot_num)
|
||||||
|
slot_to_cell = {c.slot_id: c for c in cells}
|
||||||
|
|
||||||
|
kind_counts = {"slab": 0, "artifact": 0, "empty": 0, "unknown": 0, "merged": 0}
|
||||||
|
for row_cfg in grid:
|
||||||
|
y = row_cfg["rows"]
|
||||||
|
for x in range(GRID_COLS):
|
||||||
|
if x >= row_cfg["cols"]:
|
||||||
|
tk.Frame(self.preview_inner, width=64, height=80,
|
||||||
|
bg=self.preview_inner.winfo_toplevel()["bg"]) \
|
||||||
|
.grid(row=y, column=x, padx=2, pady=2)
|
||||||
|
continue
|
||||||
|
slot_id = f"{y}-{x}"
|
||||||
|
cell = slot_to_cell.get(slot_id)
|
||||||
|
ov = self.overrides.get(slot_id)
|
||||||
|
effective = self._effective(cell, ov)
|
||||||
|
kind_counts[effective["kind"]] = kind_counts.get(effective["kind"], 0) + 1
|
||||||
|
self._make_cell(y, x, slot_id, effective)
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"석판 {kind_counts.get('slab', 0)} · 아티팩트 {kind_counts.get('artifact', 0)} · "
|
||||||
|
f"빈칸 {kind_counts.get('empty', 0)} · 합쳐진(?) {kind_counts.get('merged', 0)} · "
|
||||||
|
f"미인식 {kind_counts.get('unknown', 0)}\n"
|
||||||
|
"셀을 클릭하면 종류/값을 교정할 수 있습니다. 끝나면 [이 구성으로 계산]."
|
||||||
|
)
|
||||||
|
self.status["text"] = msg
|
||||||
|
|
||||||
|
def _effective(self, cell: Optional[CellResult], ov: Optional[dict]) -> dict:
|
||||||
|
if ov is not None:
|
||||||
|
return ov
|
||||||
|
if cell is None:
|
||||||
|
return {"kind": "empty", "value": None, "rotation": 0, "merged": None, "score": 0.0}
|
||||||
|
return {
|
||||||
|
"kind": cell.kind, "value": cell.value,
|
||||||
|
"rotation": cell.rotation, "merged": None, "score": cell.score,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _make_cell(self, y: int, x: int, slot_id: str, info: dict) -> None:
|
||||||
|
kind = info.get("kind", "empty")
|
||||||
|
value = info.get("value")
|
||||||
|
rot = info.get("rotation", 0) or 0
|
||||||
|
score = info.get("score", 0.0)
|
||||||
|
border = {"slab": "#7a4a8a", "artifact": "#6a82c8", "merged": "#c870c0",
|
||||||
|
"unknown": "#c8a050", "empty": "#3a2a3a"}.get(kind, "#3a2a3a")
|
||||||
|
frame = tk.Frame(self.preview_inner, bd=2, relief="solid",
|
||||||
|
width=72, height=82, bg="#2c1a2a",
|
||||||
|
highlightbackground=border, highlightthickness=2)
|
||||||
|
frame.grid(row=y, column=x, padx=2, pady=2)
|
||||||
|
frame.grid_propagate(False)
|
||||||
|
|
||||||
|
if kind == "slab" and value:
|
||||||
|
thumb = _slab_thumb(value, size=44)
|
||||||
|
if thumb is not None:
|
||||||
|
if rot:
|
||||||
|
# generate rotated thumb on the fly
|
||||||
|
base = fetch_slab_image(SLABS_BY_VALUE[value].image)
|
||||||
|
if base is not None:
|
||||||
|
base = base.resize((44, 44)).rotate(-90 * rot, expand=False)
|
||||||
|
thumb = ImageTk.PhotoImage(base)
|
||||||
|
self._thumbs.append(thumb)
|
||||||
|
lbl = tk.Label(frame, image=thumb, bg="#2c1a2a")
|
||||||
|
else:
|
||||||
|
lbl = tk.Label(frame, text=SLABS_BY_VALUE[value].ko_label,
|
||||||
|
fg="#fff", bg="#2c1a2a")
|
||||||
|
elif kind == "artifact" and value:
|
||||||
|
thumb = _artifact_thumb(value, size=44)
|
||||||
|
if thumb is not None:
|
||||||
|
self._thumbs.append(thumb)
|
||||||
|
lbl = tk.Label(frame, image=thumb, bg="#2c1a2a")
|
||||||
|
else:
|
||||||
|
a = ARTIFACTS_BY_VALUE.get(value)
|
||||||
|
lbl = tk.Label(frame, text=a.ko_label if a else value,
|
||||||
|
fg="#9bf", bg="#2c1a2a")
|
||||||
|
elif kind == "merged":
|
||||||
|
lbl = tk.Label(frame, text="?", font=("TkDefaultFont", 26, "bold"),
|
||||||
|
fg="#ffaaff", bg="#2c1a2a")
|
||||||
|
elif kind == "unknown":
|
||||||
|
lbl = tk.Label(frame, text="?", font=("TkDefaultFont", 26, "bold"),
|
||||||
|
fg="#ffd070", bg="#2c1a2a")
|
||||||
|
else:
|
||||||
|
lbl = tk.Label(frame, text="·", fg="#666", bg="#2c1a2a")
|
||||||
|
lbl.pack(expand=True)
|
||||||
|
|
||||||
|
caption = ""
|
||||||
|
if kind == "slab" and value:
|
||||||
|
caption = SLABS_BY_VALUE[value].ko_label
|
||||||
|
if rot:
|
||||||
|
caption += f" ↻{rot}"
|
||||||
|
elif kind == "artifact" and value:
|
||||||
|
a = ARTIFACTS_BY_VALUE.get(value)
|
||||||
|
caption = a.ko_label if a else value
|
||||||
|
elif kind == "merged":
|
||||||
|
m = info.get("merged") or {}
|
||||||
|
la = SLABS_BY_VALUE.get(m.get("a", "")) if m else None
|
||||||
|
lb = SLABS_BY_VALUE.get(m.get("b", "")) if m else None
|
||||||
|
caption = f"{la.ko_label if la else '?'}+{lb.ko_label if lb else '?'} L{m.get('level', 1)}" if m else "합쳐진 박스"
|
||||||
|
elif kind == "unknown":
|
||||||
|
caption = f"미인식 {score:.2f}"
|
||||||
|
else:
|
||||||
|
caption = "빈칸"
|
||||||
|
tk.Label(frame, text=caption, fg="#cfcfcf", bg="#2c1a2a",
|
||||||
|
font=("TkDefaultFont", 7)).pack(fill="x")
|
||||||
|
|
||||||
|
def on_click(_e=None, sid=slot_id):
|
||||||
|
self._edit(sid)
|
||||||
|
for w in (frame, lbl):
|
||||||
|
w.bind("<Button-1>", on_click)
|
||||||
|
|
||||||
|
def _edit(self, slot_id: str) -> None:
|
||||||
|
cell = next((c for c in self.cells if c.slot_id == slot_id), None)
|
||||||
|
dlg = CellEditor(self.winfo_toplevel(), cell)
|
||||||
|
self.winfo_toplevel().wait_window(dlg)
|
||||||
|
if dlg.result is None:
|
||||||
|
return
|
||||||
|
# store override, then re-render
|
||||||
|
self.overrides[slot_id] = {**dlg.result, "score": 1.0}
|
||||||
|
self._show_cells(self.cells)
|
||||||
|
|
||||||
|
def _confirm(self) -> None:
|
||||||
|
if not self.cells and not self.overrides:
|
||||||
|
messagebox.showinfo("안내", "먼저 캡처 + 영역 지정을 해주세요.")
|
||||||
|
return
|
||||||
|
basket: List[str] = []
|
||||||
|
for cell in self.cells:
|
||||||
|
ov = self.overrides.get(cell.slot_id)
|
||||||
|
eff = self._effective(cell, ov)
|
||||||
|
if eff["kind"] == "slab" and eff.get("value"):
|
||||||
|
basket.append(eff["value"])
|
||||||
|
elif eff["kind"] == "merged":
|
||||||
|
m = eff.get("merged") or {}
|
||||||
|
# treat a merged box as having both its component slabs in the basket
|
||||||
|
if m.get("a"):
|
||||||
|
basket.append(m["a"])
|
||||||
|
if m.get("b"):
|
||||||
|
basket.append(m["b"])
|
||||||
|
self.on_confirmed(basket)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- manual input frame ----------
|
||||||
|
|
||||||
|
class ManualFrame(ttk.Frame):
|
||||||
|
def __init__(self, master, on_changed) -> None:
|
||||||
|
super().__init__(master, padding=4)
|
||||||
|
self.counts: Dict[str, int] = {s.value: 0 for s in SLABS}
|
||||||
|
self.count_labels: Dict[str, tk.Label] = {}
|
||||||
|
self.on_changed = on_changed
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
def _build(self) -> None:
|
||||||
|
canvas = tk.Canvas(self, highlightthickness=0)
|
||||||
|
scroll = ttk.Scrollbar(self, orient="vertical", command=canvas.yview)
|
||||||
|
inner = ttk.Frame(canvas)
|
||||||
|
inner.bind("<Configure>",
|
||||||
|
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||||
|
canvas.create_window((0, 0), window=inner, anchor="nw")
|
||||||
|
canvas.configure(yscrollcommand=scroll.set)
|
||||||
|
canvas.pack(side="left", fill="both", expand=True)
|
||||||
|
scroll.pack(side="right", fill="y")
|
||||||
|
|
||||||
|
by_tier: Dict[str, List] = {}
|
||||||
|
for s in SLABS:
|
||||||
|
by_tier.setdefault(s.tier, []).append(s)
|
||||||
|
|
||||||
|
row = 0
|
||||||
|
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
|
||||||
|
ttk.Label(inner, text=TIER_LABEL.get(tier, tier),
|
||||||
|
font=("TkDefaultFont", 10, "bold")) \
|
||||||
|
.grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2))
|
||||||
|
row += 1
|
||||||
|
for slab in by_tier[tier]:
|
||||||
|
ttk.Label(inner, text=slab.ko_label, width=8).grid(row=row, column=0, sticky="w")
|
||||||
|
ttk.Button(inner, text="−", width=2,
|
||||||
|
command=lambda v=slab.value: self._change(v, -1)).grid(row=row, column=1)
|
||||||
|
lbl = ttk.Label(inner, text="0", width=3, anchor="center")
|
||||||
|
lbl.grid(row=row, column=2)
|
||||||
|
self.count_labels[slab.value] = lbl
|
||||||
|
ttk.Button(inner, text="+", width=2,
|
||||||
|
command=lambda v=slab.value: self._change(v, 1)).grid(row=row, column=3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
def _change(self, v: str, d: int) -> None:
|
||||||
|
self.counts[v] = max(0, self.counts.get(v, 0) + d)
|
||||||
|
self.count_labels[v]["text"] = str(self.counts[v])
|
||||||
|
self.on_changed()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for v in self.counts:
|
||||||
|
self.counts[v] = 0
|
||||||
|
self.count_labels[v]["text"] = "0"
|
||||||
|
self.on_changed()
|
||||||
|
|
||||||
|
def basket(self) -> List[str]:
|
||||||
|
out: List[str] = []
|
||||||
|
for v, n in self.counts.items():
|
||||||
|
out.extend([v] * n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- main app ----------
|
||||||
|
|
||||||
class App(tk.Tk):
|
class App(tk.Tk):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.title("Sephiria Inventory Optimizer")
|
self.title("Sephiria Inventory Optimizer")
|
||||||
self.geometry("1280x800")
|
self.geometry("1320x820")
|
||||||
self.minsize(1000, 600)
|
self.minsize(1100, 640)
|
||||||
|
|
||||||
# counts: value -> int
|
self.slot_var = tk.IntVar(value=34)
|
||||||
self.counts: Dict[str, int] = {s.value: 0 for s in SLABS}
|
self.basket: List[str] = []
|
||||||
self.count_labels: Dict[str, tk.Label] = {}
|
|
||||||
self.summary_var = tk.StringVar(value="선택된 석판: 0개")
|
self.summary_var = tk.StringVar(value="선택된 석판: 0개")
|
||||||
self.score_var = tk.StringVar(value="score: -")
|
self.score_var = tk.StringVar(value="score: -")
|
||||||
self.slot_var = tk.IntVar(value=34)
|
|
||||||
self.solving = False
|
self.solving = False
|
||||||
self.preview_image: ImageTk.PhotoImage | None = None
|
self.preview_image: Optional[ImageTk.PhotoImage] = None
|
||||||
self.last_solution = None
|
self.last_solution = None
|
||||||
|
|
||||||
self._build()
|
self._build()
|
||||||
|
|
||||||
# -------------------------------------------------------------- layout
|
|
||||||
def _build(self) -> None:
|
def _build(self) -> None:
|
||||||
root = ttk.Frame(self, padding=8)
|
root = ttk.Frame(self, padding=8)
|
||||||
root.pack(fill="both", expand=True)
|
root.pack(fill="both", expand=True)
|
||||||
@@ -52,12 +745,14 @@ class App(tk.Tk):
|
|||||||
root.columnconfigure(1, weight=3)
|
root.columnconfigure(1, weight=3)
|
||||||
root.rowconfigure(0, weight=1)
|
root.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
# Left: catalog
|
nb = ttk.Notebook(root)
|
||||||
left = ttk.LabelFrame(root, text="석판 목록 (보유 개수)", padding=6)
|
nb.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
||||||
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
self.screenshot = ScreenshotFrame(nb, on_confirmed=self._on_screenshot_confirmed)
|
||||||
self._build_catalog(left)
|
nb.add(self.screenshot, text="게임창/스크린샷")
|
||||||
|
self.manual = ManualFrame(nb, on_changed=self._on_manual_changed)
|
||||||
|
nb.add(self.manual, text="수동 선택")
|
||||||
|
self.nb = nb
|
||||||
|
|
||||||
# Right: controls + preview
|
|
||||||
right = ttk.Frame(root)
|
right = ttk.Frame(root)
|
||||||
right.grid(row=0, column=1, sticky="nsew")
|
right.grid(row=0, column=1, sticky="nsew")
|
||||||
right.columnconfigure(0, weight=1)
|
right.columnconfigure(0, weight=1)
|
||||||
@@ -66,23 +761,19 @@ class App(tk.Tk):
|
|||||||
ctl = ttk.LabelFrame(right, text="옵션", padding=8)
|
ctl = ttk.LabelFrame(right, text="옵션", padding=8)
|
||||||
ctl.grid(row=0, column=0, sticky="ew")
|
ctl.grid(row=0, column=0, sticky="ew")
|
||||||
ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w")
|
ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w")
|
||||||
slot_scale = ttk.Scale(
|
slot_scale = ttk.Scale(ctl, from_=18, to=60, orient="horizontal",
|
||||||
ctl, from_=18, to=60, orient="horizontal",
|
variable=self.slot_var, command=self._on_slot)
|
||||||
variable=self.slot_var, command=self._on_slot_change,
|
|
||||||
)
|
|
||||||
slot_scale.grid(row=0, column=1, sticky="ew", padx=6)
|
slot_scale.grid(row=0, column=1, sticky="ew", padx=6)
|
||||||
ctl.columnconfigure(1, weight=1)
|
ctl.columnconfigure(1, weight=1)
|
||||||
self.slot_label = ttk.Label(ctl, text="34")
|
self.slot_label = ttk.Label(ctl, text="34")
|
||||||
self.slot_label.grid(row=0, column=2, sticky="w")
|
self.slot_label.grid(row=0, column=2, sticky="w")
|
||||||
|
|
||||||
ttk.Button(ctl, text="모두 비우기", command=self._clear).grid(
|
ttk.Button(ctl, text="비우기", command=self._clear_basket).grid(
|
||||||
row=1, column=0, pady=(8, 0), sticky="w"
|
row=1, column=0, pady=(8, 0), sticky="w")
|
||||||
)
|
|
||||||
self.solve_btn = ttk.Button(ctl, text="최적 배치 계산", command=self._solve)
|
self.solve_btn = ttk.Button(ctl, text="최적 배치 계산", command=self._solve)
|
||||||
self.solve_btn.grid(row=1, column=1, pady=(8, 0), sticky="ew", padx=6)
|
self.solve_btn.grid(row=1, column=1, pady=(8, 0), sticky="ew", padx=6)
|
||||||
ttk.Button(ctl, text="이미지 저장…", command=self._save).grid(
|
ttk.Button(ctl, text="이미지 저장…", command=self._save).grid(
|
||||||
row=1, column=2, pady=(8, 0), sticky="e"
|
row=1, column=2, pady=(8, 0), sticky="e")
|
||||||
)
|
|
||||||
|
|
||||||
summary = ttk.Frame(right)
|
summary = ttk.Frame(right)
|
||||||
summary.grid(row=1, column=0, sticky="ew", pady=(6, 0))
|
summary.grid(row=1, column=0, sticky="ew", pady=(6, 0))
|
||||||
@@ -93,113 +784,48 @@ class App(tk.Tk):
|
|||||||
preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0))
|
preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0))
|
||||||
self.preview = ttk.Label(
|
self.preview = ttk.Label(
|
||||||
preview_frame, anchor="center",
|
preview_frame, anchor="center",
|
||||||
text="좌측에서 석판을 추가하고 '최적 배치 계산'을 눌러주세요.",
|
text="좌측에서 인벤토리를 가져오거나 석판을 추가한 뒤 "
|
||||||
|
"'최적 배치 계산'을 눌러주세요.",
|
||||||
)
|
)
|
||||||
self.preview.pack(fill="both", expand=True)
|
self.preview.pack(fill="both", expand=True)
|
||||||
|
|
||||||
def _build_catalog(self, parent: tk.Widget) -> None:
|
def _on_manual_changed(self) -> None:
|
||||||
canvas = tk.Canvas(parent, highlightthickness=0)
|
self.basket = self.manual.basket()
|
||||||
scroll = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
|
self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (수동)")
|
||||||
inner = ttk.Frame(canvas)
|
|
||||||
inner.bind(
|
|
||||||
"<Configure>",
|
|
||||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
|
|
||||||
)
|
|
||||||
canvas.create_window((0, 0), window=inner, anchor="nw")
|
|
||||||
canvas.configure(yscrollcommand=scroll.set)
|
|
||||||
canvas.pack(side="left", fill="both", expand=True)
|
|
||||||
scroll.pack(side="right", fill="y")
|
|
||||||
|
|
||||||
# mousewheel
|
def _on_screenshot_confirmed(self, basket: List[str]) -> None:
|
||||||
def _wheel(e):
|
self.basket = list(basket)
|
||||||
canvas.yview_scroll(int(-e.delta / 120), "units")
|
self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (스크린샷)")
|
||||||
|
messagebox.showinfo("확정",
|
||||||
|
f"{len(self.basket)}개를 가져왔습니다. '최적 배치 계산'을 누르세요.")
|
||||||
|
|
||||||
canvas.bind_all("<MouseWheel>", _wheel)
|
def _on_slot(self, _evt) -> None:
|
||||||
canvas.bind_all("<Button-4>", lambda e: canvas.yview_scroll(-1, "units"))
|
|
||||||
canvas.bind_all("<Button-5>", lambda e: canvas.yview_scroll(1, "units"))
|
|
||||||
|
|
||||||
# group by tier
|
|
||||||
by_tier: Dict[str, List] = {t: [] for t in TIER_ORDER}
|
|
||||||
for s in SLABS:
|
|
||||||
by_tier.setdefault(s.tier, []).append(s)
|
|
||||||
|
|
||||||
row = 0
|
|
||||||
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
|
|
||||||
slabs = by_tier[tier]
|
|
||||||
if not slabs:
|
|
||||||
continue
|
|
||||||
ttk.Label(
|
|
||||||
inner,
|
|
||||||
text=TIER_LABEL.get(tier, tier),
|
|
||||||
font=("TkDefaultFont", 10, "bold"),
|
|
||||||
).grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2))
|
|
||||||
row += 1
|
|
||||||
for slab in slabs:
|
|
||||||
ttk.Label(inner, text=slab.ko_label, width=8).grid(
|
|
||||||
row=row, column=0, sticky="w"
|
|
||||||
)
|
|
||||||
ttk.Button(
|
|
||||||
inner, text="−", width=2,
|
|
||||||
command=lambda v=slab.value: self._change_count(v, -1),
|
|
||||||
).grid(row=row, column=1)
|
|
||||||
lbl = ttk.Label(inner, text="0", width=3, anchor="center")
|
|
||||||
lbl.grid(row=row, column=2)
|
|
||||||
self.count_labels[slab.value] = lbl
|
|
||||||
ttk.Button(
|
|
||||||
inner, text="+", width=2,
|
|
||||||
command=lambda v=slab.value: self._change_count(v, 1),
|
|
||||||
).grid(row=row, column=3)
|
|
||||||
row += 1
|
|
||||||
|
|
||||||
# -------------------------------------------------------------- handlers
|
|
||||||
def _change_count(self, value: str, delta: int) -> None:
|
|
||||||
n = max(0, self.counts.get(value, 0) + delta)
|
|
||||||
self.counts[value] = n
|
|
||||||
self.count_labels[value]["text"] = str(n)
|
|
||||||
self._update_summary()
|
|
||||||
|
|
||||||
def _clear(self) -> None:
|
|
||||||
for v in self.counts:
|
|
||||||
self.counts[v] = 0
|
|
||||||
self.count_labels[v]["text"] = "0"
|
|
||||||
self._update_summary()
|
|
||||||
|
|
||||||
def _update_summary(self) -> None:
|
|
||||||
total = sum(self.counts.values())
|
|
||||||
self.summary_var.set(f"선택된 석판: {total}개")
|
|
||||||
|
|
||||||
def _on_slot_change(self, _evt) -> None:
|
|
||||||
v = int(round(self.slot_var.get()))
|
v = int(round(self.slot_var.get()))
|
||||||
self.slot_var.set(v)
|
self.slot_var.set(v)
|
||||||
self.slot_label["text"] = str(v)
|
self.slot_label["text"] = str(v)
|
||||||
|
|
||||||
def _expand_basket(self) -> List[str]:
|
def _clear_basket(self) -> None:
|
||||||
basket: List[str] = []
|
self.manual.clear()
|
||||||
for v, n in self.counts.items():
|
self.basket = []
|
||||||
basket.extend([v] * n)
|
self.summary_var.set("선택된 석판: 0개")
|
||||||
return basket
|
|
||||||
|
|
||||||
def _solve(self) -> None:
|
def _solve(self) -> None:
|
||||||
if self.solving:
|
if self.solving:
|
||||||
return
|
return
|
||||||
basket = self._expand_basket()
|
if not self.basket:
|
||||||
if not basket:
|
messagebox.showinfo("안내",
|
||||||
messagebox.showinfo("안내", "먼저 보유한 석판을 추가하세요.")
|
"먼저 인벤토리를 가져오거나 수동 선택으로 석판을 추가해주세요.")
|
||||||
return
|
return
|
||||||
slot_num = int(round(self.slot_var.get()))
|
slot_num = int(round(self.slot_var.get()))
|
||||||
|
basket = list(self.basket)
|
||||||
if len(basket) > slot_num:
|
if len(basket) > slot_num:
|
||||||
if not messagebox.askyesno(
|
if not messagebox.askyesno("확인",
|
||||||
"확인",
|
f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다. 초과분 무시?"):
|
||||||
f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다.\n"
|
|
||||||
"초과분은 무시하고 계산할까요?",
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
self.solving = True
|
self.solving = True
|
||||||
self.solve_btn["state"] = "disabled"
|
self.solve_btn["state"] = "disabled"
|
||||||
self.score_var.set("score: 계산 중…")
|
self.score_var.set("score: 계산 중…")
|
||||||
threading.Thread(
|
threading.Thread(target=self._solve_worker, args=(basket, slot_num), daemon=True).start()
|
||||||
target=self._solve_worker, args=(basket, slot_num), daemon=True
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def _solve_worker(self, basket: List[str], slot_num: int) -> None:
|
def _solve_worker(self, basket: List[str], slot_num: int) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -217,7 +843,6 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
def _show_result(self, sol, img: Image.Image) -> None:
|
def _show_result(self, sol, img: Image.Image) -> None:
|
||||||
self.last_solution = sol
|
self.last_solution = sol
|
||||||
# fit preview width
|
|
||||||
w = max(self.preview.winfo_width(), 600)
|
w = max(self.preview.winfo_width(), 600)
|
||||||
scale = min(1.0, w / img.width)
|
scale = min(1.0, w / img.width)
|
||||||
if scale < 1.0:
|
if scale < 1.0:
|
||||||
@@ -231,8 +856,7 @@ class App(tk.Tk):
|
|||||||
messagebox.showinfo("안내", "먼저 계산을 실행하세요.")
|
messagebox.showinfo("안내", "먼저 계산을 실행하세요.")
|
||||||
return
|
return
|
||||||
path = filedialog.asksaveasfilename(
|
path = filedialog.asksaveasfilename(
|
||||||
defaultextension=".png",
|
defaultextension=".png", filetypes=[("PNG", "*.png")],
|
||||||
filetypes=[("PNG", "*.png")],
|
|
||||||
initialfile="sephiria_layout.png",
|
initialfile="sephiria_layout.png",
|
||||||
)
|
)
|
||||||
if not path:
|
if not path:
|
||||||
|
|||||||
206
sephiria_inv/recognizer.py
Normal file
206
sephiria_inv/recognizer.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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] = []
|
||||||
|
# Slabs: 4 rotations for rotatable, 1 otherwise
|
||||||
|
for s in SLABS:
|
||||||
|
img = fetch_slab_image(s.image)
|
||||||
|
if img is None:
|
||||||
|
continue
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
base = _on_dark(img)
|
||||||
|
out.append(_Template("artifact", a.value, 0, _to_feat(base)))
|
||||||
|
_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))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- cell classification ----------
|
||||||
|
|
||||||
|
def _is_empty(cell: Image.Image) -> bool:
|
||||||
|
"""Heuristic: empty slots are dark and ~uniform."""
|
||||||
|
g = np.asarray(cell.convert("L"), dtype=np.float32)
|
||||||
|
return bool(g.mean() < 60.0 and g.std() < 14.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(
|
||||||
|
cell: Image.Image,
|
||||||
|
templates: List[_Template],
|
||||||
|
*,
|
||||||
|
min_score: float = 0.55,
|
||||||
|
) -> Tuple[str, Optional[str], int, float]:
|
||||||
|
"""Return (kind, value, rotation, score)."""
|
||||||
|
if _is_empty(cell):
|
||||||
|
return "empty", None, 0, 1.0
|
||||||
|
feat = _to_feat(cell)
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 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.55,
|
||||||
|
) -> 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 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