
简介一套面向计算机视觉课程设计与期末大作业的高分项目以卫星云层图像的理解与识别为课题提供完整Python源码、实验报告与可视化结果适合本科高年级学生用于课程设计、期末答辩或科研入门。项目覆盖数据读取、模型搭建、训练评估与结果展示全流程代码包含详细注释逻辑清晰便于新手快速理解核心思路。压缩包共收纳140个文件以70个Python脚本为主体另有预编译pyc、Shell脚本、CSV数据、yaml配置、结果图片、PDF实验报告和PPT演示文稿整体大小62.35MB目录组织合理方便按模块查阅实验数据、配置参数和输出样例。附带的实验报告系统梳理方法设计与误差分析配合多组测试对比有助于验证模型效果与撰写结题材料。目前已有216人学习下载简单部署即可运行实用性与完成度较高。1. 卫星云层图像的理解与识别一个能拿高分也能复现的计算机视觉大作业卫星云层图像的理解与识别是计算机视觉大作业里出现频率很高、但真正做明白的人不多的选题。很多同学拿到题目第一反应是找个预训练模型直接跑结果在五类云图混着、光照角度又乱的数据集上准确率卡在七成上下报告也写不出有价值的分析。这门作业真正考的不是深度学习理论背得多熟而是能不能把数据、模型、实验设计这三件事串起来。这篇文章给出一套完整可复现的流程公开数据集怎么选、预处理怎么做、ResNet迁移学习怎么调、报告怎么写。读完直接照着做就能交付一份能拿高分、经得起答辩追问的完整项目。2. 卫星云图数据与预处理公开数据集选型与归一化里的四个细节2.1 公开云图数据集怎么选三类常见方案与适用场景云图数据和普通猫狗数据集差别很大。大多数遥感原始数据是16位单波段或多波段文件有的还带投影坐标信息和云掩膜层学术竞赛里的数据则已经做好裁剪和标注拿到就能训练。大作业场景下我建议优先找两类现成资源一类是Kaggle上以卫星云图或遥感图像分类为题的竞赛数据集通常已经按类别分好文件夹直接下载就能喂给模型另一类是国内数据竞赛平台的遥感分类赛题数据很多来自高分系列或哨兵二号影像的裁剪图类别标注规范写报告时引用数据来源也方便。如果没有现成分类数据集用MODIS或葵花8的L1B产品自己裁剪也不是不行但我不建议大作业阶段碰原始气象数据。原因很实际你需要额外学Rasterio和GDAL去处理投影和波段还要自己生成云掩膜工作量已经超过大作业本身。判断数据选型就一条标准数据能不能在半小时内变成文件夹名等于类别名、里面全是JPG或PNG的结构能就用不能果断换。这个标准能帮你过滤掉大量看着高级、实际拖垮进度的数据源。2.2 读取与归一化16位遥感图如何变成模型能吃的张量拿到数据后的第一个坑是位深。遥感裁剪图经常以16位无符号整数的TIFF格式存储像素范围在0到65535之间直接用Image.open()读取再转成PIL图像模型的输入范围会是0到65535而不是0到255网络前向传播的数值分布直接被带偏训练时梯度要么爆炸要么消失。所以读取时要加一道位深探测的逻辑把图像转成numpy数组后看最大值如果超过255就按65535做归一化缩放到0到255再继续走后续流程。我用PyTorch实现这类数据集的读取时习惯把所有逻辑放在一个自定义Dataset类里而不是每次单独处理文件。这样训练、验证、测试三套流程共用一份代码改起来也方便。下面是核心实现。from pathlib import Path import numpy as np import torch from PIL import Image from torch.utils.data import Dataset from torchvision import transforms class SatelliteCloudDataset(Dataset): # root_dir 下要求是 class_name/images 的结构 def __init__(self, root_dir, modetrain, img_size224): self.root Path(root_dir) self.mode mode self.class_names sorted( [p.name for p in self.root.iterdir() if p.is_dir()] ) self.class_to_idx {name: i for i, name in enumerate(self.class_names)} self.samples [] for cls_name in self.class_names: cls_dir self.root / cls_name for img_path in cls_dir.iterdir(): if img_path.suffix.lower() in [.jpg, .jpeg, .png, .tif]: self.samples.append((str(img_path), self.class_to_idx[cls_name])) print(f[info] {mode} dataset: {len(self.samples)} images, fclasses: {self.class_names}) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label self.samples[idx] img Image.open(img_path) if img.mode ! RGB: img img.convert(RGB) img np.array(img, dtypenp.float32) # 关键探测位深16位图先缩放到0-255 if img.max() 255: img img / 65535.0 * 255.0 else: img img / 255.0 img Image.fromarray(img.astype(np.uint8)) img img.resize((img_size, img_size), Image.BILINEAR) if self.mode train: img self.train_transform(img) else: img self.val_transform(img) return img, label这段代码里最值得看的是位深探测那一行。先看最大值再决定除以65535还是255这个策略比写死一种归一化方式稳得多因为同一份数据集里可能混着8位和16位图写死就会出现白一片或者黑一片。类别名排序用sorted()保证字典顺序稳定避免不同机器上类别索引不一致这个细节后面还会在避坑章节展开。归一化参数使用的是ImageNet统计值mean[0.485, 0.456, 0.406],std[0.229, 0.224, 0.225]因为后面要做ResNet迁移学习预训练权重就是按这套统计值训练的。如果你完全从零训练模型可以改用数据自身的均值和方差但对大作业来讲沿用ImageNet统计值配合迁移学习是成功率最高的做法也能省去单独统计像素分布的时间。训练和验证的变换分别定义如下。train_transform transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])2.3 类别不平衡的先手处理不均衡抽样与样本权重卫星云图数据集里类别不平衡是常态。尤其是晴空这种大范围出现的类别样本量可能是积雨云的五六倍。如果不处理模型会学成一个偷懒的分类器不管输入是什么预测概率最高的总是样本量最大的那个类。最直接的处理方式是在DataLoader层面加WeightedRandomSampler让每个batch里小类别的样本出现次数被补上来。权重计算公式很简单每个类别的权重等于该类别样本数的倒数再对每个样本取所属类别的权重。from torch.utils.data import DataLoader from torch.utils.data.sampler import WeightedRandomSampler def make_weights_for_balanced(dataset): # dataset.samples 是 (img_path, label_idx) 的列表 labels [label for _, label in dataset.samples] class_counts torch.bincount(torch.tensor(labels)) class_weights 1.0 / class_counts.float() sample_weights torch.tensor( [class_weights[label] for label in labels] ) return sample_weights def build_dataloader(dataset, batch_size32, shuffleTrue): if shuffle: weights make_weights_for_balanced(dataset) sampler WeightedRandomSampler( weights, num_sampleslen(weights), replacementTrue ) loader DataLoader( dataset, batch_sizebatch_size, samplersampler, num_workers4 ) else: loader DataLoader( dataset, batch_sizebatch_size, shuffleFalse, num_workers4 ) return loader采样器里的replacementTrue表示有放回抽样小类别样本在训练过程中会多次重复出现。代价是每个epoch里同一张小样本图可能被看到两三次需要配合较强的随机增强来稀释重复感否则模型容易把小样本类别给背下来。如果数据集本身已经平衡就把shuffleTrue交给原始DataLoader不必动用采样器。训练集用采样器保证类间均衡验证集保持原始分布因为验证集准确率必须是真实分布下的准确率才能反映模型在实际场景里的表现。3. 用PyTorch搭建云图识别模型ResNet迁移学习的最小可跑代码3.1 模型选型为什么大作业先选ResNet18而不是自己堆卷积卫星云图和ImageNet里那些自然图像相比纹理更平滑、边缘更不清晰。这意味着模型既要浅层捕捉纹理细节也要深层捕捉云团整体形态。自己堆三层卷积加全连接不是不行但对大作业这种数据量有限的场景很容易欠拟合和过拟合二选一调参周期很长。ResNet18在ImageNet上预训练过的权重已经学会了边缘、纹理、颜色这些通用低级特征我们只需要换掉分类头让它在云图上微调。这是大作业场景里成功率最高的策略预训练权重负责通用特征提取后面几层负责云图特有的判别特征。为什么不建议直接上ResNet50或更深的模型因为云图数据集通常只有几千张到一两万张深度模型的参数量翻好几倍微调时后面层很快过拟合前面层又更新不动。ResNet18作为特征提取器完全够用而且显存占用小普通学生用的GTX 1660或RTX 2060都能轻松跑起来。等实验做完如果还想冲指标再换ResNet34或EfficientNet做对比实验作为报告里的模型对比部分性价比更高。这个思路跟做工程项目是一样的先跑通基线再谈提升。3.2 模型定义与数据加载一段能直接跑的完整代码模型部分我用torchvision内置的ResNet18先把最后一层全连接换成新任务所需的分类头。分类头不需要做太复杂一层Dropout加两个全连接就能打得很准做太复杂反而在小数据上更容易过拟合。下面是完整的最小可跑训练脚本框架。import torch import torch.nn as nn import torchvision.models as models def build_model(num_classes4, pretrainedTrue): if pretrained: model models.resnet18( weightsmodels.ResNet18_Weights.IMAGENET1K_V1 ) else: model models.resnet18(weightsNone) in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(p0.3), nn.Linear(in_features, 256), nn.ReLU(inplaceTrue), nn.Linear(256, num_classes) ) return model注意weights参数的写法。新版torchvision已经不再推荐用pretrainedTrue这种旧接口会提示deprecation warning要用weights...来指定预训练权重。ResNet18_Weights.IMAGENET1K_V1是官方在ImageNet上预训练好的版本如果下载权重时网络不稳定可以手动下载权重文件放到torch的cache目录再重新创建模型模型会自动加载本地缓存。我把Dropout放在全连接之前目的是让最后一层学到更鲁棒的特征。很多同学喜欢在整个ResNet前面加Dropout这基本没用因为卷积层本身参数共享Dropout放在后面全连接层才能参与训练并在推理时关闭。256这个中间维度也是实际调出来的从512直接降到4可能让分类头容量太小导致微调时特征表达不够准确率上不去。3.3 迁移学习冻结策略冻结哪些层、放开哪些层所谓微调不是所有层都要更新。对云图这种域差距比较大的图像我一般冻结模型前三个stage也就是layer1到layer3只训练layer4和分类头。理由是这样能保留ImageNet学到的通用边缘和纹理特征同时让深层针对云图的全局形态做调整既省显存又降低过拟合风险。def freeze_except_last_stage(model): for name, param in model.named_parameters(): if name.startswith(layer4) or name.startswith(fc): param.requires_grad True else: param.requires_grad False return model这里有个关键点layer4对应模型最深层的残差块它的输出直接进分类头所以它学到的是面向任务的语义特征必须放开训练。layer1到layer3保留预训练参数不动。如果数据集比较小这个策略能明显压制过拟合如果数据集有一万张以上也可以把layer3一起放开让它进一步适应云图的纹理分布。这个冻结多少层本身就是报告里可以写的一个对比实验点全微调、冻结前三层、冻结前四层三个设置各跑一遍验证集准确率的差异就是你实验部分最有说服力的内容。另外冻结后调用optimizer时要确保只把requires_gradTrue的参数传进去否则冻结层仍然会被更新并且还会占用优化器内存。常见做法就是filter(lambda p: p.requires_grad, model.parameters())。用这个冻结策略配合AdamW优化器学习率设3e-4通常30到40个epoch就能收敛在单张消费级显卡上训练时间不超过二十分钟。4. 卫星云图模型训练三组必调参数与消融实验设计4.1 学习率与优化器AdamW和余弦退火的搭配云图识别模型微调时优化器选择比想象中重要。直接用SGD配合稍大学习率预训练权重在前几个epoch会被破坏导致loss先降后升最后收敛到很差的局部最优。我一般用AdamW这是Adam加上权重衰减修正的版本对微调任务更稳。学习率从3e-4开始配合CosineAnnealingLR在30个epoch内逐渐降到接近零。这样前几个epoch能快速找到合适方向后面逐步收敛不容易震荡。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR optimizer AdamW( [p for p in model.parameters() if p.requires_grad], lr3e-4, weight_decay1e-4 ) scheduler CosineAnnealingLR(optimizer, T_max30) # 每个epoch结束之后调用 scheduler.step()weight_decay设1e-4目的是给分类头加一点正则防止它把训练集里的噪声也记住。如果发现验证集准确率波动特别大可以提高到5e-4。学习率不要大于1e-3很多同学上来就按默认的1e-2直接训loss直接飞掉。理论上加一个warmup让学习率从1e-5升到3e-4再进入退火会更顺但对大作业这种规模warmup收益不明显直接从固定学习率开始就行。如果你训练时发现前三个epoch的loss在下降但acc没动不用慌这是分类头在把预训练特征映射到新类别空间通常第4个epoch之后acc会突然跳起来。4.2 数据增强的边界云图不适合用的增强操作torchvision的transforms里那些常见增强用到云图上需要筛选。随机水平翻转、垂直翻转、旋转这三项可以放心用因为卫星拍摄的云图没有上下颠倒的概念旋转30度依然是合法云图。但有些操作会改变云的物理含义最典型的是RandomErasing和Cutout它们会把一块云抹掉这跟云图上天然存在的遮挡不一样模型学到的是云可以被随意抹掉推理时会混乱。色彩抖动也要慎用云的灰度梯度是判型的重要线索把对比度拉太高或者色调偏移积云和层云会变得更难区分。我的训练增强配置是Resize到224、随机水平翻转、随机垂直翻转、随机旋转15度、ColorJitter只调亮度和对比度0.2。验证集只用Resize和Normalize。这套配置的核心思路是只做几何不变性增强不做物理属性增强因为云的类型本身就由物理属性决定。如果你用的是公开竞赛数据增强策略可以直接沿用这一套如果你自己裁了数据还要额外考虑裁剪窗口的位置偏差适当加入RandomResizedCrop模拟不同尺度的云团但裁剪比例不要小于0.6否则会切掉太多上下文。4.3 训练循环与实验记录一份能写进报告的消融实验表有了数据、模型、增强策略接下来就是按实验设计来训练。大作业不像论文那样要求几十组对照但三组对比实验是必须的预训练与从零训练对比、增强与无增强对比、冻结与全微调对比。每组的训练设置完全一致只改一个变量然后记录准确率。下面是训练循环的核心代码包含训练和评估两个函数。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, preds torch.max(outputs, dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) _, preds torch.max(outputs, dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total训练过程中的记录格式也很重要。我习惯每个epoch打印一行日志包含epoch号、训练loss、训练准确率、验证loss、验证准确率、当前学习率。这些数值直接抄到CSV里最后用matplotlib画两条曲线一张是train和val的loss曲线一张是train和val的acc曲线放进报告里就是很扎实的实验结果展示。实验记录建议用这种三列结构把三组实验的结果填进去实验配置验证集准确率结论预训练 增强 冻结填写你的结果完整方案的baseline从零训练 增强填写你的结果预训练带来的提升量预训练 无增强 冻结填写你的结果增强带来的提升量每组实验设置完全一致只改一个变量这样报告里写预训练带来X个百分点的提升、数据增强带来Y个百分点的提升时每个数字都有对照支撑。答辩时老师问任何一个实验的价值你都能给出明确的数字和原因分析。5. 云图识别项目避坑指南五类翻车现场与排查思路5.1 训练loss死活不降准确率一直徘徊在30%现象训练集准确率在25%上下波动loss几乎没有下降趋势跑了十个epoch还是老样子。原因最常见的是学习率设置过大导致预训练权重被破坏模型始终在震荡其次是16位图像直接喂给了模型像素值范围不匹配网络前向传播被数值分布带偏梯度也不正常。我第一次跑这个项目时这两种问题叠加卡了一整天反复调网络结构后来才发现数据根本没对。解决先把学习率降到3e-4再检查数据集读取后的图像数值范围。取一个batch出来打印image.min()和image.max()如果在0到1之间且图像有内容说明归一化正确如果最大像素是65535说明位深探测逻辑没生效回到第二章的代码检查。如果数据没问题就换一个不带预训练的小CNN先跑通训练流程确认数据通路正常再回来做迁移学习。这样分段排查比盯着loss曲线瞎猜高效得多。5.2 训练集准确率99%验证集只有70%现象前20个epoch训练集acc一路冲到95%以上验证集acc却停滞在70%附近loss曲线明显分叉一个低一个高。原因过拟合。云图数据集普遍偏小模型的深层参数自由度又高尤其是分类头那两层全连接很快就把训练集里的图像细节背下来了。这是记忆而不是理解。解决优先检查分类头Dropout从0.3提到0.5其次把冻结策略收紧只放开layer4和fc不要连着layer3一起训练最后把训练集增强强度加大旋转范围从15度扩到30度加入随机缩放裁剪。如果这三招用了验证集还是差3个百分点以上检查train和val划分是不是混了同源样本比如同一张图的不同裁剪同时出现在两边。这种情况在遥感数据里很常见按文件路径去重后再划分问题就消失了。5.3 CUDA显存不够batch_size设64直接OOM现象训练启动时报RuntimeError: CUDA out of memory把batch_size降到32也在十几个step之后爆掉。原因显存主要消耗在中间激活值上。ResNet18虽然模型参数小但输入224乘以224、batch_size为64时反向传播要保存每一层的中间结果显存峰值被拉高实际占用远超模型参数量。解决把batch_size降到16或8保证能跑起来。不要急着换小模型因为ResNet18在低batch_size下依然能收敛只是BatchNorm的统计量会有点抖如果batch小到2或4可以把BatchNorm换成GroupNorm重写模型但对大作业来说没必要。另外确认验证推理包了torch.no_grad()不然验证阶段也会反向保存计算图白白占掉一块显存。最后检查一下是不是开了pin_memory和num_workers的进程也占用显存这个量不大但有时压垮骆驼的最后一根稻草。5.4 标签和图像对不上训练时loss剧烈抖动现象loss曲线在大幅下降和突然升高之间来回跳训练集acc不稳定偶尔出现离谱的数值。原因文件夹遍历顺序不稳定或者手动改标签时把类别索引写错了。例如sorted()排序后cirrus排在clear前面如果你手动假设clear是索引0就会错位。另一个常见原因是读取数据时用了glob()不带排序文件顺序在不同机器上不一致导致部分样本对错了标签。解决在数据加载阶段把类别名和索引打印出来手动验证一遍。读取文件列表时统一用sorted()。训练前做一次快速校验取前20个batch打印labels的分布看每个类是否都有样本。这个检查花两分钟能省下半天排错时间。还有一个细节如果在训练过程中修改了数据集目录结构要重新启动训练而不是热加载因为Dataset初始化时已经把标签列表固定了。5.5 模型把所有图片都预测成同一种云现象验证集整体acc看起来有60%逐类看却发现其中一个类acc是95%另外几个类acc全部是0模型输出概率几乎都是同一个类。原因典型的类别不平衡加分类头偏置。数据集中晴空样本占80%即便用了WeightedRandomSampler但如果训练轮数不够分类头的偏置项还是会偏向样本量大的类另一种可能是验证集没有用同样的预处理导致模型输入的统计特性偏移。有时候这两种原因叠加出现排查时容易晕头转向。解决训练时把分类头的bias项初始化成log(1/num_classes)让每个类别在初始状态下概率均衡可以明显缓解这个现象。同时确认每个epoch结束时在验证集上计算逐类acc而不是只看整体acc。如果连续三个epoch某一个类的recall都是0检查这个类的样本是否真的进入了训练batch用WeightedRandomSampler时检查加权采样后的batch类别比例而不是只看原始数据分布。这几个检查做完绝大多数偏科问题都能定位。6. 让项目从能跑变高分Grad-CAM可视化与报告写作技巧6.1 用Grad-CAM证明模型在看对的云实验报告里最加分的图就是Grad-CAM热力图它能把模型决策依据可视化证明你的模型不是黑匣子乱猜而是真的在关注云团的形态和边缘。实现方法是对backbone最后一个卷积层的输出做梯度加权核心代码如下。import torch.nn.functional as F def grad_cam(model, image_tensor, target_layer, device): model.eval() activations {} gradients {} def forward_hook(module, input, output): activations[value] output.detach() def backward_hook(module, grad_input, grad_output): gradients[value] grad_output[0].detach() fh target_layer.register_forward_hook(forward_hook) bh target_layer.register_full_backward_hook(backward_hook) output model(image_tensor.unsqueeze(0).to(device)) pred_idx output.argmax(dim1).item() model.zero_grad() output[0, pred_idx].backward() fh.remove() bh.remove() weights gradients[value].mean(dim(2, 3), keepdimTrue) cam (weights * activations[value]).sum(dim1, keepdimTrue) cam F.relu(cam) cam F.interpolate(cam, sizeimage_tensor.shape[1:], modebilinear, align_cornersFalse) cam cam.squeeze().cpu().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-5) return cam, pred_idx使用技巧取model.layer4的最后一个残差块作为target_layer因为它既保留了空间位置信息又包含了语义信息不要取fc层fc层输出没有空间分辨率热力图没有意义。把热力图叠加到原图时用cv2.applyColorMap把cam转成jet色透明度设0.4左右视觉上不会被热力图盖住云的纹理。6.2 混淆矩阵与报告材料的呈现混淆矩阵能直观告诉评审老师模型在哪里犯错。用sklearn.metrics的confusion_matrix在验证集上跑完一遍记录预测和真实标签就能画出类别间的混淆情况。报告里分析时挑两类最容易混淆的说明原因比如卷云和层云混淆最多因为它们在可见光波段纹理接近模型在前端提取到的边缘特征相似再配合两类图各自的Grad-CAM热力图对比分析就非常扎实老师也很难追问出漏洞。报告结构控制在五页内问题定义一段话、数据与预处理一页、方法与模型一页、实验与结果一页、结论与改进半页、参考附录半页。图表清单控制在六张以内数据样本图、预处理流程图、网络结构图、训练曲线图、Grad-CAM对比图、混淆矩阵。这六张图配合文字足够撑起一份高分报告。做完这些再回头问自己一句验证集准确率为什么是94%而不是74%我能用一张图解释清楚吗能解释清楚答辩就稳了。这几次做云图项目我最大的教训是先看数据再调模型。翻车案例里大部分问题都不是网络结构选错了而是数据位深、文件顺序、类别分布这些看着不起眼的基础环节。希望你做完这个项目后能养成跑代码前先打印一行图像统计信息的习惯可以省掉很多玄学调参的夜晚。希望帮到你。本文还有配套的精品资源点击获取