OpenVINO CPU部署车牌识别:从模型转换到端到端推理实战
前阵子手里的一个智能交通Demo要从服务器端迁到边缘盒子要求不依赖GPU纯CPU推理还得保证车牌识别的可用性和吞吐。折腾了一圈最后落在了OpenVINO的预训练模型上。这套方案把车牌检测和字符识别两个网络拆开再串成一条流水线从模型转换到端侧推理一周跑通CPU下的帧率比直接用PyTorch推理高了一大截。这篇文章就把整个测试过程里能复现的东西都写出来包括模型怎么选、IR怎么转、推理代码怎么搭、输出怎么解析以及各种让新手翻车的坑。这套方案适合三种人一是想把OpenVINO跑起来但找不到完整例子的入门者二是手头有车牌数据集、想先拿现成模型验证效果再决定要不要自训的开发者三是在低功耗设备上做交通应用、需要立竿见影提升性能的部署工程师。我会尽量把步骤写到能直接抄作业的程度同时把每步选择的理由讲清楚。1. 车牌识别项目的整体拆解为什么是“检测识别”两段式1.1 车牌识别到底在解决什么问题车牌识别从来不是单个模型能优雅解决的。输入是一张任意尺寸的道路图片里面可能有多辆车车牌角度各异、光照不均直接用一个端到端模型把“图像→车牌字符串”做出来对训练数据的覆盖要求极其苛刻。更常见的做法是拆成两个阶段先用检测模型在画面里定位车牌位置再把车牌区域裁出来做字符识别。从工程上讲这也是天然的解耦。检测模型负责“哪里是车牌”识别模型负责“车牌号是多少”。两个模型可以独立升级检测模型换了更强的识别模型不用动识别模型想加新能源车牌字符集也不需要重新碰检测网络。实际部署时还可以把检测放到较低分辨率上跑降低CPU压力只有检测到车牌才对局部区域做高分辨率识别。我的测试流程是车辆/车牌检测 → 抠图 → 车牌字符识别 → 字符串后处理 → 输出车牌文本和置信度。注意我在中间特意加了“抠图”这一步这步做得好不好直接影响识别模型的准确率。1.2 为什么用OpenVINO而不是在PyTorch里直接跑同样的检测模型在PyTorch里跑CPU推理FPS低得让人怀疑人生OpenVINO毕竟是专门针对Intel CPU和集显调优过的它能把模型转换成一个优化后的IR格式顺便把MobileNet这种轻量骨架的算子融合、内存布局重新梳理一遍。我实测下来在不开任何魔法的情况下CPU推理用时能缩短一半以上。另外OpenVINO的部署链非常干净。不依赖Python版本的GIL、不依赖GPU驱动、不需要大型C依赖运行时就是几个so/dll。对边缘盒子项目来说这意味着交付物更小、启动更快、线上问题更少。实测一个检测模型IR文件加一个识别模型IR文件总共也就几十MB比直接带PyTorch环境和权重小了太多。而且OpenVINO在ARM上也越来越能打。2022年之后的版本对ARM CPU做了不少优化配合XNNPACK后端树莓派级别的小盒子跑轻量车牌识别是可行的。这也是我最终选OpenVINO而不是其他推理框架的重要原因。1.3 预训练模型到底能不能直接用这个问题我被问了很多次。先说结论标准蓝牌、常见角度、中等光照下OpenVINO官方模型库里的预训练模型可以直接用识别率能做到95%以上但对绿牌、黄牌、倾斜大角度、低光照场景需要评估后补充自己的数据和后处理策略。我这次测试用的是OpenVINO Open Model Zoo里两个现成模型一个负责车牌检测叫vehicle-license-plate-detection-barrier-0106另一个负责字符识别叫license-plate-recognition-barrier-0001。之所以选这两个是因为它们在公开模型库里有对应的FP16/FP32/INT8版本而且模型结构偏轻量适合CPU部署。如果你的项目正好也是这两类模型打底那下面的内容基本可以直接复用。2. 模型选型与IR转换全流程2.1 官方模型库里的车牌模型配置OpenVINO有个配套工具叫Open Model ZooOMZ里面包含了大量预训练模型和使用说明。车牌相关的模型主要分两类一类是检测模型负责返回车牌框另一类是识别/重识别模型负责把车牌区域转成文字序列。我用的检测模型vehicle-license-plate-detection-barrier-0106输入是1x3x672x384NCHW输出是1x1x200x7的检测框集合。这里的200表示最多检出的目标数7是每个目标的信息通常包括batch_id、class_id、score、x_min、y_min、x_max、y_max但你下载的模型具体输出顺序可能不一样拿到IR文件以后最好先查一下模型元信息不要凭记忆写解析代码。识别模型license-plate-recognition-barrier-0001输入是1x3x24x94输出是1x71x1871是字符类别数18是序列长度。这个模型是直接用序列分类做中文车牌识别不带OCR式的词表解码逻辑比较简单。两个模型合起来的计算量很小。检测模型差不多每帧几亿次乘加识别模型更轻。CPU上跑起来耗时大头反而是图像缩放和格式转换这个后面专门说。2.2 用omz工具下载和转换模型OMZ提供了命令行工具下载和转换一步到位。建议先建一个干净的模型目录pip install openvino openvino-dev omz-tools cd ~/models omz_downloader --name vehicle-license-plate-detection-barrier-0106 omz_downloader --name license-plate-recognition-barrier-0001 omz_converter --name vehicle-license-plate-detection-barrier-0106 omz_converter --name license-plate-recognition-barrier-0001omz_downloader下载的是原始模型权重通常是ONNX或TensorFlow格式omz_converter会调用Model Optimizer老版本叫mo新版本集成到OpenVINO的Python API里把原始模型转成OpenVINO的IR格式也就是.xml和.bin文件。转换完成后目录下会多出vehicle-license-plate-detection-barrier-0106.xml、.bin和license-plate-recognition-barrier-0001.xml、.bin。OpenVINO推理时读的就是这两个文件对。如果你拿到的原始模型是ONNX也可以不走OMZ直接写一行代码转import openvino as ov ov.convert_model(plate_recognition.onnx, input_shape[1, 3, 24, 94])这里手动指定input_shape是为了防止转换器推理不出动态shape也为了后续推理时避免动态reshape的开销。2.3 精度与动态Shape怎么选OpenVINO模型转换时一般保留FP32权重然后可以额外生成FP16或者INT8版本。对于CPU推理FP16不一定比FP32更快因为大部分CPU没有对FP16的SIMD加速强行改成FP16反而可能出现精度损失。我建议CPU场景先用FP32跑通确认推理逻辑无误后再考虑INT8量化做性能优化。INT8需要额外做模型量化校准不是模型转换出来就自动变成INT8的。动态shape和固定shape是另一个容易踩坑的点。检测模型输入尺寸固定成672x384的话CPU推理可以提前锁定内存布局实测比动态shape稳定很多。但如果你的视频流宽高比变化很大强行固定输入会导致图像被拉伸变形检测精度下降。折中方案是输入保持动态每次读图后做letterbox填充再进网络。官方OMZ模型默认是动态维度的也有不少。如果你用老版本IE API处理动态shape比较麻烦新版Core.read_model支持reshape建议直接更新到新版OpenVINO。3. 从图像到字符串端到端推理代码实现3.1 图像预处理为什么不能一把梭OpenVINO的推理核心很简单难的是推理前的数据准备。以我的检测模型为例输入尺寸是672x384也就是宽672、高384。真实摄像头画面通常是1920x1080直接resize到672x384会严重压扁画面车牌的长宽比例变了检测框会漂。正确的做法是做letterbox。先计算目标尺寸和原图的长宽比用等比缩放填满短边多出来的区域用固定像素值填充。我这里的填充值取114这是YOLO系预训练里常用的灰色值模型训练时用的就是这个分布。如果你用的是其他模型最好看一眼训练代码里的pad值。def letterbox(img, new_shape(672, 384), color(114, 114, 114)): h, w img.shape[:2] new_w, new_h new_shape scale min(new_w / w, new_h / h) nw, nh int(w * scale), int(h * scale) resized cv2.resize(img, (nw, nh), interpolationcv2.INTER_LINEAR) canvas np.full((new_h, new_w, 3), color, dtypenp.uint8) canvas[0:nh, 0:nw] resized return canvas, scale, (0, 0)推理结束后检测框坐标是相对于672x384的需要按scale和偏移反算回原图坐标。很多人直接拿模型输出画框画出来位置不准就是这个反算漏了。3.2 推理调用OpenVINO新版APIOpenVINO从2022.1版本开始推荐使用openvino包下的新API老版本的ie_api已经弃用。这里直接给一段可以直接运行的推理代码from openvino import Core import cv2 import numpy as np core Core() det_model core.read_model(vehicle-license-plate-detection-barrier-0106.xml) compiled_det core.compile_model(det_model, device_nameCPU) # 推理 def inference_detector(frame): input_tensor frame.transpose(2, 0, 1)[np.newaxis, ...].astype(np.float32) input_tensor / 255.0 outputs compiled_det([input_tensor]) return outputs注意几点一是输入必须先转成float32OpenVINO不支持uint8直接作为这个模型的输入二是绝大多数OMZ模型要求0到1归一化不是0到255三是通道顺序。OpenCV读取的图像是BGR但OMZ模型大多按模型作者习惯训练有的用BGR有的用RGB。如果识别效果差到离谱先检查通道顺序。最基本的筛查方法把同一张图分别按BGR和RGB输入看哪个输出的字符更接近真实车牌。3.3 检测框过滤与NMS处理检测模型一次输出200个候选框但真正有效的可能只有几个。这里要过滤置信度低的框再做非极大值抑制NMS合并重叠框。置信度阈值我常用0.5经验是车牌检测模型输出分数偏高0.5已经足够。OpenCV自带NMS函数可以直接用boxes [] # 每个元素 [x1, y1, x2, y2, score] if len(boxes) 0: return [] indices cv2.dnn.NMSBoxes( [b[:4] for b in boxes], [b[4] for b in boxes], score_threshold0.5, nms_threshold0.4 )NMS的IOU阈值别设太严格正常取0.4到0.5。车牌目标小、相邻车位车距近太严格的NMS会把同一块车牌拆成两个框。如果画面里同时出现多个车牌NMS结果里会出现多个框识别阶段要循环处理。3.4 车牌识别网络的输入处理检测到车牌框后先根据反算后的坐标从原图裁剪出车牌区域再resize到识别模型要求的24x94。这一步不要直接拉伸到24x94车牌长宽比大概是2到4固定resize到24x94在视觉上会变形严重。官方识别模型的训练数据是按相似方式裁剪的如果你直接拉伸中文字符会被横向压扁识别率暴跌。注意到这个模型非常扁高度只有24像素宽度94像素。这意味着车牌区域在送入网络前必须尽量正、尽量清晰。如果画面里的车牌有旋转需要先用四点透视或者仿射变换把车牌拉正。我的处理是检测框如果倾斜角度超过5度先对裁剪区域做仿射旋转再送识别。简单倾斜直接旋转即可重投影变换在车牌上一般不必要。4. 输出解码从概率序列还原车牌字符串4.1 理解识别模型的输出维度识别模型输出是1x71x18其中18是时间步序列长度71是字符类别数。它不是输出18个字符的定长结果而是每个时间步输出一个71维的概率分布表示“这一步最可能是哪个字符”。如果是定长分类模型取每步argmax就行。我用的官方模型并不是CTC模式简单取argmax以后还要做一步关键处理去除相邻重复。因为同一字符在连续多个时间步上被预测到如果不做去重会出现苏AAAA变成苏A A A A A之类的结果。def decode_predictions(pred, labels): # pred shape: [71, 18] indices np.argmax(pred, axis0) chars [labels[i] for i in indices] # 去除相邻重复 merged [chars[0]] for c in chars[1:]: if c ! merged[-1]: merged.append(c) return .join(merged)4.2 字符集标签表从哪来71类字符并不全是中国省市的简称。官方模型里包含了31个省份简称、大写字母、数字以及警、学、挂、港、澳等特殊字符。最重要的是字符顺序不是自然字典序而是模型训练时固定的索引顺序。你必须和模型下载仓库里提供的标签表一致不能自己按字典序重新排一遍。否则模型输出的索引值到字符串那里就全错了。我测试时遇到一个典型问题模型把“苏D”识别成“苏D”和“苏O”反复横跳。查了标签表发现是模型的字符映射表里 O 和 0 挨得很近概率稍微波动就把argmax结果带偏了。这种情况不用改模型在后处理里把“O”和“0”做规则校验根据车牌规范决定显示哪个。4.3 置信度阈值怎么定才稳识别模型会给出每个时间步的置信度。简单粗暴的方式是取所有时间步置信度的均值如果低于某个阈值就认为这次识别不可信。我建议在整流程里设置两个阈值一个是检测阶段的分数阈值控制“能不能检出车牌”另一个是识别阶段字符置信度均值阈值控制“这个字符串敢不敢直接输出”。根据我的测试结果识别置信度均值超过0.8基本都能稳定识别在0.6到0.8之间大概率是车牌被裁切、有遮挡或角度不正低于0.6就可以当作误检处理。但这个值和模型、数据分布强相关你拿到模型以后先用一批自己的图片测一下分布再定阈值不要拿现成的阈值硬套所有场景。5. 实测性能数据与调优技巧5.1 参考测试环境与FPS表现测试机器是一台8核桌面级CPU无独立显卡内存16G。模型是FP32设备选择CPU视频分辨率1920x1080帧循环里检测加识别全流程同步推理。所有工作都做好以后裸测FPS大概是18到22。单个检测模型推理约22毫秒识别模型约5毫秒预处理加后处理约15毫秒加起来差不多50毫秒一帧。如果要把帧率继续推上去唯一的办法不是换模型而是抠预处理和后处理。5.2 第一坑预处理比推理还慢我一开始用OpenCV直接resize整张1920x1080原图到672x384加上transpose、归一化、复制单帧预处理耗时接近12毫秒。对实时项目来说这是不可忽视的。解决方式有几个。第一是把resize改成INTER_AREA在高倍缩小时速度更快且更平滑第二是避免在Python层做逐像素操作一次性用np.ascontiguousarray保证内存连续提高OpenVINO拷贝输入的效率第三是当摄像头分辨率过大的时候可以在进入检测流程之前先把原图按比例缩小到1280左右哪怕只缩到1440检测效果不会明显变差速度却快很多。5.3 编码规范不要在同步推理里串行处理视频流一帧一帧串行做检测和识别CPU跑不满帧率却上不去。原因是检测和识别是串行依赖的每个阶段中间还有大量等待。最简单的优化方案是双线程一个线程专门做预处理把图像放到队列里另一个线程做推理每次从队列拿数据。OpenVINO的CompiledModel在单线程里创建推理请求多线程调用时需要每个线程创建自己的InferRequest不要跨线程共享同一个InferRequest否则性能会急剧下降。如果你有多个视频流不要开多个进程分别跑模型资源浪费巨大。更好的做法是在同一个进程里创建多个InferRequest设置num_streams为2或者4用wait等待结果实测多路同时播放时总吞吐能跟着核数线性涨到一定程度。5.4 不同输入分辨率的取舍检测模型输入672x384这个分辨率对远处的小车牌很不友好。如果车距远、车牌在画面里只有几十像素宽检测框可能被过滤或者检出来的框不够完整。我的经验是视频流超过1080p时适当加大模型输入比如768x448或1024x576检测召回率会明显提升但CPU耗时也会增加。需要拿自己的车流数据做一次分辨率扫描找到速度与召回率的拐点。这个拐点通常在目标车牌像素宽度小于24像素之前继续提升分辨率感知不明显。5.5 INT8量化才是CPU性能的大杀器FP32跑18到22FPS可能已经不错但如果目标是想在盒子上同时处理两路视频就得考虑INT8量化。OpenVINO的NNCF工具可以对模型做量化感知训练或后训练量化。我尝试了对检测模型做INT8量化单帧检测耗时从22毫秒降到13毫秒左右识别模型从5毫秒降到4毫秒效果很可观。但不要指望无脑量化不掉点。后训练量化需要准备校准数据集我用了500张覆盖不同角度和光线的车牌图量化后先跑一遍全套测试对比蓝牌识别率从96.2%降到了95.1%还能接受。如果你的场景对低光照特别敏感INT8量化后最好多验证一遍夜间的图夜间噪声在量化后会放大有时候字符会糊掉。6. 常见问题排查与避坑速查6.1 模型加载报错或输出维度和预期不符新版OpenVINO读IR模型时如果报Reshape相关错误多半是模型里存在动态维度而当前设备不支持。建议先打印模型输入输出shape再用compiled_model.input(0).get_partial_shape()看一眼哪个维度是动态的。如果有-1需要手动reshapeinput_shape [1, 3, 672, 384] compiled_det.reshape({compiled_det.inputs[0]: input_shape}) det.reshape()注意老版本的network.reshape和新版本的model.reshape不完全一样新版尽量在read_model之后直接reshape。6.2 识别结果乱码或字符顺序错乱这个问题十有八九出在标签表不匹配。检查方式很简单打印几个样本的每步argmax索引再对照标签表看索引0对应的是不是模型训练时的第一个字符。如果标签顺序乱了字符串整体是乱的这时候调整后处理没用必须重新对齐标签。还有一种常见情况模型输入是RGB但OpenCV默认读BGR导致模型看到的是颜色反转后的车牌图。中文字符本身是结构特征颜色反转对字形影响不大但字母数字的笔画权重在训练数据里和颜色通道强关联最终体现为部分字符频繁识别错误。6.3 多目标车牌识别时的框排序一张图里有多个车牌时检测框会同时返回。如果你的业务需要按空间顺序输出比如按“先左后右、先上后下”排列请不要在NMS之前排序NMS会打乱顺序。正确做法是在NMS结果出来以后再按框的中心点坐标做排序。如果业务上需要优先输出最大车牌按框面积排序即可。6.4 视频流里车牌闪烁不再出现有些视频流里车牌并不是每帧都清晰车开快了车牌会模糊。简单粗暴地逐帧识别会造成结果频繁跳字。我建议做短时多帧投票连续3帧识别结果中如果2帧得到同一个字符串且置信度都达标就输出这个字符串否则继续等下一组。这个策略简单有效比任何模型都兜底。6.5 夜间的低照度场景夜间识别率下降最明显。常规做法是拉亮度、提对比度但我试了几种图像增强后识别率反而更差了因为增强操作放大了噪声。更有效的是用训练时见过类似夜间增广的模型或者在检测阶段提高敏感度把模糊车牌也检出来让识别模型在局部区域有机会看到更多细节。也可以给车牌区域单独做一次直方图均衡化但只对车牌小图做不要对整幅图做。7. 跑通以后还能怎么扩展OpenVINO车牌识别这套流程跑通以后扩展方向至少有四个一是把模型换成语种相关的其他版本比如支持其他国家和地区的车牌字符集。二是加入车道线检测和车辆检测模型把车牌识别嵌入到完整的电子警察流程里而不只是单独跑一个识别。三是用NNCF做更激进的INT8和稀疏化量化让模型能塞进更低成本的边缘设备。四是接入RTSP流做多路并发识别把输入、推理、后处理做成流水线充分压榨CPU。我在实际使用时还专门加了一个小工具把每次识别的原始车牌裁剪图保存下来定期整理成验证集。后续想微调模型或者验证量化精度时这批数据非常有用。这个习惯建议从项目第一天就养成别等项目上线了再回头收集到那时数据分布已经变了。车牌识别看起来是个标准任务可真在边缘设备上跑起来坑和惊喜都藏在细节里。如果你正在用OpenVINO做类似项目希望这份记录能帮你少走几步弯路。