WechatOnCloud/bridge/woc_bridge/ui/locators/registry.py

354 lines
13 KiB
Python
Raw Normal View History

"""L2 LocatorRegistry按 (app_version, resolution) 加载 YAML profile + 熔断接口。
加载优先级精确匹配 > 版本匹配 > default 兜底
熔断接口record_image_failure / record_image_success / is_circuited
P1 阶段 no-opopencv=NoneP2 启用图像路径后生效
"""
from __future__ import annotations
import logging
import os
import re
import time
from typing import Optional
import yaml
from woc_bridge.ui.locators.base import GeomSpec, Selector
logger = logging.getLogger("woc-bridge")
class LocatorRegistry:
"""选择器注册表。
Attributes:
profile_dir: YAML profile 目录绝对路径
opencv: OpenCVBackend 实例P1 阶段为 NoneP2 启用图像路径
selectors: kind -> Selector 映射
theme: 当前主题light / darkP1 阶段固定 light
"""
# 熔断参数:连续失败 10 次熔断 5 分钟
_FAIL_THRESHOLD = 10
_CIRCUIT_OPEN_SEC = 300.0
def __init__(self, profile_dir: str, opencv=None) -> None:
"""初始化注册表。
Args:
profile_dir: YAML profile 目录绝对路径
opencv: OpenCVBackend 实例P1 阶段为 None
"""
self.profile_dir = profile_dir
self.opencv = opencv
self.selectors: dict[str, Selector] = {}
self.theme: str = "light"
self._fail_counts: dict[str, int] = {}
self._circuit_open_until: dict[str, float] = {}
# ------------------------------------------------------------------
# Profile 加载
# ------------------------------------------------------------------
def load(self, app_version: str, resolution: tuple[int, int]) -> None:
"""按精确 > 最近分辨率 > 版本 > default 优先级加载 YAML profile。
Args:
app_version: 微信版本号 "4.0"
resolution: (width, height) 元组 (1920, 1080)
"""
exact_name = f"wechat_{app_version}_{resolution[0]}x{resolution[1]}.yaml"
exact_path = os.path.join(self.profile_dir, exact_name)
if os.path.exists(exact_path):
logger.info(
"[locator] load exact profile: %s (app_version=%s resolution=%s)",
exact_name,
app_version,
resolution,
)
self._load_yaml(exact_path)
return
# 无精确匹配时,找同版本最近分辨率并按比例缩放绝对偏移
nearest = self._find_nearest_profile(app_version, resolution)
if nearest:
name, profile_w, profile_h = nearest
logger.info(
"[locator] load nearest profile: %s for resolution=%s "
"(scale=%.3fx%.3f)",
name,
resolution,
resolution[0] / profile_w,
resolution[1] / profile_h,
)
self._load_yaml(
os.path.join(self.profile_dir, name),
target_resolution=resolution,
profile_resolution=(profile_w, profile_h),
)
return
# 版本通用/默认兜底
for name in [f"wechat_{app_version}.yaml", "default.yaml"]:
path = os.path.join(self.profile_dir, name)
if os.path.exists(path):
logger.info(
"[locator] load profile: %s (app_version=%s resolution=%s)",
name,
app_version,
resolution,
)
self._load_yaml(path)
return
logger.warning(
"[locator] no profile found in %s, fall back to built-in defaults "
"(app_version=%s resolution=%s)",
self.profile_dir,
app_version,
resolution,
)
self._load_defaults()
def _find_nearest_profile(
self, app_version: str, resolution: tuple[int, int]
) -> Optional[tuple[str, int, int]]:
"""找与当前分辨率最接近的 wechat_<version>_<w>x<h>.yaml。"""
pattern = re.compile(rf"wechat_{re.escape(app_version)}_(\d+)x(\d+)\.yaml$")
target_w, target_h = resolution
target_area = target_w * target_h
best: Optional[tuple[str, int, int]] = None
best_score: Optional[float] = None
for name in os.listdir(self.profile_dir):
m = pattern.match(name)
if not m:
continue
pw, ph = int(m.group(1)), int(m.group(2))
area = pw * ph
area_ratio = min(target_area, area) / max(target_area, area)
aspect_diff = abs(target_w / target_h - pw / ph)
# 面积越接近、宽高比越接近,分数越低
score = (1 - area_ratio) + aspect_diff
if best_score is None or score < best_score:
best_score = score
best = (name, pw, ph)
return best
def set_theme(self, theme: str) -> None:
"""切换主题light / dark"""
if theme != self.theme:
self.theme = theme
logger.info("[locator] theme switched to %s", theme)
# ------------------------------------------------------------------
# 选择器访问 + 熔断
# ------------------------------------------------------------------
def get(self, kind: str) -> Selector:
"""返回 kind 对应 Selector无则返回 _default_selector(kind)。"""
return self.selectors.get(kind) or self._default_selector(kind)
def is_circuited(self, kind: str) -> bool:
"""检查 kind 是否处于熔断期。
熔断到期时自动重置失败计数使半开状态允许完整次数的试探
而非首次失败即重新熔断
"""
deadline = self._circuit_open_until.get(kind, 0)
if time.monotonic() < deadline:
return True
# 熔断已到期:清理熔断标记和失败计数,进入半开状态
if kind in self._circuit_open_until:
self._circuit_open_until.pop(kind, None)
self._fail_counts[kind] = 0
logger.info(
"[locator] kind=%s circuit recovered to HALF_OPEN (fail_count reset)",
kind,
)
return False
def record_image_failure(self, kind: str) -> None:
"""记录一次图像匹配失败。连续 _FAIL_THRESHOLD 次后熔断 _CIRCUIT_OPEN_SEC 秒。"""
self._fail_counts[kind] = self._fail_counts.get(kind, 0) + 1
if self._fail_counts[kind] >= self._FAIL_THRESHOLD:
self._circuit_open_until[kind] = time.monotonic() + self._CIRCUIT_OPEN_SEC
logger.warning(
"[locator] kind=%s image match circuit OPEN for %.0fs "
"(fail_count=%d)",
kind,
self._CIRCUIT_OPEN_SEC,
self._fail_counts[kind],
)
def record_image_success(self, kind: str) -> None:
"""记录一次图像匹配成功,重置失败计数与熔断。"""
self._fail_counts[kind] = 0
self._circuit_open_until.pop(kind, None)
# ------------------------------------------------------------------
# YAML 解析与默认值
# ------------------------------------------------------------------
def _load_yaml(
self,
path: str,
target_resolution: Optional[tuple[int, int]] = None,
profile_resolution: Optional[tuple[int, int]] = None,
) -> None:
"""用 yaml.safe_load 加载 YAML 并填充 self.selectors。
若提供 target_resolution profile_resolution则对 by_geom 中的
绝对偏移 x_offset / y_offset 按分辨率比例缩放宽高比不变时坐标更接近目标
YAML 结构每个 key 对应一个 Selector
search_box:
by_image: search_box.png # str可省略
by_geom: # dict可省略
relative_to: window
x_ratio: 0.08
y_ratio: 0.05
x_offset: 0
y_offset: 0
threshold: 0.80
require_image: false
description: 搜索框
"""
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except (OSError, yaml.YAMLError) as exc:
logger.error("[locator] load yaml failed: %s (%s)", path, exc)
self._load_defaults()
return
scale_x = scale_y = 1.0
if target_resolution and profile_resolution:
scale_x = target_resolution[0] / profile_resolution[0]
scale_y = target_resolution[1] / profile_resolution[1]
self.selectors.clear()
for kind, spec in data.items():
if not isinstance(spec, dict):
continue
by_image = spec.get("by_image")
by_geom_raw = spec.get("by_geom")
by_geom: Optional[GeomSpec] = None
if isinstance(by_geom_raw, dict):
by_geom = GeomSpec(
relative_to=by_geom_raw.get("relative_to", "window"),
x_ratio=float(by_geom_raw.get("x_ratio", 0.0)),
y_ratio=float(by_geom_raw.get("y_ratio", 0.0)),
x_offset=int(int(by_geom_raw.get("x_offset", 0)) * scale_x),
y_offset=int(int(by_geom_raw.get("y_offset", 0)) * scale_y),
)
self.selectors[kind] = Selector(
kind=kind,
by_image=by_image,
by_geom=by_geom,
threshold=float(spec.get("threshold", 0.80)),
require_image=bool(spec.get("require_image", False)),
description=str(spec.get("description", "")),
)
logger.info(
"[locator] loaded %d selectors from %s (scale=%.3fx%.3f)",
len(self.selectors), path, scale_x, scale_y,
)
def _default_selector(self, kind: str) -> Selector:
"""返回 kind 的兜底 Selector仅 description 标注 default"""
return Selector(kind=kind, description=f"default {kind}")
def _load_defaults(self) -> None:
"""从 xdotool_driver.py 现有硬编码常量迁移默认几何兜底。
参考 spec 4.2 YAML 示例与项目记忆中的硬编码值
覆盖 6 个核心元素search_box / send_button / input_box /
input_box_empty / search_result_first / main_view
relative_to 字段仅作语义占位实际坐标计算统一用 ratio + offset
offset 已含正负号区分方向详见 Actions._geom_find
"""
defaults = {
"session_manager_icon": Selector(
kind="session_manager_icon",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.018,
y_ratio=0.109,
x_offset=0,
y_offset=0,
),
description="左侧导航栏顶部微信消息/会话管理图标",
),
"search_box": Selector(
kind="search_box",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.092,
y_ratio=0.095,
x_offset=0,
y_offset=0,
),
description="左侧顶部搜索框",
),
"send_button": Selector(
kind="send_button",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.972,
y_ratio=0.929,
x_offset=0,
y_offset=0,
),
description="右下角发送按钮",
),
"input_box": Selector(
kind="input_box",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.743,
y_ratio=0.914,
x_offset=0,
y_offset=0,
),
description="聊天输入框",
),
"input_box_empty": Selector(
kind="input_box_empty",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.743,
y_ratio=0.914,
x_offset=0,
y_offset=0,
),
description="空输入框灰色提示态(发送成功后校验)",
),
"search_result_first": Selector(
kind="search_result_first",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.078,
y_ratio=0.174,
x_offset=0,
y_offset=0,
),
description="搜索结果第一项",
),
"main_view": Selector(
kind="main_view",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.65,
y_ratio=0.50,
x_offset=0,
y_offset=0,
),
description="主聊天视图post_verify 校验用)",
),
}
self.selectors.clear()
self.selectors.update(defaults)
logger.info(
"[locator] loaded %d built-in default selectors", len(self.selectors)
)