搞过遥感数据的人都懂,最磨人的往往不是算法,而是数据本身。AVIRIS这种机载高光谱影像,一个场景动辄一两GB,格式还是ENVI那套用文本头文件描述二进制体的老结构;USGS光谱库又是一堆制表符分隔的ASCII文件,波长单位还藏在文件注释里。两头一夹,从下载到变成PyTorch能直接喂给模型的Dataset,中间隔了至少五六道工序。这篇我就把整套流程完整写下来,从NASA数据门户的账号配置、ENVI头文件解析、水汽坏波段剔除,再到光谱库重采样和torch Dataset封装,全部是可落地、可复现的步骤。适合正在做高光谱分类、矿物识别、植被遥感或者想入门遥感深度学习的人,花一两个小时跟着走一遍,后面再碰其他ENVI格式的高光谱数据就能少走很多弯路。
1. 先搞清楚:AVIRIS和USGS光谱库到底是个啥
1.1 AVIRIS成像仪与数据格式
AVIRIS全称是Airborne Visible/Infrared Imaging Spectrometer,也就是机载可见光/红外成像光谱仪,由NASA喷气推进实验室(JPL)研制。老款AVIRIS-Classic有224个波段,覆盖380-2500纳米,光谱分辨率约10纳米;后来升级的AVIRIS-NG把波段数提到了425个,覆盖380-2510纳米,分辨率大约5纳米,空间分辨率随飞行高度变化,低空飞行时能做到亚米级,常规航线数据通常是几米到十几米。
这种数据最大的价值在于“图谱合一”:一个像素它就是一条连续光谱曲线。地物类型不一样,光谱曲线的形态就不一样,矿物、植被、水体、人造材料各有各的诊断性吸收特征。这也是后面能用USGS光谱库做参考标签的原因。
AVIRIS公开发布的数据产品常见后缀是_rfl,表示经过大气校正后的地表反射率产品。下载解压后,一份完整的ENVI格式数据至少包含两个文件:
.hdr:ASCII头文件,记录行列数、波段数、数据格式、波段中心波长等元信息.img:二进制数据体,默认以BIP(Band Interleaved by Pixel)方式存储
我一开始拿到数据时傻乎乎只看了看.img文件,以为就是裸矩阵,结果一reshape就乱套。ENVI这套格式虽然老,但远程感领域几乎都在用,所以头文件这关必须过。
1.2 为什么要拉上USGS光谱库
USGS光谱库(spectral library)是美国地质调查局维护的参考光谱数据库,目前常用版本是splib07a,收录了几千条矿物、土壤、植被、人造材料、水体等典型地物的光谱曲线。每条光谱都对应一个明确的材料类别,波长范围从紫外线到热红外,正好覆盖AVIRIS的观测区间。
它的作用在这条流程里有两个:一是做“分类标签”,AVIRIS影像本身没有逐像素的地物类别标记,但我们可以通过光谱匹配(比如光谱角制图SAM),把影像里每个像素的光谱和库里的参考光谱做比对,用最相似的类别作为伪标签;二是做“训练参考”,直接把库里不同类别的光谱当作分类任务的样本,配合数据增强就能训练一个小型光谱分类网络。
USGS光谱库提供的文件格式里,最通用的是ASCII文本,每个样品一个文件,文件头部有若干注释行,之后是两列数据。用起来不复杂,但需要自己处理单位、注释行这些细节。
1.3 整个处理链路长什么样
把这条链路拆开,大致是这么几步:
- 从NASA数据门户检索并下载AVIRIS影像(ENVI格式)
- 解析
.hdr头文件,用Python正确读入反射率立方体 - 下载并解析USGS光谱库的参考光谱文本
- 剔除水汽吸收等坏波段,把USGS光谱重采样到AVIRIS的波段中心
- 构造标签(光谱匹配或人工解译区域)
- 封装成
torch.utils.data.Dataset,配置DataLoader进入训练
每一步都有隐藏的坑:下载授权、文件单位、波段顺序、内存占用,任何一个环节疏忽都会让你在后面的训练阶段莫名其妙地出错。下面挨个讲。
2. 数据落地:从NASA门户把AVIRIS影像下载到本地
2.1 注册Earthdata账号与免密下载配置
AVIRIS数据的下载入口统一走NASA Earthdata体系。可以理解成:先有一个统一登录账号,然后各家数据中心(GES DISC、ASF、ORNL DAAC等)共用这套账号,AVIRIS相关产品也在这个体系下面。
注册地址是Earthdata Login的注册页,填邮箱、姓名、机构,验证一下就完成,不需要审批,也不花钱。注册完之后,最常见的问题是浏览器下载大文件时中断,所以正规做法是配置免密命令行下载。
我推荐用.netrc文件的方式。在用户主目录下新建一个.netrc文件,写入:
machine urs.earthdata.nasa.gov login 你的用户名 password 你的密码然后设置文件权限,避免被系统拒绝:
chmod 600 ~/.netrc这样wget请求Earthdata相关链接时就会自动带上凭证,不用每次手动输入。
如果需要更安全的方式,也可以在Earthdata个人资料页生成一个Bearer Token,下载时加在请求头里。.netrc对个人研究项目来说已经完全够用。
2.2 在AVIRIS数据门户检索目标航线
AVIRIS数据有两个常用的检索入口。一个是NASA Earthdata Search,它像一个跨数据中心的搜索引擎,可以按时间段、区域、关键词检索,地图上框选你要的区域,然后直接加入下载列表;另一个是JPL维护的AVIRIS数据门户,更适合按航线名找历史飞行数据,界面上能看到每条航线的时间、地点、仪器配置。
检索时要注意产品类型。我们做光谱库匹配和深度学习,要选地表反射率产品(_rfl),别选辐射亮度(_rad)或者未定标的原始数据。反射率产品已经做过大气校正,数值范围通常在0到1附近,和USGS光谱库里的反射率可以放在同一个量纲下比较。
选定数据后,Earthdata Search会在右上角生成一个下载列表,你可以把整个列表抓下来。每个产品条目对应的下载链接指向具体的数据服务器,常用协议是https,也可以选azcopy、wget等方式。
上面说的这些下载路径都会要求带登录凭证,所以把.netrc配置好再下载,能省下一堆询问账号密码的麻烦。
2.3 用wget批量下载与文件完整性校验
单景AVIRIS影像的ENVI格式文件通常几百MB到一两GB,多航线下载时用浏览器显然不现实,必须上命令行。我常用的下载命令是:
wget --continue --user=用户名 --password=密码 \ --header="Host: urs.earthdata.nasa.gov" \ --auth-no-challenge \ "https://一些具体数据下载链接"如果已经配置好.netrc,--user和--password都可以省掉。--continue参数非常重要,网络一断,重新执行同一个命令会从断点继续,不用从头再来。
如果有几十个链接,可以把所有URL写进一个文本文件,然后循环下载:
cat urls.txt | xargs -P 4 -n1 wget --continue-P 4表示同时开4个下载任务,这个并发数比较稳妥,太快容易被服务器限流。
下载完成后千万别急着解压和读取,先做完整性校验。用ls -lh核对文件大小和门户页标注的Content-Length是否一致,如果下载工具支持,也可以用.md5或.chk校验文件。我有一次就是文件少了最后几十KB,程序能打开但数据后半段全是错乱的,后来浪费了两个小时排查才意识到是文件不完整。
提示:AVIRIS的ENVI产品常常是以
.tar.gz或.zip打包的,解压后注意看目录里是否同时存在.hdr和.img。这两个文件缺一个,后面的数据读取都会失败。
3. 解析ENVI格式:把二进制影像变成Python里的numpy数组
3.1 ENVI头文件里的关键字段
ENVI头文件是纯文本,VIsualization环境下一般长这样:
ENVI description = {AVIRIS-NG Reflectance Product} samples = 800 lines = 1800 bands = 425 header offset = 0 file type = ENVI Standard data type = 4 interleave = bip byte order = 0 wavelength units = Nanometers wavelength = {382.98, 387.02, 391.06, ...} fwhm = {5.0, 5.0, 5.0, ...}几个关键字段要弄清楚:
samples:每行像素数,相当于宽度lines:行数,相当于高度bands:波段数data type:二进制体的数据类型编码,4表示float32interleave:数据排列方式,常见BIP、BIL、BSQwavelength:每个波段的中心波长,单位由wavelength units决定fwhm:每个波段的半高全宽,做重采样时会用到
我见过有人直接把.img按行列波段reshape,结果完全混乱,其实就是把interleave忽略了。BIP的意思是第一个像素的所有波段排在一起,接着第二个像素的所有波段;BIL是先排完一行的所有波段;BSQ则是把所有波段的数据分块存放。这样设计本来是出于不同的IO访问偏好,但对使用者来说就是个巨大的坑。
3.2 spectral库的正确打开方式
Python处理ENVI格式,我建议直接用spectral包,它封装了ENVI头文件的解析和多种interleave的读取,还提供了open_memmap这种不把整个文件读进内存的方法。安装很简单:
pip install spectral读取方式非常直接:
from spectral.io import envi # 打开头文件,自动关联同名的 .img img = envi.open('ang20150420t233546_rfl_v1y9.hdr') print(img.metadata['samples']) print(img.metadata['lines']) print(img.metadata['bands']) print(img.metadata['interleave'])如果要把数据真正拿到内存里,最朴素的做法:
data = img.load() print(data.shape)load()会把整个立方体读进来,对AVIRIS这种大场景来说可能瞬间吃掉几个GB内存,所以我更推荐用open_memmap:
memmap_img = img.open_memmap(interleave='bip') print(memmap_img.shape) # (lines, samples, bands)open_memmap返回的是一个内存映射数组,Python侧只是持有一个文件映射的视图,真正访问到哪个像素才会从磁盘读取哪一部分,这在构建Dataset时可以显著降低内存压力。
如果你手头只有.img和.hdr分离的两个文件,无法保证同名,可以用envi.open(hdr_path, img_path)显式传入两个路径。我自己封装了一套辅助函数,读取时就强制指定interleave='bip',这样后续所有代码都把数据当(行, 列, 波段)处理,逻辑统一不易出错。
3.3 USGS光谱库文本文件的读法
USGS光谱库从官网下载后,目录里按章节组织,比如矿物、土壤、植被、人造材料等。每个样品的ASCII文件开头有大量以#开头的注释,记录样品名称、产地、采集方式等信息,正文是两列数据:波长和反射率。
先给一个常见的文件片段示例:
# SPLIB07A: Vermiculite GDS22 # ... 0.3950 0.1023 0.4000 0.1048 0.4050 0.1065 ...这里有个极易踩坑的细节:USGS光谱库的波长单位通常是微米(µm),而AVIRIS头文件里的波长单位通常是纳米(nm)。直接把两组数据放在一起做匹配,结果会完全对不上。所以解析时第一步就是把波长统一成纳米:
import numpy as np def load_usgs_spectrum(filepath): wv = [] ref = [] with open(filepath, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue parts = line.split() try: wave_um = float(parts[0]) refl = float(parts[1]) except ValueError: continue wv.append(wave_um * 1000.0) # 微米 -> 纳米 ref.append(refl) return np.array(wv), np.array(ref)USGS光谱库同时提供ENVI格式版本,叫作splib07a ENVI,里面每个光谱也是.hdr加数据文件的组合。用ENVI格式的好处是可以直接用spectral库读取,省去自己解析注释行的麻烦,但文本格式更通用,不受库版本影响,所以我个人还是倾向于文本解析。
4. 光谱预处理:坏波段剔除与库光谱重采样
4.1 水汽吸收段和坏波段的筛除
AVIRIS反射率产品虽然已经做了大气校正,但大气水汽吸收强烈的区间,地表反射信号仍然很弱,噪声很大,这些波段一般在1350-1450纳米和1800-1960纳米附近。另外传感器边缘的一些波段也可能出现响应异常。
如果直接把这些坏波段喂给模型,等于给模型输入了大量高噪声特征,影响分类精度,也浪费计算资源。正确的做法是先根据波长筛选出好的波段索引。
def get_bad_band_mask(wavelength): """波长单位: nm""" mask = np.zeros_like(wavelength, dtype=bool) # 大气水汽强吸收区间 mask |= ((wavelength > 1340) & (wavelength < 1450)) mask |= ((wavelength > 1800) & (wavelength < 1960)) # 传感器边缘异常区间,根据实际产品微调 mask |= (wavelength < 400) mask |= (wavelength > 2490) return mask bad_mask = get_bad_band_mask(wavelength_nm) good_bands = np.where(~bad_mask)[0]对这个good_bands索引,我建议单独存成一个npy文件,后续所有步骤统一使用,避免在不同脚本里重复定义。还要提醒一句:坏波段剔除是按“波段索引”进行的,和USGS光谱库重采样之后的“波段索引”要保持一致,否则后面做光谱匹配时维度会对不上。
4.2 将USGS参考光谱重采样到AVIRIS波段中心
AVIRIS的波段中心波长和USGS光谱库的采样波长并不是一一对应的。USGS光谱库的数据通常按固定波长间隔均匀采样,而AVIRIS的波段中心是离散且不完全是均匀间隔的。要让两者可比,必须把USGS光谱重采样到AVIRIS的波段中心上。
最简单的线性插值可以用numpy.interp:
usgs_refl_resampled = np.interp( aviris_wavelength_nm[good_bands], usgs_wavelength_nm, usgs_reflectance )线性插值速度快,但和真实的成像光谱仪响应过程不完全一致。AVIRIS每个波段实际上是具有一定带宽的响应函数,理想的做法是用高斯响应函数做卷积积分。AVIRIS的fwhm在头文件里有,高斯标准差和FWHM的换算关系是:
[ \sigma = \frac{FWHM}{2\sqrt{2\ln 2}} ]
我写过一个高斯重采样函数:
def gaussian_resample(source_wv, source_ref, target_wv, fwhm=5.0): """把源光谱按每个目标波段的高斯响应重采样。 source_wv: 源光谱波长,纳米 source_ref: 源光谱反射率 target_wv: 目标波段中心波长,纳米 fwhm: 目标传感器波段半高全宽,纳米 """ sigma = fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0))) resampled = np.zeros_like(target_wv, dtype=np.float32) for i, wv in enumerate(target_wv): weights = np.exp(-0.5 * ((source_wv - wv) / sigma) ** 2) weights /= weights.sum() resampled[i] = np.dot(weights, source_ref) return resampled这个函数对每条USGS光谱调用一次,得到的就是和AVIRIS波段中心对齐的参考光谱。实际计算时,可以把所有USGS光谱组织成一个(N, M)的矩阵,M是源光谱的波长点数,然后向量化循环,速度会快很多。几百条光谱、几十个目标波段,即使是逐条循环也完全够快,不用过度优化。
4.3 标签构造与归一化策略
有了重采样后的USGS参考光谱,就可以生成AVIRIS影像的伪标签了。常用的匹配算法是光谱角制图(Spectral Angle Mapping,SAM),它把每条光谱看作高维空间里的一个向量,计算待测像素与参考光谱之间的夹角,夹角越小越相似。这个方法的优点是受光照和整体亮度变化影响小,因为只关心方向不关心模长。
def sam_match(pixel, library): """pixel: (B,), library: (N, B),返回最相似库索引和角度""" dot = library @ pixel norm_lib = np.linalg.norm(library, axis=1) norm_pix = np.linalg.norm(pixel) cos_theta = dot / (norm_lib * norm_pix + 1e-10) cos_theta = np.clip(cos_theta, -1.0, 1.0) angles = np.arccos(cos_theta) best = int(np.argmin(angles)) return best, angles[best]对这个匹配结果,我建议设定一个角度阈值,超过阈值说明影像里这个像素的光谱在库里找不到合适参考,可以直接标记为“未知类”,不参与训练。经验上,5到10度的阈值比较常见,具体看场景复杂度。
归一化策略同样不能马虎。最常见的错误是先在全图上算均值和标准差再切训练验证集,这会造成数据泄漏,验证集的统计信息被训练集污染了。正确做法是先切分空间区域,再在训练区域上统计均值和标准差,然后把同样的统计量应用到验证集和测试集。
我习惯把统计量存成字典:
norm_params = { 'mean': mean.astype(np.float32), 'std': std.astype(np.float32), 'good_bands': good_bands.astype(np.int32) } np.save('aviris_norm_params.npy', norm_params, allow_pickle=True)后面构建Dataset时直接加载,保证训练、验证、推理三段流程用的是同一套统计参数。
5. 工程化封装:从数组到torch.utils.data.Dataset
5.1 Dataset设计:memmap延迟加载与长度定义
现在到了重头戏:把处理好的AVIRIS数据封装成PyTorch的Dataset。这一步的核心思路是“按需读”,不要让__init__阶段就把整个立方体读进内存。spectral库的open_memmap正好配合这一点。
import numpy as np import torch from torch.utils.data import Dataset from spectral.io import envi class AVIRISDataset(Dataset): """从ENVI格式AVIRIS影像构建PyTorch Dataset。 参数: hdr_path: ENVI头文件路径,自动关联同名.img good_bands: 保留的有效波段索引 numpy数组 patch_size: 1表示单像素光谱,否则取正方形邻域 normalize: 是否做z-score标准化 sample_cap: 限制样本总量,用于大场景降采样 transform: 可选的在线数据增强 """ def __init__(self, hdr_path, good_bands, patch_size=1, normalize=True, sample_cap=None, transform=None): self.env_img = envi.open(hdr_path) self.meta = self.env_img.metadata self.rows = int(self.meta['lines']) self.cols = int(self.meta['samples']) self.num_bands = int(self.meta['bands']) self.patch_size = patch_size # 强制以BIP方式映射,索引统一为 (行, 列, 波段) self.array = self.env_img.open_memmap(interleave='bip') self.good_bands = np.asarray(good_bands, dtype=np.int64) self.normalize = normalize self.transform = transform self.mean = None self.std = None self.total_pixels = self.rows * self.cols if sample_cap is not None and sample_cap < self.total_pixels: rng = np.random.RandomState(42) self.indices = rng.choice(self.total_pixels, sample_cap, replace=False) else: self.indices = None def set_normalization(self, mean, std): self.mean = mean.astype(np.float32) self.std = std.astype(np.float32) + 1e-6 def compute_normalization(self, n_pixels=20000): """随机抽部分像素统计均值和标准差,供fit之后保存复用。""" rng = np.random.RandomState(42) pix = rng.choice(self.total_pixels, min(n_pixels, self.total_pixels), replace=False) rows = pix // self.cols cols = pix % self.cols block = self.array[rows, cols][:, self.good_bands].astype(np.float32) self.mean = block.mean(axis=0).astype(np.float32) self.std = block.std(axis=0).astype(np.float32) + 1e-6 def __len__(self): if self.indices is not None: return len(self.indices) return self.total_pixels def __getitem__(self, idx): if self.indices is not None: idx = int(self.indices[idx]) row = idx // self.cols col = idx % self.cols if self.patch_size <= 1: # 单像素光谱: (B,) spec = self.array[row, col][self.good_bands].astype(np.float32) x = torch.from_numpy(spec) else: # Patch: (B, H, W) pad = self.patch_size // 2 r0, r1 = max(0, row - pad), min(self.rows, row + pad + 1) c0, c1 = max(0, col - pad), min(self.cols, col + pad + 1) patch = self.array[r0:r1, c0:c1][..., self.good_bands] patch = np.ascontiguousarray(patch, dtype=np.float32) x = torch.from_numpy(patch).permute(2, 0, 1) if self.normalize and self.mean is not None: mean_t = torch.from_numpy(self.mean) std_t = torch.from_numpy(self.std) if x.dim() == 3: x = (x - mean_t.view(-1, 1, 1)) / std_t.view(-1, 1, 1) else: x = (x - mean_t) / std_t if self.transform is not None: x = self.transform(x) return x这段代码有几点要说明。第一,open_memmap(interleave='bip')是打开文件的视图,不拷贝数据,所以Dataset初始化很轻量。第二,__len__返回的是像素总数,对AVIRIS这种大场景,一景影像可能有上百万像素,每个epoch全量遍历会非常慢,因此我用sample_cap随机抽一部分像素参与训练。第三,__getitem__里的索引计算用idx // cols和idx % cols这种整除取余的方式,比每次都传行列坐标更简单,也省去维护一张大索引表的内存开销。
5.2 像素级与patch级取样的实现
上面代码里已经包含了单像素和patch两种模式。为什么要支持patch级?因为单像素光谱只能捕捉光谱维的信息,完全丢失空间上下文。而实际地物是有空间连续性的,相邻像素通常是同一种地物,用一个以目标像素为中心的邻域patch作为输入,可以同时利用光谱和空间特征,在大多数高光谱分类任务里精度会明显提升。
patch大小一般取奇数,比如5x5、9x9、11x11。太小了空间上下文不足,太大了又可能跨越多个地物类别,引入大量混合像元。我试过在AVIRIS-NG数据上做矿物填图实验,9x9的patch比单像素方案在测试集上的总体分类精度高3到5个百分点,但训练时间也相应增长。如果只是做快速原型验证,先用1x1单像素模式把流程跑通,再切到patch模式调参,这是比较务实的推进方式。
边界像素取patch时会超出行列范围,所以代码里用了max和min把patch边界限制在有效范围内。这样处理后的边界patch会比正常patch小一圈,实际使用中问题不大,因为边界像素本来信息就不完整。
5.3 DataLoader配置与训练集/验证集划分
Dataset封装好之后,DataLoader的配置也有讲究。先看一个基本用法:
from torch.utils.data import DataLoader train_ds = AVIRISDataset('train.hdr', good_bands, patch_size=9, normalize=True) train_ds.compute_normalization() val_ds = AVIRISDataset('val.hdr', good_bands, patch_size=9, normalize=True) val_ds.set_normalization(train_ds.mean, train_ds.std) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, drop_last=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)关于训练集/验证集划分,我强烈建议按空间块划分,而不是随机打乱像素。高光谱影像存在很强的空间自相关,相邻像素的光谱几乎一样,如果随机划分,验证集里会出现大量和训练集几乎重复的像素,验证精度会虚高。按块划分的做法是:把影像沿行方向切成上下两块,上面训练、下面验证;或者隔行采样。这样验证结果才真实反映模型的泛化能力。
num_workers的数值要结合机器配置调。AVIRIS的patch读取落在磁盘IO上,num_workers开4到8通常能显著提速,但并不是越大越好,worker进程之间争抢磁盘IO反而会变慢,建议实测几个值再定。pin_memory=True在GPU训练时能减少CPU到GPU的拷贝时间,只要显存够就开着。
提示:如果你的训练数据来自多个AVIRIS场景,别把不同场景的数据混在一个Dataset里。不同飞行日期的光照、大气条件不同,反射率分布可能有偏移,按场景划分数据集更公平。稍微讲究一点的做法是先做逐场景的直方图匹配,再做统一归一化。
6. 一路踩过的坑:下载、内存与训练IO
6.1 下载侧:断点续传与权限问题
下载踩的坑集中在两类。一是断点续传失效,根源是下载链接带有时效性token,过期后-c续传拿不到新token,直接报403。解决办法是失效后重新获取最新链接,再执行wget -c。二是.netrc配置好但仍然被要求输密码,通常是因为没有加--auth-no-challenge参数,或者Python生态里某些库不走curl/wget的netrc逻辑。如果用的是Python的requests,需要手动构造Auth:
import requests from requests.auth import HTTPBasicAuth resp = requests.get(url, auth=HTTPBasicAuth(user, password), stream=True)另外一个不太起眼但很实际的问题是磁盘空间。AVIRIS单景解压后可能占1-2GB,如果同时下多景,记得预留足够空间。下载脚本里最好加一个磁盘剩余空间检查,别全下完了才发现写不下。
6.2 数据侧:interleave、单位与dtype的陷阱
数据解析阶段的常见问题,我整理成一个速查表:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 读取后shape不对 | interleave设置错误 | 强制open_memmap(interleave='bip') |
| 数值范围异常 | data type读错 | 检查头文件data type,4为float32,12为uint16 |
| 波长对应不上 | 单位不统一 | USGS光谱库用微米,AVIRIS用纳米,统一后再匹配 |
| 光谱曲线出现毛刺 | 未剔除坏波段 | 先按水汽区间和边缘异常筛除波段 |
| 训练loss不降 | 数据未归一化或标签有NaN | 检查反射率是否含Fill Value,先mask掉 |
这里特别提一下Fill Value问题。AVIRIS反射率产品中对无效像元通常填充一个极值或者NaN,直接把NaN送进模型会污染梯度。我处理的做法是:在生成训练样本时,先判断像素是否有效,一个有效像素的反射率应该落在合理的物理范围,比如0到1.5之间,超出就丢弃。或者在Dataset里加一个valid_mask,配合WeightedRandomSampler控制采样权重。
还有一个不少人忽略的坑:USGS光谱库的光谱曲线可能本身带有少量异常值(比如负反射率、数据缺口),重采样前最好做一次中值平滑或者插值填缺,不然重采样后这些异常会被“抹开”到邻近波段,影响匹配结果。
6.3 训练侧:内存爆炸、数据增强与随机抽样
内存问题是训练阶段最先冒出来的。有人直接把整个AVIRIS立方体load()到内存,一景1GB的影像加上float32转换,内存占用轻松翻几倍,最后就是MemoryError。用memmap之后内存占用基本稳定在单batch的大小附近,整个训练过程流畅得多。这里有个坑要单独说:memmap对象不能在多进程DataLoader的worker里被反复序列化,spectral库的文件对象本身也不是所有场景都线程安全。如果训练时遇到奇怪的崩溃,可以把num_workers先改成0验证一下,确认没问题再调高,或者直接在worker初始化阶段单独打开文件句柄。
数据增强方面,遥感高光谱和普通RGB图像不太一样。光谱维可以做加性噪声、乘性亮度扰动、光谱偏移、随机波段丢弃;空间维可以用随机裁剪、水平翻转、垂直翻转、旋转变换。但不建议做全局仿射变换或颜色抖动,因为高光谱的每个波段都有物理意义,随意改变光谱形态会破坏地物诊断特征。一个比较实用的组合是:小幅的加性高斯噪声加随机空间翻转。
随机抽样的问题上面提过,我再补充一个细节。如果场景里某些地物类别像素很少,直接用均匀随机抽样会导致类别严重不均衡。我的经验是先用USGS光谱库匹配生成伪标签,然后用WeightedRandomSampler按类别频率的倒数设置权重,让小类别也能被抽到。这个操作在代码里只是多几行配置,但对训练结果的影响非常明显。
from torch.utils.data import WeightedRandomSampler # labels 是长度为N的伪标签数组 class_counts = np.bincount(labels, minlength=num_classes) weights = 1.0 / class_counts[labels].astype(np.float32) sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) train_loader = DataLoader(train_ds, batch_size=64, sampler=sampler, num_workers=4, pin_memory=True, drop_last=True)最后再分享一个小技巧:把good_bands、归一化参数、波长数组、USGS参考光谱这些预处理产物统一打包保存,后续每次跑实验都从这份缓存加载,不要每次都重新解析原始那份1GB的ENVI数据。我自己的项目目录里会放一个cache/文件夹,保存good_bands.npy、norm_params.npy、library_reflectance.npy、library_labels.npy,实验脚本启动时间从几分钟降到了几秒钟。数据准备流程本身已经足够复杂,能缓存就缓存,把精力留给真正要调的网络结构上。这套流程我自己跑了两个月的矿物填图实验,从下载到出第一版模型,现在基本能做到一小时以内完成全部数据准备,希望这个整理也能帮你少走点弯路。