简介:《基于全卷积神经网络的船舶检测和船牌识别系统》是一份便携式文档格式的学术论文资源,面向深度学习、计算机视觉及智慧港口应用场景,适合研究生、算法工程师和相关领域研究者学习参考。论文针对船舶轮廓复杂、船牌位置不固定、文本类型多样等实际挑战,提出基于全卷积神经网络(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_name="resnet50", out_stride=16): super().__init__() # 新版 torchvision 建议用 weights 参数代替 pretrained,二选一即可 base = tvmodels.resnet50(pretrained=True) 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_classes=1): super().__init__() hidden = 256 self.shared = nn.Sequential( nn.Conv2d(in_channels, hidden, 3, padding=1), nn.ReLU(inplace=True), ) 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, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d((2, 2)), # 高度压到 16 nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d((2, 1)), # 只压高度,不压宽度,保留时序长度 ) self.blstm = nn.LSTM(128, 64, bidirectional=True, batch_first=True) 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(dim=2) # 高度方向池化 -> 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, alpha=2, beta=4) 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, blank=0) 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(limit=15, border_mode=0, value=0, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5), A.GaussBlur(blur_limit=(3, 7), p=0.2), A.HorizontalFlip(p=0.0), # 默认关掉,原因见下文 A.RandomSizedBBoxSafeCrop(height=512, width=512, p=0.5), ], bbox_params=A.BboxParams(format="pascal_voc", label_fields=["labels"]))LongestMaxSize和PadIfNeeded的组合保证输入尺寸固定,不足的地方补黑边,避免船被压变形。RandomSizedBBoxSafeCrop随机裁一小块再放大,等于模拟船时远时近,这是提升小船召回最有效的一招。HorizontalFlip默认关掉,是因为水平翻转虽然能增加船的姿态变化,但船牌上的字符也会跟着镜像,训练识别头时字符就全反了;如果要用,必须同时把字符顺序反转,前后端都要处理对,麻烦大于收益,我后来直接弃用了。
这里说一句数据量的底线:船舶检测部分,带ship框的图至少准备 2000 帧以上,小目标比例最好占三成;船牌识别部分,不同船牌号的样本至少 500 条,太少了字符集覆盖不全,0 和 O、1 和 I 这类混淆对根本学不出来。这条线以下,模型再折腾也白搭。
3.3 检测训练循环:损失叠加与参数基线
检测训练循环比想象中简单,难的是把三路损失接对。下面这段是训练骨架,只看关键部分:
optimizer = torch.optim.AdamW(params, lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=100, eta_min=1e-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, stride=1, padding=1) mask = (hm == peaks) & (hm > 0.3) ys, xs = torch.nonzero(mask[0, 0], as_tuple=True) # 3. 从峰值点解码出船体框(回归量以 stride=16 为单位) 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 显存爆掉被迫调小 batch,loss 反而开始震荡
现象:ResNet50 主干加双头,输入 1024,batch size 设为 16,训练刚开始就直接爆显存;改成 2 之后能跑,但 loss 曲线大幅抖动,热力图分支始终收敛不上去。
原因:batch 从 16 降到 2,BatchNorm 的均值和方差估计噪声太大,小目标检测对这种统计波动尤其敏感,不是模型本身有问题。
解决:第一,batch 保持 8,开启混合精度训练,显存基本能减半;第二,还想再稳一点,就用梯度累积,每 4 个 step 更新一次优化器,等效 batch 还是 32。改完之后 loss 曲线立刻平滑,训练时间也基本可接受。以后碰到显存问题,先动精度和梯度累积,不要先砍 batch。
5. 进阶:双尺度推理与分组验证,仿真结果才敢信
模型能跑通只是第一步,生产环境里真正比拼的是漏检率和识别置信度。这里给两个直接见效的技巧,以及一套验证习惯。
5.1 用 3×3 局部最大池化提峰值,再按置信度分档过滤
热力图峰值提取不要用简单的阈值加连通域,我在推理代码里用F.max_pool2d(hm, 3, stride=1, padding=1)得到一个局部最大值图,与原始热力图相等的位置才是真正的峰值点,这一步能避免同一艘船在相邻像素上产生重复框。阈值不要定死一个,按置信度分两档:0.3 用于远距离小船召回,0.7 用于高置信度常规船;低阈值段的检测结果在后续识别阶段如果置信度低,可以单独标记人工复核,这样既保召回又不污染识别结果。
5.2 双尺度推理:整图检测和大图切块,按 IoU 合并
对 1080p 以上监控画面,先整体缩放到 512 做一遍全局检测,主要抓住大船;再按 512 窗口以 256 步长切块、每块放大到 1024 做一遍局部检测,抓小船。两类检测结果按置信度排序后做非极大值抑制,IoU 阈值取 0.5,重合度过高的框保留置信度高的一侧。这个方案实测能让远距离小船召回提升十个百分点左右,代价是推理时间大约翻倍,适合离线分析场景,实时场景再用单尺度加高分辨率输入替代。
5.3 按距离和字符长度分组验证,指标才不会骗人
最后提一个评估习惯。不要只看一个总 mAP 和总字符准确率,把验证结果按船的像素面积分成大、中、小三组,分别看检测召回;识别结果按船牌字符长度分组,看每组的字准率。这样哪个环节拖后腿一眼就能看到,调参也有明确方向。我每次迭代完都会顺手把漏检图按“太远、遮挡、亮度异常”三类分到三个文件夹,回放一遍再决定下一步动数据还是动模型。
这套方向做到最后你会发现,网络结构带来的提升是有上限的,真正拉开差距的是数据组织的严谨性和验证方法能不能反映真实场景。我自己在这些项目里栽过几个跟头之后,养成的习惯是先花两三天把数据划分和评估脚本做扎实,再投入到调网络里去,这个顺序希望也能帮到你。
本文还有配套的精品资源,点击获取