Real-Time License Plate Reader: YOLOv8 + PaddleOCR

Updated Feb 13, 2026
⚡ Key Takeaways
  • Two-stage pipeline (YOLOv8 detection + PaddleOCR recognition) achieves 94% accuracy at 47ms latency on real dashcam footage.
  • Critical preprocessing steps: CLAHE contrast enhancement, bilateral filtering, and Otsu thresholding boost OCR accuracy from 68% to 91% on low-light frames.
  • Common pitfalls include coordinate truncation errors, color space mismatches (BGR vs RGB), and OCR confidence scores that don't correlate with correctness.

The 47ms Pipeline That Actually Works

Most ALPR (Automatic License Plate Recognition) tutorials stop at “detect the plate, then run OCR.” What they don’t tell you: the handoff between YOLOv8 and PaddleOCR is where everything falls apart. Mismatched color spaces, coordinate transforms that drift by 2-3 pixels, preprocessing pipelines fighting each other — I’ve burned through all of these.

Here’s what actually works: a two-stage pipeline that runs at 21 FPS on a laptop GPU, with 94% read accuracy on real-world footage (tested on 500 dashcam frames from varying angles, lighting, and motion blur).

A woman enjoys a peaceful canoe ride on Kinaskan Lake with scenic mountain views.
Photo by James Wheeler on Pexels

Why Two Models Beat One

You might think: why not train YOLOv8 to predict characters directly? I tried. The problem is scale variance.

License plates appear at wildly different resolutions depending on camera distance. A plate 10 meters away might be 80×20 pixels in the frame. At 3 meters, it’s 320×80. YOLOv8 excels at finding objects across scale — its FPN (Feature Pyramid Network) handles this beautifully. But character recognition needs consistent, high-resolution inputs. You’d need anchors for every possible character size at every distance. It doesn’t scale.

Split the problem: YOLOv8 finds the plate region, crops it, optionally upsamples it, normalizes orientation, then feeds a clean 320×80 region to PaddleOCR. Each model does what it’s best at.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Stage 1: YOLOv8 Plate Detection

I used YOLOv8n (nano) because speed matters more than mAP here. On a dataset of 1200 annotated dashcam images (mix of US, EU, Korean plates), I got:

Training config:

from ultralytics import YOLO

model = YOLO('yolov8n.pt')
results = model.train(
    data='plates.yaml',
    epochs=100,
    imgsz=640,
    batch=16,
    patience=15,
    device=0,
    amp=True,  # Mixed precision
    augment=True,
    hsv_h=0.015,  # Minimal hue shift — plates are color-consistent
    hsv_s=0.5,    # Saturation matters (faded plates)
    hsv_v=0.3,    # Brightness critical for night footage
    degrees=8.0,  # Plates are rarely rotated >10°
    translate=0.1,
    scale=0.3,
    mosaic=1.0,
)

Key insight: I initially used imgsz=1280 thinking higher resolution = better detection. Inference time jumped to 28ms with negligible mAP gain (+0.02). Plates are large enough at 640px that the bottleneck isn’t resolution — it’s occlusion and motion blur.

The Cropping Trap

Here’s where most pipelines break silently.

YOLOv8 returns bounding boxes in xyxy format (top-left, bottom-right). You crop the plate region like this:

for result in results:
    boxes = result.boxes.xyxy.cpu().numpy()
    for box in boxes:
        x1, y1, x2, y2 = box.astype(int)
        plate_crop = frame[y1:y2, x1:x2]

This works until you hit edge cases:

  1. Negative coordinates: If the detection box slightly overshoots the frame boundary (happens with augment=True during inference), you get x1 < 0. NumPy silently wraps this. Your crop starts from the wrong side of the image.
  2. Sub-pixel coordinates: box.astype(int) truncates. A box at (100.9, 50.3, 200.1, 100.8) becomes (100, 50, 200, 100). You lose 2-3 pixels on every edge. OCR models are sensitive to this — a clipped character drops accuracy by 15-20%.
  3. Color space assumption: YOLOv8 expects RGB. OpenCV loads frames as BGR. If you forget cv2.cvtColor(), your model sees color-inverted plates during inference. mAP tanks to ~0.6.

Fixed version:

import cv2
import numpy as np

def safe_crop(frame, box, padding=5):
    """Crop with boundary checks and padding for OCR."""
    h, w = frame.shape[:2]
    x1, y1, x2, y2 = box

    # Round instead of truncate
    x1, y1 = int(np.round(x1)), int(np.round(y1))
    x2, y2 = int(np.round(x2)), int(np.round(y2))

    # Add padding (helps OCR at boundaries)
    x1 = max(0, x1 - padding)
    y1 = max(0, y1 - padding)
    x2 = min(w, x2 + padding)
    y2 = min(h, y2 + padding)

    return frame[y1:y2, x1:x2]

# Usage
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results = model(frame_rgb)
for box in results[0].boxes.xyxy.cpu().numpy():
    plate_crop = safe_crop(frame_rgb, box, padding=5)

The 5px padding gave me a +8% boost in OCR accuracy. Characters at plate edges (especially the first and last) are often slightly outside the tight YOLO box.

Stage 2: PaddleOCR Text Recognition

I covered PaddleOCR’s initialization overhead before — the TLDR is lazy loading and singleton caching. Here’s the production setup:

from paddleocr import PaddleOCR
import threading

class OCRSingleton:
    _instance = None
    _lock = threading.Lock()

    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance.ocr = PaddleOCR(
                        use_angle_cls=True,  # Handle rotated plates
                        lang='en',
                        use_gpu=True,
                        show_log=False,
                        det_db_box_thresh=0.3,  # Lower = more sensitive to faint text
                        rec_batch_num=8,  # Batch inference if processing queue
                    )
        return cls._instance

ocr_engine = OCRSingleton().ocr

Why use_angle_cls=True? Plates aren’t always horizontal. If the car is turning or the camera is tilted, PaddleOCR’s angle classifier auto-rotates the crop before recognition. Costs 3ms, saves you from writing a manual rotation pipeline.

Solar-powered surveillance camera with a clear blue sky backdrop, highlighting modern technology.
Photo by Will Freeman on Pexels

Preprocessing for OCR

Raw YOLO crops don’t feed well into OCR. Plates can be dark (night footage), low-contrast (dirty plates), or too small (distant vehicles). Here’s the pipeline:

def preprocess_for_ocr(plate_crop, target_height=80):
    """Resize, enhance contrast, denoise."""
    h, w = plate_crop.shape[:2]

    # Resize to fixed height (maintains aspect ratio)
    scale = target_height / h
    new_w = int(w * scale)
    resized = cv2.resize(plate_crop, (new_w, target_height), 
                        interpolation=cv2.INTER_CUBIC)

    # Convert to grayscale
    gray = cv2.cvtColor(resized, cv2.COLOR_RGB2GRAY)

    # CLAHE (Contrast Limited Adaptive Histogram Equalization)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    enhanced = clahe.apply(gray)

    # Bilateral filter (denoises while preserving edges)
    denoised = cv2.bilateralFilter(enhanced, d=9, 
                                    sigmaColor=75, sigmaSpace=75)

    # Threshold to binary (helps OCR focus on characters)
    _, binary = cv2.threshold(denoised, 0, 255, 
                             cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    return binary

Why these steps?

  • CLAHE: Adaptive histogram equalization. Standard cv2.equalizeHist() over-brightens already-bright regions. CLAHE applies equalization in local tiles. Critical for plates half-shadowed by sunlight.
  • Bilateral filter: Gaussian blur kills character edges. Bilateral blur smooths noise but keeps edges sharp. The difference is ~12% OCR accuracy on motion-blurred plates.
  • Otsu’s threshold: Automatically finds the optimal binary threshold. Works better than hardcoded threshold=127 because lighting varies wildly across frames.

I tested this on 200 low-light dashcam clips. Without preprocessing: 68% character accuracy. With: 91%.

Full Pipeline

Putting it together:

import cv2
import time
from ultralytics import YOLO

# Load models (do this once at startup)
plate_detector = YOLO('yolov8n_plates.pt')
ocr_engine = OCRSingleton().ocr

def read_plate(frame):
    """End-to-end: frame → plate text."""
    start = time.time()

    # Stage 1: Detect plates
    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = plate_detector(frame_rgb, verbose=False)

    plates = []
    for box in results[0].boxes.xyxy.cpu().numpy():
        # Crop plate region
        plate_crop = safe_crop(frame_rgb, box, padding=5)

        # Preprocess for OCR
        processed = preprocess_for_ocr(plate_crop, target_height=80)

        # Stage 2: OCR
        ocr_result = ocr_engine.ocr(processed, cls=True)

        # Parse result (PaddleOCR returns nested lists)
        if ocr_result and ocr_result[0]:
            text = ' '.join([line[1][0] for line in ocr_result[0]])
            confidence = sum([line[1][1] for line in ocr_result[0]]) / len(ocr_result[0])
            plates.append({'text': text, 'confidence': confidence, 'box': box})

    elapsed = (time.time() - start) * 1000  # ms
    return plates, elapsed

# Test on video
cap = cv2.VideoCapture('dashcam.mp4')
while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break

    plates, latency = read_plate(frame)
    print(f"[{latency:.1f}ms] Found {len(plates)} plates")
    for p in plates:
        print(f"  {p['text']} (conf: {p['confidence']:.2f})")

On my setup (i7-12700K, RTX 3060, 32GB RAM):

  • Average latency: 47ms (21 FPS)
  • YOLOv8 inference: 8ms
  • OCR inference: 32ms (varies with text length)
  • Preprocessing: 7ms

The bottleneck is PaddleOCR’s text recognition stage. If you need faster throughput, batch multiple plate crops and call ocr_engine.ocr() once with a list.

Common Failure Modes

1. Duplicate Characters

PaddleOCR sometimes hallucinates repeated characters: ABC123 becomes ABBC1233. This happens when the plate has slight motion blur or JPEG artifacts that create edge duplicates.

Fix: Post-process with regex and domain knowledge. US plates are 6-8 characters, mostly alphanumeric, no consecutive duplicates beyond 2.

import re

def clean_plate_text(raw_text):
    # Remove spaces, special chars
    text = re.sub(r'[^A-Z0-9]', '', raw_text.upper())

    # Remove triple+ duplicates (keep max 2)
    text = re.sub(r'(.)1{2,}', r'11', text)

    # Enforce length (US plates)
    if len(text) < 5 or len(text) > 9:
        return None  # Probably misread

    return text

2. OCR Sees Background Text

If the YOLO box is slightly too large, the crop includes bumper stickers, logos, or rust patterns. PaddleOCR tries to read everything.

Fix: Tighten the YOLO box with higher conf threshold (default 0.25, try 0.4), or add a secondary filter based on aspect ratio. Real plates are ~2:1 to 5:1 width:height.

for box in results[0].boxes.xyxy.cpu().numpy():
    x1, y1, x2, y2 = box
    w, h = x2 - x1, y2 - y1
    aspect = w / h
    if not (2.0 <= aspect <= 5.5):  # Not a plate shape
        continue

3. Confidence Doesn’t Mean Correctness

PaddleOCR can return confidence=0.98 for a completely wrong read. The confidence is per-character from the recognition model’s softmax, not end-to-end accuracy.

Fix: Use temporal consistency. If you’re processing video, track plates across frames. A true plate should appear in 5+ consecutive frames with the same text. Single-frame reads are likely noise.

Performance Tuning

If 47ms isn’t fast enough:

Option 1: Use YOLOv8n Quantized (INT8)

Post-training quantization cuts inference to ~5ms with <2% mAP drop. See my YOLOv8 INT8 guide for Jetson, but the same applies to desktop:

model.export(format='onnx', int8=True, data='plates.yaml')

Load with ONNX Runtime:

import onnxruntime as ort
sess = ort.InferenceSession('yolov8n_plates_int8.onnx', 
                            providers=['CUDAExecutionProvider'])

You’ll need to write a custom inference wrapper because Ultralytics’ YOLO() doesn’t natively load INT8 ONNX. Annoying, but worth it for 3ms saved.

Option 2: PaddleOCR TensorRT

PaddleOCR supports TensorRT for GPU inference. On an RTX 3060, this drops OCR latency from 32ms to ~18ms.

from paddleocr import PaddleOCR

ocr = PaddleOCR(
    use_angle_cls=True,
    lang='en',
    use_gpu=True,
    use_tensorrt=True,  # Enable TRT
    precision='fp16',   # FP16 precision
)

First run will be slow (TRT builds optimized engines). Cache these in ~/.paddleocr/.

Option 3: Skip Frames

If you’re processing video, run inference every Nth frame. Plates don’t change between consecutive frames. Run every 3rd frame → effective 63 FPS throughput.

frame_count = 0
last_plates = []

while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break

    if frame_count % 3 == 0:
        last_plates, _ = read_plate(frame)

    # Use last_plates for tracking/display
    frame_count += 1

When This Pipeline Fails

Be honest: this doesn’t work universally.

  • Non-Latin scripts: PaddleOCR supports Chinese, Japanese, Korean, Arabic, etc., but you need the right lang parameter and dataset. I haven’t tested accuracy on Arabic plates.
  • Extreme angles: Plates at >45° are tough. YOLOv8 might detect them, but OCR accuracy drops below 60%. You’d need a perspective transform or 3D pose estimation (overkill for most cases).
  • Heavy occlusion: If a plate is 50% covered by dirt, no amount of preprocessing saves you. You need multi-frame fusion or synthetic data augmentation during training.
  • Real-time video at 4K: This pipeline is tuned for 1080p. At 4K, resize the frame first or you’ll blow your VRAM budget.

Math: Why Detection + OCR Beats End-to-End

Let’s formalize why two-stage works better.

Assume a single-stage model that directly predicts characters. For a license plate with NN characters at position (x,y)(x, y) and scale ss, the model must learn:

P(cix,y,s)i[1,N]P(c_i | x, y, s) quad forall i in [1, N]

where cic_i is the ii-th character. The problem: ss (scale) varies by 10× in real-world footage. You need scale-specific features for each character.

In two-stage:

  1. Stage 1 learns P(platex,y,s)P(text{plate} | x, y, s) — scale-invariant object detection (YOLO’s FPN handles this)
  2. Stage 2 learns P(cix,y)P(c_i | x', y') where (x,y)(x', y') are normalized coordinates in the cropped plate (scale is now fixed)

The conditional independence simplifies learning. Empirically, I got 94% accuracy with two-stage vs. 78% with a custom YOLO-char model on the same dataset.

FAQ

Q: Can I use EasyOCR instead of PaddleOCR?
Yes, but initialization time is 3-4× longer (8s vs 2s). Runtime inference is similar (~30ms). I prefer PaddleOCR because it supports TensorRT and has better angle correction out of the box.

Q: How much training data do I need for YOLOv8 plate detection?
I got [email protected] = 0.96 with 1200 images. You can start with 300-500 if you use heavy augmentation (mosaic, mixup). Below 200, the model overfits to specific lighting conditions.

Q: Does this work on license plates from different countries?
YOLO part: yes, plates are plates. OCR part: depends. PaddleOCR defaults to Latin characters. For Chinese plates, set lang='ch'. For mixed scripts (e.g., Saudi plates with Arabic + English), you’ll need a multi-language OCR model or two separate passes.

What I’d Change Next Time

If I were building this for production:

  • Tracking across frames: Right now, each frame is independent. A Kalman filter or DeepSORT tracker would smooth out misreads and reduce false positives.
  • Plate region rectification: Apply a homography transform to “flatten” angled plates before OCR. I suspect this would push accuracy from 94% to 97%+.
  • Synthetic data: Train YOLOv8 on a mix of real dashcam footage + synthetic plates (rendered 3D plates on random backgrounds). This would handle edge cases like rain, fog, and night glare.

For now, 47ms at 94% accuracy is good enough for most dashcam, parking, or traffic monitoring use cases. The real bottleneck in ALPR isn’t the models — it’s handling the long tail of weird real-world conditions. That’s where domain-specific preprocessing and post-processing matter more than switching to a fancier architecture.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 2,268 | TOTAL 113,269