Articles › AI & Computer Vision
License Plate Recognition with YOLO and OCR: A Complete Pipeline
Build a license plate recognition system: labeling data, training a YOLO detector, reading Persian plates, video tracking and running the model in .NET.
Automatic License Plate Recognition (ALPR, also called ANPR) powers parking systems, toll gates, access control and traffic monitoring. A modern ALPR system is a pipeline of deep learning models: one finds the plate, another reads it. This article walks through building one with YOLO, with special attention to Iranian (Persian) plates, which have their own format and character set.
The pipeline
camera frame → [1] plate detection (YOLO) → crop & rectify → [2] character recognition → [3] validation & tracking → result
- Detection — locate plates in the image as bounding boxes.
- Recognition — read the characters inside each plate crop.
- Post-processing — validate the format, combine readings across video frames, and store the result.
Splitting the problem this way lets each model focus on one task and be improved independently.
Step 1: collect and label data
Model quality depends mostly on data. Collect images that match your deployment conditions:
- the actual camera angle and height (gate camera vs street camera),
- day, night, infrared, rain, glare and motion blur,
- different vehicle types, dirty or partially occluded plates,
- motorcycle plates if relevant.
A few thousand well-labeled images from your real cameras beat tens of thousands of random internet images.
Label with a tool like CVAT, Label Studio or Roboflow, exporting in YOLO format — one .txt per image with class x_center y_center width height normalized to 0–1:
0 0.512 0.734 0.186 0.061
Split into train/validation/test (e.g. 80/10/10), making sure images from the same video or location don't leak across splits — otherwise validation scores look better than reality.
# plates.yaml
path: datasets/plates
train: images/train
val: images/val
test: images/test
names:
0: plate
Step 2: train the plate detector
With the Ultralytics package, training a custom YOLO detector is a few lines:
pip install ultralytics
from ultralytics import YOLO
model = YOLO("yolo11n.pt") # small pretrained model; try "s" or "m" for more accuracy
model.train(
data="plates.yaml",
epochs=100,
imgsz=640,
batch=16,
patience=20, # early stopping
degrees=5, translate=0.1, scale=0.5, fliplr=0.0, # no horizontal flip: it mirrors characters
)
metrics = model.val()
print(metrics.box.map50, metrics.box.map)
Key points:
- Start from pretrained weights (transfer learning) — it converges much faster with less data.
- Disable horizontal flip augmentation; mirrored plates don't exist in reality.
- Watch mAP@50 and recall on the validation set: for ALPR, missing a plate (low recall) is usually worse than an occasional false box, which the recognition step can reject.
- Small, distant plates benefit from a larger
imgsz(e.g. 960 or 1280), at the cost of speed.
Step 3: crop and normalize the plate
Before recognition, crop each detected plate with a small margin, then:
- resize to a fixed height (e.g. 64 px) keeping the aspect ratio,
- optionally correct perspective (detect the four corners with a keypoint/OBB model and warp to a rectangle),
- convert to grayscale and improve contrast (CLAHE) if your recognizer was trained that way.
import cv2
def crop_plate(frame, box, pad=0.08):
x1, y1, x2, y2 = box
w, h = x2 - x1, y2 - y1
x1, y1 = max(0, int(x1 - w * pad)), max(0, int(y1 - h * pad))
x2, y2 = min(frame.shape[1], int(x2 + w * pad)), min(frame.shape[0], int(y2 + h * pad))
return frame[y1:y2, x1:x2]
Step 4: reading the characters
There are two popular approaches.
Option A: characters as detection classes
Train a second YOLO model where each character is a class (digits 0–9 plus the Persian plate letters). Sort detected characters by x-coordinate to form the string. This works very well for fixed-format plates and is robust to spacing and fonts.
The standard Iranian private-car plate has the layout:
[2 digits] [letter] [3 digits] | [2-digit region code]
For example ۱۲ ب ۳۴۵ - ۶۷. Since the region code sits in a separate box on the right, assign characters to the main part or the region part by position.
Option B: sequence recognition (CRNN / transformer OCR)
A CRNN with CTC loss, or a small transformer OCR model, reads the whole crop as a sequence. It handles variable-length plates and needs only the plate text as a label, not per-character boxes, but it usually needs more training data.
Warning: General-purpose OCR engines (like Tesseract out of the box) perform poorly on plates: unusual fonts, the Persian letter set, low resolution and dirt. A model trained on your plates performs dramatically better.
Step 5: validate the format
Use the known plate structure to reject impossible readings and fix common confusions:
import re
PERSIAN_LETTERS = "بجدسصطقلمنوهیالفپتثزشعژ" # adapt to the letters your model outputs
PATTERN = re.compile(rf"^(\d{{2}})([{PERSIAN_LETTERS}])(\d{{3}})(\d{{2}})$")
def normalize(text: str) -> str:
return text.translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹", "0123456789"))
def validate(raw: str):
m = PATTERN.match(normalize(raw))
if not m:
return None
two, letter, three, region = m.groups()
return {"plate": f"{two}{letter}{three}", "region": region}
Format rules filter out a large share of errors (e.g. a letter predicted in a digit position).
Step 6: video, tracking and voting
On video, don't trust a single frame. Track each vehicle across frames (ByteTrack or BoT-SORT are built into Ultralytics) and vote across readings:
from collections import Counter, defaultdict
readings = defaultdict(Counter)
for result in model.track(source="rtsp://camera/stream", stream=True, persist=True):
for box in result.boxes:
if box.id is None:
continue
track_id = int(box.id)
text = recognize(crop_plate(result.orig_img, box.xyxy[0].tolist()))
if (plate := validate(text)) and box.conf > 0.5:
readings[track_id][plate["plate"] + plate["region"]] += 1
# when a track ends, the most common reading wins
best, votes = readings[track_id].most_common(1)[0]
Voting across 5–15 frames turns a 90% single-frame accuracy into a much higher final accuracy and removes flicker.
Measuring accuracy properly
Measure what matters to the business:
| Metric | Meaning |
|---|---|
| Detection recall | % of plates that were found |
| Character accuracy | % of characters read correctly |
| Full-plate accuracy | % of plates with every character correct — the number that matters |
| Latency | time from frame to result |
Evaluate on a test set from your cameras, separated by day/night and weather, and inspect failures — they tell you exactly what data to add next.
Deployment and serving
Export the trained models to ONNX (or TensorRT on NVIDIA, OpenVINO on Intel CPUs):
model.export(format="onnx", imgsz=640, dynamic=False, simplify=True)
Common architectures:
- Python service (FastAPI) running the models, called by your main application over HTTP or a message queue.
- Direct inference in .NET with
Microsoft.ML.OnnxRuntime— no Python in production:
using var session = new InferenceSession("plate-detector.onnx");
var input = new DenseTensor<float>(Preprocess(frame), [1, 3, 640, 640]);
using var results = session.Run([NamedOnnxValue.CreateFromTensor("images", input)]);
var output = results[0].AsTensor<float>();
var plates = PostProcess(output, confidenceThreshold: 0.4f, iouThreshold: 0.5f); // decode boxes + NMS
An ASP.NET Core backend can then store readings, match them against an allow-list (for gates), push live events with SignalR, and expose reports.
Practical tips
- Camera setup is half the accuracy: shutter speed fast enough to avoid motion blur, IR illumination at night, plates at least ~100–150 px wide in the frame.
- Process only a region of interest (the lane) to reduce false detections and CPU time.
- Keep a human-review queue for low-confidence readings in critical systems (tolls, fines).
- Respect privacy laws: plates are personal data — limit retention, secure storage and access.
Summary
An ALPR system combines a YOLO plate detector, a character recognizer trained on your plate format, rule-based validation and multi-frame voting. Data from real deployment conditions is the biggest factor in accuracy; transfer learning, appropriate augmentation and format validation do the rest. Export to ONNX to run efficiently on edge devices or directly inside a .NET application.
FAQ
How many images do I need?
For the plate detector, 1,000–3,000 varied, well-labeled images from your cameras is a solid start. Character recognition needs more examples per character, especially for rare letters.
Can it run in real time on a CPU?
Yes, with a small model (YOLO "n"/"s"), a reduced input size, a region of interest and ONNX Runtime or OpenVINO, multiple frames per second on a modern CPU is realistic. A GPU or edge accelerator handles many streams.
Why is night-time accuracy much lower?
Low light causes noise and blur, and headlights cause glare. Use IR cameras and illumination, and add night images to training data.