基于SRCNN的哨兵2号遥感影像超分重建:从10米到2.5米实战
做遥感的人多少都经历过这种尴尬哨兵2号影像免费、覆盖广、更新勤但10米分辨率放在县级尺度还好用一放大到村镇级就糊成一片道路、田块、房子边界全靠猜。想用高分商业影像钱又不答应。于是不少人把目光投向了超分重建——用深度学习模型把10米影像“脑补”成2.5米。我最近用SRCNN把这个流程完整跑了一遍从下载哨兵2数据、做训练集、训练模型到输出带坐标的2.5米GeoTIFF全程没花一分钱买数据。这篇文章就记录一下整个项目的思路、代码和踩过的坑给同样想做遥感影像增强的读者一个可复制的起点。先说结论SRCNN这种2014年的老模型放到今天依然很适合遥感超分场景尤其是哨兵2这种“量大管饱但分辨率不够细”的数据源。它结构极简训练成本低CPU都能推理而且效果比直接bicubic放大扎实得多。当然超分不是无中生有它更像是在影像先验约束下的纹理增强和边缘重建——10米像素里被模糊掉的信息模型会按“看起来合理”的方式补回来所以适合目视解译和特征提取不适合拿去做像元级定量反演。这个边界心里要有数。1. 项目整体设计与思路拆解1.1 为什么选SRCNN而不是更花哨的模型SRCNN是2014年提出的超分重建开山之作核心思路非常简单先用bicubic把低分辨率图放大到目标尺寸再让三个卷积层去学习从“插值放大图”到“高清图”的映射关系。放在今天这个结构比SRGAN、ESRGAN、SwinIR落后不少但用在哨兵2号这种场景里它有几个实实在在的好处。第一模型轻。SRCNN参数不到十万训练起来特别快一张普通显卡几分钟就能跑一个epoch甚至纯CPU也能在几小时内完成训练。哨兵2影像动辄上万乘上万像素推理阶段要分块处理轻量模型在这里是巨大优势——不用堆显卡普通办公电脑就能批处理整景影像。第二稳定性好。遥感影像不像自然图像那样“错一个像素无所谓”超分结果会被拿去叠图、分类、量算。SRCNN的训练目标是MSE损失输出天然偏“保守”不会像GAN类模型那样凭空生成让人惊艳但不可信的细节。对我这种要拿结果做分析的人来说保守往往是优点。第三对初学者友好。如果你是第一次接触深度学习遥感应用SRCNN几乎是完美的练手项目。我之前在《动手学深度学习》里刷过CNN相关章节真正自己复现一个模型时还是从这种经典结构入手最有踏实感。等流程跑通了后续想换ESRGAN、SwinIR也只是替换网络主体的事数据流和工程框架都不用大改。1.2 10米到2.5米的数据闭环怎么设计这里有个绕不开的问题超分训练需要成对的低分辨率/高分辨率样本哨兵2号本身没有2.5米真值那训练标签从哪来我在项目里试了两条路也推荐大家按场景选择。方案A降尺度模拟训练本文实操方案。做法是先把10米影像当作“高清真值”用bicubic下采样4倍得到40米影像再把40米影像上采样回10米作为低分辨率输入训练SRCNN把输入重建回原始的10米影像。推理阶段对真实的10米哨兵2影像做同样的bicubic上采样到2.5米网格送进模型得到超分结果。这个方案的好处是零成本、可快速验证但要说清楚一点训练时的数据对是“40米→10米”推理时是“10米→2.5米”相对尺度都是4倍绝对尺度不同属于尺度外推。实际效果通常不错因为bicubic退化模型的一致性很好模型学到的主要是高频先验和边缘锐化换一个绝对尺度依然适用。但严格讲这不等于真实4倍信息恢复。方案B高分辨率参考数据监督。如果项目对精度要求高可以用同一区域的Planet 3米、SPOT 6/7或航空正射影像作为HR参考哨兵2的10米波段作为LR输入配对训练。这个方案更严谨但数据不一定免费而且跨传感器影像之间存在几何配准误差、大气差异和时相差异预处理工作量会明显增大。对比维度方案A降尺度模拟方案B外部高分参考数据成本零成本可能需要购买或申请是否需配准不需要需要严格几何配准训练可控性高退化模型已知低受传感器差异影响结果意义视觉增强、边缘锐化更接近真实信息恢复适合场景快速出图、算法原型定量反演、高精度制图我这次以方案A为主线因为它最容易在没预算的前提下跑通全流程。如果你手头正好有高分参考影像把训练数据替换一下就行模型代码完全不用动。2. 核心细节解析与实操要点2.1 SRCNN模型结构到底在学什么SRCNN的原始结构只有三层卷积我用PyTorch重写了一份。需要注意训练和推理时输入图像都要先经过bicubic插值放大到目标尺寸网络做的是“插值结果到高清结果”的残差式修正。import torch import torch.nn as nn class SRCNN(nn.Module): def __init__(self, num_channels3): super(SRCNN, self).__init__() # 特征提取 self.conv1 nn.Conv2d(num_channels, 64, kernel_size9, padding4) # 非线性映射 self.conv2 nn.Conv2d(64, 32, kernel_size1, padding0) # 重建 self.conv3 nn.Conv2d(32, num_channels, kernel_size5, padding2) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.conv1(x)) x self.relu(self.conv2(x)) x self.conv3(x) return x三个卷积层分工明确第一层9×9卷积感受野大负责从低分辨率输入上提取局部边缘、纹理等特征第二层1×1卷积做跨通道特征融合相当于把一个高维特征向量映射到更高层的语义空间第三层5×5卷积把特征图重新组合成RGB图像。整个过程可以理解成一个可学习的“高清化滤镜”。原始论文里用的是valid卷积输出尺寸会比输入小一圈所以训练时要对标签做中心裁剪。我在代码里用same padding保持尺寸不变效果没有实质差别还省去裁剪的麻烦。损失函数就是最标准的MSE衡量的是预测图和真值之间的逐像素误差。2.2 数据准备从下载哨兵2影像到制作训练集数据源方面我推荐直接从Copernicus Data Space下载L2A级产品也就是已经做过大气校正的地表反射率数据。它的10米波段包括B2蓝、B3绿、B4红和B8近红外做真彩色合成用B2/B3/B4就够了。下载后先检查云量挑云量小于10%的景。拿到影像后的第一步是用rasterio读取把需要的波段打包成一个三通道影像。这里有个细节L2A地表反射率的有效值范围是0到10000无效区域如云掩膜、边缘通常是0或NaN训练前必须处理。我一般把小于0或大于10000的像素统一置为0并在裁剪patch时跳过有效像元占比低于95%的块避免模型学到一堆无意义的空洞纹理。制作训练集的核心代码大概长这样import numpy as np import rasterio from torch.utils.data import Dataset def load_rgb_bands(path): with rasterio.open(path) as src: b2 src.read(1).astype(np.float32) b3 src.read(2).astype(np.float32) b4 src.read(3).astype(np.float32) # 反射率量纲转成0~1方便网络收敛 rgb np.stack([b2, b3, b4], axis0) / 10000.0 return np.clip(rgb, 0.0, 1.0) def make_hr_lr_pair(hr_patch, scale4): # hr_patch: (C, H, W)直接用双三次下采样再放大构造低分辨率输入 import torch.nn.functional as F hr torch.from_numpy(hr_patch).unsqueeze(0) lr F.interpolate(hr, scale_factor1.0/scale, modebicubic, align_cornersFalse) lr_up F.interpolate(lr, scale_factorscale, modebicubic, align_cornersFalse) return lr_up, hr裁剪patch时我习惯用64×64或128×128尺寸。patch太小学不到大范围纹理关系patch太大训练慢且显存压力大。为了增强泛化性每块patch都做随机翻转和90度旋转相当于把数据量翻了8倍。如果手里有五六景不同地貌的哨兵2影像全部裁完能得到几万对训练块对SRCNN这种小模型来说完全够用。3. 实操过程与核心环节实现3.1 训练SRCNN的完整代码与参数选择训练配置我直接给出可复用的参数都是从实际效果里调出来的经验值新手照抄问题不大输入patch64×64×3batch size32优化器Adam初始学习率1e-4损失函数MSEepoch60到80最后一个阶段学习率降到1e-5验证集单独留一景影像的patch不参与训练训练主循环的核心代码import torch.optim as optim from torch.utils.data import DataLoader model SRCNN(num_channels3).cuda() optimizer optim.Adam(model.parameters(), lr1e-4) criterion nn.MSELoss() for epoch in range(epochs): total_loss 0.0 for lr_up, hr in train_loader: lr_up, hr lr_up.cuda(), hr.cuda() optimizer.zero_grad() sr model(lr_up) loss criterion(sr, hr) loss.backward() optimizer.step() total_loss loss.item() if (epoch 1) % 20 0: # 验证集上看一下PSNR防止过拟合到训练区 val_psnr evaluate(model, val_loader) print(fEpoch {epoch1}, Loss: {total_loss/len(train_loader):.4f}, Val PSNR: {val_psnr:.2f} dB)训练过程中你会发现MSE loss下降得很快前10个epoch就能看到明显效果后面进入缓慢优化阶段。这种“大头在前”的特征正是小模型的好处——不需要像大模型那样烧几十万步。一个比较容易踩的坑是训练集与验证集的划分必须按“景”来分。如果你从同一景影像的不同位置裁patch一部分训练一部分验证验证指标会虚高很多因为模型已经见过同一个漏检区域或同一种地物纹理了。我后来改成用两景完全不同的影像做验证PSNR和SSIM才变得有参考意义。3.2 推理单景哨兵2影像超分与GeoTIFF输出训练好的模型要应用到整景哨兵2影像上最忌讳的做法是直接把整张影像一次性送进网络。一万像素乘一万像素的输入显存瞬间爆炸。正确做法是分块推理每块256×256带一定重叠区域最后做平均融合避免拼接缝。另外GeoTIFF的地理坐标信息必须在整个流程中保持住。我的做法是先用rasterio读取原影像的transform然后把影像分块处理每块的超分结果写回对应的高分辨率网格位置。import rasterio import numpy as np import torch from rasterio.transform import Affine from rasterio.enums import Resampling def inference_geotiff(model, input_path, output_path, block_size256, overlap16): with rasterio.open(input_path) as src: height, width src.height * 4, src.width * 4 transform src.transform * src.transform.scale( src.width / width, src.height / height ) profile src.profile.copy() profile.update(heightheight, widthwidth, transformtransform) model.eval() result np.zeros((src.count, height, width), dtypenp.float32) with rasterio.open(output_path, w, **profile) as dst: for y in range(0, src.height * 4, block_size - overlap): for x in range(0, src.width * 4, block_size - overlap): # 从原影像按窗口读取再放大到目标网格 win rasterio.windows.Window( x // 4, y // 4, min(block_size // 4 overlap // 4, src.width - x // 4), min(block_size // 4 overlap // 4, src.height - y // 4) ) block src.read(windowwin, out_shape( src.count, win.height * 4, win.width * 4 ), resamplingResampling.bilinear) block_in torch.from_numpy(block / 10000.0).unsqueeze(0).float() with torch.no_grad(): out model(block_in.cuda()).cpu().numpy()[0] * 10000.0 # 写回重叠区域直接取均值 ...实际写代码时重叠区域的融合建议用距离权重也就是越靠近块中心权重越高块边缘权重越低。这样可以显著减少拼接缝。如果嫌麻烦一个简单的平均也能用只要overlap取16个像素以上。输出GeoTIFF后可以在QGIS里直接叠加到原始10米影像上对比查看。最直观的效果是道路边界更锐利农田地块之间的田埂分界线更清楚城镇区域屋顶轮廓有明显改善。3.3 效果怎么评估有参考指标和无参考指标评估这一步我分两种情况说。如果你的验证集是模拟数据比如把10米降采样到40米再超分回10米可以直接用PSNR和SSIM做有参考评估。代码很简单from skimage.metrics import peak_signal_noise_ratio as psnr from skimage.metrics import structural_similarity as ssim score_psnr psnr(hr_image, sr_image, data_range1.0) score_ssim ssim(hr_image, sr_image, channel_axis0, data_range1.0)我实测下来在模拟数据上SRCNN通常比bicubic高0.8到1.5dB PSNRSSIM提升在0.01到0.03左右。这个差距在数值上看起来不大但目视效果差异却非常明显尤其是高频细节区域。如果是真实影像的10米到2.5米超分没有真值就需要无参考清晰度指标。最常用的是拉普拉斯方差Variance of Laplacian它衡量图像梯度能量的强弱值越大说明边缘越锐利。另一个是Tenengrad梯度基于Sobel算子计算。这类指标不能说明“恢复的信息是对的”但可以说明“图像变清晰了”。import cv2 def variance_of_laplacian(img): return cv2.Laplacian(img, cv2.CV_64F).var()更重要的是目视抽查。我通常挑三类区域做对比道路交叉口、农田边界、城镇建筑轮廓。如果这些地方的边缘比bicubic放大结果更干净利落而且没有出现明显的伪纹理或者扭曲这个模型就算合格。4. 常见问题与排查技巧实录4.1 新手最容易翻车的5个问题跑这个项目时我踩了不少坑有些问题折腾了大半天才找到原因。这里整理成一张速查表希望后面的人能少走弯路。问题现象根本原因解决办法训练loss不下降归一化不一致或学习率过大输入和标签统一除以10000把学习率降到1e-4输出图像整体偏灰反射率值域被模型压到0~1之间过窄训练前做min-max或固定区间归一化推理后做反向映射色彩严重偏色每个通道独立归一化破坏了光谱比例三通道用同一组最大最小值统一缩放大图拼接处有接缝分块推理没有重叠或没做融合重叠16~32像素边缘做线性衰减融合云和阴影区域出现伪纹理训练集混入了太多无效值根据云掩膜剔除无效patch推理时可对云区跳过超分这里面最隐蔽的是第一个坑。SRCNN对输入值域非常敏感输入是0到1的浮点标签却是0到10000的整数loss会瞬间爆炸或者根本不收敛。我后来统一把反射率除以10000再clip到0到1模型才正常起来。4.2 一些比较深的实操心得项目跑通之后回头总结有几个经验想重点分享。第一个经验是别一上来就做4倍超分。我最初直接做10米到2.5米总感觉边缘不够干净。后来改成先做2倍10米到5米目视效果进步非常明显再叠加一次2倍超分到2.5米比单次4倍更稳定。当然分两次推理会多花一点时间但换来的是更好的边缘质量特别适合哨兵2这种本身纹理信息就不算丰富的数据源。第二个经验是训练数据的地物类型一定要和目标区域匹配。我用城区影像训练后去推农田区田块边界普遍发虚反过来用农田区训练去推城区屋顶纹理又会出现奇怪的振铃。解决方案很简单多收集几景不同地貌的影像混着训练。SRCNN吃数据给它五六种典型地貌泛化性会明显提升。第三个经验是超分结果适合做“视觉增强底图”不适合直接当测绘成果。10米波段本身的光谱信息有限超分重建只能优化空间纹理没法凭空创造真实的高频细节。我一般把超分影像作为辅助图层叠加在原始10米影像上做半透明显示既保留了原始光谱可靠性又能利用锐利边缘辅助判读。5. 总结与一些个人体会这个项目我从数据准备到最终出图大约花了三个晚上其中大部分时间都耗在了数据清洗和调试归一化上真正写模型反而很快。SRCNN之所以到现在还有生命力是因为它在“工程稳定性”和“效果提升”之间找到了一个很实用的平衡点。做遥感应用的朋友如果只是想让现有免费影像在目视层面更好用完全不需要一上来就上生成对抗网络SRCNN这套轻量方案在多数场景里已经够用。我个人在实际操作中最满意的一点是这个流程完全可重复、不依赖昂贵硬件。训练一晚上推理一景影像半小时全程免费数据、开源工具。如果后续想做更深度的增强可以考虑把网络换成EDSR或SwinIR再配合感知损失细节真实度会进一步提升。但作为第一步先用SRCNN把数据管线、训练流程、评估方案跑通价值已经很大。最后再分享一个汇报小技巧把超分结果做成滑动对比图或者透明叠加图左边是bicubic放大右边是SRCNN结果领导或者甲方一眼就能看出差别。技术指标说得再多都不如一张直观的对比图有说服力。