WechatOnCloud/bridge/woc_bridge/ui/backends/opencv.py

228 lines
7.7 KiB
Python
Raw Normal View History

"""L1 OpenCVBackend基于 OpenCV 的模板匹配后端。
提供多尺度模板匹配能力 LocatorRegistry/Actions find_element
优先用图像定位精确度高失败再回退几何兜底
模板缓存采用 LRUOrderedDict上限 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