先说结论 很多人以为「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 |
三个关键点:
- GCJ-02 不是测绘误差,是主动加密。算法本身是保密的,目前公开的实现都是社区逆向工程得出的近似算法,精度足够工程使用,但不是官方版本。
- BD-09 是二次加密,不是「GCJ-02 加一个固定偏移」。它用的是极坐标变换,所以偏移方向和距离随位置变化。
- 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 位小数的十进制,别混着存度分秒字符串------后面做距离计算会很痛苦。
七、怎么快速验证转换结果对不对
改完代码总得验一下,不用每次都写脚本。我平时用这个在线工具贴坐标直接看三种坐标系的结果,还能在地图上对照位置:
它的做法是同时输出 WGS-84 / GCJ-02 / BD-09 三套坐标,然后在地图上叠加显示,偏移多少一眼能看出来。批量数据(比如 Excel 里几百条)用这个:
支持粘贴或上传,结果导出 CSV。
提醒:验证时一定要拿「已知正确」的坐标做基准,比如从高德地图直接复制一个 POI 的坐标,反向转换看能不能回到原值。只对比两个数字大小是看不出对错的。
八、小结
- 偏移量不是固定 500 米 ,国内实测 265
625 米,转 BD-09 累计 8521378 米 - BD-09 是二次加密,不是简单加偏移,累计误差超过 1 公里
- 境外坐标转 BD-09 会凭空偏 900 米,这是多数开源实现的隐藏 bug,跨境业务必查
- 转换有损,别来回转,数据库存原始 WGS-84,展示层再转
- 港澳台和度分秒精度是两个容易被忽略的边界