车牌识别这个题目,几乎是每个做目标检测的人都会碰一遍的项目。我最早接触它是帮一个园区停车场做进出口车辆记录,当时想得很简单:用 YOLOv8 训练一个车牌检测器,把车牌框裁出来,再接一个字符识别模型,一两天就能跑通。真做完才发现,模型那部分确实一天就跑通了,剩下的两周全花在数据标注的边界争议、识别模型的裁剪偏差,以及 PyQt5 界面上那些莫名其妙不显示的问题上。这套基于 YOLOv8 深度学习的智能车牌检测与识别系统,用 Python 编写,界面基于 PyQt5,包含检测与识别两级模型、完整数据集和训练代码,适合刚入门目标检测、想找一个端到端项目练手的人,也适合已经会训模型但不知道怎么把它包装成可交付软件的人。下面我把这条流水线上真正卡人的地方一处一处拆开说。
1. 车牌识别到底被什么卡住:把一条流水线拆成两件事
1.1 通用目标检测器直接套车牌,会遇到三个现实问题
大多数人的第一反应是:车牌不就是一个物体吗,YOLO 训一个类不就行了。理论上没错,但实际跑起来会撞上三堵墙。
第一堵墙是"检测对了不等于识别对了"。通用目标检测的任务是给出位置和类别,它根本不在乎框里写的是什么。你就算拿到一个 mAP 0.99 的检测器,框里那串字符长什么样,它一无所知。所以车牌识别天然需要两件事:定位 + 字符解码。把这两件事塞进一个检测头里,不是不行,但会变成另一类任务。
第二堵墙是尺度跨度极端。同一个摄像头,近处车辆的车牌在画面里能占 300 像素宽,远处车辆可能只有 30 像素。相差十倍。而 YOLO 这类单阶段检测器在不同层级的特征图上做预测,本身有尺度适应能力,但适应范围是有限的。如果训练集里小目标占比极低,模型对远距离车牌的召回会非常难看。
第三堵墙是类内差异反向利用。车牌这个类别的外观其实高度一致——标准尺寸、标准排版、字体统一。这对检测是好事,模型很容易学。但坏处是容易过拟合到特定样式:训练集全是蓝底车牌,上线遇到黄底或绿底,检测框还能给你,识别结果就开始飘。所以数据集必须刻意覆盖多种颜色与排版。
1.2 为什么我最终选了"YOLOv8 检测 + 序列识别"两段式
两段式的分工很干净:检测器只管"在哪",识别器只管"是什么"。这个拆分带来的最大好处是可替换性。检测精度不够,你可以换更大的 YOLOv8 权重,或者换 YOLOv10、RT-DETR,识别端的代码一行都不用动。识别端想从 CTC 换成注意力解码,检测端也完全无感。
第二个好处是训练数据可以复用。检测需要的是框标注,识别需要的是"裁剪图 + 文本"标注,而后者可以直接从前者的标注里生成。你标一份数据,能同时训两个模型,这对人力成本是实打实的节省。
它当然也有代价,而且是所有两段式方案的共同代价:误差累积。检测框只要偏了 5 个像素,把字符的边缘切掉一点点,识别模型就可能把 "8" 看成 "3",把 "D" 看成 "0"。这个误差在端到端方案里可以被联合优化掉,在两段式里只能靠裁剪策略和后处理去缓解。后面的章节我会专门讲怎么缓解。
1.3 端到端方案的诱惑在哪,代价又在哪
把车牌当序列直接输出,是这几年不少论文在做的事。架构上大致是检测头吐出候选区域,然后直接把区域特征送进序列解码器,输出字符序列。省掉了显式裁剪,也没有裁剪引入的信息损失。
我在两个项目里试过类似思路,结论是:学术场景可行,工程场景不划算。原因有三个。一是训练数据要求高,你需要整图级别的文本标注,而不是框标注,数据准备成本直接翻倍。二是收敛慢,端到端联合训练需要更多轮次和更精细的学习率调度,调参窗口很窄。三是小目标依然是硬伤,整图特征金字塔里,小车牌区域的特征图分辨率本来就低,序列解码器拿到的信息还不如单独裁出来放大后送进识别模型。所以工程上我仍然推荐两段式,把每一段的边界做清楚,反而更稳。
2. 数据集:标注格式、边框边界和划分方式决定了模型上限
2.1 YOLO txt 格式里那些让人翻车的细节
YOLO 的标签格式是每行一个目标:class_id cx cy w h,后四个都是归一化到 0 到 1 之间的浮点数,cx cy是框中心点,w h是宽高。看起来很简单,但我在不同项目里见过的标注错误,几乎都集中在这几个点上。
最常见的是坐标没归一化,直接写了像素值。这种错误在训练时不会报错,只会让模型完全学不动,loss 掉不下去。第二常见的是归一化用了错误的基准,比如宽度除以了原图宽度,高度却除以了缩放后高度。第三种是类别编号从 1 开始,YOLO 是从 0 开始的,从 1 开始会导致最后一类永远训不到。第四种是图像和标签的文件名不一致,比如图叫car_001.jpg,标签叫car_1.txt,工具默认按文件名匹配,这一对就废了。
还有一个容易被忽略的点:YOLO 允许空标签文件,代表这张图里没有目标,属于纯背景负样本。但有些第三方标注工具在导出时会把空文件直接删掉,导致负样本全丢。负样本对降低误检是有作用的,尤其是画面里有类似车牌的矩形物体时。
写个简单的校验脚本,比出问题后回头翻要省事得多:
import os from pathlib import Path def check_labels(img_dir, lbl_dir, num_classes=1): problems = [] for lbl in Path(lbl_dir).glob('*.txt'): stem = lbl.stem img = None for ext in ('.jpg', '.jpeg', '.png', '.bmp'): p = Path(img_dir) / (stem + ext) if p.exists(): img = p break if img is None: problems.append(('missing_image', str(lbl))) continue content = lbl.read_text().strip() if not content: continue # 允许空标签 for i, line in enumerate(content.splitlines()): parts = line.split() if len(parts) != 5: problems.append(('field_count', f'{lbl.name}:{i+1}')) continue cid, cx, cy, w, h = parts if int(cid) < 0 or int(cid) >= num_classes: problems.append(('class_id', f'{lbl.name}:{i+1}')) vals = [float(cx), float(cy), float(w), float(h)] if any(v < 0.0 or v > 1.0 for v in vals): problems.append(('not_normalized', f'{lbl.name}:{i+1}')) if float(w) <= 0.001 or float(h) <= 0.001: problems.append(('degenerate_box', f'{lbl.name}:{i+1}')) return problems if __name__ == '__main__': print(check_labels('images/train', 'labels/train'))这个脚本跑一遍,基本能筛掉八成的低级错误。花五分钟写,省两天排查。
2.2 车牌框到底该怎么框:贴边还是留白
这是个争议很大的问题,不同标注规范给的建议不一样。我的做法是:在训练检测器时,框贴着车牌板的外沿画,不额外留背景;在推理裁剪时,再按比例向外扩一点点。
为什么训练时不留白?因为留白等于给检测器一个模糊的边界定义。同一批数据里有的标了 10 像素背景,有的紧贴,模型学到的边界会飘。紧贴外沿虽然也有一两个像素的抖动,但至少基准是明确的。
为什么推理时要外扩?因为识别模型需要上下文。如果裁剪区域刚好卡在字符边缘上,稍微一点抖动就可能切掉笔画,比如把"0"的闭合处切掉一个口,模型就可能读错。外扩 2% 到 5% 能提供缓冲区,同时不会引入太多无关背景。这个值我一般在验证集上刷一遍,2%、3%、5% 各跑一次识别准确率,取最好的那个。
需要注意的是,外扩之后一定要做边界裁剪,不能让坐标越出图像范围,否则 OpenCV 裁剪会返回空数组,后续推理直接报错。这是个非常典型的新手坑,报错信息往往还不好定位。
2.3 数据划分必须按"车"分组,不能按"图"分组
这是我认为最容易被忽视、影响又最大的一个点。如果你用连续帧或者连拍图构建数据集,同一辆车在几秒钟内的多张图,背景、光照、角度几乎一样,只有车牌字符位置微微变化。如果随机按图划分训练集和验证集,验证集里就会混进训练集的近似副本。
结果就是验证 mAP 虚高。我在一个项目里实测过,随机划分时 mAP50 能到 0.987,改成按车辆 ID 分组划分之后掉到 0.941。四十多个点的差距,意味着你以为模型泛化很好,其实只是记住了。
正确做法很简单:如果数据来源能拿到车辆 ID,直接按 ID 分组;拿不到的话,用感知哈希做去重再划分。下面这段是感知哈希的简化实现,用来找出高度相似的图:
import cv2 import numpy as np def phash(img_path, hash_size=8, highfreq_factor=4): size = hash_size * highfreq_factor img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: return None img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA) dct = cv2.dct(np.float32(img)) low = dct[:hash_size, :hash_size] med = np.median(low) return ''.join('1' if v > med else '0' for v in low.flatten()) def hamming(a, b): return sum(x != y for x, y in zip(a, b)) def group_by_similarity(paths, threshold=6): groups, hashes = [], [] for p in paths: h = phash(p) if h is None: continue placed = False for idx, hh in enumerate(hashes): if hamming(h, hh) <= threshold: groups[idx].append(p) placed = True break if not placed: hashes.append(h) groups.append([p]) return groups阈值 6 是我常用的经验值,汉明距离小于等于 6 基本可以认为是同一个场景。划分时按组切分,保证同一组只出现在训练集或验证集其中一侧。
2.4 用合成数据和强增广补齐长尾场景
真实采集的数据,分布往往是偏的:白天多、夜间少;正面多、斜角少;晴天多、雨天少。这类长尾靠人工采集成本很高,用合成是个性价比不错的思路。
具体做法是先渲染车牌模板:定义好底板尺寸、底色、字符字体、字符间距,用图像库直接画出来,随机拼字符,随机加噪声、模糊、光照渐变、JPEG 压缩伪影,然后把车牌贴到真实的车辆背景图上,同时自动生成对应的 YOLO 标签。这样一晚上能生成几万张,成本几乎为零。
但合成数据有个必须警惕的问题:域差异。合成图太干净了,字符边缘锐利、噪声是标准高斯、没有传感器噪声。如果全部用合成数据训练,模型会学到一些只在合成图里存在的特征,一到真实场景就崩。我的做法是把合成数据控制在总数据量的 20% 到 30%,并且强制加上模糊(高斯核大小随机 0 到 3)、亮度扰动、压缩伪影这几道处理,让它在统计分布上更接近真实图。另外合成数据只用来补齐缺失场景,不用来替代真实数据。
3. YOLOv8 训练:参数不是玄学,是配比问题
3.1 n、s、m、l 怎么选:算力、速度、精度三角
YOLOv8 提供了从 n 到 x 五个量级。车牌是单类检测任务,目标外观高度一致,实际上不需要很大的模型容量。下面这张表是我在不同硬件上实测下来的大致感受,供参考:
| 模型 | 参数量 | 单帧推理(普通显卡,640) | 适合场景 |
|---|---|---|---|
| yolov8n | 约 3.2M | 3 至 6 毫秒 | 边缘设备、实时视频流 |
| yolov8s | 约 11.2M | 6 至 12 毫秒 | 通用首选,性价比最高 |
| yolov8m | 约 25.9M | 15 至 25 毫秒 | 精度优先,离线批处理 |
| yolov8l | 约 43.7M | 30 毫秒以上 | 服务器端,追求极限精度 |
我的建议是先用 yolov8s 跑一个基线,看 mAP 和召回率。如果验证集上小目标漏检明显,先别急着换大模型,先检查 imgsz 和增强策略,往往调整输入分辨率比换模型更有效。只有在数据和分辨率都调到位、精度仍然不够时,才考虑上 m。车牌单类任务从 s 换到 m,mAP 通常只涨 1 到 2 个点,但推理耗时翻倍,实时场景基本不划算。
3.2 imgsz 与车牌像素高度的定量关系
输入分辨率是车牌检测中最容易被低估的参数,因为它直接决定了车牌在特征图上的实际像素高度。
标准车牌的物理尺寸是 440 毫米宽、140 毫米高,宽高比约为 3.14。假设你的原图是 1920×1080,车牌在画面里占 300 像素宽,那么它对应的高度约为 300 / 3.14,也就是 95 像素左右。如果把图整体缩放到 640 输入,缩放系数是 640 / 1920 约等于 0.333,车牌宽度变成 100 像素,高度变成 32 像素。这个尺寸是安全的。
但如果是远距离监控,车牌只占 100 像素宽,那缩放之后宽度是 33 像素,高度只有 10 像素。YOLOv8 的最高层级特征图步长是 32,640 输入下特征图尺寸是 20×20。一个高度 10 像素的目标,在最高层特征图上还不到一个格点。这意味着模型主要靠低层特征图检测它,而低层特征图的语义信息弱,容易误检和漏检。
所以结论很明确:远景监控场景,imgsz 至少给到 960,条件允许给 1280。如果显存吃不下,用切片推理的思路,把大图切成有重叠的小块分别检测再合并。代价是推理耗时随切片数量线性增长,且重叠区域的重复检测需要做非极大值抑制合并。这是个典型的拿时间换精度。
3.3 数据增强参数里哪些必须关掉
YOLOv8 的默认增强很激进,直接拿来训车牌会出问题。下面这套是我在多个项目里收敛出来的配置:
# 训练超参数 lr0: 0.01 lrf: 0.01 momentum: 0.937 weight_decay: 0.0005 warmup_epochs: 3.0 # 增强 hsv_h: 0.015 hsv_s: 0.5 hsv_v: 0.4 degrees: 5.0 translate: 0.1 scale: 0.5 shear: 2.0 perspective: 0.0 flipud: 0.0 fliplr: 0.0 mosaic: 1.0 mixup: 0.0 copy_paste: 0.0 close_mosaic: 10逐条说一下理由。hsv_h默认是 0.015,这个值不需要动,因为色调变化会直接改变车牌底色,而底色是强特征,变化太大反而害了模型。hsv_s和hsv_v保持默认是有意义的,因为真实场景里光照强度变化非常剧烈,模型必须适应。
degrees我调到了 5.0。车牌在真实画面里确实会有倾斜,但大幅旋转会让字符严重扭曲,反而制造了现实里不存在的样本。5 度以内足够覆盖常见的轻微倾斜。
flipud和fliplr必须是 0。水平翻转会让字符左右镜像,比如"2"变形成反向的"2",这在现实中永远不会出现,训练时引入只会污染数据分布。这一点和通用目标检测很不一样,做猫狗检测时水平翻转完全没问题,做文字类任务就是灾难。
mixup和copy_paste我通常关掉。mixup 会让车牌区域和背景半透明叠加,字符变得模糊;copy_paste 依赖分割掩码,车牌检测一般没有掩码标注。
mosaic保持开启,但要注意close_mosaic这个参数。它控制最后多少个 epoch 关闭 mosaic,默认 10。作用是在训练末期让模型回到真实分布上收敛,这一步对最终精度影响挺明显,不要设成 0。
3.4 训练日志怎么看:三条损失加两个 mAP
训练跑起来之后,runs/detect/train目录下会有一堆输出,重点看这几个。
损失方面有box_loss、cls_loss、dfl_loss三条。box_loss是边界框回归损失,衡量框位置准不准;cls_loss是分类损失,单类任务下它会降得很快;dfl_loss是分布焦点损失,和框的边界置信度有关。正常情况下三条都是下降趋势,如果box_loss震荡剧烈,通常是学习率偏大或者 batch size 太小。
精度方面看mAP50和mAP50-95。前者是 IoU 阈值 0.5 时的平均精度,后者是 0.5 到 0.95 每隔 0.05 取一次再平均,更严格。车牌检测关注mAP50-95更有意义,因为框的贴合程度直接影响后续裁剪质量。
几个典型现象和对策:如果训练损失一直降但验证 mAP 在某个点之后就平了甚至回升,说明开始过拟合,减少 epoch 或者加大数据量;如果验证 mAP 从头到尾都很低且不怎么涨,九成是标注有问题,回去跑 2.1 里的校验脚本;如果 mAP 很高但实际测试时误检很多,看看混淆矩阵里的背景误检列,可能是负样本不够。
单类任务的混淆矩阵其实信息量有限,但我还是会看一眼背景行,也就是真实背景被误判成车牌的次数。如果这个数很大,说明画面里有类似车牌的干扰物,比如某些反光贴纸、数字标牌,这时候需要针对性补负样本。
3.5 freeze 与学习率的实战经验
freeze参数控制冻结前 N 层。它的使用场景是数据集比较小的时候,比如只有一两千张。这时候让整个网络从头微调容易过拟合,冻结 backbone 的前若干层,只训练检测头,能让预训练权重发挥更稳定的作用。
我的经验是数据量在 2000 张以下时freeze=10,2000 到 10000 张时freeze=0全量微调。注意freeze参数在你需要改变输入分辨率时有个陷阱:冻结层里的 BatchNorm 统计量不会更新,如果新分辨率下特征分布变化很大,冻结层可能会拖后腿。这种情况下要么不冻结,要么先解冻跑几轮再冻结。
学习率方面,默认lr0=0.01配 SGD 优化器,在大数据集上是合理的。小数据集(2000 张以下)我一般降到 0.001,并且把warmup_epochs提到 5,让模型有个更平缓的起步。lrf是最终学习率的比例,默认 0.01,也就是衰减到初始值的百分之一,这个保持默认就行。
4. 字符识别:CTC 序列模型与裁剪校正的细节
4.1 检测框裁剪的三个隐形杀手
裁剪这一步代码只有几行,但出问题的概率非常高。我把常见问题归成三类。
第一类是透视畸变。摄像头很少正对车牌,斜视角下车牌在图像里是梯形,字符间距不均匀,左边字宽右边字窄。识别模型如果只在正面图上训过,遇到这种就会明显掉点。解决办法有两个:一是如果用四角点检测,可以做透视变换把梯形拉成矩形;二是如果只有水平框,至少在训练数据里掺入足够多的斜视角裁剪图,让模型自己适应。后者实现成本低,多数项目我都是这么做的。
第二类是边界抖动。同一辆车在连续帧里,检测框的位置会有几个像素的跳动。这种抖动在视频里看起来不明显,但裁出来的图会有细微差异,识别结果就可能在两帧之间跳变。处理办法是在裁剪时加固定比例的外扩,让抖动落在缓冲区里。
第三类是越界截断。车牌出现在画面边缘时,框的一部分超出了图像范围,OpenCV 的数组切片会静默返回一个尺寸不对的区域,有时候是空数组,有时候是缺了一边的图。这个错误不会抛异常,只会让识别结果莫名其妙。所以裁剪函数里必须做坐标钳制:
import numpy as np def crop_with_pad(img, box, pad_ratio=0.03): """按比例外扩裁剪,并钳制到图像边界。""" h, w = img.shape[:2] x1, y1, x2, y2 = [int(v) for v in box] bw, bh = x2 - x1, y2 - y1 px = max(1, int(bw * pad_ratio)) py = max(1, int(bh * pad_ratio)) x1 = max(0, x1 - px) y1 = max(0, y1 - py) x2 = min(w, x2 + px) y2 = min(h, y2 + py) if x2 - x1 < 4 or y2 - y1 < 4: return None # 太小,判定为无效框 crop = img[y1:y2, x1:x2] if crop.size == 0: return None return crop这个函数看着简单,但最后那两行有效性判断能帮你挡掉大量诡异 bug。我在一个项目里因为漏了这段判断,视频流跑到边缘区域就崩,排查了半天才定位到裁剪返回空数组。
4.2 CTC 为什么能不做字符切分
字符识别里最经典的问题就是字符切分。传统方法要先定位每个字符的位置,再逐个分类,前后依赖很强,一旦切分点偏了,后面全错。
CTC 的思路是绕开切分。你可以这样理解:假设有一段音频,老师念了"一二三",但录音里每个字占据多少帧是不确定的,而且字与字之间还有停顿。CTC 的做法是引入一个特殊的空白符号,允许模型在任意帧输出空白,最后把连续重复的字符合并、把空白删掉,就还原出文字。这样一来,输入序列长度和时间步的对齐关系就不用提前确定了。
用在车牌上,输入是裁剪图在宽度方向提取的特征序列,长度比如是 32 步,输出是 7 位或 8 位字符,两者长度不等,CTC 正好处理。它对位数不定长天然友好,你不需要为 7 位和 8 位车牌分别训两个模型。
CTC 有一个已知的弱点:输出字符之间必须满足"不可重复"的假设。比如车牌里出现 "AA" 这样的连续重复字符,CTC 会因为合并规则而只输出一个 A。标准解法是在两个重复字符之间插入空白,训练时让模型学会这种模式。车牌里连续重复字符虽然不常见,但不能不防,实际训练中我会刻意构造一批带重复字符的样本来覆盖这种情况。
4.3 字符集定义与位数不定长的处理
字符集的大小直接影响最后一层的输出维度。车牌字符大致分成三类:阿拉伯数字 10 类,大写字母若干类,汉字若干类。为了减少混淆,我通常会剔掉几个易混字符,比如字母 I 和 O,因为它们在视觉上和数字 1 和 0 太接近。剔掉之后总类别数大概在六十多到七十之间,再加一个 CTC 的空白符号。
剔字符这个操作有个前提:你的实际业务里确实不会出现这两个字母。如果业务里就是有,那就不能剔,只能靠增加样本量让模型自己去分辨。这一点要提前确认,不能想当然。
位数方面,训练时把所有标签统一补到最大长度,比如 8 位,不足的用空白填充。CTC 解码后把空白去掉,自然就得到真实长度。这样一套模型能同时处理 7 位和 8 位。
4.4 识别模型的训练数据从哪来
不用单独标。检测器的标注框加文本标签,直接就能生成识别训练数据:按框裁剪,缩放到固定高度比如 32 或 48 像素,宽度按比例缩放到一个上限比如 160 像素,然后和文本标签配对。
这里有个细节值得说:我会刻意用"带误差的框"来做裁剪,而不是用完美的真值框。因为推理时你拿到的就是检测器输出的框,它一定有误差。如果用完美框训练识别模型,模型会不适配带误差的输入,形成训练和推理的分布不一致。所以生成识别训练数据时,我会给框加上 ±3% 的随机扰动,模拟检测器的输出特性。这个小技巧能让最终系统的端到端准确率提升 1 到 3 个百分点。
另外一个细节是识别模型的输入尺寸归一化。不同车牌的宽高比差异不小,如果直接拉伸到固定尺寸,字符会被压扁或拉长。我一般保持高度固定,宽度按比例缩放并居中填充,超出上限的才做裁剪。这样字符的宽高比失真最小。
5. PyQt5 封装:把模型变成能交付的桌面软件
5.1 为什么推理必须离开主线程
PyQt5 的界面事件循环跑在主线程里。如果你在主线程里做推理,一帧推理耗时 50 毫秒,那这 50 毫秒内界面就完全没响应,按钮点不动、窗口拖不动,用户会以为程序卡死了。视频模式下这个问题会被放大,因为推理是持续不断的。
标准做法是把推理放进 QThread,通过信号槽把结果传回主线程更新界面。核心结构大致是这样:
from PyQt5.QtCore import QThread, pyqtSignal import cv2 class InferWorker(QThread): frame_ready = pyqtSignal(object) # 渲染后的帧 result_ready = pyqtSignal(list) # 结构化结果 def __init__(self, det_model, rec_model, source): super().__init__() self.det = det_model self.rec = rec_model self.source = source self._running = True def run(self): cap = cv2.VideoCapture(self.source) if not cap.isOpened(): self.result_ready.emit([('error', '无法打开视频源')]) return while self._running: ok, frame = cap.read() if not ok: break results = self.det.predict(frame, conf=0.35, iou=0.5, verbose=False) records = [] for box in results[0].boxes: xyxy = box.xyxy[0].cpu().numpy().astype(int).tolist() crop = crop_with_pad(frame, xyxy, pad_ratio=0.03) if crop is None: continue text = self.rec.infer(crop) records.append({'box': xyxy, 'text': text}) self.frame_ready.emit(self.draw(frame, records)) self.result_ready.emit(records) cap.release() @staticmethod def draw(frame, records): for r in records: x1, y1, x2, y2 = r['box'] cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, r['text'], (x1, max(0, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) return frame def stop(self): self._running = False self.wait(2000)有几个点要注意。stop()里必须调用wait()等线程真正退出,否则窗口关闭时线程还在跑,程序会残留进程。另外信号里传的对象类型要用object,因为pyqtSignal不支持直接声明 numpy 数组。
还有一点,视频模式下如果推理速度跟不上视频帧率,不要排队,要丢帧。排队的结果是延迟越积越大,画面越来越滞后。判断逻辑很简单:如果上一帧还没处理完,直接跳过当前帧。宁可降低帧率,也要保证画面实时。
5.2 图片、批量、视频三种模式的界面设计差异
三种模式看起来都是"输入图像、输出结果",但交互逻辑完全不同。
单张图片模式是同步的,用户点一下按钮,等一两百毫秒出结果,这完全可接受,不用起线程,代码最简单。界面上需要一个图片显示区、一个结果表格、一个置信度阈值滑块,滑块调完要实时重推理,让用户能直观看到阈值对结果的影响。
批量模式的关键是进度反馈和可中断。几百张图跑下来可能要几分钟,没有进度条用户会以为死了。我会用QProgressBar加一个取消按钮,取消逻辑用标志位而不是强杀线程,保证资源能正常释放。同时结果要能导出成 CSV,字段包括文件名、车牌文本、置信度、框坐标。
视频模式最复杂,涉及线程管理、帧率控制、实时绘制。我一般会在界面右下角显示实时 FPS,这个数字能直观反映性能瓶颈在哪。如果 FPS 掉到个位数,基本可以确定是检测模型太重或者输入分辨率太高。
5.3 高频坑位:OpenGL 渲染、分辨率缩放、资源路径
这块是我踩坑最多的地方,列一张表方便对照:
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| 界面一片黑,控件不显示 | OpenGL 上下文创建失败,常见于远程桌面和虚拟机 | 在创建 QApplication 之前设置软件渲染属性 |
| 高分屏下控件错位、字体模糊 | 未启用 DPI 缩放 | 在 QApplication 实例化前开启 DPI 缩放属性 |
| 打包后提示找不到模型文件 | 相对路径基于工作目录,打包后工作目录变化 | 用打包环境的临时目录前缀拼接绝对路径 |
| 视频播放卡顿、界面无响应 | 推理在主线程执行 | 移到 QThread,用信号槽回传结果 |
| 关闭窗口后进程残留 | 子线程未正常退出 | 在 closeEvent 中调用 stop 并等待线程结束 |
| 图片显示颜色偏蓝 | OpenCV 读入是 BGR,Qt 需要 RGB | 转换颜色通道后再构造 QImage |
关于设置顺序,这两个属性必须在 QApplication 对象创建之前设置,写在后面不起作用:
import sys from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication # 必须在 QApplication 之前 QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) # 远程桌面/虚拟机环境下强制软件渲染,避免界面不显示 QApplication.setAttribute(Qt.AA_UseSoftwareOpenGL, True) app = QApplication(sys.argv)资源路径的处理,判断是否处于打包环境:
import sys import os def resource_path(relative): base = getattr(sys, '_MEIPASS', os.path.abspath('.')) return os.path.join(base, relative) model_path = resource_path('weights/plate_det.onnx')5.4 打包与交付:模型文件、依赖和启动速度
打包用 PyInstaller 是最省事的。基本命令是:
pyinstaller --noconfirm --windowed --name PlateSystem \ --add-data "weights;weights" \ --add-data "assets;assets" \ main.py注意 Windows 下--add-data的分隔符是分号,Linux 和 macOS 下是冒号,跨平台打包时容易搞混。--windowed会隐藏控制台窗口,但调试阶段建议先不加,方便看报错。
启动速度是个容易被忽略的体验问题。如果依赖 PyTorch,首次导入就要好几秒,用户双击之后要等很久才看到界面。我的做法是把推理模型统一导出成 ONNX,用 onnxruntime 推理,依赖体积从几百兆降到几十兆,启动时间也从五六秒降到一秒多。代价是 ONNX 的推理速度在不同硬件上表现不一,但车牌这种小模型,差异可以接受。
还有一个交付细节:模型文件最好做一层完整性校验。用户拿到软件后可能会误删或替换权重文件,导致运行时报奇怪的错。启动时算一下文件的哈希值,和预置的值比一下,不匹配就弹一个明确的提示框,比让用户去看堆栈信息友好得多。
6. 部署与加速:ONNX、TensorRT 和边缘算力的现实预期
6.1 导出 ONNX 时的动态维度和 opset 选择
YOLOv8 导出 ONNX 只有一行,但参数选不对会有隐患:
from ultralytics import YOLO model = YOLO('runs/detect/train/weights/best.pt') model.export( format='onnx', opset=12, # 12 兼容性最好 dynamic=True, # 动态 batch 和尺寸 simplify=True, # 图优化,能去掉冗余算子 imgsz=640, half=False, # 先导出 FP32,量化和半精度后面单独做 )opset我给 12,原因是它对多数推理引擎的支持最完整。用更高的 opset 可能在某些旧版 onnxruntime 上报不支持的算子。dynamic=True打开后,batch 和输入尺寸都可变,方便你在同一个模型上跑不同分辨率。但要注意,动态维度会让部分推理引擎无法做激进优化,如果确定输入尺寸固定,关掉 dynamic 能快一点。
simplify=True会调用 onnx-simplifier 做图优化,去掉一些恒等算子和冗余节点,通常能带来 5% 到 10% 的提速。但偶尔也会引入兼容性问题,导出后一定要用 onnxruntime 跑一遍数值对齐,看看输出和 PyTorch 原模型差多少。
6.2 半精度与批处理的收益边界
FP16 的收益取决于硬件。支持 Tensor Core 的显卡上,FP16 相比 FP32 通常有 1.5 到 2 倍的提速,精度损失在车牌这种任务上基本可以忽略,mAP 掉点一般在 0.5 以内。但如果不支持的硬件,FP16 可能会被自动转换成 FP32 执行,甚至更慢,所以一定要实测。
批处理的收益在离线场景很明显。批量处理图片时,batch 从 1 提到 8,吞吐量能提升两三倍。但实时视频场景不能用批处理,因为你要等齐 8 帧才能开始推理,延迟直接增加。所以实时场景永远是 batch=1,靠模型轻量化和硬件加速来提性能。
多进程是另一个提升批处理吞吐的思路。CPU 解码图像是瓶颈时,开几个进程并行解码,主进程只做推理,能把整体吞吐再拉高一截。这个方案实现复杂度高一些,但效果确实好。
6.3 端侧算力与帧率的真实预期
不同硬件的预期差得很远,我把实测过的几类整理一下,供你评估方案可行性。
普通消费级显卡跑 yolov8n,640 输入,用 TensorRT FP16 大约 2 到 4 毫秒一帧;换成 yolov8s 大约 5 到 8 毫秒。加上识别模型的 1 到 2 毫秒,整个流水线可以轻松做到 100 FPS 以上。所以有独立显卡的场景,性能完全不是问题。
嵌入式 AI 开发板这类平台,yolov8n 跑 640 输入,用内置 NPU 加速,大概 20 到 40 毫秒一帧,也就是 25 到 50 FPS。这个性能跑单路视频够用,多路就需要做降分辨率或者降帧率处理。要注意的是,模型转成板子支持的格式时通常需要做 INT8 量化,量化需要一批校准数据,校准集选得不好会掉点明显。我一般从验证集里随机抽 200 到 500 张做校准,分布覆盖各种光照。
纯 CPU 推理就不用指望实时了。yolov8n 在桌面 CPU 上跑 640 输入,一帧大概 30 到 80 毫秒,勉强能跑十几 FPS,但风扇会狂转。如果是要做演示,建议至少用 ONNX Runtime 并且在导出时关掉 dynamic,能省不少。
6.4 多路视频时的资源分配思路
实际项目往往不是单路,停车场可能同时有四路、八路摄像头。这时候不能简单地开八个线程各跑一个模型,显存和算力都会爆。
我的做法是先按通道分组,每路分配一个推理线程,但共享同一份模型权重,避免重复加载。然后限制并发推理的数量,用一个信号量控制同时进入推理的线程数。显卡上同时跑两到四个推理是合理的,再多就会出现线程切换开销大于收益的情况。
如果算力实在不够,还有个务实的做法:降低非重点区域的检测频率。比如四路视频,主通道每帧都检测,其他通道每三帧检测一次,中间帧复用上一次的结果并做简单的跟踪关联。人眼对这种降频的感知其实不强,但算力能省下一大半。
7. 排查实录:mAP 很高但识别结果不对的几个真实原因
7.1 检测框抖动导致的字符切边
现象是视频里同一辆车,识别结果在几帧之间来回跳,比如在"京A12345"和"京A1234S"之间反复。这种问题几乎都是检测框抖动引起的。
排查方法是把连续几帧的裁剪图存下来对比。如果发现框的边缘一直在变,一会儿切到字符,一会儿切到背景,那就确认了。解决分两层:裁剪时加固定外扩比例,把抖动吃进缓冲区;同时在识别端做多帧投票,同一个车牌在连续 N 帧里出现相同结果超过一半才认定为最终结果。多帧投票这个后处理对消除抖动效果非常明显,代价是引入了几帧的延迟,实时性要求极高的场景要权衡。
7.2 过曝、逆光和夜间补光的处理思路
光照是车牌识别最难缠的环境因素。我遇到过几种典型情况。
第一种是正对强光,车牌区域直接过曝,字符和底板都变成一片白,信息完全丢失。这种情况靠算法救不回来,只能在硬件层面解决,比如调整相机曝光策略或者加装遮光罩。算法层面能做的是识别出这种低质量图,标记成"无法识别"而不是硬猜一个错误结果。我一般会加一个质量评估环节,用图像的对比度和边缘密度做判断,质量分低于阈值就返回占位符。
第二种是逆光,车牌处于阴影里,整体偏暗但细节还在。这种可以通过限制对比度自适应直方图均衡来增强。这个方法在车牌上效果不错,但要注意别把噪声也放大,clip limit 参数给 2.0 到 3.0 比较稳。
第三种是夜间补光,红外灯打上去之后车牌反光,局部出现高亮斑点。这种情况比较麻烦,因为高亮区域和正常区域的动态范围差很大。我的做法是先从检测框里裁出来,再做局部对比度拉伸,而不是对整图做处理。局部处理能针对车牌区域调参,副作用更小。
7.3 运动模糊下的多帧投票策略
车辆快速通过时,即使快门速度够,视频编码也可能引入运动模糊或压缩伪影,导致单帧识别不准。
这种情况最有效的办法不是提升单帧质量,而是利用时间维度的信息。前面提到的多帧投票在这里同样适用,但有个改进点:不要简单地数众数,而是把每帧的置信度也纳入投票权重。置信度高的帧权重更大,这样只要有几帧清晰,结果就稳。
实现上可以用一个滑动窗口,窗口长度设 5 到 8 帧,记录每帧识别出的文本和置信度。当窗口内某个文本的加权得分超过阈值时输出结果。窗口长度不能太大,否则车辆已经开走了结果还没出来。5 到 8 帧是我试过的比较平衡的范围。
还有一种情况是模糊导致字符粘连,比如"11"看起来像"H","8"看起来像"B"。这种单帧无解,但结合前后帧的时间一致性,往往能纠正过来。比如同一辆车在清晰帧里识别出"1123",模糊帧识别出"H23",从字符位数和上下文就能判断出后者是错的。
7.4 一个容易被忽略的问题:模型版本不一致
这个坑我在交付阶段踩过一次。开发时用的是 yolov8s 训练的检测模型,识别模型是某个版本。后来优化时检测模型换成了 yolov8n 重训的版本,识别模型没换。结果整体准确率下降了不少。
原因是识别模型是在旧检测器的裁剪分布上训的,新检测器的框特性不一样,比如框的松紧程度、偏移倾向都变了。识别模型没见过这种分布,自然掉点。所以每次替换其中一个模型,都必须在验证集上重新评估端到端准确率,不能只看单模型的指标。
我的做法是维护一个端到端测试集,固定 500 张带完整标注的图,每次模型更新都跑一遍全流程,记录检测召回、识别准确率和端到端准确率三个数字。这样任何一方退化都能立刻发现。
最后分享一个我个人在多个项目里反复验证的小经验:整个系统里,投入产出比最高的环节永远是数据质量,而不是模型结构。我见过太多人花两周去换模型、调结构,涨了 1 个点;也见过把 500 张标注错误的样本修正之后,直接涨了 8 个点。所以每次效果不达预期,我第一件事是随机抽 50 张验证图,把预测结果和真值并排画出来一张一张看。看多了你会发现,问题往往就藏在那两三张典型的错例里,而这几张图恰好能告诉你下一步该干什么。