Building a license plate reader for under €50
License plate recognition sounds like it should be complicated. The commercial systems used at parking garages and border crossings are indeed complicated — they handle every angle, every light condition, every plate format across multiple countries. But for a fixed, controlled setup like a driveway or a parking lot with one entry point, the problem is a lot simpler, and you can get surprisingly far with open source tools and cheap hardware.
What I was trying to do
Log which vehicles enter my driveway and when, get a notification when a plate I don’t recognize shows up, and be able to search the log later. Nothing fancy.
Hardware
- Raspberry Pi Zero 2 W (about €18)
- Raspberry Pi Camera Module 3 (€25)
- A short camera cable that fits the Zero 2 W’s smaller CSI connector (€3)
- A waterproof case or a small project box if it’s exposed to weather
If you already have a repurposed Android phone from a previous project, the IP Webcam setup works here too — you can pull frames from /shot.jpg the same way. The Pi camera is a bit cleaner for a dedicated install.
Camera placement: this is the most important thing to get right. The plate needs to be approximately facing the camera, well-lit (consider IR illumination for night use), and fill a reasonable fraction of the frame. A bad angle will defeat any OCR no matter how good your pipeline is.
The pipeline
The basic flow:
- Grab a frame
- Detect a region containing a license plate
- Straighten and crop that region
- Run OCR on it
- Log the result with a timestamp
Step 1: frame capture
import cv2
cap = cv2.VideoCapture(0) # Pi camera via libcamera-v4l2
ret, frame = cap.read()
Or from an IP Webcam stream:
import urllib.request
import numpy as np
url = "http://192.168.1.50:8080/shot.jpg"
with urllib.request.urlopen(url) as r:
img = np.frombuffer(r.read(), dtype=np.uint8)
frame = cv2.imdecode(img, cv2.IMREAD_COLOR)
Step 2: plate region detection
For a fixed camera in controlled lighting, edge detection and contour filtering works reasonably well without a neural network:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blur, 50, 150)
contours, _ = cv2.findContours(edges, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
candidates = []
for c in contours:
x, y, w, h = cv2.boundingRect(c)
aspect = w / h
area = w * h
# Finnish plates are roughly 520x110mm, aspect ratio ~4.7
if 3.0 < aspect < 6.0 and 5000 < area < 50000:
candidates.append((x, y, w, h))
This is tuned for Finnish plates. Adjust the aspect ratio and area bounds for your country’s plate dimensions.
For more robust detection in variable conditions — different angles, partial occlusion, rain on the lens — a proper detector (YOLO-based or a dedicated ANPR model) does better. But the contour approach works well for a clean, fixed setup.
Step 3: OCR
import pytesseract
for x, y, w, h in candidates:
plate_img = gray[y:y+h, x:x+w]
# upscale and threshold for better OCR accuracy
plate_img = cv2.resize(plate_img, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)
_, plate_img = cv2.threshold(plate_img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
text = pytesseract.image_to_string(
plate_img,
config='--psm 8 --oem 3 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-'
)
plate = text.strip().replace(' ', '').replace('\n', '')
if 4 <= len(plate) <= 8:
return plate
--psm 8 tells Tesseract to treat the image as a single word. The whitelist strips out anything that isn’t a valid plate character. Finnish plates follow an ABC-123 format, so a length check filters a lot of garbage reads.
Step 4: logging
import sqlite3
from datetime import datetime
db = sqlite3.connect('/var/plr/plates.db')
db.execute('''
CREATE TABLE IF NOT EXISTS sightings (
id INTEGER PRIMARY KEY,
plate TEXT NOT NULL,
seen_at TEXT NOT NULL,
image BLOB
)
''')
def log_plate(plate, frame):
_, buf = cv2.imencode('.jpg', frame)
db.execute(
'INSERT INTO sightings (plate, seen_at, image) VALUES (?, ?, ?)',
(plate, datetime.now().isoformat(), buf.tobytes())
)
db.commit()
Storing the image as a BLOB means you can retrieve the original frame for any sighting. SQLite handles this fine at the scale of a residential driveway.
Notifications
For unknown plates I use a simple allowlist check:
KNOWN_PLATES = {'ABC-123', 'XYZ-789'}
def on_plate_detected(plate, frame):
log_plate(plate, frame)
if plate not in KNOWN_PLATES:
notify(f"Unknown plate: {plate}")
Accuracy
In good daylight conditions with a clean camera angle I get around 90% accurate reads on the first attempt. The failure modes are:
- Dirty plates: mud or snow covering characters
- Motion blur: fast entry speed — reduce shutter speed or use a parking-speed-appropriate location
- Night conditions: add IR illumination; the camera module 3 has decent night sensitivity but needs some light
Running three consecutive reads and taking the most common result (a simple voting scheme) improves accuracy significantly.
Cost summary
| Item | Cost |
|---|---|
| Raspberry Pi Zero 2 W | ~€18 |
| Camera Module 3 | ~€25 |
| Camera cable (Zero 2 W) | ~€3 |
| Project box / housing | €0–10 |
| Total | €46–56 |
If you already have a phone running IP Webcam, total cost is zero — just add the detection and logging scripts.
If you’re looking at building something similar for a small business — a car park, a gate, a delivery area — feel free to reach out.