☰
WHU-RS19遥感分类数据集实战指南:CNN训练与避坑要点
2026/9/28 16:34:26 网站建设 项目流程

简介:WHU-RS19土地利用分类数据集是一份面向遥感图像分类研究与深度学习初学者的已标注数据集,覆盖机场、海滩、桥梁、商业区、沙漠、农田等19种土地利用类型,共约1000张遥感图像。压缩包内文件总计1016个,主体为1006张jpg图片,并按类别划分好训练集、测试集,可直接用于分类模型训练;另含1个py可视化脚本与1个json类别配置文件,方便查看图像与标签映射,少量db文件为系统缩略图缓存,不影响使用。整包大小约100MB,兼顾规模与下载成本。当前已有199人浏览学习,较适合作为CNN等图像分类任务的入门实验数据。借助配套show脚本可快速浏览样本,结合作者相关分类项目,还能进一步衔接YOLO检测与图像分割等扩展任务。

1. 为什么把WHU-RS19当作遥感分类入门数据集:1000张图也能练出能用的模型

做遥感图像分类的人,最先被劝退的往往不是模型,而是数据。自己从高分影像里抠机场、抠农田,一套流程下来眼睛都快瞎了。WHU-RS19这个数据集的价值在于它把这层门槛直接压低:19种土地利用类型,从机场、海滩、桥梁到商业区、沙漠、农田,约1000张已标注的遥感卫星图像,并且预先切好了训练集和测试集。你拿到的不是一堆裸图,而是能直接喂给分类模型的标准化数据。刚入门的人拿它跑通全流程,有经验的人把它当作算法验证基准,看一个改进的CNN在遥感场景里到底能涨几个点。

2. 数据集内部结构:19类地物怎么组织、标签从哪看

2.1 类别构成与目录组织

WHU-RS19的19个类别覆盖了遥感土地利用分类里最常见的形态:airport、beach、bridge、commercial、desert、farmland,后边还有football field、forest、industrial、meadow、mountain、park、parking lot、pond、port、railway station、residential、river、viaduct。这些类别在视觉上差异很大——机场有长条跑道和停机坪,农田呈现规则的几何块状纹理,商业区建筑物密集排列,河流与海滩主要靠色彩和走向区分。真正考验模型的不是“河 vs 山”这种大差异类别,而是商业区与居住区这种在影像上高度接近的组合。

我拿到的版本里图片尺寸是统一的256×256 RGB影像,这也是遥感分类最常见的输入尺寸。如果你的包是512×512甚至更大,训练前最好统一缩放,否则数据加载器会报形状不匹配。解压后目录结构大致如下:

WHU-RS19/ ├── train/ │ ├── airport/ │ │ ├── airport_001.jpg │ │ └── ... │ ├── beach/ │ ├── bridge/ │ └── ... (共19个类别文件夹) ├── test/ │ ├── airport/ │ └── ... ├── labels.json └── show.py

train和test各自独立,按类别建子文件夹,同一类图片放在一个目录里。这种组织方式配合torchvision的ImageFolder可以直接加载,不需要像目标检测那样解析xml或txt标注。之前跑过YOLO系列的人应该清楚,目标检测要把图像和txt标注文件拼在一起做dataset,而ImageFolder方式省掉了组装这一层,目录即标注。

每个类别样本数上,约1000张图分到19类,平均每类50张上下,但不会精确相等,有的类多一些、有的类少一些。这种小规模不平衡在训练时会影响损失函数的走向,具体处理办法在第5章展开。另外解压包的根目录里可能会直接躺着Mountain_31.jpg、railwayStation_31.jpg这种示例图片,那是作者放出来预览的样图,不在train/test目录里,也不计入训练样本,看到不用困惑。

注意:不同渠道下载到的压缩包结构可能有细微差别,有的版本会把train和test合并成一个目录。先列目录再写代码,不要上来就假设路径。

遥感影像的成像机制也值得在这里点一句:WHU-RS19选用的全是光学遥感影像,没有SAR或高光谱数据。这意味着所有类别都能直接用RGB三通道的视觉特征做区分,不需要设计多通道输入。如果换成ICVL高光谱数据集那种带几十个波段的格式,数据加载和预处理是另一套复杂流程。对做CNN分类的人来说,这个数据集最大的好处就是可以把它当成普通RGB图像任务来跑。

2.2 labels.json:数字标签的权威来源

类别名称和数字标签的映射存放在labels.json里,常见内容如下:

{ "airport": 0, "beach": 1, "bridge": 2, "commercial": 3, "desert": 4, "farmland": 5 }

这个文件的顺序很关键。如果数字编号不是按字母序给出的,直接使用ImageFolder自动生成的class_to_idx就会与它不一致。训练时模型学的是0号类别是airport、1号是beach,推理时如果索引错位,输出类别就会整体平移,准确率再高也是错的。

我一般会先把labels.json读进来,按value排好序得到一个classes列表:

import json # 读取标签映射文件 with open('labels.json', 'r', encoding='utf-8') as f: label_map = json.load(f) # 按数字标签从小到大排列类别名,保证与训练标签严格对齐 classes = sorted(label_map.keys(), key=lambda x: label_map[x]) print(classes) print(len(classes)) # 期望输出 19

这段代码先读取JSON,再把类别名按数字标签从小到大的顺序排列,得到一个与训练标签严格对齐的列表。之后无论是设置模型分类头还是推理后处理,都用这个classes列表做转换,避免手写硬编码。如果labels.json里有些类别没有对应文件夹,或者文件夹是空的,这一步也会暴露出来。

2.3 Thumbs.db:一个需要认识的“假文件”

解压后还会看到不少名字叫Thumbs.db的文件混在图片目录里。这是Windows系统自动生成的缩略图缓存,是系统在文件夹里预览图片时自动写出来的,跟数据集本身没有任何关系,里面没有标签、没有额外元数据。第一次在Windows下解压数据集,系统会在每个图片目录生成它,大小通常只有十几KB。

有同学第一眼看到它,以为这是什么加密标注信息,对着它研究了一晚上。处理方式只有一个:删掉或忽略。用ImageFolder或PIL按扩展名加载图片时,它不会被读进去。不过如果你后续要重新打包数据集做二次分发,建议先全盘搜一遍Thumbs.db清掉再压缩,能省去使用者大量困惑。

实际经验里还有一个隐蔽问题:Windows压缩再传到Linux服务器解压时,Thumbs.db会原样保留下来,虽然不影响训练,但会在数据集目录里多出一堆无用小文件,占用inode。我一般会在服务端先跑一条find . -name "Thumbs.db" -delete,一秒钟清干净。这个文件就像压缩包里的一块口香糖,不碍事,但粘着难受。

提示:JSON里类别顺序决定了数字标签编号。后续做迁移学习、替换分类头时,数字标签映射必须与训练时保持一致,否则模型会错得莫名其妙。

3. 可视化脚本与训练/测试划分:先跑通show脚本再动手建模

3.1 运行show脚本:先看数据再写模型

包里自带的可视化脚本(show.py)是判断数据集可用性的第一关。它的作用是把每个类别的样本拼成网格图显示,同时验证图片能否正常读取。我在拿到任何数据集时,第一步永远是跑这种脚本,先亲眼确认类别长什么样,再考虑模型结构。

脚本核心逻辑通常是:

import os import matplotlib.pyplot as plt from PIL import Image train_dir = 'train' # 读取所有类别文件夹名并排序 classes = sorted(os.listdir(train_dir)) fig, axes = plt.subplots(4, 5, figsize=(15, 12)) axes = axes.flatten() for idx, cls in enumerate(classes): cls_dir = os.path.join(train_dir, cls) img_name = os.listdir(cls_dir)[0] # 取每类第一张图 img = Image.open(os.path.join(cls_dir, img_name)) axes[idx].imshow(img) axes[idx].set_title(cls) axes[idx].axis('off') plt.tight_layout() plt.show()

代码逻辑是把train目录下的所有类别名取出来排序,创建一个4×5的网格,每个类别取第一张图显示,标题就是类别名。跑完之后你能一眼看出19类数据的真实样貌、有没有损坏图片、哪些类别之间容易混淆。如果运行时报“No module named matplotlib/PIL”,先执行pip install matplotlib pillow把依赖补上,不要一上来就怀疑代码。

这个脚本还起到一个作用:验证PIL与当前环境的兼容性。有些数据集里的图片虽然是.jpg后缀,但实际编码可能是PNG或WebP,用PIL的Image.open能自动兼容大部分格式,但如果换成cv2.imread,碰到异常编码就会返回None。先跑Python可视化而不是直接用OpenCV去读,能少踩一个暗坑。

3.2 训练集与测试集的划分意义

WHU-RS19在压缩包里就已经分好了train和test,这个划分应该直接被使用,不建议自己重新随机切分。遥感数据如果来自同一区域或同一时段的影像,两张图之间存在空间相关性。自己随机切分,极可能把高度相似的照片同时分进训练集和测试集,测试精度看着很高,一到新的影像区域就拉胯。

数据集划分方式对最终评估的影响可以看这两个方向:

划分方式优点风险
使用包预设划分避免空间相关性泄漏,结果可复现无法自定义比例
自行随机划分灵活控制训练比例相似影像跨集合,精度虚高

从影像来源看,WHU-RS19的图像大多来自Google Earth和卫星遥感平台,同一类别下的图片可能来自不同城市、不同时间。这种情况下,按文件名随机划分的风险不只是空间相关性,还有时间相关性。比如同一片区域的农田在春夏两季的影像颜色完全不同,如果两季影像被拆进train和test,模型可能因为没见过另一季的颜色而误判。作者预设的划分把这些因素都考虑进去了,这是自己随机切分无法替代的。

我拿到的版本里训练样本约占总样本的75%,测试约占25%。具体比例以你实际解压为准,但无论比例多少,评估模型时都以test目录上的准确率为准,train的指标只作训练过程的参考,不作结论。

3.3 类别顺序对齐:训练前必须做的一步

在训练开始之前,先把类别名和数字标签严格对齐。用上一节读进来的classes列表就可以。这一步看着不起眼,却是后续很多问题的根源。我以前帮人排查过一个项目,准确率怎么调都上不去,最后发现是classes列表和加载器索引不一致,整个训练都在错位状态下进行。

训练代码里可以加两行断言:

# 类别顺序对齐校验 assert len(classes) == 19, f"类别数量不对,当前为{len(classes)}" assert os.path.exists('train/airport'), "train目录结构异常"

第一行判断标签映射是否完整,第二行判断目录是否正常。这两个断言能在第一时间暴露包结构问题,避免后续几小时的无用功。如果你拿到的是别人二次打包过的版本,类别文件夹名可能被改成中文,这时候classes列表也要跟着改。

类别顺序对齐这件事,本质上是在建立“文件夹名、JSON数字标签、模型输出节点、可视化标题”四者之间的单射关系,任何一环脱钩,整个评估都会失真。我在实际项目里习惯把classes列表序列化成一个classes.json存下来,模型保存时放在同一个目录,推理时再读回来。这样即使过了几个月再加载模型,标签语义也不会丢。

4. 用CNN跑一个分类基线:从数据加载到训练闭环

4.1 数据加载与增强策略

包内图片是RGB三通道的普通JPG,用torchvision的ImageFolder可以直接加载:

from torchvision import datasets, transforms # 训练集:随机水平翻转 + 小角度旋转做增强 train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 测试集:只做缩放和归一化,不做随机增强,保证评估可复现 test_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder(root='train', transform=train_transform) test_dataset = datasets.ImageFolder(root='test', transform=test_transform) train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=16, shuffle=True, num_workers=2) test_loader = torch.utils.data.DataLoader( test_dataset, batch_size=16, shuffle=False, num_workers=2)

这里最关键的一点:训练集和测试集的transform必须分开。训练集加RandomHorizontalFlip和RandomRotation做增强,测试集只保留resize和归一化,不做任何随机操作,否则测试结果每次执行都不同,没法复现。mean和std用了ImageNet标准值,如果训练时loss在初期剧烈震荡,可以算一下这个数据集自己的均值和方差替换进去,不过多数组网在WHU-RS19上直接用ImageNet的归一化参数也能收敛。

另一个容易踩的坑是num_workers。Windows下num_workers大于0偶尔会触发DataLoader的worker报错,这跟数据集本身无关。遇到这种问题直接把num_workers改成0,程序就能正常跑。

4.2 模型结构与训练参数

只有1000张图,从头训练ResNet18不是不行,但效果上限低。常见做法是加载ImageNet预训练权重,把最后一层全连接换成19类输出:

import torch import torch.nn as nn from torchvision import models device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 加载预训练ResNet18,替换分类头为19类 model = models.resnet18(pretrained=True) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 19) model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)

这些参数展开说一下。lr=1e-4是小数据集上Adam比较稳妥的起点,调大到5e-4可能让早期loss波动加剧,调小到1e-5收敛会变慢。batch_size=16对1000张图的数据量正合适,显存不够就降到8,不要在batch size上硬扛。StepLR每10个epoch把学习率乘0.1,让后期优化步长变小。CrossEntropyLoss内部自带softmax,不要在网络最后一层再拼一个Softmax,否则梯度会出问题。

训练循环写成标准epoch轮转:

num_epochs = 25 for epoch in range(num_epochs): model.train() # 切换训练模式 total_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() total_loss += loss.item() # 每个epoch结束后在测试集上评估一次 model.eval() # 关闭dropout和batchnorm的训练行为 correct, total = 0, 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / len(train_loader) test_acc = correct / total print(f'Epoch {epoch+1}/{num_epochs}, ' f'loss={avg_loss:.4f}, test_acc={test_acc:.4f}') scheduler.step()

这段循环里有几个细节值得注意。avg_loss用total_loss除以batch数量而不是样本总数,这样出来的是每个batch的平均损失,方便跨epoch对比。模型测试时必须用model.eval(),不写的话测试结果会被随机性污染。外层包着torch.no_grad(),告诉框架不要为推理计算梯度,省显存也提速。

训练初期的loss合理范围在1.5到2.5之间,因为19个类别均匀分布时的熵约为2.94。如果loss很快降到0.01以下但test_acc还在80%以下徘徊,多半是过拟合在起作用,第5章会专门讲。

4.3 验证集还是测试集:小数据集的取舍

严格从流程上讲,应该从train里再分出一部分当验证集来调参,test只留作最终评估。但WHU-RS19总共约1000张,再分验证集会挤占训练样本。我的习惯是直接拿test当验证集,训练过程中每个epoch都看test准确率,选最终模型时也参考它。这个做法在标准流程里不严谨,但在千级样本的数据集实战里能多留出10%-15%的训练数据,收益更实际。如果你的目的是发论文或者参加正式评测,还是应该老老实实按train/val/test三份来划分。

5. 避坑专项:标签错位、类别失衡和过拟合三个经典问题

5.1 标签错位:训练时一切正常,推理时全部对不上

现象:训练loss正常下降,测试准确率表面不差,但单独拿一张图推理,输出的类别和实际内容对不上,而且错得很有规律,总是固定偏移到某个位置。

原因:训练过程中使用了不一致的标签映射。最常见的是labels.json里的顺序和ImageFolder扫描文件夹生成的顺序不一致。ImageFolder默认按文件夹名字母序生成索引,而JSON里的数字编号不一定是字母序。模型学到的0号类别是某个文件夹名,而你心里以为的0号是JSON里的第一个key,推理时全部错位。

解决:训练前把classes列表固定下来,之后一直沿用:

import json from torchvision import datasets with open('labels.json', 'r', encoding='utf-8') as f: label_map = json.load(f) classes = sorted(label_map.keys(), key=lambda x: label_map[x]) dataset = datasets.ImageFolder(root='train') # 检查ImageFolder索引与JSON映射是否一致 assert list(dataset.classes) == classes, f"映射不一致: {dataset.classes} vs {classes}"

如果断言失败,最快的处理办法是给文件夹加数字前缀,让字母序和数字序天然一致。比如把airport改成00_airport、beach改成01_beach,这样ImageFolder扫描出来的顺序就和JSON对齐了。改文件夹名看似粗暴,但能一劳永逸,不用每次训练都纠结映射关系。

5.2 类别不均衡:整体准确率虚高,少数类几乎不识别

现象:测试准确率显示85%,但逐个类别看召回率,某些类只有20%多,样本少的类别在混淆矩阵里几乎一整行都是0。

原因:约1000张图分到19类,平均每类50张,但实际分布不齐。样本多的类别在损失中占比更大,模型偏向拟合它们,少样本类的梯度在反向传播中被淹没。

解决:给CrossEntropyLoss加权重,按类别样本数的倒数计算,让少样本类获得更高关注:

import os import numpy as np import torch.nn as nn # 统计每个类别的样本数 counts = np.array([len(os.listdir(f'train/{cls}')) for cls in classes], dtype=np.float32) # 按样本数倒数计算权重,少样本类获得更高损失权重 weights = 1.0 / counts weights = weights / weights.sum() weights = torch.tensor(weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=weights)

权重归一化之后,样本数最少的类别获得的总梯度贡献和其他类别基本持平。但要提醒,加了权重后整体准确率可能不升反降,因为模型从“偏向多数类”变成“兼顾少数类”,这是正常的。评估时请多看per-class的平均准确率或F1,而不是只看整体acc。

5.3 过拟合:训练集99%,测试集70%

现象:训练集准确率很快冲到99%,测试集却停在70%上下,两个数字之间的差距随epoch越拉越大。

原因:模型参数量远超数据量。ResNet18有一千多万参数,而训练数据只有不到一千张,模型把训练集里的背景、噪声、拍摄角度这些与类别无关的细节全部记住了。

解决:先上数据增强,再降学习率,最后考虑换小模型。对WHU-RS19,有效的增强组合是RandomHorizontalFlip加RandomRotation(15)加ColorJitter(0.2, 0.2, 0.2),然后把Adam的学习率降到1e-5,epoch数控制在25以内。如果还不行,把网络换成MobileNetV3-Large或EfficientNet-B0,参数量小,在千级数据集上反而更容易收敛。

这里有个增强的边界问题。RandomRotation如果设成90的倍数,会改变图像朝向,遥感影像虽然不像普通照片那样对方向极度敏感,但停车场、港口这类类别旋转90度后人眼都可能认错。所以遥感分类的旋转增强我一般控制在±15度,不是随便照搬ImageNet的增强参数。

6. 进阶:迁移学习组合拳与混淆矩阵验证

6.1 冻结主干与分层学习率

第4章的baseline已经用了ImageNet预训练权重,但还可以更进一步。ResNet18主干虽然初始化得好,但微调时如果所有层都跟着小数据集的梯度大幅更新,预训练学到的基础特征会被破坏。常见做法是冻结前几层,只训练靠近分类头的Block和全连接层。优化器也可以分层设置学习率,主干用1e-5,分类头用1e-3,各自按需要更新。

6.2 混淆矩阵:找出真正分不清的类别对

训练结束打印混淆矩阵,这一步在遥感分类里特别重要。19个类别之间存在自然的易混淆组合,比如park和forest、commercial和residential。混淆矩阵能直接告诉你哪些类别互相认错,从而判断是数据问题、模型问题还是标注问题。

import torch import numpy as np from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for images, labels in test_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) plt.figure(figsize=(12, 10)) plt.imshow(cm, cmap='Blues') plt.colorbar() plt.xticks(range(19), classes, rotation=45, ha='right') plt.yticks(range(19), classes) plt.tight_layout() plt.show()

如果某些类别对混淆严重,比如park大面积预测成forest,优先考虑把这两类的训练样本做针对性扩充,多裁剪局部区域,而不是盲目调整模型结构。这比换网络、加层来得直接。

从那以后,我每次拿到分类数据集都强制按同一套顺序走:先跑可视化脚本确认目录和图片可读性,再对齐labels.json的类别顺序,最后才进入训练阶段。这套流程帮我挡掉了至少三回标签错位引发的翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询