周末花两天手撸了一个车牌识别,CCPD 上 99%,换张手机照片就翻车了 🚗

64 阅读8分钟

前言

事情是这样的。

公司做车险业务,录单的时候要从车辆照片里提取车牌号。我们一直用的是蓝兴AI的车牌号识别接口,传张图过去,几百毫秒就返回车牌号、颜色和单双层,用着挺省心。

但作为一个有好奇心的程序员,我总觉得:"不就是个车牌识别吗?YOLO 一把梭,能有多难?"

于是某个周末,我决定自己从零撸一个。

剧透一下结局:模型跑通了,指标很好看,然后在真实照片上被教做人了。 😅

这篇文章就把这两天的过程完整记录下来,代码都能直接跑,顺便聊聊踩过的坑。


🎯 先定个目标

输入一张车辆照片,输出:

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

拆一下,其实就三步:

  1. 找到车牌在哪:目标检测
  2. 认出车牌上的字:文字识别
  3. 判断车牌颜色:蓝牌、绿牌、黄牌……

技术选型我没纠结,直接上最经典、资料最多的组合:

步骤方案为啥选它
检测YOLOv8n轻、快、文档全,pip install 就能用
识别LPRNet不用切字符,CTC 端到端,模型才几百 KB
颜色HSV 统计先糊一个能用的,后面再说

整体流程长这样:

pipeline.png


📦 Day 1 上午:搞数据

车牌识别最常用的开源数据集是 CCPD,几十万张图,蓝牌绿牌都有。

第一次打开数据集的时候我愣了一下:没有标注文件?

仔细一看,原来标注全写在文件名里了:

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

是不是很朴素 😂。用 - 切开,第 3 段是车牌框坐标,第 5 段是车牌号的编码。

写个脚本转成 YOLO 格式:

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

车牌号也顺手解析出来,后面训练识别模型要用:

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:])

💡 冷知识:车牌字母里没有 I 和 O,因为太容易和 1、0 混淆,国标直接禁用了。所以字符集里也不用放它们。


🏋️ Day 1 下午:训练检测模型

YOLOv8 真的是把门槛降到了地板。写个配置文件:

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

然后一行命令开训:

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

去吃了个饭回来,mAP50 已经 0.99 了。

当时的我:🤩 这也太简单了吧!

(此处请记住这个表情,后面要用)

导出 ONNX,后面部署用:

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

🔤 Day 2 上午:识别车牌字符

检测只是找到车牌,接下来要把车牌上的字认出来。

LPRNet 的思路很巧妙:不切单个字符,直接把整张车牌图喂进去,输出一个序列,每个位置是"这里是哪个字"的概率,然后用 CTC 解码。

CTC 解码听着高大上,贪心版本其实就两条规则:去掉空白,合并相邻的重复字符。

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)

举个例子,模型输出 川川-AA-Q-33-C-2-8(- 是空白),解码后就是 川AQ3C28。

为什么要有空白?因为车牌里可能有连续相同的字符,比如 京A11111,没有空白分隔的话,11111 会被合并成一个 1。

LPRNet 有现成的开源实现,拿 CCPD 解析出来的车牌号直接训就行,这里不展开了。


🚀 Day 2 下午:串起来,包成 HTTP 服务

两个模型都有了,写个推理类串起来。只依赖 onnxruntime 和 opencv,不用装 torch:

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

光有个类不够过瘾,再用 FastAPI 包成接口,前端同学也能直接调:

# 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 "file=@car.jpg" http://127.0.0.1:8000/plate

整个流程跑起来大概是这样(示意):

effect.png

周日傍晚,CCPD 测试集上整牌准确率很漂亮,我觉得可以收工了。


💥 然后,翻车了

周一上班,我从业务系统里随手拿了一批真实的车辆照片去测。

当时的我:🤡

问题一个接一个冒出来:

1. 外省车牌省份全错

CCPD 是在安徽采集的,数据里绝大部分是"皖"牌。模型基本只认识"皖",看到"川""粤"就开始瞎猜。

2. 手机斜拍、远拍直接漏检

CCPD 主要是停车场正面拍的,业务员现场拍的照片角度千奇百怪,还有夜间、逆光、车牌反光。

3. 双层黄牌识别成一团乱码

货车、挂车常见的双层车牌,LPRNet 默认按单行处理,直接懵了。

4. 截图、翻拍、PDF 完全没见过

车险资料里很多是手机翻拍电脑屏幕、或者 PDF 扫描件,摩尔纹、压缩噪点一堆,训练集里一张都没有。

5. 颜色判断一言难尽

HSV 阈值在光线正常时还行,傍晚一拍,蓝牌被判成黑牌,绿牌被判成黄牌。

我把同一批"难图"丢给我们平时用的蓝兴AI车牌号识别对比了一下,差距主要就在这几类场景上。官网有网页版可以直接上传体验,每天免费 10 次,图片和 PDF 都行,大家想对比自己的模型也可以拿来当个参照。


🤔 复盘:难的从来不是模型

折腾完这一圈,我最大的感受是:

模型结构决定下限,数据决定上限。

YOLOv8 + LPRNet 在开源数据集上跑到 99% 真的不难,难的是:

你以为的难点实际的难点
选什么模型去哪找覆盖全国各省份的数据
调什么超参怎么覆盖斜拍、夜间、翻拍、PDF
精度怎么到 99%双层牌、港澳牌、新能源牌这些长尾怎么处理
推理怎么加速线上并发、错误码、可追溯这些工程问题

如果想自己继续优化,几个方向供参考:

  • 补数据:按省份、按颜色补充真实样本,不够就用字体渲染合成;
  • 加增强:亮度对比度、模糊、旋转、透视、JPEG 压缩,模拟真实拍摄条件;
  • 分组评估:一定要按省份、颜色分开算准确率,总准确率会骗人;
  • 颜色改用分类网络:比 HSV 阈值稳定得多。

📝 最后

这个周末没白折腾,起码把检测、CTC 识别、ONNX 部署这条链路彻底搞明白了,强烈推荐想入门 CV 的同学自己撸一遍。

至于公司的业务?我默默地把接口调用代码改回了蓝兴AI 🙃。毕竟要把上面那张"实际的难点"表格一项项解决掉,可不是一个周末能搞定的。

完整代码就是上面这些,复制下来补上模型文件就能跑。有问题欢迎评论区交流,觉得有帮助的话点个赞再走呗 👍