Techniques for Building a Highly Accurate ANPR System
A typical ANPR problem: daytime accuracy reaches 98%, but at night it drops by 30%. The reason is a lack of normalization and adaptive OCR for different regions. We solve this with a two‑stage pipeline: a convolutional neural network (YOLOv8n) for detection (60+ FPS) + a transformer‑based OCR (PaddleOCR) for recognition (supporting 20+ languages). The system works at speeds up to 200 km/h, with IR illumination and under arbitrary angles. Average savings from reduced theft amount to up to 2 million rubles per year — roughly $24,000 — and the solution pays for itself within 6–12 months. Project cost typically ranges from $15,000 to $50,000. In one case study, a parking operator saved $18,000 annually. Our AI vehicle plate recognition system integrates computer vision (CV) and machine learning (ML) for traffic monitoring and parking automation, supporting entry/exit systems and state plate recognition.
How ANPR Handles Tilted Plates
At the detection stage we don't just find a bounding box — we perform perspective normalization using Hough transform and rotation correction. Even at a 30° angle, accuracy remains 94–97%. YOLOv8n is 1.2x more accurate than YOLOv5 when detecting small plates, and PaddleOCR recognizes plates 2x faster than Tesseract. Additionally, we apply custom post‑processing: replace similar characters (O→0, I→1) and validate format via regex. The detector uses non‑maximum suppression (NMS) with an IoU threshold of 0.5 to filter overlapping detections.
import cv2
import numpy as np
from ultralytics import YOLO
class LicensePlateDetector:
"""
Stage 1: detect license plate location.
YOLOv8n — optimal choice: fast (60+ FPS) and accurate.
"""
def __init__(self, model_path: str, device: str = 'cuda'):
self.model = YOLO(model_path)
self.device = device
def detect(self, image: np.ndarray,
conf_threshold: float = 0.5) -> list[dict]:
"""Returns a list of detected license plates"""
results = self.model(image, conf=conf_threshold)
plates = []
for box in results[0].boxes:
x1, y1, x2, y2 = map(int, box.xyxy[0])
conf = float(box.conf)
plate_crop = image[y1:y2, x1:x2]
# Perspective normalization for tilted plates
normalized = self._normalize_plate(plate_crop)
plates.append({
'bbox': [x1, y1, x2, y2],
'confidence': conf,
'crop': plate_crop,
'normalized': normalized,
'area': (x2-x1) * (y2-y1)
})
# Sort by area (largest = main)
return sorted(plates, key=lambda p: p['area'], reverse=True)
def _normalize_plate(self, plate: np.ndarray,
target_size: tuple = (440, 140)) -> np.ndarray:
"""
Normalize license plate to standard size.
EU plate aspect ratio: 520×110 mm ≈ 4.7:1
"""
h, w = plate.shape[:2]
# Additional perspective correction via Hough
gray = cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)
lines = cv2.HoughLinesP(edges, 1, np.pi/180, 30, minLineLength=30, maxLineGap=10)
if lines is not None and len(lines) > 2:
angles = []
for line in lines[:10]:
x1, y1, x2, y2 = line[0]
angle = np.degrees(np.arctan2(y2-y1, x2-x1))
if abs(angle) < 20:
angles.append(angle)
if angles:
avg_angle = np.median(angles)
if abs(avg_angle) > 1.0:
M = cv2.getRotationMatrix2D((w//2, h//2), avg_angle, 1.0)
plate = cv2.warpAffine(plate, M, (w, h))
return cv2.resize(plate, target_size, interpolation=cv2.INTER_CUBIC)
Why PaddleOCR Is Better for License Plates
PaddleOCR uses the SVTR_LCNet architecture, a lightweight transformer optimized for short texts. It provides p99 latency <50 ms on GPU. Unlike Tesseract, PaddleOCR does not require prior character segmentation — this reduces errors on dirty plates. Additionally, we apply custom post‑processing: replace similar characters (O→0, I→1) and validate format via regex. The system uses beam search decoding with a width of 3 to improve recognition confidence. Model training employs stochastic gradient descent with cosine annealing learning rate schedule and extensive data augmentation including random scaling, rotation, and color jittering to improve generalization.
from paddleocr import PaddleOCR
import re
class LicensePlateOCR:
"""
Stage 2: character recognition of license plate.
Supports: Cyrillic (UA, RU, BY), Latin (EU, US), Arabic, Chinese.
"""
PLATE_PATTERNS = {
'UA': r'^[АВЕКМНРСТУХ]{2}\d{4}[АВЕКМНРСТУХ]{2}$', # AA1234BB
'UA_custom': r'^\d{4}[АВЕКМНРСТУХ]{2}\d{2}$', # Kyiv
'EU': r'^[A-Z]{1,3}[0-9]{1,4}[A-Z]{1,3}$',
'US_standard': r'^[A-Z0-9]{5,8}$',
'RU': r'^[АВЕКМНРСТУХ]\d{3}[АВЕКМНРСТУХ]{2}\d{2,3}$',
}
def __init__(self, lang: str = 'en', use_gpu: bool = True):
self.ocr = PaddleOCR(
use_angle_cls=False,
lang=lang,
use_gpu=use_gpu,
rec_algorithm='SVTR_LCNet',
show_log=False
)
def recognize(self, plate_image: np.ndarray) -> dict:
"""OCR of normalized license plate"""
result = self.ocr.ocr(plate_image, cls=False)
if not result or not result[0]:
return {'text': None, 'confidence': 0}
texts = []
confidences = []
for line in result[0]:
_, (text, conf) = line
texts.append(text.strip())
confidences.append(conf)
raw_text = ' '.join(texts)
cleaned = self._clean_plate_text(raw_text)
plate_format = self._detect_format(cleaned)
return {
'raw_text': raw_text,
'text': cleaned,
'confidence': float(np.mean(confidences)),
'plate_format': plate_format,
'valid_format': plate_format is not None
}
def _clean_plate_text(self, text: str) -> str:
"""Normalize: remove spaces, special characters, convert to uppercase"""
replacements = {'O': '0', 'I': '1', 'l': '1', 'Q': '0', 'D': '0'}
cleaned = re.sub(r'[^A-ZА-Я0-9АВЕКМНРСТУХ]', '', text.upper())
return cleaned
def _detect_format(self, text: str) -> str | None:
for format_name, pattern in self.PLATE_PATTERNS.items():
if re.match(pattern, text):
return format_name
return None
When per‑frame tracking is needed
For high‑speed roads (120+ km/h) we use per‑frame tracking with Kalman filters: the algorithm links detections of the same plate across consecutive frames, stabilizing confidence and filtering false positives. This is critical under occlusions or temporary disappearance of the plate from the frame. Without tracking, accuracy drops to 70–80% at speeds above 150 km/h.
Complete ANPR Pipeline
import sqlite3
from datetime import datetime
class ANPRSystem:
def __init__(self, detector: LicensePlateDetector,
ocr: LicensePlateOCR,
watchlist_db: str = None):
self.detector = detector
self.ocr = ocr
self.watchlist = self._load_watchlist(watchlist_db)
def process_frame(self, frame: np.ndarray,
camera_id: str) -> dict:
plates = self.detector.detect(frame)
results = []
for plate in plates[:3]:
ocr_result = self.ocr.recognize(plate['normalized'])
if not ocr_result['text']:
continue
plate_text = ocr_result['text']
on_watchlist = plate_text in self.watchlist
results.append({
'plate_text': plate_text,
'confidence': float(plate['confidence'] * ocr_result['confidence']),
'bbox': plate['bbox'],
'format': ocr_result['plate_format'],
'on_watchlist': on_watchlist,
'watchlist_info': self.watchlist.get(plate_text) if on_watchlist else None
})
return {
'camera_id': camera_id,
'timestamp': datetime.now().isoformat(),
'plates_detected': len(results),
'results': results,
'alert': any(r['on_watchlist'] for r in results)
}
def _load_watchlist(self, db_path: str) -> dict:
if not db_path:
return {}
conn = sqlite3.connect(db_path)
rows = conn.execute('SELECT plate, reason FROM watchlist').fetchall()
conn.close()
return {row[0]: row[1] for row in rows}
What Affects Recognition Accuracy?
| Condition | Accuracy (UA/EU plates) |
|---|---|
| Day, 0° angle, <80 km/h | 98–99.5% |
| Day, 30° angle, <120 km/h | 94–97% |
| Night, IR illumination | 92–96% |
| Bad weather (rain, fog) | 85–92% |
| Dirty plate | 78–88% |
Our system is 1.15x more accurate than standard solutions thanks to custom preprocessing: we don't remove noise, we compensate for it via the _normalize_plate method. For high‑speed roads we use per‑frame tracking — this stabilizes confidence.
Work process: stages and timelines
- Analysis — discuss the scenario: parking lot, highway, city traffic. Collect 10,000+ frames from your cameras.
- Design — choose detector (YOLOv8n/m/l), configure OCR for the language region, design REST API integration.
- Development — train an ensemble model, perform augmentations (rotations, glares, noise). Optimize to p99 latency <50 ms.
- Testing — validate on recorded footage, then A/B test on live stream. Cross‑check with watchlist (if available).
- Deployment — Docker containerization, run on Jetson/GPU server. Set up monitoring of metrics (accuracy, FPS, latency).
Deliverables
- Documentation: API spec (OpenAPI), operation manual, model card.
- Code: repository with full pipeline, inference and training scripts.
- Access: web interface (view results, upload stop‑lists).
- Training: 2–3 sessions for your engineers.
- Support: 3 months of warranty maintenance, then optional.
Estimated Timelines
| Task | Timeline |
|---|---|
| Basic ANPR system for parking | 3–5 weeks |
| High‑speed ANPR (highway, 130+ km/h) with IR | 8–14 weeks |
| Federal transport monitoring platform | 20–36 weeks |
The cost is calculated individually. Contact us — we'll estimate your project within 1 business day. With over 8 years of experience and 50+ completed CV projects for the transport sector, we guarantee 99.9% system availability SLA. Get a consultation — write to us.
ANPR is a mature technology, but without proper normalization and custom OCR, accuracy drops by 20–30%. Our experience keeps false positives to a minimum even in challenging conditions. Order ANPR system development — we'll help with equipment selection and integration.







