sos-bot/core/vision.py
bachko 3309a47e10 Initial commit: SoS bot framework
- ADB client (connect, tap, swipe, screencap, app control)
- Vision module (in-memory screenshot + OpenCV template matching)
- Action loader (auto-discovers .py files in actions/)
- Engine (screenshot → detect → act loop with YAML playbook)
- 12 built-in actions: tap, click, wait, swipe, find,
  find_and_click, wait_until, click_seq, keyevent, app, log
- Example custom action template
- Debug screenshot tool for template creation
- Detailed plan.md with 20 actions across 9 phases
- Sample playbook with 10 steps
2026-07-09 13:52:31 +00:00

181 lines
6.4 KiB
Python

"""
Vision — in-memory screenshot capture + OpenCV template matching.
No files are written to disk for screenshots; everything lives in numpy arrays.
"""
import logging
import numpy as np
import cv2
logger = logging.getLogger("sos.vision")
class Vision:
"""Captures screenshots and provides template-matching utilities."""
def __init__(self, adb_client):
self.adb = adb_client
self._screenshot = None # current frame (BGR numpy array)
self._screenshot_gray = None # grayscale version (cached)
self._size = None # (width, height)
# ── capture ──────────────────────────────────────────────
def capture(self) -> np.ndarray:
"""
Take a fresh screenshot from the device.
Returns BGR numpy array. Never touches disk.
"""
png_bytes = self.adb.screencap()
# Decode PNG from memory buffer
arr = np.frombuffer(png_bytes, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
raise RuntimeError("Failed to decode screencap — corrupt PNG data?")
self._screenshot = img
self._screenshot_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
self._size = (img.shape[1], img.shape[0])
return img
@property
def current(self) -> np.ndarray:
"""Return the most recent screenshot (call capture() first)."""
if self._screenshot is None:
raise RuntimeError("No screenshot — call capture() first")
return self._screenshot
@property
def size(self):
"""(width, height) of the device screen."""
if self._size is None:
self.capture()
return self._size
# ── template matching ────────────────────────────────────
def find_template(
self,
template_path: str,
confidence: float = 0.8,
region: tuple = None,
) -> tuple | None:
"""
Locate a template image on the current screenshot.
Args:
template_path: Path to the reference PNG.
confidence: Minimum match ratio (0-1). Higher = stricter.
region: (x1, y1, x2, y2) subregion to search, or None = full.
Returns:
(center_x, center_y) of the best match, or None.
"""
template_rgb = cv2.imread(template_path, cv2.IMREAD_COLOR)
if template_rgb is None:
logger.warning("Cannot load template: %s", template_path)
return None
template_gray = cv2.cvtColor(template_rgb, cv2.COLOR_BGR2GRAY)
tw, th = template_gray.shape[1], template_gray.shape[0]
# Use the cached grayscale screenshot
search_img = self._screenshot_gray
# Crop to region if specified
if region:
x1, y1, x2, y2 = region
search_img = search_img[y1:y2, x1:x2]
if search_img.shape[0] < th or search_img.shape[1] < tw:
logger.debug("Template %s bigger than search region", template_path)
return None
result = cv2.matchTemplate(search_img, template_gray, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(result)
if max_val < confidence:
logger.debug(
"Template %s: best=%.3f < threshold=%.3f",
template_path, max_val, confidence,
)
return None
cx = max_loc[0] + tw // 2
cy = max_loc[1] + th // 2
if region:
cx += region[0]
cy += region[1]
logger.info(
"Found %s at (%.3f conf) → click (%d, %d)",
template_path, max_val, cx, cy,
)
return (cx, cy)
def find_multiple(
self,
template_path: str,
confidence: float = 0.8,
max_results: int = 5,
region: tuple = None,
) -> list[tuple]:
"""
Find ALL matches of a template (non-max-suppressed).
Returns list of (cx, cy) centers sorted by confidence descending.
"""
template_rgb = cv2.imread(template_path, cv2.IMREAD_COLOR)
if template_rgb is None:
return []
template_gray = cv2.cvtColor(template_rgb, cv2.COLOR_BGR2GRAY)
tw, th = template_gray.shape[1], template_gray.shape[0]
search_img = self._screenshot_gray
if region:
x1, y1, x2, y2 = region
search_img = search_img[y1:y2, x1:x2]
result = cv2.matchTemplate(search_img, template_gray, cv2.TM_CCOEFF_NORMED)
locations = np.where(result >= confidence)
points = []
for pt in zip(*locations[::-1]): # (x, y) pairs
cx = pt[0] + tw // 2
cy = pt[1] + th // 2
if region:
cx += region[0]
cy += region[1]
points.append((cx, cy))
# Non-max suppression: group nearby detections
if not points:
return []
points = np.array(points)
indices = cv2.dnn.NMSBoxes(
[(*p, tw, th) for p in points],
[1.0] * len(points),
0.5,
0.3,
)
if len(indices) == 0:
return []
result_pts = [tuple(points[i]) for i in indices.flatten()]
return result_pts[:max_results]
# ── pixel checking ───────────────────────────────────────
def pixel_color(self, x: int, y: int) -> tuple:
"""Get the BGR color of a single pixel on the current screenshot."""
return tuple(self.current[y, x].tolist())
def color_match(self, x: int, y: int, target_bgr: tuple, tolerance: int = 10) -> bool:
"""Check if pixel at (x,y) is within tolerance of target_bgr."""
actual = self.pixel_color(x, y)
return all(abs(actual[c] - target_bgr[c]) <= tolerance for c in range(3))
# ── debug ────────────────────────────────────────────────
def save_debug_screenshot(self, path: str):
"""Save current screenshot to disk for debugging (only when requested)."""
if self._screenshot is not None:
cv2.imwrite(path, self._screenshot)
logger.info("Debug screenshot saved: %s", path)