High-Accuracy License Plate Recognition: YOLOv8 & PaddleOCR

Manual number checks at checkpoints slow down traffic and miss errors, especially at night or with unusual angles. We build AI-based ANPR systems that automatically detect and recognize license plates in any conditions. Our team delivers the project turnkey—from audit and algorithm tuning to deployment and ongoing support—ensuring reliable entry and exit system operation.

AI Development Areas

Frequently Asked Questions

Latest works

  • Development of a web application for FEEDME
    Development of a web application for FEEDME
    1344
  • Development of an online store for the company FURNORO
    Development of an online store for the company FURNORO
    1307
  • B2B Advance company logo design
    B2B Advance company logo design
    754
  • Development of a web application for Enviok
    Development of a web application for Enviok
    1050
  • AIDER company logo development
    AIDER company logo development
    994
  • CRM development for Chasseurs
    CRM development for Chasseurs
    1099

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 $18k–26k 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
  1. Analysis — discuss the scenario: parking lot, highway, city traffic. Collect 10,000+ frames from your cameras.
  2. Design — choose detector (YOLOv8n/m/l), configure OCR for the language region, design REST API integration.
  3. Development — train an ensemble model, perform augmentations (rotations, glares, noise). Optimize to p99 latency <50 ms.
  4. Testing — validate on recorded footage, then A/B test on live stream. Cross‑check with watchlist (if available).
  5. 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.