简介:面向ICESAT系列卫星数据的科研与工程人员,这份Python程序包实现了光子计数与波形数据的加载、去噪和可视化,适用于冰川高度变化分析、全球气候变化研究等场景。资源压缩包共三十五个文件,整体约七百一十七兆,主要包含Python源码、编译缓存文件、图形界面脚本、样本数据文件、可执行程序、构建配置文件与依赖说明等;其中既提供可直接运行的桌面程序,也保留了便于改写的源码模块。内置数据加载器可读取常见格式的卫星高度数据,去噪模块针对光子和波形信号分别设计了处理算法,交互式可视化画布支持对剖面结果进行查看与比较。另外附带环境依赖清单和打包配置,便于复现运行。目前已有超过一千人学习下载,适合具备一定Python基础、需要快速处理冰卫星数据的研究生和科研人员参考使用。 说实话,第一次听到“ICESAT-1 和 ICESAT-2 数据可视化与去噪 Python 程序”这个项目时,我就知道这不是那种跑个matplotlib就能交差的活儿。ICESAT-1 代表的是全波形激光测高时代,ICESAT-2 则是光子计数时代,两代卫星数据格式、噪声形态、处理链路完全不同,却要在同一个 Python 框架里被可视化、被去噪、被解释。这篇文章就是把我的完整实现思路、代码结构和踩坑记录整理出来。如果你正在做极地冰盖、植被高度或者地形反演,手里刚拿到 ATL03 或 GLAH 系列数据不知道怎么下手,这篇文章能帮你把“从 HDF5 到一张干净的剖面图”这条路走通。
做这类东西的人,其实最头疼的不是不懂 Python,也不是不会滤波,而是不熟悉遥感数据的物理含义和存储逻辑。你要知道每一个字段是什么单位、参考的是哪个坐标系统、哪些值是填充值,然后才能把可视化做对,把去噪做好。下面我就按数据源拆解、环境搭建、读取预处理、可视化、去噪、排错调参这个顺序往下讲,全程按我实际跑通过的项目结构来。
1. 数据源拆解:两代激光卫星的差异决定了处理思路不同
1.1 GLAS 与 ATLAS:从全波形到光子计数
ICESAT-1 搭载的 GLAS 传感器是典型的全波形激光测高仪。激光脉冲打到地表后,接收端记录整个回波波形,每一个波形采样点都是地表不同高度反射能量的叠加。冰川表面可能只有一个尖锐回峰,而森林区域则会看到冠层顶、冠层内部、地面三个峰连在一起的宽波形。正因为处理对象是一维波形信号,所以去噪方式偏向于底噪估计、高斯滤波、小波阈值那一套信号处理方法。
ICESAT-2 搭载的 ATLAS 完全不同,它用了微脉冲光子计数技术。激光以很高的重复频率发射弱脉冲,接收端逐个记录返回的单光子事件。这个体制带来的最大变化是:ATL03 产品里不仅有地表信号光子,还有大量太阳背景噪声和探测器暗计数。你在剖面图上看到的景象是噪声光子像雾一样均匀铺满整个高程范围,而信号光子密集地沿着地表轮廓排成一条线。这其实和我们做通信网络流量去噪、点云数据处理面临的处境很像——数据从“一维曲线”变成了“离散点云”,处理重点也从“平滑曲线”变成了“从海量噪声点中识别高密度目标”。
这两代数据放在同一个项目里,最直接的影响是数据读取和去噪模块没法共用。我当时的设计是把程序拆成两个数据源模块,一个管 GLAS 的 HDF5 波形/高程,一个管 ATLAS 的 ATL03 光子,可视化模块尽量共用。
1.2 产品选型:处理哪一层数据最划算
ICESAT-1 能拿到的产品很多,对我来说最常用的是两层:
- GLAH06:NASA 已经反演好的 40Hz 沿轨高程点,适合快速出剖面图,但是对异常点敏感,需要做剔除噪声点的后处理。
- GLAH01 / GLAH05:原始全波形或波形参数化结果,适合自己实现底噪估计、波形分解,灵活度高,但处理量也大。
ICESAT-2 这边,我的建议是直接用 ATL03 光子级产品。ATL03 是光子云数据,只有到了光子级别才能做自定义去噪和可视化。如果直接拿 ATL06 那种已经平滑好的冰盖高度产品,去噪环节就没意义了,因为 NASA 官方已经帮你滤干净了。“处理哪一层数据”决定了整个程序的复杂度,我当时的决定是 GLAS 处理 GLAH06 高程 + GLAH01 波形两个模块,ATLAS 只处理 ATL03。
2. 环境准备与 Python 技术栈选型
2.1 核心依赖库选型
整个程序我建议用 Python 3.9 以上版本,核心依赖如下:
- h5py:读写 HDF5 文件,ATL03 和 GLAH 系列都是 HDF5 格式。
- numpy、scipy:数组计算、KDTree 邻域搜索,去噪算法的基础。
- matplotlib:所有剖面图和对比图的绘制。
- cartopy:地理轨迹图和投影地图,可选但强烈推荐。
- pywt:小波阈值去噪,处理 GLAS 波形数据时用得上。
安装时有个坑,cartopy 直接用pip install cartopy在部分机器上很容易因为 GEOS 依赖编译失败。我实测下来用 conda 安装最稳:
conda create -n icesat python=3.9 conda activate icesat conda install -c conda-forge h5py numpy scipy matplotlib cartopy pywavelets2.2 数据获取与工程目录组织
数据从 NSDIC Earthdata 下载,需要注册账号。ATL03 单条轨道文件大约几百 MB,GLAH 系列单文件也有几十 MB,建议下载后用目录区分版本:
icesat_tool/ ├── data/ │ ├── ATL03/ │ │ └── ATL03_20200101000000_00000001_001_01.h5 │ ├── GLAH01/ │ └── GLAH06/ │ └── GLAH06_634_2101_001_0079_0_01_0001.H5 ├── src/ │ ├── read_atl03.py │ ├── read_glas.py │ ├── denoise.py │ └── viz.py └── main.py目录清晰一点,后续调参数会方便很多。我最早全部脚本堆在一个目录里,后来参数多了根本分不清哪个文件对应哪个实验。
3. 核心实现一:HDF5 数据读取与预处理
3.1 ATL03 光子数据的字段提取与单位转换
ATL03 内部按波束分组,共有 6 个波束:gt1l、gt1r、gt2l、gt2r、gt3l、gt3r。每个波束下需要读取三个部分:geolocation 下的光子坐标和沿轨距离,heights 下的光子高度和置信度。其中有两个非常容易踩坑的单位转换点:
reference_photon_lat和reference_photon_lon是 int32 类型,单位是 1e-7 度,必须乘以1e-7才是十进制度。h_ph单位是米,参考 WGS84 椭球面,是可以直接用的。
我实际用的读取代码如下:
import h5py import numpy as np def load_atl03_photons(path, beam="gt1l"): with h5py.File(path, "r") as f: lat = f[f"/{beam}/geolocation/reference_photon_lat"][:] * 1e-7 lon = f[f"/{beam}/geolocation/reference_photon_lon"][:] * 1e-7 dist = f[f"/{beam}/geolocation/reference_photon_dist"][:] h = f[f"/{beam}/heights/h_ph"][:] conf = f[f"/{beam}/heights/conf_ph"][:] return lat, lon, dist, h, confconf_ph 是官方置信度字段,通常conf_ph >= 2被认为是信号光子,0 和 1 基本对应噪声。这个字段后面可以用来验证我自己写的去噪算法是否靠谱,相当于官方给了我们一个参考答案,非常宝贵。
程序里还要循环处理 6 个波束,每个波束的光子数量不同,处理时建议用一个 for 循环把结果存成字典:
beams = ["gt1l", "gt1r", "gt2l", "gt2r", "gt3l", "gt3r"] atl03_data = {} for b in beams: lat, lon, dist, h, conf = load_atl03_photons(file_path, beam=b) atl03_data[b] = {"lat": lat, "lon": lon, "dist": dist, "h": h, "conf": conf}3.2 GLAS 高程产品的读取与清洗
GLAS 的 GLAH06 文件读取逻辑和 ATL03 类似,但字段路径不同。我需要读取 40Hz 沿轨数据,主要字段是经纬度和冰盖表面高程:
def load_glas06_elevation(path): with h5py.File(path, "r") as f: lat = f["Data_40HZ/Geolocation/d_lat"][:] lon = f["Data_40HZ/Geolocation/d_lon"][:] elev = f["Data_40HZ/Elevation_Surfaces/d_elev"][:] # d_elev 可能是二维数组,形状为 (记录数, 1),需要压缩成一位数组 elev = np.squeeze(elev) lat = np.squeeze(lat) lon = np.squeeze(lon) # 剔除填充值 valid = elev > -9999 return lat[valid], lon[valid], elev[valid]这里最容易犯的错是忘记处理填充值。HDF5 存储时无效值一般用-9999或-999填充,如果不剔除,后面画剖面图会出现一个直接扎到地心去的异常点,去噪时也会被当成“信号异常点”处理,影响整条轨道的统计量。
对于 GLAH01 波形数据,读取后是二维数组,每一行对应一个激光脉冲的完整波形。单个波形通常有 544 个采样点,这些点代表激光回波随时间的能量变化:
def load_glas01_waveforms(path, shot_index=0): with h5py.File(path, "r") as f: wave = f["Data_40HZ/Waveform/wf"][shot_index, :] return np.array(wave, dtype=np.float64)4. 核心实现二:可视化方案
4.1 光子云图:一张图看清全部噪声和信号分布
ATL03 的可视化核心是绘制沿轨剖面图,X 轴用沿轨距离,Y 轴用高程,直接把所有光子画成散点。这一步看着简单,但点数量实在太大,一个波束可能就有上百万个光子,硬画很容易卡。我实际用的是rasterize=True,把散点图栅格化,导出 PDF 时不会卡死:
import matplotlib.pyplot as plt def plot_photon_cloud(dist, h, title="ATL03 Photon Cloud"): fig, ax = plt.subplots(figsize=(14, 5)) ax.scatter(dist, h, s=0.5, c="0.6", alpha=0.4, rasterized=True) ax.set_xlabel("Distance along track (m)") ax.set_ylabel("Height (m)") ax.set_title(title) return fig, ax如果内存比较紧张,可以先用np.random.choice抽一个子集再画,可视化阶段没必要把全部点都展示出来。但如果是为了检查去噪结果,我会把官方置信度字段映射成颜色,一张图上噪声画成灰色,信号画成红色:
colors = np.where(conf >= 2, "red", "gray") ax.scatter(dist, h, s=0.5, c=colors, alpha=0.5, rasterized=True)这样一眼就能看出信号光子和噪声光子的大致分布范围,后面验证自己的去噪算法时也方便对比。
4.2 剖面线图、轨迹地图与对比图
GLAS 的 GLAH06 高程点数量比 ATL03 光子少得多,画剖面图可以直接用ax.plot:
ax.plot(dist_along, elev_cleaned, linewidth=1.2, label="GLAH06 elevation")这里dist_along可以直接用沿轨序号乘以脉冲间距近似,或者从数据文件里找沿轨距离字段。
地图轨迹可视化用 cartopy 加 PlateCarree 投影最省事,极地研究建议改用ccrs.SouthPolarStereo()或ccrs.NorthPolarStereo(),不然高纬度区域的形变非常严重:
import cartopy.crs as ccrs def plot_track(lon, lat): fig = plt.figure(figsize=(10, 8)) ax = plt.axes(projection=ccrs.SouthPolarStereo()) ax.set_extent([-180, 180, -60, -90], crs=ccrs.PlateCarree()) ax.scatter(lon, lat, s=1, transform=ccrs.PlateCarree()) ax.gridlines(draw_labels=True) return fig, ax去噪前后的对比图我建议用上下两个子图,共享 X 轴,原始数据放上面,去噪数据放下面,视觉效果最直观。不要用左右子图,跨轨道剖面图纵向趋势变化很快,左右对眼睛不友好。
4.3 可视化输出细节
科研图片输出建议用fig.savefig("result.png", dpi=300, bbox_inches="tight"),如果后续要投期刊,可以导出 PDF 矢量图,配合rasterized=True保证散点部分不会让文件体积爆炸。我自己习惯把每个波束去噪前后的评估指标(信号光子数、噪声抑制率)写在图上,方便横向比较参数。
5. 核心实现三:去噪算法设计
5.1 光子计数数据的密度去噪与聚类
ATL03 去噪的核心假设是:信号光子在局部区域密度远高于噪声光子。所以最简单的去噪方式是密度过滤:对每个光子,统计它在一定空间邻域内有多少个邻近光子,如果数量超过阈值就保留,否则剔除。
我当时第一时间想到用scipy.spatial.cKDTree,因为用暴力双层循环处理几十万光子,速度慢到无法接受。cKDTree 是空间索引结构,一次建树后邻域查询非常快:
from scipy.spatial import cKDTree def denoise_by_density(dist, h, radius=10.0, min_points=5): coords = np.column_stack([dist, h]) tree = cKDTree(coords) counts = tree.query_ball_point(coords, r=radius, return_length=True) return counts >= min_points这里的难点是radius怎么取。因为光子云图里 X 轴是沿轨距离,单位是米,Y 轴是高程,单位也是米,理论上可以直接用欧氏距离。但实际中地表会倾斜,同一段信号光子沿着地表排布不是水平的,如果直接用固定的圆形邻域,倾斜度大的山坡区域可能漏检。我做了一个小改进:把每个光子的高度先减去局部中位数趋势,再做邻域统计。这一步很简单,却能明显提升倾斜地表上的信号识别效果。
如果希望去噪结果更规则,可以用 DBSCAN 聚类。sklearn.cluster.DBSCAN的eps对应邻域半径,min_samples对应最少点数:
from sklearn.cluster import DBSCAN def denoise_by_dbscan(dist, h, eps=10.0, min_samples=5): coords = np.column_stack([dist, h]) labels = DBSCAN(eps=eps, min_samples=min_samples).fit_predict(coords) return labels != -1DBSCAN 的好处是能把间距较远的信号簇也识别出来,但参数同样敏感,而且大规模数据下内存占用比 cKDTree 方案高。
另外要提一下,ATL03 中有强弱两个波束之分,强波束的信号光子密度高于弱波束。我实际调试时发现,去噪参数不能两个波束通用,弱波束的min_points要适当调低,否则信号会被成片误删。
5.2 波形数据的底噪估计与小波去噪
GLAS 全波形去噪是另一条思路。波形数据是一维离散信号,噪声主要体现为高频抖动的底噪。常用的处理分两步:
第一步是估计噪声底限。取波形两端没有信号的 bin 作为纯噪声区,计算平均值和标准差:
def estimate_noise(waveform, margin=20): noise_samples = np.concatenate([waveform[:margin], waveform[-margin:]]) return np.mean(noise_samples), np.std(noise_samples)第二步是设置阈值,低于“均值 + n 倍标准差”的采样点置零,然后再做平滑。n 通常取 2 到 4,我一般先看波形直方图再决定。如果底部噪声比较大,阈值太低会留下大量毛刺,后面高斯拟合时会出现伪峰值。
如果要用小波阈值去噪,我推荐pywt库。小波变换能把信号分解成不同频率的分量,然后把高频噪声分量的小波系数压缩,最后重构回去。这是一个广泛应用于信号处理的经典流程,算是我个人处理 GLAS 波形时比较顺手的方式。代码如下:
import pywt import numpy as np def wavelet_denoise(waveform, wavelet="db4", level=3, mode="soft"): coeffs = pywt.wavedec(waveform, wavelet, level=level) sigma = np.median(np.abs(coeffs[-1])) / 0.6745 threshold = sigma * np.sqrt(2 * np.log(len(waveform))) coeffs_threshed = [coeffs[0]] for i in range(1, len(coeffs)): coeffs_threshed.append(pywt.threshold(coeffs[i], threshold, mode=mode)) return pywt.waverec(coeffs_threshed, wavelet)这段代码里sigma * np.sqrt(2 * np.log(N))是经典的通用阈值。实测下来,对于信噪比一般的 GLAS 波形,db4三层分解已经够用。如果你发现去噪后波形顶部被削平,说明阈值太大,可以把mode改成"soft"换成"hard"或者调低 level。
5.3 参数选择经验与验证方法
去噪参数不是拍脑袋定的,我的习惯是先用官方置信度做验证。对 ATL03,以conf_ph >= 2为“标准答案”,然后对比我自己写的密度去噪算法结果,算一下查全率和查准率:
- 查全率 = 我识别出的信号光子数 / 官方信号光子数
- 查准率 = 我识别出的信号光子数 / 我保留的全部光子数
这样试几个参数组合,就能找到最合适的radius和min_points。比如平坦冰盖区域,radius取 5-20 米、min_points取 3-6 个效果较好;而城市或森林区域光子特别密,则需要更小的邻域半径。
对于 GLAS 波形,验证方法更直接:看去噪后波形是否保留了主峰的形状,同时底部噪声是否明显削弱。如果一个波形原本能看到两个回峰,去噪后两个峰都在且位置没偏,那就说明小波去噪没有破坏有效信息。
6. 实际问题排查与参数调优经验
6.1 常见异常与解决方案
我整理了一下实际处理过程中最容易遇到的几个问题,下面这张表可以当速查手册用:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 纬度范围看起来不对,出现几百甚至几千度的值 | 没有把 ref_ph_lat / ref_ph_lon 乘以 1e-7 | 读取后立刻转成十进制角度 |
| 剖面图中有一个点扎到 -9000 米 | GLAH06 填充值(如 -9999)没剔除 | 读取时加valid = elev > -9999 |
| 散点图绘制极慢,导出的 PDF 上百 MB | 光子点太多且是矢量散点 | 加rasterized=True,或者先抽样再画 |
| 去噪后信号被全部删掉 | radius 或 min_points 设置过严 | 调大 radius,调低 min_points |
| 去噪后弱波束效果明显差于强波束 | 强弱波束参数混用 | 分别设置参数,弱波束降低阈值 |
| 小波去噪后波形顶部变形严重 | 小波去噪阈值过大 | 改用"hard"阈值,或降低分解层数 |
还有就是 HDF5 文件下载不完整的问题。ATL03 文件较大,用浏览器下载很容易中断但又有部分写入,h5py 打开时报Unable to synchronously open object这类错误。我后来所有数据都用 curl 断点续传脚本下载,再验文件名大小,能省掉很多麻烦。
6.2 密度去噪的三条避坑心得
第一,窗口形状要会变通。固定圆形邻域在水平地表好用,但冰盖表面经常有坡度。我实测下来,把高度减去一个滑动窗口的中位数后再做密度统计,对倾斜地表的信号识别效果提升明显。做法很简单,先用np.convolve做平滑估计地表趋势,再算残差,最后在残差上做密度去噪。
第二,别忽视沿轨方向的距离单位。ATL03 的reference_photon_dist是沿轨距离,单位是米,而h_ph也是米,但一些低版本数据或者自己拼接的数据,容易出现单位不一致的问题。如果去噪结果把所有信号都删掉,先检查单位。
第三,把去噪和可视化拆开跑。我最初把去噪和画图放在一个脚本里,每次调参数都要重新绘图,浪费时间。后来改成先去噪、保存一个二进制筛选结果(比如保持 index 的 npy 文件),再单独跑绘图脚本。这样调参数流程能快很多。
6.3 调参时的一个高效工具
我写了一个简单循环,用来批量测试不同参数的组合并输出统计指标:
from itertools import product for radius, min_points in product([5, 10, 20], [3, 5, 8]): mask = denoise_by_density(dist, h, radius=radius, min_points=min_points) precision = np.sum(mask & (conf >= 2)) / np.sum(mask) recall = np.sum(mask & (conf >= 2)) / np.sum(conf >= 2) print(f"radius={radius}, min_points={min_points}, precision={precision:.3f}, recall={recall:.3f}")这样扫一轮参数,大概十分钟就能找到合适的量级。拿到量级之后,再在附近做小范围的细调,比纯粹拍脑袋试参数高效太多。
最后再分享一个小技巧。如果你是在 Jupyter Notebook 里调试,%matplotlib inline之后记得加plt.rcParams["agg.path.chunksize"] = 10000,不然画大规模散点图时 matplotlib 会报Path has too many points的坑。这个报错我第一次遇到时查了半天,其实就是点太多渲染内存炸了,设置一下块大小就能解决。
这个项目做完之后,我最大的感受是:两代卫星虽然不是同一时代的技术,但它们在数据分析和可视化层面其实有很多共性。无论数据形态怎么变,核心都是把物理信号从噪声中分离出来,再用直观的方式呈现给研究者。你在程序的架构上保留好扩展性,未来不管又来什么新型号的激光卫星数据,都能快速接入同一套框架里跑起来。
本文还有配套的精品资源,点击获取