Skip to content

Commit 60e6b4e

Browse files
committed
Add rotation- and scale-tolerant template matching
match_template sweeps scales but assumes axis-aligned templates; OpenCV's matchTemplate is not rotation-invariant, so a skewed control, rotated icon or dial is missed. Sweep angles (warpAffine) crossed with a linspace scale-space and keep the best, reporting the recovered scale and angle. Reuses visual_match's loaders, resize, method table and NMS.
1 parent f453138 commit 60e6b4e

15 files changed

Lines changed: 486 additions & 1 deletion

File tree

README/WHATS_NEW_zh-CN.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# 本次更新 — AutoControl
22

3+
## 本次更新 (2026-06-23) — 旋转与缩放容忍的模板匹配
4+
5+
不只缩放,还能找到旋转或倾斜的模板。完整参考:[`docs/source/Zh/doc/new_features/v158_features_doc.rst`](../docs/source/Zh/doc/new_features/v158_features_doc.rst)
6+
7+
- **`match_rotated` / `match_rotated_all` / `scale_space`**(`AC_match_rotated``AC_match_rotated_all`):`match_template` 只扫描*缩放*且假设轴对齐——OpenCV 的 `matchTemplate` 不具旋转不变性,因此倾斜的控件、旋转的图标,或转到不同角度的刻度盘都会匹配失败。本功能扫描 `angles`(每个以 `cv2.warpAffine` 变形)并与 `np.linspace` 缩放空间交叉,返回相关性最高、且带有还原 `scale` + `angle``RotatedMatch`(`*_all` 版本以 NMS 合并相邻角度 / 缩放)。重用 `visual_match` 的加载器 / resize / 方法表 / NMS——不重复任何匹配或几何代码。`haystack` 可注入;可无头测试;不导入 `PySide6`
8+
39
## 本次更新 (2026-06-23) — 一维条码解码
410

511
从屏幕或图像读取 EAN / UPC / Code-128 条码。完整参考:[`docs/source/Zh/doc/new_features/v157_features_doc.rst`](../docs/source/Zh/doc/new_features/v157_features_doc.rst)

README/WHATS_NEW_zh-TW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# 本次更新 — AutoControl
22

3+
## 本次更新 (2026-06-23) — 旋轉與縮放容忍的樣板比對
4+
5+
不只縮放,還能找到旋轉或傾斜的樣板。完整參考:[`docs/source/Zh/doc/new_features/v158_features_doc.rst`](../docs/source/Zh/doc/new_features/v158_features_doc.rst)
6+
7+
- **`match_rotated` / `match_rotated_all` / `scale_space`**(`AC_match_rotated``AC_match_rotated_all`):`match_template` 只掃描*縮放*且假設軸對齊——OpenCV 的 `matchTemplate` 不具旋轉不變性,因此傾斜的控制項、旋轉的圖示,或轉到不同角度的刻度盤都會比對失敗。本功能掃描 `angles`(每個以 `cv2.warpAffine` 變形)並與 `np.linspace` 縮放空間交叉,回傳相關性最高、且帶有還原 `scale` + `angle``RotatedMatch`(`*_all` 版本以 NMS 合併相鄰角度 / 縮放)。重用 `visual_match` 的載入器 / resize / 方法表 / NMS——不重複任何比對或幾何程式。`haystack` 可注入;可無頭測試;不匯入 `PySide6`
8+
39
## 本次更新 (2026-06-23) — 一維條碼解碼
410

511
從螢幕或影像讀取 EAN / UPC / Code-128 條碼。完整參考:[`docs/source/Zh/doc/new_features/v157_features_doc.rst`](../docs/source/Zh/doc/new_features/v157_features_doc.rst)

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# What's New — AutoControl
22

3+
## What's new (2026-06-23) — Rotation- & Scale-Tolerant Template Matching
4+
5+
Find templates that are rotated or skewed, not just scaled. Full reference: [`docs/source/Eng/doc/new_features/v158_features_doc.rst`](docs/source/Eng/doc/new_features/v158_features_doc.rst).
6+
7+
- **`match_rotated` / `match_rotated_all` / `scale_space`** (`AC_match_rotated`, `AC_match_rotated_all`): `match_template` sweeps *scales* but assumes axis-aligned — OpenCV's `matchTemplate` isn't rotation-invariant, so a skewed control, a rotated icon or a dial at a different angle is missed. This sweeps `angles` (each warped with `cv2.warpAffine`) crossed with a `np.linspace` scale-space, returns the best-correlating `RotatedMatch` carrying the recovered `scale` + `angle` (the `*_all` form NMS-dedupes neighbouring angles/scales). Reuses `visual_match`'s loaders / resize / method table / NMS — no matching or geometry code duplicated. Injectable `haystack`; headless-testable; no `PySide6`.
8+
39
## What's new (2026-06-23) — Barcode Decoding (1-D)
410

511
Read EAN / UPC / Code-128 barcodes off the screen or an image. Full reference: [`docs/source/Eng/doc/new_features/v157_features_doc.rst`](docs/source/Eng/doc/new_features/v157_features_doc.rst).
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
Rotation- and Scale-Tolerant Template Matching
2+
==============================================
3+
4+
``match_template`` searches a template across *scales* (DPI / zoom tolerance) but
5+
assumes it is axis-aligned — OpenCV's ``matchTemplate`` is not rotation-invariant,
6+
so a control rendered at a slight skew, a rotated icon, or a dial/knob at a different
7+
angle is missed. ``match_rotated`` adds a rotation sweep: each angle is applied to
8+
the template with ``cv2.warpAffine``, crossed with a ``np.linspace`` scale-space, and
9+
the best-correlating (scale, angle) is returned — so the caller also learns the
10+
recovered *pose*.
11+
12+
It reuses ``visual_match``'s grayscale loaders, scale resize, correlation-method
13+
table and non-maximum suppression, so no matching or geometry code is duplicated.
14+
The ``haystack`` is injectable (ndarray / path / PIL), so the search is unit-testable
15+
on synthetic arrays; only the default (grab the screen) is device-bound. Imports no
16+
``PySide6``.
17+
18+
Headless API
19+
------------
20+
21+
.. code-block:: python
22+
23+
from je_auto_control import match_rotated, match_rotated_all, scale_space
24+
25+
# find a knob that may be turned to any of these angles, at any of these scales
26+
hit = match_rotated("knob.png", angles=[-15, 0, 15, 30],
27+
scales=scale_space(0.9, 1.1, 3), min_score=0.85)
28+
if hit:
29+
print(hit.angle, hit.scale, hit.score, hit.center)
30+
31+
# every rotated occurrence, overlaps merged by NMS
32+
for m in match_rotated_all("arrow.png", angles=[0, 90, 180, 270]):
33+
print(m.center, m.angle)
34+
35+
``match_rotated`` returns a single ``RotatedMatch`` (``x`` / ``y`` / ``width`` /
36+
``height`` / ``score`` / ``scale`` / ``angle`` + ``center``) or ``None``;
37+
``match_rotated_all`` returns every hit at or above ``min_score`` with overlapping
38+
detections from neighbouring angles / scales collapsed by NMS, ordered by score.
39+
``scale_space(min, max, steps)`` is a helper returning evenly spaced scales.
40+
41+
Executor commands
42+
-----------------
43+
44+
``AC_match_rotated`` (``template`` / ``min_score`` / ``angles`` / ``scales`` /
45+
``region`` / ``method`` → ``{found, match}``) and ``AC_match_rotated_all`` (adds
46+
``max_results`` / ``nms_iou`` → ``{count, matches}``). They are exposed as the MCP
47+
tools ``ac_match_rotated`` / ``ac_match_rotated_all`` (read-only) and as Script
48+
Builder commands **Match Template (rotated)** / **Match Template All (rotated)**
49+
under **Image**.

docs/source/Eng/eng_index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ Comprehensive guides for all AutoControl features.
180180
doc/new_features/v155_features_doc
181181
doc/new_features/v156_features_doc
182182
doc/new_features/v157_features_doc
183+
doc/new_features/v158_features_doc
183184
doc/ocr_backends/ocr_backends_doc
184185
doc/observability/observability_doc
185186
doc/operations_layer/operations_layer_doc
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
旋轉與縮放容忍的樣板比對
2+
========================
3+
4+
``match_template`` 能跨*縮放*搜尋樣板(容忍 DPI / 縮放),但假設樣板為軸對齊——
5+
OpenCV 的 ``matchTemplate`` 不具旋轉不變性,因此略為傾斜的控制項、旋轉的圖示,或
6+
轉到不同角度的旋鈕 / 刻度盤都會比對失敗。``match_rotated`` 加入旋轉掃描:每個角度
7+
以 ``cv2.warpAffine`` 套用到樣板上,並與 ``np.linspace`` 縮放空間交叉,回傳相關性
8+
最高的(scale, angle)——因此呼叫端也能得知還原出的*姿態*。
9+
10+
本功能重用 ``visual_match`` 的灰階載入器、縮放 resize、相關性方法表與非極大值抑制,
11+
不重複任何比對或幾何程式。``haystack`` 可注入(ndarray / 路徑 / PIL),因此搜尋可在
12+
合成陣列上單元測試;只有預設(擷取螢幕)為裝置相依。不匯入 ``PySide6``。
13+
14+
無頭 API
15+
--------
16+
17+
.. code-block:: python
18+
19+
from je_auto_control import match_rotated, match_rotated_all, scale_space
20+
21+
# 尋找可能轉到任一角度、任一縮放的旋鈕
22+
hit = match_rotated("knob.png", angles=[-15, 0, 15, 30],
23+
scales=scale_space(0.9, 1.1, 3), min_score=0.85)
24+
if hit:
25+
print(hit.angle, hit.scale, hit.score, hit.center)
26+
27+
# 每個旋轉後的出現位置,重疊以 NMS 合併
28+
for m in match_rotated_all("arrow.png", angles=[0, 90, 180, 270]):
29+
print(m.center, m.angle)
30+
31+
``match_rotated`` 回傳單一 ``RotatedMatch``(``x`` / ``y`` / ``width`` / ``height`` /
32+
``score`` / ``scale`` / ``angle`` + ``center``)或 ``None``;``match_rotated_all``
33+
回傳所有達到 ``min_score`` 的命中,相鄰角度 / 縮放的重疊偵測以 NMS 合併,依分數排序。
34+
``scale_space(min, max, steps)`` 為回傳等間距縮放的輔助函式。
35+
36+
執行器指令
37+
----------
38+
39+
``AC_match_rotated``(``template`` / ``min_score`` / ``angles`` / ``scales`` /
40+
``region`` / ``method`` → ``{found, match}``)與 ``AC_match_rotated_all``(另加
41+
``max_results`` / ``nms_iou`` → ``{count, matches}``)。兩者以 MCP 工具
42+
``ac_match_rotated`` / ``ac_match_rotated_all``(唯讀)及 Script Builder 指令
43+
**Match Template (rotated)** / **Match Template All (rotated)**(位於 **Image**
44+
分類下)形式提供。

docs/source/Zh/zh_index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ AutoControl 所有功能的完整使用指南。
180180
doc/new_features/v155_features_doc
181181
doc/new_features/v156_features_doc
182182
doc/new_features/v157_features_doc
183+
doc/new_features/v158_features_doc
183184
doc/ocr_backends/ocr_backends_doc
184185
doc/observability/observability_doc
185186
doc/operations_layer/operations_layer_doc

je_auto_control/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@
279279
match_template_all,
280280
)
281281
from je_auto_control.utils.visual_match import Match as TemplateMatch
282+
# Rotation- and scale-tolerant template matching (scale-space x angle sweep)
283+
from je_auto_control.utils.rotated_match import (
284+
RotatedMatch, match_rotated, match_rotated_all, scale_space,
285+
)
282286
# Locate on-screen regions by colour (mask + connected components)
283287
from je_auto_control.utils.color_region import (
284288
find_color_region, find_color_regions,
@@ -1182,6 +1186,10 @@ def start_autocontrol_gui(*args, **kwargs):
11821186
"match_masked",
11831187
"match_masked_all",
11841188
"best_matches",
1189+
"RotatedMatch",
1190+
"match_rotated",
1191+
"match_rotated_all",
1192+
"scale_space",
11851193
"find_color_region",
11861194
"find_color_regions",
11871195
"ssim_compare",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,37 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
304304
),
305305
description="Find every masked match of a template (NMS-deduped).",
306306
))
307+
specs.append(CommandSpec(
308+
"AC_match_rotated", "Image", "Match Template (rotated)",
309+
fields=(
310+
FieldSpec("template", FieldType.FILE_PATH),
311+
FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.8,
312+
min_value=0.0, max_value=1.0),
313+
FieldSpec("angles", FieldType.STRING, optional=True,
314+
placeholder="[-10, 0, 10]"),
315+
FieldSpec("scales", FieldType.STRING, optional=True,
316+
placeholder="[0.9, 1.0, 1.1]"),
317+
FieldSpec("region", FieldType.STRING, optional=True,
318+
placeholder=_REGION_PLACEHOLDER),
319+
),
320+
description="Locate a template tolerating rotation + scale; reports angle.",
321+
))
322+
specs.append(CommandSpec(
323+
"AC_match_rotated_all", "Image", "Match Template All (rotated)",
324+
fields=(
325+
FieldSpec("template", FieldType.FILE_PATH),
326+
FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.8,
327+
min_value=0.0, max_value=1.0),
328+
FieldSpec("angles", FieldType.STRING, optional=True,
329+
placeholder="[-10, 0, 10]"),
330+
FieldSpec("scales", FieldType.STRING, optional=True,
331+
placeholder="[0.9, 1.0, 1.1]"),
332+
FieldSpec("max_results", FieldType.INT, optional=True, default=20),
333+
FieldSpec("nms_iou", FieldType.FLOAT, optional=True, default=0.3,
334+
min_value=0.0, max_value=1.0),
335+
),
336+
description="Find every rotation/scale-tolerant match (NMS-deduped).",
337+
))
307338
specs.append(CommandSpec(
308339
"AC_find_color_region", "Image", "Find Colour Region",
309340
fields=(

je_auto_control/utils/executor/action_executor.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import types
2-
from typing import Any, Callable, Dict, List, Optional, Union
2+
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
33

44
from je_auto_control.utils.exception.exception_tags import (
55
action_is_null_error_message, add_command_exception_error_message,
@@ -3282,6 +3282,47 @@ def _match_masked_all(template: str, mask: Any = None, min_score: Any = 0.9,
32823282
return {"count": len(matches), "matches": [m.to_dict() for m in matches]}
32833283

32843284

3285+
def _seq_arg(value: Any, default: Sequence[float]) -> Sequence[float]:
3286+
"""Coerce a JSON-string / list arg into a tuple of floats, or the default."""
3287+
import json
3288+
if isinstance(value, str):
3289+
value = json.loads(value) if value.strip() else None
3290+
return tuple(float(v) for v in value) if value else tuple(default)
3291+
3292+
3293+
def _match_rotated(template: str, min_score: Any = 0.8, scales: Any = None,
3294+
angles: Any = None, region: Any = None,
3295+
method: str = "ccoeff_normed") -> Dict[str, Any]:
3296+
"""Adapter: best rotation/scale-tolerant template match on the screen."""
3297+
import json
3298+
from je_auto_control.utils.rotated_match import match_rotated
3299+
if isinstance(region, str):
3300+
region = json.loads(region) if region.strip() else None
3301+
match = match_rotated(template, region=region,
3302+
scales=_seq_arg(scales, (1.0,)),
3303+
angles=_seq_arg(angles, (0.0,)),
3304+
min_score=float(min_score), method=method)
3305+
return {"found": match is not None,
3306+
"match": match.to_dict() if match else None}
3307+
3308+
3309+
def _match_rotated_all(template: str, min_score: Any = 0.8, scales: Any = None,
3310+
angles: Any = None, max_results: Any = 20,
3311+
nms_iou: Any = 0.3, region: Any = None) -> Dict[str, Any]:
3312+
"""Adapter: every rotation/scale-tolerant template match (NMS)."""
3313+
import json
3314+
from je_auto_control.utils.rotated_match import match_rotated_all
3315+
if isinstance(region, str):
3316+
region = json.loads(region) if region.strip() else None
3317+
matches = match_rotated_all(template, region=region,
3318+
scales=_seq_arg(scales, (1.0,)),
3319+
angles=_seq_arg(angles, (0.0,)),
3320+
min_score=float(min_score),
3321+
max_results=int(max_results),
3322+
nms_iou=float(nms_iou))
3323+
return {"count": len(matches), "matches": [m.to_dict() for m in matches]}
3324+
3325+
32853326
def _find_color_region(rgb: Any, tolerance: Any = 20, min_area: Any = 50,
32863327
region: Any = None) -> Dict[str, Any]:
32873328
"""Adapter: locate coloured regions on the screen, largest first."""
@@ -5684,6 +5725,8 @@ def __init__(self):
56845725
"AC_match_template_all": _match_template_all,
56855726
"AC_match_masked": _match_masked,
56865727
"AC_match_masked_all": _match_masked_all,
5728+
"AC_match_rotated": _match_rotated,
5729+
"AC_match_rotated_all": _match_rotated_all,
56875730
"AC_ssim_compare": _ssim_compare,
56885731
"AC_ssim_changed_regions": _ssim_changed_regions,
56895732
"AC_feature_match": _feature_match,

0 commit comments

Comments
 (0)