diogoneno commited on
Commit
05f3c62
·
verified ·
1 Parent(s): f9a8ea8

initial release: 15-class MobileNetV3-small GUI element classifier

Browse files
Files changed (6) hide show
  1. MANIFEST.md +42 -0
  2. README.md +138 -0
  3. classes.json +21 -0
  4. inference_example.py +105 -0
  5. mobilenetv3_small.onnx +3 -0
  6. mobilenetv3_small.pth +3 -0
MANIFEST.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Artifact Manifest — GUI Element Classifier
2
+
3
+ Standalone bundle of a 15-class MobileNetV3-small GUI element type classifier, prepared for HuggingFace Hub release.
4
+
5
+ ## Files
6
+
7
+ | File | sha256 | Purpose |
8
+ |---|---|---|
9
+ | `mobilenetv3_small.onnx` | `5e17d4c43de2927ca3f6ab56e46edf44d2d5ef991fa0afc5d1892bbbf65fbf05` | ONNX export, fixed batch=1, primary inference artifact |
10
+ | `mobilenetv3_small.pth` | `a1176d43ce666e819d712892b0c8e47bdff99616f094c3e5e58d2ff886535292` | PyTorch state_dict source for the same weights — useful for fine-tuning further or re-exporting with dynamic batch axis |
11
+ | `classes.json` | (small) | 15-class taxonomy + index ordering |
12
+ | `inference_example.py` | (small) | Self-contained 100-line inference demo |
13
+ | `README.md` | (small) | Model card |
14
+
15
+ ## Architecture & training
16
+
17
+ - **Architecture:** MobileNetV3-small, 15-class output head.
18
+ - **Training data:** A curated set of GUI element crops drawn primarily from Linux desktop UIs (~7000 crops across the 15 classes after consolidation).
19
+ - **Training framework:** PyTorch with `torchvision.models.mobilenet_v3_small` backbone + cross-entropy loss on the 15-class head; ImageNet-pretrained backbone fine-tuned end-to-end.
20
+ - **Held-out test set:** 1449 examples sampled from the training distribution (not strictly out-of-distribution).
21
+ - **Export:** ONNX opset 17, fixed batch_size=1.
22
+ - **Training-time metrics:** accuracy ≈ 0.72, macro-F1 ≈ 0.58, weighted-F1 ≈ 0.76. Per-class F1 details and the limits of these metrics are spelled out in `README.md`.
23
+
24
+ ## Framework versions used during this export
25
+
26
+ - Python 3.11
27
+ - ONNX Runtime 1.25.x (validated on the inference path)
28
+ - PIL (Pillow) 10.4+
29
+ - NumPy 1.26+
30
+
31
+ ## Build date
32
+
33
+ - Weights frozen / exported: 2026-03 (training run). Bundle assembled for HF release: 2026-05-02.
34
+
35
+ ## Verification
36
+
37
+ After download, verify the ONNX file hash matches:
38
+
39
+ ```bash
40
+ sha256sum mobilenetv3_small.onnx
41
+ # expected: 5e17d4c43de2927ca3f6ab56e46edf44d2d5ef991fa0afc5d1892bbbf65fbf05
42
+ ```
README.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ library_name: onnx
6
+ tags:
7
+ - gui-element-classification
8
+ - ui-classification
9
+ - desktop-agents
10
+ - agentic-ai
11
+ - mobilenet-v3
12
+ - computer-use
13
+ - onnx
14
+ pipeline_tag: image-classification
15
+ ---
16
+
17
+ # GUI Element Classifier — MobileNetV3-small (15 classes)
18
+
19
+ Lightweight (~6 MB) MobileNetV3-small ONNX classifier for **15 GUI element types**. CPU-friendly (~5 ms per crop, ONNX Runtime), designed as a deterministic preprocessing step before VLM-based GUI agent pipelines. No GPU required, no external dependencies beyond `onnxruntime`, `numpy`, and `pillow`.
20
+
21
+ ## What it's for
22
+
23
+ You have a screenshot, a list of detected element bounding boxes (from any detector — YOLOv8, OWL-ViT, SAM-then-filter, accessibility tree, anything else), and you need cheap, deterministic per-element type labels (`button` vs `text_input` vs `slider` vs …) before passing the structured layout to a reasoning LLM. Drop this classifier into the pipeline as the typing layer:
24
+
25
+ ```
26
+ [Screenshot]
27
+
28
+ [Your detector] → list of bboxes
29
+
30
+ [This classifier] → per-bbox type label + confidence
31
+
32
+ [Your reasoning / action LLM] → reasons over typed elements, not pixels
33
+ ```
34
+
35
+ The classifier is not a replacement for VLM captioning — it's the cheap deterministic layer that adds structure to your prompt so the LLM doesn't have to look at every region just to figure out what it is.
36
+
37
+ ## Classes (15)
38
+
39
+ `button`, `checkbox`, `container`, `dropdown`, `icon_button`, `image`, `label`, `link`, `menu_item`, `scrollbar`, `slider`, `tab`, `text_input`, `toggle`, `unknown`
40
+
41
+ The class indices in the model output (0..14) match the alphabetical ordering above. See [`classes.json`](classes.json) for the canonical list.
42
+
43
+ ## Files
44
+
45
+ | File | Purpose |
46
+ |---|---|
47
+ | `mobilenetv3_small.onnx` | ONNX export (fixed batch=1). Primary inference artifact. |
48
+ | `mobilenetv3_small.pth` | PyTorch state_dict for those who want to fine-tune further or re-export with dynamic axes. |
49
+ | `classes.json` | Class names + ordering. |
50
+ | `inference_example.py` | 100-line self-contained demo. `pip install onnxruntime numpy pillow` then `python inference_example.py crop.png`. |
51
+
52
+ ## Quick start
53
+
54
+ ```python
55
+ from PIL import Image
56
+ import numpy as np
57
+ import onnxruntime as ort
58
+
59
+ CLASSES = ['button','checkbox','container','dropdown','icon_button',
60
+ 'image','label','link','menu_item','scrollbar',
61
+ 'slider','tab','text_input','toggle','unknown']
62
+
63
+ MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
64
+ STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
65
+
66
+ def preprocess(img: Image.Image) -> np.ndarray:
67
+ img = img.convert("RGB")
68
+ w, h = img.size
69
+ m = max(w, h)
70
+ pad = Image.new("RGB", (m, m), (128, 128, 128))
71
+ pad.paste(img, ((m - w) // 2, (m - h) // 2))
72
+ arr = np.array(pad.resize((224, 224), Image.BILINEAR), dtype=np.float32) / 255.0
73
+ arr = (arr - MEAN) / STD
74
+ return arr.transpose(2, 0, 1)[None, :, :, :].astype(np.float32)
75
+
76
+ sess = ort.InferenceSession("mobilenetv3_small.onnx",
77
+ providers=["CPUExecutionProvider"])
78
+ crop = Image.open("button.png")
79
+ logits = sess.run(None, {sess.get_inputs()[0].name: preprocess(crop)})[0]
80
+ probs = np.exp(logits - logits.max()) / np.exp(logits - logits.max()).sum()
81
+ idx = int(probs.argmax())
82
+ print(CLASSES[idx], float(probs[0, idx]))
83
+ ```
84
+
85
+ ## Preprocessing (must-match-or-quality-degrades)
86
+
87
+ 1. **PadToSquare with gray (128, 128, 128)** on the shorter axis.
88
+ 2. **Resize to 224x224** with `Image.BILINEAR`.
89
+ 3. `array / 255.0` → float32 in [0, 1].
90
+ 4. **ImageNet normalize**: `mean=[0.485, 0.456, 0.406]`, `std=[0.229, 0.224, 0.225]`.
91
+ 5. Transpose HWC → CHW. Add batch dim.
92
+
93
+ The model is sensitive to all five steps — wrong pad colour, BICUBIC instead of BILINEAR, or skipping the ImageNet stats will degrade accuracy noticeably.
94
+
95
+ ## Performance
96
+
97
+ Aggregate metrics from the training-time held-out evaluation:
98
+
99
+ | Metric | Value |
100
+ |---|---|
101
+ | Test set | 1449 examples (sampled from the training distribution, **not strictly out-of-distribution** — see Limitations) |
102
+ | Accuracy | ~0.72 |
103
+ | Macro F1 | ~0.58 |
104
+ | Weighted F1 | ~0.76 |
105
+
106
+ Weighted F1 is dominated by the `icon_button` class (~50% of test support, F1=0.78). Per-class F1s vary widely — `tab` and `text_input` clear 0.95+, `container` and `slider` lag at 0.15-0.25. **If your domain skews heavily to specific classes, expect class-imbalance effects.**
107
+
108
+ **Latency:** ~5 ms per crop on a modern x86 laptop CPU (Intel i7-12th gen, single thread). The shipped ONNX is fixed batch_size=1 for the simplest possible drop-in; if you're processing >100 crops per screenshot, re-export with dynamic axes from the included `.pth` for batching.
109
+
110
+ ## Limitations & honest scope
111
+
112
+ - **Training-time test-set metrics are not a tight estimate of accuracy on your domain.** The aggregate numbers above (acc ~0.72, weighted F1 ~0.76) come from a single held-out split **sampled from the training distribution — this is not strictly out-of-distribution evaluation**. On a domain that differs from the training mix (web vs desktop, different OS look-and-feel, dark mode vs light mode, custom design systems), expect a meaningful gap from these numbers. Validate on your own labelled crops before depending on the figures.
113
+ - **Per-class F1 varies widely.** `tab` and `text_input` clear 0.95+, `icon_button` sits around 0.78 (and dominates weighted F1 because of class imbalance — ~50% of test support), while `container` and `slider` lag at 0.15-0.25. The aggregate numbers (acc / macro / weighted F1) hide this variance — read the per-class story above before trusting the headline.
114
+ - **Linux desktop bias.** Training data skews toward Linux desktop UIs (XFCE / GTK toolkits / Firefox / Mousepad / Thunar / terminal). Web pages, macOS, Windows 11, and mobile UIs are likely under-represented and may need domain adaptation.
115
+ - **`unknown` is a real class.** When the classifier produces `unknown` with high confidence, the input is genuinely ambiguous (small icon with no clear visual identity); don't paper over it with `argmax-but-skip-unknown` logic.
116
+ - **`container` and `slider` underperform** at training-time evaluation. Consider using bbox geometry as a sanity check (containers are large, sliders are wide-and-thin) alongside the model rather than trusting it alone for those two classes.
117
+ - **Single-label argmax.** No multi-class output. If a region could legitimately be both `tab` and `button` (some apps style tabs as buttons), the model picks one.
118
+ - **Fixed batch_size=1 in the shipped ONNX.** For high-throughput scenarios, re-export from the included `.pth` with dynamic axes (`torch.onnx.export(..., dynamic_axes={'input': {0: 'batch'}})`).
119
+ - **ImageNet preprocessing is assumed.** The model was trained against the standard ImageNet mean/std + PadToSquare-with-gray. Substituting different normalization will silently degrade results.
120
+
121
+ ## License
122
+
123
+ Apache-2.0. The MobileNetV3-small architecture itself was originally introduced by Google Research; this export uses the open architecture and re-trained weights.
124
+
125
+ ## Citation
126
+
127
+ ```
128
+ @misc{gui_element_classifier_mobilenetv3_2026,
129
+ author = {Diogo Neno},
130
+ title = {15-class MobileNetV3-small GUI Element Classifier},
131
+ year = {2026},
132
+ url = {https://huggingface.co/diogoneno/gui-element-classifier},
133
+ }
134
+ ```
135
+
136
+ ## Changelog
137
+
138
+ - `2026-05-02` — Initial public release.
classes.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "classes": [
3
+ "button",
4
+ "checkbox",
5
+ "container",
6
+ "dropdown",
7
+ "icon_button",
8
+ "image",
9
+ "label",
10
+ "link",
11
+ "menu_item",
12
+ "scrollbar",
13
+ "slider",
14
+ "tab",
15
+ "text_input",
16
+ "toggle",
17
+ "unknown"
18
+ ],
19
+ "n_classes": 15,
20
+ "ordering": "alphabetical (model output index 0..14 corresponds to this list in order)"
21
+ }
inference_example.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained inference example for the 15-class UI-element classifier.
2
+
3
+ Run:
4
+ pip install onnxruntime numpy pillow
5
+ python inference_example.py path/to/element_crop.png
6
+
7
+ Designed to drop into an LLM-orchestration loop where you have a screenshot,
8
+ a list of detected element bounding boxes (from any detector — YOLOv8, OWL-ViT,
9
+ SAM-then-filter, accessibility tree, etc.), and you need cheap, deterministic
10
+ per-element type labels before passing them to a reasoning LLM.
11
+
12
+ Inference is CPU-friendly (~5 ms per crop on a modern x86 laptop). Use it as a
13
+ 'helper' that adds structure to the orchestrator's prompt — e.g., 'click the
14
+ text_input near label "Username"' — instead of paying VLM tokens to look at
15
+ every crop.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ import onnxruntime as ort
25
+ from PIL import Image
26
+
27
+ HERE = Path(__file__).parent
28
+ ONNX_PATH = HERE / "mobilenetv3_small.onnx"
29
+ CLASSES = json.loads((HERE / "classes.json").read_text())["classes"]
30
+
31
+ IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
32
+ IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
33
+
34
+
35
+ def pad_to_square(img: Image.Image) -> Image.Image:
36
+ """Pad shorter side with gray (128, 128, 128) — must match training transform."""
37
+ w, h = img.size
38
+ m = max(w, h)
39
+ out = Image.new("RGB", (m, m), (128, 128, 128))
40
+ out.paste(img, ((m - w) // 2, (m - h) // 2))
41
+ return out
42
+
43
+
44
+ def preprocess(img: Image.Image) -> np.ndarray:
45
+ """PadToSquare -> Resize 224x224 BILINEAR -> /255 -> ImageNet normalize -> CHW."""
46
+ img = pad_to_square(img.convert("RGB"))
47
+ img = img.resize((224, 224), Image.BILINEAR)
48
+ arr = np.array(img, dtype=np.float32) / 255.0
49
+ arr = (arr - IMAGENET_MEAN) / IMAGENET_STD
50
+ arr = arr.transpose(2, 0, 1)
51
+ return arr[None, :, :, :].astype(np.float32) # (1, 3, 224, 224)
52
+
53
+
54
+ def softmax(x: np.ndarray) -> np.ndarray:
55
+ e = np.exp(x - np.max(x, axis=1, keepdims=True))
56
+ return e / np.sum(e, axis=1, keepdims=True)
57
+
58
+
59
+ def classify(crop_path: str | Path) -> dict:
60
+ """Classify a single element crop. Returns label, confidence, full score map."""
61
+ so = ort.SessionOptions()
62
+ so.intra_op_num_threads = 4
63
+ sess = ort.InferenceSession(str(ONNX_PATH), sess_options=so, providers=["CPUExecutionProvider"])
64
+ img = Image.open(crop_path)
65
+ batch = preprocess(img)
66
+ logits = sess.run(None, {sess.get_inputs()[0].name: batch})[0]
67
+ probs = softmax(logits)[0]
68
+ idx = int(np.argmax(probs))
69
+ return {
70
+ "label": CLASSES[idx],
71
+ "confidence": float(probs[idx]),
72
+ "scores": {c: float(probs[i]) for i, c in enumerate(CLASSES)},
73
+ }
74
+
75
+
76
+ def classify_batch(crop_paths: list[str | Path]) -> list[dict]:
77
+ """Convenience: per-crop loop. The shipped ONNX is fixed batch_size=1.
78
+
79
+ For higher throughput on large batches, re-export with dynamic axes and
80
+ run a single batched session.run() — kept simple here for clarity.
81
+ """
82
+ so = ort.SessionOptions()
83
+ so.intra_op_num_threads = 4
84
+ sess = ort.InferenceSession(str(ONNX_PATH), sess_options=so, providers=["CPUExecutionProvider"])
85
+ results = []
86
+ for p in crop_paths:
87
+ img = Image.open(p)
88
+ batch = preprocess(img)
89
+ logits = sess.run(None, {sess.get_inputs()[0].name: batch})[0]
90
+ probs = softmax(logits)[0]
91
+ idx = int(np.argmax(probs))
92
+ results.append({
93
+ "label": CLASSES[idx],
94
+ "confidence": float(probs[idx]),
95
+ })
96
+ return results
97
+
98
+
99
+ if __name__ == "__main__":
100
+ if len(sys.argv) < 2:
101
+ print("usage: python inference_example.py <crop.png> [<crop2.png> ...]")
102
+ sys.exit(1)
103
+ for path in sys.argv[1:]:
104
+ result = classify(path)
105
+ print(f"{path}: {result['label']} (confidence={result['confidence']:.3f})")
mobilenetv3_small.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5e17d4c43de2927ca3f6ab56e46edf44d2d5ef991fa0afc5d1892bbbf65fbf05
3
+ size 6142536
mobilenetv3_small.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a1176d43ce666e819d712892b0c8e47bdff99616f094c3e5e58d2ff886535292
3
+ size 6266042