
第一次做高光谱图像分类实战很多人的第一反应是直接上3D CNN或者各种注意力机制模型结果数据预处理还没搞明白就被复杂的网络结构折腾到怀疑人生。我的建议很直接入门阶段就用PyTorch写一个2D CNN先把高光谱图像分类的完整链路走通再去碰那些花哨模型。这套方案代码量小、思路清楚Indian Pines这种经典数据集在普通显卡上几分钟就能训练一轮非常适合作为遥感深度学习的第一课。这篇文章就围绕基于PyTorch的高光谱图像分类2D_CNN网络实战来写。我会把实际项目中总结下来的数据预处理方法、网络结构设计、训练技巧和踩坑记录都整理出来代码可以直接复制去跑。你不需要懂太深的遥感背景只要会一点Python和PyTorch基础就能跟着把整个流程跑通并且在测试集上拿到一个还不错的分类精度。1. 先搞懂高光谱图像分类到底在做什么1.1 高光谱图像和普通照片差在哪普通相机拍出来的照片一般只有3个波段也就是红、绿、蓝。高光谱成像仪不一样它能在几百个连续的窄波段上同时成像每个波段对应一条光谱通道。拿Indian Pines数据集来说原始图像是145乘以145像素去掉了噪声严重的吸水波段之后每个像素带有200个波段的光谱信息。这意味着每个像素不再是一个简单的RGB颜色值而是一条记录了地物反射率随波长变化的光谱曲线。不同物质的光谱曲线差异很大比如植被在近红外区间反射率会明显升高水体则表现为强烈的吸收特征。高光谱分类的核心就是利用这种丰富的光谱信息来判断每个像素属于哪一类地物。但波段多不一定是好事。200个波段的数据维度很高如果直接用原始光谱向量去做分类容易陷入维度灾难而且标注样本往往很少模型很容易过拟合。好在高光谱图像还有一个特点相邻波段之间相关性很强信息冗余度高这给PCA这类降维方法留了很大空间后面我会详细讲。在分类任务里高光谱图像通常需要同时利用光谱信息和空间信息。只使用单个像素的光谱曲线相当于把每个像素孤立看待忽略了地物的空间上下文。而图像里的地物往往是成片分布的比如一块农田里相邻的像素大概率属于同一个类别。2D CNN处理高光谱图像时正是通过截取像素周围的邻域小块来同时获取光谱和空间特征。1.2 2D CNN凭什么能处理高光谱数据很多初学者会有一个疑问高光谱图像有几百个波段2D CNN的卷积核是二维的怎么处理这种数据其实很简单把波段数当作通道数就行了。在PyTorch里Conv2d的输入要求是(batch_size, channels, height, width)这个形状。我们截取一个像素周围11乘以11的邻域每个邻域有200个波段那这个patch的形状就是(200, 11, 11)。把200当通道数、11当高和宽直接就能喂给2D卷积层。也就是说2D卷积是在空间维度上滑动同时综合考虑所有波段的特征。这里有一个特别容易搞混的地方。原始高光谱数据以numpy数组加载进来时形状往往是(rows, cols, bands)也就是通道在最后一维。直接把这个数组截成patch后得到的是(patch_size, patch_size, bands)必须转置成(bands, patch_size, patch_size)才能输入模型。我在最开始做这个项目的时候就是忘了这一步结果模型输入维度报错排查了半天。那为什么不直接用3D CNN呢3D CNN确实能同时建模空间维度和光谱维度之间的关系分类精度理论上更高但代价是参数量成倍增加、训练时间长、对小样本数据集更容易过拟合。2D CNN的参数少、收敛快、代码实现简单作为基线模型非常合适。先跑通2D CNN后面再升级到HybridSN这类混合模型也能有一个清晰的对比基准。2. PyTorch环境与数据预处理2.1 搭建环境先把PyTorch跑起来做这个项目之前首先得有一个能用的PyTorch环境。我推荐用Anaconda创建独立环境免得把系统Python搞乱。装的时候不需要纠结太多直接按官方命令来就可以。conda create -n hs python3.9 conda activate hs pip install torch torchvision如果你是NVIDIA显卡并且想用GPU加速建议前往PyTorch官网的Get Started页面根据你的CUDA版本选择对应的安装命令通常会包含一个cu121或者cu118之类的标识。安装完之后在Python里验证一下环境是否正常import torch print(torch.__version__) print(torch.cuda.is_available())如果torch.cuda.is_available()返回True说明GPU版本安装成功。返回False也不代表项目没法做Indian Pines全图才145乘以145200个波段数据集非常小CPU训练也能在可接受的时间内跑完。我第一次做这个实验的时候就是在笔记本CPU上跑的训练10个epoch也就几分钟。还有一个常见问题是版本不匹配。有些朋友安装PyTorch的时候不看Python版本结果import直接报错。建议Python用3.8到3.11之间的版本对应PyTorch 1.13到2.x都行尽量不要追最新稳定才是关键。Linux下如果用WSL提醒一下要保证显卡驱动是Windows侧安装的WSL里面不需要重复装驱动但需要安装与驱动匹配的CUDA toolkit相关内容。这里不展开太多遇到问题去查对应系统的安装日志就好。2.2 数据预处理从.mat到训练样本高光谱分类领域最常用的数据集之一就是Indian Pines。网上可以下载到两个关键文件一个是修正后的高光谱数据一般是Indian_pines_corrected.mat另一个是地面真值标签一般是Indian_pines_gt.mat。两个都是MATLAB的.mat格式在Python里用scipy读取就行。import scipy.io as sio data sio.loadmat(Indian_pines_corrected.mat)[indian_pines_corrected] gt sio.loadmat(Indian_pines_gt.mat)[indian_pines_gt] print(data.shape) # (145, 145, 200) print(gt.shape) # (145, 145)数据加载进来后第一步是归一化。高光谱数据各个波段的数值范围不太一样有的在0到1之间有的可能到几千直接输入网络会让训练很不稳定。常用做法是逐波段做z-score标准化也就是对每个波段单独减去均值、除以标准差。这样每个波段的数值都归一到零均值、单位方差的分布模型收敛会快很多。def normalize(data): rows, cols, bands data.shape data data.reshape(-1, bands) for b in range(bands): mean data[:, b].mean() std data[:, b].std() data[:, b] (data[:, b] - mean) / (std 1e-8) return data.reshape(rows, cols, bands)归一化之后我会用PCA把200个波段压缩到30个主成分。这里为什么压缩30而不是保留全部一方面相邻波段高度相关200个波段里可能有一大部分的方差信息可以用前几十个主成分表示另一方面输入通道减少后模型参数量会显著下降训练速度更快过拟合风险也更低。用sklearn实现非常方便。from sklearn.decomposition import PCA def apply_pca(data, n_components30): rows, cols, bands data.shape data_2d data.reshape(-1, bands) pca PCA(n_componentsn_components) data_pca pca.fit_transform(data_2d) return data_pca.reshape(rows, cols, n_components), pca这里要注意PCA是对每个像素的光谱向量降维是在像素维度上做的不是对整张图做卷积式的处理。fit_transform的输入是像素数乘以波段数输出是像素数乘以30。接下来是构造patch。所谓patch就是给图像里每个有标签的像素从它周围截取一个11乘以11的邻域小块。这样每个训练样本就同时包含中心像素的光谱特征和周围像素的空间特征。边界上的像素不够11乘以11怎么办用np.pad做reflect填充也就是以边界为镜面反射填充这样不会像零填充那样引入大量无意义的黑色边缘。import numpy as np def create_patches(data, gt, patch_size11): rows, cols, bands data.shape pad patch_size // 2 data_pad np.pad(data, ((pad, pad), (pad, pad), (0, 0)), modereflect) patches [] labels [] for i in range(rows): for j in range(cols): if gt[i, j] 0: continue patch data_pad[i:ipatch_size, j:jpatch_size, :] # 转换为 PyTorch 的 NCHW 格式 patch patch.transpose(2, 0, 1) patches.append(patch) labels.append(gt[i, j]) return np.array(patches), np.array(labels) X, y create_patches(data_pca, gt, patch_size11) print(X.shape) # (num_samples, 30, 11, 11) print(y.shape) # (num_samples,)Indian Pines有标签的像素一共一万出头也就是X的第一个维度大概在10249左右。这个数据量对深度学习来说不大所以这类项目最大的挑战就是防止过拟合后面我会专门讲。2.3 样本划分别让数据泄漏坑了你数据准备好了接下来就是划分训练集、验证集和测试集。这一步看起来简单其实里面藏着一个很容易犯的错误空间数据泄漏。如果我们把所有有标签的像素随机打乱再划分那么同一个地物区域的邻近像素可能一部分进了训练集、一部分进了测试集。由于邻近像素在空间上高度相似模型可能“记住”了区域纹理特征而不是学习真正可分的光谱特征这会让测试精度虚高实际部署到新的遥感图像上的效果打折扣。更严谨的做法是按区域划分比如把整幅图切成若干块取其中几块做训练、剩余做测试或者在地理位置上划分训练和测试区域。但Indian Pines这种单景小数据集学术论文里大多数做法仍然是按像素随机划分只是会明确说明训练样本比例。做项目时我建议至少保证训练集和测试集来自不同的空间区域或者在做实验对比时固定随机种子保证可比性。划分数据时也要处理标签的问题。Indian Pines的gt里0表示背景1到16表示不同地物类别。我们的训练只针对有标签的像素因此create_patches里已经把0跳过了。但有些类别样本极少比如部分类别只有几十个像素随机划分后测试集可能只有几个样本评估结果波动很大。实践中有两种解决办法一是直接剔除样本数少于某个阈值的类别二是采用常用的9类划分版本。我自己做对比实验时会更倾向用类别过滤后的版本让每个类别的样本量相对均衡。3. 2D CNN模型搭建与训练流程3.1 网络结构一个简单又稳的基线有了输入样本接下来就是核心的模型定义。我先给大家一个非常经典、出效果也够稳的2D CNN结构输入是(30, 11, 11)的patch输出是类别数。整体思路就是卷积提取空间特征批归一化稳定训练池化降低特征分辨率最后全连接层做分类。import torch import torch.nn as nn class CNN2D(nn.Module): def __init__(self, in_channels, num_classes, patch_size11): super().__init__() self.conv1 nn.Sequential( nn.Conv2d(in_channels, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2) ) self.conv2 nn.Sequential( nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2) ) self.conv3 nn.Sequential( nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) ) # 计算全连接层输入维度 final_size patch_size // 4 # 经过两次 stride2 的池化 self.flatten_dim 128 * final_size * final_size self.fc nn.Sequential( nn.Dropout(p0.5), nn.Linear(self.flatten_dim, 256), nn.ReLU(inplaceTrue), nn.Dropout(p0.5), nn.Linear(256, num_classes) ) def forward(self, x): x self.conv1(x) x self.conv2(x) x self.conv3(x) x x.view(x.size(0), -1) x self.fc(x) return x为什么要加BatchNorm因为高光谱不同波段的数据分布差异很大归一化只在数据输入前做了一次但网络中间层的输出分布仍然可能偏移。BN层会在每个batch内部做归一化让中间特征分布稳定下来训练速度会明显加快对收敛效果很有帮助。为什么要加Dropout这个项目最大的问题不是模型表达能力不够而是样本太少。两层全连接之间加Dropout相当于在训练时随机丢弃部分神经元强迫网络学到更鲁棒的特征这是小样本训练里性价比很高的正则化手段。卷积核大小选3乘以3padding为1这样卷积不会改变特征图尺寸。池化层用stride2会把11乘以11的patch先变到5乘以5再变到2乘以2。所以最终进入全连接层前空间尺寸是2乘以2乘以128个通道得到512维特征。你可以自己算一下如果patch_size改成了13或者15这个flatten_dim也要相应调整。3.2 Dataset与DataLoader高效读取训练数据模型定义好之后需要把numpy数组包装成PyTorch的Dataset再用DataLoader加载。这一步看起来是模板代码但有几个细节容易出问题。from torch.utils.data import Dataset, DataLoader class HyperspectralDataset(Dataset): def __init__(self, X, y): self.X torch.from_numpy(X).float() self.y torch.from_numpy(y).long() def __len__(self): return len(self.y) def __getitem__(self, idx): return self.X[idx], self.y[idx] - 1 # 标签从1开始转成0开始这里有个细节Indian Pines的标签是1到16而PyTorch的CrossEntropyLoss要求类别从0开始。所以我在__getitem__里做了y减1的操作。如果你在用9类版本也要保证类别标号是连续的0到8不能有跳号否则计算损失时会报越界错误。DataLoader的配置也值得说一下。batch_size建议用64或者128shuffle设为True。num_workers在Windows下容易出多进程坑可以设为0Linux下可以适当调大到4或者8。pin_memory设为True可以加快GPU训练时数据从CPU传到GPU的速度。train_dataset HyperspectralDataset(X_train, y_train) test_dataset HyperspectralDataset(X_test, y_test) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers0, pin_memoryTrue) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse, num_workers0, pin_memoryTrue)把整个数据一次性放进Dataset是没问题的因为高光谱小数据集完全能装进内存。千万不要用ImageFolder那一套去读图片文件根本不需要也不够高效。3.3 训练循环与评估指标训练部分的代码是比较标准的PyTorch训练流程我建议把训练和验证分开写每轮epoch结束后及时看验证集精度方便判断是否过拟合。损失函数用CrossEntropyLoss优化器用Adam初始学习率设1e-3就足够。device torch.device(cuda if torch.cuda.is_available() else cpu) model CNN2D(in_channelsX.shape[1], num_classesnum_classes, patch_size11).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) epochs 60 best_acc 0.0 for epoch in range(epochs): model.train() train_loss 0.0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() output model(x) loss criterion(output, y) loss.backward() optimizer.step() train_loss loss.item() * x.size(0) model.eval() correct 0 total 0 with torch.no_grad(): for x, y in test_loader: x, y x.to(device), y.to(device) output model(x) pred output.argmax(dim1) correct (pred y).sum().item() total y.size(0) acc correct / total if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth) print(fEpoch {epoch1}/{epochs} | Loss: {train_loss/len(train_dataset):.4f} | Test Acc: {acc:.4f})训练完之后光看一个整体精度不太够。高光谱分类论文里最常用的是三个指标OA、AA和Kappa系数。OA是全体的分类正确率AA是每个类别分类精度的平均值Kappa是衡量分类结果和随机分类之间差异性的指标值越接近1代表分类一致性越好。想看这些指标用sklearn.metrics里的classification_report和cohen_kappa_score就行。from sklearn.metrics import classification_report, cohen_kappa_score # 在测试集上推理得到 pred_all 和 y_all print(classification_report(y_all, pred_all)) print(Kappa:, cohen_kappa_score(y_all, pred_all))如果想把分类结果可视化可以把模型对每个像素的预测类别填回145乘以145的网格里用matplotlib的imshow画出来背景类别设为白色这样能直观看到预测地物分布和真实GT的差异。这也是高光谱分类项目里很出效果的一步值得做一下。4. 常见问题与实战提效经验4.1 小样本过拟合训练集很猛验证集拉胯这个项目里最容易遇到的现象就是训练集精度很快就到99%以上但验证集或测试集只有80%左右。这就是典型的过拟合。高光谱标注像素少、数据维度高模型很容易把训练集的细节当作通用规律记住。解决过拟合不是一个方法就够的而是组合拳。首先可以在数据层面做增强比如对patch做随机旋转和翻转。2D CNN对旋转比较敏感做90度、180度旋转和水平翻转能非常自然地扩充样本量而且几乎不改变地物类别信息。其次可以加大Dropout比例从0.5加到0.7或者在全连接之前再加一层Dropout。第三可以引入early stopping当验证集精度连续多个epoch不提升时直接停止训练保存验证集最好的那一次模型。我实测下来的体感是PCA压缩到30维本身就是一个很强的正则化手段如果发现过拟合严重可以先试降到15维。有些项目用原始200个波段跑2D CNN精度反而不如PCA降维之后就是因为维度太高、样本太少模型把噪声也学进去了。训练集和测试集的划分方式也会直接影响对过拟合的判断。如果按像素随机划分因为空间相似性测试精度会偏高看起来好像没过拟合其实换一块区域效果就差很多。我建议至少在实验中设置好随机种子并用相同的划分方式做对比这样模型结构和超参数之间的差异才可信。4.2 显存不足、训练慢、损失不降怎么办先说显存溢出。Indian Pines数据集很小patch只有11乘以11batch_size64的情况下显存占用通常不会超过2G。如果你跑更大尺寸的数据集比如Pavia University或者Houston数据显存不够时优先减小batch_size把64改成32或者16通常问题就能解决。还不行就减小patch_size从11改成9输入通道数也会减少模型更轻。训练慢的问题要分情况看。如果是CPU训练建议把num_workers设为0避免线程切换的开销。如果是GPU训练但GPU利用率很低大概率是数据加载成了瓶颈这时候把num_workers调大比如4或者8并且确保数据预处理提前做完了Dataset里不要每次做PCA之类的重计算。pin_memoryTrue也能减少数据传输时间。损失不降或者训练一开始就NaN这种问题我也遇到过几次。一个常见原因是学习率太大Adam虽然对学习率不那么敏感但初学率调到1e-2仍然可能让loss爆炸。另一个原因是类别标签不连续或者从1开始CrossEntropyLoss内部会做索引查找如果类别编号超过num_classes就会报错或产生诡异行为。还有数据里有NaN值也会让loss变成NaN建议预处理后检查data_pca里有没有非有限值常用的手段是np.isnan(data_pca).any()打印一下。高光谱数据的标注类别不平衡也是一个容易被忽视的问题。Indian Pines里像大豆这类样本很多某些类别却只有几十个样本模型天然偏向多数类。这时候可以给损失函数加上类别权重权重与样本数量成反比让少量类别在损失计算中获得更高的权重。from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight(balanced, classesnp.unique(y_train), yy_train) class_weights torch.tensor(class_weights, dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)4.3 让分类精度再涨一截的进阶小技巧当基线2D CNN跑通之后想进一步提升精度有几个方向可以试难度都不大。第一个是调patch_size。patch_size决定了模型能看到多大的空间上下文。9、11、13、15这几个值都值得试一下。patch太小空间信息不足patch太大会引入更多不相关区域而且边界填充的伪数据也更多。Indian Pines上我一般从11开始试上下浮动对比。第二个是PCA的维度。我之前试过10、15、30、50四档15到30之间的效果通常最好。维度太低会丢掉可分的细节光谱信息维度太高又容易过拟合。这个参数可以和patch_size一起做网格搜索用验证集的OA做选择依据。第三个是更复杂的模型结构。如果2D CNN已经没问题了可以尝试用两个分支分别提取光谱和空间特征或者参考HybridSN的结构把3D卷积和2D卷积结合起来。不过我的建议始终是先保住2D基线拿到一套稳定代码和结果再逐步升级。很多同学一上来就HybridSN网络结构复杂以后排查问题很痛苦训练也慢最后反而不如一个调得好的2D CNN。另外类别过滤也是提升整体精度的一个手段。Indian Pines原始16类里面低于100个样本的类别对整体指标影响不大但会拖低AA。做论文对比时用9类版本是常见做法做工程项目时也可以根据实际需求合并相似类别比如把几个草地类合成一个草地类。这个思路在真实遥感分类任务里也很实用地物类别体系本来就是可设计的。最后一个小建议保存每次实验的配置和结果。高光谱实验涉及数据划分、patch大小、PCA维度、网络结构、学习率、随机种子等一堆变量稍不注意就忘了当前结果对应哪组配置。我习惯用实验记录表把这些参数和对应的OA、AA、Kappa全部记录下来后面写报告或者调参时才能心里有数。我自己在实际操作中最深的体会是高光谱图像分类这个任务决定精度的往往不是模型多复杂而是数据处理细节和实验规范程度。把归一化、PCA、patch提取、样本划分这些基础环节做扎实一个简单的2D CNN就能取得相当不错的分类效果。先把这套链路稳定跑通后面再加3D卷积、注意力机制都是顺水推舟的事。你按这个流程复现一遍再换成其他数据集跑一跑很快就能上手整个高光谱深度学习项目。