Skip to content

Commit bb1d31e

Browse files
authored
Merge pull request #449 from Integration-Automation/feat/element-proposal-batch
Add element_proposal: propose a clean element list from raw pixels
2 parents 3328b9c + 01ce41e commit bb1d31e

11 files changed

Lines changed: 348 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+
### Template-Free Element Proposal (Pixels to Elements)
6+
7+
Get a clean numbered element list straight from the screen when there's no accessibility tree. Full reference: [`docs/source/Eng/doc/new_features/v220_features_doc.rst`](docs/source/Eng/doc/new_features/v220_features_doc.rst).
8+
9+
- **`propose_elements` / `tag_kinds`** (`AC_propose_elements`, `AC_tag_kinds`): Set-of-Marks, `observation` and the grounding helpers all assume you already have element boxes — but a game, a custom-drawn app or a remote desktop has no accessibility tree. `propose_elements` builds that top-of-funnel list from pixels: detect widget boxes (closed-edge blobs via Canny + morphology + `connected_boxes`) and text boxes (`text_regions.find_text_regions`), fuse them — the `element_parse` `ocr > icon` priority *is* the "drop widget-that-is-really-text" cross-check — and return them in reading order, each tagged `text` or `widget`. `tag_kinds` is the pure labeller. cv2 imported lazily; the labeller is fully testable. Seventh and final feature of the ROUND-15 perception lane. No `PySide6`.
10+
511
### Classify a Widget from Its Pixel Shape
612

713
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).
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
Template-Free Element Proposal (Pixels to Elements)
2+
===================================================
3+
4+
Set-of-Marks, ``observation`` and the grounding helpers all assume you already
5+
have a list of element boxes — but on a screen the framework doesn't model
6+
(a game, a custom-drawn app, a remote desktop) there is no accessibility tree to
7+
provide one. ``element_proposal`` builds that top-of-funnel list from pixels:
8+
detect candidate *widget* boxes (closed-edge blobs) and *text* boxes
9+
(:func:`text_regions.find_text_regions`), fuse them — dropping widget boxes that
10+
are really just text — and return them in reading order, each tagged ``text`` or
11+
``widget``.
12+
13+
* :func:`propose_elements` — the full pixel-to-elements pipeline.
14+
* :func:`tag_kinds` — pure: label fused boxes ``text`` / ``widget`` by source and
15+
keep their reading-order ``index``.
16+
17+
The fusion / cross-check / ordering reuse :mod:`element_parse` — the ``ocr`` >
18+
``icon`` source priority *is* the "drop widget-that-is-really-text" check — and
19+
the text detection reuses :mod:`text_regions`. ``cv2`` is imported lazily so the
20+
module stays importable; :func:`tag_kinds` is pure and fully testable. Imports no
21+
``PySide6``.
22+
23+
Headless API
24+
------------
25+
26+
.. code-block:: python
27+
28+
from je_auto_control import propose_elements, mark_elements
29+
30+
# No accessibility tree? Propose elements straight from the screen:
31+
elements = propose_elements(min_area=120)
32+
# [{'box': [x, y, w, h], 'kind': 'widget', 'index': 0}, ...]
33+
34+
# Feed them to Set-of-Marks like any other element list:
35+
marks = mark_elements(elements)
36+
37+
``propose_elements`` returns ``[{box, kind, index}]`` in reading order, where
38+
``kind`` is ``text`` or ``widget``. It is the missing top-of-funnel for the
39+
agent stack on un-modelled UIs: pixels in, a clean numbered element list out,
40+
ready for marking, observation or grounding. Tune ``min_area`` for the smallest
41+
control you care about and ``iou_threshold`` for how aggressively overlapping
42+
text and widget boxes are merged.
43+
44+
Executor commands
45+
-----------------
46+
47+
``AC_propose_elements`` (``region`` ``[x, y, w, h]`` / ``min_area`` /
48+
``iou_threshold`` → ``{elements}``) runs the full pipeline on the screen, and
49+
``AC_tag_kinds`` (``elements`` JSON list → ``{elements}``, pure) labels a
50+
pre-fused list. They are the matching read-only ``ac_*`` MCP tools and Script
51+
Builder commands under **Image**.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
免模板元素提案(像素到元素)
2+
============================
3+
4+
Set-of-Marks、``observation`` 與 grounding 輔助函式都假設你已有一份元素方框清單——但在框架無法
5+
建模的畫面上(遊戲、自繪 app、遠端桌面),並沒有無障礙樹可提供。``element_proposal`` 從像素建立
6+
這份漏斗頂端清單:偵測候選*控制項*方框(封閉邊緣 blob)與*文字*方框
7+
(:func:`text_regions.find_text_regions`),將兩者融合——丟棄其實只是文字的控制項方框——
8+
並依閱讀順序回傳,每個標記為 ``text`` 或 ``widget``。
9+
10+
* :func:`propose_elements` ——完整的像素到元素管線。
11+
* :func:`tag_kinds` ——純函式:依來源把融合後的方框標記 ``text`` / ``widget``,並保留其閱讀順序 ``index``。
12+
13+
融合 / 交叉檢查 / 排序重用 :mod:`element_parse`——``ocr`` > ``icon`` 來源優先序*即*「丟棄其實是
14+
文字的控制項」檢查——文字偵測則重用 :mod:`text_regions`。``cv2`` 採延遲匯入,故模組仍可匯入;
15+
:func:`tag_kinds` 為純函式且可完整測試。不匯入 ``PySide6``。
16+
17+
無頭 API
18+
--------
19+
20+
.. code-block:: python
21+
22+
from je_auto_control import propose_elements, mark_elements
23+
24+
# 沒有無障礙樹?直接從畫面提案元素:
25+
elements = propose_elements(min_area=120)
26+
# [{'box': [x, y, w, h], 'kind': 'widget', 'index': 0}, ...]
27+
28+
# 像任何元素清單一樣餵給 Set-of-Marks:
29+
marks = mark_elements(elements)
30+
31+
``propose_elements`` 依閱讀順序回傳 ``[{box, kind, index}]``,``kind`` 為 ``text`` 或 ``widget``。
32+
它是 agent 堆疊在未建模 UI 上缺少的漏斗頂端:像素進、乾淨的編號元素清單出,可供標記、observation
33+
或 grounding。以 ``min_area`` 調整你在意的最小控制項,以 ``iou_threshold`` 調整重疊文字與控制項
34+
方框合併的積極程度。
35+
36+
執行器指令
37+
----------
38+
39+
``AC_propose_elements``(``region`` ``[x, y, w, h]`` / ``min_area`` /
40+
``iou_threshold`` → ``{elements}``)在畫面上執行完整管線,``AC_tag_kinds``
41+
(``elements`` JSON 清單 → ``{elements}``,純函式)則標記預先融合的清單。皆以對應的唯讀
42+
``ac_*`` MCP 工具及 Script Builder 指令(位於 **Image** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@
149149
from je_auto_control.utils.icon_classify import (
150150
box_features, classify_icon, classify_widget,
151151
)
152+
# Propose a clean element list from raw pixels (template-free)
153+
from je_auto_control.utils.element_proposal import propose_elements, tag_kinds
152154
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
153155
from je_auto_control.utils.clipboard_rich_formats import (
154156
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1779,6 +1781,7 @@ def start_autocontrol_gui(*args, **kwargs):
17791781
"normalize_theme", "match_theme",
17801782
"localize_changes", "rank_changes",
17811783
"classify_widget", "box_features", "classify_icon",
1784+
"propose_elements", "tag_kinds",
17821785
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17831786
"set_clipboard_rtf", "get_clipboard_rtf",
17841787
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4648,6 +4648,25 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
46484648
),
46494649
description="Classify the widget in an image box from its pixels.",
46504650
))
4651+
specs.append(CommandSpec(
4652+
"AC_propose_elements", "Image", "Propose Elements (template-free)",
4653+
fields=(
4654+
FieldSpec("region", FieldType.STRING, optional=True,
4655+
placeholder="[x, y, w, h]"),
4656+
FieldSpec("min_area", FieldType.INT, optional=True, default=80),
4657+
FieldSpec("iou_threshold", FieldType.FLOAT, optional=True,
4658+
default=0.5),
4659+
),
4660+
description="Propose text/widget element boxes from raw screen pixels.",
4661+
))
4662+
specs.append(CommandSpec(
4663+
"AC_tag_kinds", "Image", "Tag Element Kinds",
4664+
fields=(
4665+
FieldSpec("elements", FieldType.STRING,
4666+
placeholder="JSON list of fused boxes"),
4667+
),
4668+
description="Label fused element boxes text/widget by source.",
4669+
))
46514670
specs.append(CommandSpec(
46524671
"AC_normalize_ext", "Shell", "Normalize Extension",
46534672
fields=(
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Propose a clean element list from raw pixels, with no template or model."""
2+
from je_auto_control.utils.element_proposal.element_proposal import (
3+
propose_elements, tag_kinds,
4+
)
5+
6+
__all__ = ["propose_elements", "tag_kinds"]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Propose a clean element list from raw pixels, with no template or model.
2+
3+
Set-of-Marks, ``observation`` and the grounding helpers all assume you already
4+
have a list of element boxes — but on a screen the framework doesn't model
5+
(a game, a custom-drawn app, a remote desktop) there is no accessibility tree to
6+
provide one. ``element_proposal`` builds that top-of-funnel list from pixels:
7+
detect candidate *widget* boxes (closed-edge blobs) and *text* boxes
8+
(:func:`text_regions.find_text_regions`), fuse them — dropping widget boxes that
9+
are really just text — and return them in reading order, each tagged ``text`` or
10+
``widget``.
11+
12+
* :func:`propose_elements` — the full pixel-to-elements pipeline.
13+
* :func:`tag_kinds` — pure: label fused boxes ``text`` / ``widget`` by source and
14+
keep their reading-order ``index``.
15+
16+
The fusion / cross-check / ordering reuse :mod:`element_parse` (the ``ocr`` >
17+
``icon`` priority *is* the "drop widget-that-is-really-text" check) and
18+
:mod:`text_regions`; ``cv2`` is imported lazily so the module stays importable.
19+
:func:`tag_kinds` is pure and fully testable. Imports no ``PySide6``.
20+
"""
21+
from typing import Any, Dict, List, Optional, Sequence
22+
23+
# Reading-order source tag to element kind.
24+
_KIND_BY_SOURCE = {"ocr": "text", "icon": "widget", "a11y": "element"}
25+
26+
27+
def tag_kinds(elements: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
28+
"""Label fused boxes ``text`` / ``widget`` by source (pure).
29+
30+
Each input box carries a ``source`` (``ocr`` / ``icon``) and an ``index``
31+
from :func:`element_parse.reading_order`. Returns ``[{box, kind, index}]``.
32+
"""
33+
result: List[Dict[str, Any]] = []
34+
for element in elements:
35+
box = [int(element["x"]), int(element["y"]),
36+
int(element["width"]), int(element["height"])]
37+
kind = _KIND_BY_SOURCE.get(element.get("source"), "widget")
38+
result.append({"box": box, "kind": kind, "index": element.get("index")})
39+
return result
40+
41+
42+
def _reasonable(box: Dict[str, Any], frame_w: int, frame_h: int) -> bool:
43+
"""Keep plausibly-widget blobs: not the whole frame, not a thin rule."""
44+
width, height = int(box["width"]), int(box["height"])
45+
if width >= 0.95 * frame_w and height >= 0.95 * frame_h:
46+
return False
47+
aspect = width / height if height else 0.0
48+
return 0.05 <= aspect <= 15.0
49+
50+
51+
def _widget_boxes(gray: Any, min_area: int) -> List[Dict[str, Any]]:
52+
"""Detect candidate widget boxes as closed-edge blobs (cv2)."""
53+
import cv2
54+
from je_auto_control.utils.cv2_utils.blobs import connected_boxes
55+
edges = cv2.Canny(gray, 50, 150)
56+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
57+
closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
58+
height, width = gray.shape[:2]
59+
return [box for box in connected_boxes(closed, min_area=int(min_area))
60+
if _reasonable(box, width, height)]
61+
62+
63+
def propose_elements(source: Optional[Any] = None, *,
64+
region: Optional[Sequence[int]] = None, min_area: int = 80,
65+
iou_threshold: float = 0.5) -> List[Dict[str, Any]]:
66+
"""Propose ``text`` / ``widget`` element boxes from pixels, in reading order.
67+
68+
Detects widget blobs and text regions on ``source`` (a fresh screen grab of
69+
``region`` by default), fuses them (overlapping text wins over widget), and
70+
orders them. Returns ``[{box, kind, index}]``.
71+
"""
72+
from je_auto_control.utils.element_parse import fuse_elements, reading_order
73+
from je_auto_control.utils.text_regions import find_text_regions
74+
from je_auto_control.utils.visual_match.visual_match import _haystack_gray
75+
gray = _haystack_gray(source, region)
76+
text = find_text_regions(gray, min_area=int(min_area))
77+
widgets = _widget_boxes(gray, int(min_area))
78+
fused = fuse_elements(ocr_boxes=text, icon_boxes=widgets,
79+
iou_threshold=float(iou_threshold))
80+
return tag_kinds(reading_order(fused))

je_auto_control/utils/executor/action_executor.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2946,6 +2946,23 @@ def _classify_icon(source: Any, box: Any) -> Dict[str, Any]:
29462946
return classify_icon(str(source), _coerce_list(box))
29472947

29482948

2949+
def _propose_elements(region: Any = None, min_area: Any = 80,
2950+
iou_threshold: Any = 0.5) -> Dict[str, Any]:
2951+
"""Adapter: propose text/widget element boxes from pixels (device)."""
2952+
from je_auto_control.utils.element_proposal import propose_elements
2953+
elements = propose_elements(region=_coerce_region(region),
2954+
min_area=int(min_area),
2955+
iou_threshold=float(iou_threshold))
2956+
return {"elements": elements}
2957+
2958+
2959+
def _tag_kinds(elements: Any) -> Dict[str, Any]:
2960+
"""Adapter: label fused boxes text/widget by source (pure)."""
2961+
from je_auto_control.utils.element_proposal import tag_kinds
2962+
items = _coerce_list(elements) if elements else []
2963+
return {"elements": tag_kinds(items)}
2964+
2965+
29492966
def _normalize_ext(target: str) -> Dict[str, Any]:
29502967
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
29512968
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6989,6 +7006,8 @@ def __init__(self):
69897006
"AC_localize_changes": _localize_changes,
69907007
"AC_classify_widget": _classify_widget,
69917008
"AC_classify_icon": _classify_icon,
7009+
"AC_propose_elements": _propose_elements,
7010+
"AC_tag_kinds": _tag_kinds,
69927011
"AC_normalize_ext": _normalize_ext,
69937012
"AC_file_association": _file_association,
69947013
"AC_get_control_text": _get_control_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4159,6 +4159,31 @@ def img_histogram_tools() -> List[MCPTool]:
41594159
handler=h.classify_icon,
41604160
annotations=READ_ONLY,
41614161
),
4162+
MCPTool(
4163+
name="ac_propose_elements",
4164+
description=("Propose text/widget element boxes from raw screen "
4165+
"pixels (template-free): detect widget blobs + text "
4166+
"regions, fuse, order. 'region' [x,y,w,h] clips. "
4167+
"Returns {elements:[{box, kind, index}]}."),
4168+
input_schema=schema({"region": {"type": "array",
4169+
"items": {"type": "integer"}},
4170+
"min_area": {"type": "integer"},
4171+
"iou_threshold": {"type": "number"}}),
4172+
handler=h.propose_elements,
4173+
annotations=READ_ONLY,
4174+
),
4175+
MCPTool(
4176+
name="ac_tag_kinds",
4177+
description=("Label fused element boxes 'text'/'widget' by source "
4178+
"(pure). 'elements' is a list of {x,y,width,height,"
4179+
"source,index}. Returns {elements:[{box, kind, "
4180+
"index}]}."),
4181+
input_schema=schema({"elements": {"type": "array",
4182+
"items": {"type": "object"}}},
4183+
required=["elements"]),
4184+
handler=h.tag_kinds,
4185+
annotations=READ_ONLY,
4186+
),
41624187
]
41634188

41644189

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,18 @@ def classify_icon(source, box):
797797
return _classify_icon(source, box)
798798

799799

800+
def propose_elements(region=None, min_area=80, iou_threshold=0.5):
801+
from je_auto_control.utils.executor.action_executor import (
802+
_propose_elements,
803+
)
804+
return _propose_elements(region, min_area, iou_threshold)
805+
806+
807+
def tag_kinds(elements):
808+
from je_auto_control.utils.executor.action_executor import _tag_kinds
809+
return _tag_kinds(elements)
810+
811+
800812
def normalize_ext(target):
801813
from je_auto_control.utils.executor.action_executor import _normalize_ext
802814
return _normalize_ext(target)

0 commit comments

Comments
 (0)