ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

EGM96.ggf二进制解析:从存储格式到Python实现

EGM96.ggf二进制解析:从存储格式到Python实现 简介解析大地水准模型EGM96.ggf二进制文件是基于Visual Studio 2012与.NET 4.5的完整C#工程面向测绘、GIS及地球物理领域需要读取全球重力场高程异常的开发者。程序已成功解析天宝公司GGF格式文件得到721行1441列高程异常矩阵支持按经纬度如23°N、113°E快速查询并以热力图形式渲染数据分布便于直观分析区域重力场变化。压缩包共33个文件、约3.29MB含9个C#源文件、解决方案/配置/资源文件、可直接运行的exe以及1个EGM96.ggf原始数据结构清晰。资源目前已有136人学习下载后除获得完整源码与二进制数据外还可对照调试缓存、资源映射等内容理解二进制网格读取原理并基于工程扩展自定义区域可视化分析。 如果你正在做GPS高程转换、大地水准面精化或者GNSS控制网数据处理大概率绕不过EGM96这个模型。前几天我从一个项目里拿到一个EGM96.ggf的二进制文件文件不大但没头没尾网上关于这个格式的中文资料少得可怜大部分教程都在讲“怎么用别人封装好的工具直接查表”根本没有说清楚二进制内部到底是什么结构。这篇文章就从一个二进制文件开始把EGM96.ggf的存储格式、解析思路、代码实现和验证方法完整过一遍给后面要处理这类模型文件的同行留一份能直接抄作业的参考。1. EGM96模型为何值得手动解析而不是直接调库1.1 EGM96怎么参与高程换算ggf文件又是从哪来的EGM96是1996年美国国家地理空间情报局NGA当时还叫NIMA、美国宇航局戈达德太空飞行中心和俄亥俄州立大学联合发布的全球重力场模型球谐展开到360阶次对应的空间分辨率大致是55公里左右。这个模型用一个统一的参考椭球定义了全球大地水准面相对WGS84椭球的大地水准面起伏geoid undulation国内常常直接叫高程异常。GPS接收机量出来的高程本质上是相对WGS84椭球的椭球高“h”。而我们日常用的海拔、水准高程是相对大地水准面的正高“H”两者关系是h H N这里的N就是大地水准面起伏。所以只要知道了EGM96给出的N值就能在GPS观测值和实际海拔之间完成换算。这也是为什么EGM96明明发布快三十年至今还有很多测绘软件把它作为默认的高程转换模型。.ggf是geoid grid file的缩写是EGM96官方分发的一种网格化格式。常见的分发版本有两种一种是15弧分分辨率的ASCII文本文件WW15MGH.GGF打开后全是密密麻麻的数字另一种是各种工具链、离线包里流通的二进制版本。二进制版本没有一个统一标准不同来源的文件头、字节序、存储方向都可能不一样这也是解析时容易翻车的地方。1.2 现成工具确实省事但手动解析能避开三类麻烦很多人的第一反应是这种模型文件用GDAL或者GMT读一下不就行了确实GDAL的gdal_translate能识别不少geoid网格GMT的grd读GGF也很方便Python里甚至有一些封装好的库可以直接下载模型、查询高程异常。但是这些现成方案有三个现实问题。第一是平台依赖。如果你所在的开发环境是C#、Java或者嵌入式平台很多地理信息库根本没法直接引用手动解析就成了唯一出路。第二是黑盒风险。网上流传的一些工具脚本内部对经度方向、纬度顺序的处理并不一定正确我用不同工具查同一个点得到的结果能差出去好几米。第三是理解成本。解析格式的过程中你会把坐标方向、网格步长、基准面这些底层约定彻底弄清楚之后做跨坐标系转换、精度评估或者定制重采样心里是有底的而不是出了问题完全不知道怎么排查。所以我的建议是拿现成工具做验收可以但核心解析逻辑一定要自己掌握。这并不难看完后面几个章节你就能体会到了。2. ggf二进制文件的内部结构从文件头到数据体2.1 经纬网格的方向约定与坐标范围二进制GGF本质上就是一张规则的经纬度网格。以很多离线工具里流通的1度×1度版本为例纬度范围通常从北纬90度到南纬90度经度范围从0度到东经360度。数据是一个二维数组每一行对应一个纬度每一列对应一个经度。存储顺序通常遵循一个约定纬度方向从北往南经度方向从0往360递增。先存纬度90度的所有经度点然后89度、88度……一路存到-90度。之所以用0到360的连续经度而不是-180到180是为了避免跨日期变更线时索引断裂的麻烦纬度从北向南则继承了很多早期Fortran程序按行输出然后直接落盘的习惯。这个方向约定极其重要。有的二进制文件会把纬度做成从南到北有的经度做成-180到180。如果你按错的顺序解析整个数据网格会上下颠倒或者左右镜像查具体点时结果完全对不上。所以拿到文件后第一件事不是写代码而是确认这份文件到底按什么方向排列。2.2 文件头、字节序与float32的排列方式另一个常见的问题是文件头。我见过两类ggf二进制文件一类是完全裸数据文件第一个字节开始就是float32数组另一类开头有一段ASCII文本头记录行列数、网格范围、单位、源模型版本等信息文本头之后才是二进制数据体。纯数据版本更隐蔽打开十六进制编辑器全是乱码容易让人误以为是损坏文件。文本头版本则比较容易猜出结构但数据起始位置需要自己定位。浮点数在内存里按IEEE 754的float32存储。绝大多数Windows和Linux的x86环境是小端序但如果你在一个老式RISC工作站或者经过网络字节序转换的文件里拿到大端数据解析结果会完全错乱。判断方法很简单读四个字节如果十六进制是“00 00 80 3F”转成浮点数约等于1.0说明是小端如果是“3F 80 00 00”则是大端。我建议拿到文件后先用十六进制编辑器看前几百个字节确认有没有文本头再随便挑四个字节用浮点数解析一手确认字节序。这几十秒的功夫能帮你省掉后面一整天的debug时间。2.3 解析之前先用文件大小反推文件结构解析之前一定要做的另一个动作是用文件长度验算自己猜的分辨率是否合理。如果你猜测的是1度网格、181行×360列那么数据体大小正好是181×360×4260640字节约254.5KB。如果文件长度比这个值多出几百字节说明存在文件头如果文件大小是这个值的两倍数据可能是float64而不是float32如果行数和列数远大于预期则要考虑是不是15弧分版本也就是721×1441的网格数据体约4MB。我第一次拿到这个文件时文件大小正好是260640字节当场判断无文件头、1度网格、float32、小端序。后续验证完全吻合。这个“先猜后验”的思路比直接套代码更能帮你建立对整个文件结构的把握。3. 手写Python解析器核心代码逐段讲解3.1 裸数据读取与二维网格重排下面这段是基础读取逻辑针对无文件头的裸数据版本。如果你的文件带文本头只需要把skip_bytes设成头部长度。import numpy as np def load_ggf(path, ncols360, nrows181, skip_bytes0, endian): dtype f{endian}f4 # 小端float32 with open(path, rb) as f: if skip_bytes: f.seek(skip_bytes) raw f.read() arr np.frombuffer(raw, dtypedtype, countncols * nrows) grid arr.reshape(nrows, ncols) return grid这段代码的核心点在于跳过文件头后把剩余字节符合同一个dtype连续读入再用reshape重排成二维数组。reshape时需要注意行方向默认第一行对应文件中的第一个float。如果文件纬度从90度开始那grid[0]就是北纬90度那一行如果文件纬度从-90度开始则grid[0]是南纬90度那一行后面取行列时公式需要反向。np.frombuffer返回的是只读数组如果后面想原地做插值、重采样或者写回最好再加一行grid grid.copy()否则改数据时会报奇怪的写保护错误。3.2 经纬度到行列索引的映射公式数据读进来之后最核心的问题是给定任意经纬度怎么定位到网格里对应的行列。我用的是线性映射公式def latlon_to_index(lat, lon, nrows181, ncols360, lat_start90.0, lat_end-90.0, lon_start0.0, lon_end360.0): row (lat_start - lat) / (lat_start - lat_end) * (nrows - 1) col (lon - lon_start) % (lon_end - lon_start) / (lon_end - lon_start) * ncols return row, col纬度方向的公式很好理解。如果行从北纬90度均匀分布到南纬90度那么lat90时row0lat0时row90lat-90时row180。这个线性插值对任何均匀网格都通用只要把90、-90替换成文件里实际的纬度范围就行。经度方向稍微复杂一点因为经度是一个连续闭环。输入-180度本质上等于数据里的180度。先用取模把lon映射到0到360的范围内再计算列索引可以避免负数越界和跨零点时的索引错乱。这个做法比单纯加360或减360更稳妥因为无论输入多大或者多小的经度最终都会落回合法的列范围。3.3 双线性插值别让1度网格浪费你的精度网格点是离散的实际查询时如果直接取最近整数行和列在纬度55度以上一个格点对应的地面距离已经不到60公里误差可能达到几十厘米。所以项目里需要把最近邻替换为双线性插值def geoid_undulation_egm96(grid, lat, lon): nrows, ncols grid.shape lat_start, lat_end 90.0, -90.0 lon_start, lon_end 0.0, 360.0 if lon lon_start: lon 360.0 if lon lon_end: lon - 360.0 r (lat_start - lat) / (lat_start - lat_end) * (nrows - 1) c (lon - lon_start) / (lon_end - lon_start) * ncols r0 int(np.floor(r)) c0 int(np.floor(c)) r0 min(max(r0, 0), nrows - 2) c0 min(max(c0, 0), ncols - 1) c1 c0 1 if c1 ncols: c1 0 # 经度循环回绕 wr r - r0 wc c - c0 top grid[r0, c0] * (1 - wc) grid[r0, c1] * wc bottom grid[r0 1, c0] * (1 - wc) grid[r0 1, c1] * wc return top * (1 - wr) bottom * wr这里需要注意的点在经度列。当c0已经处于最后一列时它右侧的邻居不是越界而是经度绕回最前面那一列也就是经度359度右边应该是0度。纬度和经度在这个文件里的处理逻辑是不对称的纬度方向是物理边界90度和-90度就是网格终点超出边界应该报错或单独处理而经度方向可以无限循环。最后说一下为什么用双线性而不是更高阶的样条插值。EGM96网格本身是模型球谐系数展开后重新采样得到的相邻格点之间的真实变化非常平滑双线性插值已经能把误差压到厘米级。用更高阶的插值并不会带来明显收益反而会引入过冲和振铃现象得不偿失。4. 验证解析正确性和已知点对比的完整过程4.1 先看全局统计量判断量级是否靠谱代码跑通后的第一步不是急着查点而是先看全局统计量。EGM96在全球范围内的大地水准面起伏大致在-106米到85米之间负值集中在印度洋斯里兰卡以南正值集中在新几内亚附近。如果你解析出来的数组最大最小值在这个量级附近说明数据结构、字节序、网格方向基本对了。反过来如果你看到最大值是几十亿、最小值是负的几十亿那大概率是字节序反了float32被按反方向解析原本的小数变成了天文数字。如果最大最小值在几百上千可能是把float64当成了float32或者没有跳过正确的文件头把文本内容也当浮点数读了进来。这一步只要写一行代码grid load_ggf(egm96.ggf) print(grid.shape, grid.min(), grid.max())只要输出结果符合预期就可以进入下一步。4.2 用可信第三方结果做抽样对比全局统计量只能证明量级对不能证明坐标映射对。接下来需要用至少三个已知点做抽样对比。我建议选取分布在北半球、南半球、跨经度零点附近的坐标分别用可信任的在线工具或者GDAL等成熟软件查一遍再用自己的解析函数查一遍两者差值的绝对值应该不超过米级。有一个容易被忽略的细节是很多在线工具返回的是基于球谐系数展开的原始计算值而ggf网格是采样后再插值的结果。两种方式的理论差值是厘米级到分米级只要在这个范围内都算正常。如果你发现某个点误差达到了几米先检查经度范围有没有映射错尤其是输入-150度时是否被正确换算到了东经210度的位置。抽样点坐标我这里给一个建议组合一个在北纬30度附近一个在南纬45度附近一个在西经/东经边界附近比如经度179.5度。这样能同时验证纬度方向和经度回绕逻辑。4.3 与球谐展开公式的差异应该是多少量级解释一下为什么网格插值和球谐展开会有差别。EGM96本质上是一组360阶的球谐系数理论上可以计算任意一点的准确值。而ggf文件是先把全球按固定间隔采样成网格再按float32保存。采样过程本身会丢掉一部分高频细节float32存储还会引入微小的量化误差。所以你在同一个坐标上用球谐系数展开算出来的N值和用ggf网格双线性插值算出来的N值通常会有厘米级到分米级的差异。这个值不是bug是正常现象。反过来如果你的两个结果完全一致反而要怀疑是不是用了同一个退化算法。理解这个差异能帮你在项目中判断当前应用对高程转换的精度要求到底是允许直接用ggf插值还是要回到球谐系数展开计算。5. 实战中的边界条件与常见坑5.1 经度跨零点与极点的行列处理我在这部分踩过的坑最多列出来供你参考。经度跨零点是最容易出现镜像问题的地方。当你输入经度-179.5度时如果程序里直接把它当作小于0的非法值返回或者只简单加了一个360但没考虑取模逻辑查出来的点就会差出半个地球。正确做法是统一走取模流程负数经度加上360大于等于360的减去360无论如何都要落在0到360的半开区间里。这个半开区间也提醒你经度360度和0度是同一个位置判断时用lon 360而不是lon 360。极点附近的处理要小心。纬度90度时行索引row0纬度-90度时row180。但在我的插值函数里r0被clamp到nrows-2所以极点附近会拿最后两行做插值结果在物理上是合理的。如果你的应用需要精确的极点值最简单的方式是取第一行或最后一行的经度平均因为极点上所有经度对应同一个空间点。5.2 文件头不固定时如何自动定位数据起点带文件头的ggf版本头部长度没有统一标准有的几百字节有的甚至几千字节。手工数十六进制偏移量不现实更稳妥的办法是用文件大小反推import os def detect_skip(path, ncols, nrows): data_bytes ncols * nrows * 4 total_bytes os.path.getsize(path) skip total_bytes - data_bytes if skip 0: raise ValueError(分辨率或数据类型不匹配请检查ncols、nrows、dtype) return skip这个函数算出的skip如果大于0就是文件头长度。如果等于0就是裸数据文件。如果小于0说明文件大小比预期数据体还小要么是分辨率猜大了要么数据不是float32需要回头检查。你可能会问如果文本头里恰好包含一些和文件大小匹配的字节怎么保证skip一定是文本头答案是算出来的skip就是数据起点因为数据体体积是固定的文件总大小减去数据体体积剩下的只能是头部或者填充字节。只要ncols、nrows、dtype这三个参数猜对了这个反推就一定是准确的。5.3 从ggf解析到真实项目集成时容易忽略的细节最后聊几个项目集成层面的注意事项。第一ggf文件给出的大地水准面起伏是相对WGS84椭球的如果你在CGCS2000或者其他参考框架下工作需要注意不同椭球定义带来的零点差异。虽然这个差异通常极小但在精密水准测量中不能直接无视。第二批量查询性能。如果只是偶尔查几个点上面这段Python代码完全够用。但如果要在无人机数据处理、PPP解算里对几百万个点做批量插值建议先用numpy把坐标批量转成行列索引一次性生成所有下标再通过grid[r_list, c_list]做向量化采集性能能提升几个数量级。逐点循环的写法在百万级数据下会很痛苦。第三数据文件的来源管理。不同来源的ggf文件可能描述的是同一模型但网格方向、文件头结构各有差异建议在代码里用一个配置字典记录每个文件的格式参数包括nrows、ncols、skip_bytes、endian、lat_start、lon_start。这样换文件时不用改解析逻辑只需要加一条配置记录。我在实际项目里第一次把经度范围理解成-180到180结果整个数据镜像错位查一个点居然偏了几百公里排查了两天才发现是列索引映射的问题。从那以后我每次解析完都会先做一次sanity check打印shape、min、max再取几个自己熟悉位置的高程异常值对一下确认没问题才进入业务逻辑。这个习惯帮我省掉了后面很多不必要的返工。本文还有配套的精品资源点击获取
返回列表