ocr_tools.py
15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
"""
Local Chinese OCR fallback — PP-OCRv6 tiny (det+rec) via onnxruntime, no cv2.
Why this exists: ui_control_tools.py's find_element/click_by_text reads the
uiautomator accessibility tree, which is strictly more reliable than OCR for
any element exposing text/resource-id/content-desc. OCR is only needed for
the two cases that tree can't cover: icon-only buttons with no accessible
label, and uiautomator returning a null root (AccessibilityService conflict,
see ui_control_tools.py's module docstring).
No opencv: opencv-python has no aarch64-android wheel and fails to build from
source in Termux (broken NDK/cmake toolchain for its `ninja` build dep). Since
phone screenshots are always axis-aligned (no skew), DBNet's polygon output is
collapsed to a plain bounding box — cv2.minAreaRect/warpPerspective are never
needed. Connected components come from scipy.ndimage.label instead of
cv2.findContours; the polygon "unclip" expand step still uses pyclipper
(pure C++ wheel, builds fine, no cv2 dependency) following PaddleOCR's
standard area/perimeter formula.
Models: PP-OCRv6 tiny det+rec ONNX (the edge/IoT tier, 1.5M params) +
ppocrv6_tiny_dict.txt, expected under ocr_models/ next to this file (~6MB,
not committed to git — see .gitignore). ONNX weights come from RapidOCR's
ModelScope mirror (huggingface.co is unreachable from this network); the
dict comes straight from the official PaddlePaddle/PaddleOCR repo, since
RapidOCR's own onnx config doesn't list one yet (see version history below):
mkdir -p ocr_models && cd ocr_models
curl -fL -o PP-OCRv6_det_tiny.onnx \
https://www.modelscope.cn/models/RapidAI/RapidOCR/resolve/v3.9.0/onnx/PP-OCRv6/det/PP-OCRv6_det_tiny.onnx
curl -fL -o PP-OCRv6_rec_tiny.onnx \
https://www.modelscope.cn/models/RapidAI/RapidOCR/resolve/v3.9.0/onnx/PP-OCRv6/rec/PP-OCRv6_rec_tiny.onnx
curl -fL -o ppocrv6_tiny_dict.txt \
https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv6_tiny_dict.txt
Version history (v4 -> v5 -> v6, all same DBNet-det + CTC-rec pipeline, only
weights/dict swapped each time — verified at each step by introspecting the
onnx I/O shapes and re-running the real-screenshot test, never by assuming):
- v4: first version found while researching; not a deliberate choice.
- v5: real accuracy upgrade over v4, fully documented (RapidOCR's
default_models.yaml carries a dict_url for it). Same input
normalization, rec height=48, sigmoid-baked det output, CTC
blank+dict+space decode as v4.
- v6 (released 2026-06-11, 12 days old at the time of writing): RapidOCR
already hosts the onnx weights but its default_models.yaml has no
dict_url for any v6 entry in any backend block (onnxruntime, openvino,
mnn, paddle) — see github.com/RapidAI/RapidOCR issue #686, an open
"support pp_ocr_v6" request. The official `paddleocr` pip package can't
be used here instead: paddleocr -> paddlex[ocr-core] hard-pins
opencv-contrib-python==4.10.0.84, which hits the exact same
no-aarch64-android-wheel wall as plain opencv-python. So: took the
RapidOCR-hosted v6 *tiny* onnx weights, paired them with
ppocrv6_tiny_dict.txt from the official PaddleOCR GitHub repo (the
"_tiny" dict, not the 18708-line unified one — confirmed by introspecting
PP-OCRv6_rec_tiny.onnx's output layer: 6906 classes ==
6904 dict lines + 1 CTC blank + 1 trailing space), and confirmed det
output is still a sigmoid-baked [0,1] map by running it on a real
screenshot rather than assuming. Same pipeline code as v4/v5, no
postprocessing changes needed.
Runtime deps (Termux native pkg, not pip — opencv-python has no aarch64-android
wheel and pip's fallback source build breaks on Termux's NDK/cmake setup):
pkg install python-onnxruntime python-numpy python-pillow python-scipy
pip install pyclipper # pure C++ ext, builds fine without cv2
and the venv this runs in needs `include-system-site-packages = true` in its
pyvenv.cfg so it can see those pkg-installed packages.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import numpy as np
import onnxruntime as ort
import pyclipper
from PIL import Image
from scipy import ndimage
from ui_control_tools import _do_screenshot, _do_tap, _run
_MODELS_DIR = Path(__file__).resolve().parent / "ocr_models"
_DET_MODEL = _MODELS_DIR / "PP-OCRv6_det_tiny.onnx"
_REC_MODEL = _MODELS_DIR / "PP-OCRv6_rec_tiny.onnx"
_DICT_FILE = _MODELS_DIR / "ppocrv6_tiny_dict.txt"
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
_DET_LIMIT_SIDE_LEN = 960
_DET_THRESH = 0.3
_DET_BOX_THRESH = 0.5
_DET_UNCLIP_RATIO = 1.6
_DET_MIN_SIZE = 3
_REC_IMG_H = 48
_REC_MAX_BATCH_WIDTH = 800 # cap per-crop resized width fed to the rec model
class _Engine:
"""Lazily-loaded singleton: model load (~15MB) happens once per process."""
_det_session: Optional[ort.InferenceSession] = None
_rec_session: Optional[ort.InferenceSession] = None
_chars: Optional[list[str]] = None
@classmethod
def det(cls) -> ort.InferenceSession:
if cls._det_session is None:
cls._det_session = ort.InferenceSession(str(_DET_MODEL),
providers=["CPUExecutionProvider"])
return cls._det_session
@classmethod
def rec(cls) -> ort.InferenceSession:
if cls._rec_session is None:
cls._rec_session = ort.InferenceSession(str(_REC_MODEL),
providers=["CPUExecutionProvider"])
return cls._rec_session
@classmethod
def chars(cls) -> list[str]:
if cls._chars is None:
dict_chars = _DICT_FILE.read_text(encoding="utf-8").splitlines()
# PaddleOCR CTCLabelDecode convention: index 0 is the CTC blank,
# the dict file supplies indices 1..N, and a trailing space is
# appended as the final class.
cls._chars = ["blank"] + dict_chars + [" "]
return cls._chars
# ── detection ────────────────────────────────────────────────────────────────
def _det_resize(img: np.ndarray) -> tuple[np.ndarray, float, float]:
h, w = img.shape[:2]
ratio = _DET_LIMIT_SIDE_LEN / max(h, w) if max(h, w) > _DET_LIMIT_SIDE_LEN else 1.0
resize_h = max(32, round(h * ratio / 32) * 32)
resize_w = max(32, round(w * ratio / 32) * 32)
resized = Image.fromarray(img).resize((resize_w, resize_h), Image.BILINEAR)
return np.asarray(resized), resize_w / w, resize_h / h
def _det_preprocess(img: np.ndarray) -> tuple[np.ndarray, float, float]:
resized, ratio_w, ratio_h = _det_resize(img)
x = resized.astype(np.float32) / 255.0
x = (x - _MEAN) / _STD
x = x.transpose(2, 0, 1)[None].astype(np.float32) # NCHW
return x, ratio_w, ratio_h
def _unclip_bbox(x0: int, y0: int, x1: int, y1: int, ratio: float) -> tuple[int, int, int, int]:
bw, bh = x1 - x0, y1 - y0
perimeter = 2 * (bw + bh)
if perimeter == 0:
return x0, y0, x1, y1
distance = bw * bh * ratio / perimeter
rect = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
pco = pyclipper.PyclipperOffset()
pco.AddPath(rect, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
expanded = pco.Execute(distance)
pts = np.array(expanded[0])
return int(pts[:, 0].min()), int(pts[:, 1].min()), int(pts[:, 0].max()), int(pts[:, 1].max())
def detect_boxes(img: np.ndarray) -> list[dict]:
"""Run PP-OCRv4 det on an RGB numpy image, return axis-aligned text boxes.
Each box is {"bbox": [x0, y0, x1, y1], "score": prob_mean} in the
original image's pixel coordinates.
"""
h, w = img.shape[:2]
x, ratio_w, ratio_h = _det_preprocess(img)
inp_name = _Engine.det().get_inputs()[0].name
prob_map = _Engine.det().run(None, {inp_name: x})[0][0, 0] # (resize_h, resize_w)
mask = prob_map > _DET_THRESH
labels, n = ndimage.label(mask)
boxes = []
for i in range(1, n + 1):
ys, xs = np.where(labels == i)
if ys.size == 0:
continue
x0, x1, y0, y1 = xs.min(), xs.max() + 1, ys.min(), ys.max() + 1
if (x1 - x0) < _DET_MIN_SIZE or (y1 - y0) < _DET_MIN_SIZE:
continue
score = float(prob_map[y0:y1, x0:x1].mean())
if score < _DET_BOX_THRESH:
continue
ex0, ey0, ex1, ey1 = _unclip_bbox(int(x0), int(y0), int(x1), int(y1), _DET_UNCLIP_RATIO)
# map back to original image scale, clamp to bounds
ox0 = max(0, int(ex0 / ratio_w))
oy0 = max(0, int(ey0 / ratio_h))
ox1 = min(w, int(ex1 / ratio_w))
oy1 = min(h, int(ey1 / ratio_h))
if ox1 <= ox0 or oy1 <= oy0:
continue
boxes.append({"bbox": [ox0, oy0, ox1, oy1], "score": score})
boxes.sort(key=lambda b: (b["bbox"][1], b["bbox"][0]))
return boxes
# ── recognition ──────────────────────────────────────────────────────────────
def _rec_preprocess(crop: np.ndarray) -> np.ndarray:
h, w = crop.shape[:2]
new_w = min(_REC_MAX_BATCH_WIDTH, max(1, round(w * _REC_IMG_H / h)))
resized = np.asarray(Image.fromarray(crop).resize((new_w, _REC_IMG_H), Image.BILINEAR))
x = (resized.astype(np.float32) / 255.0 - 0.5) / 0.5
x = x.transpose(2, 0, 1)[None].astype(np.float32)
return x
def _ctc_decode(logits: np.ndarray, chars: list[str]) -> tuple[str, float]:
idx = logits.argmax(axis=-1)
probs = logits.max(axis=-1)
text, confs, prev = [], [], -1
for t, c in enumerate(idx):
if c != 0 and c != prev:
text.append(chars[c] if c < len(chars) else "")
confs.append(probs[t])
prev = c
avg_conf = float(np.mean(confs)) if confs else 0.0
return "".join(text), avg_conf
def recognize_crop(img: np.ndarray, bbox: list[int]) -> dict:
"""OCR a single bbox crop of img. Returns {"text", "score"}."""
x0, y0, x1, y1 = bbox
crop = img[y0:y1, x0:x1]
x = _rec_preprocess(crop)
inp_name = _Engine.rec().get_inputs()[0].name
logits = _Engine.rec().run(None, {inp_name: x})[0][0] # (T, num_classes)
text, score = _ctc_decode(logits, _Engine.chars())
return {"text": text, "score": score}
# ── public entrypoint ────────────────────────────────────────────────────────
def _missing_models() -> list[str]:
return [str(p) for p in (_DET_MODEL, _REC_MODEL, _DICT_FILE) if not p.exists()]
def ocr_image(path: str) -> dict:
"""Detect + recognize all text on a screenshot. Returns JSON-able dict.
Each result item: {"text": str, "score": float, "bbox": [x0,y0,x1,y1],
"center": {"x": int, "y": int}} in the image's own pixel coordinates —
if the screenshot was taken at full device resolution, these are
tap()-ready coordinates.
"""
missing = _missing_models()
if missing:
raise FileNotFoundError(f"OCR model files missing: {missing}")
img = np.asarray(Image.open(path).convert("RGB"))
boxes = detect_boxes(img)
results = []
for b in boxes:
rec = recognize_crop(img, b["bbox"])
if not rec["text"]:
continue
x0, y0, x1, y1 = b["bbox"]
results.append({
"text": rec["text"],
"score": round(min(rec["score"], b["score"]), 4),
"bbox": b["bbox"],
"center": {"x": (x0 + x1) // 2, "y": (y0 + y1) // 2},
})
return {"ok": True, "path": path, "count": len(results), "results": results}
# ── MCP tool fallbacks for find_element / click_by_text ────────────────────
def _do_ocr_find_text(text: str = "", partial: bool = True, limit: int = 20,
path: str = "") -> dict:
owns_file = not path
if not path:
shot = _do_screenshot()
if not shot.get("ok"):
return {"ok": False, "error": shot.get("error", "screenshot failed")}
path = shot["path"]
try:
result = ocr_image(path)
except FileNotFoundError as e:
return {"ok": False, "error": str(e)}
finally:
if owns_file:
_run(f"rm -f {path}")
matches = []
for item in result["results"]:
hay = item["text"]
ok = (text in hay) if partial else (text == hay)
if text and not ok:
continue
matches.append(item)
matches = matches[:max(1, limit)]
return {"ok": bool(matches), "query": {"text": text, "partial": partial},
"count": len(matches), "matches": matches}
def _do_ocr_click_text(text: str, partial: bool = True, index: int = 0) -> dict:
found = _do_ocr_find_text(text=text, partial=partial, limit=max(index + 1, 20))
if not found.get("ok"):
return {"ok": False, "error": found.get("error") or f"no text matched {text!r} via OCR"}
matches = found["matches"]
if index < 0 or index >= len(matches):
index = 0
target = matches[index]
c = target["center"]
tap = _do_tap(c["x"], c["y"])
return {"ok": tap.get("ok", False), "action": "ocr_click_text", "text": text,
"tapped": c, "match": target, "matched_total": len(matches)}
def register_ocr_tools(mcp) -> None:
"""Register OCR-fallback tools onto an existing FastMCP instance."""
@mcp.tool()
def ocr_find_text(text: str = "", partial: bool = True, limit: int = 20,
path: str = "") -> str:
"""Find on-screen text via local OCR — fallback for find_element.
Use only when find_element can't see the element: uiautomator
returning a null root (AccessibilityService conflict), or a label
with no resource-id/content-desc for find_element to match. This is
whole-screen PP-OCRv5 inference (~1-7s on-device) reading rendered
pixels, not the accessibility tree — try find_element first. Boxes
are per detected text line/cluster, not per substring: a partial
match inside a long sentence returns that whole line's bounds, not
just the matched word's. Icon-only buttons with no visible text are
never found this way.
Args:
text: substring (or exact, see partial) to match against
recognized text. Empty returns every text region found.
partial: substring match (default True) vs exact match.
limit: max matches to return (default 20).
path: OCR an existing on-device screenshot instead of capturing a
fresh one (a fresh capture is deleted after OCR; a path you
pass in is left alone).
"""
return json.dumps(_do_ocr_find_text(text, partial, limit, path), ensure_ascii=False)
@mcp.tool()
def ocr_click_text(text: str, partial: bool = True, index: int = 0) -> str:
"""Find visible text via local OCR and tap its center — fallback for click_by_text.
Use only when click_by_text fails (uiautomator null root, or the
label has no resource-id/content-desc for find_element to match).
Do not use on payment, deletion, or sensitive-setting screens.
Args:
text: visible text to locate via OCR and tap.
partial: substring match (default True) vs exact match.
index: which match to tap if several share the text (default 0).
"""
return json.dumps(_do_ocr_click_text(text, partial, index), ensure_ascii=False)
if __name__ == "__main__":
import sys
print(json.dumps(ocr_image(sys.argv[1]), ensure_ascii=False, indent=2))