简介:针对EGM96大地水准模型二进制文件解析,基于Visual Studio 2012与C#/.NET 4.5开发的完整工具包,面向测绘、GIS及地球科学领域需要处理高程异常数据的开发者和研究人员。压缩包内含33个文件,以C#工程源码、项目配置、可执行程序及调试文件为主,并附带天宝EGM96.ggf示例数据,整体体积仅3.29MB。程序可从GGF文件读取出721行×1441列的高程异常格网数据,支持特定经纬度(如23°、113°)范围内的数值查询,并通过热力图直观呈现高程异常值的分布,便于对大地水准面变化进行可视化分析与验证。完整的源码和示例数据可帮助使用者深入理解GGF二进制存储格式、高程异常计算方法及WinForm数据处理与渲染实现细节,也便于在教学演示或后续二次开发中复用。目前已有138人浏览学习,对于需要快速理解GGF数据结构的开发者来说,是一款轻量而实用的解析工具。
1. 解析 EGM96.ggf:一个 4MB 二进制文件凭什么能撑起全球高程转换
做测绘、无人机仿地飞行或者卫星影像正射校正的朋友,应该都见过 EGM96 这个名字。EGM96.ggf 是地球重力场模型 EGM96 的大地水准面高格网文件:全球 721×1441 个点,纬度从 90°N 到 90°S,经度从 0° 到 360°,分辨率 15 弧分,每个点是一个 4 字节浮点数。就这么一个没有显眼文件头的二进制文件,能告诉你地球上任何一个地方的大地水准面同椭球面的差值。反直觉的是,解析它用不上任何 GIS 大软件,Python 里十几行代码就能把 100 万个浮点数翻出来,难就难在大小端、行列顺序和边界插值这三个地方。
2. 读取 GGF 二进制文件:从文件头到网格数据的逐字节拆解
2.1 先搞清文件结构:EGM96.ggf 的行列布局与字节序
EGM96 是 NASA GSFC 与 NIMA 在 1996 年联合发布的地球重力场模型。它的格网文件在不同渠道有不同的名字:官方早期叫WW15MGH.GRD,后来镜像站普遍用EGM96.ggf。因为文件名带.ggf,很多人误以为它和 GMT 的 netCDF 网格是一回事,实际上它就是一份裸的二进制浮点数组。
数据布局非常规整:纬度从 90°N 开始,向南每隔 0.25° 取一行,一直到 90°S,共 721 行;每一行从经度 0° 开始,向东每隔 0.25° 取一个点,到 359.75° 结束,共 1441 列。存储顺序是行优先,先存完一整条纬度线再存下一条。每个采样点是一个 IEEE 754 单精度浮点数(float32),单位是米,字节序为大端(Big-Endian)。
之所以是大端,因为这份格式定型于上世纪 90 年代,当年的 SGI、Sun 工作站都是大端机器,军方和科研机构的数据交换也延续了这个习惯。今天绝大多数 PC 和手机都是小端(Little-Endian),如果不做字节转换,读出来的每个 float 都会是一个天文数字——这是新手第一次翻车概率最高的地方。
还需要注意一点:官方正式发布的EGM96.ggf是纯二进制数据,没有任何头部。但网上不少二次转存的镜像会在文件前面追加一段 ASCII 说明文本。判断方法很简单,看文件大小:纯数据应该是 721×1441×4 = 4,155,844 字节。如果多了几百字节,那前面就是头部。
file EGM96.ggf如果输出data,说明是纯二进制;如果输出ASCII text之类,说明带头。再用十六进制看一眼前 16 个字节:
xxd -l 16 EGM96.ggf纯数据的前 4 字节应该是类似41 55 3d 71这样不可打印的二进制内容,而不是45 47 4d 39(ASCII 码的 "EGM9")这类文本开头。
2.2 Python 最小实现:struct 逐字节读出 100 万个浮点
刚接触二进制解析的人,最喜欢用struct一个点一个点读。这个方式虽然慢,但是逻辑最透明,适合先确证格式判断是否正确。下面的代码就是最朴素的实现:
import struct path = "EGM96.ggf" LAT_N = 721 # 纬度方向点数:90N 到 90S,间隔 0.25 度 LON_N = 1441 # 经度方向点数:0 到 360,间隔 0.25 度 expected = LAT_N * LON_N * 4 with open(path, "rb") as f: data = f.read() if len(data) < expected: raise ValueError(f"文件不完整:{len(data)} 字节 < {expected} 字节") offset = len(data) - expected # 尾部对齐,跳过可能的文本头部 grid = [[0.0] * LON_N for _ in range(LAT_N)] for i in range(LAT_N): for j in range(LON_N): pos = offset + (i * LON_N + j) * 4 (grid[i][j],) = struct.unpack(">f", data[pos:pos+4]) # 校验:北纬 89.75 度、东经 0 度的格点,EGM96 大地水准面高在十几米量级 print(grid[1][0])struct.unpack(">f", ...)里的>就是大端序,f是 float32。如果这里写成了<f,读出来的值会变成完全不可理喻的大数。代码里offset = len(data) - expected用的是尾部对齐策略:不管文件头是 20 字节还是 200 字节,只要总大小等于expected + header_len,数据就一定在文件末尾,从后往前推算起点比识别头部内容更省事。
这段代码能跑通,但效率很差:100 万个点嵌套循环,CPython 里需要 5 到 10 秒,且内存里还留了一份 4MB 的原始字节。只适合用来验证格式或者做一次性的单点检查,真实项目不建议这么干。
2.3 用 NumPy 批量读取:把 100 万点变成二维数组
NumPy 的fromfile可以直接指定 dtype 的字节序并完成批量转换,比struct快几个数量级。生产环境我一般直接用它:
import numpy as np from pathlib import Path path = Path("EGM96.ggf") file_size = path.stat().st_size expected = 721 * 1441 * 4 if file_size < expected: raise ValueError(f"文件太小:{file_size} 字节 < {expected} 字节") # 数据放在文件末尾,前面可能有一段文本头 offset = file_size - expected # dtype='>f4' 表示按大端 float32 读取,NumPy 会自动转成当前平台的小端 grid = np.fromfile(path, dtype=">f4", count=721 * 1441, offset=offset).reshape(721, 1441)np.fromfile的offset参数以字节为单位,count 是期望读出的元素个数,不是字节数。dtype=">f4"是big-endian float32的简写,等价于">f4";如果你想故意读成原始字节序列再手动byteswap(),可以先用dtype=">u4"读取再调用grid.byteswap(inplace=True).view(np.float32),但在fromfile直接声明大小端是最省事的选择。
读完后建议立刻做一次合理性校验:
# (0,0) 是北纬 90 度、东经 0 度,量级应在十几米 # (360, 720) 是赤道、东经 180 度,通常是一个负值 print(grid[0, 0], grid[360, 720]) print(grid.min(), grid.max()) # 全球范围大约在 -106 米到 +85 米之间grid.min()和grid.max()是最快的自检手段。如果最小值在 -110 左右、最大值在 90 左右,格式解析基本就对了;如果出现-3.4e38或者1.2e-38这类极端值,先回头检查字节序和 offset。
3. 把解析结果变成可用的高程:经纬度坐标换算与双线性插值
3.1 网格坐标换算:为什么第一行是北纬 90°,经度要绕满一圈
有了二维数组,接下来要让经纬度能对到格点上。要注意 EGM96.ggf 的行序号是从北往南数的,和我们平时看地图"上北下南"的习惯一致,但和很多数学库"第一行是 -90°"的习惯相反。
def lat_lon_to_index(lat, lon): """把经纬度转换成 EGM96 格网索引(浮点形式)""" # 纬度限制在 [-90, 90] lat = max(-90.0, min(90.0, lat)) # 经度归一化到 [0, 360) lon = lon % 360.0 # 行号:90N 是 0,90S 是 720 i = (90.0 - lat) / 0.25 # 列号:0° 是 0,359.75° 是 1439 j = lon / 0.25 return i, j # 北京约 39.9N, 116.4E print(lat_lon_to_index(39.9, 116.4)) # (200.4, 465.6)lat_lon_to_index(39.9, 116.4)返回(200.4, 465.6),说明北京落在第 200 行和 201 行之间、第 465 列和第 466 列之间,后面插值就要用到这四个格点。注意经度 360° 和 0° 是同一个位置,但网格里只有 0° 这一列;如果你传入 360°,lon % 360.0会自动归到 0°,不会越界。
这里的i和j是浮点数,整数部分就是格点索引,小数部分就是插值权重。要特别注意的是,i的方向是"越往南越大",所以北纬 90° 对应 0,南纬 90° 对应 720——如果把这个方向搞反,整个地形会垂直翻转。
3.2 双线性插值:求任意经纬度的大地水准面高
EGM96 网格分辨率是 0.25°,在低纬度一个格点大约对应 28 公里。直接取最近格点的值,在山区可能引入数米误差。双线性插值是最常见的做法:先用经度方向插出两条边,再用纬度方向插出最终值。
import math def geoid_height_at(lat, lon, grid): """任意经纬度(角度制)的 EGM96 大地水准面高,单位米""" # 先换算出浮点索引 lat = max(-90.0, min(90.0, lat)) lon = lon % 360.0 i = (90.0 - lat) / 0.25 j = lon / 0.25 i0 = int(math.floor(i)) j0 = int(math.floor(j)) % 1440 # 经度方向回绕 fi = i - i0 fj = j - j0 # 纬度方向需要做边界收敛,防止 90S 处 i0=720 时 i1 越界 i0 = max(0, min(720, i0)) i1 = max(0, min(720, i0 + 1)) # 经度方向天然回绕,j1 一定是合法的 j1 = (j0 + 1) % 1440 v00 = grid[i0, j0] v01 = grid[i0, j1] v10 = grid[i1, j0] v11 = grid[i1, j1] # 先在经度方向各插一条线 top = v00 + (v01 - v00) * fj bottom = v10 + (v11 - v10) * fj # 再沿纬度方向插值 return top + (bottom - top) * fi # 北京 39.9N, 116.4E,EGM96 大地水准面高约 -35 米量级 print(geoid_height_at(39.9, 116.4, grid))核心逻辑就是两次线性插值。j0 = int(math.floor(j)) % 1440这一步保证了经度 359.9° 时j0=1439、j1=0,数据能正确回绕到文件开头的那一列;如果不做回绕,j1=1440直接越界,结果就是数组访问异常。纬度方向的处理则是用min/max把索引夹在合法范围内,这样在 90°N 附近i1和i0都是 0,在 90°S 附近i1和i0都是 720,双线性插值自动退化成"取边界值",也就不会读出数组外的垃圾数据。
3.3 边界处理:经度 359.9° 和纬度 ±90° 的越界检查
双线性插值里最容易翻车的三个边界,我逐个说一下。
第一,经度输入如果是 -180 到 180 的表示法,务必先做归一化。比如西经 120° 实际上是东经 240°,lon % 360.0对负数取模在 Python 里会得到240.0(因为-120 % 360 = 240),这个行为刚好符合需求,但如果你自己写int(lon)再判断,很容易把负经度判成非法输入。
第二,纬度输入超过 ±90° 时直接做 clamp。虽然正常的 GPS 输出不会越界,但如果你把别的高程模型的索引逻辑搬过来,或者输入来自人工配置的坐标,越界就会出现。lat = max(-90.0, min(90.0, lat))一行就能挡掉这个问题。
第三,在南北纬 89.875° 以上,经度方向的双线性插值已经失去物理意义——极点上所有经度都汇聚于一点。此时正确的做法是放弃插值,直接取最靠近极点的那一行数据。一个折中方案:
if abs(lat) >= 89.75: row = 0 if lat > 0 else 720 # 极区经度方向取平均,减少单格点噪声 return float(grid[row, :].mean())极区取行平均是为了抵消经度方向在极点附近的数值噪声。实际使用中,如果应用场景不涉及极区,这段可以省略;但如果做全球覆盖的航迹规划,不处理这一步就会出现"插入到经度回绕点上时高度跳变几米"的伪影。
4. 精度与坐标系:解析出来之后还要做什么
4.1 从 N 到 h:椭球高、正高与海拔高的换算关系
EGM96.ggf 里存的值叫大地水准面高(Geoid Height),通常用 N 表示。GPS 直接测量得到的高程是相对于 WGS84 椭球面的椭球高(h),而我们日常说的海拔、正高(H)是相对于大地水准面的。三者近似满足:h = H + N,所以从 GPS 椭球高换算海拔就是 H = h − N。
def gps_height_to_orthometric(gps_height, lat, lon, grid): """GPS 椭球高转近似海拔高(单位米)""" N = geoid_height_at(lat, lon, grid) return gps_height - N这里有一个大多数资料不会细讲的差异:EGM96 的大地水准面是相对于 WGS84 椭球定义的,如果你的 GPS 接收机使用其他椭球(比如 PZ-90 或 CGCS2000),必须先做椭球转换,否则 N 值和实际差值会整体平移几十米。在中国大陆使用 CGCS2000 时,它与 WGS84 的椭球差异导致的系统性偏差在 0.1 米量级,多数场景可忽略,但在厘米级控制测量里必须处理。
另外要提醒:这个公式忽略了垂线偏差和重力位差异的细微影响,误差通常在亚米级。如果你做的是无人机仿地飞行这种精度要求 1 米左右的场景,直接套用即可;做一等水准测量时,别指望一个 15 弧分网格能替代实地水准联测。
4.2 EGM96 与 EGM2008 的差异:什么时候该换模型
EGM96 的分辨率是 15 弧分,大约 28 公里;EGM2008 的分辨率是 2.5 弧分,大约 5 公里。两者在低频部分非常接近,但在山区和高原,EGM2008 能捕捉到更多短波重力异常信号,局部差值可以达到 2 到 5 米。
| 项目 | EGM96 | EGM2008 |
|---|---|---|
| 发布年份 | 1996 | 2008 |
| 分辨率 | 15 弧分(约 28km) | 2.5 弧分(约 5km) |
| 格网大小 | 721×1441 | 4321×8641 |
| 二进制文件大小 | 约 4.2MB | 约 140MB(float32) |
| 适用场景 | 离线设备、实时解算、低内存环境 | 厘米级后处理、高精度测绘 |
如果你的应用在低功耗嵌入式环境、需要快速加载且内存吃紧,EGM96.ggf 是一个合理取舍;如果做科研级数据处理、且存储和算力充裕,直接上 EGM2008 或更新的 XGM2019。不要迷信"新模型一定更好"——EGM2008 在个别海洋区域和极区的数值受输入数据影响,反而有局部噪声,EGM96 的平滑特性在滤波场景里是加分项。
4.3 单位与符号陷阱:米、厘米与负值的含义
EGM96.ggf 里存的是米。有些 GPX 处理库会把 geoid height 转成厘米或分米后再输出,导致下游任务把数值放大了 100 倍。每次拿到外部数据源时,先打印一批已知点位做对照,不要轻信变量名里写着geoid_m就默认是米。
大地水准面高有正有负,这不是异常。全球范围内 EGM96 的 N 值大约在 -106 米到 +85 米之间,海洋上通常为负几十米,中国东部地区大约在 -20 到 -40 米之间。如果你在某处读到正的 80 米,那大概率是格陵兰或喜马拉雅一带的重力异常区,而不是数据出错。
5. 解析 EGM96.ggf 的踩坑记录:大小端、文件头与边界
5.1 现象一:读出的小数全是天文数字
- 现象:
grid.min()是-3.4e38,grid.max()是3.4e38,每个数都大得离谱。 - 原因:用小端序读了大端数据。现代 x86/ARM 处理器默认小端,
dtype="<f4"或struct.unpack("<f", ...)读大端文件就会得到完全错位的浮点数。 - 解决:解析时用
dtype=">f4",或者读取后用byteswap()翻转。最快的自检方法是看(0,0)这个点——北纬 90°、经度 0° 的 EGM96 大地水准面高应该在十几米,任何偏离这个量级两位以上的结果都说明字节序错了。
5.2 现象二:所有值都偏了半个格点
- 现象:在已知控制点(比如当地已知海拔的 GPS 测站)对比,误差稳定在 0.2 米附近,且所有点位都朝同一方向偏移。
- 原因:索引公式里的行列方向搞反了。比如把第 0 行当成了赤道,或者把经度列的方向从西经开始编号,都会导致整体半格偏移。0.25° 的网格在北纬 40° 处对应约 21 公里,半格就有 10 公里水平位移,反映在高程上就是系统性偏差。
- 解决:回到
lat = 90 - i * 0.25、lon = j * 0.25这个基准,找一个你熟悉的高程控制点做验证。北京(39.9N, 116.4E)的大地水准面高约 -35 米,如果读出来是 -15 米,就该查偏移了。
5.3 现象三:南北极附近插值结果突变
- 现象:航线跨越北纬 89.5° 时,求出的 N 值在几个格点之间跳变数米,形成折线状的锯齿。
- 原因:极点处经度退化,所有经线汇聚于一点,但网格仍按 0.25° 经度间隔存了 1441 个点。双线性插值对这些点做经度方向混合时,参考的是"经度差",而物理上这些点之间的距离趋近于零。
- 解决:当
abs(lat) >= 89.75°时,直接使用对应极区那一行(第 0 行或第 720 行)的数据,不再做经度插值。如果对精度有更高要求,可以对该行做经度方向的低通滤波后再取值。
5.4 现象四:文件大小和预期对不上
- 现象:下载的
EGM96.ggf是 4,213,248 字节,和 4,155,844 对不上;或者代码抛ValueError、读出来全是 NaN。 - 原因:三种常见情况——镜像站在文件头加了 ASCII 说明;下载被中断只存了部分数据;拿到的是 0.5° 分辨率版本(大小约为 1/4)。
- 解决:先用
file_size % 4判断是否为 4 字节对齐;再用file_size // 4是否能整除721*1441判断行列数是否匹配。如果能整除但商不是721*1441,说明分辨率版本不同;如果余数不为 0,说明有头部或损坏。按照offset = file_size - expected的方式从尾部对齐读取,可以避开大部分头部问题。
6. 进阶用法:用 mmap 做按需读取,把 GGF 解析做成公共服务
6.1 用 numpy.memmap 按需取点,避免全量载入内存
如果你的服务要常驻内存、同时处理大量不同经纬度的高程查询,4MB 的grid本身不占空间,但有更优雅的做法:用np.memmap把文件映射到虚拟内存,按索引直接访问,不把全部数据读进物理内存。
import numpy as np # 纯数据文件,直接从 0 偏移映射 mm = np.memmap("EGM96.ggf", dtype=">f4", mode="r", shape=(721, 1441)) # 之后所有 grid 操作照常 sample = geoid_height_at(39.9, 116.4, mm) print(sample)注意np.memmap在部分平台上对stride和offset的处理与大端序组合可能有问题,如果你观察到性能异常或数据错位,改用标准open + mmap后配合struct.unpack_from单点读取更稳:
import mmap, struct with open("EGM96.ggf", "rb") as f: mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) def get_value(i, j): pos = (i * 1441 + j) * 4 return struct.unpack_from(">f", mm, pos)[0]单点读取的延迟在几十微秒级,适合作为 HTTP 高程查询接口的后端。
6.2 Qt/C++ 场景的读取姿势
如果你是在 Qt 里做桌面工具,QDataStream的字节序设置要格外小心。默认情况下它用小端,需要显式切到大端:
QFile file("EGM96.ggf"); if (!file.open(QIODevice::ReadOnly)) return; QDataStream in(&file); in.setByteOrder(QDataStream::BigEndian); float v; for (int i = 0; i < 721 * 1441; ++i) { in >> v; // 每个 v 就是网格中的一个大地水准面高 }这里最容易犯的错是忘记setByteOrder,导致整个模型在 C++ 侧读出来全是错值,而 Python 侧由于用了>f4是对的,两边对不上debug半天。无论是 Python 还是 C++,记住同一件事:EGM96.ggf 的字节序是写死在文件里的,不是运行时可选参数。
我在自己项目里吃过一次亏:第一次写解析器时为了省事,直接按本机小端读,结果新疆区域的高程整体偏了十几米,飞控里定高全部出错。后来把所有高程数据源都统一成"先验证 (0,0) 点的值,再进业务逻辑"的习惯,这类问题基本都能在 10 秒内暴露。希望帮到你。
本文还有配套的精品资源,点击获取