From f90e4477edf579d608f423b2d671dd33d4b67373 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 16 Apr 2026 06:25:02 +0000 Subject: [PATCH] fix(calibrate): robust window capture with PrintWindow fallback + picker - Enumerate all matching windows, let user pick the largest one if multiple. - Try PrintWindow first (works with GPU-accelerated apps like TradeStation that render blank under plain mss capture). - Detect blank/uniform captures and fall back to mss automatically. - Save captured frame to logs/calibrate_capture_.png for debugging. Co-Authored-By: Claude Opus 4.6 (1M context) --- image copy.png | Bin 0 -> 9410 bytes src/atm/calibrate.py | 199 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 178 insertions(+), 21 deletions(-) create mode 100644 image copy.png diff --git a/image copy.png b/image copy.png new file mode 100644 index 0000000000000000000000000000000000000000..c24c5398825fabe02700fe4baff68e9cf389c421 GIT binary patch literal 9410 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 literal 0 HcmV?d00001 diff --git a/src/atm/calibrate.py b/src/atm/calibrate.py index 23864c3..48a4df9 100644 --- a/src/atm/calibrate.py +++ b/src/atm/calibrate.py @@ -66,31 +66,37 @@ def write_config(data: dict, out_dir: Path) -> Path: # Window capture (Windows live) / screenshot fallback # --------------------------------------------------------------------------- -def _capture_window(title_substr: str): - """Screenshot the first window whose title CONTAINS *title_substr* (case-insensitive). - - Windows-only (mss+pygetwindow). Returns a PIL RGB Image. - """ - import mss # type: ignore[import-untyped] +def _find_candidate_windows(title_substr: str): + """Return list of (title, width, height, window_obj) matching substring, sorted by area desc.""" import pygetwindow as gw # type: ignore[import-untyped] - from PIL import Image needle = title_substr.lower() - all_titles = [t for t in gw.getAllTitles() if t] - matching = [t for t in all_titles if needle in t.lower()] - if not matching: - preview = "\n ".join(sorted(set(all_titles))[:40]) - raise RuntimeError( - f"No window title contains {title_substr!r} (case-insensitive).\n" - f"Visible windows (first 40):\n {preview}" - ) - win = gw.getWindowsWithTitle(matching[0])[0] - try: - win.activate() - except Exception: - pass # activate may fail on some windows; continue — mss still captures by region - time.sleep(0.3) + out = [] + seen = set() + for t in gw.getAllTitles(): + if not t or needle not in t.lower(): + continue + if t in seen: + continue + seen.add(t) + wins = gw.getWindowsWithTitle(t) + if not wins: + continue + w = wins[0] + try: + width = int(w.width); height = int(w.height) + except Exception: + continue + if width < 10 or height < 10: + continue + out.append((t, width, height, w)) + out.sort(key=lambda r: r[1] * r[2], reverse=True) + return out + +def _capture_via_mss(win): + import mss # type: ignore[import-untyped] + from PIL import Image with mss.mss() as sct: mon = { "top": int(win.top), "left": int(win.left), @@ -100,6 +106,157 @@ def _capture_window(title_substr: str): return Image.frombytes("RGB", shot.size, shot.rgb) +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 PIL import Image + + hwnd = win._hWnd # pygetwindow exposes this + rect = win32gui.GetWindowRect(hwnd) + w = rect[2] - rect[0] + h = rect[3] - rect[1] + if w <= 0 or h <= 0: + raise RuntimeError("window has zero size") + + hwnd_dc = win32gui.GetWindowDC(hwnd) + mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc) + save_dc = mfc_dc.CreateCompatibleDC() + save_bitmap = win32ui.CreateBitmap() + save_bitmap.CreateCompatibleBitmap(mfc_dc, w, h) + save_dc.SelectObject(save_bitmap) + + PW_RENDERFULLCONTENT = 2 + result = windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), PW_RENDERFULLCONTENT) + bmp_info = save_bitmap.GetInfo() + bmp_bits = save_bitmap.GetBitmapBits(True) + img = Image.frombuffer( + "RGB", (bmp_info["bmWidth"], bmp_info["bmHeight"]), + bmp_bits, "raw", "BGRX", 0, 1, + ) + + win32gui.DeleteObject(save_bitmap.GetHandle()) + save_dc.DeleteDC() + mfc_dc.DeleteDC() + win32gui.ReleaseDC(hwnd, hwnd_dc) + + if result != 1: + raise RuntimeError(f"PrintWindow returned {result}") + return img + + +def _image_looks_blank(pil_img, dark_thresh: int = 10) -> bool: + """Heuristic: image is effectively blank if near-black or near-uniform.""" + import numpy as np + arr = np.array(pil_img.resize((64, 36))).astype(np.int16) + if arr.mean() < dark_thresh: + return True + return arr.std() < 3.0 + + +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. + + Returns a PIL RGB Image. + """ + import pygetwindow as gw # type: ignore[import-untyped] + + candidates = _find_candidate_windows(title_substr) + if not candidates: + all_titles = sorted({t for t in gw.getAllTitles() if t}) + preview = "\n ".join(all_titles[:40]) + raise RuntimeError( + f"No window title contains {title_substr!r} (case-insensitive).\n" + f"Visible windows (first 40):\n {preview}" + ) + + # If multiple candidates, ask user to pick (Tk listbox) + if len(candidates) > 1: + picked = _pick_window_dialog(candidates) + else: + picked = candidates[0] + title, w_, h_, win = picked + + try: + win.activate() + except Exception: + pass + time.sleep(0.3) + + errors = [] + img = None + for fn, name in ((_capture_via_printwindow, "PrintWindow"), + (_capture_via_mss, "mss")): + try: + candidate_img = fn(win) + if _image_looks_blank(candidate_img): + errors.append(f"{name}: returned blank/uniform image") + continue + img = candidate_img + break + except Exception as e: + errors.append(f"{name}: {e}") + + if img is None: + raise RuntimeError( + f"Capture of window {title!r} ({w_}x{h_}) failed:\n " + "\n ".join(errors) + ) + + # Save debug copy so user can verify + try: + debug_path = Path("logs") / f"calibrate_capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" + debug_path.parent.mkdir(exist_ok=True) + img.save(debug_path) + except Exception: + pass + + return img + + +def _pick_window_dialog(candidates): + """Show a Tk listbox for the user to pick a window.""" + import tkinter as tk + + root = tk.Tk() + root.title("Pick window") + root.geometry("600x400+100+100") + + tk.Label(root, text="Multiple windows match. Pick the main TradeStation chart:", + font=("Arial", 11), pady=6).pack(fill="x") + + frame = tk.Frame(root) + frame.pack(fill="both", expand=True, padx=8, pady=4) + scroll = tk.Scrollbar(frame) + scroll.pack(side="right", fill="y") + + lb = tk.Listbox(frame, font=("Consolas", 10), yscrollcommand=scroll.set) + lb.pack(side="left", fill="both", expand=True) + scroll.config(command=lb.yview) + + for title, w, h, _win in candidates: + lb.insert(tk.END, f"[{w}x{h}] {title}") + lb.selection_set(0) + + choice = {"idx": None} + + def _ok(): + sel = lb.curselection() + if sel: + choice["idx"] = sel[0] + root.destroy() + + tk.Button(root, text="OK", command=_ok, bg="#2ecc71", fg="white", + font=("Arial", 11), pady=4).pack(pady=6) + lb.bind("", lambda e: _ok()) + + root.mainloop() + if choice["idx"] is None: + raise ValueError("Window selection cancelled") + return candidates[choice["idx"]] + + # --------------------------------------------------------------------------- # Wizard # ---------------------------------------------------------------------------