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():
|
||||
"""Grab the entire virtual desktop and let the user drag a rectangle.
|
||||
|
||||
Returns a cropped PIL RGB Image. The underlying desktop-snapshot is taken
|
||||
BEFORE the Tk overlay opens, so what the user sees = what gets saved.
|
||||
Returns (cropped PIL RGB Image, (origin_x, origin_y, width, height)) in
|
||||
virtual-desktop coordinates. Runtime capture crops the same region.
|
||||
"""
|
||||
import mss # type: ignore[import-untyped]
|
||||
import tkinter as tk
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
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)
|
||||
virt_left = int(virt["left"])
|
||||
virt_top = int(virt["top"])
|
||||
|
||||
root = tk.Tk()
|
||||
root.title("Select chart region")
|
||||
@@ -203,6 +206,7 @@ def _capture_fullscreen_region():
|
||||
result: dict = {"img": None}
|
||||
|
||||
def on_press(e):
|
||||
state["started"] = True
|
||||
state["x0"], state["y0"] = e.x, e.y
|
||||
state["x1"], state["y1"] = e.x, e.y
|
||||
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)
|
||||
|
||||
def on_ok(_=None):
|
||||
if state["x0"] is None:
|
||||
if not state["started"]:
|
||||
return
|
||||
x0 = int(min(state["x0"], state["x1"]) / 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:
|
||||
return # too small, ignore
|
||||
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()
|
||||
|
||||
def on_cancel(_=None):
|
||||
@@ -242,7 +248,7 @@ def _capture_fullscreen_region():
|
||||
root.mainloop()
|
||||
if result["img"] is None:
|
||||
raise ValueError("Region selection cancelled")
|
||||
return result["img"]
|
||||
return result["img"], result["region"]
|
||||
|
||||
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
pil_img: "Image.Image"
|
||||
chart_region: tuple[int, int, int, int] | None = None
|
||||
if screenshot is not None:
|
||||
pil_img = Image.open(screenshot).convert("RGB")
|
||||
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.")
|
||||
|
||||
# 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:
|
||||
pil_img = _capture_fullscreen_region()
|
||||
pil_img, chart_region = _capture_fullscreen_region()
|
||||
except ValueError:
|
||||
pil_img = _capture_window(window_title)
|
||||
|
||||
@@ -433,6 +441,10 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
|
||||
}
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ class Config:
|
||||
canary: CanaryRegion
|
||||
discord: DiscordCfg
|
||||
telegram: TelegramCfg
|
||||
chart_window_region: ROI | None = None # virtual-desktop absolute region; when set, runtime uses full-desktop capture + crop
|
||||
debounce_depth: int = 1
|
||||
loop_interval_s: float = 5.0
|
||||
heartbeat_min: int = 30
|
||||
@@ -149,6 +150,9 @@ class Config:
|
||||
chat_id=str(data["telegram"]["chat_id"]),
|
||||
)
|
||||
opts = data.get("options", {})
|
||||
region = None
|
||||
if "chart_window_region" in data:
|
||||
region = ROI(**data["chart_window_region"])
|
||||
return cls(
|
||||
window_title=data["window_title"],
|
||||
dot_roi=roi,
|
||||
@@ -158,6 +162,7 @@ class Config:
|
||||
canary=canary,
|
||||
discord=discord,
|
||||
telegram=telegram,
|
||||
chart_window_region=region,
|
||||
debounce_depth=int(opts.get("debounce_depth", 1)),
|
||||
loop_interval_s=float(opts.get("loop_interval_s", 5.0)),
|
||||
heartbeat_min=int(opts.get("heartbeat_min", 30)),
|
||||
|
||||
@@ -277,17 +277,37 @@ def _build_capture(cfg, capture_stub: bool = False):
|
||||
|
||||
return _stub_capture
|
||||
|
||||
# Windows live path
|
||||
# Live path — prefer region-based capture if calibrate saved one
|
||||
try:
|
||||
import mss # type: ignore[import-untyped]
|
||||
import pygetwindow as gw # type: ignore[import-untyped]
|
||||
except ImportError as exc:
|
||||
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"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():
|
||||
wins = gw.getWindowsWithTitle(cfg.window_title)
|
||||
if not wins:
|
||||
@@ -297,10 +317,8 @@ def _build_capture(cfg, capture_stub: bool = False):
|
||||
import cv2 # type: ignore[import-untyped]
|
||||
import numpy as np
|
||||
mon = {
|
||||
"top": win.top,
|
||||
"left": win.left,
|
||||
"width": win.width,
|
||||
"height": win.height,
|
||||
"top": win.top, "left": win.left,
|
||||
"width": win.width, "height": win.height,
|
||||
}
|
||||
img = sct.grab(mon)
|
||||
frame = np.array(img)
|
||||
|
||||
Reference in New Issue
Block a user