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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user