☰
PyTorch高光谱数据加载实战:Dataset与DataLoader封装及避坑指南
2026/11/2 11:40:55 网站建设 项目流程

简介:面向使用 PyTorch 做深度学习、需要处理高光谱图像(HSI)的开发者与学习者,这份资源聚焦一个核心痛点:如何用 DataLoader 高效加载与预处理数百乃至上千光谱通道的高光谱数据。包内共 7 个文件,以 3 个 py 脚本为主,辅以 2 个 pyc 编译文件与 2 个 mat 数据文件,压缩包约 5.69MB,体量轻便,便于直接运行与二次修改。内容围绕自定义 Dataset 类展开,涵盖数据读取、归一化等预处理、批大小设置、多线程加载、随机打乱与数据增强、pin_memory 加速以及自定义 collate_fn 等关键环节,并配有 IndianPines 训练脚本与工具模块,可帮助读者快速搭建从数据到模型输入的完整链路,理解高光谱数据加载中的内存与效率权衡。目前已有 613 人学习,适合希望少走弯路、直接复用可运行代码的实践者参考。

1. 高光谱数据加载这件事,为什么值得单独拆一遍

第一次接手高光谱分类任务时,我以为最麻烦的是模型结构,结果真正卡住进度的是数据读取。高光谱影像一个样本动辄几百个波段,文件格式还分.mat、.tif、.hdr好几套,直接往Dataset里塞numpy数组,训练两轮就内存爆掉;换成懒加载,又在__getitem__里反复读盘,GPU 利用率低得可怜。这个项目要解决的就是这件事:用 PyTorch 的Dataset和DataLoader把高光谱数据组织成可迭代的批次,让训练循环拿到的是规整的(B, C, H, W)张量,而不是一堆散落的原始文件。适合正在做高光谱分类、目标检测,或者手里有一批.mat标注数据、想接进 PyTorch 训练流程的开发者。下面按「数据长什么样 → 怎么封装 → 怎么调参 → 怎么改」的顺序拆。

2. 数据组织与 Dataset 封装:从原始文件到可迭代批次

2.1 先看清高光谱数据的形状和存储方式

高光谱数据和普通 RGB 图像最大的差别在通道维度。一张 RGB 图是(3, H, W),高光谱影像常见的是(H, W, B),B 从几十到几百不等,比如某公开数据集是 145×145 空间尺寸、200 个波段。存储上,学术数据集多用 MATLAB 的.mat文件,里面通常有两个变量:一个是数据立方体,一个是地物标注的标签图(ground truth)。工程里也有用 ENVI 格式的,一个二进制数据文件配一个.hdr头文件描述波段数和数据类型。

我一般会先写个脚本把数据摸清楚,而不是直接开写Dataset:

import scipy.io as sio import numpy as np # 读取 .mat 文件,先看里面有哪些变量 mat = sio.loadmat('data/xxx.mat') for key, value in mat.items(): if not key.startswith('__'): # 跳过 MATLAB 元信息字段 print(key, type(value), getattr(value, 'shape', None)) # 假设数据变量叫 data,标签变量叫 gt data = mat['data'].astype(np.float32) # (H, W, B) gt = mat['gt'].astype(np.int64) # (H, W),0 表示未标注 print('波段数:', data.shape[-1], '类别数:', len(np.unique(gt)) - 1)

这段代码的作用是确认三件事:数据立方体的维度顺序、标签图的取值范围、未标注像素用什么值表示。参数上,astype(np.float32)是为了后续归一化和送进网络时避免类型转换开销;gt转成整型是因为它要当分类标签用。很多.mat文件里标签是uint8,直接拿去算交叉熵会报类型不匹配,这个坑后面还会提。

2.2 Dataset 的两种写法:整图加载和 patch 采样

高光谱分类有两种主流输入方式。一种是整图输入,把(H, W, B)整个送进网络,适合全卷积结构;另一种是 patch 采样,以每个标注像素为中心切一个p×p的小块,适合 CNN 分类器。项目里更常见的是后者,因为整图输入对显存要求高,而且标注像素往往只占一小部分。

先看整图版本的Dataset,结构最直白:

import torch from torch.utils.data import Dataset class HyperspectralDataset(Dataset): def __init__(self, data_path, transform=None): mat = sio.loadmat(data_path) self.data = mat['data'].astype(np.float32) # (H, W, B) self.labels = mat['gt'].astype(np.int64) # (H, W) self.transform = transform # 只保留有标注的像素坐标,0 视为未标注 self.coords = np.argwhere(self.labels > 0) def __len__(self): return len(self.coords) def __getitem__(self, idx): row, col = self.coords[idx] # 取单个像素的光谱向量,形状 (B,) spectrum = self.data[row, col, :] label = self.labels[row, col] - 1 # 标签从 0 开始 spectrum = torch.from_numpy(spectrum) if self.transform: spectrum = self.transform(spectrum) return spectrum, torch.tensor(label, dtype=torch.long)

逻辑上,__init__里一次性把整个数据立方体读进内存,coords记录所有有效像素的位置,__len__返回的是有效像素数而不是图像像素总数。__getitem__按坐标取光谱向量,标签减 1 是因为原始标注通常从 1 开始编号,而 PyTorch 的CrossEntropyLoss要求类别索引从 0 开始。参数transform留成可调用对象,方便后面接归一化或数据增强。

patch 版本在此基础上多一步切片:

class PatchDataset(Dataset): def __init__(self, data_path, patch_size=11): mat = sio.loadmat(data_path) self.data = mat['data'].astype(np.float32) self.labels = mat['gt'].astype(np.int64) self.patch_size = patch_size self.half = patch_size // 2 self.coords = np.argwhere(self.labels > 0) def __len__(self): return len(self.coords) def __getitem__(self, idx): row, col = self.coords[idx] # 边界处用 0 填充,保证 patch 尺寸固定 padded = np.pad( self.data, ((self.half, self.half), (self.half, self.half), (0, 0)), mode='constant' ) patch = padded[row:row + self.patch_size, col:col + self.patch_size, :] # 转成 (B, p, p),符合 PyTorch 卷积的通道优先约定 patch = np.transpose(patch, (2, 0, 1)) label = self.labels[row, col] - 1 return torch.from_numpy(patch), torch.tensor(label, dtype=torch.long)

这里的关键是np.pad和np.transpose。高光谱数据是通道在后,而 PyTorch 的Conv2d要求通道在前,所以必须转置成(B, p, p)。patch_size一般取奇数,11、13、15 都常见,取奇数是为了让中心像素正好落在 patch 正中。边界像素用 0 填充是常见做法,也有用镜像填充的,区别在于边缘样本的分布是否自然,这个后面在避坑章节展开。

2.3 DataLoader 的批次组装与多进程读取

Dataset只负责单个样本,真正把样本拼成批次的是DataLoader。高光谱数据读取慢,num_workers设成大于 0 能明显提速,但要注意 Windows 下的多进程限制。

from torch.utils.data import DataLoader, random_split dataset = PatchDataset('data/xxx.mat', patch_size=11) # 按 8:2 划分训练集和验证集 train_size = int(0.8 * len(dataset)) val_size = len(dataset) - train_size train_set, val_set = random_split(dataset, [train_size, val_size]) train_loader = DataLoader( train_set, batch_size=64, shuffle=True, # 训练集打乱,验证集不打乱 num_workers=4, # 子进程数,按 CPU 核数调整 pin_memory=True, # 锁页内存,加速 CPU 到 GPU 的拷贝 drop_last=True # 丢弃最后一个不满批次,避免 BatchNorm 抖动 ) val_loader = DataLoader(val_set, batch_size=64, shuffle=False, num_workers=4)

参数逐个说:batch_size受显存限制,patch 尺寸 11、200 波段时,64 一般够用;shuffle=True只在训练集开,验证集打乱会让指标波动;num_workers不是越大越好,超过 CPU 物理核数反而因为进程切换变慢;pin_memory=True配合.cuda()使用才有意义;drop_last=True在样本数不能被批次整除时,避免最后一个批次过小影响批归一化统计。这套配置跑起来,训练循环里拿到的就是(64, 200, 11, 11)的张量,直接喂给卷积网络即可。

3. 训练循环接入:让 DataLoader 真正跑起来

3.1 一个最小可运行的训练骨架

数据管道搭好后,要验证它是否真的能驱动训练。我习惯先写一个极简训练循环,不追求精度,只确认张量形状、标签范围、损失是否下降。

import torch.nn as nn import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 一个极简的 3D 卷积分类器,仅用于验证数据管道 class TinyNet(nn.Module): def __init__(self, in_bands, num_classes): super().__init__() self.conv = nn.Conv3d(1, 16, kernel_size=3, padding=1) self.pool = nn.AdaptiveAvgPool3d(1) self.fc = nn.Linear(16, num_classes) def forward(self, x): # x: (B, C, H, W) -> (B, 1, C, H, W) x = x.unsqueeze(1) x = torch.relu(self.conv(x)) x = self.pool(x).flatten(1) return self.fc(x) model = TinyNet(in_bands=200, num_classes=16).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) for epoch in range(5): model.train() for patches, labels in train_loader: patches, labels = patches.to(device), labels.to(device) optimizer.zero_grad() logits = model(patches) loss = criterion(logits, labels) loss.backward() optimizer.step() print(f'epoch {epoch}, loss {loss.item():.4f}')

这段代码的重点不在网络,而在数据流:patches从DataLoader出来是(B, C, H, W),进网络前unsqueeze(1)补一个维度变成 3D 卷积需要的(B, 1, C, H, W)。num_classes要和标签最大值对齐,标签范围错了会直接抛IndexError。跑通这五轮,说明Dataset、DataLoader、设备搬运这条链路是通的,后面换真正的网络结构只是替换TinyNet。

3.2 归一化放在哪一层,差别很大

高光谱数据的数值范围因传感器而异,有的反射率在 0 到 1 之间,有的原始 DN 值到几千。归一化放错位置是常见翻车点。放在Dataset.__getitem__里,每个样本单独算均值和方差,样本间不一致;放在DataLoader之后、进网络之前,按批次算,训练和推理行为不一致。

我一般用训练集全局统计量,在Dataset初始化时算好:

class NormalizedPatchDataset(PatchDataset): def __init__(self, data_path, patch_size=11): super().__init__(data_path, patch_size) # 用所有有效像素算每个波段的均值和标准差 valid = self.data[self.labels > 0] # (N, B) self.mean = valid.mean(axis=0) # (B,) self.std = valid.std(axis=0) + 1e-8 # 防止除零 def __getitem__(self, idx): patch, label = super().__getitem__(idx) # patch: (B, p, p),按波段归一化 mean = torch.from_numpy(self.mean).view(-1, 1, 1) std = torch.from_numpy(self.std).view(-1, 1, 1) return (patch - mean) / std, label

view(-1, 1, 1)是把(B,)的统计量广播到(B, p, p)上,逐波段减均值除标准差。1e-8是数值稳定项,某些波段方差极小,不加会出inf。统计量只用训练集算,验证集和测试集复用同一组,否则就是数据泄漏,指标会虚高。

3.3 用一个小脚本验证批次形状和标签分布

正式训练前,我习惯单独跑一段检查脚本,把DataLoader的第一个批次打印出来,确认形状、类型、标签范围都对。

patches, labels = next(iter(train_loader)) print('批次形状:', patches.shape) # 期望 (B, C, p, p) print('数据类型:', patches.dtype) # 期望 float32 print('标签范围:', labels.min().item(), labels.max().item()) print('标签分布:', torch.bincount(labels).tolist())

形状不对,多半是transpose漏了或者unsqueeze位置错了;标签范围超出num_classes,说明减 1 没做或者原始标签有异常值;标签分布极度不均,说明类别采样需要加权,可以在DataLoader里加sampler。这一步花两分钟,能省掉后面调半天损失不降的玄学排查。

4. 避坑与排查:高光谱 DataLoader 的五个血泪经验

4.1 内存爆掉:整图加载遇上大影像

现象是程序刚启动就被系统杀掉,或者报MemoryError。原因在__init__里把整个数据立方体读进内存,几百波段、上千像素的影像轻松上 GB,再乘上num_workers个子进程各自持有一份副本,内存直接翻倍。解决办法有两个:一是把num_workers降到 0 或 1,牺牲速度换内存;二是改成懒加载,__init__只存文件路径和坐标,__getitem__里用h5py或memmap按需读取。我一般选后者,把数据预处理成 HDF5 格式,读取时只取需要的 patch 区域。

4.2 多进程在 Windows 下卡死

现象是num_workers > 0时程序挂起,没有任何报错。原因是 Windows 用spawn方式启动子进程,Dataset里的对象必须可序列化,而且主模块要有if __name__ == '__main__':保护。解决方式是把训练入口包进main()函数,并在文件顶部加保护;如果Dataset里存了文件句柄或数据库连接,也要改成在__getitem__里按需创建。Linux 下用fork不会遇到这个问题,所以同一份代码换平台就翻车,很典型。

4.3 标签从 1 开始导致 CrossEntropyLoss 越界

现象是训练第一步就报IndexError: Target X is out of bounds。原因是原始标注图里类别编号是 1 到 N,而CrossEntropyLoss要求 0 到 N-1。解决方式是在__getitem__里统一减 1,并且确认未标注像素(通常是 0)已经被coords过滤掉。如果数据里还有 255 这类无效值,也要一并排除,否则减 1 之后变成 254,照样越界。

4.4 归一化统计量用了全量数据

现象是验证集精度高得离谱,换测试集就崩。原因是归一化的均值和方差在包含验证集的全量数据上算的,等于把测试信息泄漏进了训练。解决方式是严格只用训练集的有效像素算统计量,验证集和测试集复用。划分数据集要在算统计量之前做,顺序反了就是白算。

4.5 patch 边界填充方式影响边缘样本

现象是图像边缘的像素分类精度明显低于中心区域。原因是边界 patch 用 0 填充,引入了大量非真实值,网络在边缘学到的分布和中心不一致。解决方式可以改用镜像填充(mode='reflect'),或者干脆在采样时丢弃距离边界小于half的像素。前者保留样本量,后者更干净,看任务对边缘精度的要求取舍。

5. 二次开发:把数据管道改造成自己的形状

拿到这份代码后,最常改的地方是数据格式适配和采样策略。如果手里的数据是 ENVI 格式而不是.mat,把sio.loadmat换成spectral库或gdal读取即可,Dataset的其余结构不用动,因为对外暴露的只是self.data和self.labels两个数组。我一般会在__init__里加一个格式判断分支,把读取逻辑抽成独立函数,这样换数据源只改一处。

采样策略上,patch 版本默认对每个标注像素等概率采样,类别不均衡时会让小类别被淹没。改造方式是实现一个WeightedRandomSampler,按类别频率的倒数给每个样本赋权:

from torch.utils.data import WeightedRandomSampler # 统计每个类别的样本数 labels_all = [dataset.labels[r, c] - 1 for r, c in dataset.coords] class_counts = np.bincount(labels_all) # 每个样本的权重 = 其类别频率的倒数 sample_weights = [1.0 / class_counts[label] for label in labels_all] sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True # 有放回采样,保证小类别被重复抽到 ) train_loader = DataLoader(train_set, batch_size=64, sampler=sampler, num_workers=4)

注意用了sampler就不能再设shuffle=True,两者互斥,同时设会报错。replacement=True是有放回采样,小类别样本会被重复抽到,代价是可能过拟合,配合数据增强使用更稳。

另一个常见改造是加数据增强。高光谱不能像 RGB 那样随便翻转,因为波段间的物理意义固定,但空间维度的随机翻转和旋转是安全的。我一般把增强写成Dataset的可选参数,在__getitem__返回前对 patch 做torch.flip或torch.rot90,标签不变。验证集和测试集必须关掉增强,这个开关要显式传参,不能靠全局变量,否则很容易在评估时忘了关。

最后说一个验证改造是否正确的习惯:每次改完数据管道,先跑一遍第 3 章那个检查脚本,确认批次形状、标签范围、归一化后的数值分布(均值接近 0、标准差接近 1)都对,再开训练。有次我图省事跳过了这步,结果归一化维度广播错了,训练了三个小时 loss 纹丝不动,回头查才发现是view的维度写反了。从那以后我每次动Dataset都强制先过一遍形状检查,希望这个习惯也能帮到你。

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

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

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

立即咨询