
如果把树莓派和Jetson都排除掉让一块国产RISC-V架构的AIoT芯片去做车牌识别的实时推理你会怎么选我去年就在一个停车场道闸项目里被问过同样的问题最终落地用的是K230。这块板子不算便宜但它的KPU算力、外设接口和模型转换路径配合车牌识别这个场景刚刚好卡在一个非常舒服的位置上。这篇文章就把我从YOLOv5训练、ONNX导出、nncase转换到K230上实时检测的完整链路拆开讲一遍包括中间踩过的坑和最终的调优参数给正在评估或者已经入手K230的开发者一条能直接复现的路。1. 为什么用K230做车牌识别而不是树莓派或Jetson1.1 算力与功耗的平衡点车牌识别这种任务放到服务器上跑一块GTX 1660就能到毫秒级时延难的从来不是模型本身而是怎么把它塞进一个只有几瓦功耗、要7x24小时不停机的边缘设备里。树莓派4B或5搭配USB摄像头也能跑YOLOv5s实测帧率大约在3到8 FPS之间CPU占用还经常飙到100%。如果只是做演示、拍短视频树莓派确实够了但真放到停车场出入口车辆一停一走3秒内要完成”抓拍-识别-抬杆“树莓派就会显得很吃力尤其是现场光线杂、车牌倾斜多的时候CPU算力往往撑不住多次推理和后续的字符识别。Jetson Nano算力没问题2G版本跑YOLOv5s车牌检测加LPRNet可以到实时但它的价格、功耗和供货周期都不太适合做量产道闸设备而且整个板子的体积也偏大。K230走的是另一条路线内置双核RISC-V CPU加KPU神经网络加速单元整体功耗控制在几瓦级别而KPU在8bit整型推理上的峰值算力可以到2 TOPS左右。单看这个数字不算夸张但对车牌检测这种输入分辨率640x640以下的单阶段模型来说已经非常够用。关键是它的板级成本、硬件稳定性和外设集成度都更贴近工业设备的需求而不是开发板玩具。1.2 KPU的硬件加速边界KPU是K230上负责神经网络计算的核心单元它支持卷积、池化、全连接、各种激活函数等常见算子但和GPU、NPU一样它也有自己的“偏好”。KPU加速效果最好的是卷积和深度卷积这类计算密集型算子。对于逐元素操作比如SiLU激活、残差相加KPU虽然也支持但效率不如卷积。所以同样是YOLOv5s模型里的Focus层、SPPF中的CBS结构如果没有按照KPU的算子偏好去调整转换出来的kmodel推理时延可能会有明显劣化。这里要强调一个很多人忽略的点K230并不是一个通用的嵌入式SoC它的强项是“确定性的AI推理”。如果你指望在上面同时跑Python业务逻辑、多路视频解码、多个神经网络模型那内存和CPU资源很快就会见底。我的建议是把K230当成一个“AI推理引擎”来用业务逻辑放主控或上位机端侧只负责摄像头采集、模型推理、结果输出。在这个思路指导下车牌识别项目被拆成了两段K230负责实时检测车牌位置并裁剪车牌区域然后由字符识别模型输出车牌号道闸控制、计费、日志上传全部扔给上位机处理。2. 车牌检测模型训练、裁剪与ONNX落地2.1 数据集与标注车牌检测模型的选型我最终定的是YOLOv5s。原因有几条一是网上开源的预训练模型和教程足够多遇到问题好查二是模型结构简单稳定转ONNX时算子不会太复杂三是s版本在KPU上比n版本精度更稳比m版本速度快不少平衡点正合适。数据集是一个很关键的环节。开源数据集里CCPDChinese City Parking Dataset是公认的国内车牌检测基准但它主要是安徽合肥的车牌场景集中在城市道路和小区停车场角度、光照比较单一。CRPD和部分基于合成数据的车牌数据集也可以作为补充。我自己在项目里做了两件事用CCPD 2019的detection子集作为基础抽取了1.2万张车头、车尾样本再从中挑出夜间、雨天、车牌变形、倾斜角度大的样本把现场道闸摄像头的抓拍数据按脱敏要求处理后补充了约3000张本地样本。这个步骤很重要因为道闸机位的角度和城市道路监控差别很大车牌往往在画面中占的比例不大而且经常有过曝和反光。标注格式统一为YOLO的txt格式class x_center y_center width height。类别就一类license_plate。2.2 训练细节与mAP收敛训练时的超参数没有太多花活但有几个细节值得记一下。输入分辨率用了640x640而不是常见的416x416。车牌属于小目标在道闸画面中宽度往往只有几十到一百像素如果输入分辨率太低小目标特征很容易在下采样过程中丢失。虽然640x640在KPU上会比416慢一点但实测检测距离可以远2到3米对道闸场景来说这个收益非常明显。训练配置大致是这样预训练权重yolov5s.ptepochs100batch size16优化器SGD初始lr0.01配合cos LR调度数据增强mosaic 1.0hsv 0.015fliplr 0.5关闭scale类增强会引入太多背景干扰训练到60个epoch左右在验证集上的mAP0.5已经稳定在0.98以上mAP0.5:0.95大概在0.86左右。看起来精度很高但那是在混合数据集上测出来的到了现场还是要重新标一批真实场景数据做测试不能太乐观。2.3 导出ONNX时必须处理的动态轴问题模型训练完接下来是把PyTorch权重导出成ONNX这是整个链路里最容易出问题的一步。YOLOv5官方仓库的export.py就能直接导出ONNX但如果直接默认导出会发现导出的ONNX里有一个问题opset版本高、动态轴设置不明确nncase解析时可能报不支持。我的做法是固定输入尺寸将动态轴全部关掉。命令大致是python export.py --weights ./runs/train/exp/weights/best.pt \ --img 640 \ --batch 1 \ --include onnx \ --opset 11 \ --simplify--simplify会用onnx-simplifier做一个简化把一些冗余的Identity节点、无效的Cast节点清理掉转换出来的模型更干净nncase编译时更不容易报错。还有一点需要特别提醒导出前把模型的检测头Detect层一起导出还是只导出backbone部分这一步要提前想清楚。如果把完整检测头导出模型输出的就是三个尺度的预测特征图shape分别是1, 3, 80, 80, 6这里6对应4个坐标加1个目标置信度加1个类别、1, 3, 40, 40, 6、1, 3, 20, 20, 6。后面需要自己在K230端写解码逻辑把anchor相关的坐标换算还原成真实框。如果只导出backbone部分把最终的结果reshape成[1, 25200, 6]再交出去后处理会简单一些但多了几个Reshape和Transpose算子量化时精度多多少少会有损失。我选择了导出完整检测头后处理放Python端写。这样nncase只需要处理卷积和基本算子推理输出直接就是特征图解码时用浮点计算精度损失最小。代价是后处理代码稍微长一点但完全值得。3. nncase工具链把ONNX变成K230认得的kmodel3.1 环境搭建与模型转换流程K230的模型转换依赖嘉楠开源的nncase工具链。以我当时用的版本为例nncase 2.9.x在架构和接口上都相对稳定支持从ONNX和TFLite导入模型。安装nncase的Python包pip install nncase2.9.0安装完先验证一下能否正常导入import nncase print(nncase.__version__)转换脚本的核心流程是四步导入模型、设置输入参数、进行量化校准、编译导出kmodel。import nncase # 1. 创建编译目标 target k230 pc nncase.CompileOptions() pc.target target pc.input_type uint8 pc.input_layout NCHW pc.output_type float32 pc.output_layout NCHW # 2. 导入ONNX模型 model nncase.ImportOptions() model.model_type onnx model.model_path ./best.onnx compiler nncase.Compiler(pc) compiler.import_model(model) # 3. 配置PTQ量化校准 ptq_options nncase.PTQTensorOptions() ptq_options.calibrate_method min_max ptq_options.quant_type uint8 ptq_options.calibration_data ./calib_data.npy compiler.use_ptq(ptq_options, samples200) # 4. 编译并导出kmodel compiler.compile() kmodel compiler.gencode() with open(./plate_detect.kmodel, wb) as f: f.write(kmodel)这里有一个概念一定要先理清楚kmodel只能跑int8量化推理也支持混合量化但默认整网uint8而训练好的PyTorch模型是float32。nncase会把fp32权重转成uint8中间的激活值也会量化。所以转换完之后精度必然会有轻微下降关键是怎么把下降控制在可接受范围内。3.2 PTQ量化校准数据集决定精度nncase支持PTQ训练后量化也就是在转换时喂一批真实图片进去统计每一层的激活值分布算出合理的量化scale和zero point。校准数据是最能影响最终精度的环节比模型本身和编译器版本都关键。很多人随便准备几张测试图或者用纯色图片做校准出来的模型在电脑上测不会发现问题一上板子就出现大量漏检、误检。校准数据集推荐准备200到300张已经resize到640x640的BGR图片保存成npy文件覆盖全天候场景白天强光、傍晚逆光、夜间车灯照射、雨天反光、车牌倾斜等。注意这里必须是训练集之外的真实场景图且不要做归一化到0-1的处理因为onnx模型的输入是[0,255]的uint8范围。我最初用500张训练集图片做PTQ校准转换后精度下降不明显mAP0.5从0.98降到0.95左右后来为了赶时间用50张随机测试图做校准结果上板后漏检了将近10%的车牌大部分是夜间场景。这个教训让我后来再也不敢在校准数据上糊弄。calibrate_method我推荐用min_max理由是这个方法在小数据量下比较稳定不会像percentile那样需要调合适的截断比例。当然如果你的模型激活值分布有长尾比如某些层的输出偶尔出现特别大的值min_max可能把动态范围拉得过大导致量化精度下降这时可以试试percentile99.99但要配合多组校准数据对比验证。3.3 算子兼容性踩坑记录nncase对ONNX算子的支持在持续更新但远没到“什么都能编”的程度。我遇到的第一个报错是E: unsupported op: DepthToSpaceYOLOv5的Focus层在导出ONNX时因为有slice和concat的组合有些onnx版本会编译成Sub、Mul、Slice、Concat而某些版本会优化成DepthToSpace算子。nncase对DepthToSpace的支持在2.9版本中不够完善一旦报错整个模型就废了。解决办法有两个升级到nncase 3.x新版本对这个算子支持更好更稳妥的做法是在导出ONNX之前使用onnxsim对模型进行图形优化很多DepthToSpace会被拆回SliceConcat从而绕开不支持算子的坑。python -m onnxsim best.onnx best_sim.onnx \ --overwrite-input-shape 1,3,640,640另一个常见的坑是Resize算子中coordinate_transformation_mode不匹配。YOLOv5的SPPF里有CBS分支用的是最近邻插值上采样nncase要求Resize里的mode必须是nearest或linear如果onnx优化器把它转成了cubic编译器会直接拒绝。遇到这种情况通过修改onnx节点属性或者调整导出参数把插值方式固定为nearest即可。如果你运气好模型一次转换通过也不要高兴太早——编译通过不等于精度正常。转换完成后建议写一个简单的pc端验证脚本用nncase runtime在本地跑一遍kmodel和onnxruntime在同样输入上的输出做对比算一下最大误差。如果输出feature map的误差在0.05以内归一化后基本可以上板如果误差超过0.1优先检查校准数据和量化配置。4. 实时推理引擎设计与线程模型4.1 摄像头采集与帧缓冲模型搞定之后真正的工程问题才开始。K230跑Linux系统摄像头一般通过MIPI-CSI接口连接也可以接USB摄像头。我们的项目用的是GC2093传感器支持1080p30fps输出。K230的媒体库提供的Python接口可以方便地打开摄像头from media.sensor import * from media.display import * from media.media import * sensor Sensor() sensor.reset() sensor.set_framesize(width1920, height1080) sensor.set_pixformat(Sensor.RGB888) sensor.run()摄像头和AI推理之间建议做一个帧缓冲队列。不要每帧都直接送给KPU推理因为sensor输出的帧率是30fps而模型推理也就10到15fps640x640如果每一帧都推理队列里会积压大量旧帧产生明显的延迟。我用的是消费者-生产者模型采集线程不断把最新帧放入一个长度为2的环形缓冲推理线程每次从缓冲中取一帧如果缓冲满了就丢弃最旧的那帧保证永远推理最新帧。这样能将“从图像采集到结果输出”的端到端时延控制在200ms以内而且不会出现好几秒前的画面才被处理的情况。4.2 前处理、推理、后处理的流水线在K230上做摄像头实时的推理流水线的核心阶段是从帧缓冲取一帧1920x1080的RGB888图像将图像中心裁剪并resize到640x640送入KPU推理得到三个尺度的输出特征图后处理解码anchor、置信度过滤、NMS如果在推理过程中检测到车牌裁剪出车牌区域并送入字符识别模型输出车牌号到串口或网络接口。前处理里有一个细节K230的sensor输出是RGB888但部分sensor也可以在内部输出NV12或YUV420。为了省内存和带宽我更推荐直接用sensor的YUV420输出然后在CPU上做一次简单的BGR转换。RGB888需要3字节/像素而YUV420的Y平面就占1字节/像素带宽差异在1080p下非常可观直接影响整体帧率。推理调用用的是ai.inference接口大致流程是import numpy as np import aicube # 载入kmodel inference ai.Inference(device_id0) inference.set_model(model_path) # 等待帧就绪后执行 input_data preprocess(frame).astype(np.uint8) inference.set_input_tensor(0, input_data.tobytes()) result inference.run()KPU推理结束后拿到的是三个维度的输出数据需要做decode。因为模型是我们自己训的anchor配置和YOLOv5默认一致anchor大小 [[10,13], [16,30], [33,23]]、[[30,61], [62,45], [59,119]]、[[116,90], [156,198], [373,326]]三种尺度stride分别是8、16、32后处理我不建议在Python里做全图遍历的NMS纯Python循环在K230的CPU上会很慢。更好的做法是用aicube里的anchors和nms接口或者先用numpy做向量化的置信度过滤把候选框数量降到几十个再做NMS。实测纯Python解码加NMS的时间约120ms改用向量化numpy后降到30ms左右。4.3 车牌框稳定化处理实时视频流里还有一个容易被忽视的问题单帧检测结果抖动非常明显。相邻两帧之间同一个车牌框的中心坐标、宽高可能会有几十个像素的波动。如果直接把检测框叠加到视频上肉眼看就是框子在不停“呼吸”很影响观感如果把这个框坐标直接拿来裁车牌给字符识别模型结果会更糟可能这一帧裁到完整的车牌下一帧裁到半个车牌。我的做法是对连续N帧N取5到10的检测框做EMA平滑alpha 0.5 smooth_box alpha * current_box (1 - alpha) * smooth_box同时加上一个简单逻辑只有当车牌框中心位置偏离上一帧超过一定阈值比如50像素时才认为“换了一辆车”重置平滑状态。这个逻辑在道闸场景下特别实用因为车辆通过时有明显的前进轨迹加上平滑之后检测框会像贴上去一样稳定。还有一个工程化细节是车牌置信度阈值。模型在验证集上f1最高的阈值在0.45左右但现场因为相机角度和光线差异这个阈值很容易出现误检。建议部署后做一个阈值扫描测试以500张现场抓拍为样本分别统计不同阈值下的精确率和召回率取两者交叉点作为最终阈值。我们在现场最终用的是0.5比验证集高一点多牺牲一点召回率但换来了道闸不会被旁边停着的其他车尾灯误触发的可靠性。5. 字符识别方案从LPRNet到PP-OCR的取舍5.1 车牌字符识别的两条路线车牌框检测出来之后要得到完整的车牌号还需要字符识别模型。这条路有两条主流方案检测出车牌框后再做字符分割或直接做定长/不定长序列识别用端到端模型如YOLO系列直接识别车牌字符或LPRNet的CNNRNN直接输出序列。在K230这种低算力设备上我选择的是LPRNet。LPRNet的结构是CNN提取特征 RNN建模序列关系 CTC解码输出不定长字符序列不需要精确的字符分割对车牌倾斜、字体粗细变化都有一定鲁棒性而且模型只有约2M参数非常轻量。如果想要更高的识别精度PP-OCRv3的mobile版本也可以跑但它的整体计算量远大于LPRNet在K230上对实时性影响很大所以最终没有选择。我的建议是如果车牌检测框质量高、角度相对正LPRNet完全够用如果现场车牌倾斜很大、字符有严重粘连优先考虑在检测框上做透视矫正而不是盲目换重模型。5.2 车牌字符集与CTC解码车牌字符集比较特殊它不是单纯的数字加字母。国内蓝牌是7位字符集包含数字0-9、大写字母A-Z去掉I和O、以及省份简称汉字比如京、津、沪、渝、冀、豫、云、辽、黑、湘、皖、鲁、新、苏、浙、赣、鄂、桂、甘、晋、蒙、陕、吉、闽、贵、粤、青、藏、川、宁、琼。LPRNet最后的分类数就是字符表大小我在训练时用的是83类省份简称汉字加上数字和字母再加一个空白符。没有把汉字之外的全字符集全部无脑放进去虽然能降低模型的分类难度也减少了误识别。CTC解码这一步也要注意原始输出是[batch, seq_len, num_classes]需要做一次argmax后合并重复字符、去blank才能得到最终的字符串。K230的Python环境中没有paddle的CTC解码函数需要自己写一个几十行的代码哪怕是每次解码后直接把字符串拼起来也别忘了做字符映射。5.3 字符识别如何与检测器串联整个识别流程是这样的检测模型每帧输出车牌框如果框内区域的宽度和高度比符合车牌比例蓝牌约3:1就裁剪出来先做一次简单的透视矫正对4个角点做仿射变换把车牌拉正然后resize到LPRNet的输入尺寸94x24再送入KPU推理。很多新手会忽略透视矫正这一步觉得检测框已经是正的没必要再矫正。但道闸相机经常是斜着装的车牌在画面里可能是平行四边形。不矫正直接识别错误率能高出一大截。矫正的4个角点可以从检测框的几何关系推算也可以用最小外接矩形提取。这里有个工程坑检测模型输出的是目标框但如果车牌有倾斜目标框其实是把整个倾斜车牌的外包围框框住了直接裁剪会包含大量背景。折中做法是在目标框内用图像处理手段找到车牌的实际边缘如颜色特征蓝底白字或绿底白字再做最小外接矩形。在K230上跑这一步可以用OpenCV的findContours和minAreaRect耗时约5ms完全可接受。但环境里的OpenCV要自己编译并确认支持所需模块部分精简版OpenCV不带这些函数。6. 实车测试性能与调优记录6.1 帧率、时延与内存占用实测最后是大伙最关心的实测数据。我的测试环境是K230开发板GC2093摄像头输入1080p检测模型640x640LPRNet输入94x24。摄像头采集加前处理约25msYOLOv5s检测模型KPU推理约60ms后处理decode NMS 车牌裁剪约30msLPRNet字符识别KPU推理约15ms端到端总时延从帧到达至输出识别结果约130ms整体帧率稳定在12到14 FPS。如果只做检测不做字符识别可以到20 FPS。这个性能对道闸场景来说完全够用因为车辆通过道闸时通常会在识别区内等待1到2秒。内存方面整条推理流水线峰值内存约280MB模型两枚kmodel加帧缓冲K230的512MB内存版本运行起来还有富余。但如果后面要接网络视频流、多路摄像头内存就会变成瓶颈需要做更精细的内存规划。6.2 夜间和反光场景下的调优实车测试暴露出的最大问题是夜间。停车场道闸一般都有补光灯但如果现场补光不均匀车牌中心过曝、边缘发暗的情况非常普遍。针对这个问题我在前处理里加了一个可选的gamma矫正gamma 1.2 frame np.power(frame / 255.0, gamma) * 255.0 frame frame.astype(np.uint8)gamma值大于1会让暗部更暗亮部更亮小于1则相反。我测试发现夜间车灯直射导致车牌过曝时把gamma调到0.8到0.9反而更有效因为它压低了高光区域的亮度让字符边缘更清晰。白天光线正常时gamma设为1.0不做处理。另一个有效手段是在检测框裁剪后对车牌区域做一次对比度拉伸CLAHE可以显著提高字符和底色的区分度。不过CLAHE参数要保守一点clipLimit设为2.0左右太大容易放大噪声。现场还遇到过一种极端情况一辆黑色车尾部的车牌直接反光成一块白色视觉上看不出任何字符。这种情况下再好的模型也救不回来。后来我们通过调整摄像头曝光参数降低曝光时间、开启自动增益上限限制缓解了一部分问题但根本解决还是要靠现场补光灯的角度和强度。6.3 从单帧检测到多帧确认在道闸项目中单帧识别结果直接用来抬杆是有风险的。因为一帧图像的识别可能受运动模糊、遮挡、强光的影响产生误识别。比如“苏A·1B2C3”可能某一帧被识别成“苏A·1B2O3”而O在字符集里根本不存在。我的方案是做一个多帧投票确认机制连续识别5帧对5帧的车牌号做编辑距离校验取出现次数最多且字符序列合法符合车牌正则规则的结果如果连续3次一致才输出。这样虽然增加了一点延迟但能把误识别率降到可以量产的水平。这里要提醒一下开始多帧投票前一定要把相邻帧相同车牌的检测串起来。判断依据是检测框的IOU大于0.7。否则同一辆车还没走完识别区系统就把不同位置的同一块车牌当成多辆车在统计了。6.4 模型与工具链版本的迭代建议如果你现在才开始做K230车牌识别我建议直接用更新的nncase 3.x版本它在K230的Python运行时和C运行时都更稳定而且对更多算子做了支持。不过转换流程和API跟旧版本有一些差异网上搜到的很多资料其实是2.x的注意版本匹配。训练侧YOLOv5s目前依然够用但如果算力有余量也可以试试YOLOv8s或YOLO11s。它们的检测头解耦结构在量化后精度保持上通常更好只是算子复杂度更高转换kmodel时可能会碰到更多需要手调的地方。至于更轻量的YOLOv5n在车牌场景下精度下降比较明显除非硬性要求极致帧率否则不建议。最后分享一个我连续踩过多次坑才总结出来的经验拿到一批新的现场数据时不要急着重新训练整个模型。先单独用这批数据做PTQ校准重新转一版kmodel看看精度是否恢复。很多时候引擎的泛化问题其实是量化校准没有覆盖到现场分布而不是模型本身没学好。这一步几乎零成本却常常能救回一个看起来“没救了”的模型。