跪拜 Guibai
← Back to the summary

A Weekend Plate Recognizer Hits 99% on CCPD, Then Real Photos Humble It

前言

It goes like this.

The company does auto insurance business. When recording a policy, we have to extract the plate number from vehicle photos. We've been using Bluexing AI's license plate recognition API: send an image, and within a few hundred milliseconds it returns the plate number, color, and single- or double-deck type. It's been dependable.

But as a curious programmer, I kept thinking: "Isn't it just plate recognition? YOLO everything—how hard can it be?"

So one weekend I decided to build one from scratch, end to end.

Spoiler for the ending: The model ran, the metrics looked great, and then it got humbled on real photos. 😅

This post records the full two-day process. All the code runs directly, and I'll talk through the pitfalls I hit.


🎯 Set a Target

Input a vehicle photo, output:

{
  "plateNumber": "川AQ3C28",
  "plateColor": "blue"
}

Breaking it down, there are really three steps:

  1. Find where the plate is: object detection
  2. Recognize the characters on the plate: text recognition
  3. Determine the plate color: blue, green, yellow...

I didn't agonize over the tech stack—I went straight to the most classic, most well-documented combination:

Step Approach Why I chose it
Detection YOLOv8n Light, fast, well-documented, pip install and it works
Recognition LPRNet No character segmentation, CTC end-to-end, the model is only a few hundred KB
Color HSV statistics Get something working first, optimize later

The overall pipeline looks like this:

pipeline.png


📦 Day 1 Morning: Data

The most common open-source dataset for license plate recognition is CCPD, with hundreds of thousands of images covering blue and green plates.

The first time I opened the dataset, I was stunned: No annotation files?

Looking closely, the annotations are all encoded in the filenames:

025-95_113-154&383_386&473-386&473_177&454_154&383_363&402-0_0_22_27_27_33_16-37-15.jpg

Pretty crude, right 😂. Split by -; segment 3 is the plate bounding box coordinates, segment 5 is the plate number encoding.

I wrote a script to convert to YOLO format:

import os
import cv2

def ccpd_to_yolo(img_dir, label_dir):
    os.makedirs(label_dir, exist_ok=True)
    for name in os.listdir(img_dir):
        if not name.endswith(".jpg"):
            continue
        box = name.split("-")[2]
        (x1, y1), (x2, y2) = [map(int, p.split("&")) for p in box.split("_")]
        img = cv2.imread(os.path.join(img_dir, name))
        if img is None:
            continue
        h, w = img.shape[:2]
        cx, cy = (x1 + x2) / 2 / w, (y1 + y2) / 2 / h
        bw, bh = (x2 - x1) / w, (y2 - y1) / h
        with open(os.path.join(label_dir, name[:-4] + ".txt"), "w") as f:
            f.write(f"0 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n")

I also parsed out the plate number for later training of the recognition model:

PROVINCES = ["皖", "沪", "津", "渝", "冀", "晋", "蒙", "辽", "吉", "黑", "苏", "浙", "京", "闽", "赣", "鲁",
             "豫", "鄂", "湘", "粤", "桂", "琼", "川", "贵", "云", "藏", "陕", "甘", "青", "宁", "新"]
ALPHABETS = list("ABCDEFGHJKLMNPQRSTUVWXYZ")
ADS = list("ABCDEFGHJKLMNPQRSTUVWXYZ0123456789")

def ccpd_plate_text(name):
    idx = list(map(int, name.split("-")[4].split("_")))
    return PROVINCES[idx[0]] + ALPHABETS[idx[1]] + "".join(ADS[i] for i in idx[2:])

💡 Fun fact: The letters I and O are absent from license plate alphabets, because they're too easily confused with 1 and 0; the national standard forbids them. No need to include them in the character set.


🏋️ Day 1 Afternoon: Train the Detection Model

YOLOv8 really lowered the barrier to the floor. Write a config file:

# plate.yaml
path: datasets/plate
train: images/train
val: images/val
names:
  0: plate

Then train with one command:

yolo detect train data=plate.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=32

Went to eat; came back, and mAP50 was already 0.99.

Me at that moment: 🤩 This is too easy!

(Remember that emoji, it'll matter later)

Export to ONNX for deployment later:

yolo export model=runs/detect/train/weights/best.pt format=onnx imgsz=640

🔤 Day 2 Morning: Recognize Plate Characters

Detection just finds the plate; next, recognize the characters on it.

LPRNet's approach is elegant: Don't segment individual characters. Feed the whole plate image in, output a sequence where each position is the probability of "which character is here", then decode with CTC.

CTC decoding sounds high-level, but the greedy version is just two rules: drop blanks, merge adjacent repeated characters.

def ctc_greedy_decode(indices, charset, blank):
    out, prev = [], blank
    for i in indices:
        if i != blank and i != prev:
            out.append(charset[i])
        prev = i
    return "".join(out)

For example, model output 川川-AA-Q-33-C-2-8 (- is the blank), decoded becomes 川AQ3C28.

Why do blanks exist? Because a plate might have consecutively identical characters, like 京A11111; without blank separating them, 11111 would merge into a single 1.

LPRNet has ready open-source implementations; just train on the plate numbers parsed from CCPD. I won't expand further here.


🚀 Day 2 Afternoon: Wire It Up, Wrap It as an HTTP Service

Both models are ready; write an inference class. Only depends on onnxruntime and opencv, no torch needed:

import cv2
import numpy as np
import onnxruntime as ort

CHARS = list("京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新"
             "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ学警港澳挂使领") + ["-"]
BLANK = len(CHARS) - 1


class SimpleLPR:
    def __init__(self, det_path, rec_path, conf=0.4, iou=0.5):
        self.det = ort.InferenceSession(det_path, providers=["CPUExecutionProvider"])
        self.rec = ort.InferenceSession(rec_path, providers=["CPUExecutionProvider"])
        self.conf, self.iou = conf, iou

    def detect(self, img, size=640):
        h, w = img.shape[:2]
        r = min(size / h, size / w)
        nh, nw = int(h * r), int(w * r)
        canvas = np.full((size, size, 3), 114, np.uint8)
        canvas[:nh, :nw] = cv2.resize(img, (nw, nh))
        x = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).transpose(2, 0, 1)[None].astype(np.float32) / 255
        pred = self.det.run(None, {self.det.get_inputs()[0].name: x})[0][0].T
        pred = pred[pred[:, 4] > self.conf]
        if len(pred) == 0:
            return []
        boxes = np.stack([pred[:, 0] - pred[:, 2] / 2, pred[:, 1] - pred[:, 3] / 2,
                          pred[:, 2], pred[:, 3]], 1) / r
        keep = cv2.dnn.NMSBoxes(boxes.tolist(), pred[:, 4].tolist(), self.conf, self.iou)
        res = []
        for i in np.array(keep).flatten():
            x0, y0, bw, bh = boxes[i]
            res.append((max(int(x0), 0), max(int(y0), 0), int(x0 + bw), int(y0 + bh)))
        return res

    def recognize(self, crop):
        x = cv2.resize(crop, (94, 24)).astype(np.float32)
        x = ((x - 127.5) * 0.0078125).transpose(2, 0, 1)[None]
        idx = self.rec.run(None, {self.rec.get_inputs()[0].name: x})[0][0].argmax(0)
        text, prev = "", BLANK
        for i in idx:
            if i != BLANK and i != prev:
                text += CHARS[i]
            prev = i
        return text

    @staticmethod
    def guess_color(crop):
        h, s, v = [c.mean() for c in cv2.split(cv2.cvtColor(crop, cv2.COLOR_BGR2HSV))]
        if v < 60:
            return "black"
        if s < 50:
            return "white"
        if 100 <= h <= 130:
            return "blue"
        if 15 <= h <= 35:
            return "yellow"
        return "green" if 35 < h < 100 else "unknown"

    def __call__(self, img):
        out = []
        for x1, y1, x2, y2 in self.detect(img):
            crop = img[y1:y2, x1:x2]
            if crop.size:
                out.append({"plateNumber": self.recognize(crop), "plateColor": self.guess_color(crop)})
        return out

A bare class isn't satisfying enough, so I wrapped it with FastAPI so frontend colleagues can call it directly:

# server.py
import cv2
import numpy as np
from fastapi import FastAPI, File, UploadFile

from lpr import SimpleLPR

app = FastAPI()
lpr = SimpleLPR("models/plate_det_yolov8n.onnx", "models/lprnet.onnx")


@app.post("/plate")
async def plate(file: UploadFile = File(...)):
    data = np.frombuffer(await file.read(), np.uint8)
    img = cv2.imdecode(data, cv2.IMREAD_COLOR)
    if img is None:
        return {"code": 400, "msg": "图片解析失败"}
    return {"code": 200, "data": lpr(img)}
pip install fastapi uvicorn python-multipart onnxruntime opencv-python
uvicorn server:app --port 8000
curl -F "[email protected]" http://127.0.0.1:8000/plate

The whole flow looks like this (schematic):

effect.png

Sunday evening: exact-match accuracy on the CCPD test set was beautiful. I thought I could wrap up.


💥 Then, the Crash

Monday at work, I pulled a batch of real vehicle photos from the business system to test.

Me at that moment: 🤡

Problems kept popping up:

1. Out-of-province plates all misclassified

CCPD was collected in Anhui, and most data is "皖" plates. The model basically only knows 皖; it started guessing randomly on 川, 粤, etc.

2. Phone tilt shots and distant shots missed detection

CCPD is mostly front-facing shots from parking lots. Business staff took photos at every possible angle, plus night, backlighting, and plate glare.

3. Double-deck yellow plates turned into gibberish

Double-deck plates common on trucks and trailers: LPRNet defaults to single-row handling and crashes.

4. Screenshots, re-photographed, and PDFs completely unseen

In car insurance material, a lot of it is phone re-photography of a computer screen, or PDF scans, with moiré patterns and compression noise. Not one such image in the training set.

5. Color judgment was a mess

The HSV thresholds work fine in normal light; shoot in the evening, and a blue plate gets judged black, a green plate yellow.

I ran the same batch of "hard images" through the Bluexing AI plate recognition API we normally use, and the gap was exactly on these scenarios. The official site has a web demo where you can upload directly, free 10 times a day, images and PDFs both supported. If you want to compare your own model, it's a good reference.


🤔 Retrospective: The Hard Part Is Never the Model

After all this, my biggest takeaway:

Model architecture sets the floor; data sets the ceiling.

YOLOv8 + LPRNet reaching 99% on an open dataset isn't hard. The hard part is:

What I thought was hard What's actually hard
Which model to choose Where to find data covering all provinces
Which hyperparameters to tune How to cover tilted shots, night, re-photography, PDFs
How to get accuracy to 99% How to handle long-tail cases: double-deck, HK-Macau, new-energy plates
How to speed up inference Engineering problems: online concurrency, error codes, traceability

If you want to keep optimizing, here are a few directions:


📝 Finally

The weekend wasn't wasted. At least I thoroughly understand the detection, CTC recognition, and ONNX deployment chain. I strongly recommend anyone wanting to get into computer vision to work through it themselves.

As for the company's business? I quietly switched the API call code back to Bluexing AI 🙃. Because ticking off every item in that "actually hard" table is not something a weekend can handle.

The complete code is above. Copy it, add the model files, and it runs. Questions welcome in the comments; give a like if it helped 👍