Knowledge Transfer Guide

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.

What it isOne edge service that reads front and rear truck plates, also captures a top camera, cross-checks the reads and reports PASS / FAIL / ERROR to the client's weighment software.
Where it runsOn the gate unit itself (NVIDIA Jetson Orin Nano or a GPU PC) as one Python process behind nginx. No cloud dependency for inference.
How clients talk to itREST/JSON over HTTP, a USB-serial line to the IR / boom controller, and outbound HTTP pushes to the client's Bridge / WMS.
Product / service versionALPR Edge 0.2.0
Document date2026-09-25
AudienceClient engineering, integration, operations and support teams
BasisSource 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 stakeholderSections 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 architectSections 5, 6, 7, 13 (high-level and low-level architecture, model loading, pipeline internals)
Operations / supportSections 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)

2.4 Technology stack

ConcernTechnology
API / webFastAPI + uvicorn (Python 3.12), nginx reverse proxy, systemd service
Plate detectionUltralytics 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 handlingOpenCV (headless), NumPy
StorageSQLite (WAL for the verify store) - sessions, jobs, history, audit, verify calls, operator prefs
Configpydantic-settings: environment > .env > config/plate.yaml > defaults; plus operator prefs in SQLite
Hardware I/Opyserial (UART), OpenCV / FFmpeg (RTSP, HTTP, USB webcam, MP4 replay)
Outboundhttpx (Bridge push, SKIP alert, HMAC-signed webhooks)

3. Feature catalogue

AreaCapabilityStatus
RecognitionQuality gate (blur / dark / bright) before any GPU workShipped
Plate detection (YOLO11s) with geometry filter and rankingShipped
RF-DETR detector backend (ONNX, FP32, CUDA)Optional
PP-OCRv4 OCR on Paddle, ONNX Runtime or TensorRT; two-row plate layout; HSRP junk removalShipped
Indian plate validation: state/RTO check, BH series, fuzzy confusion repair (0/O, 1/I, 5/S, 8/B ...), plausibility scoreShipped
Multi-frame consensus (majority vote) and confidence tiersShipped
Booth verification3-camera verify: front + rear plates cross-matched, top camera capturedShipped
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 selectionShipped
Per-camera capture delay and output delay (ms), idempotent client session_idShipped
Top-camera classifier ("Module 2")Stub - hook in place
IntegrationBridge push to WMS: metadata / JSON+base64 / multipart, ACK check, recovery via GET-last, manual retryShipped
Weighment result POST /webhook/weighment → RST UART frameShipped
SKIP alert (ALERT:SKIP:<dir>) → WMS alert POSTShipped
Async scans (upload or camera capture) with job queue, retake logic, HMAC webhooksShipped
OperationsOperator dashboard (/) and Verify Console (/verify): preview, capture, results, images, audit, settings, serial monitorShipped
Health, readiness and detailed status endpoints; live log ring bufferShipped
Retention sweep (rows, images, logs, debug dumps, orphan uploads, VACUUM)Shipped
API key / JWT auth, rate limits, CORS allowlist, upload validation, log redactionShipped (auth off by default on loopback)
Human-in-the-loop edit of a failed readShipped
PlatformJetson Orin Nano GPU deployment scripts (TensorRT OCR, CUDA ONNX detector), systemd, nginx, optional ngrokShipped
Central fleet database / load balancing across nodesPlanned
GStreamer / CSI camera sourcePlanned

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.

#FlowTriggerClient seesDetail
F1Booth verify (3 cameras, fast-async)POST /webhook/verify or UART lane event202, then poll calls/{session_id}; images on demand; optional Bridge pushSection 8
F2Async scan (upload / camera capture)POST /scans or POST /cameras/{id}/capture202 ref_id, poll refs/{ref_id} or webhookSection 9
F3Sync plate (test / diagnostics)POST /plate, /plate/view, /plate/detect, /plate/ocr200 with the result in the responseSection 10
F4Bridge push to WMSAfter a verify row is persistedWMS receives session, plates, optional imagesSection 11.1
F5Weighment result → RSTWMS calls POST /webhook/weighment200 ack; controller receives RSTSection 11.2
F6SKIP alertController line ALERT:SKIP:LEFT|RIGHTWMS receives alert POSTSection 11.3
F7Operator UIsBrowserDashboard and Verify ConsoleSection 13.9
F8HousekeepingStartup + every 300 sBounded disk usage; /health retention countersSection 16.4
F9Dev edge orchestrator (alpr-edge CLI)Keyboard / scripted triggerBurst captured and fed to the same job queueSection 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
Figure 1 - End-to-end action workflow. Three actors: the physical site (sensors and boom), the ALPR Edge node, and the client's WMS.
StepActorActionCode blockResult
1Vehicle / IRVehicle reaches the entry sensor; controller writes an ENTRY lineSerialTriggerReader._runLine read from the USB-serial port
2EdgeParse line into phase + direction, debounce (1.5 s), open a lane sessionparse_trigger_line, submit_lane_event, LaneSessionStore.open_entryNew session_id; second ENTRY while lane is busy → TRIGGER_BUSY
3EdgeSnap front + rear cameras, detect and OCR both plates. Nothing is persisted yetcapture → detect → OCR workers, record_entry_platesFirst plate candidates stored on the lane session
4Vehicle / IRVehicle is on the bridge; controller writes the MID linesame readerMID lane event
5EdgeCapture 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 / FAILSessionAggregator._finalize_locked, merge_mid_and_pick, match_front_backPersistTask built
6EdgeHold for the configured output delay, write the row to SQLitePersistWorkerRow in vehicle_verify_calls, poll now returns terminal status
7Edge → WMSBridge push (plates, session_id, optional images), verify ACK, retry or recoverpush_after_persist, BridgeClient.post_sessionWMS knows the vehicle identity
8WMSReads the weight, decides Success / Failed, calls POST /webhook/weighmentclient side-
9Edge → controllerOn Success send RST\r\n over the same serial port; on Failed suppress itweighment_status, SerialTriggerReader.send_rstBoom / signal logic on the controller proceeds; audit row written
10Vehicle / IRVehicle leaves; controller writes EXITreader → submit_lane_event(exit)Lane cleared, no capture
AltController → Edge → WMSIf the vehicle skips weighment, controller writes ALERT:SKIP:<dir>; edge POSTs a SKIP alert to the WMSparse_alert_line, dispatch_skip_alertWMS 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).

http://gate-node/verify - 01 Preview
Figure A - Preview. Always-on camera feeds (MJPEG from GET /cameras/{id}/stream). Use it to confirm camera placement before going live.123456
Figure A - Preview. Always-on camera feeds (MJPEG from GET /cameras/{id}/stream). Use it to confirm camera placement before going live.
  1. Health strip: pipeline status, queue depth, in-flight sessions, P50 latency
  2. Tabs 01-06: Preview, Capture, Results, Images, Audit, Settings
  3. View filter: All / Front / Rear / Top, and Reload
  4. FRONT feed (physical plate camera, capture.plate) - click a cell to focus it
  5. REAR feed (capture.back)
  6. TOP feed (capture.material)
http://gate-node/verify - 02 Capture
Figure B - Manual capture. "Capture and process" issues the same 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.123456
Figure B - Manual capture. "Capture and process" issues the same 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.
  1. Live health strip (P50 348 ms in this run)
  2. Capture and process - trigger, then poll until terminal; the label shows "Success in 860 ms"
  3. Cameras at trigger time - the three frames dipped from the ring buffers
  4. Result strip: status, final_raw_text, ocr_confidence, match_outcome, session id and failure reason
  5. Client payload - the exact JSON a client receives (images shortened to their length)
  6. Frames from API - the stored JPEGs returned by the image endpoint
http://gate-node/verify - 03 Results
Figure C - Result history. Metadata only, filtered by IST day (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.1234
Figure C - Result history. Metadata only, filtered by IST day (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.
  1. Date (IST) and Load
  2. Result list: green bar = PASS, red bar = FAIL, with plate, session id, time and match_outcome
  3. A near-miss: matching plates but confidence 84.3 % < 85 = FAIL
  4. Session detail - click a row to inspect its full JSON
http://gate-node/verify - 04 Images
Figure D - Image fetch. Images are never part of the status payload; they are pulled on demand from GET /webhook/verify/calls/{session_id}/image.123456
Figure D - Image fetch. Images are never part of the status payload; they are pulled on demand from GET /webhook/verify/calls/{session_id}/image.
  1. Session id to look up
  2. Camera selector: All / Front / Rear / Top
  3. Optional overlays: Detection (bbox) and Plate crop (the annotated and crop query flags)
  4. Fetch - opens the request shown in 5
  5. The exact request that was issued
  6. Returned frames; a viewer opens on click. Payload size is shown below (about 2.9 million base64 characters per 4K frame)
http://gate-node/verify - 05 Audit
Figure E - Audit log. The in-memory log ring (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).1234
Figure E - Audit log. The in-memory log ring (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).
  1. Filters: current session, all events, follow, clear
  2. Startup warnings (for example auth disabled on loopback)
  3. Stage line: "Frames extracted from cameras"
  4. 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.

http://gate-node/verify - 06 Settings - Timing and Storage
Figure F - Timing and storage. Per-camera input delays and the output delay in milliseconds (0 or 5 ms to 10 minutes). The effective worst-case SLA (5.0 s here) is shown and is raised automatically when long delays are configured. Retention (days) and JPEG quality for stored frames.
Figure F - Timing and storage. Per-camera input delays and the output delay in milliseconds (0 or 5 ms to 10 minutes). The effective worst-case SLA (5.0 s here) is shown and is raised automatically when long delays are configured. Retention (days) and JPEG quality for stored frames.
http://gate-node/verify - 06 Settings - Client / Bridge push
Figure G - Client / Bridge push and Skip alert. Base URL, key, timeout (10 s) and attempts (3); transfer method Metadata, Base64 or Multipart with a POST path each; what-to-share groups; the WMS skip-alert switch and path (/api/v1/alerts/skip); connectivity tests Test POST and Test GET last. See Section 11.1 and 11.3.
Figure G - Client / Bridge push and Skip alert. Base URL, key, timeout (10 s) and attempts (3); transfer method Metadata, Base64 or Multipart with a POST path each; what-to-share groups; the WMS skip-alert switch and path (/api/v1/alerts/skip); connectivity tests Test POST and Test GET last. See Section 11.1 and 11.3.
http://gate-node/verify - 06 Settings - Hardware trigger (UART)
Figure H - Hardware trigger (UART). Port and baud, the ENTRY / MID / EXIT / LEFT / RIGHT strings, debounce (1500 ms), line template or regex, RST command and terminator, alert prefix, skip token and skip debounce (3000 ms), and the Open serial monitor button that shows live rx / tx lines. These fields let the edge node be aligned to any controller firmware without code changes (Appendix C).
Figure H - Hardware trigger (UART). Port and baud, the ENTRY / MID / EXIT / LEFT / RIGHT strings, debounce (1500 ms), line template or regex, RST command and terminator, alert prefix, skip token and skip debounce (3000 ms), and the Open serial monitor button that shows live rx / tx lines. These fields let the edge node be aligned to any controller firmware without code changes (Appendix C).

Operator dashboard (/) - single scans and batch work

http://gate-node/
Figure I - Dashboard. The original single-lane console (dark theme): upload an image or video, batch upload, and drive one server camera with Open / Capture / Close (an async scan, Section 9). Retake guidance and manual review of failed reads live here. A card links to the Verify Console.1234
Figure I - Dashboard. The original single-lane console (dark theme): upload an image or video, batch upload, and drive one server camera with Open / Capture / Close (an async scan, Section 9). Retake guidance and manual review of failed reads live here. A card links to the Verify Console.
  1. Choose image / video and Batch upload
  2. Camera controls: Open, Capture (burst), Close
  3. Live camera view (JPEG preview)
  4. 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" --> WMS
Figure 2 - System context. The edge node sits between the physical site (cameras, IR sensors, boom controller), the operators and the client's WMS.

5.2 Logical layers

1 · Interface layertalks to the outside world
FastAPI routes + middlewareapi/app.py
Auth · rate limit · CORSauth.py, rate_limit.py
Web UIsui.html, verify_ui.html
↓ calls
2 · Orchestration layerturns a trigger into work
Trigger pipelinetrigger_pipeline/
Async job queue + JobWorkerjobs/
UART reader + lane sessionsserial_trigger/
Session managersession/
↓ invokes
3 · Recognition coreimage in, plate out (no HTTP, no DB)
PlateDetectorYOLO11s or RF-DETR
PlateOCRPaddle / ONNX / TensorRT
Post-processingsanitize, layout, fuzzy, validate, consensus
↓ uses (also used by layer 2)
4 · Device and data layerhardware, storage, outbound, config
Camera readers + ring buffercapture/
SQLitedata/alpr.db (history/)
OutboundBridge, SKIP alert, webhook
ConfigYAML + env + operator prefs
Figure 3 - Layered architecture. Interface, orchestration, recognition core, device and data layers. All layers live in one process.
LayerResponsibilityMain packages
InterfaceHTTP routes, authentication, rate limiting, CORS, static UIsapi/
OrchestrationTurns a trigger into work: queues, workers, fan-in, sessions, lane state machine, job retriestrigger_pipeline/, jobs/, serial_trigger/, session/
Recognition corePure image-to-plate logic; no HTTP, no databaseplate/
Device and dataCameras and ring buffers, SQLite persistence, outbound HTTP, configurationcapture/, 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

Edge node · Jetson Orin Nano (JetPack) or GPU server
nginx:80 → 127.0.0.1:8000
client_max_body_size 16m, long-lived MJPEG streams
systemd · alpr-serve.servicestarts the process at boot
Restart=on-failure, 5 s
ONE Python process · uvicorn alpr_edge.api.app:app
asyncio event loopHTTP handlers; blocking routes use worker threads
Camera reader threadsone per distinct source, 30-frame ring buffer
Trigger pipeline5 worker threads + reaper
JobWorker1 thread (1 per GPU if multi-GPU)
Serial-trigger threadUART read / write, reconnect back-off
Bridge + Alert pools2 threads each, outbound HTTP
Retention sweepasyncio task, every 300 s
Files on diskdata/alpr.db (SQLite)
data/uploads, data/debug
logs/ (rotating)
models/YOLO .pt · RF-DETR .onnx
onnx_ocr/ det.onnx, rec.engine
External: cameras (RTSP / HTTP / USB) · USB-serial to controller · client WMS over HTTP
Figure 4 - Deployment topology. One systemd-managed Python process with a set of daemon threads; nginx terminates the public side.

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
Figure 5 - Startup sequence. Steps run in order inside the FastAPI lifespan; the server only starts accepting connections after step 7, until then nginx returns 502 (expected). Orange = time-consuming steps; red = the pipeline supervisor starts here but the verify models (set B) are deliberately not built yet (see 6.3).

6.1 What is loaded, from where, by whom

AssetFile (under models/)Loaded byHow
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.jsonRfdetrOnnxDetector._loadONNX 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.onnxOnnxTextDetectorORT CUDA session (never a TensorRT det engine)
OCR recognizer (Jetson)onnx_ocr/rec.engine (~5.5 MB)TrtPlateRecognizerTensorRT 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.onnxOnnxPlateRecognizerORT CUDA session; used when rec.engine is missing
PaddleOCR PP-OCRv4 (default backend)managed by PaddleOCRPlateOCR._init_paddlePaddleOCR(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).

Import-order rule (do not break)Paddle and torchvision both register an 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

PhaseWhat is actually happeningOrder of magnitude
Python importstorch, ultralytics, onnxruntime, paddle / paddleocr, OpenCV are large native libraries; the first import maps them into memory and initialises their kernelsseconds
Weights deserializationReading the .pt, building the network graph, loading ONNX graphs, deserializing TensorRT engines from disk into GPU memoryseconds (larger on Jetson's slower storage / shared memory)
CUDA context and provider initFirst CUDA call creates the context; ORT and TensorRT allocate workspaces; cuDNN picks algorithmsseconds on first use
Warm-up inferencewarmup_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 startCamera readers start threads and begin filling ring buffers; the pipeline supervisor starts 5 worker threads and a reapersub-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 setCreatedUsed by
Set A - get_pipeline() singleton: PlateDetector + PlateOCREagerly 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 objectsLazily: DetectWorker._ensure_detector() on its first task; OcrWorker._read_once() on its first readThe booth verify trigger pipeline only
What this means in practice
  • 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-away POST /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"]
Figure 6 - Recognition pipeline. Early exits (quality_fail, no_detection, ocr_failed) avoid wasting GPU time and give the operator a specific reason.

7.1 Block-by-block: what is called, when, and why it costs time

#Block (function)Called whenWhat it doesWhy it takes time / typical cost
1Quality gate
assess_frame_quality / assess_burst_quality
First, on the CPU, before any modelGrayscale, Laplacian variance (sharpness) and mean luma. A burst passes if any frame is usableMilliseconds. Saves a whole GPU pass when a frame is unusable
2Detector
PlateDetector.detect_all
Once per frame that passed the gateYOLO: 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 scoresLargest 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)
3Geometry filter + ranking
filter_and_rank_boxes
Immediately after detectionRejects boxes shorter than 40 px or outside the aspect range (bolts, chains, reflectors); ranks by confidence × √area; keeps at most 3Microseconds. A very small or unusual-shape plate is dropped here, which shows up as no_detection
4Crop
crop_with_padding
Per surviving boxAxis-aligned crop with 5 % paddingMicroseconds
5OCR
PlateOCR.read → read_detailed
Per crop (all crops are read; the best read wins)See 7.2Second-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
6Candidate selection
select_best_read
When more than one crop produced textRanks by: format valid, tier, OCR conf × detector conf, OCR conf, box areaMicroseconds; runs validation per candidate
7Consensus
build_consensus
Once per frame set (a single read in the booth flow)Groups reads by normalised text, majority vote weighted by confidence, checks RTO regionMicroseconds
8Validation
apply_validation → validate_plate_text
After consensusSanitise, reject fragments shorter than 8 characters, parse against the Indian formats (state, district, series, number; BH series), fuzzy repair, region check, plausibility score, confidence tierMilliseconds. Fuzzy repair enumerates up to 48 candidate strings
9Pass decision
is_plate_pass
Endstatus = 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)"]
Figure 7 - OCR internals. The GPU path (top) is the Jetson production route; Paddle (bottom) is the portable route.

7.3 Validation and confidence, explained

StepRule
SanitiseUppercase, keep A-Z and 0-9, remove IND / chakra / HSRP artefacts, merge two-line reads
Too shortFewer than min_plate_chars (8) → text withheld, tier reject, error too_short. The fragment is never shown as if it were a plate
ParseStandard 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 repairLook-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
RegionState/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 highvalid/corrected, OCR ≥ 0.85, detector ≥ 0.50, at most one correction, no low-consensus warning, plausibility ≥ 0.80
Tier mediumvalid/corrected/partial, OCR ≥ 0.65, plausibility ≥ 0.55
Tier low / rejectEverything 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

EntryHowPhases
HTTPPOST /api/v1/webhook/verify body {session_id?, phase?, direction?}phase omitted = legacy one-shot (front + rear + top in one go). phase present requires direction
UARTSerialTriggerReader parses a line (template such as {event},{direction} or a regex with event and direction groups) into entry | mid | exit + left | rightMulti-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 capture
Figure 8 - ENTRY / MID / EXIT sequence. ENTRY only gathers plate candidates; MID captures all three cameras, chooses the best read per role across both phases, and persists.
stateDiagram-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
Figure 9 - Lane session state. One active lane per edge unit. The lane expires after 120 s (lane_session_ttl_s) so a missed EXIT cannot block the booth forever.

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
Figure 10 - Trigger pipeline. Five single-thread stages connected by bounded queues, a fan-in aggregator, and a reaper.
StageThread nameReadsDoesWrites
Capturetrigger-capturecapture 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 outdetect queue (front, back), module2 queue (top), aggregator (set_capture)
Detecttrigger-detectdetect queue (8)detect_all, crop every box, draw the annotated image (JPEG)OCR queue (OcrTask with all crops) or aggregator no_detection
OCRtrigger-ocrOCR queue (8)Reads every crop; scores each as (passed validation, non-empty, confidence); keeps the winning crop; encodes itaggregator set_plate
Module 2trigger-module2module2 queue (4)run_module2_top(), the single extension point for a future top-camera model; today logs and returns processedaggregator set_top
Persisttrigger-persistpersist 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 entryvehicle_verify_calls, Bridge pool
Reapertrigger-reaper-Every 250 ms: SLA sweep, stale-finalised sweep (120 s), restarts any dead worker (logged worker_dead)aggregator
Delays are deadlines, not sleepsPer-camera 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
Figure 11 - Aggregator finalisation. A session finalises exactly once, when front, back and top are all "done" (success, failure or skipped), or when the reaper forces an error.

"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"]
Figure 12 - Front / rear match matrix (trigger_pipeline/match.py). The top camera never influences PASS / FAIL.
match_outcomeMeaningStatus
both_pass_matchFront and rear identical, or one is an OCR-confusion repair of the otherPASS if confidence ≥ 85
both_pass_fuzzy_matchEdit distance ≤ 1; the higher-confidence camera's text is usedPASS if confidence ≥ 85
front_only / back_onlyOnly one camera produced a validated readPASS if scaled confidence ≥ 85
both_pass_mismatchBoth read, plates differFAIL (forced, empty final_raw_text)
both_failNeither read is usableFAIL

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)
Figure 13 - Client-side sequence. The POST returns immediately. Polling returns processing until the row is written; images are fetched separately so status calls stay small.

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 --> POLL
Figure 14 - Async scan job chain. One accepted scan becomes two inference jobs (detect → OCR) plus an optional webhook job.
stateDiagram-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 --> [*]
Figure 15 - Scan session states. Soft failures ask the operator to retake with the same 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

MechanismHandlesBehaviour
Job retryTransient exceptionsUp to jobs.max_attempts (5) with exponential back-off (base 2 s), then the job is dead
Inference timeoutRunaway or overloaded processingCooperative 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 retakeBad image, not a system faultSession 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"]
Figure 16 - Bridge push. Runs on a 2-thread pool after the row is written, so a slow or offline WMS never delays the pipeline.

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
Figure 17 - Two-way weighment integration. A: WMS reports the outcome and the edge acknowledges to the controller. B: controller reports a skipped weighment and the edge forwards it.
ItemDetail
Weighment requestPOST /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 frameDefault RST + \r\n = bytes 52 53 54 0D 0A. Command and terminator are operator settings
SKIP detectionA 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 deliveryPOST {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
AuditRows 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
Figure 18 - Illustrative timeline. Shapes derived from real stage logs on the development host (capture 34-67 ms, detect 60-92 ms, OCR 58-80 ms, persist ~23 ms, total 200-330 ms). Absolute values differ by hardware; the structure does not.

Two structural observations explain most of the total:

  1. Detection is serialised. There is one detect thread, so the rear camera's detection starts only after the front's finishes (its queue_wait_ms in 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.
  2. 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

BlockTypical warm costDominant reasonLevers
Deadline wait0 ms defaultDeliberate business delay (capture_delay_ms) so the vehicle reaches the right positionOperator setting per camera (0 or 5-600000 ms)
Frame dip + JPEG encode~35-65 ms4K 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 CUDAResize of a large frame, network forward, host↔device transferSmaller 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 TensorRTText detection (DBNet) + recognition per line; CPU vs GPU backendUse backend=tensorrt + use_det=true on Jetson; keep blur_retry_on_fail off unless needed (+~0.8 s when it fires)
Output delay0 ms defaultDeliberate hold before the result becomes visible / pushed (front camera's setting)Operator setting
Persist~20-30 msSQLite insert with base64 imagesRetention, image sharing choices
Bridge pushnetwork dependent, off-pathWMS latency, up to 3 attempts × 10 s timeoutBridge timeout / attempts settings

12.3 End-to-end figures

ScenarioObserved
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 Jetson0.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

13. Low-level architecture

13.1 Package and module map (src/alpr_edge/)

PackageKey modulesResponsibility
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.pyFastAPI 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.pyThe booth verify engine (F1)
serial_trigger/reader.py, parse.pyUART 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.pyRecognition core (no HTTP, no DB)
jobs/store.py, handlers.py, worker.py, timeout.py, burst_cache.py, models.pyDurable job queue and JobWorker for F2
session/manager.py, store.py, models.pyScan 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.pySQLite 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.pyCamera sources, reader threads, 30-frame ring buffer, pooled readers, MJPEG preview
outbound/bridge.py, alert.py, webhook.pyBridge push, SKIP alert, HMAC-signed webhooks
config/settings.py, prefs.pyStatic 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 byPurposeStops on
uvicorn event loopuvicornHTTP handling; blocking routes use worker threadsshutdown
capture-<camera_id> (one per distinct source)CameraCaptureService.startRead frames continuously into the ring buffercamera close
trigger-capture / detect / ocr / module2 / persistPipelineSupervisor.startBooth verify stagesshutdown (capture and persist force-drain pending work first)
trigger-reaperSupervisorSLA and stale sweeps, worker restartsshutdown
job-worker-<device>JobWorker.startAsync scan jobs; one per GPU in multi-GPU modeshutdown
serial-triggerSerialTriggerReader.startRead UART, parse, fire events; reconnect with 0.5 s back-off growing to 5 sprefs change / shutdown
bridge-*, skip-alert-* (2 each)Lazy thread poolsOutbound HTTPsupervisor stop
alpr-retention-sweep (asyncio task)lifespanPurge every 300 sshutdown

13.3 Camera layer

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
  }
Figure 19 - SQLite tables in data/alpr.db. One file shared by all stores (the verify store uses WAL journal mode). Only key columns are shown.
TableWritten byRetention default
vehicle_verify_callsPersistWorker (F1). Contains metadata, base64 images, audit_json (capture timing, camera errors, output delay, lane candidates)14 days (JSON + images together)
sessions, session_eventsSessionManager (F2)Live sessions 3600 s TTL
scan_pass / scan_fail / scan_results_finish_scan (F2); fail rows keep the best frame as base64pass 15 d, fail 1 d
jobs, job_queue_metrics, webhook_deliveriesJob store (F2)14 days
audit_logSettings changes, weighment status, SKIP alerts, retakes, edits30 days
client_prefsSettings API (per-camera prefs, Bridge, serial trigger)Until changed

13.5 Configuration model

LayerWhat lives hereChanged by
Environment / .envHighest priority. ALPR_ prefix, __ for nesting, e.g. ALPR_PLATE__OCR__BACKEND=tensorrt; auth secrets; bind host/port; ngrok tokenDeployment
config/plate.yamlInstall defaults: models, thresholds, queues, SLAs, retention, camera sourcesEngineering (needs restart)
Operator prefs (SQLite)Per-camera burst / delays / enable, camera source overrides, Bridge, UART parsing, JPEG quality, retention override, SLA overrideOperators via PUT /api/v1/settings or the Verify Console; hot-applied
Model defaultspydantic defaultsCode

13.6 Error model

WhereValues
Verify statusprocessing, pass, fail, error
Verify error_codeALL_CAMERAS_FAILED, SLA_TIMEOUT, PIPELINE_BACKPRESSURE, SHUTDOWN, CAPTURE_ERROR
Per-camera camera_errorscapture_failed, no_detection, ocr_failed, validation_rejected, detect_error, ocr_error
Async scan error_codeprocessing_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 ...
HTTP202 accepted, 400 bad input, 401 auth, 404 unknown session, 429 rate limit, 503 TRIGGER_BUSY / not ready

13.7 Failure and recovery behaviour

FailureBehaviour
One camera returns no frameThat role is marked capture_failed; the other role can still produce front_only / back_only
All cameras failSession finalised as error / ALL_CAMERAS_FAILED
Camera stream dropsReader reconnects (2 s cool-down); ring buffer keeps the last frames
A pipeline worker thread diesReaper logs worker_dead at CRITICAL and restarts it
Stage queue fullBounded wait, then PIPELINE_BACKPRESSURE for that camera; top camera never fails a session
Session takes too longReaper finalises it as error / SLA_TIMEOUT
SQLite write failsRetried 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 downUp to 3 attempts, GET-last recovery, manual retry endpoint; never blocks the pipeline
Serial port unpluggedStatus reconnecting, back-off retry; RST returns serial reader not connected
Shutdown mid-flightCapture aborts pending triggers with SHUTDOWN; persist force-drains held tasks (skipping output delay, bounded to 5 s, then stubs)
Process crashsystemd 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_strictService refuses to start rather than silently running slow on CPU

13.8 Security model

13.9 Operator interfaces

Screenshots and an element-by-element description are in Section 4.2.

UIURLContains
Dashboard/Single-image upload, video burst capture, server camera select with live POV, batch upload, retake guidance, manual review (HITL edit), history
Verify Console/verifyTabs: 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.

GroupEndpointPurpose
Booth verify (F1)POST /webhook/verifyTrigger; 202 + session_id
GET /webhook/verify/calls/{session_id}Status and result (no images)
GET /webhook/verify/callsList; filters day (IST), status, limit, offset
GET /webhook/verify/calls/{session_id}/imageImages: camera=front|rear|top|all, annotated, crop
POST /webhook/weighmentWMS weighment result (F5)
POST /bridge/sessions/{session_id}/retryRe-push a stored session to the Bridge
Async scans (F2)POST /scansUpload 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 .../eventsSession management and timeline
GET /history, /pass, /fail, /history/{id}, /jobsResults and job inspection
Sync plate (F3)POST /plateFull recognition
POST /plate/viewRecognition + annotated and crop images
POST /plate/detectBoxes only
POST /plate/ocrOCR on a crop
CamerasGET /camerasList cameras
POST /cameras/{id}/open|closeCamera lifecycle
POST /cameras/{id}/captureShoot a burst and enqueue an async scan (202)
GET /cameras/{id}/preview, /streamJPEG and MJPEG preview
GET /logsRecent processing log (ring buffer; scope=audit filters chatter)
Settings / opsGET|PUT /settingsRuntime prefs
GET /settings/serial-ports, /settings/serial-monitorEnumerate ports; live rx/tx monitor
POST /settings/bridge/test, POST /webhooks/testConnectivity tests
GET /auditAudit trail (IST day or date range)
Probes (no prefix)/healthz, /ready, /healthLiveness; readiness (503 when detector not ready, GPU strict violated, or queue over threshold); detailed status incl. models, queues, pipeline health, retention counters
AuthPOST /auth/tokenMint a JWT from the API key

15. Configuration reference (key knobs)

SettingDefaultEffect
plate.detector.backendyoloyolo or rfdetr (ONNX FP32 CUDA)
plate.detector.conf_threshold / iou / imgsz / max_det0.10 / 0.45 / 640 / 3Detection sensitivity and cost
plate.detector.min_box_height / min_box_aspect / max_box_aspect40 px / 1.5 / 8.0Geometry filter (RF-DETR uses its own aspect override, min 1.0)
plate.ocr.backend / use_detpaddle / trueJetson: tensorrt + true
plate.ocr.rec_min_conf0.5Drop weak tokens
plate.gpu_strictfalsetrue on Jetson: never fall back to CPU; refuse to start instead
plate.validation.min_plate_chars8Reject fragments
plate.quality.*lap 50, luma 15-245Quality gate thresholds
trigger_pipeline.match_confidence_threshold85.0PASS threshold (0-100)
trigger_pipeline.capture_queue_size / detect / ocr / module2 / persist2 / 8 / 8 / 4 / 16Back-pressure sizes
trigger_pipeline.sla_typical / sla_worst_case2.0 s / 5.0 sWarn / force-error thresholds
capture.bidirectional_lanefalseWhether direction swaps front and rear
Per-camera capture_delay_ms, output_delay_ms00 or 5-600000; SLA is raised automatically
serial_trigger.* (operator)disabled, 9600 baud, debounce 1500 msPort, baud, strings, template / regex, lane TTL, RST command, alert prefix, skip debounce
bridge.* (operator)disabledURL, key, mode, paths, timeout 10 s, attempts 3, share flags, push statuses, skip alert
jobs.inference_timeout_seconds / max_attempts120 / 5Async scan timeout and retries
session.*_retention_dayspass 15, fail 1, audit 30, verify 14Retention; 0 = keep forever
auth.enabledfalseMandatory on non-loopback binds
rate_limit.*60 / 20 / 20 per minuteSync scans / capture / token

The complete environment-variable list is in docs/ENV_REFERENCE.md.

16. Operations runbook

16.1 Deployment (Jetson)

  1. deploy/setup_env.sh - system packages, uv virtual environment, install project with the OCR extra, seed .env.
  2. deploy/setup_gpu_jetson.sh - Jetson-specific PyTorch / ONNX Runtime GPU wheels; writes GPU device and strict flags into .env.
  3. deploy/pull_models.sh - unpack YOLO weights and the bundled RF-DETR package into models/; TensorRT engines are built on the board (deploy/export_tensorrt.sh), never copied from another machine.
  4. Edit .env (auth key and JWT secret before any LAN bind).
  5. sudo ./deploy/bootstrap_production.sh - installs and enables the alpr-serve systemd service and nginx; reboot-safe. deploy/run.sh runs it in the foreground for debugging.

16.2 Commissioning checklist

  1. GET /healthz = alive; GET /ready = 200.
  2. GET /health: confirm models labels (e.g. rfdetr/CUDAExecutionProvider/fp32 and PP-OCRv4-TRT+det), gpu_models_ok, trigger_pipeline.workers_alive all true.
  3. Verify Console → Preview: all three cameras live and correctly placed.
  4. Verify Console → Settings: set serial port, strings / template, Bridge URL and key; use Test and the serial monitor.
  5. Fire one throw-away verify trigger to warm set B (Section 6.3), then run real triggers.
  6. Confirm the WMS receives the Bridge push and that POST /webhook/weighment produces an » RST line 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

SymptomLikely causeWhere to look
nginx 502 right after restartModels still loading (expected)Log lines Loading plate pipeline → Pipeline ready
First trigger after restart is slow or SLA_TIMEOUTVerify workers (set B) build models on first useSection 6.3; send a warm-up trigger
503 TRIGGER_BUSYTriggers 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_detectionPaddle imported before torchvision, or plate smaller than min_box_height, or unusual aspectSection 6.1 callout; Verify Console Images (annotated)
Front and rear labels look swappedBidirectional lane setting vs physical wiringcapture.bidirectional_lane, direction_unknown in timing
rst_sent=falseUART disabled or port closedSettings → Hardware trigger; serial monitor
WMS not receiving resultsBridge disabled / URL / ACK formatSettings → Bridge → Test; audit log; retry endpoint
Service will not start on Jetsongpu_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 fillingRetention disabled or long retention with large images/health → retention; retention days; JPEG quality

17. Limitations, considerations and roadmap

Current limitations (be aware when planning)

Roadmap candidates

Appendix

A. Glossary

TermMeaning
ALPR / ANPRAutomatic License / Number Plate Recognition
HSRPHigh Security Registration Plate (Indian standard plate with hologram / IND marking)
RTORegional Transport Office code in the plate (e.g. the "12" in MH12)
BH seriesBharat series plate format (YY BH NNNN XX)
Front / Rear / TopLogical camera roles: capture.plate, capture.back, capture.material
SessionOne vehicle transaction; session_id ties capture, plates, images and WMS records together
Lane sessionIn-memory ENTRY → MID → EXIT record for the vehicle currently in the booth
AggregatorThe fan-in object that waits for capture, front OCR, rear OCR and top results, then finalises
BridgeClient-side receiver that the edge pushes verify results to (the WMS integration endpoint)
RSTReset frame sent to the boom controller on a successful weighment
SLAMaximum time a session may stay processing before it is forced to error
gpu_strictFail-closed mode: no silent CPU fallback for detector or OCR
TRT / ORTNVIDIA TensorRT / ONNX Runtime
CTCConnectionist Temporal Classification - the decoding scheme used by the text recogniser

B. Where to find things in source

QuestionStart 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

DirectionLineMeaningEdge action
Controller → edgeEVT:LEFT_ENTRY (or configured ENTRY string + direction)Vehicle at entryLane ENTRY, snap front + rear
Controller → edgeEVT:VEH_ON_BRIDGE:LEFTVehicle on bridgeLane MID, capture all three, persist
Controller → edgeEVT:LEFT_EXITVehicle leavingLane EXIT, clear lane
Controller → edgeALERT:SKIP:LEFT / RIGHTWeighment skippedPOST SKIP alert to WMS
Controller → edgeSYS:ALIVE:...HeartbeatIgnored (visible in serial monitor)
Edge → controllerRST\r\nWeighment success acknowledgementSent 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