WGS84 坐标转 GCJ-02 火星坐标系
GPS 模块输出 WGS84 原始坐标,但国内电子地图前端(高德/腾讯/百度 Leaflet 瓦片)要求 GCJ-02 坐标系。如果不做转换,地图上的点会有 100m–700m 的偏移。
问题
hearth 从串口拿到 $GNGGA 解析出 33.7503°N, 113.2025°E,在前端 Leaflet 地图上标记,发现距离实际位置偏移了约 500 米。
这不是解析错误,而是坐标体系不同:
- WGS84:GPS 模块直接输出的世界大地坐标系,是全球标准
- GCJ-02:国家测绘局加密后的坐标系,也称"火星坐标系",国内所有在线地图必须使用
方案
不走前端库(前端库会拖慢加载),在 Rust 服务端解析 NMEA 时直接转换。parse_nmea() 返回的 GpsPoint 自动携带 GCJ-02 坐标,下游 API/SSE/DB 无感知。
WGS84 → GCJ-02 算法
基于众所周知的 GCJ-02 加偏算法(非线性偏移,并非简单乘以固定系数):
const A: f64 = 6378245.0; // 长半轴
const EE: f64 = 0.00669342162296594323; // 偏心率平方
pub fn wgs84_to_gcj02(lat: f64, lon: f64) -> (f64, f64) {
if is_out_of_china(lat, lon) {
return (lat, lon); // 国外不转换
}
// 以 (105°E, 35°N) 为基准计算偏移量
let dlat = transform_lat(lon - 105.0, lat - 35.0);
let dlon = transform_lon(lon - 105.0, lat - 35.0);
// 将角度偏移转换为实际经纬度偏移
let radlat = lat.to_radians();
let magic = 1.0 - EE * radlat.sin().powi(2);
let sqrt_magic = magic.sqrt();
let dlat_m = (dlat * 180.0) / ((A * (1.0 - EE)) / (magic * sqrt_magic) * PI);
let dlon_m = (dlon * 180.0) / (A / sqrt_magic * radlat.cos() * PI);
(lat + dlat_m, lon + dlon_m)
}transform_lat / transform_lon 是两个高阶多项式 + 三角函数混合的偏置函数,具体系数是公开的。
接入点
pub fn parse_nmea(line: &str) -> Option<GpsPoint> {
parse_gprmc(line).or_else(|| parse_gga(line)).map(|mut p| {
let (gcj_lat, gcj_lon) = crate::coord::wgs84_to_gcj02(p.lat, p.lon);
p.lat = gcj_lat;
p.lon = gcj_lon;
p
})
}仅 4 行改动,所有下游(API、SSE、DB、trail)自动覆盖。
效果
原始 WGS84 坐标 33.7507, 113.2020 → 转换后 33.7492, 113.2083,偏移约 163m 北 / 577m 东,在前端地图上位置正确。
修 Bug:sin() 参数漏了 π 因子
初版代码的 transform_lat / transform_lon 中所有三角函数参数漏了 * PI:
// 错误(初版)
ret += (20.0 * (6.0 * lon).sin() + 20.0 * (2.0 * lon).sin()) * 2.0 / 3.0;
// 正确(标准 eviltransform 算法)
ret += (20.0 * (6.0 * lon * PI).sin() + 20.0 * (2.0 * lon * PI).sin()) * 2.0 / 3.0;对比参考实现(Python eviltransform、JS coordtransform、C# EvilTransform),所有版本的 sin() 参数都带 * PI。标准算法变量虽以度为单位,但三角函数内通过 * π 做了隐式坐标变换——这是经验拟合项,丢失 π 会导致偏移量计算错误。
另外末项 lat * 30.0 应为 lat * PI / 30.0(乘法 vs 除法的 900 倍因子差异)。
修复后与 Python eviltransform 交叉验证,坐标精确一致(< 0.000001° 误差)。
