From 5cedcaefe1367078084525485838f73ff30164bb Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 19 Jun 2026 15:46:03 +0200 Subject: [PATCH] feat(vision): add recognize CLI + verify fast-alpr end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dev CLI (uv run python -m vision_service.cli ) that runs a recognizer on an image file and prints the parsed plate(s) + confidence + region — fast feedback with no HTTP. Also a package.json `recognize` script and a vision-recognize entry point. Verified fast-alpr for real: installed the `alpr` extra, downloaded the YOLOv9 + CCT ONNX weights (~11MB, cached offline under ~/.cache), and ran recognition on the project's test image → "5AU5341" at 1.000 confidence, region "Czech Republic", ~40ms on CPU, via both the CLI and POST /analyze. Fixes result parsing against the actual fast-alpr API: ocr.confidence is a LIST of per-character confidences (not a scalar) — reduced to one plate confidence via the MIN (a plate is only as trustworthy as its weakest character); also surface ocr.region. Extracted the per-result mapping into a pure plate_from_alpr_result + _reduce_confidence and unit-tested them (no model weights needed). 7 tests pass; ruff + mypy strict clean; full turbo build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/vision/README.md | 14 +++++ apps/vision/package.json | 1 + apps/vision/pyproject.toml | 3 + apps/vision/tests/test_parsing.py | 43 ++++++++++++++ apps/vision/vision_service/cli.py | 72 ++++++++++++++++++++++++ apps/vision/vision_service/recognizer.py | 52 ++++++++++++----- apps/vision/vision_service/schemas.py | 4 ++ 7 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 apps/vision/tests/test_parsing.py create mode 100644 apps/vision/vision_service/cli.py diff --git a/apps/vision/README.md b/apps/vision/README.md index eb5286f..b727219 100644 --- a/apps/vision/README.md +++ b/apps/vision/README.md @@ -30,6 +30,20 @@ uv sync --extra alpr # installs fast-alpr + onnxruntime (downloa VISION_RECOGNIZER=fast_alpr uv run uvicorn vision_service.app:app --port 8089 ``` +Model weights (~11 MB: a YOLOv9 detector + CCT OCR) download on first use and cache under +`~/.cache/open-image-models` + `~/.cache/fast-plate-ocr` — offline after that. + +### Quick test against an image (CLI, no HTTP) + +```bash +uv run python -m vision_service.cli path/to/car.jpg # or: pnpm --filter @parking/vision recognize -- car.jpg +uv run python -m vision_service.cli car.jpg --ocr cct-s-v2-global-model # try another OCR model +``` + +Prints the parsed plate(s) + confidence + region as JSON. Confidence is the **min** of fast-alpr's +per-character confidences (a plate is only as trustworthy as its weakest character). Example output on +the fast-alpr test image: `5AU5341 (1.000) region "Czech Republic"` in ~40 ms on CPU. + `fast-alpr` is MIT (YOLOv9 detector + CCT OCR on ONNX Runtime). Swap `VISION_OCR_MODEL` to the 40+ country European model to benchmark Albanian plates. For GPU/NPU, install `onnxruntime-gpu` / `-openvino` / `-directml` instead of `onnxruntime`. diff --git a/apps/vision/package.json b/apps/vision/package.json index b287c35..0e03cc4 100644 --- a/apps/vision/package.json +++ b/apps/vision/package.json @@ -10,6 +10,7 @@ "format": "uv run ruff format .", "typecheck": "uv run mypy vision_service", "test": "uv run pytest -q", + "recognize": "uv run python -m vision_service.cli", "build": "echo 'no build step (Python service; models fetched at deploy)'" } } diff --git a/apps/vision/pyproject.toml b/apps/vision/pyproject.toml index 06e862f..2baa5e9 100644 --- a/apps/vision/pyproject.toml +++ b/apps/vision/pyproject.toml @@ -15,6 +15,9 @@ dependencies = [ "pydantic-settings>=2.6", ] +[project.scripts] +vision-recognize = "vision_service.cli:main" + [project.optional-dependencies] # The real recognizer. Install with: uv sync --extra alpr # fast-alpr is MIT (YOLOv9 detector + CCT OCR, both MIT) on ONNX Runtime — see the diff --git a/apps/vision/tests/test_parsing.py b/apps/vision/tests/test_parsing.py new file mode 100644 index 0000000..abae426 --- /dev/null +++ b/apps/vision/tests/test_parsing.py @@ -0,0 +1,43 @@ +"""Unit tests for fast-alpr result parsing — no model weights required (the objects +are duck-typed stand-ins shaped like fast-alpr's ALPRResult).""" + +from __future__ import annotations + +from types import SimpleNamespace + +from vision_service.recognizer import _reduce_confidence, plate_from_alpr_result + + +def test_reduce_confidence_takes_min_of_list() -> None: + # The weakest character governs trust in the whole plate. + assert _reduce_confidence([0.99, 0.80, 0.95]) == 0.80 + + +def test_reduce_confidence_handles_scalar_and_junk() -> None: + assert _reduce_confidence(0.7) == 0.7 + assert _reduce_confidence(None) == 0.0 + assert _reduce_confidence([]) == 0.0 + assert _reduce_confidence("nope") == 0.0 + + +def _fake_result(text: str, conf: list[float], region: str | None = None) -> SimpleNamespace: + box = SimpleNamespace(x1=10, y1=20, x2=110, y2=60) + return SimpleNamespace( + ocr=SimpleNamespace(text=text, confidence=conf, region=region), + detection=SimpleNamespace(bounding_box=box), + ) + + +def test_plate_from_result_maps_fields() -> None: + plate = plate_from_alpr_result(_fake_result("5AU5341", [0.999, 0.9995, 0.97], "Czech Republic")) + assert plate is not None + assert plate.text == "5AU5341" + assert plate.confidence == 0.97 # min of the per-character list + assert plate.region == "Czech Republic" + assert plate.bbox is not None + assert (plate.bbox.x1, plate.bbox.y1, plate.bbox.x2, plate.bbox.y2) == (10, 20, 110, 60) + + +def test_plate_from_result_skips_empty_text() -> None: + assert plate_from_alpr_result(_fake_result("", [0.9])) is None + assert plate_from_alpr_result(SimpleNamespace(ocr=None, detection=None)) is None diff --git a/apps/vision/vision_service/cli.py b/apps/vision/vision_service/cli.py new file mode 100644 index 0000000..8865946 --- /dev/null +++ b/apps/vision/vision_service/cli.py @@ -0,0 +1,72 @@ +"""Dev CLI to test a recognizer against an image file — no HTTP, fast feedback. + + uv run python -m vision_service.cli path/to/car.jpg + uv run python -m vision_service.cli car.jpg --recognizer stub # contract only + uv run python -m vision_service.cli car.jpg --ocr cct-s-v2-global-model + +Defaults to the `fast_alpr` recognizer (the point of this tool). Prints the parsed +plate result as JSON. If the `alpr` extra isn't installed it says so and exits non-zero +rather than silently using the stub. See wiki/entities/opencv-anpr-service.md. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from .recognizer import build_recognizer +from .settings import Settings + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="vision-recognize", description="Run a recognizer on an image.") + parser.add_argument("image", type=Path, help="path to an image file (JPEG/PNG) with a plate") + parser.add_argument( + "--recognizer", + choices=["fast_alpr", "stub"], + default="fast_alpr", + help="which recognizer to use (default: fast_alpr)", + ) + parser.add_argument("--detector", default=None, help="override the fast-alpr detector model name") + parser.add_argument("--ocr", default=None, help="override the fast-alpr OCR model name") + args = parser.parse_args(argv) + + if not args.image.is_file(): + print(f"error: no such file: {args.image}", file=sys.stderr) + return 2 + + settings = Settings(recognizer=args.recognizer) + if args.detector: + settings.detector_model = args.detector + if args.ocr: + settings.ocr_model = args.ocr + + rec = build_recognizer(settings) + if not rec.ready: + err = getattr(rec, "error", "unavailable") + print( + f"error: recognizer '{args.recognizer}' not ready: {err}\n" + "hint: install the models with uv sync --extra alpr", + file=sys.stderr, + ) + return 1 + + image_bytes = args.image.read_bytes() + result = rec.analyze(image_bytes) + # Pydantic v2: model_dump_json gives a clean, stable rendering. + print(result.model_dump_json(indent=2)) + + if result.plate is None: + print("\n(no plate detected)", file=sys.stderr) + else: + flag = " [LOW CONFIDENCE]" if result.low_confidence else "" + print( + f"\n→ {result.plate.text} ({result.plate.confidence:.3f}){flag} in {result.took_ms:.1f} ms", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/vision/vision_service/recognizer.py b/apps/vision/vision_service/recognizer.py index 718eb12..b4bf287 100644 --- a/apps/vision/vision_service/recognizer.py +++ b/apps/vision/vision_service/recognizer.py @@ -33,6 +33,42 @@ class Recognizer(Protocol): def analyze(self, image_bytes: bytes) -> AnalyzeResponse: ... +def _reduce_confidence(raw: object) -> float: + """fast-alpr's OCR confidence is a LIST of per-character confidences. Reduce to one + plate confidence via the MIN — a plate is only as trustworthy as its weakest + character (one misread digit changes the identity). Tolerates a scalar (future + models) or junk (→ 0.0). Pure + model-free so it's unit-testable without weights.""" + if isinstance(raw, (list, tuple)) and raw: + try: + return float(min(raw)) + except (TypeError, ValueError): + return 0.0 + if isinstance(raw, (int, float)): + return float(raw) + return 0.0 + + +def plate_from_alpr_result(r: object) -> PlateResult | None: + """Map ONE fast-alpr ALPRResult to our PlateResult, or None if it carries no text. + Uses getattr throughout so it's decoupled from the exact fast-alpr classes (and + testable with a duck-typed stand-in). See wiki/entities/opencv-anpr-service.md.""" + ocr = getattr(r, "ocr", None) + det = getattr(r, "detection", None) + text = getattr(ocr, "text", None) + if ocr is None or not text: + return None + bbox = None + box = getattr(det, "bounding_box", None) + if box is not None: + bbox = BBox(x1=int(box.x1), y1=int(box.y1), x2=int(box.x2), y2=int(box.y2)) + return PlateResult( + text=text, + confidence=_reduce_confidence(getattr(ocr, "confidence", None)), + bbox=bbox, + region=getattr(ocr, "region", None), + ) + + class StubRecognizer: """A no-model placeholder. Returns an empty (no-plate) result quickly so the whole HTTP path — Node adapter, contract, error handling — can be exercised without the @@ -111,19 +147,9 @@ class FastAlprRecognizer: results = self._alpr.predict(frame) plates: list[PlateResult] = [] for r in results: - ocr = getattr(r, "ocr", None) - det = getattr(r, "detection", None) - text = getattr(ocr, "text", None) - if not text: - continue - conf = float(getattr(ocr, "confidence", 0.0) or 0.0) - bbox = None - box = getattr(det, "bounding_box", None) - if box is not None: - bbox = BBox( - x1=int(box.x1), y1=int(box.y1), x2=int(box.x2), y2=int(box.y2) - ) - plates.append(PlateResult(text=text, confidence=conf, bbox=bbox)) + plate = plate_from_alpr_result(r) + if plate is not None: + plates.append(plate) plates.sort(key=lambda p: p.confidence, reverse=True) best = plates[0] if plates else None diff --git a/apps/vision/vision_service/schemas.py b/apps/vision/vision_service/schemas.py index 4424c7d..5fe7da4 100644 --- a/apps/vision/vision_service/schemas.py +++ b/apps/vision/vision_service/schemas.py @@ -22,8 +22,12 @@ class BBox(BaseModel): class PlateResult(BaseModel): text: str + # The plate's confidence = the MIN of fast-alpr's per-character confidences (a plate + # is only as trustworthy as its weakest character). See recognizer.py. confidence: float = Field(ge=0.0, le=1.0) bbox: BBox | None = None + # Predicted issuing region/country (advisory; fast-alpr's global model emits this). + region: str | None = None class VehicleResult(BaseModel):