ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于全卷积神经网络的船舶检测与船牌识别系统实践

基于全卷积神经网络的船舶检测与船牌识别系统实践 简介《基于全卷积神经网络的船舶检测和船牌识别系统》是一份便携式文档格式的学术论文资源面向深度学习、计算机视觉及智慧港口应用场景适合研究生、算法工程师和相关领域研究者学习参考。论文针对船舶轮廓复杂、船牌位置不固定、文本类型多样等实际挑战提出基于全卷积神经网络FCN的SDR-FCN系统利用SDNet完成船舶检测定位采用PDNet进行船牌文本检测并结合在线自适应分类器与船舶自动识别系统AIS信息提升船牌识别精度实验结果表明该方法能在较高精度下可靠运行满足港口智能监控等实际应用需求。资源包含一个PDF文件容量4.12MB论文来自《计算机与现代化》2019年第12期涵盖摘要、引言、相关研究、方法设计与实验评估等完整章节可帮助读者系统掌握全卷积神经网络、目标检测、图像特征提取、在线自适应分类等关键技术。目前已有221人浏览学习对从事船舶检测、图像识别以及智慧港口、交通管理等领域研究的读者具有直接参考价值。1. 基于全卷积神经网络的船舶检测和船牌识别系统为什么把两件事串成一条流水线港航监控摄像头拍下来的画面里一条船可能只占几十个像素船牌号更细得靠贴近镜头才能看清。基于全卷积神经网络的船舶检测和船牌识别系统就是在这种条件下把两件事放到同一条流水线里完成检测分支负责定位画面里的每一条船识别分支负责沿着船舷把船牌字符逐个读出来。它解决的是一线值班人员盯屏漏看以及船舶 AIS 信号缺失时仍然能从图像直接补全船身信息的问题。这套方向适合已经有视频或抓拍数据、想用较低成本搭一套可用识别流程的团队也适合准备做船舶识别项目、想先把检测识别链路跑通的个人。下面从网络结构、数据处理、训练参数和踩坑记录逐层拆开。新手照着走能把这套系统跑起来熟手可以直接跳到第 4 章看边界条件和参数取舍。2. 两个头共享一套主干全卷积网络怎么同时输出船舶位置和船牌字符先回答一个最常见的问题为什么非要用全卷积网络普通分类网络走到最后全连接层会把二维特征图展平成一条向量船在画面里的空间位置在这一步就丢了。船舶检测要的是热力图每个像素位置都回答“这里是不是船的中心”船牌识别要的是字符序列不是固定长度的类别标签。两个任务的输出都是空间格式或序列格式全卷积网络把特征图一路保留到最后让检测头和识别头各自从同一份特征里取自己需要的信息这是最省事、也最容易调优的做法。行业里大家关心的 CenterNet、U-Net 这些名字实际上都在全卷积的框架里区别只在检测头怎么组织。2.1 主干的选择ResNet18 够用ResNet50 更稳输出步长定在 16主干网络我用过 ResNet18也用过 ResNet50。数据量两三千张、目标普遍比较大的时候ResNet18 就够了海上远距离小目标多、船身只占几十个像素的时候ResNet50 的深层特征会明显更耐打代价是显存和推理时间都要涨一些。拿不准就从 ResNet50 起步后续资源吃紧再降这个顺序不会让你翻车。主干不能直接用 torchvision 里现成的分类模型必须把最后的全局池化和全连接层拿掉保留卷积阶段输出的四维特征图。我在项目里的基线做法是让主干的整体输出步长等于 16输入 512×512 的图像主干输出 32×32 的特征图每条船的中心点在特征图上大约对应 1 到 2 个像素后续热力图预测和偏置回归才能在这个尺度上做。import torch import torch.nn as nn import torchvision.models as tvmodels class FCNBackbone(nn.Module): def __init__(self, base_nameresnet50, out_stride16): super().__init__() # 新版 torchvision 建议用 weights 参数代替 pretrained二选一即可 base tvmodels.resnet50(pretrainedTrue) layers list(base.children())[:-2] # 去掉 avgpool 和 fc self.stem nn.Sequential(*layers) self.out_stride out_stride def forward(self, x): return self.stem(x)这段代码的关键在[:-2]torchvision 在带池化的分类网络里通常把 avgpool 和 fc 放在最后两个模块切掉之后剩下的卷积层直接输出激活特征图既不展平也不做类别打分。out_stride这个参数在工程里主要是给自己留个记录后续接检测头时要知道特征图相对原图缩小了多少倍好把回归值换算回原图坐标。另一个值得留意的点ResNet 系列到最后一层卷积阶段输出步长是 32。想要压到 16通常需要把最后一个阶段改成空洞卷积这属于结构上的细节这里先不展开。基线先用 stride 16检测和识别的整体平衡是最舒服的。2.2 检测头热力图、中心点偏置、宽高回归三个分支并排输出检测头我采用关键点检测的方式与全卷积的定位一致整个头部没有一层全连接。它从主干特征图并行拉出三条分支第一条输出单通道热力图峰值位置就是船中心通道数设为 1 是因为这个项目只检测船这一类第二条输出两通道的中心点偏置用来修正下采样带来的整数取整误差第三条输出两通道的宽高回归值表示从中心到目标框右下角的偏移。三条分支的尺寸和主干输出一致训练和推理代码都按这个约定来写。class CenterHead(nn.Module): def __init__(self, in_channels, num_classes1): super().__init__() hidden 256 self.shared nn.Sequential( nn.Conv2d(in_channels, hidden, 3, padding1), nn.ReLU(inplaceTrue), ) self.heatmap nn.Conv2d(hidden, num_classes, 1) self.offset nn.Conv2d(hidden, 2, 1) self.size nn.Conv2d(hidden, 2, 1) def forward(self, feature): x self.shared(feature) return self.heatmap(x), self.offset(x), self.size(x)三张输出表在训练里各干各的事热力图负责人偏置负责校准位置宽高负责把框从中心撑开。要特别注意最后一层 1×1 卷积前的特征通道数不要设太小256 这个数字是两次项目里对比出来的底线低于 128 之后小船的峰值常常被周围背景压下去漏检率会明显上升。2.3 识别头检测框裁出来过轻量 CNN 加 BiLSTM再用 CTC 对齐船牌识别头和检测头结构完全不是一个套路。识别输入是检测分支给出的船体框裁出的图像块先按比例缩放成固定高度 32 像素的窄条再通过一个小型卷积网络提取时序特征。因为船牌字符没有固定的数量输出端必须能对齐不定长的序列。我用的组合是轻量 CNN 加双向 LSTM最后接一个 CTC 解码器CTC 的优势在于不需要逐字符标注位置只要知道整条船牌对应的字符串就能自动对齐训练。class CharHead(nn.Module): def __init__(self, in_channels, num_classes): super().__init__() self.cnn nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 2)), # 高度压到 16 nn.Conv2d(64, 128, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1)), # 只压高度不压宽度保留时序长度 ) self.blstm nn.LSTM(128, 64, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(128, num_classes) # 字符集大小 空白符 def forward(self, cropped): # cropped: B, C, H, W x self.cnn(cropped) # - B, 128, 8, W b, c, h, w x.shape x x.mean(dim2) # 高度方向池化 - B, 128, W x x.permute(0, 2, 1) # - B, W, 128 x, _ self.blstm(x) logits self.fc(x) # B, W, num_classes return logits这段代码里最容易看漏的是第二个MaxPool2d((2, 1))船牌文字是横着排的宽度方向的时序信息不能压所以池化内核只在高维度上取 2。后面的平均池化把高度维度彻底融合剩下的宽度每一列就对应一个时序步送入 BiLSTM。num_classes在项目里取字符集长度加 1多出来的那一类是 CTC 的空白符用于吸收字符之间的间隔。2.4 损失怎么配热力图用 Focal Loss框用 L1字符用 CTC三个头的损失函数完全不同不能图省事全部用交叉熵。热力图这一路背景像素数量远远大于船中心点像素数量普通交叉熵会被大片背景主导训练出来的峰值会很钝所以用 Focal Loss 压低易分类背景样本的权重。框回归的部分是连续值预测用 L1 损失配合一个小的权重系数。识别头直接调用 PyTorch 里的 CTC Loss把预测序列和目标字符串对齐。hm_loss focal_loss(pred_heatmap, gt_heatmap, alpha2, beta4) box_loss torch.abs(pred_offset - gt_offset).mean() * 0.1 \ torch.abs(pred_size - gt_size).mean() * 0.1 rec_loss torch.nn.functional.ctc_loss( pred_logits.permute(1, 0, 2), target_indices, pred_lengths, target_lengths, blank0) total_loss hm_loss box_loss rec_loss * 0.5这里的权重系数不是拍脑袋定的。热力图是检测的主任务损失量级最大框回归和偏置回归给 0.1 的权重因为它们只影响框的精度不影响召回识别头损失给 0.5是因为初期检测框还不稳定识别头吃到的图像质量参差权重太大会反过来干扰主干。先让主干和检测头训 20 个 epoch再把识别头挂进去联合训练是我试过比较稳的节奏。下表是这套结构的核心约定训练代码和推理代码都围绕这几个数字展开模块输出张量形状含义主干输出B×512×H/16×W/16高层语义特征图热力图分支B×1×H/16×W/16船中心点置信度偏置分支B×2×H/16×W/16中心点亚像素偏移宽高分支B×2×H/16×W/16目标框宽高船牌识别头B×T×num_classes每个时序步的字符概率分布3. 从数据到训练把船舶检测与船牌识别串成一条可复现的流水线结构定好之后真正花时间的反而是数据。很多项目在模型上照着别人的结构抄一遍训练出来的效果仍然稀烂十有八九是数据准备这步埋了雷。下面按我实际跑的流程把数据标注、增强策略、检测训练循环和整条推理链的拼装顺序写一遍参数都是可以直接拿去做基线的值。3.1 数据怎么标船舶外接框和船牌框分开标注按轨迹划分数据我用的标注工具是 LabelMe导出的是 JSON 文件。每一帧图像里同时标两类目标一类是整艘船的外接框类别名ship另一类是船牌区域类别名plate。后面的训练里ship框喂给检测头plate框只用于训练识别头推理的时候系统根据检测出的ship框按经验位置裁出船牌区域再送进识别网络。船舶框的标注边界有一个容易出错的约定要把船身在画面里露出的部分完整框住包括船头船尾的轮廓。不要为了贴合船牌而裁到只留船体中部否则检测头学到的特征就不完整识别阶段也更难定位船牌区域。船牌框则尽量贴着字符边缘标不要把大片船体背景包进来。{ version: ..., shapes: [ {label: ship, points: [[210, 340], [390, 418]]}, {label: plate, points: [[250, 368], [345, 392]]} ] }LabelMe 的 polygon 点只有两个时就是矩形框上面的两个框一个框住整船一个框住船舷上的船牌。这个 JSON 在后面解析时只需要取points的外接矩形和label其他字段不用管。注意标注时保持船牌框在整船框内部这个先验后面要用到如果标得东倒西歪识别头的训练数据里会混进去大量无效区域。划分数据集时要特别留一个心眼按轨迹划分而不是按帧随机划分。同一艘船在连续几十帧里长得一模一样如果帧被切到训练集和验证集两边验证指标会虚高到吓人部署后真实场景一到马上现原形。我一般的做法是先给每艘船一个轨迹 ID再把轨迹整个分到训练或验证保证同一条船不会跨集合出现。船牌识别也同理同一个船牌号的样本必须全进同一边。3.2 数据增强旋转、亮度、模糊都可以上水平翻转要谨慎水面的光线变化剧烈船体姿态也有大有小增强环节不能省。我自己常用的组合是随机旋转 15 度以内、亮度和对比度扰动、高斯模糊、以及缩放裁剪模拟不同距离。这些操作在 albumentations 里可以一次性拼好关键参数写在注释里。import albumentations as A train_aug A.Compose([ A.LongestMaxSize(1024), A.PadIfNeeded(1024, 1024), A.Rotate(limit15, border_mode0, value0, p0.5), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2, p0.5), A.GaussBlur(blur_limit(3, 7), p0.2), A.HorizontalFlip(p0.0), # 默认关掉原因见下文 A.RandomSizedBBoxSafeCrop(height512, width512, p0.5), ], bbox_paramsA.BboxParams(formatpascal_voc, label_fields[labels]))LongestMaxSize和PadIfNeeded的组合保证输入尺寸固定不足的地方补黑边避免船被压变形。RandomSizedBBoxSafeCrop随机裁一小块再放大等于模拟船时远时近这是提升小船召回最有效的一招。HorizontalFlip默认关掉是因为水平翻转虽然能增加船的姿态变化但船牌上的字符也会跟着镜像训练识别头时字符就全反了如果要用必须同时把字符顺序反转前后端都要处理对麻烦大于收益我后来直接弃用了。这里说一句数据量的底线船舶检测部分带ship框的图至少准备 2000 帧以上小目标比例最好占三成船牌识别部分不同船牌号的样本至少 500 条太少了字符集覆盖不全0 和 O、1 和 I 这类混淆对根本学不出来。这条线以下模型再折腾也白搭。3.3 检测训练循环损失叠加与参数基线检测训练循环比想象中简单难的是把三路损失接对。下面这段是训练骨架只看关键部分optimizer torch.optim.AdamW(params, lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max100, eta_min1e-6) for epoch in range(100): for images, hm, offset, size in dataloader: pred_hm, pred_offset, pred_size model(images) loss focal_loss(pred_hm, hm) \ 0.1 * torch.abs(pred_offset - offset).mean() \ 0.1 * torch.abs(pred_size - size).mean() optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()AdamW已经是我做检测的默认选择学习率从 1e-4 开始用 CosineAnnealing 在 100 个 epoch 内衰减到 1e-6。batch size 我建议先按 8 跑显存富余再提到 16输入尺寸就是前面增强给到的 1024×1024。如果训练集只有两三千张图100 个 epoch 大约在 10 小时左右跑完看验证集的 mAP 决定是否提前停。训练过程只看验证集不要盯着训练 loss 看。训练 loss 降到某个平台后继续下降很可能只是把训练集特征死记硬背下来了验证集 mAP 不涨就应该停下来否则后面识别头也会跟着过拟合部署到新码头立刻翻车。3.4 识别训练与整条推理链拼装识别头单独训练时输入来自标注好的plate框裁出的图块缩放成高度 32 的窄条宽度保持纵横比。我不做字符级切分直接整串输入用 CTC 对齐省去大量标注逐字位置的功夫。字符集就按数字加大写字母算船牌上出现小写或特殊符号时先统一大写再进字典。训练完成后的推理是从一张监控大图直接输出文字串完整链路如下import torch.nn.functional as F # 1. 网络前向拿到三张预测图 hm, offset, size model(images) # 2. 在热力图上做 3x3 局部最大池化取峰值点 peaks F.max_pool2d(hm, 3, stride1, padding1) mask (hm peaks) (hm 0.3) ys, xs torch.nonzero(mask[0, 0], as_tupleTrue) # 3. 从峰值点解码出船体框回归量以 stride16 为单位 for cx, cy in zip(xs, ys): off_x, off_y offset[0, 0, cy, cx], offset[0, 1, cy, cx] w, h size[0, 0, cy, cx], size[0, 1, cy, cx] x1 (cx off_x - w / 2) * 16 y1 (cy off_y - h / 2) * 16 x2 (cx off_x w / 2) * 16 y2 (cy off_y h / 2) * 16 # 4. 按船牌在船体上的常见位置裁剪 plate image[int(y1 (y2 - y1) * 0.45):int(y1 (y2 - y1) * 0.7), int(x1):int(x2)] # 5. resize 成高 32 的窄条送识别头 logits char_head(plate_tensor) # B, T, num_classes text ctc_decode(logits) # 贪心解码 去重 去空白符解码偏置和宽高时所有回归量都必须乘回主干的 stride 16才能还原成原图坐标这一步经常有人在代码里漏掉结果就是检测框整体缩小一半船牌裁剪区域全错位。裁剪船牌的位置我先取船体框竖直方向 45% 到 70% 这个区间水平方向取整船宽度这是从多种码头摄像头视角里总结出的一个先验如果你的机位是俯拍或斜拍这个比例要重新标定拿几帧图量一下船牌相对船框的位置就明白了。CTC 解码我用贪心策略每个时序步取概率最高的字符索引先合并相邻重复再删掉空白符。船牌字符本身不会重复相邻相同的字母所以贪心解码的准确率已经够用不需要上 beam search 增加复杂度。4. 避坑排查笔记远距离小目标、相似字符和框漂移的修复记录结构、数据、训练都配齐之后真正考验人的是部署前后冒出来的各种问题。这里整理几个我自己反复踩过的坑全部按“现象 → 原因 → 解决”写后面做同类项目可以直接对照排查。4.1 远距离小目标漏检热力图里压根没有峰值现象输入压到 512×512 时远处一条小型货船在真值框位置上没有任何响应热力图全黑同一帧图输入切成 1024×1024小船又出现了。漏检率在“远距离”这个分组里特别难看。原因主干输出 stride 16 时一条只有 30×30 像素的小船落到特征图上只有 2×2 左右高层特征图感受野大局部信息和小船特征互相稀释峰值被背景压没了focal loss 训练再充分也救不回来。这不是模型不聪明是特征图分辨率不够。解决三个手段叠加。第一把输出主干从 ResNet50 切到更细的 ResNet18 或 MobileNetV3并启用空洞卷积使 stride 降到 8第二增加 3.2 节里提到的RandomSizedBBoxSafeCrop让小船在训练里多一些占比第三推理时不要一条路走到底对大图先下采样做一遍全局检测再对原图切块做一遍局部检测两者按置信度合并。三招里见效最快的是第一招但显存压力会上去工程上有取舍。4.2 船牌字符相似0 和 O、1 和 I、B 和 8 混着错现象检测框很稳裁出的船牌图也很清晰但识别结果里同一个船牌号的 0 被读成 O、1 被读成 I准确率卡在 90% 上下再也不动。原因船牌字符集本身是“数字 大写字母”的混合集合相似字形天然多而标注数据里这两类字符的比例不均衡模型倾向把不确定的字符往样本多的那边猜。一开始把字符集设成 36 类直接训练混淆对的区分度完全不够。解决先砍字符集把 O 和 0、I 和 1 这类容易混淆的符号合并成一个类别识别输出的原始结果再按船牌格式做规则过滤船牌有固定位数、固定字母开头的话按位多数投票修正。更稳一点的做法是用 AIS 信号做交叉验证AIS 上报的船牌候选和视觉识别结果比对不一致时再结合置信度仲裁。这一步对生产系统价值极大纯视觉的识别很难做到 100% 正确。4.3 船体框漂移导致船牌切飞现象热力图峰值位置正确但回归出的船体框要么偏上要么偏宽裁出来的船牌区域一半是水面识别直接输出乱码。原因偏置分支和宽高分支的 L1 损失权重都是 0.1在多数任务里这个数值没问题问题出在训练数据里有一部分船框标注得不够贴边回归分支在这种样本上反复震荡最终学出一个折中的平均偏移量。数据噪声不是大问题但损失权重太小会让回归分支对噪声不敏感。解决推理端做一个框外扩再裁剪把整个ship框向外扩 1.2 倍虽然会带入部分背景但保证船牌完整落在框内识别反而更稳训练端把回归损失权重从 0.1 提到 0.2并对标注面积小于某个阈值的样本做下采样减少它们在训练里的影响力。这两个改完框漂移问题基本就不影响识别了。4.4 验证集 mAP 虚高部署后立刻现原形现象离线测试 mAP 0.92看起来已经可以上线到了新码头跑真视频检测率掉到 0.7 左右船牌识别也跟着崩溃。原因前面 3.1 节提到的按帧随机划分让同一条船的多帧同时出现在训练集和验证集里。模型相当于提前“见过”验证图离线指标自然好看换场景后目标外观、光照、机位全变了模型的泛化短板立刻暴露。这是所有监控类项目里最容易让人误判的地方。解决从数据准备阶段就按轨迹 ID 划分集合再用一个完全没有训练数据的陌生码头视频做最终验收。我现在的习惯是训练过程中每 10 个 epoch 留一组独立场景跑一次快照评估不是等到最后才发现模型过拟合。4.5 显存爆掉被迫调小 batchloss 反而开始震荡现象ResNet50 主干加双头输入 1024batch size 设为 16训练刚开始就直接爆显存改成 2 之后能跑但 loss 曲线大幅抖动热力图分支始终收敛不上去。原因batch 从 16 降到 2BatchNorm 的均值和方差估计噪声太大小目标检测对这种统计波动尤其敏感不是模型本身有问题。解决第一batch 保持 8开启混合精度训练显存基本能减半第二还想再稳一点就用梯度累积每 4 个 step 更新一次优化器等效 batch 还是 32。改完之后 loss 曲线立刻平滑训练时间也基本可接受。以后碰到显存问题先动精度和梯度累积不要先砍 batch。5. 进阶双尺度推理与分组验证仿真结果才敢信模型能跑通只是第一步生产环境里真正比拼的是漏检率和识别置信度。这里给两个直接见效的技巧以及一套验证习惯。5.1 用 3×3 局部最大池化提峰值再按置信度分档过滤热力图峰值提取不要用简单的阈值加连通域我在推理代码里用F.max_pool2d(hm, 3, stride1, padding1)得到一个局部最大值图与原始热力图相等的位置才是真正的峰值点这一步能避免同一艘船在相邻像素上产生重复框。阈值不要定死一个按置信度分两档0.3 用于远距离小船召回0.7 用于高置信度常规船低阈值段的检测结果在后续识别阶段如果置信度低可以单独标记人工复核这样既保召回又不污染识别结果。5.2 双尺度推理整图检测和大图切块按 IoU 合并对 1080p 以上监控画面先整体缩放到 512 做一遍全局检测主要抓住大船再按 512 窗口以 256 步长切块、每块放大到 1024 做一遍局部检测抓小船。两类检测结果按置信度排序后做非极大值抑制IoU 阈值取 0.5重合度过高的框保留置信度高的一侧。这个方案实测能让远距离小船召回提升十个百分点左右代价是推理时间大约翻倍适合离线分析场景实时场景再用单尺度加高分辨率输入替代。5.3 按距离和字符长度分组验证指标才不会骗人最后提一个评估习惯。不要只看一个总 mAP 和总字符准确率把验证结果按船的像素面积分成大、中、小三组分别看检测召回识别结果按船牌字符长度分组看每组的字准率。这样哪个环节拖后腿一眼就能看到调参也有明确方向。我每次迭代完都会顺手把漏检图按“太远、遮挡、亮度异常”三类分到三个文件夹回放一遍再决定下一步动数据还是动模型。这套方向做到最后你会发现网络结构带来的提升是有上限的真正拉开差距的是数据组织的严谨性和验证方法能不能反映真实场景。我自己在这些项目里栽过几个跟头之后养成的习惯是先花两三天把数据划分和评估脚本做扎实再投入到调网络里去这个顺序希望也能帮到你。本文还有配套的精品资源点击获取
返回列表