考试通知
简介这份资源围绕高光谱图像HSI的PyTorch数据加载实践面向正在学习深度学习图像处理、尤其是需要处理多光谱通道数据的Python开发者与科研人员。内容以实用代码为核心演示如何通过继承Dataset类实现HDF5/MAT等格式的高光谱文件读取并在DataLoader中完成归一化、批处理、多线程加载、随机打乱及自定义collate_fn等关键配置帮助解决大数据量带来的内存压力与训练效率问题。资源压缩包共7个文件包含3个py脚本如dataloader.py、train.py可直接参考或改造、2个pyc缓存文件与2个mat数据样本如IndianPines数据集整体大小仅5.69MB轻量便于快速上手实践。目前已有611人学习下载。对想掌握高光谱数据高效加载流程、减少预处理盲区的读者而言这份资料提供了可直接运行的示例脚本和真实数据组织结构值得对照练习。1. 高光谱数据加载为什么DataLoader才是第一个拦路虎做高光谱的时候最容易被低估的环节不是模型结构而是怎么把数据喂进PyTorch的DataLoader。高光谱图像不是普通的三通道RGB它是一整块三维立方体空间维度H乘W光谱维度动辄几十上百个波段单幅数据轻轻松松几百MB。如果你直接照搬常规图片分类的加载代码十有八九在训练一开始就遇到内存爆炸、训练卡死或者图像全黑这类玄学问题。这篇笔记就围绕“高光谱使用PyTorch的DataLoader加载高光谱数据”这条主线把读取、封装、调参与排错一次性讲清楚。适合正在做高光谱分类、目标检测或者波段选择被数据加载卡住的从业者。2. 高光谱数据读取从ENVI、MAT、TIFF到标准ndarray2.1 高光谱数据为什么不能当普通图片处理常规图片读到内存里是一个 (H, W, 3) 的uint8数组通道维度固定且语义明确。高光谱数据则是 (H, W, D) 的三维立方体D是波段数可能是几十到几百个不等的连续光谱通道每个波段都对应一个窄谱段的反射率值。这个D维度不仅影响内存占用还直接决定了模型输入通道数所以第一步就是要把不同来源的数据统一成规则的三维数组。另一个特殊之处是存储格式。高光谱数据最常见的来源是无人机或卫星遥感采集的ENVI格式另外还有科研场景中常用的MAT文件、TIFF格式。ENVI格式通常由一个二进制数据文件.raw/.img加一个头文件.hdr组成头文件里记录行数、列数、波段数、数据类型、字节序以及数据排列方式。数据排列方式有BSQ波段按顺序分开存储、BIL逐行存储所有波段、BIP逐像素存储所有波段三种这三种格式在内存中的排列逻辑完全不同。你用普通图像工具直接读ENVI二进制文件读出来的结果很可能是乱的就是因为没有按头文件里的interleave方式去解析。所以在设计DataLoader之前先把数据读取这一步夯实。我一般会写一个统一的加载函数把不同格式的高光谱数据全部转成 (D, H, W) 的numpy数组后续的Dataset只认这个函数不关心原始格式是什么。2.2 三种常见格式的读取代码与参数说明这里给出一个可直接复用的读取模块覆盖ENVI、MAT、TIFF三类来源。以某高光谱遥感数据为例它在不同项目里可能是这三种格式中的任意一种统一读取口径能省掉大量重复工作。import numpy as np import spectral.io.envi as envi from scipy.io import loadmat import tifffile def load_hs_image(file_path, format_typeenvi, **kwargs): 统一加载高光谱数据返回 (D, H, W) float32 数组 file_path: 文件路径 format_type: envi / mat / tiff if format_type envi: # 读取ENVI头文件与二进制数据 dataset envi.open(file_path.replace(.raw, .hdr)) # .asarray() 默认返回 (H, W, D) arr np.array(dataset.asarray(), dtypenp.float32) # 转成 (D, H, W)方便后续输入PyTorch arr np.transpose(arr, (2, 0, 1)) return arr elif format_type mat: # MAT文件常见为字典结构key可能是data或HSI mat_dict loadmat(file_path) key kwargs.get(mat_key, data) if key not in mat_dict: # 自动查找第一个三维数组变量 key [k for k in mat_dict if isinstance(mat_dict[k], np.ndarray) and len(mat_dict[k].shape) 3][0] arr np.array(mat_dict[key], dtypenp.float32) # 保持 (D, H, W) 约定若原始是 (H,W,D) 则转置 if arr.ndim 3 and arr.shape[-1] arr.shape[0]: arr np.transpose(arr, (2, 0, 1)) return arr elif format_type tiff: # TIFF高光谱常见多页Tiff每页是一个波段 arr tifffile.imread(file_path) arr np.array(arr, dtypenp.float32) if arr.ndim 3 and arr.shape[0] 100: # 简单判断是否已经是波段优先 if arr.shape[0] arr.shape[-1]: # 如果第一维是空间行 arr np.transpose(arr, (2, 0, 1)) return arr else: raise ValueError(fUnknown format_type: {format_type})这里有三处值得说明的地方。函数返回值统一固定为 (D, H, W) float32这是给模型输入用的因为PyTorch中卷积层的输入要求是 (Batch, Channel, Height, Width)Channel维度必须在第二位。ENVI读取用spectral库的envi.open它需要同时存在.hdr和二进制文件如果文件名是.data结尾headername可以直接通过envi.open的headername参数显式给出。MAT文件读取时常见坑是变量名不固定代码里做了一个自动查找三维数组的逻辑避免每次都要手改key。TIFF多页数据每一页通常代表一个波段tifffile读出来的形状是 (D, H, W)但如果遇到某些软件把波段存在最后一维就需要靠shape判断后转置。判断条件里用了波段数和空间维度的相对大小实际场景中波段数通常小于空间尺寸但如果遇到空间尺寸本来就很小的情况这个启发式判断就需要改为显式传入参数。读取完成后建议顺手打印一下数组的shape、dtype和数值范围我排查问题时基本从这三个信息开始。如果看到shape是 (H, W, D)说明转置逻辑没有生效如果dtype是int16很多框架在归一化时会出问题如果数值范围是几万甚至更大很有可能包含了噪声带或者坏波段。2.3 坏波段去除与零值保护高光谱数据经常存在一些不可用的波段尤其是大气吸收波段或者传感器损坏导致的全零波段。这些波段不剔除会直接影响归一化精度甚至让训练出现梯度异常。常见做法是在加载后做一次波段清洗把标准差接近0的波段过滤掉或者在读取时读取元数据中的bad bands列表。def clean_hs_image(arr, bad_bandsNone, min_std1e-6): 剔除高光谱中的坏波段 arr: (D, H, W) float32 bad_bands: 已知波段下标列表 min_std: 标准差阈值低于该值的波段视为全零或恒值 if bad_bands is not None: mask np.ones(arr.shape[0], dtypebool) mask[bad_bands] False arr arr[mask] # 自动查找全零波段 std_per_band arr.reshape(arr.shape[0], -1).std(axis1) all_zero_bands np.where(std_per_band min_std)[0] if len(all_zero_bands) 0: mask np.ones(arr.shape[0], dtypebool) mask[all_zero_bands] False arr arr[mask] print(fRemoved {len(all_zero_bands)} zero variance bands: {all_zero_bands.tolist()}) return arr这段清洗逻辑按波段计算标准差因为全零波段和损坏波段的方差非常小阈值min_std默认给1e-6实际使用时需要先打印一下各波段标准差再去调整。坏波段列表如果数据源已经提供优先使用显式列表自动检测只能作为补充避免误删那些本来就平坦的光谱区域。清洗后要做一次波段连续性检查记录被删除的波段下标后续在做结果可视化或者波段选择时才能对应回原始波段号。3. 自定义Dataset与DataLoader把三维立方体喂给模型3.1 完整Dataset骨架代码读取函数解决了“怎么读”接下来要解决“怎么喂”。PyTorch的DataLoader接收的是一个Dataset对象这个对象只需要实现__len__和__getitem__两个方法。高光谱场景里每个样本可能是整幅图像也可能是从大图上切割出来的patch。先说整幅图的情况它最简单也是理解骨架的最佳入口。后续要改成patch采样只需要在getitem里加一个随机裁剪。import torch from torch.utils.data import Dataset class HighSpectralDataset(Dataset): def __init__(self, file_list, label_list, transformNone, target_sizeNone): file_list: 图像文件路径列表 label_list: 每张图对应的标签 transform: 数据增强/预处理callable对象 target_size: (patch_h, patch_w)不为空时按patch采样 self.file_list file_list self.label_list label_list self.transform transform self.target_size target_size self.data [] self.labels [] # 真实项目中内存有限这里示例保留缓存开关 self.cache_in_memory False def __len__(self): return len(self.file_list) def __getitem__(self, idx): file_path self.file_list[idx] label self.label_list[idx] # 从缓存读取或重新加载 if self.cache_in_memory: arr self.data[idx] else: arr self._load_image(file_path) # 如果设置了patch尺寸按随机位置裁剪 if self.target_size is not None: arr self._random_crop(arr, self.target_size) # 归一化与增强交给transform处理 if self.transform is not None: arr self.transform(arr) # 转成tensor arr torch.from_numpy(arr).float() label_tensor torch.tensor(label, dtypetorch.long) return arr, label_tensor def _load_image(self, file_path): # 这里调用上一章的读取函数实际项目里按需扩展 if file_path.endswith(.mat): return load_hs_image(file_path, mat) elif file_path.endswith(.tif): return load_hs_image(file_path, tiff) else: return load_hs_image(file_path, envi) def _random_crop(self, arr, size): arr: (D, H, W) size: (patch_h, patch_w) D, H, W arr.shape ph, pw size if H ph or W pw: raise ValueError(fImage size ({H}, {W}) smaller than patch size ({ph}, {pw})) h np.random.randint(0, H - ph 1) w np.random.randint(0, W - pw 1) return arr[:, h:hph, w:wpw]这段代码里最关键的设计是数据加载与预处理分离。Dataset不做归一化归一化全部放到transform里这样训练集和验证集可以用不同的transform组合。random_crop返回的是视图还是拷贝需要留意numpy的切片默认是视图如果后续transform在原始数组上做原地操作会污染缓存的数据。所以我通常会在裁剪后调用np.ascontiguousarray(arr)做一次拷贝确保后续操作不影响原始数据。cache_in_memory开关适合小数据集或者调试阶段正式训练时一般不开启避免重复读取磁盘。如果开了缓存加载后要放在内存里同时注意多进程采样时子进程会复制这份内存内存占用会成倍增加。3.2 归一化与空间增强的正确写法高光谱数据做归一化最常犯的错误是直接对整幅图像做全局min-max。高光谱图像不同波段之间的辐射范围差异极大有些波段整体数值就偏高全局min-max会把低响应波段压得很低导致模型一开始就忽略这些波段。我常用的做法是逐波段做z-score归一化统计每个波段的均值和标准差存入一个数组训练集上统计出来的参数要保存下来验证和推理时直接用不能重新统计。class SpectralNormalize: z-score归一化 初始化时传入mean和std或者用fit方法从训练集计算 def __init__(self, meanNone, stdNone): self.mean mean self.std std def fit(self, arr): # arr: (D, H, W) 或 (N, D, H, W) self.mean np.mean(arr, axis(0, -2, -1)) # 对空间维求平均 self.std np.std(arr, axis(0, -2, -1)) return self def __call__(self, arr): eps 1e-8 return (arr - self.mean[:, None, None]) / (self.std[:, None, None] eps)这里的fit方法对空间维求均值得到长度为D的mean和std数组。__call__里用[:, None, None]把维度从(D)扩展成(D,1,1)这样才能和(D,H,W)的数组直接相减。eps防止某个波段标准差为0导致除零。真实场景中建议把均值和标准差存成numpy文件推理时加载不要每次运行重新算否则同一个模型在训练和推理时输入分布不一样精度直接打折。空间增强要特别注意与普通图像增强的区别。高光谱输入通道多像torchvision里那种resize或normalize不一定适合因为很多遥感任务对空间的旋转和翻转敏感度不同尤其是带有方向性的地物翻转会改变语义。我通常只做随机裁剪和水平/垂直翻转且训练和验证的设置必须分开。增强会改变图像内容如果验证集也做随机增强判断的指标会不稳定训练时损失波动也会被误当成模型问题。class SpectralTrainTransform: def __init__(self, patch_size): self.patch_size patch_size self.normalize None # 外部传入已经fit好的归一化器 def __call__(self, arr): # 固定到统一patch尺寸 arr random_crop arr to self.patch_size # 随机水平/垂直翻转 if np.random.rand() 0.5: arr np.flip(arr, axis1) if np.random.rand() 0.5: arr np.flip(arr, axis2) arr np.ascontiguousarray(arr, dtypenp.float32) return self.normalize(arr)上面只是示意实际代码里random_crop需要调用Dataset里的方法。这里的逻辑说明是增强操作在原始数据上做切片和翻转最后必须转成连续内存因为PyTorch的Tensor.from_numpy要求数组是连续存储否则会报错。标准化放在增强之后避免翻转改变数值大小这个顺序不要颠倒。如果你的数据量小还可以加入光谱维度的扰动比如随机丢弃部分波段或者对波段添加高斯噪声这样能提升模型对波段缺失的鲁棒性。3.3 DataLoader参数选择与多进程注意事项Dataset写好后DataLoader参数的配置直接影响训练速度和内存占用。高光谱图像的单个样本尺寸比普通图片大得多一个512x512x200的浮点数组光数据量就是512512200*4约200MBbatch_size如果设成8一个batch就是1.6GB显存和内存都受不了。所以高光谱场景下batch_size一般设成2到8之间具体要看你的输入patch大小。如果用patch采样100x100x200的patch大约是8MBbatch_size可以开到16甚至更高。from torch.utils.data import DataLoader train_loader DataLoader( train_dataset, batch_size4, shuffleTrue, num_workers4, pin_memoryTrue, persistent_workersTrue, drop_lastTrue )参数选择上用我实际调出来的经验值。num_workers没必要追求特别大高光谱数据读取本来就慢磁盘IO往往是瓶颈worker多了反而会在启动时花大量时间读数据。pin_memory只对GPU训练有效开启后Tensor会放在锁页内存中GPU拷贝速度会提升但会占用一部分内存内存紧张时建议关掉。persistent_workers在PyTorch1.9以上版本才有开启后worker不会每个epoch都重启减少重复加载时间。drop_lastTrue在样本数量不能被batch整除时使用避免最后一个batch因尺寸不一致导致模型训练出错。如果是在Windows环境下跑数据加载代码必须写在ifname main:保护块里否则会无限递归创建子进程。有的环境下还会因为共享内存不足报错这种情况下可以先调小num_workers或者设置DataLoader的multiprocessing_context参数。4. 高光谱加载常见问题排查从OOM到图像全黑4.1 现象一DataLoader一启动内存直接打满实际项目中遇到过几次DataLoader刚创建内存占用就飙升到几十GB。原因是每张高光谱图都是几百MB到上GB的数组num_workers开启后每个worker进程都会复制一份数据集索引和数据缓存。如果Dataset里设置了cache_in_memory主进程先加载了全量数据然后子进程再复制内存直接翻n倍。解决方法是不要把所有数据一次性加载到内存改用按需读取同时把num_workers调低优先保证主进程内存不爆。另一个办法是使用内存映射读取也就是np.load的mmap_mode参数让操作系统按需从磁盘读取数据页而不是一次性载入。4.2 现象二训练图像全黑或全白训练时可视化输入batch发现图像要么全黑要么全白模型根本学不到东西。原因通常是归一化用错了轴。比如你对(D,H,W)的数组直接做min-max没有把空间维和通道维分开导致一个波段的异常值把其他波段压成0。还有可能是原始数据是uint16直接转float后没有除以缩放系数数值范围还是0到65535模型输入就全乱。解决方法是先打印原始数组的shape、dtype、min和max确定数据范围后逐波段归一化uint16转float时记得除以一个合适的缩放系数比如4096或65535具体看数据本身的辐射定标参数。我习惯把这条检查写进transform的开头强制保证输入范围在0到1之间。4.3 现象三训练精度不涨和验证时指标完全对不上一个典型场景训练集loss下降很快但验证集精度波动巨大甚至不升反降。原因是验证集也走了随机增强或者验证集归一化用了自己单独计算的统计量。第一个问题好办验证集的transform里不要包含随机的翻转和裁剪只用固定中心裁剪加上归一化。第二个问题隐蔽一些有的代码在Dataset里直接做了类似(img - img.mean()) / img.std()的操作这个mean和std是每个样本自己的统计量训练和验证的量纲就不统一了。正确做法是用训练集的波段均值/标准差去归一化所有数据这个统计量需要在训练开始前算好并保存。4.4 现象四报错“collate_fn无法拼接多个样本”看到这样的报错第一反应是检查Dataset返回的每个样本shape是否一致。高光谱数据里某个tiff文件被读成了(H,W,D)而另一个是(D,H,W)或者坏波段剔除后波段数不同都会让默认的collate函数拼接失败。解决方法是先在读取层统一维度并在Dataset的getitem最后打印调试信息。如果数据本身波段数确实不一致需要定义一个collate_fn根据最大波段数补齐或者干脆剔除波段数不匹配的样本。还有可能原因是样本里混入了NaN值torch的拼接不检查NaN但后续计算会全部变成NaN这种情况用np.isnan检查后做插值或者直接删波段。4.5 现象五num_workers一开就报RuntimeError报错信息像“DataLoader worker (pid(s) 1234) exited unexpectedly”高光谱场景下多半是系统共享内存不足。每个worker进程都要预加载数据当输入一副大图时numpy数组会通过共享内存传给主进程如果/dev/shm容量不够worker直接崩溃。解决方法是把num_workers改成0或者限制每个worker加载的数据大小。如果服务器配置了docker需要检查共享内存是否被限制启动容器时加--shm-size8g。如果数据非常大建议先做patch采样把大图提前切成小patch保存这样每个样本只有几MBworker压力会明显降低。5. 进阶技巧把高光谱DataLoader的速度再榨一档5.1 用内存映射mmap加载超大文件当高光谱数据动辄几个GB时传统读取方式不仅慢还容易把内存打爆。可以用numpy的memmap机制把磁盘文件映射到虚拟内存只在访问特定波段时才真正从磁盘读取数据。这样做前提是文件本身是二进制数组且格式固定ENVI数据可以直接用np.memmap加偏移量读取。对patch采样来说收益很大因为getitem只访问一个小区域操作系统只会加载那个区域对应的数据页内存占用会大幅下降。def load_envi_as_memmap(file_path, hdr_dict): 用memmap读取ENVI二进制数据 返回一个类数组对象支持切片但索引范围不能越界 dtype_map {uint8: np.uint8, uint16: np.uint16, float32: np.float32} dtype dtype_map[hdr_dict[data type]] interleave hdr_dict[interleave] rows hdr_dict[rows] cols hdr_dict[cols] bands hdr_dict[bands] # ENVI按BSQ存储时每个波段是独立的一块 mm np.memmap(file_path, dtypedtype, moder, shape(bands, rows, cols) if interleave bsq else (rows, cols, bands)) return mm这个技巧适合用于全图推理或者超大patch的场景注意memmap对象是多进程安全的但不要写入否则容易引起文件锁冲突。使用memmap后提速不在读取本身而在避免主内存被大数组占满让DataLoader的num_workers能够正常工作。5.2 用缓存层避免重复读取同一幅图训练时多个patch来自同一张大图如果每取一个patch都重新读磁盘IO开销会翻很多倍。可以在Dataset里增加一个LRU缓存以文件路径为key把读取后的数组缓存下来只要内存允许就能把重复读取降到最低。但缓存要控制在合理范围内避免内存被占满。我通常的做法是设置一个最大缓存张数比如2超过后释放最久未用的数据。在本地训练时很有用在多机训练时反而可能造成每台机器都缓存一份这个需要根据实际架构取舍。5.3 验证DataLoader输出正确性的检查函数最后分享一个我每次写完DataLoader后必跑的验证程序它能帮你快速发现维度、数值范围和数据增强的bug。原理很简单跑一个epoch统计每个batch的shape、数值min/max以及标签分布。如果发现shape里有非预期维度或者数值范围不对立即打日志截断训练。这个小习惯能省掉大量瞎调参的时间。def inspect_dataloader(loader, num_batches3): 检查DataLoader输出是否符合预期 for i, (batch_x, batch_y) in enumerate(loader): print(fBatch {i}: x shape {batch_x.shape}, dtype {batch_x.dtype}, fx range [{batch_x.min():.3f}, {batch_x.max():.3f}], labels {batch_y.unique().tolist()}) if i num_batches - 1: break print(DataLoader check passed.)检查函数里重点关注三个点batch_x的第一个维度应该等于batch_size第二个维度应该等于波段数数值范围如果做了z-score归一化不应该出现绝对值大于5的异常值标签列表应该覆盖所有类别而不是只出现某几个。如果这三项都正常再进入训练。从那以后我每次写新的高光谱加载代码都会强制走一遍这个检查流程确认输入分布和预期一致再开始调模型。遇到过太多次因为数据加载bug导致模型不收敛最后发现只是归一化写错了的情况所以希望这套流程也能帮到你的高光谱项目。本文还有配套的精品资源点击获取
高光谱数据如何高效加载到PyTorch DataLoader:从读取到排错全攻略
NEXT STEP
看完公告,下一步怎么走?
把报考交给靠谱的人:材料预审、批次抢报、考前辅导、复审提醒,全程有人跟。