feat(vision): add recognize CLI + verify fast-alpr end-to-end

Add a dev CLI (uv run python -m vision_service.cli <image>) 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
This commit is contained in:
2026-06-19 15:46:03 +02:00
parent 6933406ae3
commit 5cedcaefe1
7 changed files with 176 additions and 13 deletions
+72
View File
@@ -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())
+39 -13
View File
@@ -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
+4
View File
@@ -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):