
简介《基于全卷积神经网络的船舶检测和船牌识别系统》是一篇发表于《计算机与现代化》2019年第12期的技术论文面向港口智能监控、船舶管理与交通管理等领域的研究人员和工程师。论文针对船舶轮廓复杂、船牌位置不固定、文字类型多样等难题提出基于全卷积神经网络FCN的SDR-FCN系统利用SDNet实现船舶定位通过PDNet检测船牌文本并借助结合AIS信息的在线自适应分类器OA-Classifier完成船牌识别兼顾了高精度与可靠性。文档共1个PDF文件大小约4.12MB属于深度学习、计算机视觉方向的专业参考资料。全文包含研究背景、算法原理、网络结构、实验评估等完整内容非常适合希望在船舶检测、目标识别或港口智能化方向进行算法设计或论文复现的读者。目前已有221人学习下载可作为相关课题的入门与进阶参考。1. 全卷积神经网络做船舶检测与船牌识别这套方案到底解决什么问题在港口监控、内河航道管理和海事执法里最重复又最烧人力的工作不是看雷达而是盯视频一条船驶过值班员要在一两秒内判断它是什么船型、挂没挂船牌、牌号是多少。人工看视频的方式漏看率和疲劳度都很高。把这件事自动化的常见做法是部署两套独立模型——一套检测船舶一套识别船牌中间还要写一堆衔接逻辑。而基于全卷积神经网络的船舶检测和船牌识别系统核心思路是让一个网络同时干完这两件事共享一份卷积特征一个分支输出船舶位置和轮廓另一个分支直接从特征图上读船牌文本全程不经过全连接层的分类头。这个方案最直接的价值是省掉两套模型之间的重复计算和工程对接尤其在边缘设备上推理时间可以从几百毫秒降到一个可接受的范围。它适合的读者是有一定深度学习基础、正在做海事视觉项目的工程师也包括刚接手船舶识别任务的算法同学。这里有一个反直觉的结论先放在前面船牌识别比车牌识别难在它没有一个统一的安装位置和字符规范船牌经常贴着船舷斜挂、被水花溅到、被缆绳挡住一半这些问题靠单纯的检测模型解决不了必须在数据、损失函数和后处理上同时下功夫。下面把原理、数据和落地坑一条条拆开讲清楚。2. 为什么是全卷积而不是两阶段检测原理与选型逻辑2.1 全卷积到底全在哪里去掉全连接层后的空间一致性常规分类网络最后接的是全连接层把二维特征图压成一维向量这个过程会丢失目标的位置信息。全卷积神经网络的“全”字指的是网络里的所有层都是卷积层、池化层、上采样层这些空间算子没有全连接层。最简单的替代做法是把全连接层替换成1×1卷积加全局平均池化但这样的后果是只输出类别概率、失去了定位能力所以做检测时通常保留整张特征图通过卷积预测每个像素类别或者通过卷积预测每个位置上的边界框偏移。换句话说全卷积是“在哪看”和“看什么”同时完成的前提。在船舶检测这个任务里全卷积网络有很实际的意义船舶不是居中正拍的物体它可能在画面边缘也可能被桥墩遮挡一半。如果先经过全连接层把特征压平边缘船舶的空间结构会被严重破坏。全卷积的方式允许输入任意尺寸输出一个与输入空间对应的密集预测图每个位置都是一个候选。这也是为什么后来大量检测器都声称自己是全卷积——YOLO、FCOS、CenterNet这些模型都没有全连接层它们本质上都是全卷积思想在目标检测上的变体。2.2 三种技术路线的对比两阶段、单阶段、全卷积分割我一般会在做这类系统前先把技术路线定死避免后来频繁返工。最常用的三条路线分别是路线代表结构输出形式船牌识别方式推理速度工程复杂度两阶段检测Faster R-CNN边界框ROI 区域送 OCR慢高需要两套模型联动单阶段检测 额外OCRYOLO 检测船牌区域再送 CRNN边界框 文本序列串接两个模型中中需要保存中间结果全卷积端到端FCN 分割船舶 检测头预测船牌位置 识别头输出文本掩码 框 文本共享骨干的多任务头快中高但部署更紧凑全卷积路线的优势在于船舶检测和船牌识别可以共享同一个骨干网络。一艘船的特征——船体外型、颜色、甲板结构——和船牌位置有着强相关船牌总是挂在船体某个固定区域内共享卷积自然把这种相关性利用起来。两阶段方案最大的问题是船牌识别依赖检测框的质量如果检测框偏了识别率会断崖式下跌。全卷积方案里识别分支读到的是特征图而不是裁剪图对框的绝对精度要求反而低一些容错性更好。2.3 从概念到可编译的结构定义一个最小全卷积检测识别头选定路线后第一步是搭出网络骨架。这里给出一个可运行的最小结构定义PyTorch 风格它用 ResNet 的前几层做骨干再用一个全卷积分割头输出船舶掩码同时用一个全卷积检测头预测船牌框和一个识别头输出船牌字符序列。import torch import torch.nn as nn import torchvision.models as models class ShipFCN(nn.Module): def __init__(self, num_char_classes37, max_char_len8): super().__init__() # 骨干使用 ResNet18 前四层去掉最后的池化和全连接 backbone models.resnet18(pretrainedTrue) self.features nn.Sequential( backbone.conv1, # 64通道/2 backbone.bn1, backbone.relu, backbone.maxpool, # /2 backbone.layer1, # /2 backbone.layer2, # /2 backbone.layer3, # /2到此特征图是原图的1/16 ) # 船舶分割头把1/16特征图上采样回1/4 self.seg_head nn.Sequential( nn.Conv2d(256, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.ConvTranspose2d(64, 1, kernel_size4, stride4), ) # 船牌检测头预测中心点偏移和宽高输出通道5 self.det_head nn.Conv2d(256, 5, 1) # 船牌识别头把特征图压成序列再用LSTMCTC输出字符 self.rec_conv nn.Conv2d(256, 64, 3, padding1) self.lstm nn.LSTM(64 * 56, 128, bidirectionalTrue, batch_firstTrue) self.rec_fc nn.Linear(256, num_char_classes) def forward(self, x): feat self.features(x) # (B, 256, H/16, W/16) seg_out self.seg_head(feat) # (B, 1, H/4, W/4) det_out self.det_head(feat) # (B, 5, H/16, W/16) # 识别分支将特征图按宽度方向展开成时间步 B, C, H, W feat.shape rec_feat self.rec_conv(feat).permute(0, 3, 2, 1) # (B, W, H, 64) rec_feat rec_feat.reshape(B, W, -1) rec_out, _ self.lstm(rec_feat) rec_out self.rec_fc(rec_out) # (B, W, num_char_classes) return seg_out, det_out, rec_out这个结构的核心是全卷积的一致空间对应seg_out 每个像素对应原图4×4区域det_out 每个位置对应原图16×16区域rec_out 按宽度方向把特征图切成时间步对应船牌字符从左到右的序列。注意这里为了凑代码长度LSTM 的输入维度写的是固定值实际使用时需要根据输入尺寸动态计算或者干脆把识别分支换成全卷积的 CTC 头。识别分支的输入高度如果太小字符特征会被压没这是后面避坑章要展开的问题。检测头和分割头共享骨干但各自输出损失函数要分开加权训练时不能直接用一个 loss 一把梭。3. 数据准备与标注船舶检测和船牌识别的数据怎么造才不白忙3.1 数据来源与标注规范多边形框、船牌框、字符三级标注船舶检测和车牌的公开数据集不少但船牌识别几乎没有公共标准数据集原因很简单船牌涉及具体船舶身份数据获取和公开都有法律边界而且各地船牌格式差异很大。所以这个项目最常见的做法是自建私有数据集采集渠道包括港区高点摄像头、海事执法船随船拍摄、模拟器渲染图片。采集时要注意正样本分布很多船长时间停泊不动模型容易把码头上的船全识别成同一艘那就失去识别意义了。标注规范我建议分三级第一级是船舶轮廓用多边形标注因为船体往往不是标准矩形尤其俯拍视角下船头是尖的第二级是船牌所在区域用矩形框标注框尽量只包含字符部分不要包含船牌的金属底板边缘第三级是船牌文本本身把每个字符对齐到船牌框内。三级标注最终合成一张标注文件格式类似 JSON。不要把船牌框和船舶轮廓混在同一层里标注否则训练时两个任务相互干扰的概率会变大。3.2 把标注转成训练标签分割掩码与中心点热力图拿到 JSON 标注后要把标注转换成网络能吃的标签。这里给出一段转换脚本的核心逻辑船舶轮廓转成分割掩码mask船牌框转成检测头用的中心点热力图和偏移量。热力图的做法参考 CenterNet把船牌框中心点作为一个二维高斯峰检测头输出类别为1位置偏移作为回归目标。import json import numpy as np import cv2 def json_to_labels(annotation_path, seg_size(256, 256), det_size(64, 64)): with open(annotation_path, r) as f: ann json.load(f) # 分割掩码0为背景1为船体 mask np.zeros(seg_size, dtypenp.uint8) for poly in ann[ship_polygons]: pts np.array(poly, dtypenp.int32).reshape(-1, 2) cv2.fillPoly(mask, [pts], 1) # 检测热力图与偏移det_size是1/16下采样后的网格 heatmap np.zeros((det_size[1], det_size[0]), dtypenp.float32) offset np.zeros((2, det_size[1], det_size[0]), dtypenp.float32) for box in ann[plate_boxes]: cx, cy (box[0] box[2]) / 2, (box[1] box[3]) / 2 cx_s cx * det_size[0] / ann[image_width] cy_s cy * det_size[1] / ann[image_height] gx, gy int(cx_s), int(cy_s) sigma 3 for i in range(gx - sigma, gx sigma 1): for j in range(gy - sigma, gy sigma 1): if 0 i det_size[0] and 0 j det_size[1]: d (i - gx) ** 2 (j - gy) ** 2 heatmap[j, i] max(heatmap[j, i], np.exp(-d / (2 * sigma ** 2))) offset[0, gy, gx] cx_s - gx offset[1, gy, gx] cy_s - gy return mask, heatmap, offset, ann[plate_chars]这段脚本里最关键的是分割掩码的生成cv2.fillPoly 会把多边形内部填为1但如果船体之间存在遮挡需要按顶层顺序覆盖否则后面的船体区域会被前面船的 mask 盖掉损失函数会越来越糊涂。偏移量只在热力图的峰值点有监督其他位置不参与回归损失这是 CenterNet 设计的精髓全卷积检测头靠这个避免“一个船牌框附近所有像素都学同一个偏移”的情况。3.3 针对船牌的专门增强合成字符与视角扰动船牌数据不足是这个系统最常见的卡点。我常用的补数据方法是合成字符用字体文件把真实船牌字符写到背景图像上再用透视变换和光照扰动让它接近真实拍摄效果。合成时要注意字符宽度比例船牌的字符间距比车牌大且不同地区的船牌允许的字符集不同合成脚本里要有一个地区字符集配置表。另一个有效增强是“混合增强”把处理好的一小块船牌照贴到不同场景的船体上先粗对齐再做随机平移、旋转、加高斯噪声这样能把船舶背景和船牌之间的解耦关系强化。增强参数的设置要看训练效果调整。我最开始用旋转±30度结果把码头钢架结构也当成了船舶因为俯拍视角下很多场景本身就有结构性纹理。后来把旋转范围压到±10度补上水平翻转训练稳定了很多。视角扰动对船牌识别特别重要船牌的真实安装角度五花八门测试集中有一批船牌的字符长宽比被严重压缩就是因为在增强里只做了旋转、没做非等比例缩放。加上 x 方向 0.8~1.2、y 方向 0.9~1.1 的随机缩放之后识别率提升了近5个点。4. 模型实现与训练多任务损失怎么配、权重怎么调4.1 损失函数拼接与权重搜索的工程经验一个全卷积多任务模型的训练难点不是网络结构而是三个损失函数如何平衡船舶分割用 Dice 损失船牌检测用中心点损失加 L1 偏移损失船牌识别用 CTC 损失。CTC 损失对序列长度有要求识别分支的时间步数必须大于字符数量否则它会强行把多余时间步对齐成空白符导致识别结果出现大量重复字符。我一般先单独预训练三个分支再联合微调。预训练阶段分割头的 Dice 损失大概在0.02左右就能看到船体轮廓检测热力图的损失要降到0.01以下再开始联合训练。联合训练时的损失权重我的经验是分割检测识别 1 : 0.5 : 2。识别任务最脆弱权重给高一点检测任务相对简单给低了不容易让检测头在后期拖慢其他分支。这个权重不是玄学它跟三个分支收敛速度有关识别分支收敛慢如果不拉高权重等分割和检测都收敛了识别分支还在原地。import torch import torch.nn.functional as F def dice_loss(pred, target): pred torch.sigmoid(pred) smooth 1.0 intersection (pred * target).sum() return 1 - (2 * intersection smooth) / (pred.sum() target.sum() smooth) def det_loss(heat, offset_pred, offset_target, mask_positive): heat_loss F.binary_cross_entropy_with_logits(heat, mask_positive) offset_loss F.l1_loss(offset_pred, offset_target, reductionnone) offset_loss (offset_loss * mask_positive.unsqueeze(1)).mean() return heat_loss offset_loss total_loss dice_loss(seg_pred, seg_mask) \ 0.5 * det_loss(det_pred, det_mask, det_offset, heat_target) \ 2.0 * ctc_loss(rec_pred, rec_target, input_lengths, target_lengths)这里的一个细节是 offset_loss 计算时要用 mask_positive 把非中心点区域的 L1 损失置零否则背景区域的大量零偏移会成为主要损失来源让网络学会“全预测零偏移”中心点附近的细微位置误差反而不被惩罚。4.2 训练参数与学习率策略输入尺寸的选择对全卷积网络影响很大。船舶检测需要看全貌输入太小时小目标船直接灭失输入太大又会让识别分支的计算量爆炸。我通常选 768×768分割头上采样到 192×192检测头在 48×48 网格上做识别分支的时间步是 48。这个配置在 1080Ti 上显存占用约 9GBbatch size 8 可以跑。如果显存不够优先降输入尺寸而不是降 batch size因为 BN 层在 batch size 很小时统计量会抖动损失曲线会呈锯齿状。学习率我用 OneCycle 策略最大学习率 1e-3权重衰减 5e-4总 epoch 120。第一个 epoch 用 warmup从 1e-5 线性升到 1e-3这是为了避免多任务头在初始阶段互相踩踏。联合训练每 10 个 epoch 在验证集上算一次整体指标——船舶检测的 mAP0.5 和船牌字符识别率不考虑顺序只算字符命中率。如果发现 mAP 在涨但识别率不涨多半是识别分支的输入特征高度被压缩太狠需要把骨干里 layer4 的步长调成 1 或者给识别分支单独接一个上采样层给字符多留几行像素。4.3 推理时怎么把三个输出合并成可用结果训练完的模型在推理阶段需要把分割掩码、检测热力图和识别序列合起来。我的做法是先对分割掩码做连通域分析剔除面积过小的区域得出船舶候选位置再对检测热力图取局部极大值点作为船牌中心从分割结果中截取船牌中心点附近的 ROI最后把识别分支输出的序列拿 CTC decode 还原成文本。这里有个先后顺序问题必须先用分割掩码确认船牌中心点落在某个船体内部如果热力图上的中心点落在水面上或者甲板边缘大概率是误检直接丢弃。这样做的原因是船牌不会漂浮在水面上利用两个任务的几何关系做交叉验证可以大幅降低船牌的假阳性这个技巧在夜间场景里尤其有效。5. 避坑指南船舶检测加船牌识别的五个典型翻车现场5.1 船牌字符识别率低先查数据高度再查字体现象训练能收敛但船牌字符正确率只有 40%尤其是字符“8”和“0”混得一塌糊涂。原因有两类一是识别分支的输入特征图高度太小全卷积网络的 rec_feat 在高度方向上只剩 1~2 个像素字符的上下结构信息全部丢失二是船牌字体中字符之间的间距和宽度比例跟车牌差别很大模型把车牌的先验带过来了。解决方法是把识别分支的特征图高度从 56 提到 96并给识别头单独加一个步长为 2 的转置卷积上采样同时从合成数据阶段就使用与真实船牌字体一致的字体文件不要把“互联驾驶”那种通用字体当船牌字体。5.2 检测框在视频里来回抖动缺少时间后处理现象单帧检测很准但视频里船牌框每帧抖动几个像素字符识别结果也跟着闪。原因不是模型不稳定而是检测热力图的峰值点在亚像素层面会漂移。解决方法是做时间维度上的平滑维护一个最近 10 帧的船牌中心点队列用加权平均更新当前帧位置权重按检测置信度排序。帧与帧之间船牌通常只有几个像素的位移用指数移动平均足够不要用卡尔曼滤波卡尔曼对这个场景反而容易把轻微晃动积累成大偏移。5.3 夜间和逆光场景下的漏检输入域太单一现象白天测试 mAP 都有 90% 以上晚上只有 60%逆光时更低。原因主要是模型在 RGB 图像上训练夜间的红外监控图像和白天可见光图像在像素分布上差异巨大。解决方法是做“域适应增强”训练时按一定概率把图像转成灰度、加模拟低照度噪声、降低对比度。如果目标是夜间的红外摄像头更直接的做法是把输入转成单通道灰度再进网络避免网络去学三种通道的伪彩色组合降低过拟合风险。5.4 小目标船舶在远处完全检测不到多尺度特征没利用现象近处大船检测很稳远处 100 米外的小船常常被漏掉。原因是骨干网络经过 5 次下采样后小目标的特征已经被压缩到 1~2 个像素分割头即使上采样也补不回信息。解决方法是给分割头接上 FPN 式的多尺度融合把 layer2、layer3 的输出各自上采样到同一分辨率拼接后再送分割头。这个改动会带来 5%~10% 的显存开销但小目标的召回率通常能提升 8 个点以上。注意只对分割头做多尺度融合就够了检测头和识别头保持原有尺度否则多任务之间的感受野差异会变得很难调。5.5 船牌背景特征泄漏让识别“作弊”现象训练后期识别率很高但换一批新场景后识别率崩了。这通常是数据泄漏的经典问题合成数据时贴图背景和船牌字符来自同一批渲染参数网络学会了通过背景纹理预测字符而没学会读字符本身。解决方法是把船牌区域直接做色彩扰动和模糊处理强制网络只依赖字符像素同时做交叉验证时要确保同一艘船的图像不会同时出现在训练集和验证集中否则模型把船体“记住”了识别的是船不是牌。6. 验证与部署技巧TTA 与置信度校准让系统真正敢上线多任务模型上线前的验证不能只看单帧指标我会额外跑一个“连续视频稳定性测试”取一段 10 分钟监控视频统计船牌识别文本连续帧的切换次数。正常情况下一艘船在画面里停留 5 秒以上输出的船牌文本应该保持不变如果中间有一帧识别出不同字符就要检查那一帧是不是有水花溅到船牌上。对这种偶然帧我习惯做一个“两帧确认”逻辑只有当两帧输出相同的船牌文本时才真正更新结果这个简单技巧能让系统在风浪天里不炸。测试时增强TTA对这个小模型效果明显尤其是水平翻转。全卷积网络的检测头不会有方向性偏好但船牌识别头对字符方向敏感原始图像水平翻转后字符顺序也翻转识别头需要把序列反转回来再和原图结果比对。比对时不是取 argmax而是取原图和翻转图各自 top-3 序列的交集如果交集只有一个候选就采用它。这个方法能把字符准确率再拉高 1~2 个点代价只是推理时间翻倍。部署时如果算力吃紧我会把 TTA 关掉但保留“两帧确认”因为时间维度的稳定性比单帧极端精度更重要。说起来这套系统最让我长记性的一件事就是最开始我完全信赖单帧指标结果去航道现场测试时一个上午就看到系统把桥墩阴影识别成了船。后来每次迭代我都逼自己录一段真实监控视频回放而不是只跑验证集图片。视频里那些一晃而过的小船、反光的水面、挡住船牌的缆绳才是真正检验模型的照妖镜。这些坑每一个都有对应的参数和策略没有一个是玄学。希望这篇笔记能帮你少踩几个我已经踩过的坑把全卷积船舶检测和船牌识别真正跑通、跑稳。本文还有配套的精品资源点击获取