
简介遥感图像分类是地理空间信息处理中的核心任务其目标是从卫星或航拍影像中自动识别土地利用类型。面对小样本高维影像数据直接训练深度神经网络容易过拟合迁移学习因此成为关键技术范式——借助ImageNet预训练权重模型能够复用通用视觉特征并通过微调适配遥感影像的独特纹理与色彩分布。WHU-RS19作为公开的19类遥感图像分类数据集包含约1000张标注样本涵盖机场、农田、河流等多种地物类别是小样本场景下验证分类算法、对比模型性能的理想基准。本文以PyTorch为工具系统展示基于ResNet18的迁移学习完整流程从数据核验、分层划分、增强策略到训练调参并探讨学习率、权重衰减等关键超参的配置方法帮助读者在遥感图像分类任务中快速建立高精度基线模型。1. 遥感卫星图像分类的“小班课”WHU-RS19 这个已标注数据集到底能做什么WHU-RS19 是遥感领域绕不开的一套 19 种土地利用类型图像分类数据集。它只有约 1,000 张已标注遥感卫星图像每张属于机场、森林、农田、河流、居民区等 19 类中的一类单张尺寸集中在 600×600 像素左右。这个规模放在大模型时代显得相当袖珍但对做图像分类的从业者来说它恰恰是验证分类算法、跑通训练流水线、做迁移学习对比实验最合适的试验场数据量小迭代快19 个类别难度足够让算法差异显形。适合三类人刚上手遥感图像分类的新手、想快速验证 CNN 与 Transformer 差异的研究者以及需要公开 baseline 做对比的论文作者。2. 解剖 WHU-RS1919 类标签、文件组织与图像核验脚本拿到任何数据集第一步不是写模型而是核验它。WHU-RS19 的标注以目录结构的形式存在理解这套目录就是理解标签。很多新手一上来直接torchvision.datasets.ImageFolder读数据等训练结束画混淆矩阵时才发现类别顺序和自己想的不一样或者某类图像的通道数有问题这时候返工的成本远高于一开始花十分钟做检查。2.1 19 类土地利用类型到底覆盖了哪些地表场景公开渠道下载到的 WHU-RS19 解压后是一套按类别分目录的 JPG 图像。19 个类别覆盖了建成区、植被、水系与裸地四大地表场景建成区里有机场、港口、停车场、体育场、住宅区、城市街区植被覆盖里有森林、草地、农田水系里有河流与湖泊裸地和线状基础设施里有沙滩、沙漠、高速公路、铁路、桥梁、储油罐等。类别之间的判读难度差异很大。“河流”和“湖泊”在视觉上边界模糊“高速公路”和“铁路”都是线状地物从高空俯瞰容易混淆“密集住宅区”和“高层城市街区”都是密密麻麻的块状纹理。这正好用来检验一个分类算法对纹理、形状和上下文信息的利用能力。像“森林图像分类”这类细分任务在 WHU-RS19 里就是判断深绿色高纹理区域到底属于森林还是草地维度比常规 ImageNet 分类更贴近真实遥感业务。2.2 文件目录结构标注信息就藏在路径里WHU-RS19 最常见的布局是根目录下直接是 19 个子文件夹文件夹名即类别英文名例如airport、forest、port这样的小写形式。每个文件夹内是一批以“类别名加编号”命名的 JPG 图像。也就是说标签不需要查任何 CSV 或 JSON 文件文件夹路径本身就是标注。这也是中等规模学术数据集的典型组织方式和 UC Merced 土地利用数据集、NWPU-RESISC45 的结构一致。个别渠道发布的版本会附带一个 README 或标签对照表内容大致是把英文类名与中文名对应起来。下载后建议先看 README确认类别名与标准名称一致因为有人会把airport写成airplane、把residential写成housing本质不变但会直接影响后面代码里class_to_idx的映射关系。另一个细节是文件夹在磁盘上的排序顺序ImageFolder是按目录名的字典序生成类别编号的所以airport是 0、beach是 1依次类推。如果你之后要手工修改类别索引务必先打印dataset.classes确认。2.3 用 Python 脚本核验图像数量、尺寸与通道数以下脚本会在训练前把三类问题一次查清类别样本量是否均衡、图像尺寸是否统一、通道模式是否有异常。import os from PIL import Image from collections import Counter data_dir WHU-RS19 size_counter Counter() mode_counter Counter() cls_count {} for cls_name in sorted(os.listdir(data_dir)): cls_dir os.path.join(data_dir, cls_name) if not os.path.isdir(cls_dir): continue files [f for f in os.listdir(cls_dir) if f.lower().endswith((.jpg, .jpeg, .png, .tif))] cls_count[cls_name] len(files) for f in files: with Image.open(os.path.join(cls_dir, f)) as img: size_counter[(img.width, img.height)] 1 mode_counter[img.mode] 1 print(每类图像数量:) for name, cnt in sorted(cls_count.items(), keylambda x: -x[1]): print(f {name:16s}: {cnt} 张) print(图像尺寸分布(前5):) for size, cnt in size_counter.most_common(5): print(f {size}: {cnt} 张) print(通道模式分布:, dict(mode_counter))这段脚本的逻辑是遍历根目录下每个子文件夹用 PIL 打开每一张图统计三个维度。执行后你会得到几个关键信息每类是否都接近 50 张、是否所有图都是 600×600、是否存在RGBA或P调色板模式。如果某类只有三十几张后面做数据划分时必须按类分层如果发现RGBA图用transforms.ToTensor()时多出来的第四个通道不会报错但会改变张量形状导致模型输入维度对不上。这类问题在脚本阶段发现只需几秒钟在训练阶段发现则要折腾半小时。3. 跑通第一个图像分类模型ResNet18 迁移学习在 WHU-RS19 上的完整训练代码WHU-RS19 的数据量决定了模型选型方向。19 类、每类约 50 张这个规模从头训练任何现代 CNN 都会严重过拟合而直接套用 ViT 等 Transformer 图像分类模型同样不现实因为这类模型动辄上千万参数需要大规模数据支撑。常见且稳妥的方案是 ResNet18 配合 ImageNet 预训练权重做迁移学习参数量适中、训练速度快、在中小数据集上的表现比大模型更稳定。3.1 数据划分与加载按类别均衡划分训练集和验证集处理这种小数据量数据集最稳的划分方式是按类别分层。WHU-RS19 每类约 50 张8:2 意味着每类验证集约 10 张合计约 190 张足够估算验证准确率。按类循环切分天然就是分层抽样比整批train_test_split更不容易出错。import os import shutil from sklearn.model_selection import train_test_split src_dir WHU-RS19 train_dir WHU-RS19-split/train val_dir WHU-RS19-split/val os.makedirs(train_dir, exist_okTrue) os.makedirs(val_dir, exist_okTrue) for cls_name in os.listdir(src_dir): cls_dir os.path.join(src_dir, cls_name) if not os.path.isdir(cls_dir): continue files [f for f in os.listdir(cls_dir) if f.lower().endswith((.jpg, .jpeg, .png, .tif))] if len(files) 10: print(f警告: {cls_name} 只有 {len(files)} 张图建议人工检查) continue train_files, val_files train_test_split( files, test_size0.2, random_state42 ) os.makedirs(os.path.join(train_dir, cls_name), exist_okTrue) os.makedirs(os.path.join(val_dir, cls_name), exist_okTrue) for f in train_files: shutil.copy2(os.path.join(cls_dir, f), os.path.join(train_dir, cls_name, f)) for f in val_files: shutil.copy2(os.path.join(cls_dir, f), os.path.join(val_dir, cls_name, f)) print(划分完成, 检查各目录数量是否匹配:) for split in [train, val]: total 0 for cls in os.listdir(os.path.join(WHU-RS19-split, split)): cnt len(os.listdir(os.path.join(WHU-RS19-split, split, cls))) total cnt print(f {split}/{cls}: {cnt}) print(f {split} 总数: {total})这段代码的关键是test_size0.2和random_state42。前者控制验证集比例小数据集上 0.2 是常见折中如果项目对泛化能力要求高可以改成 0.3后者固定随机种子保证每次运行划分结果一致。shutil.copy2保留文件元数据避免后续检查时时间戳对不上。这里我习惯直接复制而不是移动文件因为原始数据集最好保持不动划分出的目录只作为训练输入后面想重新划分时删掉重建即可。3.2 模型构建把 ResNet18 改成 19 类输出的三个关键改动使用预训练 ResNet18 时需要改三处替换最后一层全连接、设置分组学习率、确认归一化参数。前两点在代码里体现第三点是隐藏约束下面的代码一并处理。import torch import torch.nn as nn import torchvision.models as models device torch.device(cuda if torch.cuda.is_available() else cpu) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, 19) model model.to(device) # 分组学习率: 预训练 backbone 用小学习率微调, 新加 fc 用大学习率快速收敛 backbone_params [p for n, p in model.named_parameters() if not n.startswith(fc.)] fc_params list(model.fc.parameters()) optimizer torch.optim.AdamW([ {params: backbone_params, lr: 1e-4}, {params: fc_params, lr: 1e-3}, ], weight_decay1e-4)替换fc层是迁移学习标准操作原模型的最终输出是 1000 类ImageNet这里改成 19 类。num_ftrs model.fc.in_features自动取到 ResNet18 全局池化后的 512 维特征维度避免硬编码。分组学习率的思路是预训练过的 backbone 只需微调学习率过大会把学好的低层特征破坏掉新随机初始化的 fc 层需要从零适应数据集分布学习率应该高一个数量级。weight_decay1e-4对千张级数据偏保守但不激进能压制部分过拟合。3.3 训练流程代码优化器、损失函数与检查点保存数据增强和归一化必须与训练循环配套。遥感图像是俯视图不存在自然图像里“上下颠倒”语义翻车的问题所以旋转和翻转增强可以放开用。from torch.utils.data import DataLoader from torchvision import datasets, transforms transform_train transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomRotation(90), transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) transform_val transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) train_ds datasets.ImageFolder(train_dir, transformtransform_train) val_ds datasets.ImageFolder(val_dir, transformtransform_val) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) criterion nn.CrossEntropyLoss() os.makedirs(checkpoints, exist_okTrue) for epoch in range(30): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) avg_loss running_loss / len(train_ds) val_acc correct / total * 100 print(fEpoch {epoch1:02d} | Loss {avg_loss:.4f} | Val Acc {val_acc:.2f}%) torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), val_acc: val_acc, }, fcheckpoints/whu_rs19_epoch{epoch1:02d}.pth)训练循环里有几个值得注意的参数。batch_size32WHU-RS19 约 1000 张32 意味着每个 epoch 约 25 个 batch迭代速度和内存占用都均衡再往上加到 64 也完全可以但小数据集上大 batch 容易让优化过程过于平滑收敛变慢。RandomResizedCrop(224, scale(0.8, 1.0))先 Resize 到 256 再随机裁剪到 224等效于做随机尺度扰动适应遥感影像中地物尺度不一致的特点。RandomRotation(90)允许 0/90/180/270 度旋转遥感俯视图不存在“图像倒了”的问题所以比自然图像里常用的 15 度小角度旋转更激进也更合理。每 30 个 epoch 保存一次 checkpoint小数据集训练速度快保留每个 epoch 的模型可以精确回滚到你认为验证准确率最高的一轮。4. 只有 1,000 张图的调参实战学习率、增强策略与 backbone 组合怎么配第 3 章给的代码能跑通但离“好用”还有距离。WHU-RS19 在 19 类遥感图像分类中属于小样本验证准确率从 70% 到 90% 的差距往往不在模型选型上而在学习率、正则化和数据增强的细节配置上。4.1 为什么迁移学习在 WHU-RS19 上几乎是必选项1000 张训练图、单类 50 张从头训练 ResNet18 会出现典型的过拟合曲线训练损失两三轮内趋近于 0验证准确率卡在 70% 上下不再上升。原因很直白模型容量远大于数据信息量网络把训练集的地表纹理背了下来而没有抽象出“可迁移”的分类规则。ImageNet 预训练权重提供的是通用视觉先验边缘检测、纹理感知、颜色分布建模这些底层能力。遥感俯视图虽然和自然照片存在域差异但低层卷积核依然有效。实际操作里“冻结 backbone 只训 fc”和“全量微调”是两条主流路线。前者适合每类只有一二十张的极端情况后者适合 WHU-RS19 这种每类 50 张的量级。我一般直接走全量微调因为分类器 fc 层很薄瓶颈在特征提取部分全量微调让低层特征往遥感域轻微偏移通常能在 30 轮内多拿 3 到 5 个点的验证准确率。4.2 三个必调参数学习率、weight decay 与 batch size 的相互作用学习率是首要排查对象。用第 3 章的优化器配置如果训练刚开始几轮 loss 不降优先把 backbone 学习率从 1e-4 降到 3e-5而不是盲目加大。预训练权重下的特征已经接近局部最优学习率过大反而跳出好区域。fc 层学习率保持 1e-3这样分类器先快速适应backbone 缓慢跟上整体更稳。weight decay 在小数据集上承担着正则化主力角色。1e-4 是通用起点如果验证准确率在训练中后期出现剧烈震荡可以提到 5e-4。但要注意weight decay 对 AdamW 的作用对象是权重矩阵而非偏置所以它主要压制 fc 层和卷积核的过拟合。batch size 与学习率之间存在换算关系batch 加倍梯度噪声减半等效于学习率可以适当上调。在 WHU-RS19 上 16 与 32 的差距不大但如果把 batch size 降到 8 以下BatchNorm 层的统计量会变得不稳定迁移学习的效果明显变差。如果显存确实紧张解决办法不是硬扛小 batch而是把图像先缩到 160×160 再训练参数量下降但传输层语义不受影响。外部再加一个余弦退火调度器是最常见也最不容易翻车的做法from torch.optim.lr_scheduler import CosineAnnealingLR scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-6) # 每个 epoch 训练结束后调用: # scheduler.step()T_max30与训练轮数对齐学习率从初始值平滑下降到eta_min1e-6。余弦退火的好处是前期保持较高学习率快速探索后期用小学习率精细收敛比固定学习率在 30 轮里普遍高 2 到 4 个点。4.3 数据增强的“够用”原则旋转、翻转与颜色抖动怎么配小数据集增强的目标是制造多样性而不是扭曲语义。WHU-RS19 是卫星俯视图像“河流”这种类别旋转 90 度后依旧是河流语义不变所以角度增强可以做满。但两个操作要克制一是 RandomCrop 的裁剪尺度别太小裁剪掉太多上下文会把“港口”变成“一片海水”二是颜色抖动幅度别过大遥感影像对色调敏感色相偏移过猛会让“森林”和“草地”失去区分度。推荐组合是第 3 章代码里的配置先放大再随机裁剪、90 度旋转、水平垂直翻转、小幅 ColorJitter。这个组合在森林图像分类、农田识别这类子任务上都很稳。对比实验里有人把 ColorJitter 的 saturation 参数调到 0.4训练集准确率反而下降原因是颜色增强过度引入了错误的颜色先验模型学到的是“偏绿森林”这种脆弱特征。增强幅度遵循够用原则saturation 0.2、brightness 0.2、contrast 0.2 就是安全上限。要在 WHU-RS19 上快速判断增强策略是否有效一个土办法是只看验证集 loss增强过强时验证 loss 不降反升增强不足时训练 loss 与验证 loss 差距会超过 20 个百分点。这个差距本身就是过拟合的度量计。5. WHU-RS19 使用避坑五条踩坑记录与排查方向以下五条是我在 WHU-RS19 以及同类切片式遥感数据集上遇到过的问题每一条都按现象、原因、解决三个部分来写。前两条最容易在新手阶段遇到后三条在高强度调参时会更明显。5.1 验证集准确率虚高同源地块切片混进了训练与验证现象某次实验验证准确率达到 96%换一个随机种子重新划分后再训练准确率掉到 86%。同一个模型、同一批参数指标差距大到无法解释。原因WHU-RS19 这类从大场景影像中切出的数据集部分类别的图像可能来自同一个大区域的不同切片。随机划分时这些同一源头但位置相邻的图像被分到了训练集和验证集造成信息泄漏验证结果虚高。解决不要只跑单个随机种子。random_state固定是复现的前提但不能只固定一次就以为拿到了真实水平。我一般跑 3 个不同随机种子报告验证准确率的均值加减标准差。如果标准差超过 2 个百分点说明划分对结果影响过大要么增加验证集比例到 0.3要么按文件名中的序号聚类后再做划分。5.2 图像通道顺序被读反BGR 与 RGB 的经典翻车现象用 OpenCV 的cv2.imread读取图像后直接转成张量训练验证准确率一直在 50% 到 60% 之间徘徊loss 也不降。显示图片时颜色偏蓝偏暗。原因OpenCV 读取的默认通道顺序是 BGR而 torchvision 预训练模型期望的输入是 RGB。BGR 输入等效于把色彩通道做了全局交换模型在颜色上完全错位尤其影响“森林”“农田”这类依赖色调的类别。解决统一加载通道。简单做法是固定用 PIL 读取Image.open(path).convert(RGB)如果项目用了 OpenCV 做预处理在最前面加上cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。这个坑在自然图像数据集上也有但在遥感数据上更隐蔽因为多数人第一反应是模型问题。5.3 类别编号写错从 1 开始数结果把 19 写成了 20现象模型 fc 层输出维度手工写了 20训练正常收敛验证准确率也正常但打印classification_report时发现混淆矩阵只有 18 行或者出现索引越界。原因WHU-RS19 是 19 类但类别名从 0 开始编号第 20 个类别编号实际不存在。很多人在代码里“数了数文件夹”顺手把nn.Linear(num_ftrs, 20)写出来模型能正常收敛因为 MLP 输出维度与训练目标并不要求严格等于类别数。解决永远不手写类别数而是从数据集中读取num_classes len(train_ds.classes) model.fc nn.Linear(num_ftrs, num_classes)ImageFolder.classes是从目录结构自动生成的它的长度就是真实类别数。这个习惯能同时避免“某个类别文件夹是空的”和“数据集版本里混入了 extra 文件夹”两类问题。5.4 验证集里某一类只有 3 张指标震荡到没法判断现象训练过程稳定验证准确率在每个 epoch 之间跳来跳去波动幅度超过 10 个百分点某一轮的“最佳模型”换一轮就不灵了。原因没有按类别分层划分随机切分后某个类别在验证集里只剩个位数样本。比如某类总共 28 张随机切出 5 张做验证错一张准确率就近 20 个点指标自然剧烈震荡。解决使用第 3.1 节的分层切分代码保证每类验证集至少 5 张理想是 8 到 10 张。如果某类确实少到只有 15 张把总验证比例降到 0.15或者干脆对该类不单独划分统一放入训练集并通过 loss 权重补偿。在 WHU-RS19 上正常处理时每类验证集约为 10 张波动能控制在 2 到 3 个点以内。5.5 预训练权重与遥感影像存在域差异ImageNet 初始化不一定稳赢现象加载预训练权重后训练前期收敛很快但第 10 轮左右验证准确率突然停滞甚至小幅回退。重新从随机初始化训练收敛速度更慢但最终验证准确率只低一点点。原因ImageNet 预训练权重来自自然光照片遥感俯视图的色彩分布、地物纹理和自然影像差别很大。虽然低层边缘和纹理滤波器仍有效但深层特征对遥感域的表征能力有限。训练后期backbone 往遥感域偏移此时学习率如果没有同步下降就会在最优解附近震荡。解决按阶段调整学习率是一个有效策略。前 5 轮只训练 fc 层backbone 冻结让分类头先适应预训练特征第 6 轮开始解冻 backbone用 1e-4 的小学习率微调。实现上把requires_grad_设置和优化器构建都放在循环之前5 轮后重建优化器只含 backbone 参数。这个做法在 WHU-RS19 上能稳定多拿 2 到 4 个点比任何数据增强都直接。6. 验收模型不止看准确率用逐类召回率与 Grad-CAM 定位模型的真实能力6.1 混淆矩阵与逐类指标哪几类在相互打架单看验证准确率无法回答“模型哪里没学好”。遥感土地利用类别的混淆具有很强的业务指向性比如“河流”和“湖泊”混在一起说明模型对水体形态的上下文利用不够“高速公路”和“铁路”互相误判说明线状纹理区分度不足。用 scikit-learn 直接打印逐类指标import numpy as np from sklearn.metrics import classification_report all_preds, all_labels [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images images.to(device) preds model(images).argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_namestrain_ds.classes))classification_report输出每个类别的精确率、召回率、F1 值。在 WHU-RS19 上比较典型的规律是“森林”召回率高但“草地”精确率低这是因为两者在绿植覆盖区域的颜色分布接近。逐类看指标比整体准确率更能定位算法的短板。6.2 Grad-CAM 可视化模型到底看的是哪里分类指标揭示了“哪里错”Grad-CAM 则揭示“为什么对”。对一张被正确分类为“港口”的图像如果热力图集中在船舶和集装箱区域模型学到的是合理特征如果热力图集中在海边空白水面那正确分类只是巧合。下面是一段基于钩子的简洁实现import torch def grad_cam(model, input_tensor, target_layer): grads, activations {}, {} def forward_hook(module, input, output): activations[value] output.detach() def backward_hook(module, grad_input, grad_output): grads[value] grad_output[0].detach() hook_f target_layer.register_forward_hook(forward_hook) hook_b target_layer.register_full_backward_hook(backward_hook) model.eval() logits model(input_tensor.unsqueeze(0).to(device)) pred logits.argmax(dim1).item() model.zero_grad() logits[0, pred].backward() hook_f.remove() hook_b.remove() weights torch.mean(grads[value], dim(2, 3), keepdimTrue) cam torch.sum(weights * activations[value], dim1).squeeze(0) cam torch.relu(cam) cam cam - cam.min() cam cam / (cam.max() 1e-6) return cam.cpu().numpy(), pred用法上target_layer一般选model.layer4[-1]也就是最后一个残差块最后一层卷积的输出它包含最高层次的语义特征。register_full_backward_hook拿到的是梯度输出配合前向激活做加权和就是 Grad-CAM 的核心计算。遥感图像的热力图通常会表现出两种问题一是热力图只覆盖图像的某一小块说明模型只用了局部特征对全局空间关系不敏感二是热力图覆盖整个图像且没有明确的高亮区域说明模型没有找到有效判据。这两种情况对应两类不同的改进方向前者需要增大感受野后者需要更强的特征表达。6.3 一个小习惯我在 WHU-RS19 上形成的固定动作是拿到任何遥感图像分类数据集先核验文件结构再跑一个 ResNet18 基线然后立刻画混淆矩阵和 Grad-CAM。这三步半小时搞定但能避免整个调参方向跑偏。比如“森林”和“草地”混淆先去看热力图到底激活在哪里再决定是调数据增强还是换模型结构。这套流程在 WHU-RS19 上跑顺了换到更大规模的遥感数据时同样适用。希望帮到你。本文还有配套的精品资源点击获取