The problem
The usual way to read license plates is to stream every camera to a server and recognise the plates there. That means paying for the bandwidth of continuous video, and it means raw footage of everyone who drives past leaves the site. Both get worse the more cameras you add.
For example: a campus gate logs vehicles by hand. Streaming four camera feeds to a cloud server for recognition would cost more in bandwidth than the whole system is worth, and it stops working entirely when the building's uplink drops.
How it works
Two EZVIZ cameras are wired to a Raspberry Pi 5 over
ethernet and read as RTSP streams. The Pi samples a frame
every second and compares it with the previous one; only
when the frame changes enough to suggest a new vehicle does
it hand the image to YOLOv11, which detects the
car and license_plate classes.
The plate crop is split into two rows — province on top,
registration number below — and each row goes through
PaddleOCR separately. What leaves the Pi is
province, plate_number,
confidence, the cropped plate image,
capture_time and direction: a few
kilobytes per vehicle instead of a continuous video stream.
Detections land in a local SQLite buffer first, so a dropped
uplink delays the upload rather than losing the record.
On the cloud side the API matches the plate against the
vehicles table, checks that the requesting user
owns that vehicle, writes the event to
detection_logs, and alerts the administrator
when a plate isn't registered. The Next.js dashboard reads
the same API to search past entries by plate number and
filter by time range.
flowchart LR
subgraph edge["Edge — Raspberry Pi 5"]
CAM["EZVIZ × 2
camera_in / camera_out"]
YOLO["YOLOv11
car + license_plate"]
OCR["Crop plate → split 2 rows
PaddleOCR (Thai)"]
BUF[("SQLite
local buffer")]
end
subgraph cloud["Cloud — GCP asia-southeast1"]
PROXY["Nginx
reverse proxy + TLS"]
API["Express API
JWT · bcrypt · Google OAuth"]
DB[("PostgreSQL
users · vehicles · detection_logs")]
WEB["Next.js dashboard"]
ALERT["Alert
unregistered vehicle"]
end
CAM -->|RTSP over LAN| YOLO --> OCR --> BUF
BUF -->|"HTTPS POST /detections
results only, no video"| CF["Cloudflare"]
CF --> PROXY
PROXY --> API
PROXY --> WEB
WEB --> API
API --> DB
API --> ALERT
USER["User / Admin"] -->|HTTPS| CF
Two decisions worth explaining
The camera is wired, not wireless. On the first build the cameras sat on the site's router over Wi-Fi, and every reconnect handed them a new DHCP address — the Pi lost the RTSP stream and someone had to go and find the new one. The fix was to stop treating it as a network problem: the camera now plugs straight into the Pi over ethernet on a small static subnet, so the address never moves and the video never shares a link with anything else.
The plate is split before OCR, not after. A Thai plate is two lines of text with different character sets — the province name on top, the registration below. Passing the whole crop to PaddleOCR meant the two lines competed with each other; cutting the crop in half first and running OCR twice turned one unreliable read into two easier ones.
watch_and_send.py
├── grab frame from RTSP
├── diff against previous frame → skip if unchanged
├── YOLOv11 → car + license_plate boxes
├── crop plate → split 2 rows → PaddleOCR
├── buffer to local SQLite
└── POST /api/detections
The trigger is a frame difference against the previous frame, which is a crude stand-in for “a new vehicle arrived.” It fires on a shadow moving across the lane and stays quiet for a car that rolls in slowly, so the Pi both does work it doesn't need to and can miss what it should catch. Checking motion only inside the lane region, or tracking the box YOLO already returns from frame to frame, would be a better signal than raw pixel difference — that's the first thing I'd change.
Results
Running at the gate since November 2025 on two cameras. What I can state honestly today:
- Inference on the Pi: ~14–25 fps depending on stream settings
- Per vehicle the Pi uploads a few kilobytes — text plus one plate crop — rather than a continuous video stream
- Recognition accuracy is not measured yet. I have no hand-labelled set to check it against, so I am not putting a percentage here that I cannot defend — building that set is the next thing on the list
What's next
- Label a set of real captures and measure OCR accuracy properly, per row — province and number fail in different ways
- Tighten plate matching for the characters Thai OCR confuses, so one misread letter does not alert the administrator about a car that is registered
- Put the backup and restore drill on the cloud database on a schedule instead of running it by hand