feat(vision): scaffold apps/vision ANPR microservice (FastAPI, stub recognizer)

Skeleton of the host-side vision service per the packaging decision: a Python/FastAPI
app at apps/vision/, uv-managed, wired into the Turbo graph via a thin package.json
shim (dev/lint/test/build → uv/uvicorn/ruff/pytest). A per-package turbo.json sets
build outputs [] so the no-op build is warning-free.

Endpoints: GET /health (readiness + model version) and POST /analyze (raw
octet-stream body, so Node POSTs Snapshot.bytes directly; empty→400, oversize→413,
recognizer-not-ready→503). The recognizer is a Protocol with a StubRecognizer (no
models, boots/tests offline — the dev/CI default) and a FastAlprRecognizer (the real
MIT YOLOv9+CCT/ONNX stack, lazily imported; missing models ⇒ ready=False, not a crash)
— the device-adapter pattern applied to the model. fast-alpr + onnxruntime are an
optional `alpr` extra, so `uv sync` needs no model download.

Verified: turbo run lint|test|build includes @parking/vision and stays green; uv run
mypy strict-clean; uvicorn boots and serves /health + /analyze live; pnpm workspace
6→7. Not built yet: the Node VisionClient adapter, a Dockerfile + model fetch, and
Job 2 (vehicle verification). Updates the packaging decision (As-scaffolded) + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 15:37:38 +02:00
parent ee28b7302f
commit 6933406ae3
17 changed files with 2036 additions and 9 deletions
+9
View File
@@ -0,0 +1,9 @@
"""Host-side ANPR / vehicle-verification microservice.
A separate process (FastAPI over localhost HTTP) that the Node backend calls with a
camera snapshot and gets back a plate read (Job 1) — and, later, vehicle-attribute
verification (Job 2, the anti-spoofing witness). Recognition is ADVISORY, never the
sole authority to open a barrier. See wiki/entities/opencv-anpr-service.md.
"""
__version__ = "0.0.0"
+88
View File
@@ -0,0 +1,88 @@
"""FastAPI app: POST /analyze (snapshot → plate) + GET /health.
Called by the Node backend over localhost HTTP (the camera driver already holds the
JPEG bytes — Snapshot.bytes). This service is a SEPARATE PROCESS with its own failure
domain: if it's down or unsure, the host falls back to the ticket path — recognition is
advisory, never the sole authority. See wiki/entities/opencv-anpr-service.md.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from .recognizer import Recognizer, build_recognizer
from .schemas import AnalyzeResponse, HealthResponse
from .settings import Settings, get_settings
# Cap an upload so a malformed/huge POST can't exhaust memory (a camera JPEG is well
# under this). 413 beyond it.
MAX_IMAGE_BYTES = 12 * 1024 * 1024
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
app.state.settings = settings
# Build the recognizer once at startup (models load here, not per-request).
app.state.recognizer = build_recognizer(settings)
yield
app = FastAPI(title="parking-vision", version="0.0.0", lifespan=lifespan)
# Typed accessors over the untyped `app.state` (so mypy --strict sees the real types).
def _recognizer(request: Request) -> Recognizer:
rec: Recognizer = request.app.state.recognizer
return rec
def _settings(request: Request) -> Settings:
settings: Settings = request.app.state.settings
return settings
@app.get("/health", response_model=HealthResponse)
async def health(request: Request) -> HealthResponse:
rec = _recognizer(request)
settings = _settings(request)
ready = bool(rec.ready)
return HealthResponse(
status="ok" if ready else "degraded",
recognizer=settings.recognizer,
ready=ready,
model_version=rec.model_version,
detail=getattr(rec, "error", None),
)
@app.post("/analyze", response_model=AnalyzeResponse)
async def analyze(request: Request) -> AnalyzeResponse:
"""Analyze raw image bytes (the camera JPEG). Body is the octet-stream itself, so
the Node side POSTs Snapshot.bytes directly with Content-Type:
application/octet-stream — no multipart wrapping. We read the raw body ourselves
(rather than a required Body param) so an empty/oversize body returns our own clean
400/413 instead of FastAPI's generic 422."""
image = await request.body()
if not image:
raise HTTPException(status_code=400, detail="empty image body")
if len(image) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="image too large")
rec = _recognizer(request)
if not rec.ready:
# The real recognizer failed to load — be explicit so Node falls back rather
# than treating a silent empty result as "no plate present".
raise HTTPException(
status_code=503,
detail=f"recognizer not ready: {getattr(rec, 'error', 'unavailable')}",
)
try:
return rec.analyze(image)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 - never leak a stack to the caller
raise HTTPException(status_code=500, detail=f"analysis failed: {exc}") from exc
+148
View File
@@ -0,0 +1,148 @@
"""The recognizer port + implementations.
The service depends on the `Recognizer` PROTOCOL, never a concrete model library — the
same swappable-behind-an-interface principle as the Node device adapters
(wiki/concepts/device-adapter-pattern.md). Two impls today:
- StubRecognizer: no model weights, deterministic placeholder. Lets the service boot
and the tests run offline with nothing downloaded (dev/CI default).
- FastAlprRecognizer: the real MIT YOLOv9-detector + CCT-OCR stack on ONNX Runtime
(the `alpr` extra). See wiki/entities/opencv-anpr-service.md "Recognizer evaluation".
Adding a recognizer (e.g. a fine-tuned YOLO + PaddleOCR) = a new class here, no app change.
"""
from __future__ import annotations
import time
from typing import Protocol
from .schemas import AnalyzeResponse, BBox, PlateResult
from .settings import Settings
class Recognizer(Protocol):
"""Reads plates from a JPEG/PNG image. Implementations must be process-local and offline."""
@property
def model_version(self) -> str: ...
@property
def ready(self) -> bool: ...
def analyze(self, image_bytes: bytes) -> AnalyzeResponse: ...
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
heavy recognizer stack or any model download."""
model_version = "stub-0"
ready = True
def __init__(self, settings: Settings) -> None:
self._settings = settings
def analyze(self, image_bytes: bytes) -> AnalyzeResponse:
started = time.perf_counter()
# Deliberately recognizes nothing — it is a stub, not a fake "always finds a plate"
# (which would be dangerous: recognition must never invent an identity).
took_ms = (time.perf_counter() - started) * 1000.0
return AnalyzeResponse(
plate=None,
plates=[],
vehicle=None,
low_confidence=False,
model_version=self.model_version,
took_ms=took_ms,
)
class FastAlprRecognizer:
"""The real recognizer: fast-alpr (YOLOv9 plate detector + CCT OCR, ONNX Runtime).
Imported lazily so the service still imports/boots in stub mode when the `alpr`
extra (and its model weights) are not installed — a missing recognizer must not
crash the process; it degrades to a clear `ready=False`.
"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._alpr = None
self._error: str | None = None
try:
from fast_alpr import ALPR
self._alpr = ALPR(
detector_model=settings.detector_model,
ocr_model=settings.ocr_model,
)
except Exception as exc: # noqa: BLE001 - any failure ⇒ not-ready, surfaced via /health
self._error = f"{type(exc).__name__}: {exc}"
@property
def model_version(self) -> str:
return f"fast-alpr:{self._settings.detector_model}+{self._settings.ocr_model}"
@property
def ready(self) -> bool:
return self._alpr is not None
@property
def error(self) -> str | None:
return self._error
def analyze(self, image_bytes: bytes) -> AnalyzeResponse:
if self._alpr is None:
raise RuntimeError(f"fast-alpr not available: {self._error}")
# fast-alpr's predict() takes a BGR ndarray; decode the JPEG with cv2 (pulled in
# transitively by the alpr extra). Import locally so stub mode needs neither.
import cv2
import numpy as np # local import: only needed on the real path
started = time.perf_counter()
buf = np.frombuffer(image_bytes, dtype=np.uint8)
frame = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if frame is None:
raise ValueError("could not decode image bytes")
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))
plates.sort(key=lambda p: p.confidence, reverse=True)
best = plates[0] if plates else None
low = best is not None and best.confidence < self._settings.min_confidence
took_ms = (time.perf_counter() - started) * 1000.0
return AnalyzeResponse(
plate=best,
plates=plates,
vehicle=None, # Job 2 not built yet
low_confidence=low,
model_version=self.model_version,
took_ms=took_ms,
)
def build_recognizer(settings: Settings) -> Recognizer:
"""Factory: pick the recognizer from settings. Falls back to the stub if the real
one can't load, so the service always comes up (with ready=False surfaced)."""
if settings.recognizer == "fast_alpr":
rec = FastAlprRecognizer(settings)
return rec
return StubRecognizer(settings)
+56
View File
@@ -0,0 +1,56 @@
"""The /analyze response contract — the shape the Node VisionClient adapter consumes.
Mirrors the first-cut API in wiki/entities/opencv-anpr-service.md:
{ plate: {text, confidence, bbox}|null, vehicle: {...}|null, modelVersion, tookMs }
Job 2 (vehicle attributes) is scaffolded as an optional field, not yet populated —
fast-alpr is plate-only; the vehicle stage is built later on the same ONNX runtime.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class BBox(BaseModel):
"""Plate bounding box in pixels (top-left origin)."""
x1: int
y1: int
x2: int
y2: int
class PlateResult(BaseModel):
text: str
confidence: float = Field(ge=0.0, le=1.0)
bbox: BBox | None = None
class VehicleResult(BaseModel):
"""Job 2 — vehicle attributes / fingerprint (anti-spoofing). Not yet produced."""
colour: str | None = None
body_type: str | None = None
make: str | None = None
model: str | None = None
class AnalyzeResponse(BaseModel):
# The single best plate, or null when none was found.
plate: PlateResult | None = None
# All plates found (a frame may contain several vehicles).
plates: list[PlateResult] = Field(default_factory=list)
vehicle: VehicleResult | None = None
# True when the best plate is below the confidence floor — Node should treat the
# read as advisory only and prefer the ticket path. See fail-state-safety.
low_confidence: bool = False
model_version: str
took_ms: float
class HealthResponse(BaseModel):
status: str
recognizer: str
ready: bool
model_version: str
detail: str | None = None
+37
View File
@@ -0,0 +1,37 @@
"""Runtime configuration, from environment (prefix VISION_).
Offline-first: every default is local and works with no network. The recognizer is
chosen by `recognizer` — "stub" (no models, deterministic placeholder) or "fast_alpr"
(the real MIT YOLOv9+CCT/ONNX stack, installed via the `alpr` extra).
"""
from __future__ import annotations
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="VISION_", env_file=".env", extra="ignore")
host: str = "0.0.0.0"
port: int = 8089
# Which recognizer to load. "stub" needs no model weights (boots anywhere, for
# dev/CI); "fast_alpr" loads the real models (requires the `alpr` extra installed).
recognizer: Literal["stub", "fast_alpr"] = "stub"
# fast-alpr model names (only used when recognizer="fast_alpr"). Defaults match the
# library defaults; swap the OCR for the 40+country EU model to benchmark Albanian
# plates. See wiki/entities/opencv-anpr-service.md "Recognizer evaluation".
detector_model: str = "yolo-v9-t-384-license-plate-end2end"
ocr_model: str = "cct-xs-v2-global-model"
# Below this OCR confidence the read is returned but flagged low_confidence, so the
# Node side can fall back to the ticket path rather than trust it.
min_confidence: float = 0.5
def get_settings() -> Settings:
return Settings()