* Add USB camera and uhubctl support for new test suite. Also added some bug fixes * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refactor test messages for clarity and consistency in regex tests --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
148 lines
4.6 KiB
Python
148 lines
4.6 KiB
Python
"""OCR wrapper for UI tests + the `capture_screen` tool.
|
||
|
||
Auto-selects a reader in priority order:
|
||
1. `easyocr` (deep-learning, high quality on OLED screens — but ~100 MB
|
||
model download on first use).
|
||
2. `pytesseract` (requires system `tesseract` binary on PATH).
|
||
3. `null` — returns `""` with a warning. Tests fall back to log + image
|
||
evidence when OCR is unavailable.
|
||
|
||
Override via `MESHTASTIC_UI_OCR_BACKEND=easyocr|pytesseract|null|auto`
|
||
(default `auto`).
|
||
|
||
`ocr_text(png_bytes) -> str` is the only public entry point. The reader is
|
||
constructed lazily on first call and cached, so the easyocr cold-start cost
|
||
only hits once per process.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import functools
|
||
import logging
|
||
import os
|
||
import shutil
|
||
import sys
|
||
from typing import Callable
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
def _backend_choice() -> str:
|
||
return os.environ.get("MESHTASTIC_UI_OCR_BACKEND", "auto").lower()
|
||
|
||
|
||
@functools.lru_cache(maxsize=1)
|
||
def _reader() -> tuple[str, Callable[[bytes], str]]:
|
||
"""Return `(backend_name, callable)` for whichever OCR is available."""
|
||
choice = _backend_choice()
|
||
|
||
def _easyocr() -> tuple[str, Callable[[bytes], str]]:
|
||
import easyocr # type: ignore[import-untyped] # noqa: PLC0415
|
||
import numpy as np # type: ignore[import-untyped] # noqa: PLC0415
|
||
|
||
reader = easyocr.Reader(["en"], gpu=False, verbose=False)
|
||
|
||
def _run(png: bytes) -> str:
|
||
try:
|
||
import cv2 # type: ignore[import-untyped] # noqa: PLC0415
|
||
|
||
arr = np.frombuffer(png, dtype=np.uint8)
|
||
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||
except ImportError:
|
||
# Fall back to PIL if cv2 isn't around.
|
||
from io import BytesIO # noqa: PLC0415
|
||
|
||
from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415
|
||
|
||
img = np.array(Image.open(BytesIO(png)).convert("RGB"))
|
||
try:
|
||
results = reader.readtext(img, detail=0, paragraph=True)
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("easyocr failed: %s", exc)
|
||
return ""
|
||
return "\n".join(str(r) for r in results)
|
||
|
||
return "easyocr", _run
|
||
|
||
def _pytesseract() -> tuple[str, Callable[[bytes], str]]:
|
||
from io import BytesIO # noqa: PLC0415
|
||
|
||
import pytesseract # type: ignore[import-untyped] # noqa: PLC0415
|
||
from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415
|
||
|
||
if shutil.which("tesseract") is None:
|
||
raise ImportError("`tesseract` binary not on PATH")
|
||
|
||
def _run(png: bytes) -> str:
|
||
try:
|
||
return str(pytesseract.image_to_string(Image.open(BytesIO(png))))
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("pytesseract failed: %s", exc)
|
||
return ""
|
||
|
||
return "pytesseract", _run
|
||
|
||
def _null() -> tuple[str, Callable[[bytes], str]]:
|
||
log.warning(
|
||
"OCR backend is null; install easyocr or tesseract for text extraction"
|
||
)
|
||
return "null", lambda _png: ""
|
||
|
||
if choice == "easyocr":
|
||
return _easyocr()
|
||
if choice == "pytesseract":
|
||
return _pytesseract()
|
||
if choice == "null":
|
||
return _null()
|
||
if choice != "auto":
|
||
print(
|
||
f"[ocr] unknown MESHTASTIC_UI_OCR_BACKEND={choice!r}; falling back to auto",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
# auto mode
|
||
try:
|
||
return _easyocr()
|
||
except ImportError:
|
||
pass
|
||
try:
|
||
return _pytesseract()
|
||
except ImportError:
|
||
pass
|
||
return _null()
|
||
|
||
|
||
def ocr_text(png_bytes: bytes) -> str:
|
||
"""Run OCR on a PNG-encoded image and return the decoded text (possibly empty)."""
|
||
if not png_bytes:
|
||
return ""
|
||
_, run = _reader()
|
||
return run(png_bytes)
|
||
|
||
|
||
def backend_name() -> str:
|
||
"""Return the currently-selected backend name, initializing if necessary."""
|
||
name, _ = _reader()
|
||
return name
|
||
|
||
|
||
def warm() -> None:
|
||
"""Run one dummy inference so the easyocr cold-start cost is paid upfront.
|
||
|
||
Pytest session fixture calls this once so the first real capture doesn't
|
||
eat the model-load latency.
|
||
"""
|
||
# A 64×32 white PNG — decodes clean, no text to extract.
|
||
white_png = bytes.fromhex(
|
||
"89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e"
|
||
"0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080"
|
||
"0b010000ffff030000000000000049454e44ae426082"
|
||
)
|
||
try:
|
||
ocr_text(white_png)
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("ocr.warm() failed: %s", exc)
|
||
|
||
|
||
__all__ = ["backend_name", "ocr_text", "warm"]
|