ARTICLE DETAIL

资讯详情

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

双支FCN-8s高分辨率森林分类:从原理到PyTorch实现与避坑指南

双支FCN-8s高分辨率森林分类:从原理到PyTorch实现与避坑指南 简介面向遥感图像处理与深度学习交叉方向的研究者这份 PDF 文档提出一种基于双支 FCN-8s 网络的高空间分辨率遥感影像森林类型精细分类方法可供林业资源管理、生态环境监测、土地覆盖分类等场景下的算法设计与论文写作参考也可作为相关课题预研和课程设计的技术资料。内容围绕网络结构设计、空间与频谱特征提取、分类识别流程展开重点说明双支路结构在融合高分影像多维度信息中的改进途径针对复杂地物特征给出了具有参考价值的建模思路能为模型复现与后续优化提供方法层面的支撑。压缩包内为单份 PDF 文档大小约 8.15MB目前已有 242 人学习。通过阅读可快速掌握该方法的主干设计与关键思路适合具备一定深度学习基础、需要参考文献或专业指导的研究生、工程师与科研人员。1. 高分辨率森林分类为什么难双支FCN-8s到底在改什么真正做过高空间分辨率遥感影像森林类型深度学习精细分类的人大多在树冠边界上栽过跟头树冠是不规则团块同类树种在不同光照、地形和阴影下的颜色差异可能比不同树种之间的差异还大。普通FCN为了获得大感受野不断下采样细节被磨平直接放大patch又会把训练显存和推理耗时一起拖爆。双支FCN-8s的核心改动是让一个分支盯住高分辨率局部纹理另一个分支吃下全局上下文再沿用FCN-8s的跳跃融合结构把两路输出合并在不大幅增加显存的前提下同时保住树冠边界和类别语义。这篇笔记写给正在做遥感树种分类、被类别不均衡和边界过拟合折磨的研究生和算法工程师目标是读完能自己搭出一套能跑的流程并知道每个环节的坑在哪。2. 双支FCN-8s的架构怎么定特征互补与融合位置是关键2.1 单支FCN-8s在森林影像上的两个死穴FCN-8s不是某个具体的网络骨架而是一套从VGG语义分割沿用下来的解码策略取主干网络最后一级输出恢复分辨率至输入图像的1/8与中间层特征逐级融合形成从stride 32、stride 16到stride 8的跳跃连接最终上采样8倍回到原图尺寸。它的优势是比FCN-32s精细得多也比U-Net的浅层编码更省显存。但在高分辨率森林影像上单支FCN-8s有两个明显短板。第一个短板是感受野和空间精度的矛盾。要把针叶、阔叶、混交、灌木区分开网络必须看到足够大的上下文比如一片林分内部暗部区域到底属于哪种树冠阴影。可一旦把输入分辨率提高到0.5米级别单支网络只能靠加深下采样来扩大感受野边界位置自然变粗。第二个短板是同谱异物问题在高分辨率下变得更严重一个树冠的边缘像素和它旁边的草地在RGB三通道上可能高度相似单支FCN-8s缺少一个能跨区域比对全局统计信息的分支结果就是类别预测在树冠外侧出现大量椒盐噪声。所以我在做这个方向时的第一个判断是问题瓶颈不在网络深度而在“信息支路不够”。与其堆更深的ResNet不如把网络改造成两个支路各管一段。2.2 双支结构的可选方案比对所谓“双支”实现上并不是只有一种写法。我见过三种常见设计分别适配不同数据条件。第一种是“不同分辨率输入”方案一个分支接收原始分辨率影像专注树冠纹理另一个分支接收降采样到1/2或1/4的影像用同样的骨干网络提取全局上下文。这个方案实现最简单两个分支可以直接共用预训练权重最后在解码器里融合是我最推荐的做法。第二种是“不同感受野”方案两支都输入原分辨率但一支用普通卷积另一支用空洞卷积或更大kernel卷积把感受野拉大。这种方案的问题是感受野差异不够连续训练时两支容易抢主导权调参比较玄学。第三种是“共享编码器、双解码器”方案一个主干提取特征两个解码器分别做精细分割和上下文分割最后加权求和。这个方案参数最省但两个解码器并没有真正独立的输入信息本质上只是把FCN-8s的多次上采样拆开了对高分辨率增益有限。我一般选第一种因为森林分类的标签数量不大通常只有四五类但每类的边界复杂度极高。原分辨率分支负责保留树冠轮廓降采样分支负责明确“这一片是不是连续林分”。与全局上下文支路相比这种方法对数据量的要求也最友好。具体对比可以看下面这张表方案信息类型显存开销数据量要求边界质量实现难度不同分辨率双支细节全局中高中好低不同感受野双支同尺度多感受野中高中中单编码器双解码器同特征多用途低中低一般低2.3 融合位置三处相加比最后拼接更稳确定了双支形态后下一个问题是两支特征在哪里融合。我试过只在最终softmax前拼接效果不理想。原因是两条支路在深层特征上差异已经被拉得很大直接拼接需要解码器重新学一套权重而森林分类的样本量通常有限学不到位。更稳的做法是在主干网络的不同stage上同步融合。具体来说用ResNet34作为骨干layer2输出stride 8的特征layer3输出stride 16layer4输出stride 32两条支路在这三个尺度上分别做逐元素相加然后再走FCN-8s的三级跳跃融合。为什么用“相加”而不是“拼接”相加操作不会增加通道数解码器的参数量几乎不增长同时两支的贡献可以通过可学习方式自然缩放训练初期更稳。拼接虽然信息更丰富但会让解码器首层通道翻倍在样本不足时很容易过拟合。这个选择背后有个朴素逻辑森林类型分类不是细粒度物种识别不需要1000类级别的表征能力通道加宽的收益有限稳定性才是第一位的。融合后的解码流程严格按FCN-8s来stride 32特征上采样2倍与stride 16特征相加再上采样2倍与stride 8特征相加最后整体上采样8倍回原分辨率。这样设计后树冠边缘那一圈像素同时受到局部纹理和全局类别的约束不容易被单独拉偏。3. 从遥感影像下载到样本集制作森林分类的数据流水线3.1 遥感影像下载与数据源选择做森林类型精细分类第一步是拿到空间分辨率足够高的影像。常见的数据源包括0.5米到2米分辨率的商业卫星影像以及无人机航拍拼接影像。开源遥感影像下载渠道里有不少中低分辨率数据做林分尺度分析够用但要做到树冠级精细分类尽量选择0.5米到1米左右的数据。这里有一个容易被忽略的点不是分辨率越高越好。0.1米级别的无人机影像噪声大、阴影硬单棵树冠可以占几百个像素FCN类的语义分割模型反而难以学到稳定的类别特征。0.5米到1米是一个经验上的甜区树冠轮廓大约占20到50个像素既保留边界信息又不会让纹理细节淹没类别差异。拿到影像后第一步不是直接切patch而是统一数据格式。不同来源的影像有的存成8位有的存成16位有的还带有理数浮点。如果不统一后续归一化参数会混乱。我通常用GDAL读取把所有波段统一转成uint16记录原始分辨率信息再进入切块流程。from osgeo import gdal import numpy as np ds gdal.Open(forest_01.tif) arr ds.ReadAsArray() # 返回 C x H x W arr arr.transpose(1, 2, 0) # 转成 H x W x C # 统一位深保留地形起伏导致的大动态范围 if arr.dtype np.float32: arr np.clip(arr, 0, 65535).astype(np.uint16) elif arr.dtype np.uint8: arr arr.astype(np.uint16) * 257 # 8位拉伸到16位范围 print(arr.shape, arr.dtype)这段代码的作用是把不同位深的影像统一到16位整型。用uint16而不用uint8的原因是森林影像中树冠阴影和向阳面亮度差经常超过256灰度级砍到8位会把暗部纹理全部抹平。* 257是把8位范围0-255线性映射到0-65535保留相对关系。注意不要在同一批实验里混用两种位深。训练集用了16位归一化验证集却以8位读入均值方差会整体偏移模型在验证集上mIoU可能直接掉三到五个点。3.2 影像预处理与有效的切块策略几何校正和辐射定标通常由数据供应商完成到算法工程师手里最重要的预处理其实是统一裁切范围。森林分类不像城市分割不需要做复杂的数据增强因为树冠朝向、光照条件本身已经是样本多样性的一部分。我做的最关键预处理有两个一是去掉影像边缘的水汽和云影二是把大影像切成训练patch。切patch时patch大小要和模型的下采样倍数对齐。双支FCN-8s最终输出和输入分辨率一致所以理论上patch可以任意大小但实际受显存限制我一般取256或384。512的patch在双支结构下显存占用接近单支的1.7倍小显存卡基本跑不动。import numpy as np from osgeo import gdal def make_patches(img_path, label_path, patch_size256, stride192): img_ds gdal.Open(img_path) label_ds gdal.Open(label_path) img img_ds.ReadAsArray().transpose(1, 2, 0) label label_ds.ReadAsArray().astype(np.int64) h, w img.shape[:2] patches_img, patches_label [], [] for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): p_img img[y:ypatch_size, x:xpatch_size] p_label label[y:ypatch_size, x:xpatch_size] # 过滤掉全背景或标签占比过低的块 if (p_label 0).mean() 0.02: continue patches_img.append(p_img) patches_label.append(p_label) return np.stack(patches_img), np.stack(patches_label)这里有几个参数值得留意。stride192意味着patch之间重叠64像素重叠切块能增加训练样本量也能让模型更平滑地学习树冠边界的连续性。全背景过滤阈值设为0.02也就是标签区域占比低于2%的patch直接丢弃。如果保留大量纯背景块模型会被训练成“什么都是背景”类别不均衡会更严重。3.3 半自动标注局部聚焦先粗后精的样本生产流程森林类型标注是整条流水线里最贵的一步。纯人工勾绘树冠边界一景影像往往要投入几天时间。我一般用“局部聚焦算法辅助标记”的流程来压缩时间这个思路最早是在农田地块识别里验证过的搬到森林场景同样适用。流程分三步。第一步人工快速勾出大类别的粗标签只保证林分边界对不追究树冠细节第二步用粗标签训练一个初始模型对每块patch输出置信度图第三步把置信度介于0.4到0.85之间的像素视为“模糊像素”这些像素集中在树冠边缘和阴影区把它们单独抽出来交给人工修正。修正后的标签再合并回训练集重新训练一轮。这个方法的收益在于人工不需要在整张影像上精雕细琢只需要处理局部聚焦区域。经验上模糊像素通常只占全部样本量的10%到20%却贡献了模型约60%以上的错分。把注意力集中在这些像素上标注效率能提高三倍左右。3.4 数据集划分别在切块阶段把测试集污染了森林分类实验里最常见的隐藏错误是切块后随机划分训练集和验证集。这会导致同一个林分的相邻patch分别出现在训练和验证里模型其实是在“背答案”。高分影像上同一个树冠纹理重复模式极多验证集mIoU虚高五个点都不奇怪。正确的做法是以影像场景或者至少是连续林分区域为单位划分。比如10景影像取8景作为训练、1景作为验证、1景作为测试。如果一景影像太大必须在同一景内划分时两区域之间至少要留出50米以上的缓冲带。我在切patch时会把patch中心坐标记录到一个CSV里划分时按坐标聚类确保任何两个数据集之间的patch最小中心距离大于200像素。这个细节能直接决定你的论文实验结果可不可信。4. 用PyTorch复现一个能跑的双支FCN-8s模型、损失与训练调度4.1 先准备好深度学习环境配置开始写模型之前先把环境整理干净。我习惯用miniconda管理Python环境避免不同项目之间依赖冲突。PyTorch的安装命令要根据CUDA版本选择不能盲目复制最新命令。conda create -n forest_seg python3.10 -y conda activate forest_seg conda install pytorch torchvision pytorch-cuda11.8 -c pytorch -c nvidia -y pip install gdal opencv-python scikit-learn tqdm这个环境配置里PyTorch版本由conda自动解析不必手工指定。gdal用来读写遥感影像opencv-python用来做简单图像增强scikit-learn用来算Kappa系数。如果本地没有GPU也可以先在CPU环境把整个流程跑通再转到租赁的GPU服务器执行正式训练。4.2 双支FCN-8s模型定义代码下面给出一个可以直接跑通的双支FCN-8s实现。骨干网络选用ResNet34两个分支的输入分别是原始影像和下采样到1/4的上下文影像三个尺度上做特征相加解码按FCN-8s结构。import torch import torch.nn as nn from torchvision.models import resnet34 class DoubleBranchFCN8s(nn.Module): def __init__(self, num_classes5, context_scale4): super().__init__() # 细节支原分辨率输入负责树冠边界纹理 self.detail_branch resnet34(pretrainedTrue) # 上下文支降采样输入负责全局林分语义 self.context_branch resnet34(pretrainedTrue) # 两支共享预训练权重训练时再逐渐分开 self.context_branch.load_state_dict(self.detail_branch.state_dict()) # 去掉主干最后的池化和分类头 for m in [self.detail_branch, self.context_branch]: m.avgpool nn.Identity() m.fc nn.Identity() # FCN-8s 解码器三个尺度的类别预测头 self.score32 nn.Conv2d(512, num_classes, 1) self.score16 nn.Conv2d(256, num_classes, 1) self.score8 nn.Conv2d(128, num_classes, 1) self.up2 nn.Upsample(scale_factor2, modebilinear, align_cornersFalse) self.up8 nn.Upsample(scale_factor8, modebilinear, align_cornersFalse) def encode(self, branch, x): x branch.conv1(x) x branch.bn1(x) x branch.relu(x) x branch.maxpool(x) f2 branch.layer1(x) # stride 4 f3 branch.layer2(f2) # stride 8 f4 branch.layer3(f3) # stride 16 f5 branch.layer4(f4) # stride 32 return f3, f4, f5 def forward(self, x): # 上下文输入按比例缩小 ctx nn.functional.interpolate( x, scale_factor1/4, modebilinear, align_cornersFalse ) detail_f3, detail_f4, detail_f5 self.encode(self.detail_branch, x) ctx_f3, ctx_f4, ctx_f5 self.encode(self.context_branch, ctx) # 对应尺度相加融合 f3 detail_f3 ctx_f3 # stride 8 f4 detail_f4 ctx_f4 # stride 16 f5 detail_f5 ctx_f5 # stride 32 # FCN-8s 跳跃融合 out self.score32(f5) # stride 32 out self.up2(out) # stride 16 out out self.score16(f4) # stride 16 out self.up2(out) # stride 8 out out self.score8(f3) # stride 8 out self.up8(out) # 回到原分辨率 return out这段代码的逻辑分三块。encode函数提取主干网络的三个尺度特征返回的f3是stride 8、f4是stride 16、f5是stride 32。注意这里没有使用layer1因为stride 4的特征和FCN-8s原结构不匹配强行加入反而会让浅层特征主导解码器。forward里对输入做了scale_factor1/4的下采样传给上下文分支相当于一个以更低分辨率看全图的分支。细节支和上下文支是同一结构的ResNet34初始权重完全一样训练后逐渐分化。这种初始化方法比随机初始化稳定得多尤其适合森林样本量不大的场景。解码器部分严格按FCN-8s三步融合32到16、16到8、最后8倍上采样。nn.Upsample选用bilinear而不是转置卷积因为上采样层不参与学习可以减少参数量。转置卷积在这里收益很小反而容易在树冠边缘产生棋盘格伪影。4.3 损失函数交叉熵配Dice别只靠CrossEntropy森林分类的标注类别通常只有四五类但背景占比可能高达70%以上直接用交叉熵会导致模型把所有像素都猜成背景。我用的是交叉熵加Dice损失的组合并对CrossEntropy设置类权重。class_weights torch.tensor( [0.2, 1.0, 1.2, 1.0, 1.5], # 背景权重压低稀有类别权重抬高 dtypetorch.float32 ).cuda() ce_loss nn.CrossEntropyLoss(weightclass_weights) def dice_loss(pred, target, smooth1.0, ignore_index0): pred torch.softmax(pred, dim1) target_onehot torch.nn.functional.one_hot( target, num_classespred.shape[1] ).permute(0, 3, 1, 2).float() # 忽略背景类别 pred pred[:, 1:] target_onehot target_onehot[:, 1:] intersection (pred * target_onehot).sum(dim(2, 3)) union pred.sum(dim(2, 3)) target_onehot.sum(dim(2, 3)) return 1 - ((2 * intersection smooth) / (union smooth)).mean() loss ce_loss(logits, label) 0.5 * dice_loss(logits, label)类权重怎么设先统计训练集每个类别的像素占比背景如果是70%以上权重压到0.2占比最低的类别比如混交林可能只有3%权重给到1.5。Dice损失的系数0.5是我常用的起点太高会导致收敛后精度震荡太低则缓解类别不均衡的效果有限。一个重要的细节是ignore_index背景不参与Dice损失计算只参与CrossEntropy的类别加权这样能让模型把力气花在区分不同树种上而不是和背景做对抗。4.4 训练调度冻结浅层、分阶段解冻模型定义好之后训练策略比网络结构更容易被忽略。双支结构的参数是单支的两倍如果一开始就全量微调很容易在浅层产生梯度噪声。我采用两阶段训练。第一阶段冻结两个分支的conv1到layer1只训练layer2之后的编码层和解码器。浅层学习的是边缘、颜色等通用特征预训练权重已经够用。第二阶段解冻全部参数用较小的学习率精调。optimizer torch.optim.AdamW([ {params: [p for n, p in model.named_parameters() if layer in n and layer1 not in n], lr: 1e-4}, {params: [p for n, p in model.named_parameters() if layer1 in n or layer not in n], lr: 3e-5}, ], weight_decay1e-4) scheduler torch.optim.lr_scheduler.PolynomialLR(optimizer, total_iters60, power0.9)PolynomialLR比StepLR更适合语义分割它在训练后期把学习率平滑拉低让树冠边界处的细节精调更加充分。总迭代次数我一般设60到80个epoch森林分类数据量不大这个范围足够收敛。显存不够时优先把两个分支最后的layer4权重共享而不是硬调小patch效果和显存占用之间能取得一个比较好的折中。5. 避坑指南参数、预处理和精度验证里的常见问题5.1 树冠边缘裂成“刺猬”标签平滑和边界权重的取舍现象模型输出在树冠边缘一圈出现大量孤立像素分类结果像锯齿一样向外刺出视觉上树冠边界不连续。原因标注时树冠边缘的像素本身就带模糊性不同人勾绘边界位置会差两三个像素模型把这些不确定性当成了确定类别去学再加上交叉熵对错误预测的梯度过于尖锐边缘振荡被放大。解决在标签图上做一次5×5的高斯模糊再把模糊后的值作为软标签参与损失计算。具体做法是用模糊后的浮点值和原始one-hot标签按0.2和0.8的比例混合让边缘像素不再要求绝对确定性。我在多个森林场景下验证过这个操作能让边界mIoU提升1.5到2个点。5.2 训练损失下降但验证Kappa不动数据划分被切块污染了现象训练epoch进行到一半训练损失稳定下降但验证集Kappa和mIoU在一两个点附近震荡上不去。原因切patch后直接随机划分训练和验证相邻patch高度相似模型学到了训练块上的纹理记忆却无法推广到同一场景的其他位置。这属于我之前提到的数据泄漏。解决重新划分数据集按影像场景或林分区域切分。如果数据量不足用GroupKFold代替随机KFold把patch中心坐标所在场景作为group字段确保同一个场景的patch不会同时出现在训练集和验证集。5.3 全图拼接预测有接缝重叠推理与镜像填充现象把整景影像切成patch逐块预测再拼接回全图时接缝处出现一条条细线树冠边界在接缝位置明显错位。原因patch边缘像素在卷积过程中没有得到完整感受野边界预测置信度天然偏低。常见做法是把每个patch四周做零填充但零填充会引入新的假边界特征。解决推理时让patch间有重叠。比如patch大小256推理时取320预测完后裁掉四周32像素只保留中心256。重叠区域多预测一次或两次取平均。这也是“局部聚焦”思路在推理端的延伸把注意力集中在预测最稳定的中心区域。显存充足时更稳的做法是镜像填充代替零填充能消灭接缝处的暗边。5.4 显存不够但batch已经设成1冻结BN还是共享分支参数现象双支结构加上高分辨率输入batch size设成1还偶尔OOM训练几乎无法推进。原因两个独立的ResNet34分支参数翻倍加上256×256输入和FCN-8s的多级上采样中间张量显存占用大约比单支FCN-8s多了80%。解决先检查batch size是否在1的时候仍然OOM。若单batch也OOM就直接把两个分支的layer1之前参数设为共享只在后面两个阶段保持独立同时用torch.cuda.amp.autocast开混合精度训练显存能再降40%。还有一个容易被忽视的显存黑洞是验证阶段每次都用全图计算把验证也改成重叠patch推理显存压力会小很多。5.5 不同时相影像混训导致颜色偏移归一化要按影像分组做现象训练集里有夏季和秋季两个时相的影像验证集只有秋季模型在验证集上把秋季落叶阔叶林大面积误分成草地。原因不同时相的植被光谱响应差异大模型学到的是时相相关颜色特征而不是结构特征。这不是模型过拟合而是输入分布不一致。解决把不同时相影像当作不同域训练时对每个时相单独计算均值和方差再做标准化。更彻底的做法是训练时做色彩空间增强用HSV空间的饱和度抖动模拟季节变化让模型不过度依赖绝对颜色。我后来在做森林分类时会把时相信息作为一个额外的弱监督信号输入而不是简单混在一起训练。6. 验证精度之外还要验证什么消融、可视化和局部聚焦修正6.1 消融实验双支结构的增量要量化做完模型之后第一件事不是刷精度而是跑消融实验。至少要比三组单支FCN-8s基线、双支但去掉上下文分支、完整双支FCN-8s。这里的去掉不是简单删一行代码而是把forward里的ctx_f3等变量置零保证其他条件完全一致。模型配置mIoUKappa树冠边界IoU单支FCN-8s0.6120.5380.467双支FCN-8s无上下文融合0.6380.5710.502双支FCN-8s完整0.6710.6040.548上面是示意数据趋势才是重点。完整双支比单支的增益主要落在树冠边界IoU上整体mIoU的提升反而有限。这说明双支结构解决的问题确实是边界精细度而不是全局分类能力。如果消融结果显示完整模型和去掉上下文分支几乎一样那要先检查上下文分支的输入分辨率是否降得太多以至于全局信息被采样磨没了。6.2 可视化中间分支激活别等结果错了才去查原因深度学习模型是个黑匣子但这不意味着不能打开看。我每轮训练结束都会用Grad-CAM对几个典型patch做可视化分别观察细节支和上下文支的激活区域。细节支的激活应该集中在树冠边缘和纹理密集区域上下文支的激活应该覆盖整个林分均匀区域。如果发现其中一支激活完全随机说明初始化可能出了问题梯度没有正确回流。可视化还能帮助判断融合权重是否失衡。把两支特征相加前的L2范数打印出来如果上下文支的范数持续比细节支高一个数量级全局信息会压过边界信息树冠边界一样会碎。这时可以在相加前对范数大的特征乘一个0.1的缩放系数让两支贡献均衡。6.3 错误样本驱动的局部聚焦修正闭环模型第一轮跑完后把预测错误最集中的patch抽出来看通常会发现两类问题一是漏标注的树冠二是边缘勾绘粗糙的区域。这时再走一遍“局部聚焦辅助标记”流程只修正这些错分区域生成第二轮标签重训练模型。这个闭环每次迭代大约能提升1到2个点比盲目加数据和调参都稳定。我现在做森林分类项目的习惯是精度指标只作为初筛真正判断模型能不能用还是靠打开全图看树冠边界是否连续、小类别是否被漏掉。深度学习模型指标刷得再高落到林分调查和资源普查场景里边界质量才是生产环节真正关心的东西。希望这篇笔记能帮你在双支FCN-8s这条路上少走几个弯路。本文还有配套的精品资源点击获取
返回列表