ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Caffe实战:SSD+CRNN号牌识别从训练到C++部署全流程

Caffe实战:SSD+CRNN号牌识别从训练到C++部署全流程 1. 为什么选择Caffe做号牌识别1.1 项目背景与需求拆解号牌识别这个需求最早是从停车场出入口管理、高速公路卡口、园区车辆通行这些场景里长出来的。核心诉求很直接给一张包含车辆的图片自动定位出号牌位置再把号牌上的字符一个个认出来。听起来简单但实际做起来光照变化、拍摄角度、号牌脏污、字符模糊、新能源号牌多一位、黄牌蓝牌绿牌混在一起每一个都是坑。我当初选Caffe而不是TensorFlow或者PyTorch原因很现实部署环境是C没有Python运行时而且推理设备算力有限需要极致的推理速度。Caffe的C接口干净利落模型文件就是一个prototxt加一个caffemodel加载快、依赖少、内存占用低。虽然现在Caffe社区不活跃了但在工业级嵌入式部署这个细分场景里它依然是一把趁手的刀。这个项目适合谁看如果你手头有Caffe的老代码需要维护或者你需要在C环境里做轻量级目标检测和字符识别再或者你单纯想理解一个完整的深度学习项目从数据准备到部署的全流程这篇笔记都能给你省不少时间。我踩过的坑你直接绕过去就行。1.2 整体技术路线设计号牌识别本质上是一个“检测识别”的两阶段任务。检测阶段负责从整张图里把号牌区域框出来识别阶段负责把框出来的号牌图片转成文字。这个拆分方式比端到端的方法更可控因为检测和识别可以分别优化出了问题也容易定位是哪个环节的锅。检测环节我选的是SSDSingle Shot MultiBox Detector架构骨干网络用MobileNet。为什么不用Faster R-CNN因为Faster R-CNN虽然精度高但两阶段检测的速度在嵌入式设备上跑不动。SSD是单阶段检测一次前向传播就能出结果配合MobileNet的轻量级结构在普通ARM芯片上也能跑到实时。号牌检测这个任务本身目标单一、特征明显SSD的精度完全够用。识别环节用的是CRNNConvolutional Recurrent Neural Network加CTCConnectionist Temporal Classification损失。CRNN的结构是CNN提取特征、RNN建模序列、CTC解决对齐问题。为什么不用传统的字符分割加分类因为号牌字符分割在字符粘连、模糊、倾斜的情况下非常脆弱而CRNN是端到端的序列识别不需要精确的字符边界鲁棒性高出一大截。整个流程串起来就是输入图片 → SSD检测号牌区域 → 裁剪并矫正号牌 → CRNN识别字符 → 输出号牌号码。每一步都有细节下面逐个拆。2. 数据准备与预处理的关键细节2.1 号牌数据集的构建思路数据是深度学习项目的命根子。号牌识别这个任务公开数据集有CCPD、CRPD这些但实际项目里你大概率需要自己标注一批数据因为你的场景和公开数据集总有差异。我当时的做法是公开数据集打底自己采集的数据做微调。标注工具用的是LabelImg检测阶段标注号牌的边界框格式是VOC的XML。识别阶段不需要额外标注直接从检测框里裁剪出号牌图片用文件名或者一个单独的标签文件记录对应的号牌号码就行。这里有个细节号牌号码的标注要统一格式比如“京A12345”不要有的写“京A·12345”有的写“京A12345”中文字符和英文字符的编码也要统一不然后面做标签映射的时候会出乱子。数据量方面检测阶段每个场景至少需要2000张以上的标注图片识别阶段每个字符类别至少需要500个样本。听起来不多但实际标注起来很费时间。我的经验是先用少量数据训一个粗糙的模型用这个模型去预标注新数据人工只需要修正错误的框和字符效率能提升三到五倍。2.2 数据增强的实操策略号牌识别的难点在于环境变化所以数据增强要模拟真实场景的各种干扰。我用的增强手段包括亮度与对比度扰动模拟白天、黄昏、夜间不同光照亮度变化范围控制在±40%对比度变化±30%。随机裁剪与缩放模拟不同拍摄距离裁剪比例控制在原图的80%到100%之间。小角度旋转模拟拍摄角度偏差旋转角度控制在±10度以内。角度太大反而会让号牌变形过度影响训练稳定性。运动模糊模拟车辆行驶中的模糊模糊核大小随机在3到7之间。噪声注入模拟低质量摄像头的噪点高斯噪声的标准差控制在5到15之间。这里有个坑要注意数据增强不能改变号牌号码的语义。比如你做水平翻转那“京A12345”就变成镜像文字了这种增强对识别是有害的。所以识别阶段的增强要禁用水平翻转和垂直翻转只能做亮度、对比度、轻微旋转和模糊这类不改变字符内容的操作。另外增强的比例要控制。我一般设置增强后的数据集是原始数据集的3到5倍太多了会导致训练时间过长而且模型可能过拟合到增强的特定模式上。2.3 数据格式转换与LMDB生成Caffe最舒服的数据输入格式是LMDB虽然也支持直接读图片但LMDB的读取速度快得多尤其是小文件多的时候。检测阶段和识别阶段需要分别生成LMDB。检测阶段的LMDB生成需要把图片和对应的XML标注转成Caffe需要的格式。我用的是Caffe自带的convert_annoset工具但需要先把VOC格式的XML转成一个统一的标注文件。具体做法是写一个Python脚本遍历所有XML提取图片路径、边界框坐标和类别标签输出成一个txt文件每行格式是图片路径 类别编号 xmin ymin xmax ymax。识别阶段的LMDB生成更直接把裁剪好的号牌图片和对应的号码标签整理成两个txt文件一个用于训练一个用于验证每行格式是图片路径 标签。然后用convert_imageset工具生成LMDB。这里要注意标签文件里的字符编码要用UTF-8而且中文字符和英文字符要统一映射到连续的整数ID。提示生成LMDB之前一定要检查图片路径是否正确我遇到过因为路径里有空格导致LMDB生成失败的情况排查了半天才发现是路径问题。3. 检测模型SSD的配置与训练3.1 SSD网络结构的定制化修改Caffe自带的SSD示例是基于VGG16的但VGG16参数量太大推理速度慢。我把骨干网络换成了MobileNet具体做法是参考Caffe的MobileNet分类网络定义把前面的卷积层拿过来作为SSD的基础网络然后在后面接上SSD的多尺度检测头。SSD的检测头是在不同尺度的特征图上做卷积预测。对于号牌检测我用了4个尺度的特征图分别是19x19、10x10、5x5、3x3输入图片尺寸是300x300。每个尺度上预设不同大小和长宽比的先验框。号牌的长宽比比较特殊一般是3:1到5:1之间所以先验框的长宽比要针对性地设置我用了1:3、1:4、1:5这三种再加上一个1:1的框作为兜底。先验框的大小计算有个公式min_size和max_size根据尺度层级线性递增。具体来说第k个尺度的先验框最小边长是min_size (max_size - min_size) * (k - 1) / (m - 1)其中m是尺度总数。我设置的min_size是30max_size是90这样四个尺度的先验框最小边长分别是30、50、70、90。号牌在图片中的实际大小一般在40到200像素之间这个设置能覆盖大部分情况。3.2 损失函数与训练参数设置SSD的损失函数是定位损失和分类损失的加权和。定位损失用的是Smooth L1分类损失用的是Softmax。权重比例我设置的是1:1但实际训练中发现定位损失收敛得比分类损失慢后来把定位损失的权重调到2效果更好。训练参数方面初始学习率设的是0.001batch size是16。为什么用这么小的batch size因为显存有限而且小batch size在SSD上反而有正则化效果。学习率衰减策略用的是step在迭代到50000次和80000次的时候各降一次降为原来的0.1倍。总迭代次数是100000次。这里有个经验SSD训练初期loss会震荡得比较厉害这是正常的因为先验框和真实框的匹配还在调整。如果震荡超过2000次还不收敛那就要检查学习率是不是太大了或者数据标注是不是有问题。3.3 训练过程中的监控与调优Caffe训练的时候日志会输出到终端和日志文件。我一般用grep命令过滤出loss信息然后用Python脚本画loss曲线。重点看两个指标总loss是否稳定下降以及定位loss和分类loss的比例是否合理。如果分类loss下降得很快但定位loss几乎不动说明模型在学“这是什么”但没学“在哪里”这时候要检查先验框的设置是否合理或者定位损失的权重是不是太小了。反过来如果定位loss下降但分类loss震荡可能是正负样本不平衡导致的需要调整正负样本的采样比例。我遇到过一个典型问题模型在训练集上loss降得很低但在验证集上loss一直很高。排查后发现是数据增强过度了验证集没有做增强导致训练集和验证集的数据分布差异太大。解决办法是验证集也做轻微的增强或者降低训练集的增强强度。4. 识别模型CRNN的搭建与训练4.1 CRNN网络结构的逐层解析CRNN的结构分三部分卷积层、循环层、转录层。卷积层用的是类似VGG的结构但做了简化。输入是32x100的灰度图号牌裁剪后统一缩放到这个尺寸经过7层卷积和3次池化输出一个1x25x512的特征图。这里的1是高度方向被池化到125是宽度方向的序列长度512是通道数。这个特征图可以理解为把号牌图片切成了25个时间步每个时间步有一个512维的特征向量。循环层用的是两层双向LSTM每层256个隐藏单元。双向LSTM能同时利用序列的前向和后向信息对于号牌这种字符顺序固定的序列双向信息都有用。比如“京A12345”里的“1”和“2”前向看是“1”在“2”前面后向看是“2”在“1”后面双向结合能更准确地判断字符。转录层用的是CTC。CTC的核心思想是引入一个空白符blank让网络在不需要精确对齐的情况下输出序列。比如号牌“京A12345”有7个字符但卷积层输出的序列长度是25CTC会自动学习哪些时间步对应哪个字符哪些时间步是空白。解码的时候用贪心策略或者束搜索我一般用贪心策略速度快精度也够用。4.2 CTC损失的计算与标签处理CTC损失的计算是CRNN训练的核心。Caffe原生不支持CTC损失需要自己写一个自定义层。我参考了GitHub上开源的Caffe CTC实现核心是一个前向传播计算损失、反向传播计算梯度的层。标签处理有个关键点CTC要求标签序列的长度不能超过输入序列的长度。号牌号码最长是8位新能源号牌输入序列长度是25完全够用。但标签里不能有重复字符相邻的情况比如“京A11234”里的“11”CTC会自动处理成“1”加空白加“1”这个不需要手动干预。字符集的定义也很重要。我用的字符集包括31个省份简称、26个大写英文字母、10个数字再加上一个空白符总共68个类别。注意号牌里没有小写字母也没有“I”和“O”容易和“1”和“0”混淆所以实际用到的字母是24个。字符集定义好之后要保存成一个txt文件训练和推理的时候都用同一个文件保证ID映射一致。4.3 训练技巧与收敛判断CRNN的训练比SSD更敏感学习率设大了容易梯度爆炸设小了收敛太慢。我最终用的初始学习率是0.0005batch size是32优化器是Adam。Adam对学习率不那么敏感适合CRNN这种包含RNN的结构。训练过程中要监控CTC loss的变化。CTC loss在初期会下降得比较快因为模型很快学会输出空白符。但到了中期会有一个平台期loss下降变慢这时候不要急着调学习率再等几千次迭代模型可能在调整字符之间的边界。如果平台期超过10000次迭代还没有下降那就要考虑是不是网络容量不够或者数据量太少了。验证的时候不能只看loss还要看实际的识别准确率。我写了一个Python脚本每训练5000次就加载当前模型在验证集上跑一遍计算字符级准确率和整牌准确率。字符级准确率一般能到99%以上整牌准确率受限于最差的那个字符一般会低一些。如果字符级准确率很高但整牌准确率很低说明模型在某些特定字符上容易出错需要针对性地补充那类样本。5. 模型转换与C部署实操5.1 从训练模型到部署模型的转换训练出来的caffemodel不能直接用于部署需要做几件事合并BN层、去除Dropout层、固定输入尺寸。合并BN层是必须的因为BN层在训练时用batch统计量推理时用全局统计量合并之后能减少计算量还能避免推理时batch size为1导致的统计量不准确问题。Caffe自带的merge_bn工具可以做这件事但需要先修改prototxt把BN层和Scale层合并成Scale层。去除Dropout层很简单在部署用的prototxt里把Dropout层删掉就行。固定输入尺寸是为了让Caffe在加载模型时就分配好内存避免推理时动态分配导致的速度波动。转换完成后的部署模型包含两个文件deploy.prototxt和deploy.caffemodel。这两个文件就是C程序需要加载的全部内容。5.2 C推理代码的核心逻辑C推理代码的核心是三个步骤加载模型、预处理输入、解析输出。加载模型用Caffe的Net类传入deploy.prototxt和deploy.caffemodel的路径。加载完成后通过net.blob_by_name(data)获取输入blob通过net.blob_by_name(detection_out)获取输出blob。预处理输入要注意Caffe的输入是BGR格式不是RGB所以如果图片是RGB的要先转换。另外输入图片要减去均值均值文件是训练时生成的mean.binaryproto加载后要转成cv::Mat格式。解析输出的时候SSD的输出是一个7维的向量[image_id, label, confidence, xmin, ymin, xmax, ymax]。confidence低于阈值的框要过滤掉然后对剩下的框做NMS非极大值抑制最后保留得分最高的那个框作为号牌位置。CRNN的输出是一个序列每个时间步有一个概率分布。用贪心策略解码每个时间步取概率最大的类别然后合并重复字符并去除空白符剩下的就是识别结果。5.3 性能优化与实测数据在ARM Cortex-A53平台上实测SSD检测单帧耗时约35msCRNN识别单帧耗时约20ms整个流程约55ms换算成帧率是18FPS左右。这个速度对于停车场出入口场景完全够用因为车辆通过时不需要每帧都识别隔几帧处理一次就行。优化手段主要有三个一是用NEON指令集加速卷积计算Caffe本身有NEON优化但需要编译时开启二是把模型量化成INT8精度损失在1%以内速度能提升一倍三是多线程并行检测和识别可以流水线处理检测完一帧就开始识别同时检测下一帧。内存占用方面两个模型加载后约占80MB内存加上输入输出缓冲区总共不超过120MB。这个内存占用在嵌入式设备上是可以接受的。6. 常见问题与排查技巧实录6.1 检测阶段典型问题速查问题现象可能原因排查方法解决方案检测框偏移严重先验框尺寸不匹配统计训练集中号牌的实际尺寸分布调整min_size和max_size重新聚类先验框漏检小号牌小尺度特征图分辨率不够检查小号牌在特征图上的响应增加一个更高分辨率的检测尺度误检率高负样本太少统计正负样本比例增加负样本挖掘调整正负样本比例训练loss不下降学习率太大或数据标注错误检查标注文件是否有越界框降低学习率重新检查标注推理速度慢模型未合并BN层对比合并前后的推理耗时合并BN层去除Dropout6.2 识别阶段典型问题速查问题现象可能原因排查方法解决方案字符重复输出CTC解码未合并重复字符检查解码逻辑在解码时合并相邻相同字符中文字符识别差中文字符样本太少统计各字符类别的样本数补充中文字符样本或做字符级数据增强整牌准确率低个别字符容易混淆统计混淆矩阵针对混淆字符补充样本或调整字符集训练不收敛学习率太大或梯度爆炸检查loss曲线降低学习率加梯度裁剪推理结果不稳定输入预处理不一致对比训练和推理的预处理代码统一预处理流程确保减均值一致6.3 独家避坑经验分享第一个坑Caffe的LMDB在生成时如果图片路径有中文会报编码错误。解决办法是把图片路径全部改成英文和数字或者用Python3的encode(utf-8)显式处理。第二个坑SSD训练时如果正样本太少分类loss会很快降到0但模型其实什么都没学到。解决办法是设置正负样本比例至少1:3或者用OHEMOnline Hard Example Mining策略。第三个坑CRNN的输入图片高度必须是32的倍数因为卷积层有5次下采样2的5次方等于32。如果输入高度不是32的倍数特征图的高度会对不齐导致LSTM报错。我一般统一缩放到32x100宽度可以不是32的倍数但高度必须是。第四个坑Caffe在推理时如果batch size设为1某些层比如BN层的行为会和训练时不一致。解决办法是推理时也保持batch size为1但确保BN层已经合并或者用net.set_batch_size(1)显式设置。第五个坑号牌颜色对识别有影响。蓝牌和黄牌的字符对比度不同如果训练数据里蓝牌多黄牌少模型对黄牌的识别率会明显偏低。解决办法是在数据增强里加入颜色通道的扰动或者分别针对不同颜色的号牌训练专门的模型。7. 项目扩展与个人体会这套方案跑通之后我陆续做了几个扩展。一个是把检测和识别合并成一个端到端的网络用类似YOLO的结构直接输出号牌号码省去了裁剪和矫正的步骤但精度会下降两三个百分点适合对速度要求极高的场景。另一个是加入了号牌颜色识别在检测阶段同时输出号牌颜色这样识别阶段可以根据颜色选择不同的字符集比如新能源号牌是8位普通号牌是7位。还有一个扩展方向是视频流处理。单张图片的识别已经稳定了但视频流里车辆是运动的可以利用前后帧的信息做跟踪和投票把多帧的识别结果融合起来准确率能再提升一个百分点。具体做法是用卡尔曼滤波跟踪号牌位置对同一个号牌的多帧识别结果做多数投票。我个人在实际操作中的体会是Caffe虽然老但它的设计哲学——配置驱动、层间解耦——在工业部署场景里依然有优势。你不需要写一堆Python代码来定义网络一个prototxt文件就能说清楚整个结构调试的时候改配置就行不用重新编译。当然Caffe的社区确实不活跃了遇到问题只能自己啃源码但这也逼着我把SSD和CRNN的实现细节都搞清楚了算是意外收获。最后分享一个小技巧如果你也在用Caffe做类似的项目建议把训练和推理的预处理代码封装成同一个函数训练时调用、推理时也调用这样能避免因为预处理不一致导致的精度下降。我当初就是因为训练时用了RGB、推理时忘了转换导致识别率掉了十几个百分点排查了一整天才发现是颜色通道的问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进