Skip to content

Commit 2ed02f2

Browse files
authored
Merge pull request #445 from Integration-Automation/feat/theme-normalize-batch
Add theme_normalize: match a light-mode template in dark mode
2 parents 91cb1b3 + b5191e4 commit 2ed02f2

11 files changed

Lines changed: 312 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## What's new (2026-06-26)
44

5+
### Theme-Invariant Matching (Light Template, Dark Mode)
6+
7+
Find a button captured in light mode even after the app switches to dark mode. Full reference: [`docs/source/Eng/doc/new_features/v217_features_doc.rst`](docs/source/Eng/doc/new_features/v217_features_doc.rst).
8+
9+
- **`normalize_theme` / `match_theme`** (`AC_match_theme`): `match_template` correlates raw pixel intensities, so a light-mode template scores terribly against the same control in dark mode — the polarity is inverted. The fix is to compare *structure*. `normalize_theme` maps an image to a polarity-invariant single channel (`sobel`/`laplacian` gradient magnitude — identical for an image and its colour inverse — or `zscore`); `match_theme` normalizes both the template and the screen, then locates the template via `visual_match.match_template`, finding it across a light/dark flip that defeats raw matching. cv2/numpy are imported lazily so the module stays importable everywhere. Fourth feature of the ROUND-15 perception lane. No `PySide6`.
10+
511
### Sample a Region's Text Contrast (WCAG)
612

713
Grade the legibility of on-screen text when you only have a region, not the two colours. Full reference: [`docs/source/Eng/doc/new_features/v216_features_doc.rst`](docs/source/Eng/doc/new_features/v216_features_doc.rst).
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
Theme-Invariant Matching (Light Template, Dark Mode)
2+
====================================================
3+
4+
``match_template`` correlates raw pixel intensities, so a template captured in
5+
light mode scores terribly against the same control in dark mode — the polarity
6+
is inverted. The fix is to compare *structure* (edges, gradients), which is the
7+
same regardless of which way the colours run. ``theme_normalize`` turns an image
8+
into a polarity-invariant representation before matching.
9+
10+
* :func:`normalize_theme` — map an image to a normalised single-channel image.
11+
``sobel`` (default) and ``laplacian`` use gradient magnitude, which is
12+
identical for an image and its colour-inverse; ``zscore`` standardises
13+
intensity.
14+
* :func:`match_theme` — :func:`normalize_theme` both the template and the
15+
haystack (the screen by default), then locate the template — finding it across
16+
a light/dark theme flip that defeats raw matching.
17+
18+
``cv2`` / ``numpy`` are imported lazily, so importing the module never requires
19+
them, and the locating logic reuses :func:`visual_match.match_template`. Imports
20+
no ``PySide6``.
21+
22+
Headless API
23+
------------
24+
25+
.. code-block:: python
26+
27+
from je_auto_control import match_theme, normalize_theme
28+
29+
# A button template grabbed in light mode, found in the dark-mode app:
30+
hit = match_theme("save_button_light.png", method="sobel", min_score=0.4)
31+
if hit and hit["score"] >= 0.5:
32+
click(hit["x"] + hit["width"] // 2, hit["y"] + hit["height"] // 2)
33+
34+
# The transform itself (e.g. to feed your own matcher):
35+
edges = normalize_theme("template.png", method="sobel")
36+
37+
Because gradient magnitude is identical for an image and its inverse,
38+
``normalize_theme(img, "sobel")`` equals ``normalize_theme(255 - img, "sobel")``
39+
— that invariance is exactly what lets one template match both themes. Use
40+
``min_score`` lower than for raw matching (structure correlation runs cooler).
41+
42+
Executor commands
43+
-----------------
44+
45+
``AC_match_theme`` (``template`` + ``region`` ``[x, y, w, h]`` / ``method`` /
46+
``min_score`` → ``{found, x, y, width, height, score}``) locates a template
47+
across a theme flip. It is the matching read-only ``ac_match_theme`` MCP tool and
48+
a Script Builder command under **Image**. :func:`normalize_theme` (which returns
49+
an image array) is the Python-API surface.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
主題不變比對(淺色模板、深色模式)
2+
==================================
3+
4+
``match_template`` 以原始像素強度相關比對,故在淺色模式擷取的模板,對深色模式下同一控制項評分極差——
5+
極性反轉了。修法是比較*結構*(邊緣、梯度),不論顏色走向如何皆相同。``theme_normalize`` 在比對前
6+
把影像轉成極性不變的表示。
7+
8+
* :func:`normalize_theme` ——把影像映射為正規化的單通道影像。``sobel``(預設)與 ``laplacian``
9+
使用梯度幅值,對影像與其顏色反相版本相同;``zscore`` 將強度標準化。
10+
* :func:`match_theme` ——對模板與 haystack(預設為螢幕)都做 :func:`normalize_theme`,再定位模板——
11+
即使在會擊敗原始比對的淺/深主題切換下也能找到。
12+
13+
``cv2`` / ``numpy`` 採延遲匯入,故匯入本模組永遠不需要它們,定位邏輯則重用
14+
:func:`visual_match.match_template`。不匯入 ``PySide6``。
15+
16+
無頭 API
17+
--------
18+
19+
.. code-block:: python
20+
21+
from je_auto_control import match_theme, normalize_theme
22+
23+
# 淺色模式擷取的按鈕模板,在深色模式的 app 中找到:
24+
hit = match_theme("save_button_light.png", method="sobel", min_score=0.4)
25+
if hit and hit["score"] >= 0.5:
26+
click(hit["x"] + hit["width"] // 2, hit["y"] + hit["height"] // 2)
27+
28+
# 轉換本身(例如餵給你自己的比對器):
29+
edges = normalize_theme("template.png", method="sobel")
30+
31+
由於梯度幅值對影像與其反相版本相同,``normalize_theme(img, "sobel")`` 等於
32+
``normalize_theme(255 - img, "sobel")``——正是這個不變性讓單一模板能比對兩種主題。
33+
``min_score`` 請設得比原始比對低(結構相關分數較低)。
34+
35+
執行器指令
36+
----------
37+
38+
``AC_match_theme``(``template`` 加上 ``region`` ``[x, y, w, h]`` / ``method`` /
39+
``min_score`` → ``{found, x, y, width, height, score}``)跨主題切換定位模板。以對應的唯讀
40+
``ac_match_theme`` MCP 工具及 Script Builder 指令(位於 **Image** 分類下)形式提供。
41+
:func:`normalize_theme`(回傳影像陣列)則是 Python API 介面。

je_auto_control/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@
141141
from je_auto_control.utils.contrast_map import (
142142
dominant_pair, grade_contrast, region_contrast,
143143
)
144+
# Theme-invariant matching so a light template matches dark mode
145+
from je_auto_control.utils.theme_normalize import match_theme, normalize_theme
144146
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
145147
from je_auto_control.utils.clipboard_rich_formats import (
146148
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1768,6 +1770,7 @@ def start_autocontrol_gui(*args, **kwargs):
17681770
"simulate_cvd", "colors_collide", "color_distance",
17691771
"place_labels", "label_color",
17701772
"grade_contrast", "dominant_pair", "region_contrast",
1773+
"normalize_theme", "match_theme",
17711774
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17721775
"set_clipboard_rtf", "get_clipboard_rtf",
17731776
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4591,6 +4591,21 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
45914591
),
45924592
description="Sample a screen region and grade its text contrast.",
45934593
))
4594+
specs.append(CommandSpec(
4595+
"AC_match_theme", "Image", "Match (Theme-Invariant)",
4596+
fields=(
4597+
FieldSpec("template", FieldType.STRING,
4598+
placeholder="template image path"),
4599+
FieldSpec("region", FieldType.STRING, optional=True,
4600+
placeholder="[x, y, w, h]"),
4601+
FieldSpec("method", FieldType.STRING, optional=True,
4602+
default="sobel",
4603+
placeholder="sobel / laplacian / zscore"),
4604+
FieldSpec("min_score", FieldType.FLOAT, optional=True,
4605+
default=0.5),
4606+
),
4607+
description="Locate a template across a light/dark theme flip.",
4608+
))
45944609
specs.append(CommandSpec(
45954610
"AC_normalize_ext", "Shell", "Normalize Extension",
45964611
fields=(

je_auto_control/utils/executor/action_executor.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2900,6 +2900,18 @@ def _region_contrast(region: Any = None) -> Dict[str, Any]:
29002900
return region_contrast(region=_coerce_region(region))
29012901

29022902

2903+
def _match_theme(template: Any, region: Any = None, method: Any = "sobel",
2904+
min_score: Any = 0.5) -> Dict[str, Any]:
2905+
"""Adapter: locate a template across a light/dark theme flip (device)."""
2906+
from je_auto_control.utils.theme_normalize import match_theme
2907+
match = match_theme(str(template), method=str(method),
2908+
min_score=float(min_score),
2909+
region=_coerce_region(region))
2910+
if match is None:
2911+
return {"found": False}
2912+
return {"found": True, **match}
2913+
2914+
29032915
def _normalize_ext(target: str) -> Dict[str, Any]:
29042916
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
29052917
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6938,6 +6950,7 @@ def __init__(self):
69386950
"AC_grade_contrast": _grade_contrast,
69396951
"AC_dominant_pair": _dominant_pair,
69406952
"AC_region_contrast": _region_contrast,
6953+
"AC_match_theme": _match_theme,
69416954
"AC_normalize_ext": _normalize_ext,
69426955
"AC_file_association": _file_association,
69436956
"AC_get_control_text": _get_control_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4091,6 +4091,22 @@ def img_histogram_tools() -> List[MCPTool]:
40914091
handler=h.region_contrast,
40924092
annotations=READ_ONLY,
40934093
),
4094+
MCPTool(
4095+
name="ac_match_theme",
4096+
description=("Locate a 'template' image on screen across a "
4097+
"light/dark theme flip, by matching gradient "
4098+
"structure ('method' sobel/laplacian/zscore). "
4099+
"'region' [x,y,w,h] clips the search. Returns {found, "
4100+
"x, y, width, height, score}."),
4101+
input_schema=schema({"template": {"type": "string"},
4102+
"region": {"type": "array",
4103+
"items": {"type": "integer"}},
4104+
"method": {"type": "string"},
4105+
"min_score": {"type": "number"}},
4106+
required=["template"]),
4107+
handler=h.match_theme,
4108+
annotations=READ_ONLY,
4109+
),
40944110
]
40954111

40964112

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,11 @@ def region_contrast(region=None):
769769
return _region_contrast(region)
770770

771771

772+
def match_theme(template, region=None, method="sobel", min_score=0.5):
773+
from je_auto_control.utils.executor.action_executor import _match_theme
774+
return _match_theme(template, region, method, min_score)
775+
776+
772777
def normalize_ext(target):
773778
from je_auto_control.utils.executor.action_executor import _normalize_ext
774779
return _normalize_ext(target)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Theme-invariant image normalisation so light templates match dark mode."""
2+
from je_auto_control.utils.theme_normalize.theme_normalize import (
3+
THEME_METHODS, match_theme, normalize_theme,
4+
)
5+
6+
__all__ = ["normalize_theme", "match_theme", "THEME_METHODS"]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Theme-invariant image normalisation so light templates match dark mode.
2+
3+
``match_template`` correlates raw pixel intensities, so a template captured in
4+
light mode scores terribly against the same control in dark mode — the polarity
5+
is inverted. The fix is to compare *structure* (edges, gradients), which is the
6+
same regardless of which way the colours run. ``theme_normalize`` turns an image
7+
into a polarity-invariant representation before matching:
8+
9+
* :func:`normalize_theme` — map an image to a normalised single-channel image.
10+
``sobel`` (default) and ``laplacian`` use gradient magnitude, which is
11+
identical for an image and its inverse; ``zscore`` standardises intensity.
12+
* :func:`match_theme` — :func:`normalize_theme` both the template and the
13+
haystack (the screen by default), then locate the template — finding it across
14+
a light/dark theme flip that defeats raw matching.
15+
16+
cv2 / numpy are imported lazily, so importing this module never requires them
17+
(the package stays importable everywhere) and the locating logic reuses
18+
:func:`visual_match.match_template`. Imports no ``PySide6``.
19+
"""
20+
from typing import Any, Dict, Optional, Sequence
21+
22+
# A normalisation method name.
23+
THEME_METHODS = ("sobel", "laplacian", "zscore")
24+
25+
26+
def _to_uint8(array: Any) -> Any:
27+
"""Rescale a float array to a 0..255 uint8 image."""
28+
import cv2
29+
return cv2.normalize(array, None, 0, 255, cv2.NORM_MINMAX).astype("uint8")
30+
31+
32+
def _zscore(gray: Any) -> Any:
33+
"""Standardise intensity to zero mean / unit variance (not inversion-safe)."""
34+
import numpy as np
35+
std = float(gray.std())
36+
if std < 1e-9:
37+
return np.zeros_like(gray)
38+
return (gray - gray.mean()) / std
39+
40+
41+
def normalize_theme(source: Any, *, method: str = "sobel") -> Any:
42+
"""Return ``source`` as a theme-normalised single-channel ``uint8`` image.
43+
44+
``sobel`` / ``laplacian`` return gradient magnitude — identical for an image
45+
and its colour-inverted (dark-mode) twin — and ``zscore`` standardises
46+
intensity. Raises ``ValueError`` for an unknown ``method``.
47+
"""
48+
import cv2
49+
import numpy as np
50+
from je_auto_control.utils.visual_match.visual_match import _to_gray
51+
gray = _to_gray(source).astype("float64")
52+
if method == "sobel":
53+
gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
54+
gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
55+
result = np.sqrt(gx * gx + gy * gy)
56+
elif method == "laplacian":
57+
result = np.abs(cv2.Laplacian(gray, cv2.CV_64F, ksize=3))
58+
elif method == "zscore":
59+
result = _zscore(gray)
60+
else:
61+
raise ValueError(f"unknown theme-normalize method: {method!r}")
62+
return _to_uint8(result)
63+
64+
65+
def match_theme(template: Any, *, haystack: Optional[Any] = None,
66+
method: str = "sobel", min_score: float = 0.5,
67+
region: Optional[Sequence[int]] = None
68+
) -> Optional[Dict[str, Any]]:
69+
"""Locate ``template`` in ``haystack`` after theme-normalising both.
70+
71+
``haystack`` defaults to a fresh screen grab (optionally clipped to
72+
``region``). Returns ``{x, y, width, height, score}`` for the best match at
73+
or above ``min_score``, or ``None``. Robust to a light/dark theme flip that
74+
defeats raw :func:`visual_match.match_template`.
75+
"""
76+
from je_auto_control.utils.visual_match import match_template
77+
from je_auto_control.utils.visual_match.visual_match import _grab_gray
78+
raw_haystack = haystack if haystack is not None else _grab_gray(region)
79+
norm_template = normalize_theme(template, method=method)
80+
norm_haystack = normalize_theme(raw_haystack, method=method)
81+
match = match_template(norm_template, haystack=norm_haystack,
82+
min_score=float(min_score))
83+
if match is None:
84+
return None
85+
return {"x": match.x, "y": match.y, "width": match.width,
86+
"height": match.height, "score": round(float(match.score), 4)}

0 commit comments

Comments
 (0)