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:
@@ -0,0 +1,11 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
|
||||
models/
|
||||
*.onnx
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,54 @@
|
||||
# @parking/vision — host-side ANPR / vehicle-verification service
|
||||
|
||||
A **separate process** (Python + FastAPI) the Node backend calls over **localhost HTTP** with a
|
||||
camera snapshot, returning a licence-plate read (and, later, vehicle-attribute verification — the
|
||||
anti-plate-spoofing witness). Recognition is **advisory, never the sole authority** to open a
|
||||
barrier: if this service is down or unsure, the host falls back to the ticket path.
|
||||
|
||||
Lives inside the Turborepo at `apps/vision/` but is **not a JS package** — Python deps are managed by
|
||||
`uv`/`pyproject.toml`; the `package.json` is a thin shim so `turbo run lint/test` includes it. See
|
||||
`wiki/decisions/vision-service-packaging.md` and `wiki/entities/opencv-anpr-service.md`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# from apps/vision/ — install the light core (boots in stub mode, no model downloads)
|
||||
uv sync
|
||||
|
||||
# dev server with reload (or: pnpm --filter @parking/vision dev)
|
||||
uv run uvicorn vision_service.app:app --reload --port 8089
|
||||
|
||||
# checks
|
||||
uv run ruff check .
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
### Enable the real recognizer (fast-alpr)
|
||||
|
||||
```bash
|
||||
uv sync --extra alpr # installs fast-alpr + onnxruntime (downloads model weights)
|
||||
VISION_RECOGNIZER=fast_alpr uv run uvicorn vision_service.app:app --port 8089
|
||||
```
|
||||
|
||||
`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`.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health` → `{ status, recognizer, ready, model_version, detail? }`
|
||||
- `POST /analyze` (body = raw image bytes, `Content-Type: application/octet-stream`) →
|
||||
`{ plate: {text, confidence, bbox}|null, plates[], vehicle: null, low_confidence, model_version, took_ms }`
|
||||
|
||||
The Node side POSTs `Snapshot.bytes` directly (no multipart). `vehicle` is scaffolded but not yet
|
||||
populated — fast-alpr is plate-only; the vehicle stage (Job 2) is built later on the same runtime.
|
||||
|
||||
## Config (env, prefix `VISION_`)
|
||||
|
||||
| Var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `VISION_RECOGNIZER` | `stub` | `stub` (no models) or `fast_alpr` (real) |
|
||||
| `VISION_PORT` | `8089` | listen port |
|
||||
| `VISION_DETECTOR_MODEL` | `yolo-v9-t-384-license-plate-end2end` | fast-alpr detector |
|
||||
| `VISION_OCR_MODEL` | `cct-xs-v2-global-model` | fast-alpr OCR |
|
||||
| `VISION_MIN_CONFIDENCE` | `0.5` | below this → `low_confidence=true` |
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@parking/vision",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
||||
"scripts": {
|
||||
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
||||
"lint": "uv run ruff check .",
|
||||
"format": "uv run ruff format .",
|
||||
"typecheck": "uv run mypy vision_service",
|
||||
"test": "uv run pytest -q",
|
||||
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
[project]
|
||||
name = "parking-vision"
|
||||
version = "0.0.0"
|
||||
description = "Host-side ANPR / vehicle-verification microservice for the parking system (separate process; localhost HTTP)."
|
||||
requires-python = ">=3.10,<4.0"
|
||||
# Core deps are LIGHT on purpose: the service boots, serves /health, and answers
|
||||
# /analyze in stub mode with ONLY these. The heavy recognizer stack (fast-alpr +
|
||||
# onnxruntime + model weights) is the optional `alpr` extra, so `uv sync` and the test
|
||||
# suite work offline without downloading models. See
|
||||
# wiki/decisions/vision-service-packaging.md + wiki/entities/opencv-anpr-service.md.
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
]
|
||||
|
||||
[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
|
||||
# recognizer evaluation in wiki/entities/opencv-anpr-service.md. onnxruntime is the
|
||||
# CPU backend; swap for onnxruntime-gpu / -openvino / -directml on capable hardware.
|
||||
alpr = [
|
||||
"fast-alpr>=0.4.0",
|
||||
"onnxruntime>=1.19",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
# Dev tooling (uv installs these by default for local work; excluded from the runtime image).
|
||||
dev = [
|
||||
"ruff>=0.8",
|
||||
"pytest>=8.3",
|
||||
"httpx>=0.27", # FastAPI TestClient transport
|
||||
"mypy>=1.13",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 110
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# A pragmatic default set: pyflakes, pycodestyle, isort, bugbear, pyupgrade.
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
# fast-alpr / onnxruntime ship without type stubs; don't fail typecheck on the optional stack.
|
||||
ignore_missing_imports = true
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["vision_service"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Smoke tests for the vision service in STUB mode (no model weights needed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from vision_service.app import app
|
||||
|
||||
|
||||
def make_client() -> TestClient:
|
||||
# TestClient runs the lifespan, building the (stub) recognizer.
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_health_ok_in_stub_mode() -> None:
|
||||
with make_client() as client:
|
||||
res = client.get("/health")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["ready"] is True
|
||||
assert body["recognizer"] == "stub"
|
||||
assert body["model_version"] == "stub-0"
|
||||
|
||||
|
||||
def test_analyze_returns_contract_shape() -> None:
|
||||
with make_client() as client:
|
||||
# The stub recognizes nothing, but the response must match the contract.
|
||||
res = client.post(
|
||||
"/analyze",
|
||||
content=b"\xff\xd8\xff\xe0not-a-real-jpeg",
|
||||
headers={"content-type": "application/octet-stream"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["plate"] is None
|
||||
assert body["plates"] == []
|
||||
assert body["vehicle"] is None
|
||||
assert body["low_confidence"] is False
|
||||
assert body["model_version"] == "stub-0"
|
||||
assert "took_ms" in body
|
||||
|
||||
|
||||
def test_analyze_rejects_empty_body() -> None:
|
||||
with make_client() as client:
|
||||
res = client.post(
|
||||
"/analyze",
|
||||
content=b"",
|
||||
headers={"content-type": "application/octet-stream"},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1466
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user