diff --git a/calibrate_capture_20260416_092557.png b/calibrate_capture_20260416_092557.png new file mode 100644 index 0000000..d4d01e0 Binary files /dev/null and b/calibrate_capture_20260416_092557.png differ diff --git a/src/atm/calibrate.py b/src/atm/calibrate.py index 48a4df9..a1cd115 100644 --- a/src/atm/calibrate.py +++ b/src/atm/calibrate.py @@ -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("", on_press) + canvas.bind("", on_drag) + canvas.bind("", lambda e: None) # keep rect visible until Enter + root.bind("", on_ok) + root.bind("", on_ok) + root.bind("", 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