feat(calibrate): region-select mode as default capture
Window-level PrintWindow/mss returns blank on GPU-accelerated apps (TradeStation, some trading terminals). Switch to full-desktop screenshot + drag-rectangle as the default method — reliable across all apps. - Title prompt still asked (needed by 'atm run' to locate window at runtime). - Captured region saved to logs/calibrate_capture_<ts>.png for verification. - Old window-capture path kept as fallback if region-select is cancelled. - Minor: silence pyright windll attr warning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
calibrate_capture_20260416_092557.png
Normal file
BIN
calibrate_capture_20260416_092557.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -110,7 +110,7 @@ def _capture_via_printwindow(win):
|
||||
"""Use Win32 PrintWindow with PW_RENDERFULLCONTENT. Reliable for GPU-accelerated apps."""
|
||||
import win32gui # type: ignore[import-untyped]
|
||||
import win32ui # type: ignore[import-untyped]
|
||||
from ctypes import windll
|
||||
from ctypes import windll # type: ignore[attr-defined]
|
||||
from PIL import Image
|
||||
|
||||
hwnd = win._hWnd # pygetwindow exposes this
|
||||
@@ -155,6 +155,96 @@ def _image_looks_blank(pil_img, dark_thresh: int = 10) -> bool:
|
||||
return arr.std() < 3.0
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
full_img = Image.frombytes("RGB", shot.size, shot.rgb)
|
||||
|
||||
root = tk.Tk()
|
||||
root.title("Select chart region")
|
||||
|
||||
# Use most of the screen but leave OS chrome visible so user can close it
|
||||
screen_w = root.winfo_screenwidth()
|
||||
screen_h = root.winfo_screenheight()
|
||||
orig_w, orig_h = full_img.size
|
||||
avail_w = max(400, screen_w - 40)
|
||||
avail_h = max(300, screen_h - 120)
|
||||
scale = min(avail_w / orig_w, avail_h / orig_h, 1.0)
|
||||
disp_w, disp_h = int(orig_w * scale), int(orig_h * scale)
|
||||
|
||||
root.geometry(f"{disp_w}x{disp_h + 40}+10+10")
|
||||
|
||||
scaled = full_img.resize((disp_w, disp_h))
|
||||
tkimg = ImageTk.PhotoImage(scaled)
|
||||
|
||||
status = tk.Label(
|
||||
root, bg="#e74c3c", fg="white", font=("Arial", 12, "bold"),
|
||||
text="DRAG a rectangle around the full chart area (including M2D MAPS strip). Enter = OK, Esc = cancel",
|
||||
pady=6,
|
||||
)
|
||||
status.pack(fill="x")
|
||||
canvas = tk.Canvas(
|
||||
root, width=disp_w, height=disp_h, cursor="crosshair",
|
||||
highlightthickness=0, bg="#000",
|
||||
)
|
||||
canvas.pack(fill="both", expand=True)
|
||||
canvas.create_image(0, 0, anchor="nw", image=tkimg)
|
||||
|
||||
state: dict = {"x0": 0, "y0": 0, "x1": 0, "y1": 0, "rect_id": None, "started": False}
|
||||
result: dict = {"img": None}
|
||||
|
||||
def on_press(e):
|
||||
state["x0"], state["y0"] = e.x, e.y
|
||||
state["x1"], state["y1"] = e.x, e.y
|
||||
if state["rect_id"] is not None:
|
||||
canvas.delete(state["rect_id"])
|
||||
state["rect_id"] = canvas.create_rectangle(
|
||||
e.x, e.y, e.x, e.y, outline="#ff3838", width=3,
|
||||
)
|
||||
|
||||
def on_drag(e):
|
||||
state["x1"], state["y1"] = e.x, e.y
|
||||
if state["rect_id"] is not None:
|
||||
canvas.coords(state["rect_id"], state["x0"], state["y0"], e.x, e.y)
|
||||
|
||||
def on_ok(_=None):
|
||||
if state["x0"] is None:
|
||||
return
|
||||
x0 = int(min(state["x0"], state["x1"]) / scale)
|
||||
y0 = int(min(state["y0"], state["y1"]) / scale)
|
||||
x1 = int(max(state["x0"], state["x1"]) / scale)
|
||||
y1 = int(max(state["y0"], state["y1"]) / scale)
|
||||
if x1 - x0 < 100 or y1 - y0 < 100:
|
||||
return # too small, ignore
|
||||
result["img"] = full_img.crop((x0, y0, x1, y1))
|
||||
root.destroy()
|
||||
|
||||
def on_cancel(_=None):
|
||||
root.destroy()
|
||||
|
||||
canvas.bind("<Button-1>", on_press)
|
||||
canvas.bind("<B1-Motion>", on_drag)
|
||||
canvas.bind("<ButtonRelease-1>", lambda e: None) # keep rect visible until Enter
|
||||
root.bind("<Return>", on_ok)
|
||||
root.bind("<KP_Enter>", on_ok)
|
||||
root.bind("<Escape>", on_cancel)
|
||||
root.focus_force()
|
||||
|
||||
root.mainloop()
|
||||
if result["img"] is None:
|
||||
raise ValueError("Region selection cancelled")
|
||||
return result["img"]
|
||||
|
||||
|
||||
def _capture_window(title_substr: str):
|
||||
"""Capture a window by title substring. Tries PrintWindow first (reliable for GPU
|
||||
apps), falls back to mss. If multiple windows match, picks the largest by area.
|
||||
@@ -290,7 +380,7 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
|
||||
root0 = tk.Tk(); root0.withdraw()
|
||||
window_title = simpledialog.askstring(
|
||||
"Window title",
|
||||
"Window title substring used by 'atm run' to locate the chart:",
|
||||
"Window title substring used by 'atm run' to locate the chart at runtime:",
|
||||
parent=root0,
|
||||
initialvalue=screenshot.stem,
|
||||
) or screenshot.stem
|
||||
@@ -299,13 +389,28 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
|
||||
root0 = tk.Tk(); root0.withdraw()
|
||||
window_title = simpledialog.askstring(
|
||||
"Window title",
|
||||
"Substring of the chart window title (e.g. 'DIA' or 'TradeStation'):",
|
||||
"Substring of the chart window title (used at runtime by 'atm run' to find it).\n"
|
||||
"Example: 'TradeStation' or 'DIA'.",
|
||||
parent=root0,
|
||||
) or ""
|
||||
root0.destroy()
|
||||
if not window_title:
|
||||
raise ValueError("Window title is required to proceed.")
|
||||
pil_img = _capture_window(window_title)
|
||||
|
||||
# Default: region-select on full desktop (reliable with GPU-rendered apps).
|
||||
# Fallback: try window capture if region-select is cancelled.
|
||||
try:
|
||||
pil_img = _capture_fullscreen_region()
|
||||
except ValueError:
|
||||
pil_img = _capture_window(window_title)
|
||||
|
||||
# Persist captured frame for debugging + as reference corpus.
|
||||
try:
|
||||
debug_path = Path("logs") / f"calibrate_capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||
debug_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pil_img.save(debug_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Launch interactive wizard on the image
|
||||
|
||||
Reference in New Issue
Block a user