Skip to content

Commit 3328b9c

Browse files
authored
Merge pull request #448 from Integration-Automation/feat/icon-classify-batch
Add icon_classify: classify a widget from its pixel shape
2 parents 3bfc506 + a802ab6 commit 3328b9c

11 files changed

Lines changed: 379 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+
### Classify a Widget from Its Pixel Shape
6+
7+
Tell a checkbox from a radio button from a text field — from pixels, no model. Full reference: [`docs/source/Eng/doc/new_features/v219_features_doc.rst`](docs/source/Eng/doc/new_features/v219_features_doc.rst).
8+
9+
- **`classify_widget` / `box_features` / `classify_icon`** (`AC_classify_widget`, `AC_classify_icon`): Set-of-Marks and element proposers return *boxes* but not *what each box is*; `form_fields.checkbox_state` reads a box already known to be a checkbox — the gap is the typing step before it. `box_features` extracts `{aspect, fill, edge_density, circularity}` for a box; `classify_widget` is the pure heuristic classifier (round→radio, wide-rounded→toggle, square-sparse→checkbox, wide-hollow→text_field, wide-filled→button, else icon); `classify_icon` composes them. The classifier is pure and fully testable; cv2/numpy imported lazily so the module stays importable. Sixth feature of the ROUND-15 perception lane. No `PySide6`.
10+
511
### Localize a Change to the Elements That Changed
612

713
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).
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
Classify a Widget from Its Pixel Shape
2+
======================================
3+
4+
Set-of-Marks and element proposers hand back *boxes*, but not *what each box is*.
5+
``form_fields.checkbox_state`` already reads a box known to be a checkbox; the
6+
gap is the typing step before it — is this box a checkbox, a radio button, a push
7+
button, a text field or a toggle? ``icon_classify`` answers that from cheap
8+
geometric features (no model).
9+
10+
* :func:`box_features` — extract ``{aspect, fill, edge_density, circularity}``
11+
for a box region (the objective measurements).
12+
* :func:`classify_widget` — pure: map a feature dict to a widget type by
13+
documented heuristics.
14+
* :func:`classify_icon` — compose the two: a box to ``{type, features}``.
15+
16+
``classify_widget`` is pure and fully testable; ``box_features`` imports cv2 /
17+
numpy lazily (the module stays importable without them) and reuses
18+
:func:`visual_match._to_gray`. Imports no ``PySide6``.
19+
20+
Headless API
21+
------------
22+
23+
.. code-block:: python
24+
25+
from je_auto_control import classify_icon, classify_widget
26+
27+
# From a screenshot + a box:
28+
classify_icon("dialog.png", [120, 80, 16, 16])
29+
# {'type': 'checkbox', 'features': {'aspect': 1.0, 'fill': 0.12, ...}}
30+
31+
# From features you already have:
32+
classify_widget({"aspect": 1.0, "circularity": 0.9, "fill": 0.4}) # 'radio'
33+
34+
The heuristics: a round box (aspect ≈ 1, high circularity) is a ``radio``; a wide
35+
rounded box is a ``toggle``; a near-square sparse box is a ``checkbox``; a wide
36+
hollow box is a ``text_field``; a wide filled box is a ``button``; anything else
37+
is an ``icon``. Tune by reading ``features`` and applying your own rules where
38+
the defaults misfire — the measurements are the durable part.
39+
40+
Executor commands
41+
-----------------
42+
43+
``AC_classify_widget`` (``features`` JSON object → ``{type}``, pure) and
44+
``AC_classify_icon`` (``source`` image + ``box`` ``[x, y, w, h]`` →
45+
``{type, features}``). They are the matching read-only ``ac_*`` MCP tools and
46+
Script Builder commands under **Image**.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
從像素形狀分類控制項
2+
====================
3+
4+
Set-of-Marks 與元素提案器回傳*方框*,卻不告訴你*每個方框是什麼*。``form_fields.checkbox_state``
5+
已能讀取一個已知是核取方塊的方框;缺少的是它之前的分類步驟——這個方框是核取方塊、單選鈕、按鈕、
6+
文字欄位還是切換開關?``icon_classify`` 從低成本的幾何特徵(無需模型)回答此問題。
7+
8+
* :func:`box_features` ——擷取方框區域的 ``{aspect, fill, edge_density, circularity}``(客觀量測)。
9+
* :func:`classify_widget` ——純函式:以記載的啟發式規則把特徵字典映射為控制項型別。
10+
* :func:`classify_icon` ——組合兩者:把一個方框轉為 ``{type, features}``。
11+
12+
``classify_widget`` 為純函式且可完整測試;``box_features`` 延遲匯入 cv2 / numpy(模組無需它們即可匯入),
13+
並重用 :func:`visual_match._to_gray`。不匯入 ``PySide6``。
14+
15+
無頭 API
16+
--------
17+
18+
.. code-block:: python
19+
20+
from je_auto_control import classify_icon, classify_widget
21+
22+
# 從截圖 + 方框:
23+
classify_icon("dialog.png", [120, 80, 16, 16])
24+
# {'type': 'checkbox', 'features': {'aspect': 1.0, 'fill': 0.12, ...}}
25+
26+
# 從你已有的特徵:
27+
classify_widget({"aspect": 1.0, "circularity": 0.9, "fill": 0.4}) # 'radio'
28+
29+
啟發式規則:圓形方框(aspect ≈ 1、高 circularity)為 ``radio``;寬且圓潤為 ``toggle``;
30+
近正方且稀疏為 ``checkbox``;寬且空心為 ``text_field``;寬且填滿為 ``button``;其餘為 ``icon``。
31+
在預設誤判處,可讀取 ``features`` 套用你自己的規則微調——量測值才是耐用的部分。
32+
33+
執行器指令
34+
----------
35+
36+
``AC_classify_widget``(``features`` JSON 物件 → ``{type}``,純函式)與
37+
``AC_classify_icon``(``source`` 影像 + ``box`` ``[x, y, w, h]`` → ``{type, features}``)。
38+
皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令(位於 **Image** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@
145145
from je_auto_control.utils.theme_normalize import match_theme, normalize_theme
146146
# Attribute a screen change to the specific element boxes that changed
147147
from je_auto_control.utils.change_localize import localize_changes, rank_changes
148+
# Classify what kind of widget a box is from its pixel shape
149+
from je_auto_control.utils.icon_classify import (
150+
box_features, classify_icon, classify_widget,
151+
)
148152
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
149153
from je_auto_control.utils.clipboard_rich_formats import (
150154
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1774,6 +1778,7 @@ def start_autocontrol_gui(*args, **kwargs):
17741778
"grade_contrast", "dominant_pair", "region_contrast",
17751779
"normalize_theme", "match_theme",
17761780
"localize_changes", "rank_changes",
1781+
"classify_widget", "box_features", "classify_icon",
17771782
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17781783
"set_clipboard_rtf", "get_clipboard_rtf",
17791784
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4632,6 +4632,22 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
46324632
),
46334633
description="Rank which element boxes changed between two frames.",
46344634
))
4635+
specs.append(CommandSpec(
4636+
"AC_classify_widget", "Image", "Classify Widget (features)",
4637+
fields=(
4638+
FieldSpec("features", FieldType.STRING,
4639+
placeholder="JSON {aspect, circularity, fill}"),
4640+
),
4641+
description="Map geometric features to a widget type.",
4642+
))
4643+
specs.append(CommandSpec(
4644+
"AC_classify_icon", "Image", "Classify Icon (box)",
4645+
fields=(
4646+
FieldSpec("source", FieldType.STRING, placeholder="image path"),
4647+
FieldSpec("box", FieldType.STRING, placeholder="[x, y, w, h]"),
4648+
),
4649+
description="Classify the widget in an image box from its pixels.",
4650+
))
46354651
specs.append(CommandSpec(
46364652
"AC_normalize_ext", "Shell", "Normalize Extension",
46374653
fields=(

je_auto_control/utils/executor/action_executor.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2932,6 +2932,20 @@ def _localize_changes(reference: Any, boxes: Any, current: Any = None,
29322932
return {"changes": changes}
29332933

29342934

2935+
def _classify_widget(features: Any) -> Dict[str, Any]:
2936+
"""Adapter: map geometric features to a widget type (pure)."""
2937+
from je_auto_control.utils.icon_classify import classify_widget
2938+
import json
2939+
data = json.loads(features) if isinstance(features, str) else dict(features)
2940+
return {"type": classify_widget(data)}
2941+
2942+
2943+
def _classify_icon(source: Any, box: Any) -> Dict[str, Any]:
2944+
"""Adapter: classify the widget in a box from its pixels (device)."""
2945+
from je_auto_control.utils.icon_classify import classify_icon
2946+
return classify_icon(str(source), _coerce_list(box))
2947+
2948+
29352949
def _normalize_ext(target: str) -> Dict[str, Any]:
29362950
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
29372951
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6973,6 +6987,8 @@ def __init__(self):
69736987
"AC_match_theme": _match_theme,
69746988
"AC_rank_changes": _rank_changes,
69756989
"AC_localize_changes": _localize_changes,
6990+
"AC_classify_widget": _classify_widget,
6991+
"AC_classify_icon": _classify_icon,
69766992
"AC_normalize_ext": _normalize_ext,
69776993
"AC_file_association": _file_association,
69786994
"AC_get_control_text": _get_control_text,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Classify what kind of widget a box is from its pixel shape."""
2+
from je_auto_control.utils.icon_classify.icon_classify import (
3+
WIDGET_TYPES, box_features, classify_icon, classify_widget,
4+
)
5+
6+
__all__ = ["classify_widget", "box_features", "classify_icon", "WIDGET_TYPES"]
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Classify what kind of widget a box is from its pixel shape.
2+
3+
Set-of-Marks and element proposers hand back *boxes*, but not *what each box is*.
4+
``form_fields.checkbox_state`` already reads a box known to be a checkbox; the
5+
gap is the typing step before it — is this box a checkbox, a radio button, a
6+
push button, a text field or a toggle? ``icon_classify`` answers that from cheap
7+
geometric features (no model):
8+
9+
* :func:`box_features` — extract ``{aspect, fill, edge_density, circularity}``
10+
for a box region (the objective measurements).
11+
* :func:`classify_widget` — pure: map a feature dict to a widget type by
12+
documented heuristics.
13+
* :func:`classify_icon` — compose the two: a box to ``{type, features}``.
14+
15+
``classify_widget`` is pure and fully testable; ``box_features`` imports cv2 /
16+
numpy lazily (the module stays importable without them) and reuses
17+
:func:`visual_match._to_gray`. Imports no ``PySide6``.
18+
"""
19+
from typing import Any, Dict, Sequence
20+
21+
# The widget types this classifier can return.
22+
WIDGET_TYPES = ("radio", "toggle", "checkbox", "text_field", "button", "icon")
23+
24+
25+
def _is_round(aspect: float, circ: float) -> bool:
26+
"""Near-square and circular (a radio button / round dot)."""
27+
return 0.7 <= aspect <= 1.4 and circ >= 0.7
28+
29+
30+
def _is_pill(aspect: float, circ: float) -> bool:
31+
"""Wide and rounded (a toggle switch)."""
32+
return 1.8 <= aspect <= 3.5 and circ >= 0.55
33+
34+
35+
def classify_widget(features: Dict[str, float]) -> str:
36+
"""Map geometric ``features`` to a widget type by heuristics (pure).
37+
38+
Uses ``aspect`` (w/h), ``circularity`` (1 = circle), and ``fill`` (ink
39+
fraction). Round → ``radio``; wide & rounded → ``toggle``; near-square &
40+
sparse → ``checkbox``; wide & hollow → ``text_field``; wide & filled →
41+
``button``; otherwise ``icon``.
42+
"""
43+
aspect = float(features.get("aspect", 1.0))
44+
circ = float(features.get("circularity", 0.0))
45+
fill = float(features.get("fill", 0.0))
46+
if _is_round(aspect, circ):
47+
return "radio"
48+
if _is_pill(aspect, circ):
49+
return "toggle"
50+
if 0.7 <= aspect <= 1.4 and fill <= 0.6:
51+
return "checkbox"
52+
if aspect >= 2.5 and fill <= 0.2:
53+
return "text_field"
54+
if aspect >= 1.5 and fill >= 0.2:
55+
return "button"
56+
return "icon"
57+
58+
59+
def _circularity(binary: Any) -> float:
60+
"""Circularity (``4*pi*A / P^2``, 1 = circle) of the largest blob."""
61+
import math
62+
63+
import cv2
64+
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL,
65+
cv2.CHAIN_APPROX_SIMPLE)
66+
if not contours:
67+
return 0.0
68+
largest = max(contours, key=cv2.contourArea)
69+
area = float(cv2.contourArea(largest))
70+
perimeter = float(cv2.arcLength(largest, True))
71+
if perimeter <= 0.0:
72+
return 0.0
73+
return min(1.0, 4.0 * math.pi * area / (perimeter * perimeter))
74+
75+
76+
def box_features(source: Any, box: Sequence[int]) -> Dict[str, float]:
77+
"""Extract ``{aspect, fill, edge_density, circularity}`` for a box (cv2).
78+
79+
``aspect`` is width/height, ``fill`` the ink fraction (Otsu foreground),
80+
``edge_density`` the Canny-edge fraction, ``circularity`` the largest blob's
81+
roundness. An empty box yields all zeros.
82+
"""
83+
import cv2
84+
from je_auto_control.utils.visual_match.visual_match import _to_gray
85+
gray = _to_gray(source)
86+
x, y, w, h = (int(box[0]), int(box[1]), int(box[2]), int(box[3]))
87+
patch = gray[max(0, y):y + h, max(0, x):x + w]
88+
if patch.size == 0:
89+
return {"aspect": 0.0, "fill": 0.0, "edge_density": 0.0,
90+
"circularity": 0.0}
91+
_, binary = cv2.threshold(patch, 0, 255,
92+
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
93+
fill = float((binary > 0).sum()) / patch.size
94+
edges = cv2.Canny(patch, 50, 150)
95+
edge_density = float((edges > 0).sum()) / patch.size
96+
return {
97+
"aspect": round(w / h, 3) if h else 0.0,
98+
"fill": round(fill, 3),
99+
"edge_density": round(edge_density, 3),
100+
"circularity": round(_circularity(binary), 3),
101+
}
102+
103+
104+
def classify_icon(source: Any, box: Sequence[int]) -> Dict[str, Any]:
105+
"""Classify the widget in a box from its pixels: ``{type, features}``."""
106+
features = box_features(source, box)
107+
return {"type": classify_widget(features), "features": features}

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4137,6 +4137,28 @@ def img_histogram_tools() -> List[MCPTool]:
41374137
handler=h.localize_changes,
41384138
annotations=READ_ONLY,
41394139
),
4140+
MCPTool(
4141+
name="ac_classify_widget",
4142+
description=("Map geometric 'features' {aspect, circularity, fill} "
4143+
"to a widget type (radio/toggle/checkbox/text_field/"
4144+
"button/icon). Pure. Returns {type}."),
4145+
input_schema=schema({"features": {"type": "object"}},
4146+
required=["features"]),
4147+
handler=h.classify_widget,
4148+
annotations=READ_ONLY,
4149+
),
4150+
MCPTool(
4151+
name="ac_classify_icon",
4152+
description=("Classify the widget in a 'box' [x,y,w,h] of a "
4153+
"'source' image from its pixel shape. Returns {type, "
4154+
"features}."),
4155+
input_schema=schema({"source": {"type": "string"},
4156+
"box": {"type": "array",
4157+
"items": {"type": "integer"}}},
4158+
required=["source", "box"]),
4159+
handler=h.classify_icon,
4160+
annotations=READ_ONLY,
4161+
),
41404162
]
41414163

41424164

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,16 @@ def localize_changes(reference, boxes, current=None, threshold=0.1,
787787
return _localize_changes(reference, boxes, current, threshold, region)
788788

789789

790+
def classify_widget(features):
791+
from je_auto_control.utils.executor.action_executor import _classify_widget
792+
return _classify_widget(features)
793+
794+
795+
def classify_icon(source, box):
796+
from je_auto_control.utils.executor.action_executor import _classify_icon
797+
return _classify_icon(source, box)
798+
799+
790800
def normalize_ext(target):
791801
from je_auto_control.utils.executor.action_executor import _normalize_ext
792802
return _normalize_ext(target)

0 commit comments

Comments
 (0)