MapLibre 实战 08|GPS 点漂了几百米才被发现:GCJ-02 纠偏原理与"转两次"陷阱

6 阅读10分钟

摘要:野外用 GPS 实测的采样点,回到室内画到天地图上,点位漂到几百米外的马路上——不是设备坏了,是底图用的根本不是你手里的坐标系。这篇讲清 GCJ-02 的非线性加扰数学、平面测量坐标为什么必须"转两次",以及 MapLibre 下为什么纠偏该收敛成一个无状态纯函数。附公式 ↔ 代码核对方法和坑对照表。

目录(TOC)


一、现场:点位漂到几百米外的马路上

想象一下这个现场:你在野外用 GPS 实测了一个采样点的经纬度,回到室内把它画到天地图上,结果点位漂到几百米外的马路上去了

不是设备坏了,也不是代码写错——因为天地图用的根本不是你手里的坐标系

在中文互联网地图生态里,几乎所有公开在线底图(天地图、高德、腾讯等)使用的都不是真实的 GPS 坐标(WGS-84),而是一种名为 GCJ-02(俗称"火星坐标")的加密偏移坐标系。

这带来一个经典痛点:

用 GPS 实测的真实经纬度,直接画在 GCJ-02 底图上,点位会整体偏移几百米。

反之,把 GCJ-02 坐标当作 WGS-84 拿去测量、叠加矢量数据,同样会错位。

而 OSM 这类国际底图是纯 WGS-84,不需要纠偏。于是工程里必须回答两个问题:

  1. 如何在一套代码里兼容"要不要纠偏"两种底图?
  2. 原始数据多是平面测量坐标(北向 N / 东向 E),又该怎么落到地图上?

本文与 osmdroid 篇(01 坐标纠偏)同源不同实现。 纠偏算法本身是引擎无关的,差异在于"这个出口该放在哪"。


二、GCJ-02 偏移的核心数学

GCJ-02 的算法是公开的(Krasovsky 1940 椭球参数 + 非线性加扰)。其本质是:

给定一个 WGS-84 经纬度,计算出一个非线性偏移量,叠加后得到 GCJ-02 坐标。

反向(GCJ-02 → WGS-84)没有解析公式,只能迭代逼近。

2.1 关键常量

a = 6378137.0                 // 椭球长半轴
ee = 0.00669342162296594323   // 第一偏心率平方
PI = 3.14159265358979323846

2.2 单点偏移计算(WGS-84 → GCJ-02)

先判断是否在中国境外(outOfChina),境外直接原样返回、不做偏移:

if (outOfChina(lat, lon)) return doubleArrayOf(lat, lon)

偏移量由下面两式给出(注意 dLat 同时受纬度与经度影响,dLon 同理):

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)

没有闭式解,采用"先正向算偏移,再反向扣减"的迭代:

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 底图上,链路是三步走:

flowchart LR
    A[&#34;测量坐标<br/>(N, E) 米&#34;] -->|&#34;① 平面→经纬度&#34;| B[&#34;WGS-84<br/>(lat, lon)&#34;]
    B -->|&#34;② 纠偏&#34;| C[&#34;GCJ-02<br/>(gcjLat, gcjLon)&#34;]
    C -->|&#34;③ 交给 Source&#34;| D[&#34;MapLibre<br/>GeoJSON Source&#34;]

    style B fill:#fff7e6,stroke:#fa8c16,stroke-width:2px
    style C fill:#e6f7ff,stroke:#1890ff,stroke-width:2px

这就是"转两次"的含义:正向转一次(平面 → 经纬度),纠偏再转一次(经纬度 → 火星)。

关键实现是这两个互逆函数:

// 平面测量坐标 → 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 变体,量纲一致(两种写法都近似正确,代码版精度略高);反算 northEastToLatLonlatLonToNorthEast 互为逆运算,代码完全对应。无偏差。

"转两次"的三个坑

  • 坑 1:忘了第二步纠偏。平面 → 经纬度后直接画,点位相对火星底图偏移数百米。
  • 坑 2:底图是 WGS-84(如 OSM)却多纠偏一次。此时应 outOfChina 之外保持原坐标,或显式"不纠偏"。
  • 坑 3:把纠偏后的点再反算平面坐标。纠偏是非线性的,反算走的应是 GCJ-02 反函数,而非简单减偏移。

坑 1 和坑 2 是对称的,而且症状完全一样("点偏了")——所以排查时第一件事不是改代码,而是先确认"当前底图到底是不是火星坐标系"。

DEMO 用一个按钮专门演示"转两次":输入一组 (N, E),先 northEastToLatLon 得到经纬度,再 correct 得到火星坐标,Toast 同时打印纠偏前 / 后经纬度,肉眼可验证偏移量。


四、按底图类型决定是否纠偏(统一出口)

把"要不要纠偏"封装成一个开关,避免业务代码到处 if

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 底图,原样使用
    }
}
flowchart TD
    RAW[&#34;原始坐标<br/>(GPS / 测量坐标)&#34;] --> F[&#34;toMapLatLng()<br/>无状态纯函数&#34;]
    F --> C{&#34;baseMapIsGcj ?&#34;}
    C -->|&#34;火星底图&#34;| CORRECT[&#34;correct()<br/>WGS-84 → GCJ-02&#34;]
    C -->|&#34;WGS-84 底图&#34;| PASS[&#34;原样返回&#34;]
    CORRECT --> SRC[&#34;GeoJSON Source&#34;]
    PASS --> SRC
    SRC --> LAYER[&#34;Layer 渲染&#34;]

    style F fill:#f6ffed,stroke:#52c41a,stroke-width:2px
    style C fill:#fff7e6,stroke:#fa8c16,stroke-width:2px

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点位整体偏移几百米忘了第二步纠偏平面 → 经纬度 → 纠偏,转两次
2OSM 底图上点也偏了不该纠偏却纠了按底图类型开关,WGS-84 底图原样返回
3反算回来的坐标不对用减法代替 GCJ-02 反函数走迭代反解,非线性不能简单相减
4偏移量大到离谱(几公里)dLat/dLon 分母抄反,量纲错位逐项核对量纲,见第二节核对说明
5境外项目坐标被误纠没做 outOfChina 短路函数入口先判境内外
6纠偏后数据存回再读,越存越偏存纠偏、读又纠偏,偏移累加存 / 读成对:存正纠、读反纠
7换底图后所有点全偏纠偏散落在各处业务代码收敛到 toMapLatLng() 单一出口

七、小结与落地清单

  • GCJ-02 偏移是非线性加扰,正向可算、反向需迭代
  • 平面测量坐标落图 = 平面 → 经纬度 + 经纬度 → 火星,必须转两次
  • 用"底图是否 GCJ"的布尔开关统一纠偏出口,业务侧零感知
  • outOfChina 判断放最前,境外直接短路返回。
  • 抄公式要逐项核对量纲,分母抄反是最高频的错误。

落地建议

  1. 把纠偏工具做成无状态纯函数单例(DEMO 即如此)——无状态才好用、好测、好替换。
  2. 补一条单元测试:取已知城市中心点,验证纠偏前后偏移落在 300–700 米区间。这条测试的价值不在"验证算法对"(算法是公开的),而在防止算法被后人误改——常量被改、分母被抄反、正负号写反,测试会立刻报警。

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

对照阅读:本系列第 01 篇(osmdroid 坐标纠偏:WGS-84 落到 GCJ-02 底图,为什么要转两次)——同一套算法,一个挂在 Canvas 投影入口、一个收敛成纯函数,对照看能理解"引擎架构如何决定代码的收敛位置"。

延伸阅读:本系列第 09 篇(MapLibre 瓦片源工厂:多域名轮询 + 压级别)

评论区聊聊:你在实际项目里被"火星坐标"坑过吗?是"忘了纠"还是"多纠了一次"?偏移了多少米才发现的?


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