
简介这是一篇关于街景门牌号识别的技术论文面向深度学习、计算机视觉方向的开发者与学生提供基于深度卷积神经网络的完整识别方案。资源为单一PDF文件大小1.78MB内容涵盖网络结构设计、算法改进、训练实验与结果分析。作者基于AlexNet加深网络层数引入批归一化BN与Dropout策略并在预处理阶段采用灰度化以弱化复杂背景、突出字符特征在谷歌SVHN数据集上达到94.58%的识别率训练约13小时兼具识别精度与训练效率。论文同时梳理了CNN、深度学习、图像识别、OCR、LeNet-5、GPU加速等关键知识点适合作为入门与进阶的参考资料。该资源已有480人学习适合正在研究自然场景字符识别、图像分类或希望了解卷积神经网络调优方法的读者。1. 深度卷积神经网络做街景门牌号识别这不是通用OCR是端到端场景文本理解做城市数据采集时最头疼的不是拍不到门牌号而是拍到了认不出来。基于深度卷积神经网络的街景门牌号识别方法要解决的就是这个场景从任意角度、光照和被遮挡的街景照片里把门牌号这一串字符端到端地抠出来并识别成文本。它跟通用OCR的区别在于深度卷积神经网络在这里同时承担了目标定位和字符解码两件事而不是先切图再套一个识别器。这套方案适合做地图数据更新、地址库校验、资产巡检自动录入的团队也适合刚接触场景文本识别的算法工程师。这篇笔记把我自己跑通的一套流程拆成数据、结构、参数和踩坑四条线来讲照着走能少走不少弯路。2. 门牌号数据从哪来公开数据集搭配自采数据预处理做对一半2.1 街景门牌数据和通用OCR数据的本质差别训练门牌号识别很多人第一反应是拿现成的OCR数据集比如场景文本检测里的ICDAR、Total-Text。这类数据集关注的是整句文本门牌号这种短字符串在里面的占比极低直接拿来训练模型会把寻找门牌号学成寻找任意文字误检率高到没法用。门牌号数据有自己的脾气字符串通常只有3到6个字符以数字为主偶尔带字母或分隔符门牌本身有材质反光白天太阳直射时数字高光溢出拍摄角度刁钻门牌在画面里是透视梯形而不是矩形经常被树枝、车辆、行人遮挡一截远近差异大同样是720p的图近的门牌占满半张图远的只有几十像素。这些差异决定了数据预处理的重点不是通用OCR那种版面分析和行切分而是先精确锁定门牌区域再做透视校正让字符尽量恢复成水平排列。实测下来把透视校正做好的模型比直接硬训的模型在街景数据上精度能高出十几个点。对比项通用场景文本街景门牌号字符串长度不定长句居多通常3~6字符内容分布字母数字汉字数字为主主要干扰复杂排版、多行透视畸变、反光、遮挡颜色先验无统一规律红/白/绿底、字体固定容忍误检可接受少量错字一个数字都不能错2.2 用公开数据预训练拿自采数据微调数据构成与标签格式常见做法是先找公开的街景门牌号数据集做预训练目前用得比较多的是来自Google街景项目的SVHN数据集里面的图就是自然街景中裁出的门牌号带边框和标签训练集约七万张测试集约两万多张足够把网络的底层特征先养起来。自采数据用来做微调和泛化测试采集时覆盖不同时段、天气、街道类型比单纯追求数量更重要。标签格式上SVHN给的是框坐标和对应数字串。自采数据无论用什么标注工具导出时建议保持统一结构图像路径、框中点在原图的位置、宽高、以及文本字符串。我自己会把所有标注转成一种JSON格式避免训练脚本被标注工具的格式差异绑架import json # 转换自采标注为统一 JSON 格式 def convert_annotation(image_path, boxes, labels, out_path): # boxes: [[x1, y1, x2, y2], ...] 原图像素坐标 # labels: [138, 25A, ...] 与 boxes 一一对应 record [] for (x1, y1, x2, y2), text in zip(boxes, labels): record.append({ image: image_path, box: [int(x1), int(y1), int(x2), int(y2)], # 左上和右下坐标 text: text, len: len(text), }) with open(out_path, w, encodingutf-8) as f: json.dump(record, f, ensure_asciiFalse, indent2)逻辑说明这段代码把人工标注的框和文本整理成统一JSON后续训练脚本只认这一个格式。text字段保留原始字符串len字段用来过滤异常标注比如小于2位或大于8位的多半是误标可以在建数据集时直接剔除。参数说明box坐标必须是原图分辨率下的绝对坐标不要在裁剪后再换算如果标注工具给的是中心点加宽高就在这步先还原成左上右下两点后面做透视变换时省事。2.3 透视校正与裁剪把梯形门牌拉回正视角门牌号在街景里几乎不可能是正对镜头的直接用矩形框裁剪喂给网络字符是歪的。我一般先在检测框内做四点透视变换把门牌平面拉正再按固定比例裁剪缩放。import cv2 import numpy as np def rectify_plate(image, quad): # quad: 门牌四个角的像素坐标顺序为 左上、右上、右下、左下 (tl, tr, br, bl) quad width int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl))) height int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl))) dst np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypefloat32) matrix cv2.getPerspectiveTransform(quad.astype(float32), dst) warped cv2.warpPerspective(image, matrix, (width, height)) # 统一缩放到 64x192保持数字长宽比不畸变 resized cv2.resize(warped, (192, 64), interpolationcv2.INTER_CUBIC) return resized逻辑说明透视变换的核心是把检测到的门牌四角映射到一个正矩形width和height由四角距离估算保证还原后的门牌比例接近真实。最后缩放到64×192是识别网络的标准输入再高会拉长计算时间再低会丢失小数字的细节。参数说明四点顺序必须是顺时针或逆时针一致否则变换矩阵会算出镜像建议在检测阶段就输出四角而不是中心点加宽高中心点框对斜拍门牌的重合度很差。2.4 增强策略光照、透视和模糊是街景三大主角通用OCR数据增强喜欢做旋转和模糊但街景门牌识别里旋转角度超过15度就不真实了原因是门牌本身是横平竖直的物体。我常用的增强参数是这样配的import albumentations as A train_transform A.Compose([ A.ShiftScaleRotate(shift_limit0.05, scale_limit0.1, rotate_limit10, p0.5), A.RandomBrightnessContrast(brightness_limit0.3, contrast_limit0.3, p0.8), A.GaussNoise(var_limit(10.0, 30.0), p0.3), A.MotionBlur(blur_limit5, p0.2), A.RandomShadow(shadow_roi(0.0, 0.5, 1.0, 1.0), shadow_intensity0.5, p0.3), A.Perspective(scale(0.05, 0.1), fit_outputTrue, p0.4), ])逻辑说明ShiftScaleRotate的旋转限制在±10度模拟手持设备轻微晃动超过会引入不真实的大角度倾斜RandomBrightnessContrast的亮度扰动到0.3覆盖正午和黄昏的反差RandomShadow模拟树叶和电线杆在门牌上投下的阴影对夜间场景也有帮助。参数说明Perspective的scale值不宜超过0.15过大的透视会让已经做过校正的门牌又变歪增强的目的是模拟校正不彻底的情况不是制造新的失真。p值按训练集大小调整数据集小就调高到0.6以上打满增强防止过拟合。3. 网络结构怎么选DCNN骨干加序列解码门牌号识别的经典配搭3.1 两阶段还是端到端先想清楚你的输入长什么样模型结构这件事我见过两种路线。一种是把门牌号识别拆成两个模型先用目标检测网络把门牌区域框出来再送进一个文字识别模型去读数字。另一种是端到端方案检测和识别共用一套深度卷积神经网络的特征直接在原图上输出字符串。门牌号这个任务有个特点它不像车牌有个固定的颜色和尺寸比例也不像文档扫描件有固定的版面。街景里一个门牌可能只占20×60像素也可能占200×600像素光照和遮挡千奇百怪。如果走两阶段检测框质量差一点识别阶段就会被带偏端到端方案让网络自己决定哪里是门牌和上面写了什么特征共享让训练数据的需求量更小。我一般会这样选如果门牌在画面中占比较大、场景相对固定比如门禁机、快递柜用两阶段更稳每个环节可以单独调优如果输入是完整街景图、门牌尺度变化极大用端到端的深度卷积神经网络骨干序列解码头结构实测在小目标上的表现更好。下面的实现都以端到端路线为主。3.2 骨干网络搭建ResNet特征提取与下采样倍率选择端到端结构里深度卷积神经网络承担的是特征提取输出的特征图要同时保留空间位置和语义信息。门牌号尺寸小下采样倍率不能太大我常用stride为8的骨干而不是ImageNet分类默认的32否则小门牌的特征在下采样过程中就没了。import torch import torch.nn as nn from torchvision.models import resnet18 class Backbone(nn.Module): def __init__(self, out_channels256): super().__init__() base resnet18(pretrainedTrue) # 取到 conv3 层下采样 8 倍 self.stage1 nn.Sequential(base.conv1, base.bn1, base.relu, base.maxpool) self.stage2 base.layer1 self.stage3 base.layer2 # 把卷积层输出的 128 维映射到统一通道 self.reduce nn.Conv2d(128, out_channels, kernel_size1) def forward(self, x): x self.stage1(x) x self.stage2(x) x self.stage3(x) return self.reduce(x) # 输出 [B, out_channels, H/8, W/8]逻辑说明这段代码用ResNet18的前三个stage做骨干输出下采样8倍的特征图。之所以选ResNet18而不是ResNet50是因为门牌号识别不指望深层网络堆出更强的语义浅层特征的边缘和纹理更锐利对数字这种简单字形更重要而且推理速度能快一倍。参数说明pretrainedTrue时加载ImageNet预训练权重能让前几层省去大量训练时间建议冻结stage1和stage2的权重只训练stage3和后续识别头这个策略在数据量只有几千张时能显著避免过拟合。3.3 序列解码头把特征图变成一串数字CTC在这里起作用门牌号识别本质上是序列预测问题。卷积网络输出的是H/8×W/8的特征图按时间步展开成序列再用双向LSTM建模字符间的上下文最后接CTC损失对齐预测和真实字符串不需要逐字符的强对齐标注。class SequenceHead(nn.Module): def __init__(self, in_channels, hidden_size, num_classes): super().__init__() self.lstm nn.LSTM(in_channels, hidden_size, bidirectionalTrue, num_layers2, batch_firstTrue) self.fc nn.Linear(hidden_size * 2, num_classes) # 双向所以乘2 def forward(self, cnn_features): # cnn_features: [B, C, H, W]先按宽度方向展开 b, c, h, w cnn_features.shape # 把 H 维度压缩全局池化保留 W 作为序列长度 pooled cnn_features.mean(dim2) # [B, C, W] seq pooled.permute(0, 2, 1) # [B, W, C] out, _ self.lstm(seq) # [B, W, hidden*2] logits self.fc(out) # [B, W, num_classes] return logits逻辑说明特征图的高度维度在H方向取平均把二维特征压成一维序列宽度维度W成为序列长度。这样设计的依据是门牌号是单行文字高度方向的池化不会破坏字符顺序反而能聚合垂直方向的纹理信息让网络对字符的垂直位置变化更鲁棒。参数说明num_classes要设为字符表大小1多出来的是CTC的blank标记用来分隔连续重复的数字比如77中的两个7不能在相邻时间步都输出7否则解码会合并成一个7。hidden_size一般取256门牌号最长不过8个字符序列不长隐层太大反而容易记住训练集噪声。注意CTC的blank索引在训练和预测时必须保持一致我见过有人训练时blank0、预测时把blank设成字符表长度结果连续重复字符全被误合并这类问题排查起来很隐蔽。3.4 轻量骨干替换MobileNetV3与速度精度的取舍端到端方案要落到嵌入式或旧手机上ResNet18可能都嫌重。把骨干换成MobileNetV3-Small参数量能降到原来的三分之一精度损失控制在1到2个点以内换来的是推理帧率翻倍。这个替换在Backbone代码里只改一步把resnet18换成mobilenet_v3_small取到相同的下采样倍率特征层后面SequenceHead不用动。实际项目里我在低端移动芯片上做过对比ResNet18单帧要120ms换成MobileNetV3后压到45ms精度只掉了0.8个点这个权衡对移动端巡检性价比很高。不过要注意MobileNetV3的特征层通道数不是均匀分布的替换时要把reduce卷积的输入通道数改成实际对应层的输出否则维度对不上会直接报错。4. 训练到收敛参数配置、两阶段训练与精度提升路线4.1 两阶段训练先训识别头再解冻骨干用CTC损失驱动深度卷积神经网络模型不是一次训到底的。数据量不大的时候直接端到端从头训骨干的预训练权重很快会被打乱。我习惯先冻结骨干只训练SequenceHead几百个iteration让新加的解码头先适应骨干特征的分布然后再解冻整个网络微调。这个顺序能让loss曲线平稳很多。import torch import torch.nn.functional as F from torch.optim import AdamW # 把第3章的 Backbone 和 SequenceHead 串起来 model nn.Sequential(backbone, head) char_list 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ- char_to_idx {c: i 1 for i, c in enumerate(char_list)} # blank 索引为 0 def encode_texts(texts): targets [] for t in texts: targets.extend([char_to_idx[c] for c in t]) return torch.tensor(targets, dtypetorch.long) # 冻结骨干只训识别头 model.requires_grad_(False) model[1].requires_grad_(True) # head optimizer AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr1e-3, weight_decay0.01) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) for epoch in range(50): for images, texts in train_loader: logits model(images) # [B, W, num_classes] input_lengths torch.full((images.size(0),), logits.size(1), dtypetorch.long) target_lengths torch.tensor([len(t) for t in texts], dtypetorch.long) targets encode_texts(texts) log_probs logits.transpose(0, 1).log_softmax(2) # [W, B, num_classes] loss F.ctc_loss(log_probs, targets, input_lengths, target_lengths, blank0) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()逻辑说明CTC loss的输入要按[序列长度, batch, 类别]排列transpose(0,1)就是干这个的log_softmax在类别维度做blank0对应num_classes里的额外标记。冻结骨干后优化器只更新识别头的参数AdamW的学习率1e-3不会把预训练特征冲乱。参数说明T_max50表示余弦退火在50个epoch内从1e-3降到接近0头一个阶段跑完再解冻骨干把学习率改到5e-5继续微调。微调阶段如果发现loss回升多半是lr太大降到1e-5重来。4.2 参数速查表batch、lr、优化器与EMA的推荐范围这里给一组我试出来的参数范围门牌号任务直接按这个起步比瞎调收敛快得多参数推荐范围说明输入分辨率宽192高64门牌是横向物体宽高比要保留batch size64~128街景样本差异大太小loss震荡初始学习率1e-3头/ 5e-5骨干头用大lr骨干小lr优化器AdamWweight decay取0.01防止过拟合EMA衰减0.995~0.999对精度有肉眼可见的提升字符表0-9 A-Z 分隔符别漏了常见门牌字母序列长度32~48过长浪费计算过短截断长门牌最大文本长度8超过8的标注先剔除这些参数不是玄学门牌号识别对位置敏感输入宽度要能容纳最长门牌加上左右留白EMA在多轮epoch里对权重做滑动平均能有效压制最后一轮过拟合带来的波动尤其是数据量小时效果明显。4.3 类别不平衡与困难样本挖掘让模型在真实场景不偏科街景门牌号的数字分布严重偏向0到9中的一部分有些数字出现次数是其他数字的十倍。如果不做处理模型会对高频数字过拟合低频数字识别率崩掉。# 统计训练集中每个字符的出现次数计算权重 char_counts {c: 0 for c in 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-} for texts in all_texts: for ch in texts: char_counts[ch] char_counts.get(ch, 0) 1 total sum(char_counts.values()) weights {c: total / (char_counts[c] * len(char_counts)) for c in char_counts} # 在 compute_loss 里对 CTC 输出按字符权重加权逻辑说明权重计算采用逆频率方式出现次数少的字符得到更高权重CTC loss在解码到低频字符时会被放大促使模型更重视它们。这个trick比OHEM简单效果却稳定。参数说明权重值不需要归一化到某个区间直接用总频次除以单字符频次值域在1到20之间如果某个字符在训练集一次都没出现先别急着加权重检查标注数据是否漏标硬加权只会让模型学会乱猜。5. 避坑与排查街景门牌号识别在真实场景的五个翻车点5.1 镜头一远就漏检小门牌特征被下采样吃掉现象模型在测试集上mAP很高但实拍图里远处的小门牌几乎全漏。排查发现骨干网络下采样倍率16而小门牌在输入图上只有二三十像素宽经过三次下采样后特征已经退化成几个点。 原因把ImageNet的分类网络直接搬来做检测骨干没有考虑目标尺寸分布。 解决把下采样倍率从16降到8去掉最后一个下采样层同时把输入分辨率从192×64提高到384×128小目标的像素占比翻倍。这两个改动之后小门牌的召回率能从50%提到80%。5.2 77被读成7CTC的blank标记和重复字符解码现象所有连续重复数字11、77、88都被合并成单个字符门牌号77输出7。 原因CTC的解码定义就是靠blank分割连续重复字符如果两个7之间没有blank间隔贪心解码会把相邻的相同字符合并成一个时间步的输出。 解决不要改解码逻辑而是确保训练时输入图像宽度足够大让两个7分别落在不同的时间步同时确认blank索引在训练和预测时指向同一个位置。我踩的坑是把blank和真实字符表里的空格混用了导致blank失效简单修一下索引定义就恢复了。5.3 蓝底白字的车牌抢了门牌的位置现象街景里同时出现门牌和车牌检测头把车牌也框出来识别结果混入大量车牌字符。 原因门牌和车牌在视觉上都是背景色数字的矩形区域深度卷积神经网络学到的是矩形数字这个模式而不是门牌这个语义。 解决在训练数据里加入大量含车牌的负样本并显式地标记为负例后处理时加一个颜色先验过滤门牌底色的HSV分布集中在红、白、绿三类车牌的蓝底特征差异明显做个直方图判断能砍掉大半误检。5.4 白天95%的精度晚上掉到60%增强没覆盖光照域现象白天测试集上识别精度95%晚上街景测试集只有60%而且不是模糊导致的是数字区域对比度太低。 原因训练增强里的亮度扰动范围太窄网络没见过低照度下数字反光微弱的样子。 解决把RandomBrightnessContrast的brightness_limit从0.3扩大到0.6再加一条夜晚专用的合成增强把图像整体乘以0.3的系数叠加少量高斯噪声。这样训练集的光照域覆盖了白天到深夜的跨度夜间精度能从60%拉到85%以上。5.5 量化后精度掉点INT8下的数字对不齐现象用INT8量化后整体精度掉了5个点集中在8和3这种曲率接近的数字上。 原因训练时用的是FP32量化校准集用的是普通街景图激活值分布和实际部署场景不一致导致量化比例因子选偏。 解决量化前做QAT量化感知训练在计算图中插入伪量化节点让网络适应数值扰动校准集不要随机选几十张而是挑白天、夜晚、雨天各占三分之一的200张。这个流程能把精度回损从5个点压到1个点以内。6. 上线前最后一步用回灌测试和量化推理验证模型真能扛事模型训练完别急着上线。我见过太多模型在验证集上漂亮一压到生产环境就露馅。我的习惯是上生产前做两件事回灌测试和推理引擎验证。回灌测试的做法是把过去三个月积累的街景照片按时段、天气、拍摄距离分组每组用人工标注跑出基线精度再用新模型跑一遍对比每组精度的差异。只看总分是最容易骗自己的白天高、晚上低平均下来好像还行但业务方晚上要用就是不行。分组统计才能暴露光照、天气这些维度的短板。代码上回灌测试就是读图、推理、比对跑一遍已有基线新模型替换后跑同样的脚本。推理引擎验证则要看两件事能不能导出、导出后精度和速度对不对。PyTorch训练完的模型直接部署太重我一般转成ONNX用推理引擎跑转换时把动态轴固定住import torch from models import build_model model build_model(pretrainedFalse) model.load_state_dict(torch.load(best.pt, map_locationcpu)) model.eval() dummy torch.randn(1, 3, 64, 192) # batch1, 高64, 宽192 torch.onnx.export( model, dummy, house_number.onnx, opset_version13, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}} )逻辑说明dynamic_axes只保留batch维度动态输入宽高固定为训练时的192×64这样推理引擎可以做输入尺寸相关的算子优化比如卷积核融合和内存池复用。如果部署场景的输入尺寸不固定把高和宽也设为动态但要接受部分算子无法融合带来的性能损失。参数说明opset_version优先选推理引擎当前支持的最高版本13能兼容绝大多数开源和商用推理框架导出的模型在推理引擎里跑一遍对比PyTorch原模型的输出差异差值超过1e-3就要排查算子映射。一次教训我有个项目只看了测试集精度就发版结果现场反馈雨天识别率崩到一半回灌测试一查才发现雨天分组的精度从来没达标过。自那以后我把回灌测试脚本固定成了发布流程的一部分跑不完这步不发版。这套方法坚持下来门牌号识别模型上线后的线上事故率降了大半。希望帮到你。本文还有配套的精品资源点击获取