Files
hkuds--cli-anything/macrocli/agent-harness/cli_anything/macrocli/backends/visual_anchor.py
T
2026-07-13 12:55:23 +08:00

731 lines
26 KiB
Python

"""VisualAnchorBackend — find UI elements by image template and interact.
Approach:
1. Capture full screen with mss (pure Python, cross-platform)
2. Find the template image inside the screenshot using numpy correlation
3. Use pynput to click / type / scroll at the discovered coordinates
This backend never uses hardcoded absolute coordinates in macro definitions.
Instead, macros store small PNG templates of the UI elements they want to
interact with, and coordinates are computed at runtime.
Supported actions:
click_image — find template on screen and click its center
click_relative — click at (x_pct, y_pct) relative to a named window bounds
wait_image — wait until template appears on screen
type_text — type a string (keyboard injection, no coordinates needed)
hotkey — send a keyboard shortcut
scroll — scroll at the position of a template image
capture_region — screenshot a region and save it (for template creation)
Example YAML steps:
- backend: visual_anchor
action: click_image
params:
template: templates/export_button.png
confidence: 0.85 # 0..1, lower = more tolerant
timeout_ms: 5000 # wait this long for the image to appear
- backend: visual_anchor
action: click_relative
params:
window_title: "Draw.io" # partial window title match
x_pct: 0.5 # 50% across the window
y_pct: 0.1 # 10% down the window
- backend: visual_anchor
action: type_text
params:
text: "output.png"
interval_ms: 30 # delay between key presses
- backend: visual_anchor
action: hotkey
params:
keys: ctrl+shift+e # + separated
- backend: visual_anchor
action: wait_image
params:
template: templates/dialog_ok.png
timeout_ms: 10000
- backend: visual_anchor
action: capture_region
params:
output: templates/my_button.png
x: 100
y: 200
width: 80
height: 30
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import Optional
from cli_anything.macrocli.backends.base import Backend, BackendContext, StepResult
from cli_anything.macrocli.core.macro_model import MacroStep, substitute
def _x_env() -> dict:
"""Return env dict with DISPLAY set, for subprocess calls to X tools."""
env = os.environ.copy()
if "DISPLAY" not in env:
env["DISPLAY"] = ":0"
return env
# ── lazy imports (only needed when backend is actually used) ──────────────────
def _require_numpy():
try:
import numpy as np
return np
except ImportError:
raise ImportError(
"numpy is required for the visual_anchor backend.\n"
" pip install numpy"
)
def _require_pil():
try:
from PIL import Image
return Image
except ImportError:
raise ImportError(
"Pillow is required for the visual_anchor backend.\n"
" pip install Pillow"
)
def _require_mss():
try:
import mss
return mss
except ImportError:
raise ImportError(
"mss is required for screen capture.\n"
" pip install mss"
)
def _require_pynput():
try:
from pynput import mouse as _m, keyboard as _k
return _m, _k
except ImportError:
raise ImportError(
"pynput is required for mouse/keyboard control.\n"
" pip install pynput"
)
# ── template matching ─────────────────────────────────────────────────────────
def _load_image_as_array(path: str):
"""Load an image file as a numpy uint8 RGB array."""
np = _require_numpy()
Image = _require_pil()
img = Image.open(path).convert("RGB")
return np.array(img, dtype=np.uint8)
def _screenshot_as_array():
"""Capture the full screen and return as numpy RGB array."""
np = _require_numpy()
mss = _require_mss()
Image = _require_pil()
with mss.mss() as sct:
# Monitor 1 = first physical monitor (index 0 = all monitors combined)
monitor = sct.monitors[1]
raw = sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
return np.array(img, dtype=np.uint8), monitor
def _find_template(
screen: "np.ndarray",
template: "np.ndarray",
confidence: float = 0.85,
step: int = 1,
) -> Optional[tuple[int, int, float]]:
"""Find template in screen. Returns (center_x, center_y, score) or None.
score is 0..1 where 1 = perfect match.
Confidence threshold: only return match if score >= confidence.
"""
np = _require_numpy()
sh, sw = screen.shape[:2]
th, tw = template.shape[:2]
if th > sh or tw > sw:
return None
screen_f = screen.astype(np.float32)
tmpl_f = template.astype(np.float32)
tmpl_norm = tmpl_f - tmpl_f.mean()
tmpl_std = tmpl_f.std()
if tmpl_std < 1e-6:
return None # blank template
best_score = -1.0
best_pos: Optional[tuple[int, int]] = None
for y in range(0, sh - th + 1, step):
for x in range(0, sw - tw + 1, step):
region = screen_f[y:y + th, x:x + tw]
region_norm = region - region.mean()
region_std = region.std()
if region_std < 1e-6:
continue
score = float(
(region_norm * tmpl_norm).sum()
/ (th * tw * region_std * tmpl_std)
)
if score > best_score:
best_score = score
best_pos = (x + tw // 2, y + th // 2)
if best_pos is None or best_score < confidence:
return None
return (best_pos[0], best_pos[1], best_score)
def _wait_for_template(
template_array: "np.ndarray",
confidence: float,
timeout_ms: int,
poll_ms: int = 300,
) -> Optional[tuple[int, int, float]]:
"""Poll until template found on screen or timeout. Returns match or None."""
deadline = time.time() + timeout_ms / 1000.0
while time.time() < deadline:
screen, _ = _screenshot_as_array()
result = _find_template(screen, template_array, confidence)
if result is not None:
return result
time.sleep(poll_ms / 1000.0)
return None
# ── window bounds helper ──────────────────────────────────────────────────────
def _get_window_bounds(title_fragment: str) -> Optional[dict]:
"""Return {x, y, width, height} of the first window whose title contains
title_fragment. Works on Linux (xwininfo + wmctrl) and macOS (AppleScript).
Returns None if not found or not available.
"""
import subprocess
import shutil
import platform
system = platform.system()
if system == "Linux":
# Try wmctrl first (most reliable)
if shutil.which("wmctrl"):
r = subprocess.run(
["wmctrl", "-lG"], capture_output=True, text=True, env=_x_env()
)
for line in r.stdout.splitlines():
parts = line.split(None, 9)
if len(parts) >= 9 and title_fragment.lower() in parts[-1].lower():
try:
# wmctrl -lG: wid desktop x y w h host title
x, y, w, h = int(parts[2]), int(parts[3]), int(parts[4]), int(parts[5])
return {"x": x, "y": y, "width": w, "height": h}
except ValueError:
pass
# Fallback: xwininfo
if shutil.which("xwininfo"):
r = subprocess.run(
["xwininfo", "-name", title_fragment],
capture_output=True, text=True, env=_x_env()
)
bounds = {}
for line in r.stdout.splitlines():
line = line.strip()
if "Absolute upper-left X:" in line:
bounds["x"] = int(line.split()[-1])
elif "Absolute upper-left Y:" in line:
bounds["y"] = int(line.split()[-1])
elif "Width:" in line:
bounds["width"] = int(line.split()[-1])
elif "Height:" in line:
bounds["height"] = int(line.split()[-1])
if len(bounds) == 4:
return bounds
elif system == "Darwin":
# macOS: use AppleScript to get window position
script = f"""
tell application "System Events"
set ws to every window of every process whose name contains "{title_fragment}"
if ws is not {{}} then
set w to item 1 of item 1 of ws
set p to position of w
set s to size of w
return (item 1 of p as text) & "," & (item 2 of p as text) & "," & (item 1 of s as text) & "," & (item 2 of s as text)
end if
end tell
"""
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
if r.returncode == 0 and r.stdout.strip():
parts = r.stdout.strip().split(",")
if len(parts) == 4:
try:
return {
"x": int(parts[0]), "y": int(parts[1]),
"width": int(parts[2]), "height": int(parts[3])
}
except ValueError:
pass
return None
# ── Backend ───────────────────────────────────────────────────────────────────
class VisualAnchorBackend(Backend):
"""Find UI elements by image template and interact with them."""
name = "visual_anchor"
priority = 75 # between file_transform(70) and gui_macro(80)
def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult:
t0 = time.time()
action = step.action
p = substitute(step.params, params)
if context.dry_run:
return StepResult(
success=True,
output={"dry_run": True, "action": action},
backend_used=self.name,
duration_ms=(time.time() - t0) * 1000,
)
dispatch = {
"click_image": self._click_image,
"click_relative": self._click_relative,
"wait_image": self._wait_image,
"type_text": self._type_text,
"hotkey": self._hotkey,
"scroll": self._scroll,
"drag": self._drag,
"drag_relative": self._drag_relative,
"capture_region": self._capture_region,
}
handler = dispatch.get(action)
if handler is None:
return StepResult(
success=False,
error=f"VisualAnchorBackend: unknown action '{action}'. "
f"Available: {sorted(dispatch)}",
backend_used=self.name,
duration_ms=(time.time() - t0) * 1000,
)
try:
output = handler(p, context)
return StepResult(
success=True,
output=output or {},
backend_used=self.name,
duration_ms=(time.time() - t0) * 1000,
)
except Exception as exc:
return StepResult(
success=False,
error=f"VisualAnchorBackend.{action}: {exc}",
backend_used=self.name,
duration_ms=(time.time() - t0) * 1000,
)
def is_available(self) -> bool:
for pkg in ("mss", "numpy", "PIL", "pynput"):
try:
__import__(pkg if pkg != "PIL" else "PIL.Image")
except ImportError:
return False
return True
# ── Actions ──────────────────────────────────────────────────────────────
def _click_image(self, p: dict, context: BackendContext) -> dict:
"""Find template on screen and click its center."""
template_path = p.get("template", "")
if not template_path or not Path(template_path).is_file():
raise FileNotFoundError(
f"Template image not found: '{template_path}'. "
"Use 'macro record' or 'capture_region' to create one."
)
confidence = float(p.get("confidence", 0.85))
timeout_ms = int(p.get("timeout_ms", 5000))
button = p.get("button", "left") # left | right | middle
double = bool(p.get("double", False))
template_arr = _load_image_as_array(template_path)
match = _wait_for_template(template_arr, confidence, timeout_ms)
if match is None:
raise RuntimeError(
f"Template not found on screen after {timeout_ms}ms: {template_path} "
f"(confidence={confidence})"
)
cx, cy, score = match
_mouse_click(cx, cy, button=button, double=double)
return {
"clicked_at": [cx, cy],
"match_score": round(score, 4),
"template": template_path,
}
def _click_relative(self, p: dict, context: BackendContext) -> dict:
"""Click at a fractional position within a named window."""
title = p.get("window_title", "")
x_pct = float(p.get("x_pct", 0.5))
y_pct = float(p.get("y_pct", 0.5))
button = p.get("button", "left")
double = bool(p.get("double", False))
if title:
bounds = _get_window_bounds(title)
if bounds is None:
raise RuntimeError(
f"Window not found: '{title}'. "
"Make sure the application is open and the title matches."
)
cx = int(bounds["x"] + bounds["width"] * x_pct)
cy = int(bounds["y"] + bounds["height"] * y_pct)
else:
# Relative to full screen
_, monitor = _screenshot_as_array()
cx = int(monitor["width"] * x_pct)
cy = int(monitor["height"] * y_pct)
_mouse_click(cx, cy, button=button, double=double)
return {"clicked_at": [cx, cy], "x_pct": x_pct, "y_pct": y_pct}
def _wait_image(self, p: dict, context: BackendContext) -> dict:
"""Wait until a template image appears on screen."""
template_path = p.get("template", "")
if not template_path or not Path(template_path).is_file():
raise FileNotFoundError(f"Template image not found: '{template_path}'")
confidence = float(p.get("confidence", 0.85))
timeout_ms = int(p.get("timeout_ms", 10000))
template_arr = _load_image_as_array(template_path)
match = _wait_for_template(template_arr, confidence, timeout_ms)
if match is None:
raise RuntimeError(
f"Template never appeared within {timeout_ms}ms: {template_path}"
)
cx, cy, score = match
return {"found_at": [cx, cy], "match_score": round(score, 4)}
def _type_text(self, p: dict, context: BackendContext) -> dict:
"""Type a string using keyboard injection."""
text = p.get("text", "")
interval_ms = int(p.get("interval_ms", 30))
if not text:
raise ValueError("type_text requires 'text' param.")
_, keyboard_mod = _require_pynput()
ctrl = keyboard_mod.Controller()
import time as _time
for char in text:
ctrl.press(char)
ctrl.release(char)
if interval_ms > 0:
_time.sleep(interval_ms / 1000.0)
return {"typed": len(text), "text_preview": text[:40]}
def _hotkey(self, p: dict, context: BackendContext) -> dict:
"""Send a keyboard shortcut (e.g. ctrl+shift+e)."""
keys_str = p.get("keys", "")
if not keys_str:
raise ValueError("hotkey requires 'keys' param (e.g. 'ctrl+s').")
_, keyboard_mod = _require_pynput()
Key = keyboard_mod.Key
ctrl = keyboard_mod.Controller()
# Parse keys: ctrl+shift+e → [Key.ctrl, Key.shift, 'e']
key_objects = []
for k in keys_str.split("+"):
k = k.strip().lower()
# Map common names to pynput Key enum
mapping = {
"ctrl": Key.ctrl, "control": Key.ctrl,
"shift": Key.shift,
"alt": Key.alt,
"cmd": Key.cmd, "super": Key.cmd, "win": Key.cmd,
"enter": Key.enter, "return": Key.enter,
"tab": Key.tab,
"esc": Key.esc, "escape": Key.esc,
"space": Key.space,
"backspace": Key.backspace,
"delete": Key.delete,
"up": Key.up, "down": Key.down,
"left": Key.left, "right": Key.right,
"home": Key.home, "end": Key.end,
"f1": Key.f1, "f2": Key.f2, "f3": Key.f3, "f4": Key.f4,
"f5": Key.f5, "f6": Key.f6, "f7": Key.f7, "f8": Key.f8,
"f9": Key.f9, "f10": Key.f10, "f11": Key.f11, "f12": Key.f12,
}
if k in mapping:
key_objects.append(mapping[k])
elif len(k) == 1:
key_objects.append(k)
else:
raise ValueError(f"Unknown key name: '{k}'")
# Press all, then release all in reverse
for k in key_objects:
ctrl.press(k)
for k in reversed(key_objects):
ctrl.release(k)
return {"hotkey": keys_str}
def _scroll(self, p: dict, context: BackendContext) -> dict:
"""Scroll at the position of a template image."""
template_path = p.get("template", "")
dx = int(p.get("dx", 0))
dy = int(p.get("dy", -3)) # negative = scroll down
timeout_ms = int(p.get("timeout_ms", 5000))
confidence = float(p.get("confidence", 0.85))
if template_path and Path(template_path).is_file():
template_arr = _load_image_as_array(template_path)
match = _wait_for_template(template_arr, confidence, timeout_ms)
if match is None:
raise RuntimeError(f"Template not found: {template_path}")
cx, cy, _ = match
else:
# Scroll at current mouse position
mouse_mod, _ = _require_pynput()
pos = mouse_mod.Controller().position
cx, cy = int(pos[0]), int(pos[1])
mouse_mod, _ = _require_pynput()
mouse_ctrl = mouse_mod.Controller()
mouse_ctrl.position = (cx, cy)
mouse_ctrl.scroll(dx, dy)
return {"scrolled_at": [cx, cy], "dx": dx, "dy": dy}
def _drag(self, p: dict, context: BackendContext) -> dict:
"""Drag from one template image to another (or to absolute coords).
Params:
from_template: path to template image for drag start (optional)
to_template: path to template image for drag end (optional)
from_x / from_y: fallback absolute coords if no from_template
to_x / to_y: fallback absolute coords if no to_template
button: left | right | middle (default left)
duration_ms: how long to hold during drag (default 200)
confidence: template match threshold (default 0.85)
timeout_ms: how long to wait for templates (default 5000)
"""
button = p.get("button", "left")
duration_ms = int(p.get("duration_ms", 200))
confidence = float(p.get("confidence", 0.85))
timeout_ms = int(p.get("timeout_ms", 5000))
# Resolve start position
from_tmpl = p.get("from_template", "")
if from_tmpl and Path(from_tmpl).is_file():
tmpl = _load_image_as_array(from_tmpl)
match = _wait_for_template(tmpl, confidence, timeout_ms)
if match is None:
raise RuntimeError(f"drag: from_template not found: {from_tmpl}")
fx, fy = match[0], match[1]
else:
fx = int(p.get("from_x", 0))
fy = int(p.get("from_y", 0))
# Resolve end position
to_tmpl = p.get("to_template", "")
if to_tmpl and Path(to_tmpl).is_file():
tmpl = _load_image_as_array(to_tmpl)
match = _wait_for_template(tmpl, confidence, timeout_ms)
if match is None:
raise RuntimeError(f"drag: to_template not found: {to_tmpl}")
tx, ty = match[0], match[1]
else:
tx = int(p.get("to_x", fx))
ty = int(p.get("to_y", fy))
_mouse_drag(fx, fy, tx, ty, button=button, duration_ms=duration_ms)
return {"dragged_from": [fx, fy], "dragged_to": [tx, ty]}
def _drag_relative(self, p: dict, context: BackendContext) -> dict:
"""Drag within a window using fractional coordinates.
Params:
window_title: partial window title (uses focused window if empty)
from_x_pct: drag start x as fraction of window width
from_y_pct: drag start y as fraction of window height
to_x_pct: drag end x as fraction of window width
to_y_pct: drag end y as fraction of window height
button: left | right | middle (default left)
duration_ms: hold duration in ms (default 200)
"""
title = p.get("window_title", "")
button = p.get("button", "left")
duration_ms = int(p.get("duration_ms", 200))
if title:
bounds = _get_window_bounds(title)
if bounds is None:
raise RuntimeError(f"drag_relative: window not found: '{title}'")
wx, wy = bounds["x"], bounds["y"]
ww, wh = bounds["width"], bounds["height"]
else:
_, monitor = _screenshot_as_array()
wx, wy = 0, 0
ww, wh = monitor["width"], monitor["height"]
fx = int(wx + ww * float(p.get("from_x_pct", 0.0)))
fy = int(wy + wh * float(p.get("from_y_pct", 0.0)))
tx = int(wx + ww * float(p.get("to_x_pct", 1.0)))
ty = int(wy + wh * float(p.get("to_y_pct", 1.0)))
_mouse_drag(fx, fy, tx, ty, button=button, duration_ms=duration_ms)
return {
"dragged_from": [fx, fy],
"dragged_to": [tx, ty],
"from_pct": [p.get("from_x_pct"), p.get("from_y_pct")],
"to_pct": [p.get("to_x_pct"), p.get("to_y_pct")],
}
def _capture_region(self, p: dict, context: BackendContext) -> dict:
"""Screenshot a region of the screen and save as a template."""
output_path = p.get("output", "")
if not output_path:
raise ValueError("capture_region requires 'output' param.")
x = int(p.get("x", 0))
y = int(p.get("y", 0))
width = int(p.get("width", 100))
height = int(p.get("height", 50))
mss = _require_mss()
Image = _require_pil()
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with mss.mss() as sct:
region = {"left": x, "top": y, "width": width, "height": height}
raw = sct.grab(region)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
img.save(output_path)
size = Path(output_path).stat().st_size
return {
"saved": output_path,
"region": [x, y, width, height],
"file_size": size,
}
# ── pynput mouse helpers ──────────────────────────────────────────────────────
def _mouse_click(x: int, y: int, button: str = "left", double: bool = False):
"""Move mouse to (x, y) and click."""
mouse_mod, _ = _require_pynput()
Button = mouse_mod.Button
ctrl = mouse_mod.Controller()
btn_map = {
"left": Button.left,
"right": Button.right,
"middle": Button.middle,
}
btn = btn_map.get(button.lower(), Button.left)
ctrl.position = (x, y)
time.sleep(0.05)
ctrl.press(btn)
ctrl.release(btn)
if double:
time.sleep(0.08)
ctrl.press(btn)
ctrl.release(btn)
def _mouse_drag(
fx: int, fy: int, tx: int, ty: int,
button: str = "left", duration_ms: int = 200
):
"""Press at (fx, fy), move to (tx, ty) over duration_ms, release.
Tries xdotool first (works with Qt5/KDE apps), falls back to pynput.
"""
import shutil, subprocess, os
env = os.environ.copy()
if "DISPLAY" not in env:
env["DISPLAY"] = ":0"
if shutil.which("xdotool"):
# xdotool is more reliable with Qt5 apps
steps = max(5, duration_ms // 30)
subprocess.run(["xdotool", "mousemove", str(fx), str(fy)], env=env)
time.sleep(0.05)
subprocess.run(["xdotool", "mousedown", "1"], env=env)
time.sleep(0.05)
for i in range(1, steps + 1):
ix = int(fx + (tx - fx) * i / steps)
iy = int(fy + (ty - fy) * i / steps)
subprocess.run(["xdotool", "mousemove", str(ix), str(iy)], env=env)
time.sleep(duration_ms / 1000.0 / steps)
subprocess.run(["xdotool", "mousemove", str(tx), str(ty)], env=env)
time.sleep(0.05)
subprocess.run(["xdotool", "mouseup", "1"], env=env)
return
# Fallback: pynput
mouse_mod, _ = _require_pynput()
Button = mouse_mod.Button
ctrl = mouse_mod.Controller()
btn_map = {"left": Button.left, "right": Button.right, "middle": Button.middle}
btn = btn_map.get(button.lower(), Button.left)
ctrl.position = (fx, fy)
time.sleep(0.05)
ctrl.press(btn)
time.sleep(0.05)
steps = max(10, duration_ms // 20)
step_sleep = duration_ms / 1000.0 / steps
for i in range(1, steps + 1):
ix = int(fx + (tx - fx) * i / steps)
iy = int(fy + (ty - fy) * i / steps)
ctrl.position = (ix, iy)
time.sleep(step_sleep)
ctrl.position = (tx, ty)
time.sleep(0.05)
ctrl.release(btn)