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:

  1. Grab a frame
  2. Detect a region containing a license plate
  3. Straighten and crop that region
  4. Run OCR on it
  5. 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

ItemCost
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.