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 检测网络与识别网络的选型逻辑
检测网络我选的是SSD(Single Shot MultiBox Detector)的Caffe实现,骨干网络用MobileNet。选SSD而不是Faster R-CNN的原因是速度。Faster R-CNN的RPN阶段在嵌入式设备上太慢,SSD的单阶段结构更适合实时场景。选MobileNet而不是VGG的原因是参数量。VGG-SSD的模型文件大概100MB,MobileNet-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_height=32 \ --resize_width=96 \ --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灰度图
- 卷积层1:5x5卷积核,32个输出通道,ReLU激活
- 池化层1:2x2最大池化,步长2
- 卷积层2:3x3卷积核,64个输出通道,ReLU激活
- 池化层2:2x2最大池化,步长2
- 全连接层1:512个输出单元,ReLU激活,Dropout率0.5
- 全连接层2:65个输出单元,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 两阶段网络的联合调优策略
检测网络和识别网络虽然是分开训练的,但在推理时是串联的,所以需要联合调优。我的做法是:先单独训练检测网络,直到验证集上的mAP(mean Average Precision)达到0.9以上;然后固定检测网络,训练识别网络,直到验证集上的字符准确率达到0.98以上;最后把两个网络串起来,在端到端的数据上做一次联合评估。
联合评估时如果发现整体准确率低于预期,要分环节排查:是检测框偏了,还是字符切分错了,还是分类错了。我的经验是,检测框的IoU(Intersection 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: 10000,gamma: 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::Net<float> 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工具做了性能分析,发现整个流水线的耗时分布是这样的:
| 环节 | 耗时(毫秒) | 占比 |
|---|---|---|
| 图像预处理 | 2 | 5% |
| 检测网络推理 | 25 | 62% |
| 透视变换矫正 | 1 | 2.5% |
| 字符切分 | 1 | 2.5% |
| 识别网络推理 | 10 | 25% |
| 后处理与拼接 | 1 | 2.5% |
检测网络是最大的瓶颈,占了62%的时间。优化检测网络是提升整体速度的关键。我试过三种优化方案:减小输入尺寸、裁剪检测头、合并BatchNorm。三种方案叠加后,检测网络耗时从25毫秒降到15毫秒,整体流水线从40毫秒降到28毫秒,满足了实时性要求(每秒处理35帧以上)。
7. 常见问题排查与避坑经验
7.1 训练不收敛的排查思路
训练不收敛是Caffe最常见的问题,表现是loss不下降或者震荡。排查步骤我整理成了一个表:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| loss一直是nan | 学习率太大 | 看日志里的lr值 | 降低base_lr到0.0001 |
| loss震荡不下降 | 批大小太小 | 看batch_size配置 | 增大批大小或降低学习率 |
| loss下降但验证集准确率不升 | 过拟合 | 对比训练和验证loss | 加数据增强或加Dropout |
| loss突然变大 | 数据有问题 | 检查LMDB里的图像 | 重新生成LMDB |
| loss下降很慢 | 学习率太小 | 看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,踩坑是难免的,但踩完之后你会对深度学习框架的底层机制有更深的理解。