adb-mlkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adb_mlkit/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """On-device multilingual OCR over ADB."""
2
+ from .client import ADBMLKit
3
+ from .models import ADBMLKitError, Device, OCRResult, TextBlock, TextLine, TextElement
4
+ from .models import LANGUAGES, SCRIPTS, script_for_language
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["ADBMLKit", "ADBMLKitError", "Device", "OCRResult", "TextBlock", "TextLine",
8
+ "TextElement", "LANGUAGES", "SCRIPTS", "script_for_language"]
adb_mlkit/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
Binary file
@@ -0,0 +1,5 @@
1
+ {
2
+ "package": "io.github.adbmlkit.helper",
3
+ "version": "0.1.0",
4
+ "sha256": "515118b49037a73b22245f309dd508cc3b19b2458e489f0c02375258a85fa904"
5
+ }
adb_mlkit/cli.py ADDED
@@ -0,0 +1,96 @@
1
+ """Command-line interface; JSON goes to stdout, diagnostics to stderr."""
2
+ import argparse
3
+ from dataclasses import asdict
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ from .client import ADBMLKit
9
+ from .models import ADBMLKitError, LANGUAGES, SCRIPTS
10
+
11
+
12
+ def parser():
13
+ root = argparse.ArgumentParser(prog="adb-mlkit", description="Offline Android OCR over ADB")
14
+ root.add_argument("--serial", help="Device serial; required if multiple devices are authorized")
15
+ root.add_argument("--adb-path", help="ADB executable (otherwise ADB_PATH or PATH)")
16
+ root.add_argument("--timeout", type=float, default=30)
17
+ commands = root.add_subparsers(dest="command", required=True)
18
+ commands.add_parser("devices", help="List attached devices and authorization states")
19
+ commands.add_parser("info", help="Inspect selected device and helper installation")
20
+ commands.add_parser("languages", help="List scripts and convenience language aliases")
21
+ install = commands.add_parser("install", help="Explicitly install bundled helper or a custom APK")
22
+ install.add_argument("apk", type=Path, nargs="?", help="Optional custom APK; default is the bundled helper")
23
+ for name in ("recognize", "batch"):
24
+ command = commands.add_parser(name, description="Read text; automatically install the bundled helper if missing")
25
+ if name == "recognize":
26
+ source = command.add_mutually_exclusive_group(required=True)
27
+ source.add_argument("--file", type=Path)
28
+ source.add_argument("--device-file", help="Absolute path readable by Android shell")
29
+ source.add_argument("--screenshot", action="store_true")
30
+ source.add_argument("--xpath", help="Optional uiautomator2 lookup; OCR reads the text")
31
+ else:
32
+ command.add_argument("files", type=Path, nargs="+")
33
+ selection = command.add_mutually_exclusive_group()
34
+ selection.add_argument("--script", choices=SCRIPTS)
35
+ selection.add_argument("--language", choices=sorted(LANGUAGES))
36
+ command.add_argument("--roi", nargs=4, type=int, metavar=("LEFT", "TOP", "RIGHT", "BOTTOM"))
37
+ command.add_argument("--rotation", type=int, choices=(0, 90, 180, 270), default=0)
38
+ command.add_argument("--runs", type=int, choices=range(1, 31), default=1, metavar="1..30")
39
+ command.add_argument("--json", action="store_true", help="Structured result; human timing stays off stdout")
40
+ command.add_argument("--output", type=Path, help="Save result locally instead of stdout")
41
+ command.add_argument("--overwrite", action="store_true", help="Allow replacing an existing output file")
42
+ return root
43
+
44
+
45
+ def main(argv=None):
46
+ args = parser().parse_args(argv)
47
+ try:
48
+ if args.command == "languages":
49
+ print(json.dumps({"scripts": SCRIPTS, "language_aliases": LANGUAGES}, indent=2))
50
+ return 0
51
+ if getattr(args, "output", None) and args.output.exists() and not args.overwrite:
52
+ raise FileExistsError(f"Output exists: {args.output}; choose another path or --overwrite")
53
+ client = ADBMLKit(serial=args.serial, adb_path=args.adb_path, timeout=args.timeout)
54
+ if args.command == "devices":
55
+ print(json.dumps([asdict(d) for d in client.devices()], ensure_ascii=False, indent=2))
56
+ return 0
57
+ if args.command == "info":
58
+ print(json.dumps(client.info(), ensure_ascii=False, indent=2))
59
+ return 0
60
+ if args.command == "install":
61
+ print(client.install(args.apk))
62
+ return 0
63
+ options = dict(script=args.script, language=args.language, roi=args.roi,
64
+ rotation=args.rotation, runs=args.runs)
65
+ if args.command == "batch":
66
+ results = client.batch_files(args.files, **options)
67
+ elif args.file:
68
+ results = [client.recognize_file(args.file, **options)]
69
+ elif args.device_file:
70
+ results = [client.recognize_device_file(args.device_file, **options)]
71
+ elif args.screenshot:
72
+ results = [client.recognize_screenshot(**options)]
73
+ else:
74
+ results = [client.recognize_xpath(args.xpath, **options)]
75
+ if args.json:
76
+ data = [r.to_dict() for r in results]
77
+ text = json.dumps(data if args.command == "batch" else data[0], ensure_ascii=False, indent=2)
78
+ else:
79
+ text = "\n\n".join(r.text for r in results)
80
+ for result in results:
81
+ print(json.dumps({"id": result.id, "android_timing": result.timing,
82
+ "host_timing": result.host_timing}), file=sys.stderr)
83
+ if args.output:
84
+ mode = "w" if args.overwrite else "x"
85
+ with args.output.open(mode, encoding="utf-8") as stream:
86
+ stream.write(text + "\n")
87
+ else:
88
+ print(text)
89
+ return 0
90
+ except (ADBMLKitError, ValueError, OSError) as exc:
91
+ print(f"ERROR: {exc}", file=sys.stderr)
92
+ return 1
93
+
94
+
95
+ if __name__ == "__main__":
96
+ sys.exit(main())
adb_mlkit/client.py ADDED
@@ -0,0 +1,244 @@
1
+ """ADB transport and synchronous OCR API. No device access at import time."""
2
+ import base64
3
+ from contextlib import contextmanager
4
+ from io import BytesIO
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import threading
12
+ from time import perf_counter
13
+ import uuid
14
+ import warnings
15
+
16
+ from .models import ADBMLKitError, Device, OCRResult, SCRIPTS, script_for_language
17
+
18
+ PACKAGE = "io.github.adbmlkit.helper"
19
+ RUNNER = f"{PACKAGE}/.OcrInstrumentation"
20
+ MAX_INPUT_BYTES = 32 * 1024 * 1024
21
+
22
+
23
+ class ADBMLKit:
24
+ """One client serializes its requests; use one client per device.
25
+
26
+ timeout controls ordinary ADB calls. Recognition allows at least
27
+ 60 + runs * 35 seconds for Android process startup and engine timeouts.
28
+ Independent clients/processes must not invoke instrumentation concurrently
29
+ on the same device.
30
+ """
31
+ def __init__(self, serial=None, adb_path=None, timeout=30, script="latin"):
32
+ if timeout <= 0:
33
+ raise ValueError("timeout must be positive")
34
+ if script not in SCRIPTS:
35
+ raise ValueError(f"script must be one of {SCRIPTS}")
36
+ self.serial = serial
37
+ self.adb_path = str(adb_path or os.environ.get("ADB_PATH") or shutil.which("adb") or "adb")
38
+ self.timeout = timeout
39
+ self.script = script
40
+ self._lock = threading.RLock()
41
+
42
+ def _adb(self, args, payload=None, timeout=None, selected=True):
43
+ command = [self.adb_path]
44
+ if selected:
45
+ if not self.serial:
46
+ self._select_device()
47
+ command += ["-s", self.serial]
48
+ try:
49
+ result = subprocess.run(command + list(args), input=payload, stdout=subprocess.PIPE,
50
+ stderr=subprocess.PIPE, timeout=timeout or self.timeout, check=False)
51
+ except FileNotFoundError as exc:
52
+ raise ADBMLKitError("ADB not found. Install Android platform-tools or set ADB_PATH.") from exc
53
+ except subprocess.TimeoutExpired as exc:
54
+ raise ADBMLKitError(f"ADB timed out after {exc.timeout}s") from exc
55
+ if result.returncode:
56
+ message = (result.stderr + result.stdout).decode("utf-8", errors="replace").strip()
57
+ raise ADBMLKitError(f"ADB exit {result.returncode}: {message}")
58
+ return result.stdout
59
+
60
+ def _shell(self, *args, payload=None, timeout=None):
61
+ return self._adb(["shell", " ".join(shlex.quote(str(arg)) for arg in args)], payload, timeout)
62
+
63
+ def _exec(self, *args):
64
+ return self._adb(["exec-out", " ".join(shlex.quote(str(arg)) for arg in args)])
65
+
66
+ def devices(self):
67
+ output = self._adb(["devices", "-l"], selected=False).decode("utf-8", errors="replace")
68
+ result = []
69
+ for line in output.splitlines():
70
+ if not line.strip() or line.startswith(("List of devices", "*")):
71
+ continue
72
+ parts = line.split(maxsplit=2)
73
+ if len(parts) >= 2:
74
+ result.append(Device(parts[0], parts[1], parts[2] if len(parts) > 2 else ""))
75
+ return result
76
+
77
+ def _select_device(self):
78
+ devices = self.devices()
79
+ available = [d.serial for d in devices if d.state == "device"]
80
+ if self.serial:
81
+ if self.serial not in available:
82
+ raise ADBMLKitError(f"Device {self.serial!r} is not connected/authorized")
83
+ elif len(available) == 1:
84
+ self.serial = available[0]
85
+ else:
86
+ raise ADBMLKitError(f"Expected one authorized device, found {available}; specify serial")
87
+ return self.serial
88
+
89
+ def _helper_installed(self):
90
+ packages = self._shell("pm", "list", "packages", PACKAGE).splitlines()
91
+ return f"package:{PACKAGE}".encode() in packages
92
+
93
+ def _ensure_helper(self):
94
+ if not self._helper_installed():
95
+ self.install()
96
+
97
+ def info(self):
98
+ with self._lock:
99
+ self._select_device()
100
+ return {"serial": self.serial,
101
+ "android_api": self._shell("getprop", "ro.build.version.sdk").decode().strip(),
102
+ "model": self._shell("getprop", "ro.product.model").decode().strip(),
103
+ "helper_installed": self._helper_installed(),
104
+ "package": PACKAGE, "scripts": list(SCRIPTS), "protocol_version": 1}
105
+
106
+ def install(self, apk=None):
107
+ """Explicitly install the bundled helper, or a caller-supplied APK."""
108
+ if apk is None:
109
+ from .helper import bundled_apk
110
+ with bundled_apk() as path:
111
+ return self.install(path)
112
+ path = Path(apk).resolve()
113
+ if not path.is_file():
114
+ raise FileNotFoundError(path)
115
+ with self._lock:
116
+ self._select_device()
117
+ output = self._adb(["install", "-r", str(path)], timeout=max(180, self.timeout)).decode(errors="replace")
118
+ if "Success" not in output:
119
+ raise ADBMLKitError(f"APK installation not confirmed: {output}")
120
+ return output.strip()
121
+
122
+ @staticmethod
123
+ def _options(script, language, default_script, roi, rotation, runs):
124
+ if script is not None and language is not None:
125
+ raise ValueError("Choose script or language, not both")
126
+ script = script_for_language(language) if language else (script or default_script)
127
+ if script not in SCRIPTS:
128
+ raise ValueError(f"script must be one of {SCRIPTS}")
129
+ if type(rotation) is not int or rotation not in (0, 90, 180, 270):
130
+ raise ValueError("rotation must be 0, 90, 180 or 270")
131
+ if type(runs) is not int or not 1 <= runs <= 30:
132
+ raise ValueError("runs must be an integer from 1 to 30")
133
+ if roi is not None:
134
+ if len(roi) != 4 or any(type(v) is not int for v in roi):
135
+ raise ValueError("roi must contain four integer pixel coordinates")
136
+ l, t, r, b = roi
137
+ if not (0 <= l < r and 0 <= t < b):
138
+ raise ValueError("roi must be a nonempty positive rectangle")
139
+ roi = list(roi)
140
+ return script, roi
141
+
142
+ @contextmanager
143
+ def _stage(self, timings, name):
144
+ start = perf_counter()
145
+ try:
146
+ yield
147
+ finally:
148
+ timings[name] = (perf_counter() - start) * 1000
149
+
150
+ def _recognize(self, loader, *, script=None, language=None, roi=None, rotation=0, runs=1):
151
+ script, roi = self._options(script, language, self.script, roi, rotation, runs)
152
+ with self._lock:
153
+ started = perf_counter()
154
+ timing = {}
155
+ self._select_device()
156
+ request_id = uuid.uuid4().hex
157
+ directory = f"files/requests/{request_id}"
158
+ image_path = f"{directory}/input.png"
159
+ result = None
160
+ staged = False
161
+ try:
162
+ with self._stage(timing, "load_ms"):
163
+ image, source_roi = loader()
164
+ if source_roi is not None:
165
+ if roi is not None:
166
+ raise ValueError("An explicit ROI cannot be combined with XPath bounds")
167
+ roi = list(source_roi)
168
+ if not isinstance(image, bytes) or not 0 < len(image) <= MAX_INPUT_BYTES:
169
+ raise ValueError("Input must contain 1..32 MiB of encoded image bytes")
170
+ with self._stage(timing, "setup_ms"):
171
+ self._ensure_helper()
172
+ request = {"schema_version": 1, "id": request_id, "script": script,
173
+ "source": {"type": "private", "path": image_path},
174
+ "rotation": rotation, "roi": roi, "runs": runs}
175
+ with self._stage(timing, "transfer_ms"):
176
+ self._shell("run-as", PACKAGE, "mkdir", "-p", directory)
177
+ staged = True
178
+ for path, data in ((image_path, image),
179
+ (f"{directory}/request.json", json.dumps(request).encode("utf-8"))):
180
+ # ASCII transport avoids Windows binary-stdin conversion/truncation.
181
+ self._shell("run-as", PACKAGE, "sh", "-c", f"base64 -d > {path}",
182
+ payload=base64.b64encode(data))
183
+ with self._stage(timing, "instrumentation_ms"):
184
+ output = self._shell("am", "instrument", "-w", "-e", "request_id", request_id,
185
+ RUNNER, timeout=max(self.timeout, 60 + 35 * runs))
186
+ with self._stage(timing, "result_ms"):
187
+ try:
188
+ raw = self._exec("run-as", PACKAGE, "cat", f"{directory}/result.json")
189
+ except ADBMLKitError as exc:
190
+ raise ADBMLKitError("No result from helper: " + output.decode("utf-8", errors="replace")) from exc
191
+ try:
192
+ data = json.loads(raw.decode("utf-8"))
193
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
194
+ raise ADBMLKitError("Helper returned invalid UTF-8 JSON") from exc
195
+ result = OCRResult.from_dict(data, request_id)
196
+ finally:
197
+ if staged:
198
+ with self._stage(timing, "cleanup_ms"):
199
+ try:
200
+ self._shell("run-as", PACKAGE, "rm", "-rf", directory)
201
+ except ADBMLKitError as exc:
202
+ warnings.warn(f"Could not remove own request {request_id}: {exc}", RuntimeWarning)
203
+ timing["total_ms"] = (perf_counter() - started) * 1000
204
+ result.host_timing = timing
205
+ return result
206
+
207
+ def recognize_bytes(self, data: bytes, **options) -> OCRResult:
208
+ return self._recognize(lambda: (data, None), **options)
209
+
210
+ def recognize_file(self, path, **options) -> OCRResult:
211
+ def load():
212
+ with Path(path).open("rb") as stream:
213
+ return stream.read(MAX_INPUT_BYTES + 1), None
214
+ return self._recognize(load, **options)
215
+
216
+ def recognize_device_file(self, path: str, **options) -> OCRResult:
217
+ if not isinstance(path, str) or not path.startswith("/") or "\x00" in path:
218
+ raise ValueError("device path must be an absolute Android path without NUL")
219
+ return self._recognize(lambda: (self._exec("cat", path), None), **options)
220
+
221
+ def recognize_screenshot(self, **options) -> OCRResult:
222
+ return self._recognize(lambda: (self._exec("screencap", "-p"), None), **options)
223
+
224
+ def recognize_xpath(self, xpath: str, xpath_timeout=10, **options) -> OCRResult:
225
+ if not xpath or xpath_timeout <= 0:
226
+ raise ValueError("xpath and a positive xpath_timeout are required")
227
+ if options.get("roi") is not None:
228
+ raise ValueError("XPath supplies its own ROI")
229
+ def load():
230
+ try:
231
+ import uiautomator2 as u2
232
+ except ImportError as exc:
233
+ raise ADBMLKitError('Install optional UI support: pip install "adb-mlkit[ui]"') from exc
234
+ device = u2.connect(self.serial)
235
+ bounds = tuple(device.xpath(xpath).get(timeout=xpath_timeout).bounds)
236
+ image = device.screenshot().convert("RGB")
237
+ buffer = BytesIO()
238
+ image.save(buffer, format="PNG")
239
+ return buffer.getvalue(), bounds
240
+ return self._recognize(load, **options)
241
+
242
+ def batch_files(self, paths, **options):
243
+ """Sequential fail-fast batch; each image has its own invocation/timing."""
244
+ return [self.recognize_file(path, **options) for path in paths]
adb_mlkit/helper.py ADDED
@@ -0,0 +1,36 @@
1
+ """Version-matched Android helper resource. No network or device side effects."""
2
+ from contextlib import contextmanager
3
+ import hashlib
4
+ from importlib.resources import as_file, files
5
+ import json
6
+
7
+ from .models import ADBMLKitError
8
+
9
+
10
+ @contextmanager
11
+ def bundled_apk():
12
+ """Yield a verified filesystem path, including from zip-based installations."""
13
+ root = files("adb_mlkit").joinpath("assets")
14
+ apk = root.joinpath("adb-mlkit.apk")
15
+ manifest = root.joinpath("helper.json")
16
+ if not apk.is_file() or not manifest.is_file():
17
+ raise ADBMLKitError(
18
+ "Bundled helper APK is missing. Install an official wheel, or build Android "
19
+ "and run scripts/prepare_package.py before packaging. "
20
+ "You can also use adb-mlkit install /path/to/helper.apk."
21
+ )
22
+ try:
23
+ metadata = json.loads(manifest.read_text(encoding="utf-8"))
24
+ from . import __version__
25
+ if metadata["package"] != "io.github.adbmlkit.helper" or metadata["version"] != __version__:
26
+ raise ValueError("helper/package version mismatch")
27
+ with as_file(apk) as path:
28
+ digest = hashlib.sha256()
29
+ with path.open("rb") as stream:
30
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
31
+ digest.update(chunk)
32
+ if digest.hexdigest() != metadata["sha256"]:
33
+ raise ValueError("helper APK checksum mismatch")
34
+ yield path
35
+ except (KeyError, ValueError, OSError) as exc:
36
+ raise ADBMLKitError(f"Cannot verify bundled helper: {exc}") from exc
adb_mlkit/models.py ADDED
@@ -0,0 +1,100 @@
1
+ """Typed, lossless wrappers for protocol-v1 OCR results."""
2
+ from dataclasses import dataclass, field, asdict
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ class ADBMLKitError(RuntimeError):
8
+ """Transport, device selection, protocol or OCR error."""
9
+
10
+
11
+ SCRIPTS = ("latin", "chinese", "devanagari", "japanese", "korean")
12
+ LANGUAGES = {
13
+ "vi": "latin", "en": "latin", "fr": "latin", "de": "latin",
14
+ "es": "latin", "it": "latin", "pt": "latin", "id": "latin",
15
+ "ms": "latin", "tr": "latin", "nl": "latin", "pl": "latin",
16
+ "zh": "chinese", "zh-hans": "chinese", "zh-hant": "chinese",
17
+ "ja": "japanese", "ko": "korean", "hi": "devanagari",
18
+ "mr": "devanagari", "ne": "devanagari",
19
+ }
20
+
21
+
22
+ def script_for_language(language: str) -> str:
23
+ """Resolve a documented convenience alias; this is not language detection."""
24
+ key = language.lower().replace("_", "-")
25
+ if key not in LANGUAGES:
26
+ raise ValueError(f"Unsupported language alias {language!r}; choose a script explicitly.")
27
+ return LANGUAGES[key]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Device:
32
+ serial: str
33
+ state: str
34
+ details: str = ""
35
+
36
+
37
+ @dataclass
38
+ class TextElement:
39
+ text: str
40
+ bounds: list[int] | None = None
41
+ corner_points: list[list[int]] = field(default_factory=list)
42
+ recognized_language: str = ""
43
+ confidence: float | None = None
44
+
45
+
46
+ @dataclass
47
+ class TextLine(TextElement):
48
+ elements: list[TextElement] = field(default_factory=list)
49
+
50
+
51
+ @dataclass
52
+ class TextBlock(TextElement):
53
+ lines: list[TextLine] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class OCRResult:
58
+ id: str
59
+ text: str
60
+ script: str
61
+ image: dict[str, Any]
62
+ timing: dict[str, Any]
63
+ blocks: list[TextBlock]
64
+ host_timing: dict[str, float] = field(default_factory=dict)
65
+ schema_version: int = 1
66
+ ok: bool = True
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ return asdict(self)
70
+
71
+ def to_json(self, indent: int | None = 2) -> str:
72
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
73
+
74
+ @classmethod
75
+ def from_dict(cls, data: dict, request_id: str) -> "OCRResult":
76
+ if (not isinstance(data, dict) or type(data.get("schema_version")) is not int
77
+ or data.get("schema_version") != 1 or data.get("id") != request_id):
78
+ raise ADBMLKitError("Invalid response version or request ID")
79
+ if data.get("ok") is False:
80
+ error = data.get("error", {})
81
+ if not isinstance(error, dict):
82
+ raise ADBMLKitError("Malformed error response")
83
+ raise ADBMLKitError(f"{error.get('code', 'OCR_FAILED')}: {error.get('message', error)}")
84
+ if data.get("ok") is not True:
85
+ raise ADBMLKitError("Missing success status in response")
86
+ def fields(item):
87
+ return {k: item[k] for k in ("text", "bounds", "corner_points", "recognized_language", "confidence") if k in item}
88
+ try:
89
+ if not isinstance(data["text"], str) or data["script"] not in SCRIPTS:
90
+ raise ValueError("Invalid text/script")
91
+ if not isinstance(data["image"], dict) or not isinstance(data["timing"], dict):
92
+ raise ValueError("Invalid image/timing")
93
+ blocks = []
94
+ for block in data["blocks"]:
95
+ lines = [TextLine(**fields(line), elements=[TextElement(**fields(e)) for e in line["elements"]])
96
+ for line in block["lines"]]
97
+ blocks.append(TextBlock(**fields(block), lines=lines))
98
+ return cls(data["id"], data["text"], data["script"], data["image"], data["timing"], blocks)
99
+ except (KeyError, TypeError, ValueError, AttributeError) as exc:
100
+ raise ADBMLKitError(f"Malformed OCR response: {exc}") from exc
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: adb-mlkit
3
+ Version: 0.1.0
4
+ Summary: On-device multilingual ML Kit OCR from Python over ADB
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/zidonghua-source/ADB-MLKit
7
+ Project-URL: Repository, https://github.com/zidonghua-source/ADB-MLKit
8
+ Project-URL: Issues, https://github.com/zidonghua-source/ADB-MLKit/issues
9
+ Project-URL: Documentation, https://github.com/zidonghua-source/ADB-MLKit/blob/main/README.md
10
+ Keywords: adb,android,ocr,mlkit,vietnamese
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: THIRD_PARTY_NOTICES.md
17
+ Provides-Extra: ui
18
+ Requires-Dist: uiautomator2<4,>=3.0; extra == "ui"
19
+ Requires-Dist: Pillow>=10; extra == "ui"
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.2; extra == "dev"
22
+ Requires-Dist: twine>=6; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # ADB-MLKit
26
+
27
+ **Read text on Android from Python, using Google's on-device ML Kit models over ADB.**
28
+
29
+ ADB-MLKit consists of a Python SDK/CLI and a small, headless Android instrumentation helper. It is an independent community project, not an official Google product. No OCR cloud account or API key is required.
30
+
31
+ [Tiếng Việt](README.vi.md) · [API reference](docs/API.md) · [Protocol](docs/PROTOCOL.md) · [Security](SECURITY.md)
32
+
33
+ ## Features
34
+
35
+ - Read a local image, encoded image bytes, an image already on Android, or a screenshot.
36
+ - Optional XPath-to-region lookup with uiautomator2; recognized text always comes from OCR.
37
+ - Five bundled script models: **Latin (including Vietnamese), Chinese, Japanese, Korean and Devanagari**.
38
+ - Crop ROI, clockwise rotation, block/line/element text and geometry, engine-provided confidence where available.
39
+ - Multiple devices via explicit serial selection; sequential batch images.
40
+ - Cold/first invocation and repeated-same-image OCR timings, plus host transfer/startup/cleanup timings.
41
+ - Request-isolated temporary storage and best-effort cleanup; Base64-safe ADB input transport.
42
+ - Structured JSON output, typed Python results, unit tests and GitHub Actions build configuration.
43
+
44
+ ## Requirements
45
+
46
+ - Python 3.10+, Android platform-tools (`adb`) on PATH, and an authorized USB/TCP device.
47
+ - Android 6.0 / API 23 or newer.
48
+ - A distribution wheel includes the version-matched Android helper APK. End users do not need Java/Gradle/Android Studio.
49
+ - ADB/platform-tools remains a separate prerequisite; pip never installs an APK on your phone automatically.
50
+ - Optional XPath support: install the `[ui]` extra.
51
+
52
+ This does **not** bypass Android screen-capture restrictions or private-storage permissions. Avoid enabling ADB on untrusted networks.
53
+
54
+ ## Quick start
55
+
56
+ Install the built wheel (available locally in `dist/`; publication is a separate maintainer step):
57
+
58
+ ```powershell
59
+ python -m pip install ./dist/adb_mlkit-0.1.0-py3-none-any.whl
60
+ adb-mlkit devices
61
+ ```
62
+
63
+ After the maintainer publishes this project to PyPI, installation by name becomes:
64
+
65
+ ```text
66
+ python -m pip install adb-mlkit
67
+ python -m pip install "adb-mlkit[ui]"
68
+ ```
69
+
70
+ These package-index commands require an actual release; preparing this repository does not publish one. See [PyPI publishing](docs/PUBLISHING.md).
71
+
72
+ Run OCR directly. Recognition commands and Python recognition methods automatically install the bundled, checksum-verified helper on the selected phone if it is missing:
73
+
74
+ ```powershell
75
+ adb-mlkit recognize --screenshot --language vi --runs 3 --json
76
+ adb-mlkit info
77
+ ```
78
+
79
+ An existing helper is not automatically reinstalled or updated. Use `adb-mlkit install` to install/update it manually, or `adb-mlkit install path/to/custom.apk` for a custom APK. Installation errors stop recognition; the SDK never uninstalls an existing package to resolve a conflict.
80
+
81
+ The new helper package is **`io.github.adbmlkit.helper`**, separate from the earlier `com.example.mlkitocrtest` prototype. The prototype APK cannot serve this protocol.
82
+
83
+ ### Images on Android
84
+
85
+ ```powershell
86
+ adb-mlkit recognize --device-file "/sdcard/Download/example.png" --language vi --json
87
+ ```
88
+
89
+ The Android shell must already be allowed to read the path. The host reads the encoded bytes over ADB and stages them in helper-private storage; this is not zero-copy device-only ingestion and does not require broad storage permissions.
90
+
91
+ ### Local images, crop and other scripts
92
+
93
+ ```powershell
94
+ adb-mlkit recognize --file image.png --language vi --roi 60 100 1000 700 --runs 5
95
+ adb-mlkit recognize --file japanese.png --script japanese --json
96
+ adb-mlkit recognize --file rotated.jpg --rotation 90 --json --output result.json
97
+ adb-mlkit batch first.png second.png --language vi --json --output batch.json
98
+ adb-mlkit --serial DEVICE_SERIAL recognize --screenshot --script latin
99
+ ```
100
+
101
+ Existing output files are protected unless `--overwrite` is given. Global options (`--serial`, `--adb-path`, `--timeout`) go **before** the subcommand. `python -m adb_mlkit` is equivalent to `adb-mlkit`.
102
+
103
+ ### Python API
104
+
105
+ ```python
106
+ from adb_mlkit import ADBMLKit
107
+
108
+ ocr = ADBMLKit(serial="DEVICE_SERIAL")
109
+ result = ocr.recognize_device_file("/sdcard/Download/example.png", language="vi", runs=3)
110
+ print(result.text)
111
+ print(result.timing) # Android decode/init/recognition durations
112
+ print(result.host_timing) # Host load/setup/transfer/instrumentation/result/cleanup/total
113
+
114
+ for block in result.blocks:
115
+ for line in block.lines:
116
+ print(line.text, line.bounds, line.confidence)
117
+ ```
118
+
119
+ See [API reference](docs/API.md) and [examples](examples/) for all entry points.
120
+
121
+ ## Languages versus scripts
122
+
123
+ `--language vi` selects the **Latin model**, not a Vietnamese-only model. It does not translate, force output into Vietnamese, or provide a recognition hint. The SDK maps a documented set of aliases to the five supported scripts; `adb-mlkit languages` lists them. The alias list is not Google's exhaustive language list. Unsupported scripts (for example Arabic or Thai) are not automatically recognized by this integration.
124
+
125
+ ML Kit may recognize mixed-script text supported by a selected model, but this project does not run all five recognizers automatically or promise arbitrary multilingual detection. For mixed documents, select and benchmark an appropriate model or process the image separately with multiple models.
126
+
127
+ ## Coordinate and timing semantics
128
+
129
+ ROI is `[left, top, right, bottom]` in the original unrotated source image, with exclusive right/bottom edges. Crop precedes rotation. Output geometry uses the cropped, rotated image coordinate system, **not necessarily screen coordinates**. Explicit rotation is used; normalize EXIF orientation yourself when needed.
130
+
131
+ `runs=N` recognizes **one image N times** using one recognizer in one invocation. The first run can include lazy model initialization; later values measure warm recognition. Each API call starts instrumentation again. Warm OCR time is not end-to-end latency. The host total includes image loading/capture, helper setup, transfers and cleanup. `host_timing.setup_ms` measures the package check and any automatic APK installation; the first call on a device without the helper therefore takes longer. No fixed speed or accuracy guarantee is made.
132
+
133
+ ## Development and verification
134
+
135
+ ```powershell
136
+ python -m pip install -e ".[dev]"
137
+ python -m unittest discover -s tests -v
138
+ # Build Android first: see android/README.md
139
+ python scripts/prepare_package.py
140
+ python -m build
141
+ python scripts/verify_wheel.py dist/adb_mlkit-0.1.0-py3-none-any.whl
142
+ ```
143
+
144
+ Python unit tests mock ADB; they do not establish on-device accuracy. Build the Android helper separately. See [CONTRIBUTING.md](CONTRIBUTING.md) for synthetic-image device tests and publication notes. No device credentials or private images are included.
145
+
146
+ ## Limitations
147
+
148
+ - One instrumentation invocation at a time per device. Use one client per device; separate processes must coordinate themselves.
149
+ - No always-running HTTP daemon, streaming camera service, translation API or cloud fallback.
150
+ - Image input limit: 32 MiB; decoded dimensions: at most 32 million pixels. Large input can still be memory-intensive.
151
+ - Same-device screenshot and XPath lookup are sequential, not an atomic UI snapshot. Changing UI may move the target.
152
+ - Debug APK is a developer tool, not a hardened Play Store app. Model libraries make the all-script APK substantially larger than a Latin-only helper.
153
+ - ML Kit models/libraries have their own terms. This repository provides integration source, not source code for Google's recognition models.
154
+
155
+ ## License and upstream
156
+
157
+ Project code: [MIT](LICENSE). See [third-party notices](THIRD_PARTY_NOTICES.md).
158
+
159
+ - [Google ML Kit Android setup](https://developers.google.com/ml-kit/vision/text-recognition/v2/android)
160
+ - [Supported languages](https://developers.google.com/ml-kit/vision/text-recognition/v2/languages)
161
+ - [Official sample source](https://github.com/googlesamples/mlkit)
@@ -0,0 +1,15 @@
1
+ adb_mlkit/__init__.py,sha256=7GJdytVoeRb3vK2iDmMLDJX-5snMpGzjJifgHpP4LgM,403
2
+ adb_mlkit/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ adb_mlkit/cli.py,sha256=0yfDv0E_E2WVV4Xkc3uGwPYORXnOz5v-EfKh7rJJT0w,5047
4
+ adb_mlkit/client.py,sha256=k64_ygOjIEswfd1Tx_3-vuSgMwxAbgVW43waeuPYpMA,11643
5
+ adb_mlkit/helper.py,sha256=7Wr-y1weoRfrLwVAK5xWmMXNYyPLYvVyByQuf9BpGWQ,1584
6
+ adb_mlkit/models.py,sha256=womXrra4M9uNlqqc8-7VYDEmv5rhYz6s1cyi0SswnF0,3731
7
+ adb_mlkit/assets/adb-mlkit.apk,sha256=UVEYtJA3pzsiJF8wndUIzDsZskWOSJ8MAjdSWKhfqQQ,49023571
8
+ adb_mlkit/assets/helper.json,sha256=5OmeWwV2htiBKF00Kq03ACWE5hhAz6xmOqpJuoFf3po,147
9
+ adb_mlkit-0.1.0.dist-info/licenses/LICENSE,sha256=sFwJWN9s8IeEH7yUs3MAi_3DseBgWZ-KCYX5aTSiIzc,1079
10
+ adb_mlkit-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md,sha256=3tGIKlVdX4l4-sm5dw27uoWF4Na6M2MPmTV8kUkmFX0,1385
11
+ adb_mlkit-0.1.0.dist-info/METADATA,sha256=ft9ie4xesCwV1j8VeivQyPwV2rZ6LnbE9nd3xc6hGHk,8909
12
+ adb_mlkit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ adb_mlkit-0.1.0.dist-info/entry_points.txt,sha256=6nVeByvRo8kUMuQ-J37fz00RgidtL-HSyDzwCMAF9GE,49
14
+ adb_mlkit-0.1.0.dist-info/top_level.txt,sha256=qlRKEftco-pYTrJYMd6ARHVjRUlfikDNdoaIEHpQaNE,10
15
+ adb_mlkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ adb-mlkit = adb_mlkit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ADB-MLKit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ # Third-party components
2
+
3
+ ADB-MLKit's MIT license covers the integration source written for this repository. It does not relicense external software, pretrained models, platform tooling, or generated wrapper components.
4
+
5
+ - **Google ML Kit Text Recognition v2**: bundled Latin, Chinese, Devanagari, Japanese and Korean Android libraries, currently pinned in `android/app/build.gradle`. Google's license notices and [ML Kit terms](https://developers.google.com/ml-kit/terms) apply. Model implementation/source is not part of this project.
6
+ - **Android SDK / ADB / Android Gradle Plugin**: Google/Android tooling, distributed separately under their applicable terms. Android SDK license acceptance is the builder's responsibility.
7
+ - **Gradle wrapper**: generated upstream Gradle wrapper scripts/JAR; Gradle is Apache License 2.0. The wrapper downloads its pinned distribution from the official Gradle service.
8
+ - **uiautomator2 and Pillow**: optional Python dependencies for XPath-assisted screenshot capture. Consult their upstream distributions for license details.
9
+ - **JUnit**: Android JVM test dependency; consult the dependency's upstream license.
10
+
11
+ Dependency notices included in artifacts must be preserved. Review the resolved dependency tree and vendor terms before redistributing APKs, especially for commercial use. This file is an inventory, not a substitute for those licenses.
@@ -0,0 +1 @@
1
+ adb_mlkit