1. 为什么现在还有人用Caffe做号牌识别先把话说在前头如果你现在打开任何一个深度学习框架的排行榜Caffe大概率排不进前三。PyTorch、TensorFlow、PaddlePaddle这些后起之秀在易用性、动态图、部署生态上早就把Caffe甩开了。但如果你手头有一块算力有限的嵌入式板子或者你接到的需求是“在一条老产线上加一个车牌识别工位预算三千块”那你大概率会重新翻出Caffe这个老家伙。我这次做的项目就是一个典型的“老框架新场景”用Caffe框架搭一套号牌识别流程从数据准备、模型选型、训练调参到部署推理完整跑一遍。号牌识别这个任务本身不复杂本质上是“目标检测字符识别”两阶段流水线但它对实时性和资源占用有硬要求这恰恰是Caffe的强项——静态图、内存占用低、推理速度快、C部署干净利落。这篇文章适合三类人看第一类是被嵌入式部署逼着回头用Caffe的算法工程师第二类是想理解传统深度学习框架工作流的在校学生第三类是做智能交通、停车场系统、门禁系统的工程人员你们可能不直接训模型但需要看懂整个流程才能跟算法团队对接。我会把整个项目的设计思路、数据准备、模型结构、训练细节、部署踩坑全部摊开讲代码和配置能贴的都贴出来你照着抄作业基本能跑通。需要提前说明的是Caffe的生态在2024年之后基本处于维护状态官方仓库更新很少很多依赖库的版本兼容性需要自己处理。我下面给出的方案是基于我在实际项目中验证过的组合但不保证在你的环境下零改动跑通遇到问题可以参考我后面列的排查表。2. 整体方案设计与技术选型拆解2.1 号牌识别的两阶段流水线设计号牌识别这件事拆开来看就是两个子任务先找到车牌在哪再把车牌上的字符读出来。业内主流做法有两种一种是端到端的一阶段检测识别比如用YOLO直接输出车牌字符序列另一种是两阶段先做车牌检测框再对框内图像做字符识别。我选的是后者原因有三个。第一Caffe对一阶段端到端模型的支持不够友好。Caffe的Layer定义是静态的你要实现一个带序列输出的检测头需要自己写很多自定义Layer调试成本极高。而两阶段方案里检测可以用SSD或Faster R-CNN的Caffe实现识别可以用简单的CNN分类网络都是Caffe原生支持的结构。第二两阶段方案的可解释性更强。检测框偏了、字符切分错了、分类置信度低了每个环节的问题都能单独定位。在实际工程中客户经常要求“把识别错误的样本调出来看看”两阶段方案能直接给你中间结果一阶段端到端模型就很难拆。第三数据标注成本更低。检测阶段只需要标注车牌的四点坐标识别阶段只需要标注字符类别两个数据集可以分开标注、分开训练、分开迭代。如果后面要换识别模型检测模型不用动反之亦然。整个流水线的数据流是这样的输入一张含车牌的图像先经过检测网络输出车牌区域的边界框然后根据框的坐标做透视变换矫正把倾斜的车牌摆正再送入字符识别网络输出每个字符的类别和置信度最后按顺序拼接成完整号牌。2.2 为什么选Caffe而不是PyTorch这个问题我被问过很多次。说实话如果项目允许用PyTorch我绝对不会选Caffe。但现实约束是目标部署平台是一块ARM架构的嵌入式板子内存只有512MB没有Python运行时只支持C推理。在这种环境下Caffe的优势就体现出来了。Caffe的模型文件是纯文本的prototxt加二进制的caffemodel加载速度快内存占用可控。它的C接口非常干净不需要链接庞大的运行时库。相比之下PyTorch的LibTorch虽然也支持C但编译出来的二进制体积大得多在低内存设备上容易出问题。另外Caffe的推理速度在同等模型结构下确实比PyTorch快10%到20%因为它的计算图是静态的没有动态调度的开销。还有一个很实际的原因很多做智能交通的老牌厂商他们的算法栈就是基于Caffe建的历史模型、训练脚本、部署工具链全是Caffe生态。你要跟他们对接用Caffe是最省事的。我这次的项目就是这种情况客户那边已经有一套Caffe的训练环境我只需要在这个基础上做号牌识别的适配。当然Caffe的缺点也很明显不支持动态图调试困难Python接口的文档少很多API要靠读源码社区活跃度低遇到问题搜不到答案。这些坑我在后面会逐一讲怎么绕过去。2.3 检测网络与识别网络的选型逻辑检测网络我选的是SSDSingle Shot MultiBox Detector的Caffe实现骨干网络用MobileNet。选SSD而不是Faster R-CNN的原因是速度。Faster R-CNN的RPN阶段在嵌入式设备上太慢SSD的单阶段结构更适合实时场景。选MobileNet而不是VGG的原因是参数量。VGG-SSD的模型文件大概100MBMobileNet-SSD只有20多MB在内存受限的设备上差距很大。识别网络我选的是一个轻量级的CNN分类网络结构参考LeNet-5但做了加深和加宽。输入是32x96的灰度图对应车牌字符的宽高比输出是65个类别10个数字24个大写字母31个省份简称。这个网络很小参数量不到1MB推理一张图只要几毫秒。这里有个细节值得展开为什么识别网络不直接用CRNN或者CTC因为Caffe实现CRNN需要自定义循环层而Caffe对循环神经网络的支持很差官方没有内置的LSTM Layer要用得自己写。在项目周期紧张的情况下我选择了“检测框字符切分单字符分类”的方案虽然多了一步切分但每个环节都是Caffe原生支持的调试成本低很多。字符切分的逻辑是基于垂直投影的对矫正后的车牌灰度图做二值化然后统计每一列的白色像素数找到波谷作为字符边界。这个方法对标准车牌很有效但对磨损、污损、光照不均的车牌容易切错。后面我会讲怎么用检测框的宽高比和字符间距做后处理校验。3. 数据准备与标注的实操细节3.1 车牌检测数据集的采集与标注规范检测数据集的规模建议至少5000张其中训练集4000张验证集1000张。采集场景要覆盖白天顺光、白天逆光、夜间补光、地下车库、雨天、雪天、车牌倾斜、车牌遮挡。每个场景至少500张否则模型在对应场景下的召回率会明显偏低。标注格式我用的是VOC格式的XML每个车牌标注一个bndbox坐标是左上角和右下角。这里有个坑很多标注工具默认输出的是矩形框但车牌在图像中往往是倾斜的矩形框会包含大量背景。我的做法是先用矩形框标注然后在训练时通过数据增强加入随机旋转让模型学会对倾斜车牌的鲁棒性。如果项目要求高精度可以标注四点坐标然后用透视变换做矫正但标注成本会翻倍。标注的注意事项我列几条车牌框要贴紧字符边缘不要留太多空白否则检测框会偏大影响后续字符切分。对于模糊到人眼都看不清的车牌直接跳过不标不要强行标一个“大概位置”这种样本会污染训练集。新能源车牌绿牌和传统车牌蓝牌要分开统计如果新能源车牌样本太少后面识别阶段会出问题。标注完成后一定要做一次交叉校验让另一个人随机抽200张检查错误率超过2%就要重新标。3.2 字符识别数据集的生成与增强字符识别数据集不需要单独采集可以从检测数据集的标注框里裁剪出来。具体做法是用标注框的坐标从原图中裁剪车牌区域然后做透视变换矫正再按字符切分得到单个字符的图像。每个字符图像保存为32x96的灰度图文件名用“字符类别_序号”的格式方便后续生成LMDB。这里有个关键问题字符切分的准确性直接影响识别模型的训练质量。如果切分错了模型学到的是错误的字符边界。我的做法是先用一个简单的投影法切分然后人工抽检1000张把切分错误的样本挑出来手动调整边界。这个过程很枯燥但值得做因为切分错误率每降低1%识别准确率大概能提升0.5%。数据增强我用了四种方式随机旋转-10度到10度模拟车牌倾斜。随机亮度调整0.7倍到1.3倍模拟光照变化。随机对比度调整0.8倍到1.2倍模拟雾天和夜间。随机遮挡在字符图像上随机画1到2个黑色小矩形模拟污损。增强的代码是在生成LMDB之前做的用OpenCV实现每张原图生成5张增强图这样训练集规模从4000扩展到24000效果提升很明显。3.3 LMDB数据集的生成与校验Caffe默认的数据格式是LMDB虽然也支持HDF5和ImageData但LMDB的读取速度最快内存占用最低。生成LMDB的脚本我用的是Caffe自带的convert_imageset工具但做了两点改造。第一我把图像列表文件train.txt的格式改成了“路径 类别”的形式路径是相对于LMDB生成目录的相对路径类别是字符的索引。这样convert_imageset可以直接用。第二我在生成LMDB之后加了一个校验步骤用Caffe的Python接口读取LMDB随机抽100张图像和对应的标签用matplotlib显示出来人工确认图像和标签是否匹配。这一步能发现很多低级错误比如路径写错、标签错位、图像损坏等。LMDB的生成命令大概是这样的convert_imageset \ --resize_height32 \ --resize_width96 \ --shuffle \ /path/to/images/ \ /path/to/train.txt \ /path/to/train_lmdb注意--shuffle参数一定要加否则LMDB里的数据是按文件名排序的训练时每个batch的样本分布不均匀会影响收敛。另外--resize_height和--resize_width要和网络输入的尺寸一致不一致的话Caffe会在训练时报错。4. 检测网络与识别网络的结构设计4.1 MobileNet-SSD的Caffe配置详解MobileNet-SSD的prototxt文件比较长我不在这里全文贴出重点讲几个关键配置。首先是数据层。我用的是AnnotatedData Layer这是Caffe SSD分支特有的Layer支持直接读取VOC格式的标注。配置如下layer { name: data type: AnnotatedData top: data top: label include { phase: TRAIN } transform_param { mean_value: 127.5 scale: 0.007843 resize_param { prob: 1.0 resize_mode: WARP height: 300 width: 300 interp_mode: LINEAR } emit_constraint { emit_type: CENTER } } data_param { source: train_lmdb batch_size: 16 backend: LMDB } annotated_data_param { batch_sampler { max_sample: 1 max_trials: 50 } label_map_file: labelmap.prototxt } }这里的mean_value和scale是MobileNet的标准预处理参数不要改。resize_param里的300x300是SSD300的输入尺寸如果你的嵌入式设备算力不够可以改成224x224但检测小车牌的能力会下降。然后是检测头部分。SSD在6个不同尺度的特征图上做检测每个特征图上的priorbox数量和宽高比需要根据车牌的实际尺寸调整。车牌的宽高比大概是3:1到4:1所以priorbox的aspect_ratio要设置成2、3、4而不是默认的2、3。这个细节很多教程都不讲但不改的话检测框的回归效果会差很多。损失函数用的是MultiBoxLoss配置里要注意loc_weight参数。默认是1.0我调成了2.0因为车牌检测对定位精度要求高加大定位损失的权重能让框更准。4.2 字符识别网络的轻量化设计识别网络的结构我设计得很简单一共7层输入层32x96灰度图卷积层15x5卷积核32个输出通道ReLU激活池化层12x2最大池化步长2卷积层23x3卷积核64个输出通道ReLU激活池化层22x2最大池化步长2全连接层1512个输出单元ReLU激活Dropout率0.5全连接层265个输出单元Softmax激活这个网络的参数量大概是1.2MB在ARM Cortex-A53上推理一张图大概3毫秒。训练时的batch size设为128学习率初始0.01每10000次迭代降为原来的0.1倍。这里有个经验字符识别网络的输入尺寸不要太大。我试过64x192的输入准确率只提升了0.3%但推理时间翻了一倍。对于车牌字符这种结构简单的目标32x96已经足够了。另外Dropout率我设的是0.5但如果你发现模型过拟合不严重可以降到0.3。判断过拟合的方法是看训练损失和验证损失的差距如果验证损失在训练损失还在下降的时候就开始上升说明过拟合了需要加大Dropout或加数据增强。4.3 两阶段网络的联合调优策略检测网络和识别网络虽然是分开训练的但在推理时是串联的所以需要联合调优。我的做法是先单独训练检测网络直到验证集上的mAPmean Average Precision达到0.9以上然后固定检测网络训练识别网络直到验证集上的字符准确率达到0.98以上最后把两个网络串起来在端到端的数据上做一次联合评估。联合评估时如果发现整体准确率低于预期要分环节排查是检测框偏了还是字符切分错了还是分类错了。我的经验是检测框的IoUIntersection over Union每降低0.05整体准确率大概下降2%到3%。所以检测网络的质量是整个流水线的瓶颈值得多花时间调。联合调优还有一个技巧在训练识别网络时不要用标注框裁剪的图像而是用检测网络输出的框裁剪的图像。这样识别网络见到的图像分布和推理时一致能减少“训练-推理不一致”的问题。具体做法是先用检测网络跑一遍训练集把输出的框保存下来然后用这些框裁剪字符图像再训练识别网络。5. 训练过程中的参数调优与踩坑记录5.1 学习率与批大小的选择依据学习率和批大小是训练中最关键的两个超参数。我的经验是批大小先定学习率再调。批大小的选择受限于显存在单卡12GB显存的机器上MobileNet-SSD的批大小最大可以到32但为了训练稳定我用了16。字符识别网络的批大小可以到256但我用了128因为更大的批大小对准确率提升不明显反而增加训练时间。学习率的初始值我用的是0.001这是SSD训练的常用值。但这里有个坑Caffe的SGD优化器默认是lr_policy: step也就是每隔一定迭代次数降一次学习率。我设的是stepsize: 10000gamma: 0.1意思是每10000次迭代学习率降为原来的0.1倍。这个策略在训练初期收敛很快但后期容易陷入局部最优。后来我改成了lr_policy: multistep在8000、12000、16000次迭代时各降一次效果更好。还有一个细节Caffe的base_lr是全局学习率每个Layer可以单独设置lr_mult。我一般把卷积层的lr_mult设为1全连接层的lr_mult设为2因为全连接层的参数量大需要更大的学习率才能快速收敛。偏置项的lr_mult设为2这是Caffe的惯例。5.2 数据增强在Caffe中的实现方式Caffe的数据增强是在Data Layer的transform_param里配置的支持镜像、裁剪、缩放、颜色抖动等。但Caffe内置的增强方式有限不支持随机旋转和随机遮挡。我的做法是在生成LMDB之前用Python脚本做增强把增强后的图像也写入LMDB。具体流程是读原始图像和标注对每张图像做5种增强旋转、亮度、对比度、遮挡、缩放把增强后的图像和对应的标注保存到临时目录然后用convert_imageset生成LMDB。这样LMDB里就包含了增强后的样本训练时不需要额外的增强操作。这个方法的缺点是LMDB文件会变大5倍但换来的是训练时不需要做增强计算速度更快。在嵌入式设备上训练时这个优势很明显。5.3 训练日志分析与模型选择Caffe的训练日志默认输出到终端但更好的做法是重定向到文件然后用Python脚本解析。我用的解析脚本会提取每次迭代的loss、学习率、准确率然后画成曲线图。通过曲线图可以判断loss是否在下降、是否过拟合、学习率是否合适。模型选择的标准不是训练loss最低而是验证集上的mAP最高。我一般每1000次迭代保存一个snapshot训练结束后用Python脚本遍历所有snapshot在验证集上评估mAP选最高的那个作为最终模型。这个过程叫“模型选择”是防止过拟合的关键步骤。这里有个坑Caffe的snapshot文件名默认是ssd_mobilenet_iter_10000.caffemodel但如果你改了solver配置里的snapshot_prefix文件名会变。评估脚本要能自动识别最新的snapshot否则会评估错模型。6. 部署推理与性能优化实战6.1 C推理接口的封装与调用Caffe的C推理接口核心是Net类和Blob类。加载模型只需要两行代码caffe::Netfloat net(deploy.prototxt, caffe::TEST); net.CopyTrainedLayersFrom(model.caffemodel);然后准备输入Blob把图像数据填充进去调用net.Forward()再从输出Blob里取结果。整个过程很干净没有Python运行时的开销。但这里有几个坑要注意输入图像的预处理要和训练时一致。训练时用了mean_value: 127.5和scale: 0.007843推理时也要做同样的减均值和缩放否则结果会偏。输入Blob的维度是[1, 3, 300, 300]如果你的图像是灰度图要复制成3通道或者改网络第一层的输入通道数。net.Forward()是同步调用会阻塞当前线程。如果要在多线程环境里用需要加锁或者每个线程一个Net实例。6.2 模型量化与推理加速技巧Caffe本身不支持模型量化但可以通过修改prototxt把浮点运算改成定点运算。具体做法是把卷积层的type从Convolution改成ConvolutionDepthwise然后把权重和激活值量化到8位整数。这个改造需要自己写Layer工作量不小。更简单的加速方法是减少网络层数。MobileNet-SSD有28层我砍掉了最后两个尺度的检测头只保留4个尺度的检测头模型大小从23MB降到18MB推理速度提升15%mAP只下降0.5%。这个取舍在嵌入式场景下是值得的。还有一个技巧是合并BatchNorm层到卷积层。Caffe的BatchNorm层在推理时是线性变换可以合并到前一个卷积层的权重和偏置里。合并后模型层数减少推理速度提升5%到10%。合并的公式是W_merged W * gamma / sqrt(var epsilon) b_merged beta - gamma * mean / sqrt(var epsilon)其中gamma、beta、mean、var是BatchNorm层的参数W和b是卷积层的权重和偏置。这个合并可以在Python里用numpy实现然后把合并后的权重写回caffemodel。6.3 端到端流水线的性能瓶颈定位部署完成后我用perf工具做了性能分析发现整个流水线的耗时分布是这样的环节耗时毫秒占比图像预处理25%检测网络推理2562%透视变换矫正12.5%字符切分12.5%识别网络推理1025%后处理与拼接12.5%检测网络是最大的瓶颈占了62%的时间。优化检测网络是提升整体速度的关键。我试过三种优化方案减小输入尺寸、裁剪检测头、合并BatchNorm。三种方案叠加后检测网络耗时从25毫秒降到15毫秒整体流水线从40毫秒降到28毫秒满足了实时性要求每秒处理35帧以上。7. 常见问题排查与避坑经验7.1 训练不收敛的排查思路训练不收敛是Caffe最常见的问题表现是loss不下降或者震荡。排查步骤我整理成了一个表现象可能原因排查方法解决方案loss一直是nan学习率太大看日志里的lr值降低base_lr到0.0001loss震荡不下降批大小太小看batch_size配置增大批大小或降低学习率loss下降但验证集准确率不升过拟合对比训练和验证loss加数据增强或加Dropoutloss突然变大数据有问题检查LMDB里的图像重新生成LMDBloss下降很慢学习率太小看loss下降曲线增大学习率或换优化器我遇到最多的是“loss一直是nan”原因通常是学习率太大或者数据里有损坏的图像。排查方法是先把学习率降到0.0001如果还是nan就用Python脚本遍历LMDB检查每张图像的像素值范围把异常图像挑出来删掉。7.2 检测框偏移与漏检的修正方法检测框偏移的表现是框的位置和车牌实际位置有偏差漏检的表现是有些车牌没被检测到。这两个问题的原因不同解决方法也不同。检测框偏移通常是因为priorbox的宽高比和实际车牌不匹配。车牌的宽高比大概是3:1到4:1如果priorbox的宽高比是1:1、2:1、3:1那3:1的priorbox会匹配得比较好但4:1的车牌就会偏。解决方法是在priorbox配置里加上4:1的宽高比或者用K-means聚类统计训练集里车牌的宽高比根据统计结果设置priorbox。漏检通常是因为小目标检测能力不足。SSD对小目标的检测效果本来就一般如果车牌在图像中占比很小很容易漏检。解决方法是增大输入图像的尺寸从300x300改成512x512或者在浅层特征图上加检测头。我试过把输入尺寸改成512x512漏检率从8%降到3%但推理时间增加了40%。这个取舍要根据实际场景决定。7.3 字符切分错误的典型场景与对策字符切分错误是识别准确率下降的主要原因。我总结了三种典型场景第一种是车牌有边框或铆钉二值化后边框和铆钉也被当成字符。对策是在二值化之前先做一次边缘检测把边框和铆钉的区域标记出来切分时跳过这些区域。第二种是字符粘连两个字符连在一起投影法找不到波谷。对策是用连通域分析代替投影法先找到所有连通域然后根据连通域的宽度和间距判断是否合并或拆分。第三种是光照不均导致二值化后字符断裂。对策是用自适应阈值代替全局阈值OpenCV的adaptiveThreshold函数可以处理光照不均的情况。这三种场景我都遇到过最麻烦的是第二种。后来我用了一个简单的启发式规则如果两个连通域的间距小于字符平均宽度的0.3倍就认为它们是粘连的尝试在中间找一个波谷切开。这个规则在90%的粘连场景下有效。7.4 部署环境下的内存与速度平衡嵌入式设备的内存和速度是一对矛盾。模型越大准确率越高但内存占用和推理时间也越大。我的经验是先确定内存上限再在这个限制下选最大的模型。具体做法是用valgrind工具测量模型加载后的内存占用然后逐步增大模型规模直到内存占用接近上限的80%。留20%的余量是为了应对推理时的临时内存分配。速度方面如果推理时间不满足要求优先优化检测网络因为它是瓶颈。优化方法按性价比排序合并BatchNorm 裁剪检测头 减小输入尺寸 量化。合并BatchNorm几乎不损失准确率裁剪检测头损失0.5%的mAP减小输入尺寸损失2%的mAP量化损失3%到5%的mAP。8. 项目复盘与后续扩展方向这个项目从数据采集到部署上线一共用了六周其中数据标注占了两周模型训练占了一周部署调试占了三周。部署调试花的时间最多因为嵌入式环境的兼容性问题很多Caffe的依赖库版本、OpenCV的编译选项、交叉编译工具链的配置每个环节都可能出问题。如果让我重新做一遍我会在项目开始前先搭一个最小可用的部署环境用随机初始化的模型跑通推理流程确认环境没问题之后再开始训练。这样能避免“模型训好了但部署不了”的尴尬。后续扩展方向有三个一是加入车牌颜色识别区分蓝牌、绿牌、黄牌这对某些业务场景很重要二是加入车牌类型识别区分普通车牌、新能源车牌、使领馆车牌等三是把检测和识别合并成一个端到端模型减少中间环节的误差累积。第三个方向最有挑战性因为Caffe对序列输出的支持不好可能需要换框架。我个人在实际操作中的体会是Caffe虽然老但在嵌入式部署场景下依然有生命力。它的静态图、低内存、快推理的特点是很多新框架替代不了的。如果你也被嵌入式部署困扰不妨试试Caffe踩坑是难免的但踩完之后你会对深度学习框架的底层机制有更深的理解。