1. 先把链路想清楚:车牌识别相机到底在跑什么业务
我这两年接手过几个"车牌识别相机"的活儿,客户描述都特别简单:一台相机对着车道,车过来,读出车牌号,抬杆。听起来像是一个模型就能搞定的事,真做起来才发现,从"能识别"到"能连续跑三个月不出事",中间隔着一整套工程化的工作量。用 VideoPipe 这类视频分析框架来做,最大的好处不是它自带多少模型,而是它把"取流—解码—检测—跟踪—二次识别—绘制—输出"这条链路拆成了可以独立调试的节点,你能精确知道问题出在哪一环。这篇内容我想聊的就是:怎么用 VideoPipe 把一个车牌识别相机从零搭起来,包括节点怎么选、参数怎么算、坑在哪、怎么排。适合已经会写 C++、懂一点深度学习推理、但还没做过完整视频结构化流水线的开发者;如果你只是想跑个 demo 看效果,前面两节看完就够了,后面几节是真正上现场才用得着的部分。
车牌识别相机这个品类,本质上是"边缘视频结构化设备"的一个细分。它和普通监控相机的区别在于:普通相机只负责把画面传回去,车牌相机要在本地把画面里的车辆、车牌位置、车牌字符三样东西抽出来,变成一个字符串和一张小图,再送给业务系统。所以它的核心技术点不是单一的车牌检测模型,而是三层级联:先在大画面里找车,再在车上找牌,最后在牌上读字。少一层,误检率就会飙上去——我见过直接全图跑车牌检测的方案,白天能用,晚上路灯光斑、广告牌反光、货车尾部贴纸全都会被当成车牌,误识别率高到没法交付。
1.1 一台车牌识别相机的真实工作链路
把业务链路摊开来看,其实是这样一条流水:相机出流(RTSP 或 USB/GigE)→ 解码成帧 → 车辆检测(找出画面里所有车,拿到 bbox 和 track_id)→ 目标跟踪(给同一辆车分配稳定的 ID,避免每帧重复识别)→ 车牌检测(在车辆 bbox 内做二次检测,定位车牌四角或矩形)→ 车牌矫正与裁剪(透视变换把倾斜车牌拉正)→ 字符识别(输出车牌字符串和置信度)→ 结果融合与去重(同一 track_id 只保留最优结果)→ OSD 绘制与推流/落盘/上报。这条链路上每一步都有独立的失败模式,这也是我坚持用流水线框架而不是写一个大函数的原因:失败要能定位。
举个实际遇到的例子。某个园区入口的项目,白天识别率 97%,晚上掉到 70%。如果是一坨代码,你只能猜是模型不行还是图像不行;拆成节点之后,我把每个节点的输出帧单独存下来看,一眼就发现是车辆检测节点在夜间把"车"和"地上的水渍反光"混在一起,导致送进车牌检测的 ROI 压根不包含车牌。问题定位在十分钟内完成,解决方案也很简单:把送检 ROI 往上收缩 10%,并且给车辆检测加一个夜间专用的阈值。这种排查效率,是流水线架构最实在的回报。
1.2 单模型一把梭的问题在哪里
新手最容易犯的错,是拿一个车牌检测模型直接怼全图,检测到车牌就直接送 OCR。这条路在小图上看着很美,实际落地会碰到三个硬问题。第一个是召回和精度的矛盾:为了让远处的小车牌也能被检出,你得把输入分辨率拉高、置信度阈值调低,结果近处的干扰物全进来了;反过来调高阈值,远车就漏了。第二个是没有跟踪就没有去重,同一辆车在画面里停留 3 秒、25fps,就是 75 次识别,业务系统会收到 75 条重复的过车记录,还得在上层做去重,纯属给自己找麻烦。第三个是算力浪费,全图跑车牌检测的计算量远大于"先找车、只在车的框里找牌",尤其当你一台设备要接 4 路、8 路相机的时候,这个差距直接决定你能不能用一张消费级显卡扛下来。
所以我在方案选型上有一个固定原则:能用层级过滤的,绝不靠单模型硬扛。车辆检测负责把搜索空间从"整幅画面"压缩到"若干个小矩形",这一步的召回可以做得比较高,因为车辆目标大、特征明显;车牌检测只在车辆框内跑,输入尺寸小、干扰少,可以做得很严格。两级过滤下来,误检率的下降是指数级的,不是线性的。
1.3 我为什么把 VideoPipe 作为骨架
选 VideoPipe 的理由很朴素:它是 C++ 写的,节点化设计,管道可以灵活拼装,自带了一批常用的推理节点封装(检测、分类、跟踪、车牌识别、OSD 绘制、多种输出),而且节点的 attach/detach 机制让"一个源分发给多个下游"这种事变得很自然。对于车牌识别相机这种"一条主链 + 若干旁路(存图、推流、上报)"的场景,这个模型非常贴合。相比之下,用 Python 脚本串 OpenCV + ONNX Runtime 也能跑,但一旦要做多路并发、要做优雅停止、要做节点级的帧缓存和背压控制,就得自己造一遍轮子,而这些东西恰恰是现场稳定性的关键。
需要说明的是,下面涉及的具体节点类名、构造参数签名,我会按 VideoPipe 的通用设计来写,但不同版本的接口可能有差异,你落地时以自己拉下来的那份代码里的头文件为准,思路是一致的。这也是我一直建议的做法:框架是骨架,参数和业务逻辑是你自己的肉,别指望照抄某一份示例就能直接上生产。
2. VideoPipe 的节点模型拆解与选型思路
2.1 节点、管道与数据流:把业务拆成快递分拣线
VideoPipe 的核心抽象就两个:节点(node)和管道(pipeline)。节点是处理单元,有输入队列和输出队列;管道负责把节点串起来、启动线程、管理生命周期。数据在节点之间流动的单位是"帧"(frame),每一帧上挂着一个 meta 仓库,用来在节点之间传递结构化信息——比如车辆检测节点往 meta 里写detections,跟踪节点往里写track_ids,车牌识别节点往里写plate_text和plate_score。这个设计的关键价值在于解耦:下游节点不需要知道上游是怎么实现的,只需要约定好 meta 里的字段。
你可以把它想成快递分拣:包裹(帧)从传送带进来,第一站贴面单(检测),第二站编组(跟踪),第三站称重(识别),最后装车(输出)。每一站只管自己那一段,坏了就换那一站,不影响别的。我在实际项目中特别依赖这个特性做灰度替换:比如车牌检测模型从 A 换成 B,只需要换掉一个节点的构造参数,其他链路一行不动。
管道构建的写法大致是这样,用attach_to和detach_to描述拓扑:
#include "vp_pipeline.h" #include "nodes/vp_video_src_node.h" #include "nodes/vp_vehicle_detector.h" #include "nodes/vp_tracker_node.h" #include "nodes/vp_plate_detector.h" #include "nodes/vp_lprnet_node.h" #include "nodes/vp_osd_node.h" #include "nodes/vp_screen_des_node.h" int main() { auto src = std::make_shared<vp_nodes::vp_video_src_node>( "src", "rtsp://192.168.1.64:554/stream1", 1.0 /* 播放倍速 */, false /* 循环 */); auto veh_det = std::make_shared<vp_nodes::vp_vehicle_detector>( "veh_det", "models/vehicle_det.onnx", 1 /* batch */, 0.5f /* score */, 0.5f /* nms */); auto tracker = std::make_shared<vp_nodes::vp_tracker_node>( "tracker", vp_nodes::vp_track_algo::SORT, 30 /* max_age */); auto plt_det = std::make_shared<vp_nodes::vp_plate_detector>( "plt_det", "models/plate_det.onnx", 1, 0.55f, 0.5f, /* 额外的裁剪扩展比例 */ 0.1f); auto lpr = std::make_shared<vp_nodes::vp_lprnet_node>( "lpr", "models/lprnet.onnx", 1, 0.8f /* 输出置信度下限 */); auto osd = std::make_shared<vp_nodes::vp_osd_node>("osd"); auto desk = std::make_shared<vp_nodes::vp_screen_des_node>("desk", 0); src->attach_to({veh_det}); veh_det->detach_to({tracker}); tracker->detach_to({plt_det}); plt_det->detach_to({lpr}); lpr->detach_to({osd}); osd->detach_to({desk}); vp_pipeline pipe; pipe.set_print_interval(10); pipe.start(); if (!pipe.wait()) { return -1; } pipe.stop(); return 0; }注意:上面的构造参数顺序是按常见设计写的,部分版本里 ROI 裁剪比例、跳过帧数、最大目标数这些可能是通过单独的 setter 或 config 结构体传的。编译报错就去看头文件,别硬猜。
2.2 视频源与解码节点的选型要点
视频源节点看起来最简单,其实是现场最容易出问题的一环。它要处理的事情包括:连接建立、断线重连、解码(硬件解码还是软件解码)、时间戳对齐、以及"推流端卡住不吐数据"的检测。我的经验是,能走硬件解码就不要走软件解码。1080P 25fps 的 H.264 流,软件解码单路就能吃掉一两个 CPU 核,你接 8 路的时候 CPU 直接满载,后面的推理节点反倒没时间片了。
另一个常被忽略的点是取流分辨率与推理分辨率的关系。很多相机的子码流是 720P、主码流是 400 万或 800 万像素,我的做法是:如果做车牌识别,主码流直接用,别贪省带宽去用子码流。原因在下一节的像素计算里会说清楚——车牌字符在画面里就那么几十个像素,你每降一档分辨率,就是直接砍掉识别裕度。
断线重连这块,我自己的做法是在源节点前面加一层守护逻辑:定期检查帧时间戳,如果超过 N 秒(我一般设 3 到 5 秒)没有新帧进来,就主动 stop 再 start 一次管道。VideoPipe 的管道本身支持优雅停止,但"什么时候该停"需要你自己判断,框架不知道你的业务容忍度。
2.3 检测、跟踪、识别三类节点的组合方式
这三个节点的组合方式是整个方案的核心,我把它拆成三句话:检测给初值,跟踪保连续,识别做收敛。
车辆检测节点的职责是粗筛。我通常把它配置成"高召回、容忍一定误检"的模式:置信度阈值压到 0.4 到 0.5,NMS 阈值 0.5 左右,输入尺寸用模型的默认值(比如 640),别为了小目标去拉到 1280,因为车辆在画面里通常够大,拉高输入只会拖慢速度。检测出来的框还要做一个业务过滤:宽高比异常的(比如特别扁的横条)直接扔掉,太小的(宽或高小于某个阈值)也扔掉,这些框大概率是误检。
跟踪节点解决的是"同一辆车别重复报"。SORT 类的跟踪器靠 IOU 匹配,实现简单、速度快,对车牌场景完全够用。这里的max_age参数很关键,它决定了目标消失多少帧后销毁轨迹。车牌识别场景里,车辆被前车遮挡几帧是常态,max_age设太小(比如 5)会导致 ID 频繁切换,同一辆车被当成两辆车识别两次;设太大(比如 60)又会让死掉的轨迹占着 ID 不释放。我一般在 25fps 的场景下设 25 到 30,也就是容忍一秒左右的遮挡。
车牌检测节点是二段检测,只在车辆框内跑。这里有个细节必须处理:车辆框往往贴得很紧,车牌可能在框边缘被切掉一半。所以我在把 vehicle bbox 传给车牌检测之前,会按一定比例向外扩展(比如宽高各扩 10%),并且裁剪时做边界截断,防止越界访问。这个扩展比例不能太大,否则又把隔壁车道的车牌框进来了,10% 到 15% 是比较稳的区间。
字符识别节点(LPRNet 这类轻量序列识别模型)负责把裁剪出来的车牌小图变成字符串。它的输入尺寸通常是固定的(比如 94x24 或 128x32),所以进入之前一定要做 resize,而 resize 的方式直接决定识别率。这一点下一节展开讲。
2.4 输出与 OSD 节点:别小看这一层
OSD 节点负责把框、ID、车牌字符串画到帧上,看起来是锦上添花,实际上调试期它是你唯一能直观看到"每个节点干了什么"的窗口。我在联调阶段会特意在 OSD 上画三类信息:车辆框和 track_id(不同 ID 用不同颜色)、车牌框、识别出的字符串和置信度。这样一眼就能看出是"车没检到"还是"牌没检到"还是"字读错了"。
输出节点按业务需求选。屏显输出(vp_screen_des_node)用于本地调试;文件输出(vp_file_des_node)用于存证,我一般只存"识别成功且置信度达标"的帧和对应的车牌小图,不然硬盘一周就满了;推流输出(RTMP 之类)用于把带 OSD 的画面回传给管理端。这里有个工程上的小技巧:输出节点一定要做限速或异步,否则下游写盘慢的时候会反压整条管道,导致取流节点丢帧,看起来像是"识别卡顿",实际是 IO 拖累的。
3. 核心参数怎么定:把"凭感觉调"变成"算出来"
3.1 分辨率与车牌像素高度:一个必须算的账
我见过太多项目在相机选型阶段拍脑袋,最后识别率上不去,回头怪模型。其实车牌识别能不能成,在你选相机和定安装位置的那一刻就决定了七成。这笔账很好算,我把它写成一个固定的估算流程。
普通蓝牌的外廓尺寸是 440mm × 140mm,字符高度大约 90mm。字符识别模型要稳定工作,车牌区域在送入网络前的字符高度至少要有 16 到 24 像素,低于这个值,笔画粘连、字符糊成一团,什么模型都救不回来。
假设相机水平视场覆盖的车道宽度是 W 米,分辨率水平像素是 P,那么车牌在画面中的横向像素数为:
车牌横向像素 = P × (0.44 / W)举两个实际配置对比:
| 场景 | 分辨率 P | 覆盖宽度 W | 车牌横向像素 | 字符高度(约) | 结论 |
|---|---|---|---|---|---|
| 配置 A | 1920 | 4 m | 211 | 43 px | 裕度充足,夜间也稳 |
| 配置 B | 1920 | 8 m | 105 | 21 px | 勉强够用,夜间易掉 |
| 配置 C | 1920 | 16 m | 52 | 10 px | 基本不可用 |
| 配置 D | 3840 | 16 m | 105 | 21 px | 与配置 B 等效,覆盖翻倍 |
看清楚这张表,很多问题就不用争论了。配置 C 那种"一台相机看两个车道还想识车牌"的方案,无论换什么模型都白搭,唯一解是换长焦镜头或者提高分辨率。我给客户的建议通常是:先按字符高度 25 像素以上的目标反推相机焦距和安装距离,再谈算法。这条建议帮我省掉的返工,比任何模型优化都多。
还有一个容易被忽略的因素是俯仰角。车牌是垂直安装的,相机如果装得太高、俯角太大,车牌在画面里会被强烈压缩,垂直方向的像素数骤减,等效于字符高度打折。我一般把俯角控制在 15 到 25 度之间,既能覆盖一定纵深,又不会把车牌压扁。
3.2 裁剪与缩放的细节:形变是识别率的隐形杀手
车牌检测框裁出来之后,下一步是 resize 到识别模型的输入尺寸。这里有个很多人不注意的坑:车牌的真实长宽比是 440:140 ≈ 3.14:1,而多数识别网络的输入是 94:24 ≈ 3.92:1。如果你直接把 3.14 的图硬拉到 3.92,字符在水平方向被拉宽了 25%,笔画的粗细比例全变了,识别率会明显下降。
我的处理方式是:裁剪时不按检测框原样裁,而是以框中心为基准,按目标长宽比(3.9:1 左右)重新计算裁剪区域。具体做法是先取检测框的中心点 (cx, cy) 和宽度 w,然后令裁剪高度 h = w / 3.9,再按这个 h 上下扩展。这样裁出来的图比例和网络输入一致,resize 的时候几乎不产生形变。
同时要留一点 padding。车牌检测框有时会贴得太紧,把边框的白色字符切掉一角,识别就会出错。我在裁剪时会在四周各留 5% 到 8% 的余量,宁可多带一点车身背景,也别切断字符。
提示:如果你的车牌检测模型输出的是四个角点(而不是水平矩形),那更好办——直接做透视变换把车牌拉成正视的矩形,再 resize。这个操作对倾斜车牌的识别率提升非常明显,尤其是斜向驶入的车辆。
3.3 检测阈值与跟踪阈值的取舍
阈值的设定没有万能值,但有一套判断逻辑。检测阈值我分两类看:
车辆检测阈值,我倾向压得低一些,0.4 到 0.5。因为漏掉一辆车的代价是整个车牌都丢了,而多检一个框的代价只是多跑一次车牌检测(几十毫秒)。这个买卖明显划算。当然低的代价是误检变多,所以要在后面用车牌检测的严格阈值兜住。
车牌检测阈值,我倾向设得高一些,0.55 到 0.65。因为送进车牌检测的区域已经是"车"了,误检的空间小,收紧阈值能有效过滤掉车身上的装饰条、反光贴、后备胎这类长得像车牌的东西。
字符识别置信度,我一般设 0.8 作为接受下限,低于这个值的结果标记为"待定"而不是直接丢弃。实际跑起来你会发现,很多低置信度结果是"部分正确"(比如把 8 认成 B),保留下来给上层做模糊匹配,比直接扔掉更有价值。
跟踪器的 IOU 匹配阈值我通常用 0.3。设太高(比如 0.5)会导致车速快的时候框位移大、匹配不上,ID 频繁切换;设太低(比如 0.1)又会让相邻两辆车串 ID。0.3 是个比较中庸的起点,实际按帧率和车速微调。
3.4 跳帧与算力预算:一张卡能接几路
这笔账要这么算:单帧的总耗时 = 解码 + 车辆检测 + 车牌检测 + 字符识别 + 绘制。以一张中端显卡为例,1080P 下单帧大致是车辆检测 8ms、车牌检测 2ms(因为只在小 ROI 上跑)、字符识别 1ms、再加上解码和绘制的 5ms 左右,合计约 16ms,理论上一路相机 25fps 只要 400ms/秒,也就是 40% 的单卡占用。听起来能接两路,但实际要打折扣,因为管道有排队、有序列化开销,还有突发流量。
所以我的经验值是:一张中端卡稳定接 4 路 1080P 车牌识别,前提是开了跳帧。跳帧的思路是——车不是每秒都在过,没必要每帧都跑完整链路。我的做法是用跟踪节点的结果做触发:只有当某个 track_id 连续出现 3 帧以上(说明是个真实目标,不是一闪而过的误检),才把它送进车牌检测和识别链路,且同一个 track_id 最多识别 3 次(取置信度最高的那次作为最终结果)。这样绝大多数帧只跑了车辆检测这一步,算力省下来一大半,而识别结果的准确度反而更高,因为我们是从多次识别里挑的最优解。
这个"多次识别投票"的策略,在车牌轻微模糊或者反光的时候特别好用。我见过一个场景,车牌被阳光照得局部过曝,单帧识别出来是"京A·12B45",但前后几帧分别识别出"京A·12845"和"京A·12845",投票之后取多数,结果是对的。
4. 实操:从零跑通一条车牌识别流水线
4.1 环境准备与编译
VideoPipe 是 CMake 工程,依赖 OpenCV 和推理后端(ONNX Runtime 或 TensorRT 之类)。我在 Linux 上的一般流程是:
# 依赖装齐:OpenCV(带 videoio、imgproc)、CMake 3.15+、g++ 支持 C++17 sudo apt install -y build-essential cmake libopencv-dev # 拉代码,注意第三方依赖(opencv、onnxruntime 等)按仓库 README 的脚本准备好 git clone <repo> VideoPipe cd VideoPipe mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc)这里有两个坑我要提前说。第一个是OpenCV 版本,不同版本之间 videoio 模块的 RTSP 行为有差异,我遇到过换版本之后取流延迟从 200ms 涨到 1.5 秒的情况,建议固定用你自己验证过的那一版。第二个是推理后端的 GPU 支持,如果你打算上 TensorRT,要确认 CUDA、cuDNN、TensorRT 三者版本互相兼容,这个组合的坑最深,装完先跑一个最小的推理示例验证,别直接上管道。
模型方面,你需要准备三个:车辆检测模型、车牌检测模型、车牌字符识别模型。前两个用常见的单阶段检测器导出成 ONNX 即可,第三个用 LPRNet 这类轻量序列模型。字符集一定要和你的业务区域对齐,国内车牌包含省份简称汉字、字母、数字,还有新能源的 8 位牌,如果你的训练集只覆盖了 7 位蓝牌,遇到绿牌就会出错。这一点我在第五节还会展开。
4.2 流水线的代码骨架与关键改动
上一节给了一个基础骨架,实际落地时我一般会做三处改造。
第一处,在车辆检测和车牌检测之间加一层 ROI 管理。不是所有车辆框都值得送检:太小的(宽度小于画面宽度的 6%)直接跳过,因为它们大概率在远处、车牌像素不够;位于画面边缘的也要谨慎,边缘框常常是被裁掉一半的车。
// 伪代码:车辆框过滤逻辑 for (auto& det : frame_meta->detections) { int w = det.box.width; int h = det.box.height; if (w < frame_w * 0.06) continue; // 太远的车不检 if (det.box.x <= 2 || det.box.y <= 2) continue; // 贴边的不检 if (w * 1.0 / h > 5.0) continue; // 极端长条,大概率误检 candidates.push_back(det); }第二处,外扩裁剪框并做边界截断。这是防止车牌被切的关键:
int pad_w = static_cast<int>(det.box.width * 0.12f); int pad_h = static_cast<int>(det.box.height * 0.12f); cv::Rect roi(det.box.x - pad_w, det.box.y - pad_h, det.box.width + 2 * pad_w, det.box.height + 2 * pad_h); roi &= cv::Rect(0, 0, frame.cols, frame.rows); // 截断越界部分 if (roi.width < 16 || roi.height < 16) continue;第三处,结果缓存与去重。用一个map<int, PlateResult>按 track_id 存最优结果,只有当前帧的识别置信度高于缓存值时才更新。这样输出节点吐出去的数据天然就是"一车一条",上层业务不用再自己去重。
4.3 字符识别的输入处理与后处理
字符识别这一环,输入处理和输出处理各占一半的重要性。输入处理前面说过,核心是按目标长宽比裁剪 + 不变形 resize。这里补一个细节:resize 的插值方式。缩小图像时用INTER_AREA比INTER_LINEAR干净得多,能减少摩尔纹;放大时用INTER_LINEAR或INTER_CUBIC。车牌在画面里通常比网络输入大,属于缩小,所以走INTER_AREA。
输出后处理有两件事必须做。第一件是字符集映射校验:LPRNet 输出的是 CTC 解码后的序列,偶尔会吐出训练集里不存在的组合,比如第一位出现数字。国内的普通车牌第一位必然是省份简称汉字,第二位是字母,后面五位是字母数字混合(有具体规则)。我会加一层轻量的规则校验,明显不合规的结果降权处理。第二件是置信度计算:不要用 CTC 最后一层的最大值当置信度,要用整个序列路径的概率乘积或者平均值,否则短字符串会天然占便宜。
// 伪代码:CTC 解码 + 简单规则校验 std::string text = ctc_decode(output); float score = sequence_confidence(output, text); if (text.size() < 7 || text.size() > 8) score *= 0.5f; // 长度异常降权 if (!is_valid_first_char(text[0])) score *= 0.6f; // 首位不是省份简称 if (score < 0.5f) return; // 低于底线直接丢 plate_cache[track_id] = keep_better(plate_cache[track_id], {text, score});4.4 联调验证的做法
联调阶段我不用实时流,而是先录一段视频文件跑离线。原因是实时流不可控,你没法复现同一辆车、同一帧的问题。用vp_video_src_node读本地 MP4,把每个节点的中间结果都落盘:车辆检测结果存成 JSON、车牌裁剪图存成 jpg、识别结果存成 CSV。跑完之后统计三个指标——车辆检出率、车牌检出率、字符准确率,一层一层往上排查。这套流程跑一遍,你就知道瓶颈在哪一层了,比在现场盯着屏幕猜高效得多。
现场部署的时候,我还会做一件事:留一个"回放模式"的开关。把现场录的流存下来,出问题时直接回放,能复现的问题都好解决,不能复现的问题才是噩梦。这个习惯救过我好几次。
5. 常见问题排查实录
5.1 漏检与误检速查表
| 现象 | 最可能的原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 白天正常,夜间车牌全漏 | 车灯过曝导致车牌区域饱和 | 存夜间帧看车牌区域像素值 | 相机开宽动态,或降低曝光补偿 |
| 远处车辆识别不到 | 车牌像素高度不足 | 用 3.1 的公式反算 | 换长焦或提高分辨率,别调模型 |
| 车身上贴纸被识别成车牌 | 车牌检测阈值过低 | 看误检框的位置分布 | 阈值提到 0.6 以上,加长宽比过滤 |
| 同一辆车报了多次 | 跟踪 ID 频繁切换 | 打印 track_id 变化日志 | 调大 max_age,降低 IOU 阈值 |
| 车牌被切掉一半 | 车辆框贴太紧 | 看裁剪出的车牌小图 | 外扩裁剪比例提到 12% 到 15% |
| 斜向车辆识别错字 | 车牌透视变形 | 看原始裁剪图 | 加透视变换矫正,或改用四点检测 |
这张表是我这几年遇到问题的一个浓缩,其中"远处车辆识别不到"和"车贴误检"这两条出现频率最高。前者是硬件问题,后者是阈值问题,都不该在模型层面折腾。
5.2 字符识别错字的典型原因
字符错了,先别急着换模型,八成是输入的问题。我总结了三类高频原因。
第一类是形近字。"8"和"B"、"0"和"D"、"1"和"I"、"2"和"Z",这几组在低分辨率下几乎无法区分。出现这种情况,先看字符高度够不够,如果只有十几像素,那就是像素不够,不是模型不行。如果像素够还错,才考虑模型的字符集或者训练数据问题。
第二类是字符集缺项。国内车牌有省份简称汉字,新能源车牌是 8 位,还有一些特殊用途的车牌格式。如果你的模型训练集里没有 8 位绿牌,那它遇到绿牌时会强行按 7 位输出,结果必然是错的。这个问题只能靠扩充训练数据解决,没有捷径。
第三类是裁剪偏移。车牌检测框稍微偏一点,边缘字符就被切掉了,识别出来就成了"京A·1234"(少一位)。这类错误有个特征:识别结果的长度经常偏短,置信度反而不低。排查方法是把裁剪图存下来人工看,一眼就能确认。
心得:我在项目里固定了一个"错例归档"流程——凡是置信度在 0.5 到 0.8 之间、或者长度异常的结果,自动把原始帧和裁剪图存到一个目录。每周扫一遍,能发现大量系统性问题,比盲目调参有用得多。
5.3 性能、内存与稳定性
跑长稳测试是必须的,最少 72 小时。我遇到过的问题里,有一半只在长时间运行后才暴露。常见的有三个。
内存缓慢增长。多数是节点里的缓存没清理,比如结果缓存 map 按 track_id 存结果,但轨迹销毁时没删对应的缓存条目,跑几天就吃掉几个 G。解决办法是给缓存加过期机制,比如每条记录带上时间戳,定期清理超过 5 分钟的条目。
帧率抖动。表现为平时 25fps,隔几十秒卡一下。原因通常是下游 IO 阻塞导致反压,或者解码器遇到关键帧时耗时突增。我的做法是给输出节点加独立线程和队列上限,队列满了就丢帧而不是阻塞——车牌识别场景里丢几帧不影响结果,阻塞整条链路才是灾难。
断流不重连。RTSP 流在网络抖动后会静默断开,管道还在跑,但帧不再更新。这个必须靠前面说的"时间戳看门狗"解决,框架本身不负责这件事。
6. 工程化落地的一些经验补充
6.1 相机选型与安装位置
选相机这件事,我的建议顺序是:先定识别距离和车道宽度,按字符高度 25 像素反算需要的水平像素数,再选分辨率和焦距。别反过来——先买个便宜相机再想办法优化算法,这条路基本走不通。镜头的焦距选择上,宁可长一点、视场窄一点,把像素集中在车道上,也别为了"看得广"牺牲车牌像素密度。
补光也是绕不开的一环。夜间车牌识别最怕两件事:车灯直射导致过曝,以及环境光不足。我一般建议用相机自带的补光或者外置的频闪补光灯,频闪的好处是能和相机曝光同步,减少对驾驶员的影响,同时提升车牌区域的信噪比。安装位置上,补光灯和镜头尽量同轴或者夹角很小,避免车牌反光直接打进镜头。
6.2 触发策略与数据闭环
纯视频检测触发(靠车辆检测框出现来判断有车)实现简单,但会有无效计算——比如画面里停着一辆车不动,它就一直在跑。如果有条件,我建议加一路地感或者雷达做硬触发,车到了才开始跑识别,这样算力能省一大半,误报也能压下去。
数据闭环这件事,我现在的做法是在设备上留一个"疑难样本上传"的通道:置信度落在中间区间、或者规则校验失败的结果,连同原始帧一起上传到服务器。这些数据攒够了,就是下一版模型最好的训练集。这套机制跑上半年,识别率通常能再往上提几个点,而且提的都是你现场真实遇到的难例,比公开数据集有用得多。
最后说个我踩过最深的坑。早期做这类项目,我总想着把参数调到"最优",检测阈值精确到小数点后两位,裁剪比例反复试。后来发现,现场环境是会变的——早上顺光、下午逆光、雨天车牌沾泥、冬天车牌结霜,你在实验室调出来的那组参数,到了现场能覆盖七成场景就不错了。真正让系统稳下来的,不是某一组完美参数,而是分层兜底的策略:检测阈值放宽一点、多识别几次投票、置信度分档处理、难例自动归档。这套东西看起来笨,但它在真实的、脏的、千变万化的场景里,比任何精调都靠谱。