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:
@@ -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
|
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+
|
`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` /
|
country European model to benchmark Albanian plates. For GPU/NPU, install `onnxruntime-gpu` /
|
||||||
`-openvino` / `-directml` instead of `onnxruntime`.
|
`-openvino` / `-directml` instead of `onnxruntime`.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"format": "uv run ruff format .",
|
"format": "uv run ruff format .",
|
||||||
"typecheck": "uv run mypy vision_service",
|
"typecheck": "uv run mypy vision_service",
|
||||||
"test": "uv run pytest -q",
|
"test": "uv run pytest -q",
|
||||||
|
"recognize": "uv run python -m vision_service.cli",
|
||||||
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.6",
|
"pydantic-settings>=2.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
vision-recognize = "vision_service.cli:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# The real recognizer. Install with: uv sync --extra alpr
|
# The real recognizer. Install with: uv sync --extra alpr
|
||||||
# fast-alpr is MIT (YOLOv9 detector + CCT OCR, both MIT) on ONNX Runtime — see the
|
# fast-alpr is MIT (YOLOv9 detector + CCT OCR, both MIT) on ONNX Runtime — see the
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -33,6 +33,42 @@ class Recognizer(Protocol):
|
|||||||
def analyze(self, image_bytes: bytes) -> AnalyzeResponse: ...
|
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:
|
class StubRecognizer:
|
||||||
"""A no-model placeholder. Returns an empty (no-plate) result quickly so the whole
|
"""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
|
HTTP path — Node adapter, contract, error handling — can be exercised without the
|
||||||
@@ -111,19 +147,9 @@ class FastAlprRecognizer:
|
|||||||
results = self._alpr.predict(frame)
|
results = self._alpr.predict(frame)
|
||||||
plates: list[PlateResult] = []
|
plates: list[PlateResult] = []
|
||||||
for r in results:
|
for r in results:
|
||||||
ocr = getattr(r, "ocr", None)
|
plate = plate_from_alpr_result(r)
|
||||||
det = getattr(r, "detection", None)
|
if plate is not None:
|
||||||
text = getattr(ocr, "text", None)
|
plates.append(plate)
|
||||||
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))
|
|
||||||
|
|
||||||
plates.sort(key=lambda p: p.confidence, reverse=True)
|
plates.sort(key=lambda p: p.confidence, reverse=True)
|
||||||
best = plates[0] if plates else None
|
best = plates[0] if plates else None
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ class BBox(BaseModel):
|
|||||||
|
|
||||||
class PlateResult(BaseModel):
|
class PlateResult(BaseModel):
|
||||||
text: str
|
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)
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
bbox: BBox | None = None
|
bbox: BBox | None = None
|
||||||
|
# Predicted issuing region/country (advisory; fast-alpr's global model emits this).
|
||||||
|
region: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class VehicleResult(BaseModel):
|
class VehicleResult(BaseModel):
|
||||||
|
|||||||
Reference in New Issue
Block a user