摘要:野外用 GPS 实测的采样点,回到室内画到天地图上,点位漂到几百米外的马路上------不是设备坏了,是底图用的根本不是你手里的坐标系。这篇讲清 GCJ-02 的非线性加扰数学、平面测量坐标为什么必须"转两次",以及 MapLibre 下为什么纠偏该收敛成一个无状态纯函数。附公式 ↔ 代码核对方法和坑对照表。
目录(TOC)
- 一、现场:点位漂到几百米外的马路上
- [二、GCJ-02 偏移的核心数学](#二、GCJ-02 偏移的核心数学 "#%E4%BA%8Cgcj-02-%E5%81%8F%E7%A7%BB%E7%9A%84%E6%A0%B8%E5%BF%83%E6%95%B0%E5%AD%A6")
- 三、最反直觉的一步:平面测量坐标要"转两次"
- 四、按底图类型决定是否纠偏(统一出口)
- [五、与 osmdroid 的异同:通用原理 vs MapLibre 实现](#五、与 osmdroid 的异同:通用原理 vs MapLibre 实现 "#%E4%BA%94%E4%B8%8E-osmdroid-%E7%9A%84%E5%BC%82%E5%90%8C%E9%80%9A%E7%94%A8%E5%8E%9F%E7%90%86-vs-maplibre-%E5%AE%9E%E7%8E%B0")
- 六、坑对照表
- 七、小结与落地清单
一、现场:点位漂到几百米外的马路上
想象一下这个现场:你在野外用 GPS 实测了一个采样点的经纬度,回到室内把它画到天地图上,结果点位漂到几百米外的马路上去了。
不是设备坏了,也不是代码写错------因为天地图用的根本不是你手里的坐标系。
在中文互联网地图生态里,几乎所有公开在线底图(天地图、高德、腾讯等)使用的都不是真实的 GPS 坐标(WGS-84),而是一种名为 GCJ-02(俗称"火星坐标")的加密偏移坐标系。
这带来一个经典痛点:
用 GPS 实测的真实经纬度,直接画在 GCJ-02 底图上,点位会整体偏移几百米。
反之,把 GCJ-02 坐标当作 WGS-84 拿去测量、叠加矢量数据,同样会错位。
而 OSM 这类国际底图是纯 WGS-84,不需要纠偏。于是工程里必须回答两个问题:
- 如何在一套代码里兼容"要不要纠偏"两种底图?
- 原始数据多是平面测量坐标(北向 N / 东向 E),又该怎么落到地图上?
本文与 osmdroid 篇(01 坐标纠偏)同源不同实现。 纠偏算法本身是引擎无关的,差异在于"这个出口该放在哪"。
二、GCJ-02 偏移的核心数学
GCJ-02 的算法是公开的(Krasovsky 1940 椭球参数 + 非线性加扰)。其本质是:
给定一个 WGS-84 经纬度,计算出一个非线性偏移量,叠加后得到 GCJ-02 坐标。
反向(GCJ-02 → WGS-84)没有解析公式,只能迭代逼近。
2.1 关键常量
text
a = 6378137.0 // 椭球长半轴
ee = 0.00669342162296594323 // 第一偏心率平方
PI = 3.14159265358979323846
2.2 单点偏移计算(WGS-84 → GCJ-02)
先判断是否在中国境外(outOfChina),境外直接原样返回、不做偏移:
kotlin
if (outOfChina(lat, lon)) return doubleArrayOf(lat, lon)
偏移量由下面两式给出(注意 dLat 同时受纬度与经度影响,dLon 同理):
kotlin
dLat = transformLat(lon - 105.0, lat - 35.0)
dLon = transformLon(lon - 105.0, lat - 35.0)
radLat = lat / 180.0 * PI
magic = sin(radLat)
magic = 1 - ee * magic * magic
sqrtMagic = sqrt(magic)
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * PI)
dLon = (dLon * 180.0) / (a / sqrtMagic * cos(radLat) * PI)
gcjLat = lat + dLat
gcjLon = lon + dLon
其中 transformLat / transformLon 是若干正弦、余弦多项式的组合(带 2π 周期项 300.0 + lat + lon 的扰动),这是 GCJ-02 非线性感的来源。
公式 ↔ 代码一致性核对 :代码中的
transformLat(x, y)第一个参数是lon - 105.0,第二个是lat - 35.0,与惯例"经度减 105、纬度减 35"一致;dLat的分母使用了(a * (1 - ee)) / (magic * sqrtMagic),与 WGS-84 子午圈曲率半径M = a(1-ee)/sqrt(magic³)的量纲一致(公式写成了除以magic*sqrtMagic的等价形式);dLon分母的a / sqrtMagic * cos(radLat)对应卯酉圈曲率半径N = a/sqrtMagic乘以cos(lat)。三者与文本公式完全对应,无偏差。
为什么要做这个核对? 因为这套公式是"抄来的",而抄公式最容易出现量纲错位------把 dLat 的分母用到 dLon 上,偏移会大到离谱但程序不报错。我在 osmdroid 篇里就栽过这个跟头。逐项对齐量纲,比事后调试便宜得多。
2.3 反向纠偏(GCJ-02 → WGS-84)
没有闭式解,采用"先正向算偏移,再反向扣减"的迭代:
kotlin
fun wgs84FromGcj02(gcjLat, gcjLon): DoubleArray {
if (outOfChina(gcjLat, gcjLon)) return doubleArrayOf(gcjLat, gcjLon)
val gLat = gcjLat - (transformLat(gcjLon - 105.0, gcjLat - 35.0))
val gLon = gcjLon - (transformLon(gcjLon - 105.0, gcjLat - 35.0))
// 上面只是初值;更精确的多数实现再做一次微调迭代
return doubleArrayOf(gLat, gLon)
}
工程上常用一次近似即可满足"几百米量级"的纠偏需求;若要求更高,可把初值再代入正向公式做一次残差修正。
三、最反直觉的一步:平面测量坐标要"转两次"
测绘外业拿到的往往不是经纬度,而是 以某已知点为原点的平面测量坐标 :北向增量 N(米)、东向增量 E(米),以及原点的 WGS-84 经纬度。
要把它画到 GCJ-02 底图上,链路是三步走:
这就是"转两次"的含义:正向转一次(平面 → 经纬度),纠偏再转一次(经纬度 → 火星)。
关键实现是这两个互逆函数:
kotlin
// 平面测量坐标 → WGS-84 经纬度(以原点 lat0/lon0 为基准)
fun northEastToLatLon(n: Double, e: Double): Pair<Double, Double> {
val lat = lat0 + n / 111000.0
val lon = lon0 + e / (111000.0 * cos(lat0 * PI / 180.0))
return lat to lon
}
// WGS-84 经纬度 → 平面测量坐标(反算,供双向校验)
fun latLonToNorthEast(lat: Double, lon: Double): DoubleArray {
val n = (lat - lat0) * 111000.0
val e = (lon - lon0) * 111000.0 * cos(lat0 * PI / 180.0)
return doubleArrayOf(n, e)
}
公式 ↔ 代码一致性核对 :
lat = lat0 + n / 111000.0与文本公式一致;lon = lon0 + e / (111000 * cos(lat0)),代码中的111320.0 * cos(...)是更精确的赤道子午线周长 / 360 变体,量纲一致(两种写法都近似正确,代码版精度略高);反算northEastToLatLon与latLonToNorthEast互为逆运算,代码完全对应。无偏差。
"转两次"的三个坑
- 坑 1:忘了第二步纠偏。平面 → 经纬度后直接画,点位相对火星底图偏移数百米。
- 坑 2:底图是 WGS-84(如 OSM)却多纠偏一次 。此时应
outOfChina之外保持原坐标,或显式"不纠偏"。 - 坑 3:把纠偏后的点再反算平面坐标。纠偏是非线性的,反算走的应是 GCJ-02 反函数,而非简单减偏移。
坑 1 和坑 2 是对称的,而且症状完全一样("点偏了")------所以排查时第一件事不是改代码,而是先确认"当前底图到底是不是火星坐标系"。
DEMO 用一个按钮专门演示"转两次":输入一组 (N, E),先 northEastToLatLon 得到经纬度,再 correct 得到火星坐标,Toast 同时打印纠偏前 / 后经纬度,肉眼可验证偏移量。
四、按底图类型决定是否纠偏(统一出口)
把"要不要纠偏"封装成一个开关,避免业务代码到处 if:
kotlin
fun toMapLatLng(rawLat, rawLon, baseMapIsGcj: Boolean): Pair<Double, Double> {
return if (baseMapIsGcj) {
val c = correct(rawLat, rawLon) // WGS-84 → GCJ-02
c[0] to c[1]
} else {
rawLat to rawLon // WGS-84 底图,原样使用
}
}
DEMO 中 OSM 按钮走 else 分支(不纠偏),天地图按钮走 if 分支(纠偏),同一份原始 GPS 点就这样兼容两种底图。
五、与 osmdroid 的异同:通用原理 vs MapLibre 实现
通用原理(两篇完全一致,可互相印证)
GCJ-02 偏移数学、多项式 + 三角函数加扰、outOfChina 短路、平面测量坐标"转两次"(先投影到经纬度、纠偏、再投影回平面)、以及"按底图类型单一出口决定是否纠偏"------这些与 osmdroid 篇(01)描述的原理相同,纠偏算法本身与底层引擎无关。
MapLibre 专属实现差异
| 维度 | osmdroid 篇(01) | 本文(MapLibre) |
|---|---|---|
| 落点方式 | 命令式 addMarker / 注入 GeoPoint |
经纠偏后的坐标交给 RasterSource 底图与 GeoJSON 业务层统一渲染 |
| 底图切换 | 切换 MapView 的瓦片源 provider |
通过 RasterSource + 注记层热插拔(见瓦片源工厂篇) |
| 纠偏出口位置 | 写在坐标 → 屏幕投影入口(Overlay 体系内) | 收敛为 toMapLatLng() 纯函数,与渲染解耦 |
| 适用范围 | Canvas 命令式绘制 | GPU 声明式图层(点 / 线 / 面 Layer) |
一句话:
纠偏算法是"引擎无关"的通用知识;MapLibre 下它更该收敛成一个无状态的纯函数,因为底图与业务要素都走统一的"源 + 图层"模型,纠偏只需在"原始坐标 → 源数据"这一处发生一次。
这个差异很关键:osmdroid 是命令式绘制,每一帧都可能触发投影,所以纠偏要挂在投影入口;MapLibre 是声明式,数据进 Source 时就定型了,所以纠偏发生在"生成 GeoJSON"那一步------更早、更集中、也更好测。
六、坑对照表
| # | 症状 | 根因 | 解法 |
|---|---|---|---|
| 1 | 点位整体偏移几百米 | 忘了第二步纠偏 | 平面 → 经纬度 → 纠偏,转两次 |
| 2 | OSM 底图上点也偏了 | 不该纠偏却纠了 | 按底图类型开关,WGS-84 底图原样返回 |
| 3 | 反算回来的坐标不对 | 用减法代替 GCJ-02 反函数 | 走迭代反解,非线性不能简单相减 |
| 4 | 偏移量大到离谱(几公里) | dLat/dLon 分母抄反,量纲错位 |
逐项核对量纲,见第二节核对说明 |
| 5 | 境外项目坐标被误纠 | 没做 outOfChina 短路 |
函数入口先判境内外 |
| 6 | 纠偏后数据存回再读,越存越偏 | 存纠偏、读又纠偏,偏移累加 | 存 / 读成对:存正纠、读反纠 |
| 7 | 换底图后所有点全偏 | 纠偏散落在各处业务代码 | 收敛到 toMapLatLng() 单一出口 |
七、小结与落地清单
- GCJ-02 偏移是非线性加扰,正向可算、反向需迭代。
- 平面测量坐标落图 = 平面 → 经纬度 + 经纬度 → 火星,必须转两次。
- 用"底图是否 GCJ"的布尔开关统一纠偏出口,业务侧零感知。
outOfChina判断放最前,境外直接短路返回。- 抄公式要逐项核对量纲,分母抄反是最高频的错误。
落地建议
- 把纠偏工具做成无状态纯函数单例(DEMO 即如此)------无状态才好用、好测、好替换。
- 补一条单元测试 :取已知城市中心点,验证纠偏前后偏移落在 300--700 米 区间。这条测试的价值不在"验证算法对"(算法是公开的),而在防止算法被后人误改------常量被改、分母被抄反、正负号写反,测试会立刻报警。
完整可运行源码 :GitCode 仓库 · android_osmdroid_maplibre
对照阅读:本系列第 01 篇(osmdroid 坐标纠偏:WGS-84 落到 GCJ-02 底图,为什么要转两次)------同一套算法,一个挂在 Canvas 投影入口、一个收敛成纯函数,对照看能理解"引擎架构如何决定代码的收敛位置"。
延伸阅读:本系列第 09 篇(MapLibre 瓦片源工厂:多域名轮询 + 压级别)
评论区聊聊:你在实际项目里被"火星坐标"坑过吗?是"忘了纠"还是"多纠了一次"?偏移了多少米才发现的?
本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。