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

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.
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:
- [email protected]: 0.96
- [email protected]:0.95: 0.78
- Inference time: 8ms (RTX 3060, batch=1, FP16)
- Model size: 6.2 MB
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:
- Negative coordinates: If the detection box slightly overshoots the frame boundary (happens with
augment=Trueduring inference), you getx1 < 0. NumPy silently wraps this. Your crop starts from the wrong side of the image. - 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%. - 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.

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=127because 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
langparameter 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 characters at position and scale , the model must learn:
where is the -th character. The problem: (scale) varies by 10× in real-world footage. You need scale-specific features for each character.
In two-stage:
- Stage 1 learns — scale-invariant object detection (YOLO’s FPN handles this)
- Stage 2 learns where 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 coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,793 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (947 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (759 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (649 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (549 views)