"""L1 OpenCVBackend:基于 OpenCV 的模板匹配后端。 提供多尺度模板匹配能力,供 LocatorRegistry/Actions 在 find_element 时 优先用图像定位(精确度高),失败再回退几何兜底。 模板缓存采用 LRU(OrderedDict),上限 max_cache_size=20,避免内存膨胀。 所有 numpy 中间变量显式 del,及时释放 GIL 外的内存。 依赖:opencv-python-headless(容器内已安装)、numpy。 """ from __future__ import annotations import asyncio import logging import os import time from collections import OrderedDict from typing import Optional import cv2 import numpy as np logger = logging.getLogger("woc-bridge") class OpenCVBackend: """OpenCV 模板匹配后端。 Attributes: template_dir: 模板根目录(如 /app/bridge/woc_bridge/ui/profiles/templates/wechat_4.0/light) max_cache_size: LRU 缓存上限,默认 20 _cache: OrderedDict[str, np.ndarray] LRU 缓存(key=模板名,value=灰度图) """ # 默认多尺度(spec 4.3 节) _DEFAULT_SCALES = [1.0, 0.75, 1.25, 1.5] def __init__( self, template_dir: str, max_cache_size: int = 20, ) -> None: """初始化 OpenCV 后端。 Args: template_dir: 模板根目录绝对路径 max_cache_size: LRU 缓存上限 """ self.template_dir = template_dir self.max_cache_size = max_cache_size self._cache: OrderedDict[str, np.ndarray] = OrderedDict() def _load_template(self, name: str) -> Optional[np.ndarray]: """加载模板为灰度图,LRU 缓存。 命中:move_to_end(最近使用)。 未命中:cv2.imread(IMREAD_GRAYSCALE),超限时 popitem(last=False) 删最旧。 文件不存在或读取失败返回 None。 Args: name: 模板文件名(如 search_box.png) Returns: 灰度图 np.ndarray 或 None """ if name in self._cache: self._cache.move_to_end(name) return self._cache[name] path = os.path.join(self.template_dir, name) if not os.path.exists(path): logger.warning("[opencv] template not found: %s", path) return None tpl = cv2.imread(path, cv2.IMREAD_GRAYSCALE) if tpl is None: logger.warning("[opencv] imread failed: %s", path) return None # LRU 淘汰 if len(self._cache) >= self.max_cache_size: self._cache.popitem(last=False) self._cache[name] = tpl return tpl async def find_template( self, screenshot_png: bytes, template_name: str, threshold: float = 0.80, scales: Optional[list[float]] = None, roi: Optional[tuple[int, int, int, int]] = None, ) -> Optional[tuple[int, int, int, int, float]]: """在截图中查找模板,返回 (x, y, w, h, confidence) 或 None。 - 多尺度 [1.0, 0.75, 1.25, 1.5] 逐个处理 - 每个 scale 下用 cv2.matchTemplate + minMaxLoc - 命中(max_val >= threshold)立即返回,置信度最高的优先 - ROI 偏移补偿:roi=(x, y, w, h),返回坐标 = ROI 内坐标 + ROI 偏移 - 显式 del scaled/res 释放内存 Args: screenshot_png: PNG 字节流(来自 backend.screenshot()) template_name: 模板文件名 threshold: 匹配置信度阈值,默认 0.80 scales: 自定义尺度列表,None 用 _DEFAULT_SCALES roi: (x, y, w, h) 限制匹配区域(窗口绝对坐标),None 全图 Returns: (x, y, w, h, confidence) 绝对坐标,或 None """ # 同步 CPU 密集计算交给线程池 return await asyncio.to_thread( self._find_template_sync, screenshot_png, template_name, threshold, scales or self._DEFAULT_SCALES, roi, ) def _find_template_sync( self, screenshot_png: bytes, template_name: str, threshold: float, scales: list[float], roi: Optional[tuple[int, int, int, int]], ) -> Optional[tuple[int, int, int, int, float]]: """同步实现:cv2.matchTemplate 多尺度匹配。 被 find_template 用 asyncio.to_thread 调用,避免阻塞事件循环。 """ # 1. 解码截图 logger.info("[opencv] decode screenshot: size=%d", len(screenshot_png)) if not screenshot_png: logger.warning("[opencv] screenshot_png is empty") return None arr = np.frombuffer(screenshot_png, dtype=np.uint8) img = cv2.imdecode(arr, cv2.IMREAD_GRAYSCALE) if img is None: logger.warning("[opencv] decode screenshot failed") return None del arr # 2. 加载模板 tpl = self._load_template(template_name) if tpl is None: return None th, tw = tpl.shape[:2] # 3. ROI 裁剪 roi_x, roi_y = 0, 0 if roi is not None: rx, ry, rw, rh = roi # 边界保护 rx = max(0, rx) ry = max(0, ry) rw = max(0, rw) rh = max(0, rh) img_h, img_w = img.shape[:2] x1 = min(rx, img_w) y1 = min(ry, img_h) x2 = min(rx + rw, img_w) y2 = min(ry + rh, img_h) if x2 <= x1 or y2 <= y1: logger.warning( "[opencv] invalid roi %s in image (%dx%d)", roi, img_w, img_h, ) return None img = img[y1:y2, x1:x2] roi_x, roi_y = x1, y1 best: Optional[tuple[int, int, int, int, float]] = None for scale in scales: # 缩放模板 new_w = int(tw * scale) new_h = int(th * scale) if new_w < 8 or new_h < 8: continue # 模板不能比截图大 if new_w > img.shape[1] or new_h > img.shape[0]: continue scaled = cv2.resize(tpl, (new_w, new_h), interpolation=cv2.INTER_AREA) try: res = cv2.matchTemplate(img, scaled, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) except cv2.error as exc: logger.debug( "[opencv] matchTemplate failed scale=%.2f: %s", scale, exc, ) continue finally: del scaled del res if max_val >= threshold: # max_loc 是 ROI 内坐标,转绝对坐标 abs_x = max_loc[0] + roi_x abs_y = max_loc[1] + roi_y # 命中即返回(首个满足阈值的尺度) logger.info( "[opencv] match %s scale=%.2f conf=%.3f at (%d,%d)", template_name, scale, max_val, abs_x, abs_y, ) return (abs_x, abs_y, new_w, new_h, float(max_val)) if best is None or max_val > best[4]: best = ( max_loc[0] + roi_x, max_loc[1] + roi_y, new_w, new_h, float(max_val), ) if best is not None: logger.warning( "[opencv] no match for %s (best conf=%.3f < threshold=%.2f)", template_name, best[4], threshold, ) else: logger.warning( "[opencv] no match for %s (no valid scale returned result)", template_name, ) return None