osmdroid 地图实战 01|外业点全偏几百米?WGS-84 落到 GCJ-02 底图,为什么要"转两次"

2 阅读11分钟

摘要:一个测绘外业 App,现场采回来的坐标往地图上一投,整片数据跟实际地物错开几十到几百米——不是 GPS 坏了,是坐标系差了两次转换。这篇讲清 WGS-84 ↔ GCJ-02 的双向换算,以及当你的业务坐标是平面测量坐标 (N, E, H) 时,纠偏为什么必须是一条"转两次"的完整链。

目录(TOC)


一、先说一个真实的翻车现场

先说我的第一次踩坑经历,你大概能直接对号入座。

那是给一个测绘外业 App 做打点功能。现场测完回来,测量员把采集的坐标往地图上一投——点跟实际地物错开一大截,整片外业数据直接没法用。

我第一反应是定位不准。于是换手机、重启 GPS、跑到空旷处转圈重新采……折腾了一下午,点还是偏。那种感觉特别难受:你明明知道数据就在那儿,就是跟底图对不上,而你连问题出在哪一环都不知道。

最后查代码才发现,根本不是定位的问题,是坐标系不一致:GPS 给的是国际通用的 WGS-84,而我们的底图是"火星坐标系"GCJ-02,两个坐标系之间隔着一段人为加进去的非线性偏移

更坑的是,我当时的业务坐标不是经纬度,而是"北东高"的平面测量坐标 (N, E, H)。这意味着纠偏不是一步换算,而是一整条转换链——不搞懂这条链,就永远在"偏几百米"里打转。

先看那个直观但错误的写法:

// 直观但错误:直接把测量坐标丢进地图投影
fun drawPoint(map: MapView, north: Double, east: Double, height: Double) {
    val latLon = northEastToLatLon(north, east, height)   // 平面 -> 经纬度
    map.addMarker(GeoPoint(latLon.first, latLon.second))  // 直接画,结果偏了
}

结果:点的位置和底图上真实地物对不上,偏出几十到几百米。你反复确认定位没问题、坐标没错,就是不知道差在哪。

这篇文章要解决的,就是"差在哪"这三个字。


二、根因:两套坐标系,中间隔着一次"人为加密"

要理解纠偏,先要知道两个坐标系:

  • WGS-84:GPS 直接输出的坐标系,全球通用、公开。你手机里的 GPS 芯片、任何一款正规 GNSS 接收机,吐出来的都是它。
  • GCJ-02(俗称"火星坐标"):国内部分底图服务(含谷歌国内服务、天地图等)使用的加密坐标系。它在 WGS-84 基础上叠加了一个有意的、非线性的偏移,目的是坐标脱密。

为什么叫"火星坐标"?因为早期开发者发现,把 WGS-84 坐标直接画在国内底图上,点会整体飘移到"不知所谓的地方",戏称"跑到火星上去了"。这个外号本身就说明了问题的普遍性。

两个坐标系之间的换算,有三个容易被忽略的关键性质:

  1. 不是常数偏移。你没法用一个"加 0.005 度"之类的固定值搞定——偏移量随经纬度非线性变化,是多项式叠加三角函数的结果。
  2. 偏移幅度通常在几十米到几百米量级,且不同地区不同。这解释了为什么同一个 bug 在不同项目里"偏多少"不一样,也让"手动加个修正值"这种做法彻底失效。
  3. 境外不生效。经纬度落在国界之外时,纠偏函数直接原样返回。所以任何纠偏实现的第一件事,都是判断是否在中国境内

根因总结一句话:

GPS 给的是 WGS-84,你的底图要的是 GCJ-02,中间缺一次非线性换算;而你手上不是经纬度、而是平面测量坐标,于是这个换算还要嵌进"平面 ↔ 经纬度 ↔ 平面"的转换链里。


三、实现:正算、反算与完整偏移公式

3.1 正算:WGS-84 → GCJ-02

纠偏的核心是一组多项式加三角函数叠加的偏移计算。核心函数示意如下:

// 单点纠偏:WGS-84 -> GCJ-02
private const val PI = 3.14159265358979324
private const val A = 6378245.0                  // 长半轴(米)
private const val EE = 0.00669342162296594323    // 偏心率平方

fun correct(lat: Double, lon: Double): DoubleArray {
    if (outOfChina(lat, lon)) {
        return doubleArrayOf(lat, lon)   // 境外,原样返回
    }

    val dLat = latOffset(lon - 105.0, lat - 35.0)
    val dLon = lonOffset(lon - 105.0, lat - 35.0)
    val radLat = lat / 180.0 * PI
    val magic = 1 - EE * sin(radLat) * sin(radLat)
    val sqrtMagic = sqrt(magic)

    // 注意:纬度用 (A*(1-EE)) 分母,经度用 A 分母,两者不能互换
    val dLatFinal = dLat * 180.0 / ((A * (1 - EE)) / (magic * sqrtMagic) * PI)
    val dLonFinal = dLon * 180.0 / (A / sqrtMagic * cos(radLat) * PI)

    return doubleArrayOf(lat + dLatFinal, lon + dLonFinal)
}

fun outOfChina(lat: Double, lon: Double): Boolean {
    if (lon < 72.004 || lon > 137.8347) return true
    if (lat < 0.8293 || lat > 55.8271) return true
    return false
}

其中纬度、经度的偏移量各自是一串正弦项叠加:

// 纬度偏移量(示意,取关键几项)
fun latOffset(x: Double, y: Double): Double {
    var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(abs(x))
    ret += (20.0 * sin(6.0 * x * PI) + 20.0 * sin(2.0 * x * PI)) * 2.0 / 3.0
    ret += (20.0 * sin(y * PI) + 40.0 * sin(y / 3.0 * PI)) * 2.0 / 3.0
    ret += (160.0 * sin(y / 12.0 * PI) + 320.0 * sin(y * PI / 30.0)) * 2.0 / 3.0
    return ret
}

量纲一致性说明dLat / dLon 的最终换算,分母中的 magicsqrtMagic 与代码逐项对应;A 为长半轴 6378245.0 米,EE 为偏心率平方 0.00669342162296594323,量纲一致。

这里我要多说一句踩坑体验:第一次抄这套公式时,我把 dLatdLon 的最终换算系数抄反了——纬度用了经度的分母、经度用了纬度的分母,结果偏得更离谱,一度以为是算法本身有问题。后来逐项对着公式比对才发现是量纲错位。

所以别嫌公式长,抄的时候慢一点、逐项对齐,一次到位比调试一下午划算得多

3.2 反算:GCJ-02 → WGS-84

反向换算没有闭合解析式——你能正着算过去,但没法用一个公式精确算回来。工程上常用"先正算、再按差量反推"的近似法:

// 反纠偏:GCJ-02 -> WGS-84(近似还原)
fun uncorrect(lat: Double, lon: Double): DoubleArray {
    if (outOfChina(lat, lon)) {
        return doubleArrayOf(lat, lon)
    }
    val dLat = latOffset(lon - 105.0, lat - 35.0)
    val dLon = lonOffset(lon - 105.0, lat - 35.0)
    val radLat = lat / 180.0 * PI
    val magic = 1 - EE * sin(radLat) * sin(radLat)
    val sqrtMagic = sqrt(magic)

    val dLatFinal = dLat * 180.0 / ((A * (1 - EE)) / (magic * sqrtMagic) * PI)
    val dLonFinal = dLon * 180.0 / (A / sqrtMagic * cos(radLat) * PI)
    val mgLat = lat + dLatFinal
    val mgLon = lon + dLonFinal

    // 原值 * 2 - 加密值,近似还原
    return doubleArrayOf(lat * 2 - mgLat, lon * 2 - mgLon)
}

原值 × 2 − 加密值 这个技巧的本质是:假设偏移量在小范围内近似线性,那么"从 WGS 到 GCJ 加了 Δ",反过来就"从 GCJ 减掉 Δ"。用正算得到的 Δ 反推,误差在米级以内——对大多数地图展示场景够用了。


四、最反常识的一步:测量坐标下必须"转两次"

这是整篇最反常识的一点,也是我最开始栽跟头的地方。

如果你的业务坐标是 (N, E, H) 平面测量坐标,纠偏必须发生在"经纬度"这一层。所以完整链路是:

flowchart LR
    A[&#34;(N, E, H)<br/>平面测量坐标&#34;] -->|&#34;① 投影&#34;| B[&#34;(lat, lon)<br/>WGS-84 经纬度&#34;]
    B -->|&#34;② 纠偏&#34;| C[&#34;(lat', lon')<br/>GCJ-02 经纬度&#34;]
    C -->|&#34;③ 反投影&#34;| D[&#34;(N', E', H)<br/>纠正后平面坐标&#34;]
    style B fill:#fff4e6,stroke:#f59e0b,stroke-width:2px
    style C fill:#e6f7ff,stroke:#1890ff,stroke-width:2px

也就是说,为了在一个点上做一次纠偏,你要先投影成经纬度、纠偏、再投影回平面坐标——转两次。

// 测量坐标下纠偏:先投影到经纬度,纠偏,再投影回平面
fun correctNorthEast(north: Double, east: Double, height: Double): DoubleArray {
    val latLon = northEastToLatLon(north, east, height)  // 平面 -> 经纬度
    val corrected = correct(latLon.first, latLon.second) // 经纬度层纠偏
    return latLonToNorthEast(corrected.first, corrected.second, height)
}

为什么不能"直接纠偏"?

因为坐标是有量纲的

  • 经纬度是角度(度),dLat / dLon 算出来的是"度"的偏移;
  • 平面测量坐标是长度(米),(N, E, H) 是"米"的偏移。

把角度偏移量直接加到长度上,量纲就不对——越纠越偏

我当时图省事,把纠偏直接套在了平面坐标上,想着"反正都是坐标,偏一下不就行了",结果外业数据全废。这是我在这条链上栽的第二个跟头,也是最贵的一个:一步顺序错,整片数据报废,还得到现场重采。

常见错误对照表

错误做法后果正确做法
把纠偏直接套在平面坐标上量纲错位,越纠越偏投影到经纬度层再纠偏
用固定常数补偿偏移换个地区就失效走完整非线性公式
连续两次正向纠偏偏移累加,越叠越大存纠偏、读反纠偏,成对使用
在每处打点逻辑里手写判断换底图要改七八处,漏一处偏一处收敛到坐标转换单一入口
忽略境外判断海外项目坐标被误纠函数入口先判断境内外

五、生产级:纠偏开关到底该放哪

不是所有底图都需要纠偏:

  • 火星坐标系底图(谷歌国内、天地图等)→ 需要纠偏
  • 标准 WGS-84 底图(公共 OSM 等)→ 不需要纠偏

所以正确做法是:根据当前底图类型,决定是否走纠偏链

// 根据底图类型决定是否纠偏
fun maybeCorrect(north: Double, east: Double, height: Double): DoubleArray {
    return if (isGcjBaseMap) {          // 火星坐标系底图
        correctNorthEast(north, east, height)
    } else {
        doubleArrayOf(north, east, height)
    }
}

关键是这个开关放在哪。放在"坐标投影到屏幕"的必经入口处,就能让所有绘制点自动跟随底图类型,而不是在每一处业务代码里手动判断:

flowchart TD
    A[&#34;业务层打点<br/>(各处业务代码)&#34;] --> B[&#34;坐标转换门面<br/>CoordinateFacade&#34;]
    B --> C{&#34;读底图配置<br/>isGcjBaseMap ?&#34;}
    C -->|&#34;火星底图&#34;| D[&#34;走纠偏链<br/>转两次&#34;]
    C -->|&#34;WGS-84 底图&#34;| E[&#34;原样返回&#34;]
    D --> F[&#34;屏幕绘制&#34;]
    E --> F
    style B fill:#f6ffed,stroke:#52c41a,stroke-width:2px
    style C fill:#fff7e6,stroke:#fa8c16,stroke-width:2px

这也是我第二次返工的直接原因——第一次把"是否纠偏"的判断散落在每个打点的地方,换个底图就要改七八处,漏一处就偏一处。改完这个架构之后,换底图变成了改一个配置项的事。


六、如果今天重写:坐标变换应该"就近拦截"

如果今天重写,我不会在每处打点逻辑里手写纠偏,而是:

6.1 把纠偏收敛到坐标转换的单一入口

所有"测量坐标 → 屏幕 / 经纬度"的转换都走同一个门面,门面内部按底图类型自动决定是否纠偏。谁也不用记得"这里要不要纠偏",门面替你记。

6.2 把"是否火星底图"做成可动态切换的配置

用户切换底图时,开关自动更新,后续所有打点立即正确,无需重启。这个看起来小,但真实项目里"换底图后数据全偏、又得重启"是我见过最多的一类线上事故。

6.3 把正 / 反纠偏封装为对偶方法

存进去时纠偏,读出来时反纠偏,保证"存了什么就能读回什么",避免反复累加偏移。特别是连续两次正向纠偏,偏移会越叠越大,这个坑我踩过不止一次。

6.4 给转换链加"方向"语义

如果重写,我会让转换器显式区分"原始 WGS-84 数据"和"已加密 GCJ-02 数据",在数据进出门面时打标签,而不是用隐式的 isGcjBaseMap 一个布尔值——

因为底图是 GCJ 不代表你手头的测量数据就是 GCJ,两者混用会静默出错。布尔值掩盖了这个区别,而"静默出错"是坐标类问题里最难查的一种。

这套"就近拦截、单一入口"的思路,不止适用于坐标纠偏——任何"底层数据格式和展示层不一致"的场景(单位换算、投影切换、坐标加密),都该这么做。


七、结论

  • GPS 输出 WGS-84,火星底图要 GCJ-02,中间差一次非线性、分境内外的换算,偏移通常几十到几百米且随地区变化。
  • 纠偏的偏移量是多项式 + 三角函数叠加,不是简单加减常数;抄公式时逐项对齐,避免量纲错位。
  • 反纠偏没有闭合解析式,工程上常用"原值 × 2 − 加密值"近似还原,误差米级。
  • 测量坐标下纠偏,必须先投影到经纬度、纠偏、再投影回平面,转两次,顺序不能错,也不能把角度偏移当长度偏移。
  • 是否纠偏取决于底图类型,应把开关放在坐标转换的单一入口,而非各处打补丁。
  • 正 / 反纠偏要成对封装,避免偏移累加。

完整可运行源码GitCode 仓库 · android_osmdroid_maplibre

评论区聊聊:你们项目里的 WGS-84 数据,最后落到的是哪个坐标系?有没有因为"转了两次"或者"少转一次"翻过车?说说你的链路。


本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。