Skip to content

Commit 6c826a4

Browse files
committed
Add change_localize: attribute a screen change to the element boxes that changed
Existing diffs return raw pixel regions or a11y-element diffs; the gap is 'given a frame diff and a list of element boxes, which of those changed?'. localize_changes diffs reference vs current and scores each element box by its mean per-pixel change; rank_changes is the pure ranker (changed when score >= threshold, sorted most-changed first). cv2/numpy lazy.
1 parent 2ed02f2 commit 6c826a4

11 files changed

Lines changed: 353 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+
### Localize a Change to the Elements That Changed
6+
7+
Turn a raw screen diff into "element 3 changed" by scoring a list of element boxes. Full reference: [`docs/source/Eng/doc/new_features/v218_features_doc.rst`](docs/source/Eng/doc/new_features/v218_features_doc.rst).
8+
9+
- **`localize_changes` / `rank_changes`** (`AC_localize_changes`, `AC_rank_changes`): existing diffs answer *where* pixels changed (`motion_regions`, `perceptual_diff`, `ssim_changed_regions` → raw pixel regions) or which *accessibility* elements differ (`element_diff`, needs metadata) — but not "given a frame diff **and a list of element boxes**, which of *those* changed?". `localize_changes` diffs a reference against the current screen and scores each supplied element box by its mean per-pixel change; `rank_changes` is the pure ranker that flags `changed` (score ≥ `threshold`) and sorts most-changed first. Pairs with `set_of_marks`/accessibility boxes to give a per-element "what changed" feedback signal after a click. cv2/numpy imported lazily; ranking is pure and fully testable. Fifth feature of the ROUND-15 perception lane. No `PySide6`.
10+
511
### Theme-Invariant Matching (Light Template, Dark Mode)
612

713
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).
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
Localize a Change to the Elements That Changed
2+
==============================================
3+
4+
The existing diffs answer "*where* did pixels change" (``motion_regions``,
5+
``perceptual_diff``, ``ssim_changed_regions`` return raw pixel regions) or "which
6+
*accessibility* elements differ" (``element_diff``, needs a11y metadata). The
7+
missing middle is: given a frame diff **and a list of element boxes**, which of
8+
*those* elements changed? ``change_localize`` scores each supplied box by how
9+
much it changed and ranks them.
10+
11+
* :func:`rank_changes` — pure: take ``[{box, score}]`` and mark each box
12+
``changed`` (score at or above ``threshold``), sorted most-changed first.
13+
* :func:`localize_changes` — diff a reference against the current screen, score
14+
each element box by its mean pixel change, and rank them.
15+
16+
``cv2`` / ``numpy`` are imported lazily (the module stays importable without
17+
them) and the loaders reuse :mod:`visual_match`. The ranking is pure and fully
18+
testable. Imports no ``PySide6``.
19+
20+
Headless API
21+
------------
22+
23+
.. code-block:: python
24+
25+
from je_auto_control import localize_changes, rank_changes, mark_elements
26+
27+
boxes = [mark["bbox"] for mark in mark_elements(elements)]
28+
29+
# After an action, which of those elements actually changed?
30+
changed = localize_changes("before.png", boxes, current="after.png")
31+
for entry in changed:
32+
if entry["changed"]:
33+
print("element changed:", entry["box"], entry["score"])
34+
35+
# Or rank pre-computed scores yourself:
36+
rank_changes([{"box": [0, 0, 40, 20], "score": 0.6}], threshold=0.1)
37+
38+
``localize_changes`` returns ``[{box, score, changed}]`` sorted most-changed
39+
first, where ``score`` is the box's mean per-pixel change (0..1). It pairs with
40+
``set_of_marks`` / accessibility element boxes to turn a raw screen diff into a
41+
per-element "what changed" signal — an agent feedback channel after a click.
42+
43+
Executor commands
44+
-----------------
45+
46+
``AC_localize_changes`` (``reference`` + ``boxes`` JSON list + ``current`` /
47+
``threshold`` / ``region`` → ``{changes}``) and ``AC_rank_changes``
48+
(``scored_boxes`` JSON list + ``threshold`` → ``{changes}``, pure). They are the
49+
matching read-only ``ac_*`` MCP tools and Script Builder commands under
50+
**Image**.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
把變化歸因到實際改變的元素
2+
==========================
3+
4+
既有的 diff 回答「像素在*哪裡*改變」(``motion_regions``、``perceptual_diff``、
5+
``ssim_changed_regions`` 回傳原始像素區域),或「哪些*無障礙*元素不同」(``element_diff``,需 a11y 中介資料)。
6+
缺少的中段是:給定一個畫面 diff **與一份元素方框清單**,*那些*元素中哪些改變了?``change_localize`` 依
7+
每個提供的方框改變多少評分並排序。
8+
9+
* :func:`rank_changes` ——純函式:接受 ``[{box, score}]`` 並把每個方框標記為 ``changed``
10+
(分數達到或超過 ``threshold``),依改變最多排在最前。
11+
* :func:`localize_changes` ——把參考影像對目前螢幕做 diff,依每個元素方框的平均像素改變評分,再排序。
12+
13+
``cv2`` / ``numpy`` 採延遲匯入(模組無需它們即可匯入),載入器重用 :mod:`visual_match`。
14+
排序為純函式且可完整測試。不匯入 ``PySide6``。
15+
16+
無頭 API
17+
--------
18+
19+
.. code-block:: python
20+
21+
from je_auto_control import localize_changes, rank_changes, mark_elements
22+
23+
boxes = [mark["bbox"] for mark in mark_elements(elements)]
24+
25+
# 某動作後,那些元素中哪些真的改變了?
26+
changed = localize_changes("before.png", boxes, current="after.png")
27+
for entry in changed:
28+
if entry["changed"]:
29+
print("元素改變:", entry["box"], entry["score"])
30+
31+
# 或自行排序預先算好的分數:
32+
rank_changes([{"box": [0, 0, 40, 20], "score": 0.6}], threshold=0.1)
33+
34+
``localize_changes`` 回傳 ``[{box, score, changed}]`` 依改變最多排序,``score`` 是方框的平均
35+
逐像素改變(0..1)。它與 ``set_of_marks`` / 無障礙元素方框搭配,把原始螢幕 diff 轉成逐元素的
36+
「什麼改變了」訊號——點擊後的 agent 回饋通道。
37+
38+
執行器指令
39+
----------
40+
41+
``AC_localize_changes``(``reference`` 加上 ``boxes`` JSON 清單加上 ``current`` /
42+
``threshold`` / ``region`` → ``{changes}``)與 ``AC_rank_changes``(``scored_boxes`` JSON 清單加上
43+
``threshold`` → ``{changes}``,純函式)。皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令
44+
(位於 **Image** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@
143143
)
144144
# Theme-invariant matching so a light template matches dark mode
145145
from je_auto_control.utils.theme_normalize import match_theme, normalize_theme
146+
# Attribute a screen change to the specific element boxes that changed
147+
from je_auto_control.utils.change_localize import localize_changes, rank_changes
146148
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
147149
from je_auto_control.utils.clipboard_rich_formats import (
148150
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1771,6 +1773,7 @@ def start_autocontrol_gui(*args, **kwargs):
17711773
"place_labels", "label_color",
17721774
"grade_contrast", "dominant_pair", "region_contrast",
17731775
"normalize_theme", "match_theme",
1776+
"localize_changes", "rank_changes",
17741777
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17751778
"set_clipboard_rtf", "get_clipboard_rtf",
17761779
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4606,6 +4606,32 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
46064606
),
46074607
description="Locate a template across a light/dark theme flip.",
46084608
))
4609+
specs.append(CommandSpec(
4610+
"AC_rank_changes", "Image", "Rank Changed Boxes",
4611+
fields=(
4612+
FieldSpec("scored_boxes", FieldType.STRING,
4613+
placeholder="JSON list of {box, score}"),
4614+
FieldSpec("threshold", FieldType.FLOAT, optional=True,
4615+
default=0.1),
4616+
),
4617+
description="Rank scored element boxes by how much they changed.",
4618+
))
4619+
specs.append(CommandSpec(
4620+
"AC_localize_changes", "Image", "Localize Changed Elements",
4621+
fields=(
4622+
FieldSpec("reference", FieldType.STRING,
4623+
placeholder="reference image path"),
4624+
FieldSpec("boxes", FieldType.STRING,
4625+
placeholder="JSON list of [x, y, w, h]"),
4626+
FieldSpec("current", FieldType.STRING, optional=True,
4627+
placeholder="current image path (else screen)"),
4628+
FieldSpec("threshold", FieldType.FLOAT, optional=True,
4629+
default=0.1),
4630+
FieldSpec("region", FieldType.STRING, optional=True,
4631+
placeholder="[x, y, w, h]"),
4632+
),
4633+
description="Rank which element boxes changed between two frames.",
4634+
))
46094635
specs.append(CommandSpec(
46104636
"AC_normalize_ext", "Shell", "Normalize Extension",
46114637
fields=(
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Attribute a screen change to the specific element boxes that changed."""
2+
from je_auto_control.utils.change_localize.change_localize import (
3+
localize_changes, rank_changes,
4+
)
5+
6+
__all__ = ["localize_changes", "rank_changes"]
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Attribute a screen change to the specific elements that changed.
2+
3+
The existing diffs answer "*where* did pixels change" (``motion_regions``,
4+
``perceptual_diff``, ``ssim_changed_regions`` return raw pixel regions) or "which
5+
*accessibility* elements differ" (``element_diff``, needs a11y metadata). The
6+
missing middle is: given a frame diff **and a list of element boxes**, which of
7+
*those* elements changed? ``change_localize`` scores each supplied box by how
8+
much it changed and ranks them.
9+
10+
* :func:`rank_changes` — pure: take ``[{box, score}]`` and mark each box
11+
``changed`` (score at or above ``threshold``), sorted most-changed first.
12+
* :func:`localize_changes` — diff a reference against the current screen, score
13+
each element box by its mean pixel change, and rank them.
14+
15+
cv2 / numpy are imported lazily (the module stays importable without them) and
16+
the loaders reuse :mod:`visual_match`. The ranking is pure and fully testable.
17+
Imports no ``PySide6``.
18+
"""
19+
from typing import Any, Dict, List, Optional, Sequence
20+
21+
22+
def _unpack(item: Any) -> tuple:
23+
"""Return ``(box, score)`` from a ``{box, score}`` dict or a ``(box, score)``."""
24+
if isinstance(item, dict):
25+
return item["box"], item["score"]
26+
return item[0], item[1]
27+
28+
29+
def rank_changes(scored_boxes: Sequence[Any], *,
30+
threshold: float = 0.1) -> List[Dict[str, Any]]:
31+
"""Mark and rank scored element boxes by how much they changed (pure).
32+
33+
``scored_boxes`` is a sequence of ``{box, score}`` (or ``(box, score)``).
34+
Returns ``[{box, score, changed}]`` sorted by descending score; ``changed``
35+
is ``True`` when the score is at or above ``threshold``.
36+
"""
37+
limit = float(threshold)
38+
result = [
39+
{"box": [int(value) for value in box],
40+
"score": round(float(score), 4),
41+
"changed": float(score) >= limit}
42+
for box, score in (_unpack(item) for item in scored_boxes)
43+
]
44+
result.sort(key=lambda entry: entry["score"], reverse=True)
45+
return result
46+
47+
48+
def _box_mean(diff: Any, box: Sequence[int]) -> float:
49+
"""Mean change (0..1) of the diff map inside ``box`` (numpy)."""
50+
x, y, w, h = (int(box[0]), int(box[1]), int(box[2]), int(box[3]))
51+
patch = diff[max(0, y):y + h, max(0, x):x + w]
52+
return float(patch.mean()) if patch.size else 0.0
53+
54+
55+
def localize_changes(reference: Any, boxes: Sequence[Sequence[int]], *,
56+
current: Optional[Any] = None, threshold: float = 0.1,
57+
region: Optional[Sequence[int]] = None
58+
) -> List[Dict[str, Any]]:
59+
"""Score and rank which of ``boxes`` changed between two frames.
60+
61+
Diffs ``reference`` against ``current`` (a fresh screen grab of ``region``
62+
by default), takes each box's mean per-pixel change (0..1), and ranks them
63+
via :func:`rank_changes`. Returns ``[{box, score, changed}]``.
64+
"""
65+
import numpy as np
66+
from je_auto_control.utils.visual_match.visual_match import (
67+
_grab_gray, _to_gray)
68+
ref = _to_gray(reference).astype("float64")
69+
other = current if current is not None else _grab_gray(region)
70+
cur = _to_gray(other).astype("float64")
71+
diff = np.abs(ref - cur) / 255.0
72+
scored = [{"box": list(box), "score": _box_mean(diff, box)}
73+
for box in boxes]
74+
return rank_changes(scored, threshold=threshold)

je_auto_control/utils/executor/action_executor.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2912,6 +2912,26 @@ def _match_theme(template: Any, region: Any = None, method: Any = "sobel",
29122912
return {"found": True, **match}
29132913

29142914

2915+
def _rank_changes(scored_boxes: Any, threshold: Any = 0.1) -> Dict[str, Any]:
2916+
"""Adapter: rank scored element boxes by how much they changed (pure)."""
2917+
from je_auto_control.utils.change_localize import rank_changes
2918+
items = _coerce_list(scored_boxes) if scored_boxes else []
2919+
return {"changes": rank_changes(items, threshold=float(threshold))}
2920+
2921+
2922+
def _localize_changes(reference: Any, boxes: Any, current: Any = None,
2923+
threshold: Any = 0.1, region: Any = None
2924+
) -> Dict[str, Any]:
2925+
"""Adapter: rank which element boxes changed between two frames (device)."""
2926+
from je_auto_control.utils.change_localize import localize_changes
2927+
box_list = _coerce_list(boxes) if boxes else []
2928+
changes = localize_changes(str(reference), box_list,
2929+
current=str(current) if current else None,
2930+
threshold=float(threshold),
2931+
region=_coerce_region(region))
2932+
return {"changes": changes}
2933+
2934+
29152935
def _normalize_ext(target: str) -> Dict[str, Any]:
29162936
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
29172937
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6951,6 +6971,8 @@ def __init__(self):
69516971
"AC_dominant_pair": _dominant_pair,
69526972
"AC_region_contrast": _region_contrast,
69536973
"AC_match_theme": _match_theme,
6974+
"AC_rank_changes": _rank_changes,
6975+
"AC_localize_changes": _localize_changes,
69546976
"AC_normalize_ext": _normalize_ext,
69556977
"AC_file_association": _file_association,
69566978
"AC_get_control_text": _get_control_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4107,6 +4107,36 @@ def img_histogram_tools() -> List[MCPTool]:
41074107
handler=h.match_theme,
41084108
annotations=READ_ONLY,
41094109
),
4110+
MCPTool(
4111+
name="ac_rank_changes",
4112+
description=("Rank scored element boxes by how much they changed. "
4113+
"'scored_boxes' is a list of {box:[x,y,w,h], score}. "
4114+
"Pure. Returns {changes:[{box, score, changed}]} "
4115+
"sorted most-changed first."),
4116+
input_schema=schema({"scored_boxes": {"type": "array",
4117+
"items": {"type": "object"}},
4118+
"threshold": {"type": "number"}},
4119+
required=["scored_boxes"]),
4120+
handler=h.rank_changes,
4121+
annotations=READ_ONLY,
4122+
),
4123+
MCPTool(
4124+
name="ac_localize_changes",
4125+
description=("Which of the supplied element 'boxes' changed between "
4126+
"a 'reference' image and the current screen (or "
4127+
"'current' image). Returns {changes:[{box, score, "
4128+
"changed}]}."),
4129+
input_schema=schema({"reference": {"type": "string"},
4130+
"boxes": {"type": "array",
4131+
"items": {"type": "array"}},
4132+
"current": {"type": "string"},
4133+
"threshold": {"type": "number"},
4134+
"region": {"type": "array",
4135+
"items": {"type": "integer"}}},
4136+
required=["reference", "boxes"]),
4137+
handler=h.localize_changes,
4138+
annotations=READ_ONLY,
4139+
),
41104140
]
41114141

41124142

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,19 @@ def match_theme(template, region=None, method="sobel", min_score=0.5):
774774
return _match_theme(template, region, method, min_score)
775775

776776

777+
def rank_changes(scored_boxes, threshold=0.1):
778+
from je_auto_control.utils.executor.action_executor import _rank_changes
779+
return _rank_changes(scored_boxes, threshold)
780+
781+
782+
def localize_changes(reference, boxes, current=None, threshold=0.1,
783+
region=None):
784+
from je_auto_control.utils.executor.action_executor import (
785+
_localize_changes,
786+
)
787+
return _localize_changes(reference, boxes, current, threshold, region)
788+
789+
777790
def normalize_ext(target):
778791
from je_auto_control.utils.executor.action_executor import _normalize_ext
779792
return _normalize_ext(target)

0 commit comments

Comments
 (0)