做高光谱遥感深度学习的朋友应该都有体会:论文里的Pipeline画得明明白白,真到自己动手,光是“数据从哪下、下下来怎么读、读出来怎么喂给DataLoader”就能磨掉一整天。AVIRIS这种经典机载高光谱数据更是重灾区,NASA那套数据平台、USGS光谱库、ENVI格式、HDF5格式,随便一个环节都能让人卡住。
我这次把整条链路完整走了一遍:从NASA DataPort检索AVIRIS影像,配合USGS光谱库做参考,把原始数据转换成能直接训练PyTorch模型的Dataset。整个过程包括了数据下载、格式解析、预处理、归一化、标签构建、Dataset封装和DataLoader调优。这篇文章就把每一步怎么操作、为什么这么操作、中间踩了哪些坑,全部交代清楚,保证你拿着就能复现。
1. 项目背景与整体技术方案
1.1 AVIRIS数据是什么,为什么拿它做实验
AVIRIS(Airborne Visible/Infrared Imaging Spectrometer)是美国宇航局喷气推进实验室主导的机载可见光/红外成像光谱仪,从上世纪80年代开始飞行,至今仍在采集数据。它最核心的特点是连续波段覆盖:224个波段,波长范围从380纳米延伸到2500纳米,覆盖可见光到短波红外。每个像元拿到的不是三通道RGB,而是一条完整的光谱曲线,这对地物分类、矿物填图、植被监测、水体参数反演这类任务来说,信息量是碾压式地高于多光谱数据。
对做深度学习的同学来说,AVIRIS数据还有一个特别友好的地方:空间分辨率通常在几米到二十米级别,影像大小适中,单场景数据量在几百MB到几个GB之间,既不像高分辨率卫星影像那样动辄几十GB,也不会因为数据量太小而失去真实感。更重要的是,这类数据带有明确的物理含义——每个波段对应真实的光谱范围,模型的输出可以跟USGS光谱库里的标准光谱曲线做对照,实验结果有据可查,写论文的时候说服力强得多。
1.2 为什么不能跳过数据处理直接开训
很多人拿到数据后的第一反应是直接np.load然后丢进模型,实测下来基本都会翻车。AVIRIS原始数据的坑至少有三个:第一,影像文件是ENVI标准格式或者HDF5格式,不是PNG也不是JPEG,直接用PIL读不出来;第二,原始数据往往包含大量无效值、坏波段、未定标像元,直接丢给模型训练,损失函数会被这些噪声点带偏;第三,遥感影像的数值范围通常是0到10000以上的反射率缩放值,跟PyTorch里常用的归一化输入完全不匹配,如果不做处理,模型收敛速度会非常慢。
所以,技术方案分阶段设计为:下载数据→解析格式→预处理(归一化、掩膜、波段筛选)→构建标签体系→切成训练集/验证集/测试集→封装成PyTorch Dataset→用DataLoader加载。核心原则是,每一个阶段都保证数据是可追溯、可复现的,避免后面模型出了问题不知道是数据还是模型的原因。
2. 数据下载:NASA DataPort检索与USGS光谱库配合
2.1 DataPort检索不迷路
NASA DataPort(也就是NASA的数据门户站点)对第一次用的人来说不太友好,检索界面信息密度很高,但导出下载链接的入口却藏得比较深。我在实际操作中整理了一套比较稳的流程:
第一步,先确认你要检索的数据类型。如果目标是AVIRIS影像数据,在DataPort的数据集筛选栏里选“AVIRIS”相关产品,注意区分AVIRIS Classic、AVIRIS-NG和AVIRIS Next Generation,这三者波段数和光谱范围有差异,其中AVIRIS Classic通常指224波段的经典版本。
第二步,设置时空范围。AVIRIS是机载数据,不是全球覆盖的卫星数据,所以检索前必须知道目标站点的大致经纬度和飞行时间。NASA官网会发布每次飞行任务(Flight Campaign)的元数据,里面会给出飞行区域和日期,把这些信息填进DataPort的检索条件里。
第三步,查看数据产品列表。每个场景的数据包通常包含影像文件、头文件、几何定位文件和元数据文件。下载时优先看文件大小,太小的可能是预览图,真正的影像文件一般都在几十MB以上。
提示:USGS光谱库可以配合下载。USGS光谱库是标准地物光谱曲线的集合,包含矿物、植被、人造材料、水体等类别,每一条光谱曲线都有波长范围和反射率值。你在处理AVIRIS像元时,可以用光谱库里的标准曲线做端元提取、混合像元分解,或者直接用它来校验你自己从影像里提取的光谱曲线是否正确。
2.2 下载脚本与完整性校验
DataPort的下载链接通常是HTTPS直链,用wget就能下载,但有一个坑:链接可能会带token或者需要登录态。最稳妥的办法是登录之后,在浏览器里把数据包的下载地址复制到urls.txt,然后写一个批量下载脚本:
# 批量下载AVIRIS数据包 while read url; do echo "开始下载: $url" wget -c --tries=3 --timeout=60 -P ./aviris_data "$url" if [ $? -eq 0 ]; then echo "下载成功: $url" else echo "下载失败,稍后重试: $url" >> download_failed.log fi done < urls.txt用-c参数可以实现断点续传,遥感数据包通常体积不小,网络断了重新从头下载非常浪费时间。另外,下载完成后一定要做完整性校验,NASA的下载页面通常会提供Checksum(MD5或SHA256),用md5sum或者sha256sum对比一下,避免下载的文件损坏后面白跑一遍流程。
2.3 本地目录结构设计
下载完不能一股脑全堆在同一个目录,后面处理的时候你会疯掉。我建议按下面的结构组织:
aviris_data/ ├── raw/ # 原始下载文件,不建议修改 │ ├── scene_001/ │ │ ├── f180928t01p00_r02_sc01.a.tif │ │ ├── f180928t01p00_r02_sc01.a.hdr │ │ └── ... │ └── scene_002/ ├── processed/ # 预处理后的数组,npy或h5格式 ├── labels/ # 标签文件,掩膜或分类图 └── splits/ # 数据集划分的记录文件(json/csv)raw目录保持只读,所有中间产物放在processed和splits里,这样即便处理脚本出bug,也不会污染原始数据。这算是工程上的好习惯,数据处理这种“脏活累活”尤其需要这种纪律。
3. 格式解析与读取:从ENVI到内存映射
3.1 认识ENVI格式:hdr文件是最关键的说明书
AVIRIS最经典的数据产品是ENVI标准格式,由两个文件组成:一个.img或.dat的二进制文件存储像素数据,一个.hdr文本文件存储元信息。很多人拿到数据直接去读二进制文件,然后被里面的乱码折磨,原因就是没看.hdr。
一个典型的hdr文件长这样:
ENVI description = {AVIRIS scene} samples = 614 lines = 512 bands = 224 header offset = 0 file type = ENVI Standard data type = 4 interleave = bil byte order = 0这些字段就是解码的关键:samples是列数,lines是行数,bands是波段数,data type是数值类型(4代表float32,2代表int16,12代表uint16),interleave是像素存储布局,byte order是字节序。
这里重点讲一下interleave,因为它直接影响读取性能。ENVI有三种存储布局:
| 布局 | 全称 | 存储方式 | 适合的操作 |
|---|---|---|---|
| BSQ | Band Sequential | 先存完第1个波段的所有行,再存第2个波段 | 逐波段处理、光谱曲线可视化 |
| BIL | Band Interleaved by Line | 先存第1行的所有波段,再存第2行 | 逐行处理,遥感软件常用 |
| BIP | Band Interleaved by Pixel | 先存第1个像元的所有波段,再存第2个像素 | 逐像元处理,深度学习patch提取最友好 |
不同布局只是数据在文件中的排列顺序不同,读出来后再通过reshape和transpose转成你想要的数组形状即可。
3.2 用内存映射读取AVIRIS大文件
高光谱数据一个场景动辄几百MB,如果直接np.load()全量读入内存,几个场景就能把内存耗尽。我的做法是用np.load的mmap_mode参数做内存映射,让操作系统按需加载文件内容,而不是一次性把整个文件塞进内存。
import numpy as np import rasterio from pathlib import Path def load_envi_scene(img_path: str, hdr_path: str = None): """ 加载ENVI格式的高光谱影像,优先用rasterio,失败则手动解析hdr。 返回形状为 (bands, rows, cols) 的数组,并自动处理int16/uint16到float32。 """ img_path = Path(img_path) if hdr_path is None: hdr_path = img_path.with_suffix('.hdr') # 方法1:用rasterio,支持ENVI标准头,读取省心 try: with rasterio.open(img_path) as src: data = src.read() # shape: (bands, rows, cols) # 如果是整型,转float32以便后续归一化 if data.dtype in (np.int16, np.uint16, np.int32): data = data.astype(np.float32) return data except Exception as e: print(f"rasterio读取失败,尝试手动解析: {e}") # 方法2:手动解析hdr + numpy内存映射 hdr_info = parse_envi_hdr(hdr_path) samples = hdr_info['samples'] lines = hdr_info['lines'] bands = hdr_info['bands'] dtype = np.dtype('float32') if hdr_info['data_type'] == 4 else np.dtype('uint16') offset = hdr_info['header_offset'] # mmap_mode='r' 实现内存映射,避免一次性读入内存 arr = np.memmap( img_path, mode='r', dtype=dtype, offset=offset, shape=(lines, samples, bands) if hdr_info['interleave'] == 'bip' else (lines, bands, samples) if hdr_info['interleave'] == 'bil' else (bands, lines, samples) ) # 统一转成 (bands, rows, cols) if arr.ndim == 3 and arr.shape[0] != bands: if hdr_info['interleave'] == 'bip': arr = arr.transpose(2, 1, 0) elif hdr_info['interleave'] == 'bil': arr = arr.transpose(1, 2, 0) # mmap对象不能直接跨进程传递,转成普通ndarray,但注意这里会复制数据 # 如果要省内存,可以在Dataset里直接对mmap做切片 return np.asarray(arr).astype(np.float32)这里提醒一句:np.memmap返回的是一个特殊对象,如果直接在PyTorch的DataLoader里配合多进程使用,会出现序列化问题。如果你在Dataset.__getitem__里直接操作mmap没有问题,但如果需要把整个数组传出去,就要用np.asarray()转成普通数组。代价是复制一遍数据,这几百MB的内存换来了后续流程的稳定,比较划算。
3.3 如果是HDF5格式怎么处理
AVIRIS的有些产品(尤其是经过预处理的反射率产品)会打包成HDF5格式,后缀为.h5或.hdf5。遇到这种格式别慌,用h5py库读取即可。
import h5py def load_h5_scene(h5_path: str, data_key: str = 'Reflectance'): with h5py.File(h5_path, 'r') as f: # 查看文件内部结构,首次使用时先打印keys print("HDF5 keys:", list(f.keys())) data = f[data_key][:] # 根据需要确定key名 # 通常HDF5存储为 (rows, cols, bands) 或 (bands, rows, cols) if data.ndim == 3 and data.shape[-1] == 224: data = data.transpose(2, 0, 1) return data.astype(np.float32)HDF5的好处是自带元数据,读取时可以直接拿到波段列表、波长信息、无效值标记等,比手动解析hdr省事不少。缺点是这个格式的文件会比同内容的ENVI文件大一些,读取速度也略慢。
4. 数据预处理:归一化、无效值、波段筛选与数据集切分
4.1 归一化的坑:先想清楚要做“全局统计”还是“场景内统计”
归一化是深度学习中再常见不过的操作,但高光谱数据里有特殊的坑:如果你按每个像元自己跨波段做min-max归一化,会彻底破坏光谱曲线的相对关系。比如一个像元的2450纳米波段反射率是0.08,另一个像元的同波段反射率是0.30,逐像元归一化之后这两个值可能都变成了1.0,模型就无从区分了。
正确的做法是用全局统计值做归一化。实际操作中,我会在整个数据集的可用像元上统计反射率的最小值和最大值,然后统一用这两个参数做放缩:
def compute_global_minmax(data): """ 计算全局min和max。data形状为 (bands, rows, cols)。 -9999等无效值应当在调用此函数前被掩膜处理。 """ # 只看有效像元,避免把异常极值纳入统计 valid_mask = data > 0 data_valid = data[valid_mask] if data_valid.size == 0: raise ValueError("没有有效像元,请检查数据") gmin = float(data_valid.min()) gmax = float(data_valid.max()) return gmin, gmax def minmax_normalize(data, gmin, gmax): # 避免除零 if gmax - gmin < 1e-8: return np.zeros_like(data, dtype=np.float32) return (data - gmin) / (gmax - gmin)这里又有一个容易被忽略的细节:统计min和max时,一定要先把无效值排除掉。AVIRIS原始数据里常见无效值有-9999、0、255之类,如果直接把-9999一起统计,得到的最小值可能是-9999,归一化之后正常像元的数值范围会被压缩得很小,相当于白白损失了精度。
先做掩膜再统计,统计完再归一化,顺序不能乱。
4.2 坏像元和无效值处理:宁可填零也不要带病训练
高光谱影像里除了明显的-9999,还有一类不太好发现的坏像元:传感器坏线、条带噪声、水汽吸收波段。坏线和条带噪声肉眼能看到,但算法上很难用统一的规则剔除,我的经验是先用一个简单的阈值方法生成掩膜,把反射率为负值、0、或者超过合理范围的像元全部标记出来。
def build_mask(data, nodata_values=(-9999, 0), reflectance_max=10000.0): """ 构建有效像元掩膜,返回 (rows, cols) 的bool数组,True表示有效。 data形状为 (bands, rows, cols)。 """ mask = np.ones((data.shape[1], data.shape[2]), dtype=bool) for v in nodata_values: # 只要任一波段等于该值,就视为无效像元 mask &= ~(np.abs(data - v) < 1e-6).any(axis=0) # 反射率超过合理范围(比如>10000或>1),视为无效 mask &= (data < reflectance_max).all(axis=0) return mask这里反射率的上限要根据产品类型灵活调整。如果下载的是辐射亮度产品,数值范围可能是0到几百;如果是反射率产品,通常是0到1之间的小数,或者0到10000的整数缩放。我的建议是:先打印一下数据的min和max,再定阈值,不要上来就写死。
水汽吸收波段的问题也需要处理。AVIRIS的224个波段里,有一部分位于水汽强吸收区域,比如1350-1420纳米和1800-1940纳米附近,这些波段的信号被大气中的水汽严重衰减,数据质量很差。许多公开的AVIRIS产品会直接剔掉这些波段,但有些不会。做法是查看产品自带的波段列表,把吸收波段的索引记录下来,在训练时直接不用:
# 示例:要剔除的波段索引,需要根据实际波长列表确定 BAD_BAND_INDICES = [108, 109, 110, 111, 112, 113, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170] def filter_bands(data, bad_band_indices): """ 剔除坏波段,返回数据波段数会减少。 data: (bands, rows, cols) """ good_bands = [i for i in range(data.shape[0]) if i not in bad_band_indices] return data[good_bands, :, :]剔除坏波段之后,一定要重新打印一下data.shape,记住实际使用的波段数,后面写模型输入通道数的时候要用到。这个容易在实验中混淆,我自己就吃过亏。
4.3 标签体系构建与数据集切分策略
处理影像数据本身只是第一步,监督学习还需要标签。AVIRIS数据最常见的标签来源是配套的土地覆盖分类图,或者你根据USGS光谱库自己标注的感兴趣区域。如果官方标签文件是ENVI格式,用前面的读取方法转成int数组即可;如果不是,就需要自己手动划定ROI。
切分数据集时有一个遥感数据特有的重要原则:必须按场景或者按空间区域切分,不能随机打乱像元。原因很简单,同一场景内相邻像元高度相关,如果随机切分,训练集和验证集之间会有严重的空间自相关,相当于数据泄漏,验证集的精度会虚高,模型真正遇到一个新场景时效果会大打折扣。
from sklearn.model_selection import GroupShuffleSplit # 假设你有一组场景ID,每个像元都归属于某一个场景 # pixel_scene_ids 形状为 (n_pixels,),例如 [0,0,0,...,1,1,1,...] # n_pixels = rows * cols train_idx, val_idx = next( GroupShuffleSplit(test_size=0.2, n_splits=1, random_state=42).split( np.zeros(len(pixel_scene_ids)), groups=pixel_scene_ids ) )切分完成后,一定要把train_idx、val_idx、test_idx保存成文件,方便复现。不要每次运行都重新切一次,否则后面你会完全分不清当前模型是在哪个数据划分下训练出来的。
5. 构建PyTorch Dataset与DataLoader
5.1 Dataset三个核心方法到底怎么写
PyTorch的Dataset类本质上就是提供一个接口,让DataLoader可以从数据集中取样本。高光谱的样本通常不是整张图,而是以某个像元为中心的patch(比如15×15×波段数的小立方体),这样既能利用空间上下文信息,又能通过滑动窗口生成大量训练样本。
一个可直接复用的AVIRIS Dataset实现如下:
import torch from torch.utils.data import Dataset import numpy as np class AVIRISDataset(Dataset): def __init__(self, data, label, mask=None, patch_size=9, transform=None): """ data: (bands, rows, cols) 归一化后的高光谱影像 label: (rows, cols) 每个像元的标签,0表示背景/无效 mask: (rows, cols) bool数组,True表示有效样本 patch_size: 提取patch的边长,奇数 """ self.data = data self.label = label self.patch_size = patch_size self.half = patch_size // 2 self.transform = transform if mask is None: mask = label > 0 self.mask = mask # 预先生成所有有效像元的坐标列表,避免每次getitem都扫描mask rows, cols = np.where(mask) self.valid_coords = list(zip(rows.tolist(), cols.tolist())) def __len__(self): return len(self.valid_coords) def _pad_patch(self, band_data, center_row, center_col): """ 从中心像元提取patch,边界用reflect填充。 band_data: (rows, cols) 单波段图像 """ half = self.half row_start = center_row - half row_end = center_row + half + 1 col_start = center_col - half col_end = center_col + half + 1 # 如果越界,用np.pad的reflect模式填充 if row_start >= 0 and row_end <= band_data.shape[0] and \ col_start >= 0 and col_end <= band_data.shape[1]: return band_data[row_start:row_end, col_start:col_end] # 更通用的做法:先pad整个波段,再切 padded = np.pad(band_data, half, mode='reflect') return padded[row_start+half:row_end+half, col_start+half:col_end+half] def __getitem__(self, idx): row, col = self.valid_coords[idx] # 提取所有波段的patch,形状为 (bands, patch_size, patch_size) patch = np.stack([ self._pad_patch(self.data[b], row, col) for b in range(self.data.shape[0]) ], axis=0).astype(np.float32) target = int(self.label[row, col]) # 转成torch tensor x_tensor = torch.from_numpy(patch).float() y_tensor = torch.tensor(target, dtype=torch.long) return x_tensor, y_tensor这里有几个关键细节。第一,valid_coords预先生成,避免每次getitem都做全图扫描,不然训练速度会被拖垮。第二,边界处理用reflect填充,因为反射率光谱在空间上一般有连续性,用0填充会在边界引入大量人为的低值,影响模型训练。第三,getitem返回的是归一化后的浮点tensor和long类型的标签,直接适应PyTorch的CrossEntropyLoss。
5.2 DataLoader参数调优:num_workers并不是越大越好
Dataset写好之后,DataLoader的配置也需要注意。AVIRIS patch提取涉及到对数据数组的多次切片操作,如果num_workers设置太大,多个worker进程会同时频繁读取数组,反而会因为进程切换和内存瓶颈拖慢速度。
我实测下来比较稳的配置是:
from torch.utils.data import DataLoader train_dataset = AVIRISDataset(train_data, train_label, train_mask, patch_size=9) train_loader = DataLoader( train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, drop_last=True, persistent_workers=True )persistent_workers=True会在每个epoch之间保持worker进程不销毁,省去了反复创建进程的开销,实测对遥感大数据集提速明显。但要注意,如果你是在本地Windows上跑,建议把num_workers设为0,否则多进程数据加载偶尔会报错,这是PyTorch在Windows上已知的问题。
pin_memory=True适合在GPU训练时使用,它会将数据锁定在页锁定内存中,加快CPU到GPU的拷贝速度。如果你只在CPU上调试,这个参数影响不大。
5.3 最小可跑的训练循环验证
Dataset接好之后,可以先用一个极小的卷积网络验证数据管道是否通畅。不要一上来就上大模型,先跑通再扩展。
import torch.nn as nn class SpectralCNN(nn.Module): def __init__(self, in_channels=224, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(in_channels, 16, kernel_size=3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(1), ) self.classifier = nn.Linear(16, num_classes) def forward(self, x): x = self.features(x) # (B, 16, 1, 1) x = x.flatten(1) # (B, 16) return self.classifier(x) model = SpectralCNN(in_channels=train_data.shape[0], num_classes=10)跑一个epoch的循环:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss(ignore_index=0) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) model.train() for batch_idx, (x, y) in enumerate(train_loader): x, y = x.to(device), y.to(device) optimizer.zero_grad() output = model(x) loss = criterion(output, y) loss.backward() optimizer.step() if batch_idx % 10 == 0: print(f"Batch {batch_idx} | Loss: {loss.item():.4f}") if batch_idx >= 20: break # 测试用,只跑20个batch注意这里我加了ignore_index=0,对应标签里的背景类。如果你的标签体系里0是有效类别,就不要加这个参数。
6. 常见问题排查与性能调优实录
6.1 问题速查表:这些坑我全部踩过
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 读出来的数值全是0或255 | ENVI头文件的data type理解错误,或者没有跳过header offset | 核对hdr文件里data type字段,换算正确的numpy dtype |
| DataLoader多进程报错shared memory不足 | num_workers开太大,或机器共享内存过小 | 调小num_workers,关闭pin_memory |
| 训练集验证集精度差异极大 | 切分前做了全局归一化,或随机切分导致空间泄漏 | 只对训练集统计min/max,按场景切分 |
| 模型loss不下降 | 归一化失效,或坏波段未剔除 | 检查数据是否已归一化到合理范围,打印数据统计 |
| patch提取速度慢 | Dataset的getitem每次都重复做大量计算 | 预计算坐标列表,缓存常用patch |
| 运行几轮后内存持续增长 | mmap对象在多进程下被反复复制 | 将mmap转为普通ndarray,或用持久化worker |
6.2 两种读取方式的实测对比
我在处理一个512×614×224的AVIRIS场景时,对比了两种读取方式。直接np.load全量加载耗时约1.8秒,内存占用约280MB;使用np.memmap加载几乎瞬间完成,内存占用约50MB。但在实际训练中,如果使用np.memmap,每个worker进程在访问不同位置的patch时都会触发磁盘I/O,首次epoch速度会比全量加载慢20%左右。不过从第二个epoch开始,得益于操作系统的page cache,速度差异基本消失。
结论很简单:如果你的机器内存够大,直接把数据读进内存最省心;如果内存紧张,用mmap加persistent_workers也能跑得很顺。不要为了省内存而生硬地套mmap,性能反而可能下降。
6.3 关于波段筛选和归一化参数保存的独家经验
最后分享两个我在实际项目中固定下来的习惯。第一个是,完成归一化之后,把gmin、gmax以及坏波段索引保存到JSON文件里,推理阶段加载模型时要使用完全相同的参数,否则训练和推理的数据分布不一致,精度会莫名其妙地掉。第二个是,虽然AVIRIS有224个波段,但并不是所有波段对任务都有用,水汽波段和噪声波段筛掉之后往往还有150-200个波段,这就足够训练了。实测在矿物分类任务中,使用剔除水汽吸收波段后的180个波段,比使用全部224个波段精度更高,原因是噪声波段相当于给模型引入了额外的干扰。
这套流程跑通之后,你会发现在NASA DataPort和USGS光谱库之外,其他高光谱数据源(比如EnMAP、Hyperion、或是国产的高光谱卫星数据)也可以复用同样的处理思路。数据的格式千差万别,但“解析→预处理→封装Dataset→训练验证”这条管道是通用的。把这一步走扎实,后面换数据、换模型、写论文做对比实验都会顺很多。