ALPR Edge
Automatic license plate recognition at the gate: how the system works end to end - features, flows, high-level and low-level architecture, the complete action workflow, what code runs when, why it takes the time it takes, and how the models are loaded.
| Product / service version | ALPR Edge 0.2.0 |
| Document date | 2026-09-25 |
| Audience | Client engineering, integration, operations and support teams |
| Basis | Source code in src/alpr_edge/, deployment scripts, and a live run of the service (screens and timings in this document were captured from it) |
1. How to use this document
The document is written in layers so different readers can stop at the depth they need.
| If you are... | Read |
|---|---|
| Project / business stakeholder | Sections 2, 3, 4 (what it does, features, flows, and the real operator screens in 4.2) and 17 (limitations and roadmap) |
| Integration engineer (WMS / Bridge / PLC) | Sections 4, 8, 11, 14 (booth flow, WMS integration, API reference) and Appendix C (UART lines) |
| Solution / software architect | Sections 5, 6, 7, 13 (high-level and low-level architecture, model loading, pipeline internals) |
| Operations / support | Sections 4.2 (screens), 6, 12, 16 (startup, where time goes, runbook) and 17 |
Diagrams are interactive: click any diagram to enlarge it, click again to shrink. Every diagram is generated from text sources, so it can be updated together with the code. Names such as PersistWorker or api/app.py refer to real classes and files in src/alpr_edge/, so any statement here can be traced to source.
2. Solution at a glance
2.1 The problem it solves
At a weighbridge or plant gate the client needs to know which vehicle is on the bridge, automatically, so the weight ticket is tied to a verified plate without a person typing it. ALPR Edge does this with cameras at the lane, a small GPU computer at the gate, and an API the client's software calls.
2.2 What happens, in one paragraph
An IR sensor sees a vehicle. The controller sends a line over USB-serial to the edge node (or the client's software calls POST /api/v1/webhook/verify). The node grabs the latest frame from the front, rear and top cameras, finds the plate in each image with an object-detection model, reads the characters with an OCR model, cleans and validates the text against the Indian plate format, compares the front and rear reads, decides PASS / FAIL, stores everything, and pushes the result to the client's WMS. When the WMS finishes weighing it posts the outcome back and the node pulses the boom controller with an RST frame. If a vehicle skips the weighment, the controller raises an alarm and the node forwards a SKIP alert to the WMS.
2.3 Design principles (why the code looks the way it does)
- Never block the caller. Trigger endpoints return
202immediately; results are polled or pushed. Inference happens on background threads. - Pre-warmed hardware. Cameras stay open and keep a rolling buffer, so a trigger just "dips" the latest frame instead of re-opening a device.
- One node, one lane. One process plus one SQLite file per site/lane. Simple to deploy and reason about; scale by adding nodes.
- Fail closed, never guess. Two different plates on front and rear are a forced FAIL. Short or malformed reads cannot report a high confidence.
- Everything is configurable at runtime where it is operational. Delays, camera sources, UART parsing strings, Bridge endpoints are operator settings stored in SQLite; models and security are YAML / environment only.
- Durable outcomes. A finished verification is always written to SQLite (a metadata-only "stub" row if the full write fails) so the poll always has a terminal answer.
2.4 Technology stack
| Concern | Technology |
|---|---|
| API / web | FastAPI + uvicorn (Python 3.12), nginx reverse proxy, systemd service |
| Plate detection | Ultralytics YOLO11s (.pt or TensorRT .engine) - default; optional RF-DETR ONNX at strict FP32 via ONNX Runtime CUDA |
| Text recognition (OCR) | PP-OCRv4: PaddleOCR (CPU/GPU), or ONNX Runtime CUDA text detector + recognizer, or TensorRT recognizer engine (Jetson) |
| Image handling | OpenCV (headless), NumPy |
| Storage | SQLite (WAL for the verify store) - sessions, jobs, history, audit, verify calls, operator prefs |
| Config | pydantic-settings: environment > .env > config/plate.yaml > defaults; plus operator prefs in SQLite |
| Hardware I/O | pyserial (UART), OpenCV / FFmpeg (RTSP, HTTP, USB webcam, MP4 replay) |
| Outbound | httpx (Bridge push, SKIP alert, HMAC-signed webhooks) |
3. Feature catalogue
| Area | Capability | Status |
|---|---|---|
| Recognition | Quality gate (blur / dark / bright) before any GPU work | Shipped |
| Plate detection (YOLO11s) with geometry filter and ranking | Shipped | |
| RF-DETR detector backend (ONNX, FP32, CUDA) | Optional | |
| PP-OCRv4 OCR on Paddle, ONNX Runtime or TensorRT; two-row plate layout; HSRP junk removal | Shipped | |
| Indian plate validation: state/RTO check, BH series, fuzzy confusion repair (0/O, 1/I, 5/S, 8/B ...), plausibility score | Shipped | |
| Multi-frame consensus (majority vote) and confidence tiers | Shipped | |
| Booth verification | 3-camera verify: front + rear plates cross-matched, top camera captured | Shipped |
| Fast-async API: 202 + poll + on-demand images (annotated and crop variants) | Shipped | |
| Multi-phase UART lane events: ENTRY / MID / EXIT with LEFT / RIGHT, best-of-4 selection | Shipped | |
Per-camera capture delay and output delay (ms), idempotent client session_id | Shipped | |
| Top-camera classifier ("Module 2") | Stub - hook in place | |
| Integration | Bridge push to WMS: metadata / JSON+base64 / multipart, ACK check, recovery via GET-last, manual retry | Shipped |
Weighment result POST /webhook/weighment → RST UART frame | Shipped | |
SKIP alert (ALERT:SKIP:<dir>) → WMS alert POST | Shipped | |
| Async scans (upload or camera capture) with job queue, retake logic, HMAC webhooks | Shipped | |
| Operations | Operator dashboard (/) and Verify Console (/verify): preview, capture, results, images, audit, settings, serial monitor | Shipped |
| Health, readiness and detailed status endpoints; live log ring buffer | Shipped | |
| Retention sweep (rows, images, logs, debug dumps, orphan uploads, VACUUM) | Shipped | |
| API key / JWT auth, rate limits, CORS allowlist, upload validation, log redaction | Shipped (auth off by default on loopback) | |
| Human-in-the-loop edit of a failed read | Shipped | |
| Platform | Jetson Orin Nano GPU deployment scripts (TensorRT OCR, CUDA ONNX detector), systemd, nginx, optional ngrok | Shipped |
| Central fleet database / load balancing across nodes | Planned | |
| GStreamer / CSI camera source | Planned |
4. The flows we have
The service contains several distinct flows that share the same recognition core. This table is the map; each flow is detailed in its own section.
| # | Flow | Trigger | Client sees | Detail |
|---|---|---|---|---|
| F1 | Booth verify (3 cameras, fast-async) | POST /webhook/verify or UART lane event | 202, then poll calls/{session_id}; images on demand; optional Bridge push | Section 8 |
| F2 | Async scan (upload / camera capture) | POST /scans or POST /cameras/{id}/capture | 202 ref_id, poll refs/{ref_id} or webhook | Section 9 |
| F3 | Sync plate (test / diagnostics) | POST /plate, /plate/view, /plate/detect, /plate/ocr | 200 with the result in the response | Section 10 |
| F4 | Bridge push to WMS | After a verify row is persisted | WMS receives session, plates, optional images | Section 11.1 |
| F5 | Weighment result → RST | WMS calls POST /webhook/weighment | 200 ack; controller receives RST | Section 11.2 |
| F6 | SKIP alert | Controller line ALERT:SKIP:LEFT|RIGHT | WMS receives alert POST | Section 11.3 |
| F7 | Operator UIs | Browser | Dashboard and Verify Console | Section 13.9 |
| F8 | Housekeeping | Startup + every 300 s | Bounded disk usage; /health retention counters | Section 16.4 |
| F9 | Dev edge orchestrator (alpr-edge CLI) | Keyboard / scripted trigger | Burst captured and fed to the same job queue | Section 13.1 |
4.1 The complete action workflow (one vehicle, end to end)
This is the whole story of a truck crossing the weighbridge in the UART-driven booth configuration: the IR / boom controller is wired to the edge node by USB-serial, the edge node pushes results to the WMS, and the WMS acknowledges the weighment back to the boom controller through the edge node.
%%{init: {"sequence": {"width": 260, "height": 46, "actorMargin": 70, "messageMargin": 34, "boxMargin": 8, "noteMargin": 10, "wrap": true, "useMaxWidth": true}}}%%
sequenceDiagram
autonumber
participant SITE as Site (IR sensors, boom controller)
participant EDGE as ALPR Edge node
participant WMS as Client WMS
SITE->>EDGE: ENTRY line (vehicle at entry sensor)
Note over EDGE,WMS: parse, debounce, open lane session,<br/>snap front + rear, store candidates
SITE->>EDGE: MID line (vehicle on bridge)
Note over EDGE,WMS: capture front + rear + top, detect + OCR,<br/>best-of-4, match, decide PASS / FAIL,<br/>persist to SQLite after output delay
EDGE->>WMS: Bridge push (session_id, plates, images per settings)
WMS-->>EDGE: ACK (received, length)
Note over WMS: weigh vehicle, decide<br/>Success / Failed
WMS->>EDGE: POST /webhook/weighment {session_id, status}
alt status = Success
EDGE->>SITE: RST + CRLF over the same serial port
Note over SITE,EDGE: boom / signal logic proceeds
else status = Failed
EDGE-->>WMS: 200 (RST suppressed)
end
SITE->>EDGE: EXIT line (vehicle leaves)
Note over EDGE,WMS: clear lane session
opt vehicle skipped weighment
SITE->>EDGE: ALERT:SKIP:LEFT
EDGE->>WMS: POST skip alert (weighment_skip)
end| Step | Actor | Action | Code block | Result |
|---|---|---|---|---|
| 1 | Vehicle / IR | Vehicle reaches the entry sensor; controller writes an ENTRY line | SerialTriggerReader._run | Line read from the USB-serial port |
| 2 | Edge | Parse line into phase + direction, debounce (1.5 s), open a lane session | parse_trigger_line, submit_lane_event, LaneSessionStore.open_entry | New session_id; second ENTRY while lane is busy → TRIGGER_BUSY |
| 3 | Edge | Snap front + rear cameras, detect and OCR both plates. Nothing is persisted yet | capture → detect → OCR workers, record_entry_plates | First plate candidates stored on the lane session |
| 4 | Vehicle / IR | Vehicle is on the bridge; controller writes the MID line | same reader | MID lane event |
| 5 | Edge | Capture front + rear + top; detect and OCR; merge with ENTRY candidates and take the best per role (best-of-4); match front vs rear; decide PASS / FAIL | SessionAggregator._finalize_locked, merge_mid_and_pick, match_front_back | PersistTask built |
| 6 | Edge | Hold for the configured output delay, write the row to SQLite | PersistWorker | Row in vehicle_verify_calls, poll now returns terminal status |
| 7 | Edge → WMS | Bridge push (plates, session_id, optional images), verify ACK, retry or recover | push_after_persist, BridgeClient.post_session | WMS knows the vehicle identity |
| 8 | WMS | Reads the weight, decides Success / Failed, calls POST /webhook/weighment | client side | - |
| 9 | Edge → controller | On Success send RST\r\n over the same serial port; on Failed suppress it | weighment_status, SerialTriggerReader.send_rst | Boom / signal logic on the controller proceeds; audit row written |
| 10 | Vehicle / IR | Vehicle leaves; controller writes EXIT | reader → submit_lane_event(exit) | Lane cleared, no capture |
| Alt | Controller → Edge → WMS | If the vehicle skips weighment, controller writes ALERT:SKIP:<dir>; edge POSTs a SKIP alert to the WMS | parse_alert_line, dispatch_skip_alert | WMS alarms; audit row written |
Other wiring options described in docs/client-verify/integration-details.md use the same pipeline: Mode A (IR trigger reaches ALPR Edge; the client reads results from the history APIs) and Mode B (IR trigger reaches the WMS first, and the WMS calls POST /webhook/verify with its own ticket id as session_id so ANPR is bound to the weighment ticket). In both, the UART lane steps 1-4 are replaced by the trigger call and everything from capture onward is identical.
4.2 The operator screens
Everything above can be watched and operated from two browser screens served by the edge node itself. The screenshots below were taken from a running instance on the development host (looping demo footage as the camera source, auth disabled on loopback). Click any screenshot to enlarge it.
Verify Console (/verify) - the booth operator and commissioning screen
Six numbered tabs follow the life of a trigger. The header strip is visible on every tab: PIPELINE (from /health), QUEUED and IN FLIGHT (sessions waiting or being processed by the trigger pipeline) and P50 (median processing time of the last sessions, from the aggregator).
GET /cameras/{id}/stream). Use it to confirm camera placement before going live.- Health strip: pipeline status, queue depth, in-flight sessions, P50 latency
- Tabs 01-06: Preview, Capture, Results, Images, Audit, Settings
- View filter: All / Front / Rear / Top, and Reload
- FRONT feed (physical plate camera,
capture.plate) - click a cell to focus it - REAR feed (
capture.back) - TOP feed (
capture.material)
POST /api/v1/webhook/verify a sensor trigger does, polls the session, and shows the answer. This example is a real PASS: both cameras read UK07CA9035, confidence 93.7 %, match outcome both_pass_match, total 860 ms.- Live health strip (P50 348 ms in this run)
- Capture and process - trigger, then poll until terminal; the label shows "Success in 860 ms"
- Cameras at trigger time - the three frames dipped from the ring buffers
- Result strip:
status,final_raw_text,ocr_confidence,match_outcome, session id and failure reason - Client payload - the exact JSON a client receives (images shortened to their length)
- Frames from API - the stored JPEGs returned by the image endpoint
GET /webhook/verify/calls?day=&limit=50). Note the red row UK07CA903: both cameras agreed, but the read was one character short and its confidence (84.3 %) fell below the 85 threshold, so the system failed it instead of passing a doubtful plate.- Date (IST) and Load
- Result list: green bar = PASS, red bar = FAIL, with plate, session id, time and
match_outcome - A near-miss: matching plates but confidence 84.3 % < 85 = FAIL
- Session detail - click a row to inspect its full JSON
GET /webhook/verify/calls/{session_id}/image.- Session id to look up
- Camera selector: All / Front / Rear / Top
- Optional overlays: Detection (bbox) and Plate crop (the
annotatedandcropquery flags) - Fetch - opens the request shown in 5
- The exact request that was issued
- Returned frames; a viewer opens on click. Payload size is shown below (about 2.9 million base64 characters per 4K frame)
GET /api/v1/logs) rewritten in plain language. It is the fastest way to see where time goes in a trigger: every stage line carries duration_ms and queue_wait_ms (Section 16.3).- Filters: current session, all events, follow, clear
- Startup warnings (for example auth disabled on loopback)
- Stage line: "Frames extracted from cameras"
- Per-stage timings: duration in the stage and time waiting in the queue
Settings tab - what an operator can change without a restart
Settings are stored as operator preferences in SQLite and applied live (cameras hot-reload). Delays are scheduled on deadlines, so a waiting session never blocks the trigger behind it.
/api/v1/alerts/skip); connectivity tests Test POST and Test GET last. See Section 11.1 and 11.3.Operator dashboard (/) - single scans and batch work
- Choose image / video and Batch upload
- Camera controls: Open, Capture (burst), Close
- Live camera view (JPEG preview)
- Link to the 3-camera Verify Console
5. High-level architecture
5.1 System context
flowchart LR
subgraph Field["Gate / weighbridge (physical site)"]
IR["IR sensors +<br/>boom controller"]
CF["Front plate<br/>camera"]
CB["Rear plate<br/>camera"]
CT["Top camera"]
end
subgraph Edge["ALPR Edge node - Jetson Orin / GPU PC"]
NG["nginx :80<br/>reverse proxy"]
API["alpr-serve<br/>FastAPI + workers :8000"]
end
WMS["Client WMS /<br/>weighment software"]
OP["Operator browser<br/>Dashboard + Verify Console"]
IR -- "UART lines (USB-serial)" --> API
API -- "RST frame" --> IR
CF & CB & CT -- "RTSP / HTTP / USB / file" --> API
OP -- "HTTP(S)" --> NG
WMS -- "POST verify, GET calls,<br/>POST weighment" --> NG
NG --> API
API -- "Bridge push +<br/>SKIP alert" --> WMS5.2 Logical layers
| Layer | Responsibility | Main packages |
|---|---|---|
| Interface | HTTP routes, authentication, rate limiting, CORS, static UIs | api/ |
| Orchestration | Turns a trigger into work: queues, workers, fan-in, sessions, lane state machine, job retries | trigger_pipeline/, jobs/, serial_trigger/, session/ |
| Recognition core | Pure image-to-plate logic; no HTTP, no database | plate/ |
| Device and data | Cameras and ring buffers, SQLite persistence, outbound HTTP, configuration | capture/, history/, outbound/, config/ |
The separation matters for maintenance: plate/ has no knowledge of HTTP or storage, so the same code serves the API, the job queue, the trigger pipeline, the CLI and the benchmark scripts.
5.3 Runtime / deployment view
client_max_body_size 16m, long-lived MJPEG streams
Restart=on-failure, 5 s
uvicorn alpr_edge.api.app:appdata/uploads, data/debug
logs/ (rotating)
onnx_ocr/ det.onnx, rec.engine
- One process, many threads. uvicorn hosts the asyncio event loop; all pipeline stages are plain threads communicating through bounded
queue.Queueobjects. - Restart safety. systemd restarts the service 5 s after a crash. Durable state (SQLite) survives; the in-memory verify aggregator does not (see Section 13.7).
- Network exposure. uvicorn binds to loopback by default. Binding to a LAN address is refused unless API-key / JWT auth is enabled.
6. Startup and model loading
Startup is the slowest thing the service ever does, and it is deliberately done once, before the server accepts traffic. All of it happens inside the FastAPI lifespan function in api/app.py.
flowchart TB A["Step 1 - Load configuration and logging<br/>uvicorn imports alpr_edge.api.app (run.sh sources .env first)<br/>AppSettings.load(): environment, then .env, then plate.yaml, then defaults"] B["Step 2 - Load model set A: get_pipeline()<br/>torch-first guard, PlateDetector (YOLO weights or ORT session), PlateOCR (TRT engine / ONNX / Paddle)<br/>SLOWEST STEP: library imports, weights, engines, CUDA context"] C["Step 3 - Safety checks and housekeeping<br/>validate_auth_settings (public bind without auth = refuse to start),<br/>rate limiters, CORS allowlist, retention sweep + VACUUM"] D["Step 4 - warmup_pipeline()<br/>dummy 1280x720 frame through detect + OCR<br/>one-time CUDA / cuDNN kernel init, logged as warmup_ms"] E["Step 5 - Start workers and cameras<br/>JobWorker (shares set A), preview cameras, open plate / back / material readers,<br/>start_supervisor(): 5 pipeline threads + reaper"] F["Step 6 - start_serial_trigger_from_prefs()<br/>UART reader thread, if enabled in operator prefs"] G["Step 7 - lifespan yields: server accepts traffic<br/>(nginx returns 502 until this point)"] A --> B --> C --> D --> E --> F --> G classDef slow fill:#fff4e5,stroke:#b45309,stroke-width:2px classDef lazy fill:#fdecea,stroke:#b42318,stroke-width:2px class B,D slow class E lazy
6.1 What is loaded, from where, by whom
| Asset | File (under models/) | Loaded by | How |
|---|---|---|---|
| YOLO11s plate detector (default) | plate_yolo11s.pt (or Ultralytics-exported .engine) | PlateDetector.__init__ | YOLO(path); device resolved from auto | cpu | cuda; FP16 when on GPU |
| RF-DETR detector (optional) | rfdetr/weights.onnx, inference_config.json | RfdetrOnnxDetector._load | ONNX Runtime session with CUDAExecutionProvider (never TensorRT EP, never FP16); input 616×616 float32; refuses silent CPU when a GPU was requested |
| OCR text detector (GPU path) | onnx_ocr/det.onnx | OnnxTextDetector | ORT CUDA session (never a TensorRT det engine) |
| OCR recognizer (Jetson) | onnx_ocr/rec.engine (~5.5 MB) | TrtPlateRecognizer | TensorRT runtime deserializes the engine, creates an execution context, a CUDA stream and device buffers. Engines are built on the board and are not portable between machines |
| OCR recognizer (fallback) | onnx_ocr/rec.onnx | OnnxPlateRecognizer | ORT CUDA session; used when rec.engine is missing |
| PaddleOCR PP-OCRv4 (default backend) | managed by PaddleOCR | PlateOCR._init_paddle | PaddleOCR(ocr_version="PP-OCRv4", ...); optional separable det + rec pair for per-line processing |
OCR backend selection order (PlateOCR.__init__): if backend is tensorrt try the TRT engine, then rec.onnx; if onnx try rec.onnx; then optionally load the text detector when use_det is true. If nothing GPU-side loads: with gpu_strict=true the service refuses to start (no silent CPU fallback); otherwise it falls back to Paddle. The label reported by /health tells you which path is live: PP-OCRv4-TRT+det, PP-OCRv4-TRT, PP-OCRv4-ONNX+det or PP-OCRv4+det (Paddle).
nms operator. If Paddle loads first, YOLO silently returns zero boxes. PlateOCR.__init__ therefore calls plate/_torch_first.ensure_torch_ops_bound() before importing PaddleOCR. Any new entry point that touches Paddle must go through PlateOCR so this guard runs.6.2 Why startup takes time
| Phase | What is actually happening | Order of magnitude |
|---|---|---|
| Python imports | torch, ultralytics, onnxruntime, paddle / paddleocr, OpenCV are large native libraries; the first import maps them into memory and initialises their kernels | seconds |
| Weights deserialization | Reading the .pt, building the network graph, loading ONNX graphs, deserializing TensorRT engines from disk into GPU memory | seconds (larger on Jetson's slower storage / shared memory) |
| CUDA context and provider init | First CUDA call creates the context; ORT and TensorRT allocate workspaces; cuDNN picks algorithms | seconds on first use |
| Warm-up inference | warmup_pipeline() runs one dummy detect and one OCR call so the first real request is not the one paying kernel initialisation. It calls the detector and OCR directly (bypassing the quality gate, which would reject the synthetic frame) | 0.1 - 0.3 s on the dev host (logged as warmup_ms) |
| Camera open + supervisor start | Camera readers start threads and begin filling ring buffers; the pipeline supervisor starts 5 worker threads and a reaper | sub-second |
Measured on the development host (CPU inference): pipeline load 6 s in the run used for the screenshots (3 - 10 s across earlier logs), warm-up 216 ms in that run (134 - 288 ms earlier; from logs/alpr-serve.log and the live run log). Jetson figures depend on storage and thermal state; record them on first commissioning from the same log lines: Loading plate pipeline… → pipeline.warmed → Pipeline ready.
6.3 Two model instance sets (important for memory and first-trigger latency)
| Instance set | Created | Used by |
|---|---|---|
Set A - get_pipeline() singleton: PlateDetector + PlateOCR | Eagerly during startup (step 2), then warmed (step 4) | /plate* sync endpoints, JobWorker (async scans), /ready and /health, GPU keep-alive |
| Set B - private detector and OCR objects | Lazily: DetectWorker._ensure_detector() on its first task; OcrWorker._read_once() on its first read | The booth verify trigger pipeline only |
- The verify pipeline and the async-scan worker are independent GPU owners by design, so a large sync scan cannot stall a booth trigger (and vice versa). The trade-off is that the models exist twice in memory. On an 8 GB Jetson with unified memory this is a sizing input.
- Because set B is not covered by the startup warm-up, the first booth trigger after a restart pays model construction and first-kernel cost: the detector is built when the first frame reaches the detect thread, and PaddleOCR / the OCR engine when the first plate crop reaches the OCR thread. Measured on the development host (CPU, models already on disk): the first trigger took 855 ms against 250-350 ms once warm, and the first OCR call took 490 ms against ~85 ms warm. On a Jetson loading TensorRT engines and CUDA sessions the penalty is expected to be larger, and the default worst-case SLA is 5 s, so on a cold, slow board that first trigger could be reported as
SLA_TIMEOUT(not measured on hardware). Commissioning recommendation: after every restart, fire one throw-awayPOST /api/v1/webhook/verify(or use Capture and process in the Verify Console) and discard the result. Raising the SLA (operator setting) also covers it.
6.4 GPU keep-alive
Laptop and embedded GPUs drop to a low-power state after roughly 10-15 s of idleness, and the next CUDA launch then stalls for seconds. While the async JobWorker has no jobs it runs a cheap detector pass every 5 s (_gpu_keepalive) on a 640×640 dummy frame to keep set A warm. Set B is kept warm only by real traffic.
7. Core recognition pipeline
Every flow (F1, F2, F3 and the CLI) ends up running the same blocks in src/alpr_edge/plate/. In the booth flow the blocks are spread across the DetectWorker and OcrWorker threads; in the async and sync flows they run inside PlatePipeline (plate/pipeline.py).
flowchart TB
IN["Input frame(s), BGR"] --> Q{"Quality gate (CPU)<br/>Laplacian variance >= 50<br/>mean luma 15 .. 245"}
Q -- "every frame unusable" --> QF["quality_fail<br/>blurry / too_dark / too_bright"]
Q -- "at least one usable" --> DET["Detector.detect_all()<br/>resize to model input, run net,<br/>map boxes back to original"]
DET --> GEO{"Geometry filter<br/>height >= 40 px, width/height<br/>within allowed aspect"}
GEO -- "no box survives" --> ND["no_detection"]
GEO -- "rank by conf x sqrt(area), keep max 3" --> CROP["crop_with_padding (5 %)"]
CROP --> OCR["PlateOCR.read()<br/>see OCR internals"]
OCR -- "empty text" --> OF["ocr_failed"]
OCR -- "text" --> SEL["select_best_read()<br/>format valid, tier, conf x det conf, box area"]
SEL --> CONS["build_consensus()<br/>majority vote across burst frames"]
CONS --> VAL["apply_validation()<br/>sanitize, parse Indian format,<br/>fuzzy repair, RTO check, plausibility"]
VAL --> TIER["Confidence tier<br/>high / medium / low / reject"]
TIER --> RES["PlateResult<br/>text, region, confidence, status,<br/>validation_status, warnings"]7.1 Block-by-block: what is called, when, and why it costs time
| # | Block (function) | Called when | What it does | Why it takes time / typical cost |
|---|---|---|---|---|
| 1 | Quality gateassess_frame_quality / assess_burst_quality | First, on the CPU, before any model | Grayscale, Laplacian variance (sharpness) and mean luma. A burst passes if any frame is usable | Milliseconds. Saves a whole GPU pass when a frame is unusable |
| 2 | DetectorPlateDetector.detect_all | Once per frame that passed the gate | YOLO: letterbox to a fixed 640×640 canvas so CUDA shapes never change, run the network, unmap boxes to original pixels. RF-DETR: stretch to 616×616, normalise, ORT run, sigmoid scores | Largest fixed cost. Includes resizing a 4K frame, host→device copy, network forward, NMS. ~60-90 ms warm (dev CPU host, YOLO), ~180-220 ms Mac / ~290 ms Jetson GPU (RF-DETR) |
| 3 | Geometry filter + rankingfilter_and_rank_boxes | Immediately after detection | Rejects boxes shorter than 40 px or outside the aspect range (bolts, chains, reflectors); ranks by confidence × √area; keeps at most 3 | Microseconds. A very small or unusual-shape plate is dropped here, which shows up as no_detection |
| 4 | Cropcrop_with_padding | Per surviving box | Axis-aligned crop with 5 % padding | Microseconds |
| 5 | OCRPlateOCR.read → read_detailed | Per crop (all crops are read; the best read wins) | See 7.2 | Second-largest cost: text detection then per-line recognition. ~60-80 ms warm on a fast host; ~200-500 ms Paddle CPU on Jetson; TensorRT recognizer itself is ~5 ms |
| 6 | Candidate selectionselect_best_read | When more than one crop produced text | Ranks by: format valid, tier, OCR conf × detector conf, OCR conf, box area | Microseconds; runs validation per candidate |
| 7 | Consensusbuild_consensus | Once per frame set (a single read in the booth flow) | Groups reads by normalised text, majority vote weighted by confidence, checks RTO region | Microseconds |
| 8 | Validationapply_validation → validate_plate_text | After consensus | Sanitise, reject fragments shorter than 8 characters, parse against the Indian formats (state, district, series, number; BH series), fuzzy repair, region check, plausibility score, confidence tier | Milliseconds. Fuzzy repair enumerates up to 48 candidate strings |
| 9 | Pass decisionis_plate_pass | End | status = ok AND validation_status in {valid, corrected} AND tier in {high, medium} | Microseconds |
7.2 Inside the OCR block
flowchart TB
A["Plate crop"] --> B["Optional HSRP margin trim<br/>(yaml default: never)"]
B --> C["Optional morphology 'open' 3x3<br/>only when detector = rfdetr"]
C --> D{"Active OCR backend"}
D -- "tensorrt / onnx<br/>use_det = true" --> E1["Text detector det.onnx<br/>ONNX Runtime CUDA, DBNet<br/>resize <= 960, stride 32 -> quads"]
E1 --> E2["Per text line: perspective crop<br/>48 px high, <= 320 wide<br/>rec.engine (TRT) or rec.onnx + CTC"]
D -- "tensorrt / onnx<br/>use_det = false" --> F["Recognizer on whole crop"]
D -- "paddle<br/>use_det = true" --> G["Paddle text-det + Paddle rec per line"]
D -- "paddle bundled" --> H["PaddleOCR.predict() det + rec"]
E2 --> I
F --> I
G --> I
H --> I
I["Drop tokens below rec_min_conf 0.5<br/>keep A-Z 0-9, drop symbol noise"] --> J["ocr_layout: de-duplicate overlaps,<br/>left-to-right, two rows top-to-bottom"]
J --> K["merge lines + sanitize_plate_read<br/>(strip IND / chakra / HSRP junk)"]
K --> L["OcrRead: text, confidence, region"]
E2 -. "GPU error" .-> M["fall back to Paddle<br/>(blocked when gpu_strict)"]- Preprocessing is detector-aware. Morphological "open" (erode then dilate, 3×3) is applied only when the detector is RF-DETR, because that detector produces tighter crops with thin strokes; YOLO crops stay raw. This is controlled by
ocr.preprocess.apply_when. - Recognition details. Each text line is resized to 48 px high (at most 320 px wide, width rounded to a multiple of 8), normalised and passed through the recogniser; output is decoded with CTC (blank removal and repeat collapse) against the PP-OCRv4 English dictionary. The confidence is the mean of the kept character scores.
- Layout join. Tokens are de-duplicated when boxes overlap (e.g. a screw read as a digit), ordered left-to-right, and two-row plates are joined top row then bottom row.
- Fallback. If the GPU OCR path raises, the reader falls back to Paddle unless
gpu_strictforbids it, in which case the read returns nothing and is logged asocr.gpu_failed_strict. - Optional retry. With
blur_retry_on_failenabled (off by default), a failed read is retried on the full frame with everything outside the plate box blurred. It adds ~0.8 s only when the first read failed validation.
7.3 Validation and confidence, explained
| Step | Rule |
|---|---|
| Sanitise | Uppercase, keep A-Z and 0-9, remove IND / chakra / HSRP artefacts, merge two-line reads |
| Too short | Fewer than min_plate_chars (8) → text withheld, tier reject, error too_short. The fragment is never shown as if it were a plate |
| Parse | Standard SS DD SSS NNNN or BH-series structure; semantic checks (bad district, over-long series, 1-digit vehicle number when a series is present) |
| Fuzzy repair | Look-alike substitutions by character slot (digit slots accept 0/O, 1/I, 5/S, 8/B ...; letter slots the reverse), state-code and RTO structure repair, junk stripping. Each applied correction is recorded |
| Region | State/RTO prefix validated against the list of Indian codes; an invalid region lowers the tier |
| Plausibility (0-1) | Graded score for how complete and canonical the read is (short vehicle number, distance from canonical form, semantic errors, unknown RTO). Multiplied into the booth confidence so a truncated read such as MH12A1 can never report ~99 |
Tier high | valid/corrected, OCR ≥ 0.85, detector ≥ 0.50, at most one correction, no low-consensus warning, plausibility ≥ 0.80 |
Tier medium | valid/corrected/partial, OCR ≥ 0.65, plausibility ≥ 0.55 |
Tier low / reject | Everything else / invalid or no detection or OCR failure |
8. Flow F1 - Booth verify (3-camera, fast-async)
This is the production flow. It is the most detailed part of the code, so it is described from four angles: entry points, threads and queues, the finalisation logic, and the client contract.
8.1 Entry points
| Entry | How | Phases |
|---|---|---|
| HTTP | POST /api/v1/webhook/verify body {session_id?, phase?, direction?} | phase omitted = legacy one-shot (front + rear + top in one go). phase present requires direction |
| UART | SerialTriggerReader parses a line (template such as {event},{direction} or a regex with event and direction groups) into entry | mid | exit + left | right | Multi-phase lane events |
Both call the same function in trigger_pipeline/orchestrator.py (submit_trigger / submit_lane_event), which is the single choke point for triggers.
8.2 The lane (multi-phase) sequence
%%{init: {"sequence": {"width": 128, "height": 46, "actorMargin": 14, "messageMargin": 34, "boxMargin": 8, "noteMargin": 10, "wrap": true, "useMaxWidth": true,"messageFontSize": 13}}}%%
sequenceDiagram
autonumber
participant IR as IR / controller
participant SER as Serial reader
participant ORC as orchestrator
participant LANE as Lane store
participant AGG as Aggregator
participant PIPE as Capture-detect-OCR
participant PER as Persist
participant WMS as WMS
IR->>SER: ENTRY line (e.g. EVT:LEFT_ENTRY)
Note over SER: parse (template or regex), debounce 1.5 s
SER->>ORC: submit_lane_event (entry, left)
ORC->>LANE: open_entry() - lane_busy raises TRIGGER_BUSY
ORC->>AGG: begin(session, defer_persist=true)
ORC->>PIPE: CaptureTask roles front + back
PIPE->>AGG: plate results
AGG->>LANE: record_entry_plates() - NO persist yet
IR->>SER: MID line (vehicle on bridge)
SER->>ORC: submit_lane_event (mid, left)
ORC->>LANE: mark_mid()
ORC->>PIPE: CaptureTask roles front + back + top
PIPE->>AGG: plate results + top
AGG->>LANE: merge_mid_and_pick() - best of entry + mid per role
Note over AGG: re-run match_front_back on winners
AGG->>PER: PersistTask (pass / fail)
Note over PER: hold for output_delay, write SQLite
PER->>WMS: Bridge push (session_id, plates, images per prefs)
IR->>SER: EXIT line
SER->>ORC: submit_lane_event (exit, left)
ORC->>LANE: mark_exit() - lane cleared, no capturestateDiagram-v2 [*] --> Idle Idle --> Open: ENTRY - snap front + rear, defer persist Open --> Open: second ENTRY rejected (lane_busy, 503) Open --> AwaitingExit: MID - add top camera, best-of-4, persist + Bridge Open --> Idle: TTL 120 s expired AwaitingExit --> Idle: EXIT - lane cleared AwaitingExit --> Open: new ENTRY replaces lane AwaitingExit --> Idle: TTL expired
lane_session_ttl_s) so a missed EXIT cannot block the booth forever.- Role mask per phase: ENTRY captures {front, back}; MID captures {front, back, top}; legacy captures all three.
- Best-of-4: up to 2 front and 2 rear candidates (one per phase).
_best()picks the highest confidence for each role, then the pair is re-matched. Candidates carry anokflag so unvalidated OCR noise cannot win. - Fixed lane mapping: by default the physical plate camera is always logical "front" and the back camera always "rear", regardless of the direction token. Only a genuinely bidirectional shared lane sets
capture.bidirectional_lane: true, in which case RIGHT swaps the mapping and an unknown direction is flagged (direction_unknown) rather than guessed.
8.3 Threads, queues and who calls whom
flowchart TB
T["submit_trigger /<br/>submit_lane_event"] -- "put_nowait, size 2<br/>Full = 503 TRIGGER_BUSY" --> QC[("capture<br/>queue")]
QC --> CW["CaptureWorker<br/>deadline scheduler"]
CW -- "latest_frame x3" --> CAMS["Camera ring buffers"]
CW -- "DetectTask x2<br/>front, back" --> QD[("detect queue 8")]
CW -- "Module2Task" --> QM[("module2 queue 4")]
CW -- "set_capture / errors" --> AGG
QD --> DW["DetectWorker<br/>YOLO or RF-DETR<br/>single thread"]
DW -- "no crops" --> AGG
DW -- "OcrTask (all crops)" --> QO[("ocr queue 8")]
QO --> OW["OcrWorker<br/>PlateOCR + validate<br/>single thread"]
QM --> MW["Module2Worker<br/>top-camera hook (stub)"]
OW -- "set_plate" --> AGG["SessionAggregator<br/>in-memory fan-in"]
MW -- "set_top" --> AGG
AGG -- "front + back + top done" --> QP[("persist queue 16")]
QP --> PW["PersistWorker<br/>min-heap by release time"]
PW --> DB[("vehicle_verify_calls")]
PW --> BR["Bridge push<br/>pool of 2"]
RP["Reaper every 250 ms<br/>SLA sweep, stale sweep,<br/>restart dead workers"] -.-> AGG| Stage | Thread name | Reads | Does | Writes |
|---|---|---|---|---|
| Capture | trigger-capture | capture queue (size 2) | Registers a per-camera deadline (trigger time + capture delay); at each deadline calls latest_frame(); JPEG-encodes to base64 data URLs; fans out | detect queue (front, back), module2 queue (top), aggregator (set_capture) |
| Detect | trigger-detect | detect queue (8) | detect_all, crop every box, draw the annotated image (JPEG) | OCR queue (OcrTask with all crops) or aggregator no_detection |
| OCR | trigger-ocr | OCR queue (8) | Reads every crop; scores each as (passed validation, non-empty, confidence); keeps the winning crop; encodes it | aggregator set_plate |
| Module 2 | trigger-module2 | module2 queue (4) | run_module2_top(), the single extension point for a future top-camera model; today logs and returns processed | aggregator set_top |
| Persist | trigger-persist | persist queue (16) | Holds each task in a min-heap until its release time (output delay), writes SQLite (retry once, then a metadata-only stub), triggers the Bridge push, frees the aggregator entry | vehicle_verify_calls, Bridge pool |
| Reaper | trigger-reaper | - | Every 250 ms: SLA sweep, stale-finalised sweep (120 s), restarts any dead worker (logged worker_dead) | aggregator |
capture_delay_ms and the front camera's output_delay_ms are registered as release times. The capture and persist loops block only until the nearest deadline (never more than 0.2 s so shutdown stays responsive). A trigger with a 2 s delay therefore never stalls the trigger behind it: three staggered triggers finish in about 4.3 s where serialised sleeping needed about 7.5 s.8.4 How a session is finalised
flowchart TB
E["set_capture / set_plate /<br/>set_top / mark_pipeline_error"] --> C{"front done AND<br/>back done AND top done?"}
C -- "no" --> W["keep waiting<br/>poll shows processing"]
C -- "yes" --> F{"Error code?<br/>ALL_CAMERAS_FAILED, SLA_TIMEOUT,<br/>PIPELINE_BACKPRESSURE, SHUTDOWN"}
F -- "yes" --> ER["status = error"]
F -- "no" --> M["match_front_back()"]
M --> P{"defer_persist?<br/>(ENTRY phase)"}
P -- "yes" --> LS["store candidates on LaneSession<br/>stop - nothing persisted"]
P -- "no" --> MID{"phase = mid?"}
MID -- "yes" --> BO["merge with ENTRY candidates,<br/>best per role, re-match"]
MID -- "no" --> PT
BO --> PT["build PersistTask<br/>on_finalize -> persist queue"]
ER --> PT
RP["Reaper: session older than<br/>SLA worst case (5 s default)"] -. "force" .-> ER"Done" is deliberately broad: a camera that failed to give a frame, a detector that found no plate, an OCR that produced nothing, and a back-pressure drop all mark that camera done with an error string. That is why the client always receives a terminal answer and a per-camera reason in camera_errors (capture_failed, no_detection, ocr_failed, validation_rejected, PIPELINE_BACKPRESSURE, detect_error, ocr_error).
8.5 The PASS / FAIL decision
flowchart TB
S["Front result + Rear result"] --> A{"How many reads are ok?"}
A -- "none" --> R1["both_fail<br/>status FAIL"]
A -- "one" --> R2["front_only / back_only<br/>confidence = OCR x plausibility x 100"]
A -- "both" --> B{"Identical text?"}
B -- "yes" --> R3["both_pass_match"]
B -- "no" --> C{"Fuzzy-repairable to one<br/>shared candidate?"}
C -- "yes" --> R3
C -- "no" --> D{"Edit distance <= 1?"}
D -- "yes" --> R4["both_pass_fuzzy_match<br/>higher-confidence camera wins"]
D -- "no" --> R5["both_pass_mismatch<br/>forced FAIL - never guess<br/>between two different plates"]
R2 --> T{"confidence >= 85 ?"}
R3 --> T
R4 --> T
T -- "yes" --> PASS["status PASS"]
T -- "no" --> FAIL["status FAIL"]trigger_pipeline/match.py). The top camera never influences PASS / FAIL.match_outcome | Meaning | Status |
|---|---|---|
both_pass_match | Front and rear identical, or one is an OCR-confusion repair of the other | PASS if confidence ≥ 85 |
both_pass_fuzzy_match | Edit distance ≤ 1; the higher-confidence camera's text is used | PASS if confidence ≥ 85 |
front_only / back_only | Only one camera produced a validated read | PASS if scaled confidence ≥ 85 |
both_pass_mismatch | Both read, plates differ | FAIL (forced, empty final_raw_text) |
both_fail | Neither read is usable | FAIL |
Confidence is reported on a 0-100 scale everywhere in the verify payload (ocr_confidence, front_confidence, back_confidence), and is the OCR confidence multiplied by the plausibility score.
8.6 Client contract: trigger, poll, fetch images
%%{init: {"sequence": {"width": 170, "height": 46, "actorMargin": 40, "messageMargin": 34, "boxMargin": 8, "noteMargin": 10, "wrap": true, "useMaxWidth": true}}}%%
sequenceDiagram
autonumber
participant C as Client / WMS
participant A as FastAPI
participant O as orchestrator
participant P as Pipeline threads
participant DB as SQLite
C->>A: POST /api/v1/webhook/verify {session_id?}
A->>O: accept_vehicle_verify()
Note over O: idempotency check - DB row, then in-flight
O->>P: CaptureTask into capture queue
A-->>C: 202 {session_id, status: processing}
Note over O,DB: capture, detect, OCR, match and persist run on background threads
loop poll until terminal
C->>A: GET /webhook/verify/calls/{id}
A->>DB: lookup row
alt row not written yet
A-->>C: 200 status = processing (from aggregator)
else finalized
A-->>C: 200 pass / fail / error + plates, match_outcome, timing
end
end
C->>A: GET .../calls/{id}/image (camera, annotated, crop)
A->>DB: fetch stored JPEGs
A-->>C: data-URL images (never in list or detail)processing until the row is written; images are fetched separately so status calls stay small.- Idempotency. If the client sends a
session_id, a repeated POST with the same id returns the same session without a new capture (looked up first in the database, then among in-flight sessions). Without one the server generates a UUID. - Back-pressure. If the capture queue is full the API returns
503 TRIGGER_BUSY; a busy lane (ENTRY while a lane is open) does the same. Clients should retry after a short delay. - Payload sizes. Detail and list responses never carry images. A stored 4K frame is roughly 2 MB of base64 per camera at JPEG quality 80, so
?camera=all&annotated=true&crop=truecan be large; retention and JPEG quality are the levers. - Terminal statuses.
pass,fail,error. The poll returns the terminal status as soon as the aggregator has finalised, even if the database write is still pending or failed.
9. Flow F2 - Async scans (upload or camera capture)
Used when the client already has images (POST /api/v1/scans, 1-16 frames) or wants the node to shoot a burst (POST /api/v1/cameras/{id}/capture). Work goes through a durable SQLite job queue so it survives restarts.
flowchart TB
C["Client: POST /scans (1-16 frames)<br/>or POST /cameras/{id}/capture"] --> V["Validate upload<br/>magic bytes, <= 10 MiB, <= 40 MP<br/>decode + re-encode as JPEG"]
V --> S["create session (or begin_retake)<br/>write burst to data/uploads/ref/plate/*.jpg"]
S --> J1[("jobs: PLATE_DETECT")]
S --> ACK["HTTP 202 {ref_id, status: queued}"]
J1 --> W["JobWorker claims job<br/>fair vs webhook jobs"]
W --> H1["handle_plate_detect<br/>quality gate, detect every frame"]
H1 -- "quality_fail or no boxes<br/>early abort" --> FIN
H1 -- "boxes found" --> J2[("PLATE_OCR successor<br/>frames cached in RAM")]
J2 --> H2["handle_plate_ocr<br/>crop best box per frame, OCR,<br/>consensus, validate"]
H2 --> FIN["_finish_scan<br/>write scan_pass / scan_fail, decide retake,<br/>apply output delay, audit log"]
FIN --> WH[("WEBHOOK_PUSH job<br/>optional, HMAC-signed")]
ACK --> POLL["Client polls GET /refs/{ref_id}<br/>queued, processing, completed,<br/>failed, needs_retake"]
FIN --> POLLstateDiagram-v2 [*] --> RECEIVED RECEIVED --> PREPROCESSING: job claimed PREPROCESSING --> DETECTING: quality ok DETECTING --> RECOGNIZING: boxes found RECOGNIZING --> POSTPROCESSING POSTPROCESSING --> SAVING SAVING --> COMPLETED PREPROCESSING --> NEEDS_RETAKE: quality_fail DETECTING --> NEEDS_RETAKE: no_detection POSTPROCESSING --> NEEDS_RETAKE: ocr_failed / validation_reject NEEDS_RETAKE --> RECEIVED: resubmit same session_id (max 3 attempts) RECEIVED --> FAILED: processing_timeout / job dead DETECTING --> FAILED: processing_timeout RECOGNIZING --> FAILED: processing_timeout COMPLETED --> [*] FAILED --> [*]
session_id, up to 3 attempts.9.1 Why there are two jobs
handle_plate_detect runs the quality gate and detects on every frame. If there is nothing to read it finishes early (saving OCR time). Otherwise it caches the decoded frames and boxes in RAM (burst_frame_cache) and atomically replaces itself with a PLATE_OCR job (enqueue_successor). handle_plate_ocr then picks up the cached frames (disk is the fallback), crops the best box in each, runs OCR, consensus and validation, and calls _finish_scan.
9.2 Retry, timeout and retake are three different mechanisms
| Mechanism | Handles | Behaviour |
|---|---|---|
| Job retry | Transient exceptions | Up to jobs.max_attempts (5) with exponential back-off (base 2 s), then the job is dead |
| Inference timeout | Runaway or overloaded processing | Cooperative wall-clock check (check_inference_timeout) at the start of each job and between frames; over 120 s the job is killed immediately with processing_timeout, the session fails, no auto-retry |
| Operator retake | Bad image, not a system fault | Session becomes NEEDS_RETAKE with a machine retake_reason and a human operator_message (for example "Image too blurry - hold steady and capture again"); the client resubmits with the same session_id |
The JobWorker claims jobs fairly: after inference_burst_before_webhook (8) consecutive inference jobs it gives due webhook jobs one turn so outbound delivery cannot starve.
10. Flow F3 - Synchronous plate endpoints
POST /api/v1/plate, /plate/view (with annotated and crop images), /plate/detect (boxes only) and /plate/ocr (OCR on a supplied crop) run inline on a worker thread (asyncio.to_thread) so the event loop stays free. They use model set A directly, so concurrency is limited to one inference at a time per GPU. They are intended for testing, calibration and diagnostics; production traffic should use F1 or F2. They are covered by the sync rate limit (60 per minute).
11. Flows F4 - F6: WMS integration
11.1 Bridge push to the WMS (F4)
flowchart TB
P["PersistWorker saved the row"] --> S{"status in push_statuses<br/>and Bridge enabled?"}
S -- "no" --> X["skip push"]
S -- "yes" --> B["build_bridge_payload()<br/>share flags: session, plates,<br/>errors, timing, images"]
B --> M{"payload_mode"}
M -- "metadata" --> J1["JSON body"]
M -- "base64" --> J2["JSON body + images{}"]
M -- "multipart" --> J3["multipart: meta JSON + JPEG files"]
J1 --> POST
J2 --> POST
J3 --> POST["POST base_url + mode path<br/>timeout_s, max_attempts (3)"]
POST --> A{"2xx AND ack_ok?<br/>received = true and<br/>length == bytes sent"}
A -- "yes" --> OK["delivered"]
A -- "no" --> G["GET last-session path:<br/>already stored?"]
G -- "yes" --> REC["recovered_via_get_last"]
G -- "no" --> RT{"attempts left?"}
RT -- "yes" --> POST
RT -- "no" --> FL["failed - use POST<br/>/bridge/sessions/{id}/retry"]- Configured entirely in the Verify Console Settings (stored in operator prefs): base URL, API key, paths per mode, timeout (10 s), attempts (3), which statuses to push, which data groups to share (session, plates, errors, timing) and which images (front, rear, top, annotated, crop).
- Acknowledged delivery. A push counts as delivered only when the response is 2xx and the body says
received: truewith alengthequal to the number of bytes sent. If the POST fails, the node checks the WMS's "last session" endpoint in case the row already arrived (recovered_via_get_last). - Manual retry.
POST /api/v1/bridge/sessions/{session_id}/retryre-pushes a stored session.
11.2 Weighment result and the RST frame (F5) / 11.3 SKIP alert (F6)
%%{init: {"sequence": {"width": 200, "height": 46, "actorMargin": 60, "messageMargin": 34, "boxMargin": 8, "noteMargin": 10, "wrap": true, "useMaxWidth": true}}}%%
sequenceDiagram
autonumber
participant CTL as Boom controller
participant SER as SerialTriggerReader
participant API as ALPR Edge API
participant WMS as WMS / weighment software
Note over API,WMS: A - weighment result closes the loop
API->>WMS: Bridge push (session_id, plates, status)
Note over WMS: capture weight, decide Success / Failed
WMS->>API: POST /api/v1/webhook/weighment {session_id, status}
Note over API: look up session (not rejected if missing), audit weighment_status
alt status = Success
API->>SER: send_rst()
SER->>CTL: RST + CRLF (bytes 52 53 54 0D 0A)
API-->>WMS: 200 rst_sent = true
else status = Failed
API-->>WMS: 200 rst_sent = false (RST suppressed)
end
Note over CTL,WMS: B - vehicle skipped weighment
CTL->>SER: ALERT:SKIP:LEFT (heartbeat SYS:ALIVE:... ignored)
Note over SER: parse_alert_line, debounce 3 s
SER->>API: dispatch_skip_alert() on alert pool
API->>WMS: POST {base_url}{skip_alert_path} weighment_skip
WMS-->>API: any 2xx = accepted (retried up to max_attempts)
Note over API: audit weighment_skip_alert| Item | Detail |
|---|---|
| Weighment request | POST /api/v1/webhook/weighment {"session_id": "...", "status": "Success"|"Failed"}. Status is case-insensitive; booleans and 1/0, yes/no, pass/fail are also accepted |
| Response | {session_id, weighment, session_found, rst_sent, rst_detail}. An unknown session does not reject the call: the RST is still sent and the event audited. rst_sent=false with "serial reader not connected" means the UART trigger is disabled or the port is closed |
| RST frame | Default RST + \r\n = bytes 52 53 54 0D 0A. Command and terminator are operator settings |
| SKIP detection | A controller line of the form <alert_prefix>:<skip_string>[:<direction>] (default ALERT:SKIP:LEFT). Heartbeats such as SYS:ALIVE:SKIP_ALARM have no alert prefix and are ignored. Repeats are de-duplicated for 3 s |
| SKIP delivery | POST {Bridge base_url}{skip_alert_path} (default /api/v1/alerts/skip), JSON with event="weighment_skip", direction, IST timestamp, raw line. Any 2xx is accepted; retried up to max_attempts; the WMS handler must be idempotent |
| Audit | Rows weighment_status (actor wms) and weighment_skip_alert (actor hardware) |
12. Where the time goes
This section answers "why does a verification take the time it does" for both a steady-state trigger and the exceptional cases.
12.1 Steady-state timeline of one booth trigger
gantt title One warm booth trigger (illustrative, ms after trigger) dateFormat x axisFormat %L section Capture thread Deadline wait (capture delay, 0 by default) : 0, 5 latest_frame x3 + JPEG encode : 5, 45 section Detect thread (single) Detect front camera : 45, 125 Detect rear camera (queued behind front) : 125, 215 section OCR thread (single) OCR front : 125, 195 OCR rear : 215, 285 section Finalize Aggregator match + persist queue : 285, 300 SQLite write with base64 images : 300, 325
Two structural observations explain most of the total:
- Detection is serialised. There is one detect thread, so the rear camera's detection starts only after the front's finishes (its
queue_wait_msin the logs is about equal to the front's duration). OCR of the front camera overlaps with detection of the rear camera, because they run on different threads. - Everything after OCR is small. Matching is microseconds, the SQLite write is tens of milliseconds (it stores base64 images), and the Bridge push is off the critical path.
12.2 Time budget by block
| Block | Typical warm cost | Dominant reason | Levers |
|---|---|---|---|
| Deadline wait | 0 ms default | Deliberate business delay (capture_delay_ms) so the vehicle reaches the right position | Operator setting per camera (0 or 5-600000 ms) |
| Frame dip + JPEG encode | ~35-65 ms | 4K frame copy and JPEG/base64 encoding for up to 3 cameras (~2 MB each) | Lower camera resolution / jpeg_quality; only roles in the phase's mask are captured |
| Detect (per camera) | 60-90 ms (YOLO dev host); ~290 ms Jetson RF-DETR CUDA | Resize of a large frame, network forward, host↔device transfer | Smaller imgsz, YOLO TensorRT .engine, lower camera resolution |
| OCR (per camera) | 60-80 ms fast host; ~200-500 ms Paddle CPU on Jetson; recognizer alone ~5 ms on TensorRT | Text detection (DBNet) + recognition per line; CPU vs GPU backend | Use backend=tensorrt + use_det=true on Jetson; keep blur_retry_on_fail off unless needed (+~0.8 s when it fires) |
| Output delay | 0 ms default | Deliberate hold before the result becomes visible / pushed (front camera's setting) | Operator setting |
| Persist | ~20-30 ms | SQLite insert with base64 images | Retention, image sharing choices |
| Bridge push | network dependent, off-path | WMS latency, up to 3 attempts × 10 s timeout | Bridge timeout / attempts settings |
12.3 End-to-end figures
| Scenario | Observed |
|---|---|
| Booth verify, warm, dev host (YOLO + Paddle, 4K clip) | ~743 ms single pass; sequential p50 ~749 ms, p95 ~1.3 s |
| Booth verify, log samples, dev host | ~200 - 330 ms processing duration |
| Booth verify, live run for this document (12 consecutive triggers, dev host, demo footage) | Processing duration median ~295 ms, range 246 - 1444 ms (the two slowest were the cold first OCR and one CPU-contended run). 2 of 12 triggers produced a passing read because the demo clip only shows a readable plate in some frames; the rest were correctly reported as both_fail (input, not defect) |
| Booth verify, Jetson (RF-DETR + TensorRT recognizer, earlier build) | ~370 - 585 ms |
POST /api/v1/plate on Jetson | 0.55 - 1.5 s warm; ~2.1 s for the first (cold) call |
| First booth trigger after restart (set B cold) | Dev host: 855 ms vs 250-350 ms warm (measured). Jetson: expected higher, not yet measured - see Section 6.3 |
12.4 Concurrency and back-pressure
- Each verify stage is a single thread: throughput is roughly one trigger per (detect×2 + OCR) time. The capture queue holds up to 2 triggers not yet picked up by the capture thread; a further trigger gets
503 TRIGGER_BUSYinstead of building an unbounded backlog. Safe sustained rate measured on the dev host: one trigger at a time. - Stage queues (detect 8, OCR 8, module2 4, persist 16) time out enqueue after 1-2 s; a full queue marks that camera errored (
PIPELINE_BACKPRESSURE) rather than blocking. - The worst-case SLA (default 5 s, automatically raised to at least max capture delay + 30 s when delays are configured) is enforced by the reaper: anything older is finalised as
error / SLA_TIMEOUT. - Async scans queue in SQLite without bound;
/readyreturns 503 when pending jobs exceed 64 so a load balancer can drain the node.
13. Low-level architecture
13.1 Package and module map (src/alpr_edge/)
| Package | Key modules | Responsibility |
|---|---|---|
api/ | app.py, service.py, vehicle_verify.py, schemas.py, auth.py, rate_limit.py, camera_registry.py, preview.py, error_codes.py, log_stream.py, ui.html, verify_ui.html, main.py | FastAPI application (routes, middleware, lifespan), pipeline singleton and warm-up, verify glue, stable error codes, in-memory log ring, both web UIs; alpr-serve entry point |
trigger_pipeline/ | orchestrator.py, supervisor.py, capture_worker.py, detect_worker.py, ocr_worker.py, module2_worker.py, aggregator.py, persist_worker.py, lane_session.py, match.py, queues.py, models.py | The booth verify engine (F1) |
serial_trigger/ | reader.py, parse.py | UART reader thread, line parsing, debounce, RST writer, serial monitor buffer, SKIP alert parsing |
plate/ | pipeline.py, detector.py, detector_rfdetr.py, ocr.py, ocr_det_onnx.py, ocr_rec_onnx.py, ocr_trt.py, ocr_paddle_det_rec.py, ocr_layout.py, preprocess.py, quality.py, crop_rectify.py, sanitize.py, region.py, fuzzy.py, validate.py, consensus.py, selection.py, outcome.py, _torch_first.py | Recognition core (no HTTP, no DB) |
jobs/ | store.py, handlers.py, worker.py, timeout.py, burst_cache.py, models.py | Durable job queue and JobWorker for F2 |
session/ | manager.py, store.py, models.py | Scan session state machine (RECEIVED → ... → COMPLETED / FAILED / NEEDS_RETAKE), event timeline, output-delay buffering, TTL purge |
history/ | schema.py, store.py, vehicle_verify.py, record.py, retention.py | SQLite schema and access: pass / fail history, audit, verify calls, retention sweep |
capture/ | service.py, rolling_buffer.py, shared.py, opencv_file.py, opencv_webcam.py, opencv_stream.py, burst.py, preview.py | Camera sources, reader threads, 30-frame ring buffer, pooled readers, MJPEG preview |
outbound/ | bridge.py, alert.py, webhook.py | Bridge push, SKIP alert, HMAC-signed webhooks |
config/ | settings.py, prefs.py | Static settings (AppSettings) and operator runtime prefs (ClientPrefs, BridgePrefs, SerialTriggerPrefs) |
trigger/, events/, main.py | - | Dev / edge orchestrator (alpr-edge): manual or immediate trigger → burst → EventQueue → PLATE_DETECT job (F9) |
cli/, uploads.py, logging_setup.py, bench/ | - | plate and alpr-verify CLIs, upload validation, rotating logs with query-secret redaction, benchmark tooling |
13.2 Thread inventory
| Thread(s) | Started by | Purpose | Stops on |
|---|---|---|---|
| uvicorn event loop | uvicorn | HTTP handling; blocking routes use worker threads | shutdown |
capture-<camera_id> (one per distinct source) | CameraCaptureService.start | Read frames continuously into the ring buffer | camera close |
trigger-capture / detect / ocr / module2 / persist | PipelineSupervisor.start | Booth verify stages | shutdown (capture and persist force-drain pending work first) |
trigger-reaper | Supervisor | SLA and stale sweeps, worker restarts | shutdown |
job-worker-<device> | JobWorker.start | Async scan jobs; one per GPU in multi-GPU mode | shutdown |
serial-trigger | SerialTriggerReader.start | Read UART, parse, fire events; reconnect with 0.5 s back-off growing to 5 s | prefs change / shutdown |
bridge-*, skip-alert-* (2 each) | Lazy thread pools | Outbound HTTP | supervisor stop |
alpr-retention-sweep (asyncio task) | lifespan | Purge every 300 s | shutdown |
13.3 Camera layer
- Sources: looping MP4 file (dev replay), USB webcam (OpenCV index), and named RTSP / HTTP streams (OpenCV, with an FFmpeg pipe reader; reconnect cool-down 2 s; bare
http://host:8080auto-tries/videoand/videofeed). Camera settings (source mode, URLs, fps, width, height) live in operator prefs and hot-reload without restart. - Ring buffer: each reader pushes every frame into a 30-frame
RollingBuffer;latest_frame()is a lock-protected read of the newest entry, which is why a trigger costs almost nothing. - Shared readers: camera ids resolving to the same file or URL share one reader (
capture/shared.py, ref-counted). This removed a bug where three decoders on one MP4 drifted to three playback positions at triple the CPU cost. - Roles:
capture.plate= front,capture.back= rear,capture.material= top.
13.4 Data model
erDiagram
sessions ||--o{ session_events : "stage timeline"
sessions ||--o{ jobs : "work items"
sessions ||--o{ scan_results : "reads"
jobs ||--o{ webhook_deliveries : "attempts"
sessions {
text session_id PK
text status
text site_id
text lane_id
text current_stage
real processing_time_ms
}
session_events {
text session_id
text stage
text status
real duration_ms
}
jobs {
text id PK
text type "plate_detect, plate_ocr, webhook_push"
text status "pending, running, done, failed, dead"
int attempts
text next_run_at
text last_error
}
scan_results {
text session_id
text plate_text
real confidence
text confidence_tier
text validation_status
}
scan_pass {
text session_id PK
text plate_text
real confidence
}
scan_fail {
text session_id PK
text status
text image_base64
}
webhook_deliveries {
text job_id
text url
int response_status
int ok
}
audit_log {
text event_type
text actor
text session_id
text detail_json
}
vehicle_verify_calls {
text id PK
text client_session_id UK
text status "pass, fail, error"
real accuracy
text ocr_front
text ocr_back
text image_front
}
client_prefs {
text camera_id PK
text payload_json
}data/alpr.db. One file shared by all stores (the verify store uses WAL journal mode). Only key columns are shown.| Table | Written by | Retention default |
|---|---|---|
vehicle_verify_calls | PersistWorker (F1). Contains metadata, base64 images, audit_json (capture timing, camera errors, output delay, lane candidates) | 14 days (JSON + images together) |
sessions, session_events | SessionManager (F2) | Live sessions 3600 s TTL |
scan_pass / scan_fail / scan_results | _finish_scan (F2); fail rows keep the best frame as base64 | pass 15 d, fail 1 d |
jobs, job_queue_metrics, webhook_deliveries | Job store (F2) | 14 days |
audit_log | Settings changes, weighment status, SKIP alerts, retakes, edits | 30 days |
client_prefs | Settings API (per-camera prefs, Bridge, serial trigger) | Until changed |
13.5 Configuration model
| Layer | What lives here | Changed by |
|---|---|---|
Environment / .env | Highest priority. ALPR_ prefix, __ for nesting, e.g. ALPR_PLATE__OCR__BACKEND=tensorrt; auth secrets; bind host/port; ngrok token | Deployment |
config/plate.yaml | Install defaults: models, thresholds, queues, SLAs, retention, camera sources | Engineering (needs restart) |
| Operator prefs (SQLite) | Per-camera burst / delays / enable, camera source overrides, Bridge, UART parsing, JPEG quality, retention override, SLA override | Operators via PUT /api/v1/settings or the Verify Console; hot-applied |
| Model defaults | pydantic defaults | Code |
13.6 Error model
| Where | Values |
|---|---|
Verify status | processing, pass, fail, error |
Verify error_code | ALL_CAMERAS_FAILED, SLA_TIMEOUT, PIPELINE_BACKPRESSURE, SHUTDOWN, CAPTURE_ERROR |
Per-camera camera_errors | capture_failed, no_detection, ocr_failed, validation_rejected, detect_error, ocr_error |
Async scan error_code | processing_timeout, no_detection, ocr_failed, quality_fail, retake_required, job_dead, session_not_found, invalid_upload, upload_too_large, rate_limit_exceeded, unauthorized, internal_error ... |
| HTTP | 202 accepted, 400 bad input, 401 auth, 404 unknown session, 429 rate limit, 503 TRIGGER_BUSY / not ready |
13.7 Failure and recovery behaviour
| Failure | Behaviour |
|---|---|
| One camera returns no frame | That role is marked capture_failed; the other role can still produce front_only / back_only |
| All cameras fail | Session finalised as error / ALL_CAMERAS_FAILED |
| Camera stream drops | Reader reconnects (2 s cool-down); ring buffer keeps the last frames |
| A pipeline worker thread dies | Reaper logs worker_dead at CRITICAL and restarts it |
| Stage queue full | Bounded wait, then PIPELINE_BACKPRESSURE for that camera; top camera never fails a session |
| Session takes too long | Reaper finalises it as error / SLA_TIMEOUT |
| SQLite write fails | Retried once; then a metadata-only stub row with persist_stub=true is written so the poll has a durable terminal status; dropped_writes counts total loss |
| Bridge / WMS down | Up to 3 attempts, GET-last recovery, manual retry endpoint; never blocks the pipeline |
| Serial port unplugged | Status reconnecting, back-off retry; RST returns serial reader not connected |
| Shutdown mid-flight | Capture aborts pending triggers with SHUTDOWN; persist force-drains held tasks (skipping output delay, bounded to 5 s, then stubs) |
| Process crash | systemd restarts after 5 s. Job queue and stored results survive. In-flight booth sessions held only in memory are lost; the client's poll returns 404 for them and should re-trigger |
GPU model cannot load with gpu_strict | Service refuses to start rather than silently running slow on CPU |
13.8 Security model
- Authentication: shared API key (
X-API-Key) and/or JWT fromPOST /api/v1/auth/token. Off by default on loopback; a non-loopback bind refuses to start without auth enabled, an API key and a JWT secret. MJPEG<img>streams authenticate with a short-lived JWT in?api_key=, and access logs redactapi_key/tokenquery parameters. - Input hygiene: uploads are checked by magic bytes, limited to 10 MiB and 40 megapixels, and re-encoded so original bytes are never stored; session ids must match a safe pattern (no path traversal).
- Abuse limits: in-process rate limits (sync scans 60/min, capture 20/min, token 20/min) plus nginx
client_max_body_size 16m; CORS allowlist empty by default. - Outbound integrity: webhooks are HMAC-signed.
- Host hardening: systemd unit uses
NoNewPrivileges,PrivateTmp,ProtectSystem=full, and writes only todata/,logs/,models/. Secrets belong only in the device.env, never in git.
13.9 Operator interfaces
Screenshots and an element-by-element description are in Section 4.2.
| UI | URL | Contains |
|---|---|---|
| Dashboard | / | Single-image upload, video burst capture, server camera select with live POV, batch upload, retake guidance, manual review (HITL edit), history |
| Verify Console | /verify | Tabs: Preview (MJPEG placement views), Capture (manual trigger, delays), Results, Images (annotated / crop, lightbox), Audit (live processing log and audit trail), Settings (cameras, delays, Bridge, UART, serial monitor) |
14. API reference (summary)
All routes are under /api/v1 unless shown. Full request / response schemas are served live at /docs (OpenAPI) and mirrored in the Postman collections shipped with the project.
| Group | Endpoint | Purpose |
|---|---|---|
| Booth verify (F1) | POST /webhook/verify | Trigger; 202 + session_id |
GET /webhook/verify/calls/{session_id} | Status and result (no images) | |
GET /webhook/verify/calls | List; filters day (IST), status, limit, offset | |
GET /webhook/verify/calls/{session_id}/image | Images: camera=front|rear|top|all, annotated, crop | |
POST /webhook/weighment | WMS weighment result (F5) | |
POST /bridge/sessions/{session_id}/retry | Re-push a stored session to the Bridge | |
| Async scans (F2) | POST /scans | Upload 1-16 frames; 202 ref_id |
GET /refs/{ref_id}, GET /webhooks/{id} | Poll scan status | |
PUT /edit/{session_id} | Human edit of a failed read | |
POST /sessions, GET /sessions/{id}, PATCH .../status, POST .../complete, .../fail, GET .../events | Session management and timeline | |
GET /history, /pass, /fail, /history/{id}, /jobs | Results and job inspection | |
| Sync plate (F3) | POST /plate | Full recognition |
POST /plate/view | Recognition + annotated and crop images | |
POST /plate/detect | Boxes only | |
POST /plate/ocr | OCR on a crop | |
| Cameras | GET /cameras | List cameras |
POST /cameras/{id}/open|close | Camera lifecycle | |
POST /cameras/{id}/capture | Shoot a burst and enqueue an async scan (202) | |
GET /cameras/{id}/preview, /stream | JPEG and MJPEG preview | |
GET /logs | Recent processing log (ring buffer; scope=audit filters chatter) | |
| Settings / ops | GET|PUT /settings | Runtime prefs |
GET /settings/serial-ports, /settings/serial-monitor | Enumerate ports; live rx/tx monitor | |
POST /settings/bridge/test, POST /webhooks/test | Connectivity tests | |
GET /audit | Audit trail (IST day or date range) | |
| Probes (no prefix) | /healthz, /ready, /health | Liveness; readiness (503 when detector not ready, GPU strict violated, or queue over threshold); detailed status incl. models, queues, pipeline health, retention counters |
| Auth | POST /auth/token | Mint a JWT from the API key |
15. Configuration reference (key knobs)
| Setting | Default | Effect |
|---|---|---|
plate.detector.backend | yolo | yolo or rfdetr (ONNX FP32 CUDA) |
plate.detector.conf_threshold / iou / imgsz / max_det | 0.10 / 0.45 / 640 / 3 | Detection sensitivity and cost |
plate.detector.min_box_height / min_box_aspect / max_box_aspect | 40 px / 1.5 / 8.0 | Geometry filter (RF-DETR uses its own aspect override, min 1.0) |
plate.ocr.backend / use_det | paddle / true | Jetson: tensorrt + true |
plate.ocr.rec_min_conf | 0.5 | Drop weak tokens |
plate.gpu_strict | false | true on Jetson: never fall back to CPU; refuse to start instead |
plate.validation.min_plate_chars | 8 | Reject fragments |
plate.quality.* | lap 50, luma 15-245 | Quality gate thresholds |
trigger_pipeline.match_confidence_threshold | 85.0 | PASS threshold (0-100) |
trigger_pipeline.capture_queue_size / detect / ocr / module2 / persist | 2 / 8 / 8 / 4 / 16 | Back-pressure sizes |
trigger_pipeline.sla_typical / sla_worst_case | 2.0 s / 5.0 s | Warn / force-error thresholds |
capture.bidirectional_lane | false | Whether direction swaps front and rear |
Per-camera capture_delay_ms, output_delay_ms | 0 | 0 or 5-600000; SLA is raised automatically |
serial_trigger.* (operator) | disabled, 9600 baud, debounce 1500 ms | Port, baud, strings, template / regex, lane TTL, RST command, alert prefix, skip debounce |
bridge.* (operator) | disabled | URL, key, mode, paths, timeout 10 s, attempts 3, share flags, push statuses, skip alert |
jobs.inference_timeout_seconds / max_attempts | 120 / 5 | Async scan timeout and retries |
session.*_retention_days | pass 15, fail 1, audit 30, verify 14 | Retention; 0 = keep forever |
auth.enabled | false | Mandatory on non-loopback binds |
rate_limit.* | 60 / 20 / 20 per minute | Sync scans / capture / token |
The complete environment-variable list is in docs/ENV_REFERENCE.md.
16. Operations runbook
16.1 Deployment (Jetson)
deploy/setup_env.sh- system packages,uvvirtual environment, install project with the OCR extra, seed.env.deploy/setup_gpu_jetson.sh- Jetson-specific PyTorch / ONNX Runtime GPU wheels; writes GPU device and strict flags into.env.deploy/pull_models.sh- unpack YOLO weights and the bundled RF-DETR package intomodels/; TensorRT engines are built on the board (deploy/export_tensorrt.sh), never copied from another machine.- Edit
.env(auth key and JWT secret before any LAN bind). sudo ./deploy/bootstrap_production.sh- installs and enables thealpr-servesystemd service and nginx; reboot-safe.deploy/run.shruns it in the foreground for debugging.
16.2 Commissioning checklist
GET /healthz= alive;GET /ready= 200.GET /health: confirmmodelslabels (e.g.rfdetr/CUDAExecutionProvider/fp32andPP-OCRv4-TRT+det),gpu_models_ok,trigger_pipeline.workers_aliveall true.- Verify Console → Preview: all three cameras live and correctly placed.
- Verify Console → Settings: set serial port, strings / template, Bridge URL and key; use Test and the serial monitor.
- Fire one throw-away verify trigger to warm set B (Section 6.3), then run real triggers.
- Confirm the WMS receives the Bridge push and that
POST /webhook/weighmentproduces an» RSTline in the serial monitor.
16.3 Reading the logs
Every stage logs one line with a stable key format, which makes latency analysis a matter of grep:
stage=capture session_id=… duration_ms=66.5 queue_wait_ms=17.9 front=True back=True top=True extracted_ms=front:17.9/back:17.9 stage=detect session_id=… camera=front duration_ms=79.5 queue_wait_ms=0.1 crops=1 stage=ocr session_id=… camera=front duration_ms=58.3 queue_wait_ms=0.2 candidates=1 text=UK07CA9035 ok=True stage=persist session_id=… duration_ms=23.4 queue_wait_ms=6.7 status=pass output_delay_ms=0.0 trigger.finished session_id=… status=pass match_outcome=both_pass_match ocr_confidence=90.8 processing_duration_ms=311.9 phase=mid
duration_ms is the time in the stage; queue_wait_ms is time spent waiting for that stage's thread, which is the number to watch under load. Logs rotate at 10 MiB (10 backups, 14 days) under logs/; the live tail is at GET /api/v1/logs and in the Console Audit tab.
16.4 Housekeeping
At startup and every 300 s the retention sweep deletes expired sessions, scan history, audit rows, verify rows (with their images), jobs and webhook deliveries, rotated logs, debug dumps older than 3 days and orphan upload folders, then VACUUMs the database when rows were removed. Counters from the last sweep are in /health → retention.
16.5 Troubleshooting guide
| Symptom | Likely cause | Where to look |
|---|---|---|
| nginx 502 right after restart | Models still loading (expected) | Log lines Loading plate pipeline → Pipeline ready |
First trigger after restart is slow or SLA_TIMEOUT | Verify workers (set B) build models on first use | Section 6.3; send a warm-up trigger |
503 TRIGGER_BUSY | Triggers arriving faster than the pipeline drains, or a lane is still open | /health → trigger_pipeline.queues; lane TTL |
Detector loads but every frame is no_detection | Paddle imported before torchvision, or plate smaller than min_box_height, or unusual aspect | Section 6.1 callout; Verify Console Images (annotated) |
| Front and rear labels look swapped | Bidirectional lane setting vs physical wiring | capture.bidirectional_lane, direction_unknown in timing |
rst_sent=false | UART disabled or port closed | Settings → Hardware trigger; serial monitor |
| WMS not receiving results | Bridge disabled / URL / ACK format | Settings → Bridge → Test; audit log; retry endpoint |
| Service will not start on Jetson | gpu_strict=true and a GPU model is missing (rec.engine, det.onnx, RF-DETR CUDA provider) | Startup error text names the missing file or provider |
| Disk filling | Retention disabled or long retention with large images | /health → retention; retention days; JPEG quality |
17. Limitations, considerations and roadmap
Current limitations (be aware when planning)
- Single lane per process, SQLite per node. Scale by adding nodes; there is no shared central store, so a poll must reach the node that took the trigger.
- Two model instance sets (verify vs async / sync) double model memory; first booth trigger after restart is cold (Section 6.3).
- Verify sessions in flight are in memory. A process crash loses them; clients should re-trigger on 404.
- Serialised stages. One detect thread and one OCR thread per node; front and rear detections do not run in parallel.
- Recognition edge cases. Very small plates (below the 40 px height floor), heavy glare, stylised fonts, decorative symbols beside characters, and two-row square-ish truck plates outside the configured aspect range can fail or need a retake. These are input-quality issues, and the system fails closed (no guess) rather than reporting a wrong plate with high confidence.
- Top-camera analysis is a documented stub; the frame is captured and stored but not classified.
- Camera sources: no GStreamer / CSI source yet; RTSP needs FFmpeg present.
- Dependency locking: the checked-in
uv.locktargets Windows; Jetson uses pinned JetPack wheels installed by the deploy scripts. On JetPack 7.2 there is no official Paddle GPU wheel, hence the TensorRT / ONNX OCR path.
Roadmap candidates
- Warm the verify models (set B) during startup or share set A with the trigger pipeline.
- Top-camera model behind the existing
run_module2_tophook (material type / fill level). - Parallel front / rear detection (batched or two detector workers) to cut steady-state latency.
- Shared store and load balancing across nodes; durable outbox for verify sessions.
- Optional fail-gated blur retry for difficult plates, subject to re-benchmarking.
Appendix
A. Glossary
| Term | Meaning |
|---|---|
| ALPR / ANPR | Automatic License / Number Plate Recognition |
| HSRP | High Security Registration Plate (Indian standard plate with hologram / IND marking) |
| RTO | Regional Transport Office code in the plate (e.g. the "12" in MH12) |
| BH series | Bharat series plate format (YY BH NNNN XX) |
| Front / Rear / Top | Logical camera roles: capture.plate, capture.back, capture.material |
| Session | One vehicle transaction; session_id ties capture, plates, images and WMS records together |
| Lane session | In-memory ENTRY → MID → EXIT record for the vehicle currently in the booth |
| Aggregator | The fan-in object that waits for capture, front OCR, rear OCR and top results, then finalises |
| Bridge | Client-side receiver that the edge pushes verify results to (the WMS integration endpoint) |
| RST | Reset frame sent to the boom controller on a successful weighment |
| SLA | Maximum time a session may stay processing before it is forced to error |
| gpu_strict | Fail-closed mode: no silent CPU fallback for detector or OCR |
| TRT / ORT | NVIDIA TensorRT / ONNX Runtime |
| CTC | Connectionist Temporal Classification - the decoding scheme used by the text recogniser |
B. Where to find things in source
| Question | Start reading at |
|---|---|
| What happens at startup? | api/app.py → lifespan(); api/service.py → get_pipeline(), warmup_pipeline() |
| How is a booth trigger accepted? | api/app.py → vehicle_verify(); trigger_pipeline/orchestrator.py |
| Where are frames captured and delayed? | trigger_pipeline/capture_worker.py (_admit, _collect_due, _process) |
| Where is the plate found and read? | detect_worker.py, ocr_worker.py; plate/detector.py, plate/ocr.py |
| How is PASS / FAIL decided? | trigger_pipeline/match.py; aggregator.py → _finalize_locked |
| How is a plate validated? | plate/validate.py, plate/fuzzy.py, plate/sanitize.py |
| How do UART lines become events? | serial_trigger/parse.py, serial_trigger/reader.py, trigger_pipeline/lane_session.py |
| What is pushed to the WMS? | outbound/bridge.py (build_bridge_payload, BridgeClient), outbound/alert.py |
| How do async scans run? | api/service.py (enqueue_async_scan_from_arrays), jobs/worker.py, jobs/handlers.py |
| Which settings exist? | config/settings.py, config/prefs.py, config/plate.yaml |
C. UART line reference
| Direction | Line | Meaning | Edge action |
|---|---|---|---|
| Controller → edge | EVT:LEFT_ENTRY (or configured ENTRY string + direction) | Vehicle at entry | Lane ENTRY, snap front + rear |
| Controller → edge | EVT:VEH_ON_BRIDGE:LEFT | Vehicle on bridge | Lane MID, capture all three, persist |
| Controller → edge | EVT:LEFT_EXIT | Vehicle leaving | Lane EXIT, clear lane |
| Controller → edge | ALERT:SKIP:LEFT / RIGHT | Weighment skipped | POST SKIP alert to WMS |
| Controller → edge | SYS:ALIVE:... | Heartbeat | Ignored (visible in serial monitor) |
| Edge → controller | RST\r\n | Weighment success acknowledgement | Sent after POST /webhook/weighment Success |
Every string, the template / regex, the RST command and terminator, the alert prefix and skip token are operator settings, so the lines above are defaults and examples; they can be aligned to the client's controller firmware without code changes.
D. Companion documents in the project
docs/ARCHITECTURE.md,docs/features.md,docs/ENV_REFERENCE.md,docs/security.mddocs/client-verify/:hld.md,lld.md,api-layman.md,integration-details.md, partner hardware / Bridge / Jetson guides,system-simulation.htmldocs/weighment-integration.md,docs/deployment-orin.md,deploy/README.md,postman/collections