WGS-84 / GCJ-02 / BD-09 到底差多少?实测 12 个城市偏移量,附完整转换代码

先说结论 很多人以为「GCJ-02 相对 WGS-84 偏移 500 米左右」,这个说法不准确。我把国内 12 个城市用统一算法跑了一遍,实际是这样: | 转换路径 | 实测偏移范围 | |---|---| | WGS-84 → GCJ-02 | 265 ~ 625 米 | | GCJ-02 → BD-09 | 808 ~ 956 米 | | WGS-84 → BD-09(累计) | 852 ~ 1378 米 | 北京天安门累计偏移 1378 米 ,已经超过 1 公里。所以「偏 500 米」只描述了第一步,把 GPS 坐标直接丢进百度地图,误差是两段叠加的。 --- ## 一、一个真实场景 做过定位相关需求的人大概都遇到过这个:设备上报的 GPS 坐标,直接标在高德或百度地图上,位置是错的。不是差一点,是差了几百米到一公里,肉眼能明显看出在马路对面甚至另一个街区。 我第一次遇到时怀疑是设备精度问题,换了几台设备、等了足够长的定位时间,误差依旧稳定存在。后来才明白:这不是精度问题,是坐标系问题。GPS 给的是 WGS-84,国内地图用的

很多人以为「GCJ-02 相对 WGS-84 偏移 500 米左右」,这个说法不准确。我把国内 12 个城市用统一算法跑了一遍,实际是这样:

转换路径 实测偏移范围
WGS-84 → GCJ-02 265 ~ 625 米
GCJ-02 → BD-09 808 ~ 956 米
WGS-84 → BD-09(累计) 852 ~ 1378 米

北京天安门累计偏移 1378 米,已经超过 1 公里。所以「偏 500 米」只描述了第一步,把 GPS 坐标直接丢进百度地图,误差是两段叠加的。


一、一个真实场景

做过定位相关需求的人大概都遇到过这个:设备上报的 GPS 坐标,直接标在高德或百度地图上,位置是错的。不是差一点,是差了几百米到一公里,肉眼能明显看出在马路对面甚至另一个街区。

我第一次遇到时怀疑是设备精度问题,换了几台设备、等了足够长的定位时间,误差依旧稳定存在。后来才明白:这不是精度问题,是坐标系问题。GPS 给的是 WGS-84,国内地图用的是加密后的坐标系,两者之间有一个确定性的、可计算的偏移。


二、三种坐标系到底是什么

坐标系 全称 谁在用 EPSG
WGS-84 World Geodetic System 1984 GPS 原始输出、国际标准、Google 海外、OSM EPSG:4326
GCJ-02 国测局 2002 年制定的加密坐标(俗称「火星坐标」) 高德、腾讯、Google 中国 无公开 EPSG
BD-09 百度在 GCJ-02 基础上二次加密 百度地图全系 无公开 EPSG

三个关键点:

  1. GCJ-02 不是测绘误差,是主动加密。算法本身是保密的,目前公开的实现都是社区逆向工程得出的近似算法,精度足够工程使用,但不是官方版本。
  2. BD-09 是二次加密,不是「GCJ-02 加一个固定偏移」。它用的是极坐标变换,所以偏移方向和距离随位置变化。
  3. CGCS2000(国家大地坐标系 2000)常被和 WGS-84 混为一谈。两者差异在厘米级,绝大多数业务可以忽略,不用单独处理。

三、实测偏移量

用第四节给出的代码,对 12 个城市各取一个地标点计算:

城市 WGS-84 经纬度 GCJ-02 经纬度 偏移 BD-09 经纬度 累计偏移
北京 天安门 116.397428, 39.909230 116.403672, 39.910634 555 m 116.410044, 39.916973 1378 m
上海 人民广场 121.473701, 31.230416 121.478224, 31.228474 481 m 121.484782, 31.234327 1140 m
广州 珠江新城 113.324520, 23.119490 113.329942, 23.116894 625 m 113.336487, 23.122597 1272 m
成都 天府广场 104.066541, 30.572269 104.069046, 30.569815 363 m 104.075522, 30.575825 946 m
乌鲁木齐 87.616848, 43.825592 87.619698, 43.826797 265 m 87.626148, 43.832941 1107 m
三亚 109.511909, 18.252847 109.515993, 18.251142 471 m 109.522541, 18.256924 1211 m
哈尔滨 126.534967, 45.803775 126.541007, 45.805757 518 m 126.547626, 45.811371 1295 m
拉萨 91.140856, 29.645554 91.142385, 29.642818 338 m 91.148976, 29.648548 852 m
台北 101 121.564472, 25.033639 121.568247, 25.030706 501 m 121.574668, 25.037036 1094 m
香港 中环 114.158600, 22.281900 114.163594, 22.279180 596 m 114.170121, 22.284953 1233 m
纽约 -74.005974, 40.712776 同 WGS-84(不加偏) 0 m -73.999466, 40.718832 869 m ⚠️
东京 139.691711, 35.689487 同 WGS-84(不加偏) 0 m 139.698134, 35.695832 913 m ⚠️

说明:以上是算法计算出的理论偏移量,不是 GPS 实地打点测量。但 GCJ-02 加密算法是确定性的,同一坐标每次计算结果一致,所以这组数据可以直接作为工程参考。


四、三个反直觉的发现

发现 1:偏移量不是固定值,最小 265 米、最大 625 米

同一个算法,乌鲁木齐只偏 265 米,广州偏 625 米,差了一倍多。

这意味着不能用一个固定的「加偏量」去糊弄 。有些老代码里写 lng + 0.006 之类的魔法数字,在广州可能碰巧接近,在乌鲁木齐就偏出 300 多米。必须用完整算法。

发现 2:BD-09 的累计偏移超过 1 公里

因为 BD-09 是在 GCJ-02 上二次加密,两段偏移叠加。北京 555 m + 890 m ≈ 1378 m(不是简单相加,方向不同,但量级如此)。

实务影响:如果你的业务同时对接 GPS 设备和百度地图,且没有做坐标转换,误差是按公里算的,不是按米算的。外卖、网约车这类场景,这个误差足够把订单派到错误的商圈。

发现 3:境外坐标转 BD-09 也会凭空偏 900 米(多数开源实现的隐藏 bug)

看表格最后两行:纽约和东京的 GCJ-02 偏移是 0 (因为算法里有 out_of_china 判断,境外不加偏),但转成 BD-09 后偏移了 869 / 913 米。

原因是:大部分开源实现的 gcj02ToBd09() 没有做境外判断。境外坐标走「WGS-84 → GCJ-02(不变)→ BD-09(加偏)」这条链,等于给一个本来不需要加偏的坐标硬加了一次偏。

正确做法:

javascript 复制代码
// 错误:境外坐标也会被加偏
const bd = gcj02ToBd09(lng, lat);

// 正确:境外直接用 WGS-84,不做任何转换
const bd = outOfChina(lng, lat) ? [lng, lat] : gcj02ToBd09(lng, lat);

这个 bug 在跨境业务里很隐蔽------国内的测试用例全部通过,一上境外数据就出问题,而且偏移只有几百米,肉眼在地图上不放大看不出。


五、完整转换代码(可直接运行)

Node.js 环境,无第三方依赖,复制即可跑。包含偏移量自测。

javascript 复制代码
const PI = Math.PI;
const A = 6378245.0;                    // 克拉索夫斯基椭球长半轴
const EE = 0.00669342162296594323;      // 第一偏心率平方
const X_PI = (PI * 3000.0) / 180.0;

// 粗略判断是否在中国境外(社区惯例的矩形范围,非官方)
function outOfChina(lng, lat) {
  return !(lng > 73.66 && lng < 135.05 && lat > 3.86 && lat < 53.55);
}

function transformLat(lng, lat) {
  let ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat +
    0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng));
  ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(lat * PI) + 40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0;
  ret += (160.0 * Math.sin(lat / 12.0 * PI) + 320 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0;
  return ret;
}

function transformLng(lng, lat) {
  let ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng +
    0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng));
  ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(lng * PI) + 40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0;
  ret += (150.0 * Math.sin(lng / 12.0 * PI) + 300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0;
  return ret;
}

/** WGS-84 → GCJ-02 */
function wgs84ToGcj02(lng, lat) {
  if (outOfChina(lng, lat)) return [lng, lat];
  let dLat = transformLat(lng - 105.0, lat - 35.0);
  let dLng = transformLng(lng - 105.0, lat - 35.0);
  const radLat = (lat / 180.0) * PI;
  let magic = Math.sin(radLat);
  magic = 1 - EE * magic * magic;
  const sqrtMagic = Math.sqrt(magic);
  dLat = (dLat * 180.0) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI);
  dLng = (dLng * 180.0) / ((A / sqrtMagic) * Math.cos(radLat) * PI);
  return [lng + dLng, lat + dLat];
}

/** GCJ-02 → WGS-84(迭代逼近,5 次已收敛到亚毫米) */
function gcj02ToWgs84(lng, lat) {
  if (outOfChina(lng, lat)) return [lng, lat];
  let mLng = lng, mLat = lat;
  for (let i = 0; i < 5; i++) {
    const [gLng, gLat] = wgs84ToGcj02(mLng, mLat);
    mLng -= gLng - lng;
    mLat -= gLat - lat;
  }
  return [mLng, mLat];
}

/** GCJ-02 → BD-09(注意:调用方需自己判断境外) */
function gcj02ToBd09(lng, lat) {
  const z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * X_PI);
  const theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * X_PI);
  return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006];
}

/** BD-09 → GCJ-02 */
function bd09ToGcj02(lng, lat) {
  const x = lng - 0.0065, y = lat - 0.006;
  const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI);
  const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI);
  return [z * Math.cos(theta), z * Math.sin(theta)];
}

/** 两点球面距离(米),用于自测偏移量 */
function haversine(lng1, lat1, lng2, lat2) {
  const R = 6371008.8;
  const rad = (x) => (x * PI) / 180;
  const dLat = rad(lat2 - lat1);
  const dLng = rad(lng2 - lng1);
  const a = Math.sin(dLat / 2) ** 2 +
    Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(a));
}

// ===== 自测:验证偏移量 =====
const [gl, gt] = wgs84ToGcj02(116.397428, 39.90923);
const [bl, bt] = gcj02ToBd09(gl, gt);
console.log('GCJ-02:', gl.toFixed(6), gt.toFixed(6),
  '偏移', haversine(116.397428, 39.90923, gl, gt).toFixed(0), '米');
console.log('BD-09 :', bl.toFixed(6), bt.toFixed(6),
  '累计', haversine(116.397428, 39.90923, bl, bt).toFixed(0), '米');

// ===== 自测:往返误差 =====
const [wl, wt] = gcj02ToWgs84(gl, gt);
console.log('往返误差:', haversine(116.397428, 39.90923, wl, wt).toFixed(4), '米');

跑出来应该是:

yaml 复制代码
GCJ-02: 116.403672 39.910634 偏移 555 米
BD-09 : 116.410044 39.916973 累计 1378 米
往返误差: 0.0000 米

其他语言的实现(Java / Python / Go)逻辑完全一致,只是语法差异,核心是那两个多项式函数不能改。


六、五个必踩的坑

坑 1:把 GCJ-02 当 WGS-84 用,位置偏 500 米

最常见的错误。从高德地图复制出来的坐标是 GCJ-02,直接存进数据库当 GPS 坐标用,再喂给设备就偏了。

判断方法:拿一个已知地标(比如天安门),分别在 GPS 和高德上取坐标,如果差几百米,就说明是两套坐标系。

坑 2:转换是有损的,来回转会漂

WGS-84 → GCJ-02 → WGS-84 理论上应该回到原点,但实际因为反解是迭代逼近,会有微小残差。

我实测迭代 5 次后残差是 0.0000 米 (收敛了),但要注意:这个「0」是算法自洽的结果,不代表还原出的 WGS-84 就是当初的真实值。GCJ-02 加密本身丢掉了部分信息,反解只能得到「最接近的一个 WGS-84 值」,工程上够用(误差 1~2 米内),但不要拿它做高精度测绘。

结论:转换要一次到位,不要转来转去。数据库里最好存一份原始 WGS-84,转换只在展示层做。

坑 3:境外坐标也套了 GCJ-02

GCJ-02 只对中国境内加密。如果把东京的坐标也跑一遍 wgs84ToGcj02,虽然 outOfChina 会拦下来,但如果你自己实现的版本没这个判断,就会给境外坐标硬加一次偏。

自查 :拿一个境外坐标跑一遍,输出应该和输入完全相同。如果不一样,说明 outOfChina 判断有问题。

坑 4:out_of_china 的矩形判断对港澳台有争议

社区通用的矩形范围是 lng 73.66~135.05, lat 3.86~53.55,这个矩形包含了台湾和香港,所以按这个判断,港澳台会被加偏。

各家的实际行为不完全一致,如果你的业务涉及港澳台,不要依赖算法判断,按业务需求显式指定坐标系。

坑 5:度分秒和十进制互转时的精度丢失

ini 复制代码
116°23'50.74"  →  116 + 23/60 + 50.74/3600 = 116.397428

反算时如果只保留 4 位小数(116.3974),精度约 11 米 ;保留 6 位小数约 0.1 米。

存储坐标时建议统一用 6 位小数的十进制,别混着存度分秒字符串------后面做距离计算会很痛苦。


七、怎么快速验证转换结果对不对

改完代码总得验一下,不用每次都写脚本。我平时用这个在线工具贴坐标直接看三种坐标系的结果,还能在地图上对照位置:

gmap.net/marker/geo-...

它的做法是同时输出 WGS-84 / GCJ-02 / BD-09 三套坐标,然后在地图上叠加显示,偏移多少一眼能看出来。批量数据(比如 Excel 里几百条)用这个:

gmap.net/marker/geo-...

支持粘贴或上传,结果导出 CSV。

提醒:验证时一定要拿「已知正确」的坐标做基准,比如从高德地图直接复制一个 POI 的坐标,反向转换看能不能回到原值。只对比两个数字大小是看不出对错的。


八、小结

  1. 偏移量不是固定 500 米 ,国内实测 265625 米,转 BD-09 累计 8521378 米
  2. BD-09 是二次加密,不是简单加偏移,累计误差超过 1 公里
  3. 境外坐标转 BD-09 会凭空偏 900 米,这是多数开源实现的隐藏 bug,跨境业务必查
  4. 转换有损,别来回转,数据库存原始 WGS-84,展示层再转
  5. 港澳台和度分秒精度是两个容易被忽略的边界
相关推荐
总要学点什么1 天前
第79篇:数据可视化-模拟迁徙效果——把三十条航线画成会飞的弧
3d·信息可视化·数据挖掘·数据分析·gis·webgl·cesium
vjmap2 天前
WebGIS + AI 智能体:唯杰地图智能体平台一句话处理 CAD 图纸,支持 MCP 接入 AI 编辑器
gis·agent·mcp
web3d5204 天前
03-从一个点到一张图:Leaflet 加载水利对象 GeoJSON 实战
前端框架·gis
梅如你6 天前
中国2026年路网数据
arcgis·gis·遥感
我才是银古6 天前
GeoLibre 技术架构与实现解析
3d·ai·gis
我才是银古8 天前
OpenGIS 技能插件开发与使用:在 DeepSeek Harness 里按需加载 GIS 知识
ai·gis·dsh
总要学点什么8 天前
第65篇:地图卷帘控件——点「关闭卷帘」,消失的却是右边那半张地图
信息可视化·gis·webgl·cesium·空间分析
总要学点什么9 天前
第63篇:3DTiles-大雁塔模型——190 块瓦片不是八叉树,抬高的却是球心那条法线
3d·数据挖掘·数据分析·gis·webgl
梅如你10 天前
全国逐年 1KM 分辨率 NDVI 植被指数数据集(2000-2024)
arcgis·gis·遥感