All projects

Edge-Cloud-Detect

A license plate recognition system that runs detection on a Raspberry Pi 5 at the camera, and sends only the results to a cloud backend.

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.

Raspberry Pi 5 connected by ethernet to an EZVIZ camera, mounted in a weatherproof box
The edge node. Raspberry Pi 5 wired directly to the camera over ethernet.

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.

B arrow A -->|label| B arrow with text subgraph Name ... end groups boxes in a labelled container -->
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
What I'd do differently

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:

What's next