1. 为什么车牌识别相机值得单独做一条管道
车牌识别相机这类边缘设备,逻辑其实不复杂:取一路 RTSP 流,找出画面里的车,框出车牌,读出字符,把结果和抓拍图拼在一起送出去。真正麻烦的是这些环节要在同一台盒子里、7×24 小时、多路并发地跑稳,还要把端到端延迟压在几百毫秒以内。用VideoPipe这个视频分析框架搭车牌识别相机,我是踩了小半年坑之后才想明白的一件事:把识别精度和工程稳定性拆开来看,前者靠模型,后者靠数据流。模型换一版顶多涨两个点,管道设计得好不好,直接决定这套东西能不能出实验室。
先说清楚这是什么、适合谁看。这篇讲的是用 VideoPipe 把一个车牌识别相机从零搭起来:取流、检测、识别、去重、上报,整条链路怎么拆成节点、每个节点的关键参数怎么算、显存和帧率怎么配平、上线之后会撞见哪些奇怪问题。如果你手上有 C++ 基础、想做点边缘侧的视频分析,或者正在做停车场、园区出入口这类项目,需要本地推理而不是把视频全丢上云,这篇基本能照着抄。如果你是完全零基础,建议先把 C++ 和 OpenCV 过一遍,不然第四节的代码会比较吃力。
我这套东西最终落在一个园区出入口和一段内部道路的相机上,两路 1080p,跑在一块不太新的边缘算力盒子上。下面所有的参数、坑和经验,都是从这个场景里长出来的,不一定适用于所有场合,但相信逻辑是通的。
1.1 从"模型能识别"到"相机能交付"中间隔着什么
很多人第一次做车牌识别,路径都差不多:找个开源检测模型跑通 demo,截一张图,框出来了,开心。然后拿一段真实路口的视频一跑,问题全来了。车开得快的时候连续七八帧框都飘,字符识别结果三帧一个样——"京A12345"、"京A1234S"、"京AI2345"轮流出现;远处的车只有半个车牌大,模型直接放弃;到了晚上,补光灯过曝,车牌上一片白,识别率掉到六成以下。
这些现象背后是工程问题,不是模型问题。连续帧结果不一致,是因为你把每帧当独立样本处理,没有把同一辆车的多帧结果归拢起来投票;远处小目标漏检,是因为你把 1080p 整帧压到 640 送进网络,车牌被压缩成了十几个像素;夜晚掉点,是因为曝光策略和检测阈值的组合没针对低照度做适配。
把这些理顺之后你会发现,一个能交付的车牌识别相机至少要有这几件事做扎实:稳定的取流与重连、合理的跳帧策略、两级检测(先车后牌)、跨帧结果聚合、异常帧过滤、以及一个能把结果写成结构化数据的输出环节。这也是我后来选择 VideoPipe 的直接原因——它把这些环节抽象成节点,节点之间用统一的帧元数据结构串起来,我只需要关心每个节点里干什么,不用自己写线程池和帧队列。
1.2 VideoPipe 的节点模型到底解决了什么
VideoPipe 的核心理念是"帧在节点间流动"。一个 pipeline 有一个源节点,往下挂若干处理节点,每个节点从上游拿到帧,处理完往下游推。帧的载体是一个统一的元数据结构,里面装着原始图像、宽高、帧序号,还有一个目标数组,存着这一帧检测出来的所有框,每个框带坐标、类别、置信度和跟踪 ID。
这个设计对我来说最大的价值是责任的切割。车辆检测节点只管把车框出来,车牌检测节点只管在车的区域里找牌,识别节点只管把牌上的字符读出来。中间无论是想加一个透视矫正、加一个低照度增强,还是想换一个模型后端,改动范围都局限在一个节点里,下游毫不知情。
另一个隐性好处是线程与队列的管理被框架吃掉了。视频解码和推理的节奏天然不一样,解码快、推理慢,如果自己写,很快就会出现帧堆积、延迟越跑越大。VideoPipe 的源节点支持跳帧策略,可以按目标帧率采样,让下游永远不会被上游喂爆。这个机制我在后面对比方案时会再展开讲,因为这是决定一套边缘设备能不能长时间稳定运行的关键。
还有一点值得提:它把 OSD 叠加、屏幕输出、推流输出都做成了现成节点。做调试的时候把 OSD 节点和屏幕节点挂上,框和文字直接打在画面上,肉眼就能判断是检测飘了还是识别错了,省了大量写可视化代码的时间。
提示:不同版本的 VideoPipe 在构造函数签名、命名空间、节点可用性上会有差异,本文代码按我手上这一版写,你落地时以仓库里的 examples 为准,签名对不上是正常的,不是你的问题。
1.3 三条路线横向对比与选型结论
搭一个车牌识别相机,我实际评估过三条路,最后选了第三条。这里把差异摊开讲,你自己判断。
| 对比维度 | 纯脚本方案(Python + OpenCV 轮询) | 重型推理框架(图形化管道) | VideoPipe 节点流 |
|---|---|---|---|
| 上手成本 | 极低,一小时能跑通单路 | 中等,要学它的配置语法 | 中等偏上,需要 C++ 底子 |
| 多路并发 | 差,GIL 和多进程拷贝开销大 | 好 | 好,多 pipeline 实例互不干扰 |
| 端到端延迟 | 高,容易堆积 | 低 | 低,跳帧策略可控 |
| 模型格式 | 随意 | 绑定特定格式 | 常见 ONNX 为主,扩展方便 |
| 自定义业务节点 | 方便,但散 | 受限 | 方便,继承节点类重写一个函数即可 |
| 长时间运行稳定性 | 需要自己管队列和内存 | 好 | 好,但要自己注意智能指针 |
| 适合场景 | 验证算法、离线跑批 | 固定硬件的大规模部署 | 边缘盒子、定制化业务 |
纯脚本方案的问题不在于慢,在于不可预测。你用cv2.VideoCapture取 RTSP,读帧和推理在同一个循环里,一旦某一帧推理慢了 200ms,解码缓冲就开始膨胀,延迟从 300ms 涨到 3 秒,而且不会自己恢复。要修就得自己开线程、做有界队列、写丢帧逻辑,写完之后你会发现你造了个简易版的 VideoPipe。
重型推理框架的能力确实强,硬件加速做得深,但它的学习曲线和硬件绑定是两个绕不开的成本。我的场景是通用边缘盒子,硬件形态不固定,所以排除了。
VideoPipe 落在中间的甜点位上:C++ 写的,没有解释器开销;节点模型清晰,自定义业务逻辑塞进去很自然;模型走 ONNX 这条路,主流训练框架都能导出,换模型不用改管道。
2. 管道设计:把车牌识别拆成七个节点
动手写代码之前,我把整条链路在纸上画了三遍。第一版只画了四个节点,跑起来发现识别率不行;第二版加了车牌矫正,好了一点但还是不稳;第三版把"业务归拢"单独抽成一个节点,才真正跑顺。这套拆法未必最优,但的确是被真实数据打磨过的。
2.1 数据流向与节点职责划分
最终的节点排布是这样的:
源节点→车辆检测节点→跟踪节点→车牌检测节点→矫正节点→字符识别节点→业务归拢节点→OSD 节点→输出节点
每个节点的职责说清楚:
- 源节点:负责取流、解码、按目标帧率向下游供帧,断流自动重连。
- 车辆检测节点:整帧检测,只保留 car、truck、bus、motorcycle 这几类,其余全部丢弃。
- 跟踪节点:给每个车辆框分配并维持一个 track ID,这是后面跨帧投票的基础。
- 车牌检测节点:不处理整帧,只拿车辆框内的 ROI,先把 ROI 放大再送进网络。
- 矫正节点:对倾斜角度过大的车牌做透视变换,拉成正视角。
- 字符识别节点:对车牌小图做识别,输出字符串和逐字符置信度。
- 业务归拢节点:按 track ID 聚合多帧识别结果做投票,过滤低质结果,组装结构化数据。
- OSD 与输出节点:把结果画回画面供人看,同时把 JSON 和抓拍图落盘或上报。
为什么车牌检测要放在跟踪之后?因为车牌检测是逐个车辆框处理的,一帧里可能有三辆车,就会产生三次车牌检测调用。跟踪节点在前面把车辆框稳定下来,可以让车牌检测的输入 ROI 更平滑,减少框抖带来的识别抖动。另外,跟踪 ID 建立之后,同一辆车在后续帧里如果车牌已经识别成功,业务节点可以直接复用,不必每帧都跑识别,能省下可观的算力。
注意:不要在一帧里对同一辆车做多次车牌检测然后取最好的那个。看起来是在提精度,实际上会把不同帧、不同姿态的结果混在一起,投票逻辑会失效。一帧一次,帧间聚合,这是我的做法。
2.2 分辨率与帧率的账要提前算
这部分是我认为最该提前算清楚、但最多人跳过的地方。算完你就知道为什么你的相机在 15 米外死活认不出车牌。
蓝牌和新能源绿牌的物理尺寸大约 440mm × 140mm(绿牌略宽一点),字符高度大概占牌高的六成。要让识别网络稳定读出字符,经验上字符像素高度不能低于 15px,车牌整体宽度最好在 100px 以上。低于这个线,识别率会断崖式下跌,不是调阈值能救回来的。
按这个基准反推画面需要的分辨率:
| 横向视野宽度 | 1 米对应像素 | 车牌像素宽 | 字符像素高 | 判断 |
|---|---|---|---|---|
| 4 m | 480 px | 211 px | 约 40 px | 很稳,可放宽识别策略 |
| 6 m | 320 px | 141 px | 约 27 px | 稳,推荐 |
| 8 m | 240 px | 106 px | 约 20 px | 可用,需关注抖动 |
| 10 m | 192 px | 84 px | 约 16 px | 临界,靠 ROI 放大勉强撑住 |
| 15 m | 128 px | 56 px | 约 11 px | 基本无效,别硬扛 |
表里的"横向视野宽度"是指画面左边缘到右边缘对应的实际地面距离,不是相机到车的距离。装相机的时候拿卷尺量一下,或者在地面画两条线测,非常有必要。我见过太多项目为了"看得远"把焦距调到 12 米视野,结果识别率只有六成,最后被迫换镜头。
再说帧率。车牌识别不需要 25fps 全帧处理。车辆以 20km/h 通过 6 米视野,理论停留时间大约 1 秒。如果源节点按 25fps 解码,跳帧间隔设为 2,实际处理帧率 12.5fps,1 秒内能拿到 12 帧左右,足够做多帧投票。跳帧间隔设成 3(约 8.3fps)也够用,能省三分之一算力。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 源解码帧率 | 25 fps | 相机原生输出,别在相机端降 |
| 跳帧间隔 | 2 到 3 | 有效处理帧率 8 到 12 fps |
| 车辆检测输入尺寸 | 640 × 640 | letterbox 保持比例,别拉伸 |
| 车牌检测输入尺寸 | 320 × 320 或 416 | ROI 已经被裁小,用小输入 |
| 车辆检测置信度阈值 | 0.40 | 宁可多框,后面有跟踪兜底 |
| 车牌检测置信度阈值 | 0.30 | 小目标要放宽,误检交给投票过滤 |
| NMS IoU 阈值 | 0.50 | 车辆密集时可提到 0.60 |
置信度阈值这块,我的原则是"前松后紧"。检测阶段阈值放低,漏检的代价比误检高得多,因为误检可以在业务归拢节点用投票和规则清掉,漏检则彻底没救。业务节点的过滤规则我后面会细讲。
2.3 环境与依赖清单
依赖别贪新,版本对上比版本新更重要。我的组合跑了半年多没出过运行库级别的问题:
| 组件 | 版本 | 备注 |
|---|---|---|
| 操作系统 | Ubuntu 20.04 / 22.04 | 前者更稳,后者驱动新 |
| 编译器 | GCC 9 及以上 | 需要 C++17 |
| 构建 | CMake 3.16 及以上 | 低版本会在找依赖时报错 |
| 图像库 | OpenCV 4.5 到 4.8 | 别用 4.9 之后的,接口有小改 |
| 流媒体 | GStreamer 1.16 及以上 | 走 RTSP 必须装,含 plugins-base/good/bad/ugly |
| 推理后端 | ONNX Runtime 或 TensorRT | 前者通用,后者快 |
| 加速库 | CUDA 11.8 + cuDNN | 与推理后端版本严格对应 |
有一件事必须提前说:如果你打算用硬件解码(NVDEC),GStreamer 的插件装不全会出现"能取流但解码器创建失败"的报错,日志里往往只写一句 pipeline 构建失败,非常难查。我的建议是先跑软解把流程打通,确认业务逻辑没问题,再切硬解,出了问题至少知道不是业务代码的锅。
3. 核心节点逐个拆解与实现要点
这一节讲每个节点里我实际做了什么、为什么这么做、哪里容易翻车。
3.1 视频源节点:取流与断流自愈
车牌识别相机的输入几乎都是 RTSP。VideoPipe 提供了 RTSP 源节点,底层走 GStreamer,也提供了文件源节点给调试用。我调试阶段全部用本地录像文件,因为文件源的帧率可控,跑出来的耗时数据才可复现;上线前再切到 RTSP。
RTSP 有一个绕不开的话题:断流。网络抖一下、相机重启一下,流就断了,如果源节点不重连,整条管道就静默挂死——不报错、不输出、看起来像在正常运行,实际上已经卡住了。我处理这个问题的方式是给源节点加一个健康检查:业务归拢节点记录最后一次收到帧的时间戳,如果超过 15 秒没有新帧,就触发源节点重建。
// 简易健康检查逻辑,放在业务节点的定时任务里 if (now - last_frame_ts_ > std::chrono::seconds(15)) { src_node_->stop(); std::this_thread::sleep_for(std::chrono::seconds(1)); src_node_->start(); last_frame_ts_ = now; // 重置,避免连续触发 }另一个实操细节是传输协议的选择。RTSP 默认走 UDP,丢包时会花屏;切到 TCP 更稳但延迟略高。边缘设备接内网交换机,丢包率低,我倾向 TCP,牺牲几十毫秒延迟换画面完整,对识别准确率是正收益。
注意:重连不要写成死循环。相机彻底下线时死循环重连会把 CPU 打满,影响同一台盒子上其他几路。加个退避,重试间隔从 1 秒逐步涨到 10 秒。
3.2 车辆检测节点:先把搜索范围缩小
整帧直接找车牌,是很多人第一版的做法,效果通常很差。原因很直接:车牌在 1080p 画面里可能只有 80 像素宽,而整帧缩到 640 送进网络时,车牌就剩 47 像素,模型没有任何机会。
先找车,再在车里找牌,本质是局部放大。车辆在画面里通常占几百像素,缩放到 640 后依然清晰可检;拿到车辆框之后,把这块区域从原始 1080p 图上裁出来,再缩放到 320 或 416,车牌在这个尺度下的有效像素就上去了。这个操作对识别率的提升是数量级的,不是调参能比的。
车辆检测节点的配置要点:
- 模型选轻量级检测网络就行,车牌识别场景对车型分类精度要求不高,能框准车就够了。我用的是一个几兆参数的 ONNX 模型,单帧 8ms 左右。
- 类别过滤一定要开。COCO 类别里的 car、motorcycle、bus、truck 保留,其余全部丢弃。留着 person 只会增加下游负担。
- 输入尺寸用 640×640,letterbox 加灰边保持长宽比。不要直接 resize 成正方形,宽高比被压扁之后,远处的车会变形,检测框会明显偏移。
- 对贴近画面边缘的车辆框,裁剪时要先做边界裁剪(clip),否则 ROI 越界会直接崩。
这里有个小技巧:裁剪 ROI 时向外扩 10% 的边距。因为检测框有时候刚好贴着车牌边缘,不留余量会把车牌切掉一部分,识别必然失败。
3.3 车牌检测节点:ROI 放大与小目标阈值
这个节点是整个链路里最需要调参的地方。总结几条:
输入尺寸别照抄车辆检测。ROI 已经被裁小了,里面只有一辆车,用 320×320 就够,速度还快。用 640 反而因为缩放过猛,车牌细节被抹平。
置信度阈值要低。我最初设 0.5,漏检严重。降到 0.30 之后,召回率明显上来,多出来的误检由后面的投票环节清掉。这一条是我花最多时间才想明白的:检测节点的目标不是"准",是"全"。
加一个面积比例的前置过滤。如果某个车辆框特别大(比如车已经停在相机正下方,占了大半个画面),那么它里面检出的车牌也应该很大;反之如果检出的小得离谱,多半是误检。我用规则是车牌面积不超过车辆框面积的 15%,超过就丢掉。这条规则干掉了大量纹理误检。
矫正要不要做,取决于安装角度。相机水平正对车道,倾斜角小于 10 度,可以不矫正,省算力。如果是斜装或者俯角很大,就必须做四点透视变换,否则字符被压缩变形,识别率直接掉两成。矫正的做法是从检测结果里拿到车牌四个角点,做透视变换拉成标准矩形。这块我建议先不做,跑一遍看识别率,不行再加,因为角点检测本身也会出错,错一次比不矫正还糟。
提示:车牌检测的输出框经常比真实车牌小一圈。裁剪字符识别输入时,把框向外扩 5% 到 8%,能显著改善边缘字符的识别效果,尤其是首尾的汉字和字母。
3.4 字符识别节点:别拿通用 OCR 硬扛
我一开始用通用 OCR 模型识别车牌,测下来发现三个典型问题:一是车牌字符集很小(汉字三十来个、字母数字三十六),通用模型的几万类输出纯属浪费;二是新能源绿牌的 8 位字符和双层黄牌,通用模型经常读成一行;三是车牌特有的字符形态(比如"0"和"O"、"1"和"I")通用模型没有针对性训练。
换成车牌专用的识别模型之后,单帧耗时从 12ms 降到 3ms,准确率还涨了。如果找不到现成的,自己拿几千张实拍车牌微调一个轻量 CRNN 也是可行路线,一天能搞定。
识别节点几个实操要点:
- 输入统一缩放到固定高度(我用的 48 像素),宽度按比例,最大截断到 160。车牌宽高比很固定,不需要复杂预处理。
- 输出要拿逐字符置信度,不能只拿整串置信度。我遇到过整串置信度 0.95 但中间一位字符只有 0.4 的情况,那一位往往就是错的。整串置信度取逐字符的几何平均,低于 0.6 的结果直接判废。
- 字符集映射表要跟训练时严格一致。索引错位是典型的"看起来能跑但全是乱码"事故,第一次跑出来如果结果是整齐的乱码,八成就是这个原因。
- 车牌颜色也一起输出。蓝牌、绿牌、黄牌的字符规则不同,业务侧拿到颜色可以再做一层格式校验,比如绿牌末位是 D 或 F 的概率很高,不符合就降权。
3.5 业务节点:去重、投票与结果上报
这是我自己写的节点,也是整套系统里"最不像 AI"的一环,但它决定了最终交付质量。
核心逻辑是按 track ID 维护一个滑动结果池,每辆车的池里存最近 N 帧(我用的 10 帧)的识别结果。每来一帧新结果,做三件事:
第一,加权投票。整串完全相同的结果记一票,权重按置信度算,不是简单计数。十帧里如果有七帧是"京A12345"、两帧是"京A1234S"、一帧是"京AI2345",且前者的平均置信度显著更高,就输出前者。
第二,格式校验。标准车牌是 7 位(普通)或 8 位(新能源),第一位是省份汉字,第二位是发牌机关字母,后面是字母数字组合。不符合格式的直接丢弃,不许进投票池。这一条能干掉大量"半个车牌"造成的脏数据。
第三,稳定判定。我设两个门槛:同一结果出现满 3 次,且累计置信度均值超过 0.75,才认为这辆车"识别成功"。识别成功之后,同一 track ID 不再重复上报,除非结果发生变化(比如第一次读错了,后面修正了),这时以最后一次成功的结果为准,更新上报。
// 业务归拢节点的核心结构,示意 struct plate_record { std::map<std::string, float> votes; // 结果 -> 累计权重 float best_score = 0.0f; std::string best_plate; int hit_count = 0; bool reported = false; }; std::unordered_map<int, plate_record> track_cache_;一个必须处理的边界情况是跟踪 ID 复用。车开走之后,跟踪器可能把同一个 ID 分给下一辆新来的车,如果你不清缓存,新车的投票池里混着上一辆车的旧结果,就会输出上一辆车的车牌。我的处理是给每条记录带一个最后更新时间,超过 8 秒没更新就清掉。这个 8 秒是这么定的:车通过 6 米视野约 1 秒,加上相机到业务节点的链路延迟和可能出现的短暂跟踪丢失,8 秒足够宽容,又不会留着过期数据太久。
注意:上报数据里除了车牌字符串,务必带上抓拍图、车牌小图、时间戳、相机编号和置信度。事后排查"为什么这辆车识别错了",没有抓拍图你什么都查不出来。这个习惯能省掉无数扯皮。
4. 完整实操:从编译到跑通第一路
讲了这么多设计,落地还是得看代码。
4.1 编译与目录结构
先把依赖装齐,再拉源码。顺序很重要,先装 OpenCV 再编译,否则 CMake 找不到会静默跳过一些节点。
# 基础依赖,Ubuntu 20.04 上实测 sudo apt update sudo apt install -y build-essential cmake git \ libopencv-dev \ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ gstreamer1.0-libav编译的时候关掉用不上的模块,能省不少时间和体积。我只留了检测、跟踪、OSD 和 RTSP 相关,其他全关。
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release \ -DENABLE_CUDA=ON \ -DENABLE_TENSORRT=OFF \ -DENABLE_WRITE_VIDEO=ON make -j$(nproc)目录我按这个结构放,方便后面换模型不动代码:
project/ models/ car_det.onnx plate_det.onnx plate_ocr.onnx charset.txt conf/ app.json bin/ plate_camera模型文件单独放,配置文件里写相对路径,绝对路径在部署时一定会撞墙。
4.2 管道编排代码
下面是我实际用的编排骨架,删掉了日志和异常处理让结构更清楚。
#include "vpipeline.h" #include "nodes/vp_rtsp_src_node.h" #include "nodes/vp_yolo_detector_node.h" #include "nodes/vp_sort_track_node.h" #include "nodes/vp_osd_node.h" #include "nodes/vp_screen_des_node.h" #include "nodes/vp_plate_biz_node.h" int main(int argc, char** argv) { auto pipe = std::make_shared<vp_pipeline>(); // 1. 源节点:RTSP 输入,目标帧率 25,跳帧间隔 2 auto src = std::make_shared<vp_nodes::vp_rtsp_src_node>( "src_0", 0, "rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101", 25.0); src->set_skip_interval(2); // 有效处理帧率约 12.5fps src->set_reconnect_interval(3000); // 毫秒 // 2. 车辆检测:只保留车辆相关类别 auto car_det = std::make_shared<vp_nodes::vp_yolo_detector_node>( "car_det", "models/car_det.onnx", true, 0.40, 0.50, 640, 640); car_det->set_class_filter({2, 3, 5, 7}); // car / motorcycle / bus / truck // 3. 跟踪节点 auto tracker = std::make_shared<vp_nodes::vp_sort_track_node>("track_0"); // 4. 车牌检测:在车辆 ROI 内做,输入小尺寸 auto plate_det = std::make_shared<vp_nodes::vp_yolo_detector_node>( "plate_det", "models/plate_det.onnx", true, 0.30, 0.45, 320, 320); plate_det->set_roi_from_parent(true); // 关键:从上游目标取 ROI plate_det->set_roi_expand_ratio(0.10); // 向外扩 10% // 5. 字符识别 auto plate_ocr = std::make_shared<vp_nodes::vp_plate_ocr_node>( "plate_ocr", "models/plate_ocr.onnx", "conf/charset.txt", 48, 160); // 6. 业务归拢(自定义节点) auto biz = std::make_shared<vp_nodes::vp_plate_biz_node>( "biz", "http://127.0.0.1:9000/report", 10, 3, 0.75f); // 7. OSD 与输出 auto osd = std::make_shared<vp_nodes::vp_osd_node>("osd"); auto screen = std::make_shared<vp_nodes::vp_screen_des_node>("des_0", 0, true); pipe->set_src_node(src); pipe->append_node(car_det); pipe->append_node(tracker); pipe->append_node(plate_det); pipe->append_node(plate_ocr); pipe->append_node(biz); pipe->append_node(osd); pipe->append_node(screen); pipe->start(); std::this_thread::sleep_for(std::chrono::hours(24 * 365)); return 0; }自定义业务节点的骨架,重点是重写的那个处理函数:
class vp_plate_biz_node : public vp_node { public: node_res_handle_e handle_frame_meta(const vp_frame_meta& meta) override { last_frame_ts_ = std::chrono::steady_clock::now(); for (const auto& t : meta.targets) { if (t->track_id < 0) continue; auto& rec = track_cache_[t->track_id]; rec.last_seen = last_frame_ts_; if (!t->plate_text.empty() && is_valid_plate(t->plate_text)) { rec.votes[t->plate_text] += t->plate_text_score; maybe_report(t->track_id, rec, meta); } } return node_res_handle_e::NORMAL; } private: std::unordered_map<int, plate_record> track_cache_; std::chrono::steady_clock::time_point last_frame_ts_; };看到handle_frame_meta这个接口,就能理解为啥我说节点模型省事了——业务逻辑就是这么一个函数,输入是帧元数据,输出是"继续往下走"还是"到此为止"。线程、队列、生命周期全不用管。
4.3 参数调优实录与显存账本
调参我按这个顺序做,从粗到细,每次只改一个变量,改完跑一段固定长度的录像对比,绝不凭感觉。
第一轮定分辨率视野:拿卷尺量,把相机焦距调到 6 米横向视野。这一步不做,后面全白搭。
第二轮定跳帧间隔:从 1 试到 4,看召回率和耗时的变化曲线。我的数据是间隔 1 时单路占用约 55% GPU,召回率 96%;间隔 2 时 31%,召回率 95.2%;间隔 3 时 22%,召回率 93.8%。选了 2,性价比最好。
第三轮定检测阈值:车辆检测从 0.5 往下试到 0.3,车牌检测从 0.5 往下试到 0.2。看的是"漏检率"而不是"准确率"。最终的组合是车辆 0.40、车牌 0.30。
第四轮定投票参数:池大小从 5 试到 15,命中门槛从 2 试到 5。池子越大越稳但需要车在视野里停留更久,我的场景车速不高,选了池 10、命中 3。
显存账本给一下,两路 1080p 并发时的实测:
| 环节 | 单路显存 | 说明 |
|---|---|---|
| 车辆检测模型 | 约 180 MB | 640 输入,FP16 |
| 车牌检测模型 | 约 120 MB | 320 输入,FP16 |
| 字符识别模型 | 约 90 MB | 高度 48 |
| 解码与中间缓冲 | 约 150 MB | 1080p,两路各 5 帧缓冲 |
| 合计 | 约 540 MB / 路 | 两路共 1.1 GB 左右 |
模型常驻显存这件事很关键。如果你的实现方式是每帧加载一次模型,或者每路各加载一份,显存会成倍增长。同一个进程里两路共享同一份模型实例是标准做法,VideoPipe 的多 pipeline 场景下也支持这么干。
单帧耗时分布(GPU,FP16):解码 12ms、车辆检测 8ms、跟踪 1ms、车牌检测 5ms、OCR 3ms、业务 0.5ms。加上队列调度,端到端大约 90ms。这个数字远低于我设的 300ms 报警线,留了充足余量。
5. 现场踩坑:常见问题与排查路径
下面这些全是我真实撞过的,按痛苦程度排序。
5.1 延迟越跑越大与内存缓慢上涨
这是最典型也最折磨人的问题。表现是刚启动时延迟 100ms,跑两小时变成 2 秒,画面里车早就过去了,框还在原地。同时top看内存曲线缓慢爬升。
第一个原因:下游消费慢于上游生产,队列无限堆积。VideoPipe 的跳帧机制就是为了解决这个,但前提是你真的开了。检查skip_interval有没有设,以及源节点的实际输出帧率是不是被相机推高到了 30fps 而你以为是 25fps。
第二个原因:自定义节点里存了帧指针。元数据里那个图像对象如果是共享指针,你在业务节点里把它塞进一个 map 做缓存,等于把整帧图像挂在了内存里不释放。两路 1080p、每帧 6MB、一秒 12 帧,涨起来很快。我的教训是缓存只存字符串和坐标,绝不存图像。要留抓拍图就立刻存盘,不要留在内存。
第三个原因:跟踪缓存的清理逻辑没生效。ID 复用时如果只更新不删除,map 会越来越大。打印一下track_cache_.size(),正常应该稳定在几十的量级,如果一直涨就是这里的问题。
5.2 漏检、误检与框抖动
漏检按位置分两类。远处漏检基本是分辨率问题,回到第 2.2 节重算视野,不要试图靠降阈值解决。夜间漏检是曝光问题,先看抓拍图,如果车牌区域一片白或者一片黑,那就是相机侧的事,调补光灯亮度或改曝光模式,模型救不了。
误检主要是两类:一是把车标、广告牌上的图案当成车牌,二是把反光条、条纹当成车牌。前者的过滤靠面积比例规则(车牌占车框比例),后者靠格式校验(识别结果必须符合车牌格式)。这两条规则加起来能干掉九成误检。
框抖动看着烦但不是致命问题,因为投票机制本身对抖动有容忍度。如果抖得厉害影响了 ROI 裁剪,把跟踪器打开,用跟踪结果平滑检测框,能压下去大半。此外,把检测阈值从 0.30 提到 0.40 也会让框稳一些,代价是漏检变多,看你更在意哪头。
5.3 字符错认的几类典型情况
我按出现频率整理了一个对照表:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 汉字全错或输出空格 | 字符集映射表索引错位 | 核对训练时的字符表顺序 |
| 0 与 O、1 与 I 混 | 模型未针对车牌微调 | 换车牌专用模型或加后处理规则 |
| 末位字符常错 | ROI 裁剪切掉了右边 | 扩大 ROI 边距到 8% |
| 新能源 8 位只读出 7 位 | 模型输出宽度不足 | 输入宽度从 128 提到 160 |
| 整串乱码、位置整齐 | 归一化参数不匹配 | 检查减均值除方差是否与训练一致 |
| 双层黄牌读成一行 | 模型不支持双层 | 检测阶段先判断宽高比,分层裁剪 |
其中"末位字符常错"我卡了很久,一直以为是模型问题,最后发现是 ROI 裁剪的边界处理问题——检测框贴边时按原始坐标裁会切掉右边几个像素,最后一个字符就没了。加了 8% 的扩边之后问题消失。
5.4 问题速查表
| 症状 | 优先排查 | 快速验证 |
|---|---|---|
| 完全没有输出 | 源节点是否取到流 | 挂屏幕节点看有没有画面 |
| 有画面无识别 | 检测阈值是否过高 | 降到 0.2 试一帧 |
| 延迟持续增长 | 跳帧间隔、队列积压 | 打印帧时间戳看差值 |
| 内存缓慢上涨 | 自定义节点缓存、跟踪表 | 打印缓存 size |
| 跑了几天后卡死 | RTSP 断流未重连 | 拔网线 10 秒再插回 |
| GPU 占用 100% | 是否每路各加载一份模型 | 看显存是否成倍 |
| OSD 中文变方块 | 字体文件未指定或路径错 | 换成绝对路径的 ttf |
| 抓拍图模糊 | 取的是解码帧还是原图 | 确认拿的是当前帧不是缓存帧 |
提示:排查时尽量把问题二分。先确认"帧有没有进来"(挂屏幕节点看画面),再确认"检测有没有框"(把阈值降到极低),再确认"识别有没有字"(把识别结果打到 OSD 上)。三步下来,问题基本定位了,比盲猜快得多。
6. 上线之后:多路扩展与效果评估
单路跑通只是开始,真正交付要看四路、八路能不能一起稳。
6.1 从一路到八路的资源规划
扩展不是简单乘倍数,因为有几处开销是共享的。模型权重可以共享一份,解码和中间缓冲是每路独立的。
我的做法是每路一个 pipeline 实例,但推理后端共享。如果你的框架版本支持推理节点内部复用同一个后端实例,务必用上;不支持的话,退一步的做法是把模型加载到静态区,节点里只持有引用。
八路 1080p、跳帧间隔 2(有效 12.5fps)的资源预估:
| 资源 | 单路 | 八路合计 | 备注 |
|---|---|---|---|
| GPU 计算 | 约 25% | 接近打满 | 需降跳帧或换小模型 |
| 显存 | 540 MB | 3.5 到 4 GB | 模型共享后可省约 1.5 GB |
| CPU | 约 8% | 40% 到 60% | 大量在解码与内存拷贝 |
| 内存 | 600 MB | 3 GB | 缓冲帧数可调 |
| 带宽 | 4 Mbps | 32 Mbps | 1080p H.264 主码流 |
八路打满是能跑,但没有余量,一旦某路断流重连,峰值可能把你顶穿。我的建议是按 70% 负载设计,也就是说八路的盒子跑六路,留两路的余量给突发。
如果确实要跑满,三个方向降负载:跳帧间隔从 2 提到 3、车牌检测输入从 320 降到 256、把字符识别移到 CPU(它只有 3ms,挪出去能释放 GPU 上的一小块)。
6.2 怎么判断这套系统算不算合格
不要用"感觉还行"验收。我用的四个指标,都是可量化的:
车牌召回率:经过视野的车辆中,最终输出了正确车牌的比例。测法是从录像里人工标注出 200 辆车的真实车牌,跑一遍系统比对。我的场景做到了 94%,白天 97%,夜间 88%。
字符准确率:输出结果中字符完全正确(不是部分正确)的比例。这个指标比召回率更严格,也是最容易注水的。注意有些团队会用"编辑距离小于等于 1 就算对"这种宽松口径,报出来的数字好看但没意义。
端到端延迟:从车进入画面到结果上报的时间差。用画面里车的实际位置和上报时间戳反推。我们稳定在 100ms 到 200ms 之间。
连续运行稳定性:跑满 72 小时,看内存曲线、看有没有静默卡死、看上报条数是否连续。这一项最容易翻车,也最容易被跳过。
我给自己的验收线是:召回率 90% 以上,字符准确率 92% 以上,延迟 300ms 以下,72 小时无人工干预。达不到就继续调,别急着交付。
6.3 后续还能往上加的东西
这套管道跑稳之后,扩展空间其实挺大,因为节点模型让加东西的成本很低。
第一层是业务规则。黑名单比对、白名单放行、重复入场判断、停留时长统计,这些全可以塞进业务归拢节点,不需要动检测识别链路。
第二层是多特征。车型、车身颜色、有无天窗、是否新能源,这些信息在车辆检测和识别环节顺手就能拿到,多输出一路 tag 而已。对园区管理来说,光有车牌不够,知道是"白色 SUV"能解决很多实际问题。
第三层是跨相机去重。出入口两个相机,一辆车会从两个相机各出现一次。用时间窗口加车牌比对能做简单的合并,再用车辆颜色和车型做辅助校验,准确率能到 98% 以上。这个功能我做了但没上线,因为业务上不需要,代码留着备用。
我个人在实际操作中的体会是:这套东西的瓶颈从来不在模型精度上。你换个更强的检测网络,召回率从 94% 涨到 95.5%,用户感受不到;但你把重连逻辑写对、把延迟从 2 秒压到 200ms,用户立刻觉得"这东西好用了"。做边缘侧的视觉系统,工程的权重比算法的权重大得多,VideoPipe 这类节点化框架的真正价值,是让你能把精力放在工程细节上,而不是反复造数据流的轮子。
最后分享一个我觉得最值的小技巧:每次改参数之前,先录一段包含各种工况(白天、夜间、阴天、逆光、快车、慢车、停靠)的十分钟固定测试录像,所有对比都在这段录像上跑。凭感觉调参是最容易反复横跳的做法,有了固定测试集,你会发现很多"看起来有用"的改动其实是噪声。