feat(capture): save calibration region; runtime crops same area
Problem: region-select crop is in its own coord-frame, but runtime capture used pygetwindow+mss with the window's coords → ROI coords mismatch, chart not captured correctly. Fix: region-select now returns (image, virtual-desktop region). Wizard saves 'chart_window_region' in config. At runtime, _build_capture() prefers region mode: grabs the same virtual-desktop rectangle, so ROI coords line up. Users who calibrated before this commit must re-run 'atm calibrate' OR add chart_window_region to their TOML manually. If window moves, canary will detect drift and auto-pause. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,16 +158,19 @@ def _image_looks_blank(pil_img, dark_thresh: int = 10) -> bool:
|
|||||||
def _capture_fullscreen_region():
|
def _capture_fullscreen_region():
|
||||||
"""Grab the entire virtual desktop and let the user drag a rectangle.
|
"""Grab the entire virtual desktop and let the user drag a rectangle.
|
||||||
|
|
||||||
Returns a cropped PIL RGB Image. The underlying desktop-snapshot is taken
|
Returns (cropped PIL RGB Image, (origin_x, origin_y, width, height)) in
|
||||||
BEFORE the Tk overlay opens, so what the user sees = what gets saved.
|
virtual-desktop coordinates. Runtime capture crops the same region.
|
||||||
"""
|
"""
|
||||||
import mss # type: ignore[import-untyped]
|
import mss # type: ignore[import-untyped]
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from PIL import Image, ImageTk
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
with mss.mss() as sct:
|
with mss.mss() as sct:
|
||||||
shot = sct.grab(sct.monitors[0]) # virtual screen (all monitors)
|
virt = sct.monitors[0] # virtual screen (all monitors combined)
|
||||||
|
shot = sct.grab(virt)
|
||||||
full_img = Image.frombytes("RGB", shot.size, shot.rgb)
|
full_img = Image.frombytes("RGB", shot.size, shot.rgb)
|
||||||
|
virt_left = int(virt["left"])
|
||||||
|
virt_top = int(virt["top"])
|
||||||
|
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.title("Select chart region")
|
root.title("Select chart region")
|
||||||
@@ -203,6 +206,7 @@ def _capture_fullscreen_region():
|
|||||||
result: dict = {"img": None}
|
result: dict = {"img": None}
|
||||||
|
|
||||||
def on_press(e):
|
def on_press(e):
|
||||||
|
state["started"] = True
|
||||||
state["x0"], state["y0"] = e.x, e.y
|
state["x0"], state["y0"] = e.x, e.y
|
||||||
state["x1"], state["y1"] = e.x, e.y
|
state["x1"], state["y1"] = e.x, e.y
|
||||||
if state["rect_id"] is not None:
|
if state["rect_id"] is not None:
|
||||||
@@ -217,7 +221,7 @@ def _capture_fullscreen_region():
|
|||||||
canvas.coords(state["rect_id"], state["x0"], state["y0"], e.x, e.y)
|
canvas.coords(state["rect_id"], state["x0"], state["y0"], e.x, e.y)
|
||||||
|
|
||||||
def on_ok(_=None):
|
def on_ok(_=None):
|
||||||
if state["x0"] is None:
|
if not state["started"]:
|
||||||
return
|
return
|
||||||
x0 = int(min(state["x0"], state["x1"]) / scale)
|
x0 = int(min(state["x0"], state["x1"]) / scale)
|
||||||
y0 = int(min(state["y0"], state["y1"]) / scale)
|
y0 = int(min(state["y0"], state["y1"]) / scale)
|
||||||
@@ -226,6 +230,8 @@ def _capture_fullscreen_region():
|
|||||||
if x1 - x0 < 100 or y1 - y0 < 100:
|
if x1 - x0 < 100 or y1 - y0 < 100:
|
||||||
return # too small, ignore
|
return # too small, ignore
|
||||||
result["img"] = full_img.crop((x0, y0, x1, y1))
|
result["img"] = full_img.crop((x0, y0, x1, y1))
|
||||||
|
# Convert back to virtual-desktop absolute coordinates.
|
||||||
|
result["region"] = (virt_left + x0, virt_top + y0, x1 - x0, y1 - y0)
|
||||||
root.destroy()
|
root.destroy()
|
||||||
|
|
||||||
def on_cancel(_=None):
|
def on_cancel(_=None):
|
||||||
@@ -242,7 +248,7 @@ def _capture_fullscreen_region():
|
|||||||
root.mainloop()
|
root.mainloop()
|
||||||
if result["img"] is None:
|
if result["img"] is None:
|
||||||
raise ValueError("Region selection cancelled")
|
raise ValueError("Region selection cancelled")
|
||||||
return result["img"]
|
return result["img"], result["region"]
|
||||||
|
|
||||||
|
|
||||||
def _capture_window(title_substr: str):
|
def _capture_window(title_substr: str):
|
||||||
@@ -375,6 +381,7 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
|
|||||||
# 1. Acquire image + window title
|
# 1. Acquire image + window title
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
pil_img: "Image.Image"
|
pil_img: "Image.Image"
|
||||||
|
chart_region: tuple[int, int, int, int] | None = None
|
||||||
if screenshot is not None:
|
if screenshot is not None:
|
||||||
pil_img = Image.open(screenshot).convert("RGB")
|
pil_img = Image.open(screenshot).convert("RGB")
|
||||||
root0 = tk.Tk(); root0.withdraw()
|
root0 = tk.Tk(); root0.withdraw()
|
||||||
@@ -398,9 +405,10 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
|
|||||||
raise ValueError("Window title is required to proceed.")
|
raise ValueError("Window title is required to proceed.")
|
||||||
|
|
||||||
# Default: region-select on full desktop (reliable with GPU-rendered apps).
|
# Default: region-select on full desktop (reliable with GPU-rendered apps).
|
||||||
# Fallback: try window capture if region-select is cancelled.
|
# Returns (cropped image, virtual-desktop region) so runtime capture can
|
||||||
|
# re-crop the same area without needing pygetwindow to find the chart.
|
||||||
try:
|
try:
|
||||||
pil_img = _capture_fullscreen_region()
|
pil_img, chart_region = _capture_fullscreen_region()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pil_img = _capture_window(window_title)
|
pil_img = _capture_window(window_title)
|
||||||
|
|
||||||
@@ -433,6 +441,10 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
|
|||||||
}
|
}
|
||||||
data.setdefault("options", {})
|
data.setdefault("options", {})
|
||||||
|
|
||||||
|
if chart_region is not None:
|
||||||
|
cx, cy, cw, ch = chart_region
|
||||||
|
data["chart_window_region"] = {"x": cx, "y": cy, "w": cw, "h": ch}
|
||||||
|
|
||||||
return write_config(data, configs_dir)
|
return write_config(data, configs_dir)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class Config:
|
|||||||
canary: CanaryRegion
|
canary: CanaryRegion
|
||||||
discord: DiscordCfg
|
discord: DiscordCfg
|
||||||
telegram: TelegramCfg
|
telegram: TelegramCfg
|
||||||
|
chart_window_region: ROI | None = None # virtual-desktop absolute region; when set, runtime uses full-desktop capture + crop
|
||||||
debounce_depth: int = 1
|
debounce_depth: int = 1
|
||||||
loop_interval_s: float = 5.0
|
loop_interval_s: float = 5.0
|
||||||
heartbeat_min: int = 30
|
heartbeat_min: int = 30
|
||||||
@@ -149,6 +150,9 @@ class Config:
|
|||||||
chat_id=str(data["telegram"]["chat_id"]),
|
chat_id=str(data["telegram"]["chat_id"]),
|
||||||
)
|
)
|
||||||
opts = data.get("options", {})
|
opts = data.get("options", {})
|
||||||
|
region = None
|
||||||
|
if "chart_window_region" in data:
|
||||||
|
region = ROI(**data["chart_window_region"])
|
||||||
return cls(
|
return cls(
|
||||||
window_title=data["window_title"],
|
window_title=data["window_title"],
|
||||||
dot_roi=roi,
|
dot_roi=roi,
|
||||||
@@ -158,6 +162,7 @@ class Config:
|
|||||||
canary=canary,
|
canary=canary,
|
||||||
discord=discord,
|
discord=discord,
|
||||||
telegram=telegram,
|
telegram=telegram,
|
||||||
|
chart_window_region=region,
|
||||||
debounce_depth=int(opts.get("debounce_depth", 1)),
|
debounce_depth=int(opts.get("debounce_depth", 1)),
|
||||||
loop_interval_s=float(opts.get("loop_interval_s", 5.0)),
|
loop_interval_s=float(opts.get("loop_interval_s", 5.0)),
|
||||||
heartbeat_min=int(opts.get("heartbeat_min", 30)),
|
heartbeat_min=int(opts.get("heartbeat_min", 30)),
|
||||||
|
|||||||
@@ -277,17 +277,37 @@ def _build_capture(cfg, capture_stub: bool = False):
|
|||||||
|
|
||||||
return _stub_capture
|
return _stub_capture
|
||||||
|
|
||||||
# Windows live path
|
# Live path — prefer region-based capture if calibrate saved one
|
||||||
try:
|
try:
|
||||||
import mss # type: ignore[import-untyped]
|
import mss # type: ignore[import-untyped]
|
||||||
import pygetwindow as gw # type: ignore[import-untyped]
|
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
sys.exit(
|
sys.exit(
|
||||||
f"Live screen capture requires 'mss' and 'pygetwindow' (Windows only). "
|
f"Live screen capture requires 'mss' (Windows). "
|
||||||
f"Use --capture-stub or set ATM_STUB_CAPTURE=1 for testing on Linux. "
|
f"Use --capture-stub or set ATM_STUB_CAPTURE=1 for testing on Linux. "
|
||||||
f"Missing: {exc}"
|
f"Missing: {exc}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if cfg.chart_window_region is not None:
|
||||||
|
reg = cfg.chart_window_region
|
||||||
|
|
||||||
|
def _region_capture():
|
||||||
|
with mss.mss() as sct:
|
||||||
|
import cv2 # type: ignore[import-untyped]
|
||||||
|
import numpy as np
|
||||||
|
mon = {"top": reg.y, "left": reg.x, "width": reg.w, "height": reg.h}
|
||||||
|
img = sct.grab(mon)
|
||||||
|
frame = np.array(img)
|
||||||
|
return cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)
|
||||||
|
|
||||||
|
return _region_capture
|
||||||
|
|
||||||
|
# Legacy: window-title lookup via pygetwindow (unreliable for GPU-rendered apps;
|
||||||
|
# kept as fallback when calibrate was run without region-select).
|
||||||
|
try:
|
||||||
|
import pygetwindow as gw # type: ignore[import-untyped]
|
||||||
|
except ImportError as exc:
|
||||||
|
sys.exit(f"Fallback window capture needs pygetwindow: {exc}")
|
||||||
|
|
||||||
def _live_capture():
|
def _live_capture():
|
||||||
wins = gw.getWindowsWithTitle(cfg.window_title)
|
wins = gw.getWindowsWithTitle(cfg.window_title)
|
||||||
if not wins:
|
if not wins:
|
||||||
@@ -297,10 +317,8 @@ def _build_capture(cfg, capture_stub: bool = False):
|
|||||||
import cv2 # type: ignore[import-untyped]
|
import cv2 # type: ignore[import-untyped]
|
||||||
import numpy as np
|
import numpy as np
|
||||||
mon = {
|
mon = {
|
||||||
"top": win.top,
|
"top": win.top, "left": win.left,
|
||||||
"left": win.left,
|
"width": win.width, "height": win.height,
|
||||||
"width": win.width,
|
|
||||||
"height": win.height,
|
|
||||||
}
|
}
|
||||||
img = sct.grab(mon)
|
img = sct.grab(mon)
|
||||||
frame = np.array(img)
|
frame = np.array(img)
|
||||||
|
|||||||
Reference in New Issue
Block a user