Raspberry Pi Camera: Basic License Plate Recognition (ALPR)
Intermediate1/8/2026- Author: IoTSpark Maker

Raspberry Pi Camera: Basic License Plate Recognition (ALPR)

Uses a Raspberry Pi 4 + Camera Module v3 and OpenCV (Canny edge detection + contours) to locate a license plate using a geometric heuristic method — an intro step into computer vision, not a production-grade deep-learning ALPR system.

Raspberry PiALPRLicense PlateOpenCVCameraPythonComputer Vision
0 steps2 components

This project introduces an entry-level step into ALPR (Automatic License Plate Recognition) on a Raspberry Pi 4 with a Camera Module v3, using pure OpenCV with a geometric heuristic method (Canny edge detection + finding rectangular contours with a plate-like aspect ratio) to locate license plate candidates in the frame.

It doesn't use a deep-learning model, so its accuracy and robustness to lighting/angle are much lower than commercial ALPR systems (which rely on dedicated CNN/OCR models).

It's an exercise for understanding a classic image-processing pipeline: grayscale → noise reduction → edge detection → contour finding → aspect-ratio filtering, with optional OCR on the located region using pytesseract (if installed).

Detailed guide

An introductory ALPR project on Raspberry Pi 4 + Camera Module v3 using classical OpenCV heuristics (Canny edge detection + contour filtering) to locate license-plate candidate regions — a learning pipeline, not a production-grade system.

1. Introduction

This project introduces a beginner's step into ALPR (Automatic License Plate Recognition) on a Raspberry Pi 4 with a Camera Module v3, using pure OpenCV with a geometric heuristic approach (Canny edge detection + finding rectangular contours whose aspect ratio matches a license plate) to locate license-plate candidate regions in the frame.

This is not a production-grade deep-learning ALPR system — commercial ALPR systems use specialized CNNs for detection + OCR, achieving over 95% accuracy under controlled conditions. The method in this lesson only uses classical edge detection and contour geometry, so its accuracy and robustness to lighting/angle/complex backgrounds are much lower.

The goal of this article is to help you understand a classical image-processing pipeline step by step: grayscale → denoising (bilateral filter) → edge detection (Canny) → contour finding → filtering by the aspect ratio typical of license plates, before optionally running OCR on the identified region with pytesseract (if installed — the script still runs fine if it's missing, simply skipping the OCR step). Do not use this project's output for commercial or legal purposes.

2. Components Needed

ComponentQtyReference Price
Raspberry Pi 4 Model B (2/4/8GB RAM)1~1,500,000 – 2,200,000₫
Raspberry Pi Camera Module v3 (IMX708)1~700,000 – 900,000₫
32GB microSD Card (Class 10 or higher, with Raspberry Pi OS installed) — accessory, not part of the wiring diagram1~150,000₫
USB-C 5V/3A Power Supply — accessory, not part of the wiring diagram1~150,000₫

3. Wiring Diagram

RPi Camera Module v3Raspberry Pi 4
CSI (Ribbon connector)CSI Camera Port
SDA (I2C control)GPIO2 / SDA1 (Pin 3)
SCL (I2C control)GPIO3 / SCL1 (Pin 5)
3V3 (Power)3V3 (Pin 1/17)
GNDGND

The main connection is the 15-pin CSI ribbon cable that comes with the camera — plug it directly into the CSI port on the Raspberry Pi 4 (located between the HDMI port and the audio jack). Note the cable orientation: the side with the metal contacts must face the HDMI port; inserting it backwards is the most common cause of a "no cameras available" error. Release the plastic latch on the CSI connector before inserting the cable, feed it in, then press the latch firmly closed.

The four SDA/SCL/3V3/GND wires only apply to boards that route out extra I2C pins for autofocus control — if your module has only a plain CSI ribbon connector, skip this part; the CSI cable alone is enough. For ALPR, it's recommended to mount the camera fixed, perpendicular to the direction plates are expected to appear, to reduce perspective distortion that would throw off the aspect-ratio filtering in the contour step.

4. Example #1 — Verifying the Camera Works (Camera Self-Test)

Run this script first after mounting the camera to confirm the hardware is correctly detected before running the plate-region pipeline. The script prints a startup banner, tries to open the camera via picamera2, captures a few frames over 3 seconds, and reports the estimated FPS.

"""
camera_test.py
Kiem tra nhanh Camera Module v3 truoc khi chay bo khoanh vung bien so xe.
In banner khoi dong, mo camera qua picamera2, chup vai khung hinh, in
kich thuoc + FPS uoc tinh. Bao loi ro rang neu thieu picamera2 hoac
khong mo duoc phan cung.
"""

import sys
import time


def main() -> int:
    print("[BOOT] Raspberry Pi Basic ALPR - Camera Self-Test")
    print("[BOOT] Dang khoi tao picamera2...")

    try:
        from picamera2 import Picamera2
    except ImportError:
        print("[ERROR] Khong tim thay thu vien picamera2.")
        print("        Cai dat: sudo apt install -y python3-picamera2")
        return 1

    try:
        picam2 = Picamera2()
        config = picam2.create_preview_configuration(
            main={"size": (640, 480), "format": "RGB888"}
        )
        picam2.configure(config)
        picam2.start()
        time.sleep(1.0)
    except Exception as exc:  # noqa: BLE001
        print(f"[ERROR] Khong the khoi dong camera: {exc}")
        print("        Kiem tra: cap ribbon CSI cam dung chieu, dung chan,")
        print("        camera duoc bat trong raspi-config (Interface Options).")
        return 1

    frame_count = 0
    start = time.monotonic()
    test_duration_s = 3.0

    while time.monotonic() - start < test_duration_s:
        frame = picam2.capture_array()
        frame_count += 1
        if frame_count == 1:
            print(f"[OK] Khung hinh dau tien: shape={frame.shape}, dtype={frame.dtype}")

    elapsed = time.monotonic() - start
    fps = frame_count / elapsed if elapsed > 0 else 0.0
    print(f"[OK] Da chup {frame_count} khung hinh trong {elapsed:.2f}s (~{fps:.1f} FPS)")

    picam2.stop()
    print("[DONE] Camera hoat dong binh thuong.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

5. Example #2 — The Main Application: Heuristic Plate-Region Detection (plate_region_detector.py)

The main loop runs the pipeline: grayscale → bilateralFilter for edge-preserving denoising → Canny edge detection → findContours to find outlines → filter by minimum area and the aspect ratio (width/height) typical of a license plate (1.5–6.0) to select candidate regions. If pytesseract is available, it attempts OCR on the largest candidate region (optional — an OCR failure won't crash the program).

The script follows the required simulation conventions: a startup banner, bounded retries when opening the camera fails, skipping bad frames, and periodic JSON telemetry every 5 seconds clearly labeled with "method": "heuristic_canny_contour_NOT_deep_learning" so no one mistakes this for production ALPR.

"""
plate_region_detector.py
Ung dung chinh: khoanh vung UNG VIEN la bien so xe trong khung hinh, dung
Raspberry Pi 4 + Camera Module v3 + OpenCV thuan (KHONG deep-learning).

CANH BAO QUAN TRONG: day la phuong phap heuristic hinh hoc co ban
(Canny edge detection + tim contour hinh chu nhat co ty le khung giong
bien so), phuc vu muc dich hoc tap pipeline xu ly anh co dien. Do chinh
xac VA DO BEN thap hon nhieu so voi he ALPR thuong mai (dung CNN/OCR
chuyen biet, thuong dat >95% trong dieu kien kiem soat). Anh sang yeu,
goc chup nghieng, bien so mo/ban, hoac nen phuc tap (nhieu duong thang
ngang doc khac) se lam tang so luong ung vien sai (false positive).
Khong dung ket qua cua script nay cho muc dich thuong mai/phap ly.

Hanh vi mo phong quan sat duoc:
  - In banner ngay sau khi console san sang.
  - Retry co gioi han khi mo camera that bai (khong while vo han).
  - Khung hinh loi -> bo qua, khong crash.
  - OCR (pytesseract) la TUY CHON: neu chua cai, van chay binh thuong,
    chi bao "ocr_text": null trong telemetry.
  - In telemetry JSON dinh ky.
"""

from __future__ import annotations

import json
import time
from typing import List, Optional, Tuple

FRAME_WIDTH = 640
FRAME_HEIGHT = 480
TELEMETRY_INTERVAL_S = 5.0
CAMERA_RETRY_INTERVAL_S = 3.0
CAMERA_MAX_RETRIES = 5

# Ty le khung (width/height) dien hinh cua bien so xe may/oto VN,
# noi long bien do de bu sai so goc chup.
MIN_ASPECT_RATIO = 1.5
MAX_ASPECT_RATIO = 6.0
MIN_CONTOUR_AREA = 800

Box = Tuple[int, int, int, int]


def open_camera():
    from picamera2 import Picamera2

    for attempt in range(1, CAMERA_MAX_RETRIES + 1):
        try:
            picam2 = Picamera2()
            config = picam2.create_preview_configuration(
                main={"size": (FRAME_WIDTH, FRAME_HEIGHT), "format": "RGB888"}
            )
            picam2.configure(config)
            picam2.start()
            time.sleep(0.5)
            print(f"[BOOT] Camera san sang sau {attempt} lan thu.")
            return picam2
        except Exception as exc:  # noqa: BLE001
            print(f"[WARN] Lan thu {attempt}/{CAMERA_MAX_RETRIES} mo camera that bai: {exc}")
            if attempt < CAMERA_MAX_RETRIES:
                time.sleep(CAMERA_RETRY_INTERVAL_S)

    print("[ERROR] Khong mo duoc camera sau nhieu lan thu, dung chuong trinh.")
    return None


def find_plate_candidates(frame) -> List[Box]:
    """Pipeline heuristic: grayscale -> blur -> Canny -> contour -> loc ty le khung.

    Tra ve danh sach box (x, y, w, h) la UNG VIEN, khong dam bao dung 100%.
    """
    import cv2

    if frame is None or frame.size == 0:
        return []

    bgr = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    blurred = cv2.bilateralFilter(gray, 11, 17, 17)  # khu nhieu, giu bien ro
    edges = cv2.Canny(blurred, 30, 200)

    contours, _hierarchy = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

    candidates: List[Box] = []
    for contour in contours:
        area = cv2.contourArea(contour)
        if area < MIN_CONTOUR_AREA:
            continue

        x, y, w, h = cv2.boundingRect(contour)
        if h == 0:
            continue

        aspect_ratio = w / float(h)
        if MIN_ASPECT_RATIO <= aspect_ratio <= MAX_ASPECT_RATIO:
            candidates.append((x, y, w, h))

    return candidates


def try_ocr(frame, box: Box) -> Optional[str]:
    """OCR tuy chon bang pytesseract. Tra ve None neu khong co thu vien
    hoac OCR that bai - KHONG lam crash chuong trinh chinh.
    """
    try:
        import pytesseract
        import cv2
    except ImportError:
        return None

    try:
        x, y, w, h = box
        bgr = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
        crop = bgr[y : y + h, x : x + w]
        gray_crop = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
        text = pytesseract.image_to_string(
            gray_crop, config="--psm 7 -c tessedit_char_whitelist=ABCDEFGHIKLMNPSTUVXYZ0123456789.-"
        )
        cleaned = text.strip()
        return cleaned if cleaned else None
    except Exception:  # noqa: BLE001 - OCR loi khong duoc lam sap he thong
        return None


def build_telemetry(total_frames: int, candidates_count: int, ocr_text: Optional[str]) -> str:
    payload = {
        "ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "total_frames_processed": total_frames,
        "candidates_this_frame": candidates_count,
        "ocr_text": ocr_text,
        "method": "heuristic_canny_contour_NOT_deep_learning",
    }
    return json.dumps(payload, ensure_ascii=False)


def main() -> int:
    print("[BOOT] Raspberry Pi Basic ALPR khoi dong...")
    print("[BOOT] LUU Y: day la phuong phap heuristic hoc tap, khong phai ALPR san xuat.")

    try:
        import cv2  # noqa: F401
    except ImportError:
        print("[ERROR] Thieu thu vien opencv-python. Cai dat: pip install opencv-python")
        return 1

    picam2 = open_camera()
    if picam2 is None:
        return 1

    total_frames = 0
    last_telemetry_at = time.monotonic()

    try:
        while True:
            try:
                frame = picam2.capture_array()
                total_frames += 1
            except Exception as exc:  # noqa: BLE001
                print(f"[WARN] Loi doc khung hinh, bo qua: {exc}")
                frame = None

            candidates: List[Box] = []
            ocr_text: Optional[str] = None

            if frame is not None:
                candidates = find_plate_candidates(frame)
                if candidates:
                    best_box = max(candidates, key=lambda b: b[2] * b[3])
                    ocr_text = try_ocr(frame, best_box)
                    print(f"[DETECT] {len(candidates)} ung vien, box lon nhat={best_box}, ocr={ocr_text!r}")

            now = time.monotonic()
            if now - last_telemetry_at >= TELEMETRY_INTERVAL_S:
                print(build_telemetry(total_frames, len(candidates), ocr_text))
                last_telemetry_at = now

            time.sleep(0.1)  # pipeline nang hon nen giam toc do vong lap
    except KeyboardInterrupt:
        print("[STOP] Nhan Ctrl+C, dang dung...")
    finally:
        picam2.stop()
        print(f"[DONE] Da xu ly {total_frames} khung hinh.")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

6. Example #3 — An Offline Batch-Processing Tool (batch_process.py)

A standalone tool that doesn't need a live camera: it scans a folder of pre-saved images (.jpg/.png), reruns the exact same heuristic pipeline from plate_region_detector.py on each image, and writes the results (candidate count, largest box, OCR result if any) to a CSV file.

Useful for quickly testing the algorithm against a sample image set without setting up a real Raspberry Pi and camera — supporting offline development/evaluation before deploying to hardware.

"""
batch_process.py
Cong cu chay OFFLINE (khong can camera song): quet mot thu muc anh da luu
san (.jpg/.png), chay lai pipeline heuristic trong plate_region_detector.py
tren tung anh, va ghi ket qua (so ung vien, box lon nhat) ra file CSV.

Huu ich de kiem thu nhanh thuat toan voi bo anh mau ma khong can dung
Raspberry Pi that hoac camera that - ho tro phat trien/danh gia offline.
"""

from __future__ import annotations

import argparse
import csv
import glob
import os
import time
from typing import List, Optional

from plate_region_detector import find_plate_candidates, try_ocr, Box

IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png")


def list_images(folder: str) -> List[str]:
    paths: List[str] = []
    for ext in IMAGE_EXTENSIONS:
        paths.extend(glob.glob(os.path.join(folder, f"*{ext}")))
    return sorted(paths)


def process_folder(input_folder: str, output_csv: str) -> int:
    import cv2

    image_paths = list_images(input_folder)
    print(f"[BOOT] Tim thay {len(image_paths)} anh trong '{input_folder}'")

    if not image_paths:
        print("[WARN] Khong co anh nao de xu ly, dung.")
        return 0

    processed = 0
    with open(output_csv, "w", newline="", encoding="utf-8") as csv_file:
        writer = csv.writer(csv_file)
        writer.writerow(["timestamp_iso", "image_path", "candidates_count", "best_box", "ocr_text"])

        for path in image_paths:
            bgr = cv2.imread(path)
            if bgr is None:
                print(f"[WARN] Khong doc duoc anh, bo qua: {path}")
                continue

            frame_rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
            candidates = find_plate_candidates(frame_rgb)

            best_box: Optional[Box] = None
            ocr_text: Optional[str] = None
            if candidates:
                best_box = max(candidates, key=lambda b: b[2] * b[3])
                ocr_text = try_ocr(frame_rgb, best_box)

            timestamp_iso = time.strftime("%Y-%m-%dT%H:%M:%S")
            writer.writerow([timestamp_iso, path, len(candidates), best_box, ocr_text])
            processed += 1
            print(f"[OK] {path}: {len(candidates)} ung vien, best_box={best_box}, ocr={ocr_text!r}")

    print(f"[DONE] Da xu ly {processed}/{len(image_paths)} anh. Ket qua: {output_csv}")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Chay heuristic ALPR offline tren thu muc anh")
    parser.add_argument("--input-folder", default="./sample_plates", help="Thu muc chua anh .jpg/.png")
    parser.add_argument("--output-csv", default="alpr_batch_results.csv", help="File CSV ket qua")
    args = parser.parse_args()

    try:
        import cv2  # noqa: F401
    except ImportError:
        print("[ERROR] Thieu thu vien opencv-python. Cai dat: pip install opencv-python")
        return 1

    if not os.path.isdir(args.input_folder):
        print(f"[ERROR] Thu muc khong ton tai: {args.input_folder}")
        return 1

    return process_folder(args.input_folder, args.output_csv)


if __name__ == "__main__":
    raise SystemExit(main())

7. Common Issues

IssueCauseFix
"no cameras available" / camera not detectedThe CSI ribbon cable is inserted backwards or the latch isn't fully closedRemove the cable, check that the metal contacts face the HDMI port, reinsert, and close the latch firmly
ImportError: No module named picamera2The picamera2 library isn't installedInstall it with sudo apt install -y python3-picamera2
Too many false-positive candidates in one frameA complex background with many horizontal/vertical lines (tiles, door frames, signage...) gets misread by Canny as edgesNarrow the region of interest (ROI) before running the pipeline, raise the MIN_CONTOUR_AREA threshold, or tighten the MIN_ASPECT_RATIO/MAX_ASPECT_RATIO range
No plate detected even though a vehicle is in frameLow light, a dirty/blurry plate, or an oblique shooting angle distorting the aspect ratioImprove lighting, mount the camera more perpendicular to the plate's expected orientation, or loosen the aspect-ratio range to fit the actual mounting angle
OCR returns garbage or None even though the plate region was located correctlypytesseract/the Tesseract OCR engine isn't installed, or the cropped image is too small/blurry for accurate OCRInstall with pip install pytesseract and sudo apt install -y tesseract-ocr; if accuracy is still poor, accept this as a limitation of the heuristic method — consider a dedicated OCR/ALPR model for a real deployment
FPS is much lower than the other two camera projects in the seriesThe Canny + findContours pipeline is much heavier than HOG/pyzbar, especially with OCR enabledLower the frame resolution, run OCR less often (every few frames instead of every frame), or use batch_process.py for offline processing instead of real-time

8. Summary

This lesson demonstrates a complete classical image-processing pipeline, from Canny edge detection to geometric contour filtering, to locate license-plate regions on a Raspberry Pi 4 without any deep-learning model.

It's a good learning tool for understanding how classical computer vision works before moving on to modern models, but it is not a suitable replacement for production ALPR: accuracy depends heavily on lighting, shooting angle, and background complexity.

If you need high, consistent accuracy across varied real-world conditions, the right next step is a deep-learning-based plate-detection model (a YOLO model trained specifically for plates) paired with dedicated OCR, rather than extending the geometric heuristic further.