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