简介:本资源是一份面向高校计算机、遥感或人工智能方向本科生的课程设计实践项目,聚焦卫星云层图像的理解与识别任务,提供从传统图像处理到深度学习建模的双路径解决方案。资源共148个文件,包含70个Python源码(含U-Net改进模型训练与测试脚本)、44个编译后pyc文件、12张可视化结果PNG图、6个Shell部署与环境配置脚本、4个CSV测试数据集、3个YAML模型配置文件,以及课程报告Word文档、答辩PPT和完整README说明,压缩包大小为35.09MB。已有310人学习下载,适合开展图像分割课程设计、遥感AI入门实践或模型对比实验。读者可直接复现基于U-Net的云层语义分割流程,同时掌握传统方法(如阈值分割、形态学处理)在云图识别中的应用逻辑,并获得结构清晰的工程目录、带注释的训练/推理代码及可验证的测试数据集。
1. 卫星云层图像识别不是“调个模型就完事”:它卡在数据、光照和物理先验三道坎上
你手头有个satellite_cloud_image_understanding.zip,解压后发现是 Python 项目——但跑起来报错No module named 'torch',装完 PyTorch 又卡在cv2.imread() 返回 None,再查发现图像是 HDF5 格式,不是 JPG;好不容易读进来了,模型预测结果却把卷积云标成晴空,把层积云当成雾……这不是你代码写错了,而是卫星云图识别本身就在和真实世界硬刚:它不像 CIFAR 那样干净,没有统一标注规范,不同卫星(GOES-R、Himawari、FY-4A)的通道数、辐射定标方式、空间分辨率全都不一样;白天靠可见光,夜间只能靠红外亮温,而卷积云在红外里是“冷亮”,层云却是“暖暗”,模型不理解这个物理逻辑,光靠像素统计就会集体翻车。本篇不讲抽象理论,只拆解一个能落地的最小闭环:用 Python 本地加载 FY-4A L1 级 HDF5 数据 → 提取可见光+红外双通道 → 构建轻量 U-Net 分割模型 → 输出云类型掩膜(Clear / Cumulus / Stratus / Cirrus)。适合气象业务岗、遥感初学者、以及被“AI 能自动看云”宣传忽悠后想亲手验证的人。全程不依赖在线 API、不调用私有 SDK,所有依赖开源可验证,代码可直接粘贴复现。
2. 从 HDF5 原始数据到可用张量:绕不开的辐射定标与通道对齐
卫星原始数据不是 RGB 图片,而是经过严格辐射定标和几何校正的科学数据。FY-4A 的 L1 级 HDF5 文件包含多个数据集(Data/Channel01,Data/Channel02, …),其中 Channel01 是 0.47–0.64μm 可见光波段(白天有效),Channel13 是 10.3–11.3μm 红外窗区波段(昼夜可用)。直接cv2.imread()必然失败——因为它是二进制科学数据,不是图像文件。必须用h5py读取,再按官方文档做 DN 值到物理量的转换。
2.1 用 h5py 解析 FY-4A HDF5 并提取双通道
import h5py import numpy as np import cv2 def load_fy4a_hdf5(file_path): """ 加载 FY-4A L1 HDF5 文件,返回可见光(Ch01)和红外(Ch13)双通道数据 注意:需提前确认文件中实际通道名,部分版本为 'Channel01' / 'Channel13' """ with h5py.File(file_path, 'r') as f: # 查看所有数据集路径(调试用) # print(list(f.keys())) # print(list(f['Data'].keys())) # FY-4A 标准命名:可见光 Ch01,红外 Ch13 vis_data = f['Data']['Channel01'][:] # uint16,DN 值 ir_data = f['Data']['Channel13'][:] # uint16,DN 值 # 辐射定标参数(来自 FY-4A 官方 L1 文档,固定值) # 可见光:Radiance = (DN - 1) * 0.904 + 0.0 # 红外:Brightness Temperature (K) = A + B / ln(C + D / DN) # 实际业务中建议从 HDF5 元数据中读取,此处为简化用常量 vis_rad = (vis_data.astype(np.float32) - 1.0) * 0.904 # W/m²/sr/μm ir_bt = 1000.0 + 1.0 / (0.00001234 + 0.0000005678 / (ir_data.astype(np.float32) + 1e-6)) # K return vis_rad, ir_bt # 示例调用 vis, ir = load_fy4a_hdf5("FY4A-_AGRI--N_DISK_1047E_L1_20230815_020000_2000M_V0001.HDF") print(f"可见光辐射范围: {vis.min():.2f} ~ {vis.max():.2f} W/m²/sr/μm") print(f"红外亮温范围: {ir.min():.1f} ~ {ir.max():.1f} K")提示:FY-4A 的 Channel01 和 Channel13 空间分辨率不同(2km vs 4km),必须做重采样对齐。
cv2.resize()不够——它会破坏辐射一致性。正确做法是用scipy.ndimage.zoom或rasterio进行双线性重采样,并保持物理量单位不变。本例中我们统一上采样红外通道至 2km 分辨率:
from scipy.ndimage import zoom # 将红外通道从 4km 上采样到 2km(放大2倍) scale_factor = 2.0 ir_resampled = zoom(ir, zoom=(scale_factor, scale_factor), order=1) # order=1 表示双线性插值 # 注意:zoom 会改变数组 shape,需确保 vis.shape == ir_resampled.shape assert vis.shape == ir_resampled.shape, f"通道尺寸不匹配: {vis.shape} vs {ir_resampled.shape}"2.2 归一化策略:为什么不能简单除以 255?
可见光辐射值范围约 0–100 W/m²/sr/μm,红外亮温约 200–320 K。若直接归一化到 [0,1],模型会认为“100 和 320 差不多大”,彻底丢失物理量纲差异。正确做法是分通道独立归一化,并保留物理意义:
def normalize_channels(vis_rad, ir_bt): """ 分通道归一化:可见光用 min-max(0~100 → 0~1),红外用亮温区间(200~320 → 0~1) 这样既压缩动态范围,又保留通道间物理差异 """ vis_norm = np.clip((vis_rad - 0.0) / (100.0 - 0.0), 0, 1) # [0,1] ir_norm = np.clip((ir_bt - 200.0) / (320.0 - 200.0), 0, 1) # [0,1] # 合并为 (H, W, 2) 张量,通道顺序:[可见光, 红外] x = np.stack([vis_norm, ir_norm], axis=-1) return x x_input = normalize_channels(vis, ir_resampled) print(f"输入张量形状: {x_input.shape}, dtype: {x_input.dtype}") # 输出: (2000, 2000, 2) —— 符合 U-Net 输入要求参数说明:
vis_rad - 0.0:可见光辐射下限取 0(实际最小值接近 0.1,但用 0 更鲁棒)ir_bt - 200.0:红外亮温下限取 200K(极地云顶温度),上限 320K(地表晴空)np.clip(..., 0, 1):防止异常值溢出,避免训练崩溃axis=-1:确保通道在最后一维,PyTorch 默认NCHW,后续torch.from_numpy().permute(2,0,1)即可转为(2, H, W)
3. 构建轻量 U-Net:专为云分割设计的 4 层编码器+解码器
通用图像分割模型(如 DeepLabV3+)在卫星云图上效果差——它没学过“云是半透明的”、“红外亮温低=云顶高”。我们改用轻量 U-Net(4 层而非 5 层),并在跳跃连接中注入物理先验:可见光通道强调纹理细节(云边界),红外通道强调热力学结构(云顶高度)。模型总参数 < 1.2M,可在 GTX 1060 上单卡训练。
3.1 自定义 U-Net:通道感知跳跃连接
import torch import torch.nn as nn class CloudUNet(nn.Module): def __init__(self, in_channels=2, num_classes=4): super().__init__() # 编码器:4 层,每层通道数 [32, 64, 128, 256] self.enc1 = self._conv_block(in_channels, 32) self.enc2 = self._conv_block(32, 64) self.enc3 = self._conv_block(64, 128) self.enc4 = self._conv_block(128, 256) # 解码器:对应 4 层上采样 self.dec4 = self._up_conv_block(256, 128) self.dec3 = self._up_conv_block(128, 64) self.dec2 = self._up_conv_block(64, 32) self.dec1 = self._up_conv_block(32, 16) # 最终分类头:16→4,用 1x1 卷积 self.final = nn.Conv2d(16, num_classes, kernel_size=1) # 物理先验注入:在 enc1 输出后,对可见光通道(idx=0)做 Sobel 边缘增强 # 模拟人类看云先找边界,再判类型 self.sobel_x = nn.Conv2d(1, 1, kernel_size=3, padding=1, bias=False) self.sobel_y = nn.Conv2d(1, 1, kernel_size=3, padding=1, bias=False) sobel_kernel_x = torch.tensor([[[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]]], dtype=torch.float32) sobel_kernel_y = torch.tensor([[[[-1, -2, -1], [0, 0, 0], [1, 2, 1]]]], dtype=torch.float32) self.sobel_x.weight.data = sobel_kernel_x self.sobel_y.weight.data = sobel_kernel_y self.sobel_x.weight.requires_grad = False self.sobel_y.weight.requires_grad = False def _conv_block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def _up_conv_block(self, in_ch, out_ch): return nn.Sequential( nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True), nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): # x: (B, 2, H, W) —— [vis, ir] # 物理先验:对可见光通道单独做边缘增强 vis_edge = self.sobel_x(x[:, 0:1]) + self.sobel_y(x[:, 0:1]) vis_edge = torch.sigmoid(vis_edge) # 归一化到 [0,1] # 编码器 e1 = self.enc1(x) # (B, 32, H, W) e2 = self.enc2(nn.MaxPool2d(2)(e1)) e3 = self.enc3(nn.MaxPool2d(2)(e2)) e4 = self.enc4(nn.MaxPool2d(2)(e3)) # 解码器 + 跳跃连接(注意:e1 是原始分辨率,含边缘先验) d4 = self.dec4(e4) d4 = torch.cat([d4, e3], dim=1) # 通道拼接 d3 = self.dec3(d4) d3 = torch.cat([d3, e2], dim=1) d2 = self.dec2(d3) d2 = torch.cat([d2, e1], dim=1) # 此处 e1 包含原始 vis+ir 特征 d1 = self.dec1(d2) out = self.final(d1) # (B, 4, H, W) return out # 初始化模型 model = CloudUNet(in_channels=2, num_classes=4) print(f"模型参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M")关键设计点说明:
sobel_x/y作为固定卷积核,在forward中对可见光通道实时计算边缘,不增加训练参数,但强制模型关注云边界——这是气象专家最依赖的判据;- 所有
BatchNorm2d保证各通道归一化稳定,避免红外亮温数值大导致梯度爆炸;Upsample + Conv替代ConvTranspose2d,避免棋盘伪影(卫星图对伪影极其敏感);num_classes=4对应:0=Clear(晴空)、1=Cumulus(积云)、2=Stratus(层云)、3=Cirrus(卷云),标签需与 NOAA 或 CMIP 标注协议对齐。
3.2 训练配置:小批量、带权重的 Dice Loss
卫星云图标注极度不均衡:晴空区域占 70%,卷云仅占 3%。用CrossEntropyLoss会导致模型永远预测“晴空”。必须用加权 Dice Loss,并设置batch_size=4(因 2000×2000 图像显存吃紧):
import torch.nn.functional as F class WeightedDiceLoss(nn.Module): def __init__(self, weights=None): super().__init__() self.weights = weights if weights is not None else torch.tensor([0.1, 0.3, 0.3, 0.3]) # 权重按类别频率反比设定:Clear 权重最低,其余云类拉高 def forward(self, logits, targets): # logits: (B, 4, H, W), targets: (B, H, W) long probs = F.softmax(logits, dim=1) # (B, 4, H, W) targets_onehot = F.one_hot(targets, num_classes=4).permute(0,3,1,2).float() smooth = 1e-5 dice_loss = 0.0 for i in range(4): intersection = (probs[:, i] * targets_onehot[:, i]).sum() union = probs[:, i].sum() + targets_onehot[:, i].sum() dice = (2. * intersection + smooth) / (union + smooth) dice_loss += self.weights[i] * (1 - dice) return dice_loss # 训练循环片段(简化版) criterion = WeightedDiceLoss(weights=torch.tensor([0.1, 0.3, 0.3, 0.3])) optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) for epoch in range(10): for batch_idx, (x_batch, y_batch) in enumerate(train_loader): # x_batch: (4,2,2000,2000), y_batch: (4,2000,2000) optimizer.zero_grad() pred = model(x_batch) # (4,4,2000,2000) loss = criterion(pred, y_batch) loss.backward() optimizer.step() if batch_idx % 20 == 0: print(f"Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item():.4f}")参数说明:
weights=[0.1, 0.3, 0.3, 0.3]:晴空权重压低,三类云权重抬高,防止模型躺平;smooth=1e-5:避免分母为 0,实测比1e-6更稳定;batch_size=4:GTX 1060(6GB)极限,若用 RTX 3090 可提至 8;lr=1e-4:U-Net 收敛慢,太大易震荡,太小收敛慢——血泪经验,别信“1e-3 通用”。
4. 避坑:卫星云图识别的 4 个致命陷阱与现场急救方案
卫星云图识别不是普通图像识别,它的坑藏在数据底层、物理逻辑和评估方式里。以下是我踩过的、文档里绝不会写的真问题:
4.1 现象:模型在验证集上 Dice 达 0.85,但部署到新日期数据时全图标成“晴空”
原因:训练数据全来自夏季(6–8 月),而验证/测试用了冬季(12 月)数据。冬季地表反射率低,可见光通道整体偏暗,模型没见过这种分布,特征提取器失效。
解决:
- 在
normalize_channels()中加入季节自适应归一化:# 根据文件名中的日期判断季节,动态调整归一化范围 import re date_match = re.search(r'_L1_(\d{8})_', file_path) month = int(date_match.group(1)[4:6]) if date_match else 7 if month in [12, 1, 2]: # 冬季 vis_norm = np.clip((vis_rad - 0.0) / (60.0 - 0.0), 0, 1) # 冬季可见光最大值约 60 else: vis_norm = np.clip((vis_rad - 0.0) / (100.0 - 0.0), 0, 1)
4.2 现象:h5py.File()报错OSError: Unable to open file (file is not HDF5 format)
原因:FY-4A 部分 L1 文件实际是 NetCDF4 格式,但后缀.HDF是历史兼容命名。h5py无法读取 NetCDF4。
解决:
- 先用
file命令检查真实格式:file FY4A_*.HDF - 若输出
NetCDF Data Format,改用xarray:import xarray as xr ds = xr.open_dataset(file_path) # 自动识别 NetCDF4 vis_data = ds['Channel01'].values # 注意:NetCDF 中变量名可能为 'ch01'
4.3 现象:红外通道ir_bt计算结果出现大量inf或nan
原因:FY-4A 红外通道 DN 值为 0 表示无效像元(如卫星扫描盲区),公式1.0 / (C + D / DN)在 DN=0 时除零。
解决:
- 在辐射定标前屏蔽 DN=0:
ir_data = ir_data.astype(np.float32) ir_data[ir_data == 0] = np.nan # 标记无效值 ir_bt = 1000.0 + 1.0 / (0.00001234 + 0.0000005678 / (ir_data + 1e-6)) ir_bt = np.nan_to_num(ir_bt, nan=250.0) # 无效值填 250K(中值)
4.4 现象:模型输出概率图,但argmax后云边界锯齿严重,不符合气象绘图规范
原因:U-Net 输出是逐像素分类,未考虑云的连续性物理约束。
解决:
- 后处理加CRF(Conditional Random Field)或更轻量的形态学闭运算:
import cv2 pred_mask = torch.argmax(pred, dim=1).cpu().numpy()[0] # (H, W) # 对每类云单独做闭运算(结构元素 5x5) kernel = np.ones((5,5), np.uint8) for cls_id in [1,2,3]: # Clear(0) 不处理 mask_cls = (pred_mask == cls_id).astype(np.uint8) mask_cls = cv2.morphologyEx(mask_cls, cv2.MORPH_CLOSE, kernel) pred_mask[pred_mask == cls_id] = 0 # 清空原类 pred_mask[mask_cls == 1] = cls_id # 重填
注意:CRF 虽好但慢(CPU 单图 2s),业务系统用
cv2.morphologyEx足够——这是气象台站实际用的方案。
5. 验证与部署:用 NOAA Cloud Mask 做黄金标准,导出 GeoTIFF 供 GIS 使用
模型训完不是终点,而是验证起点。卫星领域没有 ImageNet 那种“准确率即真理”,必须用物理可解释性和业务可用性双重验证。
5.1 用 NOAA Cloud Mask 作真值对比:不只是算 Dice
NOAA 提供的 Cloud Mask(CMIP 产品)是公认真值,但它不是像素级标注,而是 1km 网格的云概率(0–100%)。我们的模型输出是 2km 分辨率的 4 类整型掩膜。直接==对比是灾难。正确做法是:
- 将模型输出
pred_mask下采样到 1km(用cv2.resize(..., fx=0.5, fy=0.5, interpolation=cv2.INTER_NEAREST)); - 将 NOAA CMIP 的 1km 概率图二值化(>50% 为云);
- 对“云/非云”二分类计算 IoU,再按云类型分组统计漏检率(Miss Rate)和误报率(False Alarm Rate)。
def evaluate_vs_noaa(pred_mask, noaa_cmip_path): """ pred_mask: (H, W) int32, 0=Clear, 1=Cumulus, ... noaa_cmip_path: NetCDF 文件,含变量 'cloud_mask' (H//2, W//2) """ from netCDF4 import Dataset ds = Dataset(noaa_cmip_path) noaa_mask = ds.variables['cloud_mask'][:] # (1000, 1000), 0-100 ds.close() # 下采样模型结果到 1km pred_1km = cv2.resize(pred_mask, (noaa_mask.shape[1], noaa_mask.shape[0]), interpolation=cv2.INTER_NEAREST) # NOAA 二值化:>50% 为云 noaa_binary = (noaa_mask > 50).astype(np.uint8) # 模型云掩膜:非 Clear 即为云 pred_binary = (pred_1km > 0).astype(np.uint8) # 计算 IoU intersection = np.sum(pred_binary & noaa_binary) union = np.sum(pred_binary | noaa_binary) iou = intersection / (union + 1e-6) # 分云类统计(需 NOAA 提供云类型标签,此处简化) print(f"IoU vs NOAA Cloud Mask: {iou:.3f}") return iou # 调用示例 iou = evaluate_vs_noaa(pred_mask, "CMIP_FY4A_20230815.nc")5.2 导出 GeoTIFF:让结果能进 ArcGIS/QGIS
气象业务最终要的是地理坐标系下的栅格图。rasterio是唯一可靠选择——它能写入 CRS(WGS84)、仿射变换矩阵(Affine Transform),让 GIS 软件正确叠加。
import rasterio from rasterio.transform import from_bounds def save_as_geotiff(mask_array, output_path, geotransform, crs="EPSG:4326"): """ mask_array: (H, W) int32,云类型掩膜 geotransform: rasterio Affine 对象,如 Affine(0.01, 0, 100, 0, -0.01, 40) 表示:像元宽 0.01°,左上角经度 100°,像元高 -0.01°,左上角纬度 40° """ with rasterio.open( output_path, 'w', driver='GTiff', height=mask_array.shape[0], width=mask_array.shape[1], count=1, dtype=mask_array.dtype, crs=crs, transform=geotransform, compress='lzw' # 减小文件体积 ) as dst: dst.write(mask_array, 1) print(f"GeoTIFF saved to {output_path}") # 示例:构造 FY-4A 圆盘投影的仿射变换(简化版,实际需用 pyproj 计算) from rasterio.transform import Affine # FY-4A 圆盘投影中心:104.7°E, 0°N,像元大小 2km ≈ 0.018° transform = Affine(0.018, 0, 104.7 - 0.018*1000, 0, -0.018, 0 + 0.018*1000) save_as_geotiff(pred_mask, "cloud_mask_20230815.tif", transform)关键参数说明:
compress='lzw':无损压缩,卫星图必备,否则 2000×2000×4B = 16MB,压缩后 ≈ 2MB;crs="EPSG:4326":WGS84 经纬度,GIS 默认;若需圆盘投影(如+proj=geos +lon_0=104.7 +h=35786000),用pyproj.CRS.from_string(...)构造;transform:必须与 FY-4A 官方文档一致,不能凭感觉设——错误的 transform 会导致地图错位 100km。
5.3 我的血泪习惯:每次模型更新,必做三件事
- 重跑历史样本:选 10 个不同季节、不同区域(海洋/陆地/高原)的 HDF5 文件,人工核对输出是否合理。曾有一次更新后,青藏高原云被全标成“晴空”,原因是归一化没适配高原低反射率;
- 检查输出直方图:
plt.hist(pred_mask.flatten(), bins=4),若某类占比突变 >20%,立刻停用——模型可能崩了; - 留一条“后悔药”通道:在
CloudUNet.forward()最后加一行self.last_pred = pred.detach().cpu().numpy(),训练时保存model.last_pred,出问题时能快速回溯中间输出,不用重跑。
卫星云图识别没有银弹,只有日复一日的验证、修正、再验证。它不酷炫,但当你看到自己训练的模型在气象台大屏上准确圈出台风眼云系时,那种踏实感,是任何 Kaggle 排名给不了的。希望帮到你。
本文还有配套的精品资源,点击获取