Raspberry Pi Camera: Nhận diện biển số xe cơ bản (ALPR)
Trung cấp1/8/2026- Tác giả: IoTlabs Maker

Raspberry Pi Camera: Nhận diện biển số xe cơ bản (ALPR)

Dùng Raspberry Pi 4 + Camera Module v3 và OpenCV (Canny edge + contour) để khoanh vùng biển số xe theo phương pháp heuristic hình học một bước nhập môn thị giác máy tính, không phải hệ ALPR deep-learning sản xuất thực tế.

Raspberry PiALPRLicense PlateOpenCVCameraPythonComputer Vision

Dự án này giới thiệu một bước nhập môn về ALPR (Automatic License Plate Recognition) trên Raspberry Pi 4 với Camera Module v3, dùng thuần OpenCV theo phương pháp heuristic hình học (Canny edge detection + tìm contour hình chữ nhật có tỉ lệ khung giống biển số) để khoanh vùng ứng viên là biển số xe trong khung hình.

Không dùng mô hình deep-learning nên độ chính xác và độ bền với ánh sáng/góc chụp thấp hơn nhiều so với ALPR thương mại (vốn dùng CNN/OCR chuyên biệt).

Đây là bài tập để hiểu pipeline xử lý ảnh cổ điển: grayscale → khử nhiễu → phát hiện biên → tìm contour → lọc theo tỉ lệ khung hình, trước khi tuỳ chọn OCR vùng đã khoanh bằng pytesseract (nếu cài đặt).

Hướng dẫn chi tiết

Dùng Raspberry Pi 4 + Camera Module v3 và OpenCV (Canny edge + contour) để khoanh vùng ứng viên biển số xe theo phương pháp heuristic hình học, bài học nhập môn computer vision, không phải ALPR deep-learning sản xuất.

1. Giới thiệu

Project này giới thiệu một bước nhập môn về ALPR (Automatic License Plate Recognition) trên Raspberry Pi 4 với Camera Module v3, dùng thuần OpenCV theo phương pháp heuristic hình học (Canny edge detection + tìm contour hình chữ nhật có tỉ lệ khung giống biển số) để khoanh vùng ứng viên là biển số xe trong khung hình.

Đây không phải là hệ ALPR deep-learning sản xuất thực tế — các hệ ALPR thương mại dùng mạng CNN chuyên biệt để phát hiện + OCR, đạt độ chính xác trên 95% trong điều kiện kiểm soát. Phương pháp trong bài này chỉ dùng edge detection và hình học contour cổ điển, nên độ chính xác và độ bền với ánh sáng/góc chụp/nền phức tạp thấp hơn nhiều.

Mục tiêu của bài viết là giúp bạn hiểu pipeline xử lý ảnh cổ điển từng bước: grayscale → khử nhiễu (bilateral filter) → phát hiện biên (Canny) → tìm contour → lọc theo tỉ lệ khung hình đặc trưng của biển số, trước khi tuỳ chọn OCR vùng đã khoanh bằng pytesseract (nếu có cài đặt — script vẫn chạy bình thường nếu thiếu, chỉ bỏ qua bước OCR). Không dùng kết quả của bài này cho mục đích thương mại hay pháp lý.

2. Linh kiện cần có

Linh kiện

Số lượng

Giá tham khảo

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đ

Thẻ nhớ microSD 32GB (Class 10 trở lên, cài Raspberry Pi OS) — phụ kiện, không nằm trong sơ đồ điện

1

~150.000đ

Nguồn USB-C 5V/3A — phụ kiện, không nằm trong sơ đồ điện

1

~150.000đ

3. Sơ đồ kết nối

RPi Camera Module v3

Raspberry 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)

GND

GND

Kết nối chính là cáp ribbon CSI 15-chân đi kèm camera — cắm thẳng vào cổng CSI trên Raspberry Pi 4 (nằm giữa cổng HDMI và jack audio). Lưu ý chiều cáp: mặt có các đường tiếp xúc kim loại phải hướng về phía cổng HDMI; cắm ngược chiều là nguyên nhân phổ biến nhất khiến hệ thống báo "no cameras available". Mở khoá chốt nhựa trên đầu nối CSI trước khi lắp cáp, luồn cáp vào rồi ấn khoá lại chắc chắn.

Bốn dây SDA/SCL/3V3/GND chỉ áp dụng cho board có route thêm chân I2C điều khiển autofocus ra ngoài — nếu module của bạn chỉ có đầu nối ribbon CSI đơn thuần, bỏ qua phần này, chỉ cần cáp CSI là đủ. Với ALPR, khuyến nghị lắp camera cố định, vuông góc với hướng biển số dự kiến xuất hiện, để giảm biến dạng phối cảnh làm sai tỉ lệ khung hình khi lọc contour.

4. Code ví dụ #1 — Kiểm tra camera hoạt động (Camera Self-Test)

Chạy script này đầu tiên sau khi lắp camera để xác nhận phần cứng nhận diện đúng trước khi chạy pipeline khoanh vùng biển số. Script in banner khởi động, thử mở camera qua picamera2, chụp thử vài khung hình trong 3 giây và báo FPS ước tính.

"""
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. Code ví dụ #2 — Ứng dụng chính: khoanh vùng biển số theo heuristic (plate_region_detector.py)

Vòng lặp chính chạy pipeline: grayscale → bilateralFilter khử nhiễu giữ biên → Canny phát hiện biên → findContours tìm đường viền → lọc theo diện tích tối thiểu và tỉ lệ khung hình (width/height) đặc trưng của biển số (1.5–6.0) để chọn ra các vùng ứng viên. Nếu pytesseract có sẵn, thử OCR vùng ứng viên lớn nhất (không bắt buộc, lỗi OCR không làm crash chương trình).

Script tuân thủ các quy tắc mô phỏng bắt buộc: banner khởi động, retry có giới hạn khi mở camera thất bại, bỏ qua khung hình lỗi, in telemetry JSON định kỳ mỗi 5 giây kèm rõ nhãn "method": "heuristic_canny_contour_NOT_deep_learning" để không ai nhầm đây là ALPR sản xuất.

"""
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. Code ví dụ #3 — Công cụ xử lý hàng loạt offline (batch_process.py)

Công cụ chạy độc lập, không cần camera thật: quét một thư mục ảnh đã lưu sẵn (.jpg/.png), chạy lại đúng pipeline heuristic trong plate_region_detector.py trên từng ảnh, và ghi kết quả (số ứng viên, box lớn nhất, kết quả OCR nếu có) ra file CSV.

Hữu ích để kiểm thử nhanh thuật toán với bộ ảnh mẫu mà không cần dựng Raspberry Pi và camera thật — hỗ trợ phát triển/đánh giá offline trước khi triển khai lên phần cứng.

"""
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. Lỗi thường gặp

Lỗi

Nguyên nhân

Cách sửa

"no cameras available" / camera không nhận

Cáp ribbon CSI cắm ngược chiều hoặc chưa cài chặt khoá chốt

Rút cáp, kiểm tra mặt tiếp xúc kim loại hướng về phía cổng HDMI, cắm lại và khoá chốt chắc chắn

ImportError: No module named picamera2

Chưa cài thư viện picamera2

Cài bằng sudo apt install -y python3-picamera2

Quá nhiều ứng viên sai (false positive) trong một khung hình

Nền phức tạp có nhiều đường thẳng ngang/dọc (gạch lát, khung cửa, biển quảng cáo...) bị Canny nhận nhầm thành biên

Thu hẹp vùng quan tâm (ROI) trước khi chạy pipeline, tăng ngưỡng MIN_CONTOUR_AREA, hoặc siết khoảng MIN_ASPECT_RATIO/MAX_ASPECT_RATIO

Không phát hiện được biển số dù xe đang trong khung hình

Ánh sáng yếu, biển số mờ/bẩn, hoặc góc chụp nghiêng làm sai tỉ lệ khung hình

Tăng ánh sáng, lắp camera vuông góc hơn với hướng biển số, hoặc nới lỏng khoảng tỉ lệ khung hình cho phù hợp góc lắp đặt thực tế

OCR trả về chuỗi rác hoặc None dù đã khoanh đúng vùng biển số

Chưa cài pytesseract/Tesseract OCR engine, hoặc ảnh crop quá nhỏ/mờ để OCR đọc chính xác

Cài pip install pytesseractsudo apt install -y tesseract-ocr; nếu vẫn sai nhiều, chấp nhận đây là giới hạn của phương pháp heuristic — cân nhắc mô hình OCR/ALPR chuyên biệt cho ứng dụng thực tế

FPS rất thấp so với 2 project camera khác trong series

Pipeline Canny + findContours nặng hơn nhiều so với HOG/pyzbar, đặc biệt khi có OCR

Giảm độ phân giải khung hình, giảm tần suất chạy OCR (chỉ OCR mỗi vài khung hình thay vì mọi khung), hoặc dùng batch_process.py để xử lý offline thay vì thời gian thực

8. Tổng kết

Bài này minh hoạ một pipeline xử lý ảnh cổ điển hoàn chỉnh từ Canny edge detection đến lọc contour theo hình học để khoanh vùng biển số xe trên Raspberry Pi 4 mà không cần bất kỳ mô hình deep-learning nào.

Đây là công cụ học tập tốt để hiểu computer vision cổ điển hoạt động ra sao trước khi bước sang các mô hình hiện đại, nhưng không phù hợp thay thế ALPR sản xuất thực tế: độ chính xác phụ thuộc mạnh vào ánh sáng, góc chụp, và độ phức tạp của nền.

Nếu cần độ chính xác cao và ổn định trong điều kiện thực tế đa dạng, hướng phát triển tiếp theo nên là các mô hình phát hiện biển số dựa trên deep-learning (YOLO huấn luyện riêng cho biển số) kết hợp OCR chuyên biệt, thay vì mở rộng thêm heuristic hình học.