车牌识别这件事,做过的人都清楚一个事实:把模型跑通只占了整个工作量的两成,剩下八成全耗在“怎么让它在真实相机上稳定跑一个月不出乱子”上。我最初也是抱着“检测加识别两个模型串一起不就完了”的想法动手的,结果第一版代码写了一百来行就出了结果,兴冲冲丢到现场,第二天就收到反馈说画面卡成幻灯片、同一辆车被记录了十几条、晚上的识别结果全是乱码。那次返工之后我才老老实实去找一套能撑住工程的骨架,最后落到 VideoPipe 这个视频分析框架上。
这里要聊的就是用 VideoPipe 搭一台车牌识别相机的完整过程。它不是“教你调用某个 API”的入门文,而是把一个从零散脚本到可长期运行服务的搭建路径摊开讲:三段式的模型分工、节点怎么编排、ROI 怎么在节点之间传递、车牌透视矫正为什么是非做不可的一步、八路 1080p 如何压进一张显卡,以及那些只有真正在现场蹲过才知道的去重和容错细节。无论你是刚接触视频结构化的新手,还是已经用 OpenCV 拼过流水线的老手,都能从里面找到能直接抄的东西。
1. 为什么车牌识别不该写成一个巨型 while 循环
1.1 一段“跑起来很爽、上线就崩”的典型代码
大多数人第一次写视频分析,代码长这样:一个while(true)里读帧、调检测、调识别、画框、写日志。在本地拿一个 mp4 文件测试,帧率 25,延迟 40 毫秒,看起来完美。但只要换到真实场景,问题会成串地冒出来。
第一个问题是取流和解码把主线程堵死。真实相机的 RTSP 流不会老老实实按 25fps 匀速到达,网络抖动、关键帧间隔、码率突增都会让read()阻塞几百毫秒。主线程一阻塞,后面的推理、绘制、输出全部排队。第二个问题是没有任何背压控制,推理慢于取流的时候,帧缓冲区越堆越深,延迟从 40 毫秒涨到 4 秒,你看到的画面已经是几秒前的世界。第三个问题最隐蔽:因为所有逻辑挤在一个函数里,你没法给“车辆检测”和“车牌识别”分配不同的线程和不同的加速比,只能整条链一起快或一起慢。
我在第一版里踩的坑很典型:为了保证实时性加了个“隔帧处理”,结果车开得快的时候,车牌在检测框里只出现两三帧,识别模型还没攒够置信度车就出画了,抓拍率直接掉到六成。这种问题靠改一个 if 是解决不了的,它需要的是流水线本身具备“跳帧但不丢目标”的能力。
1.2 VideoPipe 的 node-pipeline 模型解决的具体问题
VideoPipe 的组织方式非常朴素:一切皆节点,节点连成有向图,数据以帧元数据(frame meta)的形式在边上流动。听起来像教科书,但它带来的实际收益非常具体。
首先是解耦。源节点只管拉流和解码,推理节点只管算,输出节点只管渲染或推流。它们跑在各自的线程里,中间用带缓冲的队列连接。相机那边抖一下,影响被限制在源节点,下游该干嘛干嘛。这一条听起来平淡,但它直接消灭了“取流卡死导致整条链雪崩”这个最恶心的故障模式。
其次是分支。VideoPipe 里一个节点的输出可以attach_to多个下游节点,形成扇出。车牌识别正好需要这个能力:车辆检测的结果要同时喂给“车牌检测”和“OSD 绘制”;车牌识别结果要同时喂给“屏幕显示”和“结构化数据落库”。如果是单体代码,你得在一个循环里手动分发给三个函数,还得自己管理它们之间共享的中间状态。
第三是元数据的承载能力。VideoPipe 里的vp_frame_meta装着这一帧的原始图像和一份目标列表,每个目标(vp_target_meta)带着自己的类别、包围盒、跟踪 ID,更关键的是它支持子目标——一个车辆目标下面可以挂若干车牌目标,车牌目标下面还可以挂文字结果。这条设计是整个车牌识别链路能保持干净的根本原因,后面第三节会展开讲。
1.3 三段式识别链路的拆分逻辑
有人会问:现在不是有端到端的车牌识别模型吗,一张图进去车牌号出来,何必拆成三段?我的回答是:端到端在实验室数据集上确实好看,但现场不行。
原因在于尺度。一台 200 万像素的枪机装在 6 米高的立杆上,视野宽度大概 8 米,一辆车在画面里可能只占 200×150 像素。车牌在这个尺度下只有 40×12 像素左右。你让一个网络直接从 1920×1080 的原图里找出这 40×12 的小块并识别字符,等于让它同时解决“大海捞针”和“微雕”两个问题。而如果先做车辆检测(大目标,好检),再在车辆框这个已经被裁剪、缩放过的 ROI 里做车牌检测,车牌在送进第二个网络时等于被放大了 5 到 8 倍,检测难度断崖式下降。
所以拆分的逻辑不是“为了模块化而模块化”,而是用小 ROI 换检测精度。三段各自的分工如下:
| 环节 | 输入 | 输出 | 核心诉求 |
|---|---|---|---|
| 车辆检测 | 整帧图像(缩放后) | 车辆包围盒 | 召回率高,宁可多检不可漏检 |
| 车牌检测 | 车辆 ROI 裁剪图 | 车牌四点坐标或包围盒 | 定位精度高,边界要准 |
| 字符识别 | 矫正后的车牌条图 | 字符序列 + 置信度 | 速度优先,单帧几毫秒 |
值得强调的是第一环的取舍:车辆检测宁可放宽阈值。因为漏掉一辆车,后面所有环节都无从谈起;多检一个框,最多浪费几毫秒推理时间,而且第二环的车牌检测天然会把这个误检过滤掉——框里没有车牌,自然就没有输出。我在现场把车辆检测的置信度阈值从 0.5 降到 0.35 之后,夜间抓拍率提升了将近 15 个百分点,代价只是每帧多跑了两三个无关 ROI,整体延迟增加不到 3 毫秒。
2. 模型准备:三个模型各司其职,别指望一个网络包打天下
2.1 车辆检测、车牌检测、字符识别的能力边界
选型上我先说结论:车辆检测和车牌检测都走 YOLO 系列的单阶段检测器,字符识别走 CRNN 加 CTC 解码的序列识别模型。这套组合是目前性价比最高的方案,模型体积小、导出简单、社区里现成的预训练权重多。
车辆检测模型的输入一般用 640×640,输出的类别可以只保留“车”这一大类(轿车、卡车、客车合并即可),没必要细分。理由是车牌识别业务只关心“有没有车、车在哪”,车型信息属于锦上添花,多分类只会增加训练成本和混淆概率。
车牌检测的输入同样用 640×640,但要注意它的训练数据是车辆 ROI 裁剪图,而不是整帧。这是个很容易搞错的点:有人拿整帧图训练车牌检测,模型在训练集上指标很漂亮,一上线就发现小目标全漏。原因是整帧图里车牌像素太少,网络的感受野被大量背景稀释。用 ROI 裁剪图训练,等于人为地把车牌样本“放大”到网络舒适的尺度,泛化到现场更稳。
字符识别模型我推荐用高度 48、宽度 320 的输入规格。这里有个关键认知:车牌是超宽扁的图形,蓝牌和新能源绿牌的长宽比大概在 3.1 到 3.5 之间,而字符识别网络如果按 1:1 或 4:3 的方形输入去缩放,字符会被压扁到几乎不可辨认。按 48×320 的比例(约 1:6.7)去规整,图像只做左右补边不做纵向拉伸,字符的笔画结构才算保住。
2.2 ONNX 导出与 engine 缓存的实操细节
模型训练完之后要转成 ONNX,这一步的坑主要集中在动态维度和后处理耦合上。导出时务必把 NMS 这类后处理从网络里剥出来,交给框架侧的节点去做。我见过有人在导出时保留了 NMS 算子,结果 ONNX 里带了一堆非标准算子,转引擎时直接卡住。正确做法是让模型只输出原始预测张量(比如[1, 84, 8400]这种形状),NMS 放到推理节点的后处理代码里跑。
转成 TensorRT 引擎时,第一次序列化能慢到让你怀疑人生——我这边一个中等规模的检测模型,序列化花了将近十分钟。所以工程里一定要做引擎缓存:编译一次,把.engine文件按“模型哈希 + 精度 + 显卡型号”命名存盘,后续启动直接反序列化,启动时间从十分钟压到两秒以内。这个缓存的键一定要包含显卡型号,因为不同架构的显卡引擎不通用,换了卡还用旧引擎会直接抛异常。
还有一个容易被忽略的细节:FP16 精度对车牌检测的影响远小于对字符识别的影响。我实测下来,车辆检测和车牌检测开 FP16 精度损失在千分之一量级,几乎无感;但字符识别开到 FP16 之后,那些字形相近的字符(比如数字 8 和字母 B、数字 0 和字母 D)的混淆率会明显上升。所以我的做法是检测模型用 FP16、识别模型保持 FP32,整体显存占用增加不多,但识别准确率的提升是实打实的。
提示:如果你的部署卡显存比较紧张,先降识别模型的批大小,不要急着降精度。批大小从 8 降到 4 只会影响吞吐,而降精度影响的是结果正确性,后者没法用工程手段弥补。
2.3 字符识别的输入规整与 CTC 解码
字符识别送进去的图像必须是“矫正过的车牌条”,这一步在第三节的矫正环节之后完成。规整的方式是等比缩放到高度 48,宽度按比例算出来之后居中补零到 320,而不是硬拉伸。硬拉伸会让窄车牌的字符变得又高又瘦,宽车牌的字符被压扁,两种情况识别率都会掉。
CTC 解码这一步看起来简单,实操中最容易出错的是字符集映射表。国内车牌涉及的字符集大概是 68 类:省份简称若干、字母 24 个(去掉 I 和 O 避免与数字混淆)、数字 10 个,加上一个空白符。这个映射表的顺序必须在训练、导出、推理三个阶段完全一致,错一位就是满盘乱码。我就吃过这个亏——训练时字符集文件是按拼音排序的,推理时我手写了个按字符编码排序的表,结果识别出来的车牌永远是“看起来像但又不对”的字符串,排查了大半天才定位到映射表错位。
解码逻辑本身是标准的 CTC 贪心:对每个时间步取最大概率的类别索引,去掉连续重复的索引,再去掉空白符。这段代码很短,但要注意空白的索引位置,通常是 0 或者最后一类,取决于训练时的定义。
// CTC 贪心解码示意 std::string ctc_decode(const float* logits, int steps, int num_classes, const std::vector<std::string>& charset, int blank_idx) { std::string result; int prev = -1; for (int t = 0; t < steps; ++t) { const float* row = logits + t * num_classes; int best = static_cast<int>(std::max_element(row, row + num_classes) - row); if (best != blank_idx && best != prev) { if (best < static_cast<int>(charset.size())) { result += charset[best]; } } prev = best; } return result; }另外,别只取最大概率,要顺手把每一步的置信度累乘起来作为整条结果的分数。这个分数后面做去重投票时非常有用,可以过滤掉那些“猜出来的”结果。
3. 管道编排:节点怎么连、ROI 怎么传
3.1 源节点与解码:多路流的第一道关卡
源节点负责拉流和解码,是整个管道唯一直接跟相机打交道的地方。这里的选择直接决定了后面所有环节的稳定性。
如果相机输出的是 RTSP 流,源节点内部一般会用硬件解码。这里有个参数必须调:缓存帧数。默认缓存往往偏大,追求的是“不丢帧”,但这会让实时性变差。对于车牌识别这种场景,几秒前的画面毫无价值,我一般把缓存压到 1 到 2 帧,牺牲一点点平滑度换取低延迟。经验值是:出口闸机场景延迟超过 500 毫秒就会影响通行体验,所以宁可偶尔丢一帧也别攒着。
另一个参数是跳帧间隔。VideoPipe 的源节点支持按固定间隔丢弃若干帧后再送下游,这个能力在第五节的性能调优里会详细讲,但这里要先说清楚它的语义:跳帧是在源节点处丢弃,也就是说被丢的帧根本不会进入推理链路,下游拿到的帧序号是不连续的。这意味着你不能用帧序号做任何“连续性”假设,比如“相邻两帧的目标位置应该接近”这种判断就会失效。
多路相机的组织方式是一路一个源节点,各自独立解码,然后在管道里汇聚。不要试图用一个解码器去解多路流再拆分,那样一旦某一路网络出问题会拖垮全部。
3.2 推理节点的串联与子目标传递
这是整个编排里最核心的部分。VideoPipe 的推理节点之间传递的不是图像,而是帧元数据,这带来一个关键能力:子目标嵌套。
具体来说,车辆检测节点在帧元数据里写入一批车辆目标;车牌检测节点读到的不是原始整帧,而是每个车辆目标内部裁剪出来的 ROI 区域,它把检测到的车牌写成车辆目标的子目标;字符识别节点再读车牌子目标的 ROI,把识别文字写回车牌子目标。整条链走完,你拿到的是一个树形结构:
// 遍历结果的示意结构 for (auto& target : frame_meta->targets) { // 车辆 for (auto& sub : target->sub_targets) { // 车牌 std::string plate = sub->get_embeddings(); // 识别出的文字 // ... } }这个结构的好处是天然保持了归属关系。同一帧里如果出现三辆车,你不需要额外做“车牌属于哪辆车”的匹配,树形结构已经替你做好了。而在单体代码里,你得靠包围盒的包含关系去算,一旦车与车有遮挡、车牌跨到了相邻车框里,匹配就会错乱。
节点挂载的代码大致长这样,注意不同版本的构造函数签名会有出入,实际以仓库里的样例为准:
auto pipeline = std::make_shared<vp_pipeline>(); auto src = std::make_shared<vp_rtsp_src_node>( "cam_01", 0, "rtsp://...", 1.0, 25, 2); // 名字/通道/地址/比例/帧率/缓存 auto vdet = std::make_shared<vp_vehicle_detector_node>( "vehicle_det", det_engine_path, label_path, 0.35f, 0.45f); auto pdet = std::make_shared<vp_plate_detector_node>( "plate_det", plate_engine_path, label_path, 0.4f, 0.45f); auto prec = std::make_shared<vp_plate_recognizer_node>( "plate_rec", rec_engine_path, charset_path); auto osd = std::make_shared<vp_osd_node>("osd_01"); auto out = std::make_shared<vp_screen_des_node>("screen_01", 0); src->attach_to({vdet}); vdet->attach_to({pdet}); pdet->attach_to({prec}); prec->attach_to({osd}); osd->attach_to({out}); pipeline->start();注意节点的挂载顺序就是数据流方向,attach_to的参数是下游节点的集合。如果要做分支(比如同时输出到屏幕和推流),就在同一个attach_to里放多个节点。
3.3 跟踪节点的真实价值
很多人以为跟踪是为了画轨迹线,其实在车牌识别里它的第一价值是去重。
设想一辆车以 30 公里时速经过相机,车速每秒 8 米,相机视野覆盖 8 米,那么这辆车在画面里停留大概 1 秒,也就是 25 帧。这 25 帧里,车牌检测可能命中 20 帧,字符识别成功 15 帧。如果没有跟踪,你会往数据库里写 15 条记录,同一辆车被记了 15 次。
加上跟踪之后,每辆车有了一个稳定的track_id。业务层只需要按track_id聚合,同一 ID 的车牌结果做投票,取出现次数最多的那个字符串,一辆车只写一条记录。跟踪节点要放在车辆检测之后、车牌检测之前,因为它是按车辆包围盒做匹配的。
跟踪算法本身选 SORT 就够了,不需要上更重的模型。车牌识别场景里车辆运动相对规律,遮挡少,简单的卡尔曼滤波加匈牙利匹配完全够用。我用 ByteTrack 试过,效果差异不明显,但资源占用高一些,对多路场景不划算。
有一个参数值得单独调:最大丢失帧数。默认值往往偏小,低速场景下车辆被前车短暂遮挡几帧,跟踪 ID 就会切换,去重就失效了。我一般把这个值调到 25 到 30 帧,相当于容忍一秒左右的遮挡。代价是同一辆车如果离开又回来,可能被认成同一辆,但在闸机这种单向场景里这个误判无伤大雅。
3.4 OSD 与结构化输出的落地
OSD 节点负责把结果画到画面上,看起来是最没技术含量的一环,但实际调试时它的作用很大——你所有的中间结果对不对,全靠这层可视化来验证。
绘制的内容建议至少包含三样:车牌文字、车牌四点框、跟踪 ID。文字用大字,做描边处理(先画黑边再画白字),否则遇到浅色车身会完全看不清。跟踪 ID 用来肉眼判断去重是否正确,这是排查“一车多条”问题最快的手段。
结构化输出不要直接在 OSD 节点里做。正确的做法是写一个自定义的输出节点,挂在识别节点下游,专门负责把结果序列化成 JSON 或写入消息队列。自定义节点的模板很简单,继承基类实现一个处理函数就行:
class plate_sink_node : public vp_node { public: plate_sink_node(const std::string& name) : vp_node(name) {} protected: void handle_frame_meta(const vp_frame_meta& meta) override { for (auto& veh : meta.targets) { if (veh->sub_targets.empty()) continue; for (auto& plate : veh->sub_targets) { nlohmann::json j; j["camera"] = meta.stream_id; j["track_id"] = veh->track_id; j["plate"] = plate->get_embeddings(); j["score"] = plate->confidence; j["ts"] = static_cast<long long>(time(nullptr)); emit(j.dump()); } } } };注意:输出节点里千万别做耗时的 IO 操作,写数据库、发网络请求这些必须丢到独立的线程池或者消息队列里。节点处理函数阻塞会直接拖慢整条链,这是新手最常犯的性能错误。
4. 车牌矫正:识别率的分水岭
4.1 四点顺序与透视变换
为什么车牌检测输出的是四点而不是矩形框?因为相机装在立杆上俯拍,车牌在画面里是被压扁的梯形,而且由于镜头畸变和车头角度,左右边缘往往是斜的。你拿这个梯形直接送进字符识别网络,字符会被拉伸变形,纵向笔画变短,横向笔画变宽,识别率必然掉。
透视变换把梯形拉回矩形,这一步能把识别率提升多少?我在一个包含 800 张现场抓拍图的测试集上做过对比:不做矫正直接拉伸,整牌准确率 71%;做了透视矫正之后,整牌准确率 89%。18 个百分点的差距,比换个更贵的模型带来的提升大得多。
实现上用的是标准的四个点求解变换矩阵,问题在于点的顺序。透视变换要求源点顺序和目标点顺序严格对应,左上、右上、右下、左下,错一个点,输出图就会翻转或撕裂。检测模型的输出顺序是不确定的,所以必须自己做排序。
排序的正确做法是先按纵坐标分组:纵坐标最小的两个点构成上边,最大的两个点构成下边;然后在上边两个点里,横坐标小的是左上;在下边两个点里,横坐标小的是左下。这个方法在车牌倾斜角度小于 45 度时都成立,超过 45 度(几乎不可能出现在正常安装的相机里)会失效。
std::vector<cv::Point2f> order_points(std::vector<cv::Point2f> pts) { std::sort(pts.begin(), pts.end(), [](const cv::Point2f& a, const cv::Point2f& b) { return a.y < b.y; }); cv::Point2f tl = pts[0], tr = pts[1], br = pts[3], bl = pts[2]; if (tl.x > tr.x) std::swap(tl, tr); if (bl.x > br.x) std::swap(bl, br); return {tl, tr, br, bl}; } cv::Mat warp_plate(const cv::Mat& src, std::vector<cv::Point2f> pts) { auto p = order_points(pts); float w = std::max(cv::norm(p[0] - p[1]), cv::norm(p[3] - p[2])); float h = std::max(cv::norm(p[0] - p[3]), cv::norm(p[1] - p[2])); // 按车牌真实长宽比修正,避免拉伸 h = std::max(h, w / 3.14f); std::vector<cv::Point2f> dst = {{0, 0}, {w, 0}, {w, h}, {0, h}}; cv::Mat m = cv::getPerspectiveTransform(p, dst); cv::Mat out; cv::warpPerspective(src, out, m, cv::Size(w, h)); return out; }注意代码里那行按真实长宽比修正的写法。透视变换算出来的h有可能过小,导致输出图被横向拉长。用 3.14 这个经验比例兜个底,能避免极端情况下的形变。这个比例对不同车牌类型略有差异,但用它做下限保护足够了。
4.2 颜色、光照与二值化的取舍
有些老方案会在矫正之后再做一步二值化,把彩色车牌转成黑白条图再送识别。这个做法在白天光照均匀的时候有用,但在现场环境下反而会帮倒忙。
问题出在夜间。夜间车灯直射车牌时会有强烈的过曝区域,二值化的阈值一设,过曝部分整片变成白色,字符被吞掉;而有阴影的一半又整片变黑。相比之下,直接把彩色图送进网络,让模型自己去学光照不变性,鲁棒性反而更好。
我的做法是:主链路不做二值化,彩色图直接送识别。只在置信度低于某个阈值时,才启用一条备选分支——尝试几种不同的二值化参数各识别一次,取分数最高的结果。这条备选分支的触发率大概在 8% 左右,不影响整体吞吐,但能捞回一部分疑难样本。
颜色本身倒是可以作为一个辅助特征用。蓝牌、黄牌、绿牌、白牌的颜色分布差异明显,可以在矫正之后算一下 HSV 空间的平均色调,作为一个额外的特征参与结果校验。比如识别结果长度是 7 位但颜色判定是新能源绿牌(应该是 8 位),这就说明识别大概率出错了,可以触发重新识别或者标记为可疑。
4.3 几个实测的边界情况
第一种情况是车牌被部分遮挡。比如前车挡住了一半车牌,检测模型给出的四点框只覆盖了可见部分。这种情况下透视变换出来的图是半个车牌,识别结果必然不对。处理方法是在矫正之后检查宽高比,正常的车牌宽高比在 3 左右,如果算出来只有 1.5,说明框不完整,直接把这条结果丢弃,不要写库。
第二种是双层车牌,某些大型车辆的尾部是上下两行。这种车牌的四点框宽高比接近 1.5,跟遮挡的情况很像,容易混淆。区分方法是看框的高度相对宽度——双层车牌的高度大概是单层的两倍。识别策略上要单独处理:把图上下切成两半分别识别,再拼接结果。如果业务场景里这类车很少,简单丢弃也是一种合理选择。
第三种是车牌上的字符粘连。老旧车牌掉漆、泥点遮挡,会让两个字符粘成一片。这种情况 CTC 解码出来的结果长度会偏短。我的经验是:结果长度必须严格匹配车牌类型。普通蓝牌 7 位,新能源 8 位,如果解出来 6 位,分数再高也不可信,直接丢弃。
5. 性能调优:从 1 路跑到 8 路的硬功夫
5.1 显存与批次的平衡
先说结论:一张中端显卡跑 8 路 1080p 的车牌识别,是完全可行的,前提是把三段模型的负载算清楚。
我的实测数据大概是这样:车辆检测模型(640×640 输入)单帧推理 6 到 8 毫秒;车牌检测模型同样是 640 输入但只跑 ROI,单辆车大概 4 毫秒;字符识别模型输入很小,单张 1 到 2 毫秒。也就是说,每处理一帧含一辆车的画面,总计算量在 12 毫秒左右。
8 路 25fps 意味着每秒 200 帧。如果每帧都全量处理,就是 2400 毫秒的计算量,需要至少三张卡。所以必须做批处理和跳帧。
批处理的效果比想象中好。这三段模型的输入尺寸都是固定的,非常适合拼批。批大小从 1 提到 8,单帧平均耗时能从 8 毫秒降到 3 毫秒左右,吞吐提升接近 2.6 倍。VideoPipe 的推理节点支持配置批大小和最大批等待时间,后者的作用是:凑不满一批就等一小会儿再送,避免为了凑批把延迟拖长。我一般把这个等待时间设为 2 到 3 毫秒,兼顾吞吐和延迟。
显存占用要盯住三个数:模型本身占的、中间激活占的、批缓冲占的。批大小提到 8 之后,激活部分会明显涨。我建议先在目标卡上跑单路,用显存监控工具记下峰值,再线性外推,留 30% 余量。宁可少跑两路也别让显存打满,打满之后性能会断崖式下跌,而且是那种“平时好好的,一到车多的时候就崩”的间歇性故障,特别难查。
5.2 跳帧与跟踪补偿的配合
跳帧是降低计算量最直接的手段。8 路 25fps 跳成每 3 帧处理 1 帧,等效帧率降到 8.3,计算量直接砍到三分之一。但前面说过,跳帧会导致车辆在画面里出现的机会变少,抓拍率下降。
解决办法是让跳帧和跟踪配合。核心思路是:车辆检测可以跳帧,但跟踪不能停。具体来说,每 3 帧跑一次车辆检测,中间的两帧用跟踪器预测的框去补位,然后车牌检测和识别在这两帧上照常跑。由于车牌检测只依赖车辆框的位置,而跟踪器给出的预测框精度足够(车在 1/8 秒内移动不了多远),这条链路是通的。
这样做的效果是:计算量省了三分之二,但车牌出现的帧数基本没减少。我在现场测下来,跳帧加跟踪补偿的抓拍率是 94%,而不跳帧的全量处理是 96%,差距在可接受范围内。
有个细节要注意:跟踪器预测的框做 ROI 裁剪时,要适当外扩几个像素。预测框本身有误差,如果贴得死死的,车牌可能被裁掉一条边。我一般外扩 8 到 12 个像素,具体看分辨率。
5.3 线程与队列的配置
VideoPipe 的节点跑在独立线程上,节点之间的队列长度是需要调的。默认值偏小会导致上游频繁阻塞,偏大又会让延迟累积。
我的调法是从识别节点往上游倒着调。先看识别节点的队列,把它设成"批大小 × 2",这样能保证每次凑批都有货;再看车牌检测节点,设成跟批大小一致;车辆检测节点可以设大一点,因为它的输出要扇出到多个下游。源节点的队列反而是最小的,1 到 2 帧就够,多了纯属浪费。
还有一点:不要在节点内部做任何阻塞式的操作。我见过有人在推理节点里加了个日志文件写入,每帧写一行,磁盘 IO 一慢,整个节点就卡住。日志要异步写,采样写(比如每 100 帧写一次),或者干脆只记录异常。
6. 业务闭环与踩坑清单
6.1 去重与投票的具体做法
前面提到用track_id聚合,这里说具体实现。
思路是维护一个滑动窗口的字典,键是track_id,值是这个 ID 下所有识别结果的列表。每来一条新结果就追加进去,同时检查这个 ID 最后一条结果的时间戳,如果超过 3 秒没有更新,就把这个 ID 的结果汇总一次,投票出最终车牌号,写入业务库,然后从字典里移除。
投票的规则不是简单数票数,要加权。每条结果的权重等于识别模型的置信度乘以车牌完整性得分(长度是否符合规范、宽高比是否正常)。加权求和之后取最高分的字符串作为最终结果,同时记录第二名的分数,两者接近的话标记为低置信度,交给人工复核。
这个窗口大小 3 秒是有讲究的。太短,同一辆车的多次识别结果攒不够,投票失去意义;太长,字典里会堆积大量已离场车辆的残留记录,内存缓慢上涨。3 秒大概对应一辆车从进入到完全离开相机的时长,比较合适。另外记得加一个强制清理机制,字典里记录超过 30 秒的一律清掉,防止个别 track_id 因为跟踪器异常永远不"离场"。
struct VoteEntry { std::string plate; float score; }; // 加权投票示意 std::string weighted_vote(const std::vector<VoteEntry>& entries) { std::map<std::string, float> acc; for (const auto& e : entries) { float w = e.score * (e.plate.size() == 7 ? 1.0f : 0.6f); acc[e.plate] += w; } std::string best; float best_score = -1.0f; for (const auto& kv : acc) { if (kv.second > best_score) { best_score = kv.second; best = kv.first; } } return best; }6.2 现场踩过的几个坑
坑一:白天好好的,晚上识别全是乱码。排查下来是车灯过曝导致车牌检测框偏移。解决方案是在矫正前做一次局部对比度检查,过曝区域占比超过阈值时,改用另一种曝光参数重新取流(如果相机支持多码流),或者直接把这条结果丢弃不写库。宁可漏也别错,错误的车牌数据比没有数据更麻烦。
坑二:同一辆车被记了两次。跟踪 ID 在车经过画面中央时切换了一次,导致同一个车牌被分到两个 ID 下。根本原因是车辆在两帧之间位移过大,超过了跟踪器的匹配阈值。调大匹配阈值能缓解,但更稳的做法是在业务层加一道"车牌号 + 时间窗口"的去重:同一个车牌号在 5 秒内只记录一次,无论track_id是否相同。
坑三:跑了两三天之后程序越来越慢。这个查了很久,最后定位到是自定义输出节点里的一个容器只增不删。每处理一帧往里塞一条状态,跑一天就是几百万条,内存涨上去之后缓存命中率暴跌。教训是:任何长期持有的容器都必须有明确的清理策略,别指望"反正也不多"。
坑四:换了台机器就起不来。引擎缓存文件的命名里没有包含显卡架构,反序列化直接失败。加上架构标识之后解决。这个坑提醒我,所有跟硬件绑定的缓存都要在文件名里带上完整的硬件指纹。
6.3 上线前该做的几项验证
最后分享一套我自己用的上线前检查清单,都是被坑出来的:
- 长跑测试至少 72 小时。短时间的压测发现不了内存泄漏、文件句柄泄漏、显存碎片这类问题,必须跑够时间。
- 构造异常输入测试。把相机断电、拔网线、拔硬盘,看程序是不是能自愈。很多时候取流断了之后重连逻辑有问题,表现为第一次重连成功、第二次就卡死。
- 识别准确性抽检。从每个时段各抽 100 张抓拍,人工核对。特别注意清晨和傍晚这两个光照剧变的时段,往往是准确率的低谷。
- 延迟测量要用真实链路。别只看推理耗时,要从车进入画面开始计时,到现在屏幕上显示结果为止,这个端到端延迟才是用户能感知的。
我自己的体会是,车牌识别这个方向的技术门槛并不在模型上,现成的模型质量已经足够好。真正拉开差距的是那些琐碎的工程细节:一个点的排序、一个队列的长度、一个清理策略的有无。这些没有一样是能在论文里学到的,只能靠在一个个真实场景里撞出来。VideoPipe 这类框架的价值,就是把这些脏活累活的容器给你备好了,你只要往里填自己的业务逻辑,剩下的坑能少踩一大半。