我需要一个数字:车辆的转弯半径。指标里写着它,验收时要交它, 而手上只有一条 GPS 轨迹——一堆经纬度点。

思路很直接:如果车在绕一个稳定的圆走,那这些点就应该落在同一个圆上。 三点定圆,十二点拟合一个误差最小的圆,圆心到点的距离就是半径。

代码跑通了,结果也出来了。但看着那组数字,我停了下来—— 它好得不像是真的

01第一步:把经纬度变成米

拟合圆是平面几何,而经纬度是球面上的角度。所以先要做一个局部投影: 选第一个点当原点,把后面每个点的经纬度差换算成米。

convert_to_relative_coordinates
lat0, lon0 = points_lonlat[0]
    for lat, lon in points_lonlat:
        y = (lat - lat0) * 111320.0
        x = (lon - lon0) * 111320.0 * np.cos(np.radians(lat0))
        rel.append((x, y))

两个常数值得解释一下,因为它们是这类换算里最容易写错的地方:

  • 111320纬度方向上 1 度对应的米数(地球子午线的平均曲率)。 这个值随纬度变化很小,工程上当成常数没问题。
  • 经度方向要再乘一个 cos(纬度)。因为经线在两极收敛,同样 1 度经度, 在赤道上是 111 公里,在北纬 40 度只剩 85 公里。 忘了这个余弦,是你算出来的形状被横向拉长——圆会变成椭圆。

这里还有一个必须做的防御:如果所有点都是同一个坐标, 后面拟合会得到一个无意义的圆心,甚至除以零。所以加一句全等判断, 直接返回失败而不是硬算出一个结果。

02第二步:拟合一个圆

两点连线的中垂线能定圆心,三点就能确定唯一的圆。但真实数据点不共圆, 所以要用最小二乘——找那个"让所有点到圆周距离平方和最小"的圆心和半径。

我用的是 circle_fit 里的 hyper_fit, 它的求解过程对噪声和外点比简单的代数法稳一些:

fit_circle
def fit_circle(points_xy):
        try:
            xc, yc, r, _ = hyper_fit(points_xy)
            if np.isnan(xc) or np.isnan(yc) or np.isnan(r):
                logging.error(f"拟合圆结果异常: xc={xc}, yc={yc}, r={r}")
                return None, None, None
            return float(xc), float(yc), float(r)
        except Exception as e:
            logging.error(f"拟合圆失败: {e}")
            return None, None, None

结果对不对,不能只看 r 这一个数。所以我同时算了另外两个值: 所有点到圆心的最大距离最小距离。 理想情况下它们都应该等于 r;它们和 r 的差距, 就是这组点"到底圆不圆"的度量。

03结果

十二个点,输出如下:

result.json
{
      "xc": -76.96346957340506,
      "yc": -20.62260572804938,
      "r": 79.67831662915245,
      "R_outer": 79.678911761091,
      "R_inner": 79.67796555660075,
      "n_points": 12,
      "ok": true
    }

半径 r = 79.678316 米。翻译成直径,大约 159.4 米。

然后我把三个数字放在一起看,发现了问题:

与 r 的偏差
拟合半径 r79.678316 m
最远点 R_outer79.678912 m+0.6 毫米
最近点 R_inner79.677966 m−0.35 毫米

十二个点,最大偏差 0.6 毫米。

04好得可疑

如果这是一个机械加工件的检测报告,0.6 毫米的圆度误差说明工艺很好。 但这是一条车辆 GPS 轨迹

民用 GPS 不开差分的时候,水平定位误差在米级; 就算用上 RTK 差分,稳定状态也在厘米级。 把坐标换算成米、再拟合,残差应该至少是厘米量级—— 而实际残差比这还小了三个数量级。

换句话说:这组数据的精度,比设备能提供的精度高出一千倍

能解释这个现象的原因,我想得到三种:

  • 这些点不是实测的,而是某个环节按圆生成的(比如为了打通流程先造的测试数据)。
  • 点在进入这段计算之前被处理过——比如已经做过平滑、重采样, 把噪声滤掉了。但滤波会让点趋近于光滑曲线,也不至于到亚毫米。
  • 我拿到的列表不是真的十二条不同记录,而是少量点重复填充的。

我没有结论,因为当时我没有回去核对原始数据是怎么来的——这是我的疏忽。 但这次经历给我留下了一条比代码更有用的规则。

拟合出来的精度,永远不可能超过输入数据的精度。 所以当残差远小于你预期的时候,那不是在证明"算得准", 而是说明"这组数据不是你想象的那样"。

05顺带说说那个 159 米

还有一个数字需要警惕:直径 159.4 米。

车辆的"最小转弯直径"指的是:方向盘打到底、低速稳定行驶时, 车身外缘画出的那个圆。这个量级通常是十几米—— 因为它由轴距和最大转向角决定,和车本身一样是个小尺度量。

159 米远大于此。所以这组点测的几乎肯定不是"最小转弯直径", 而是车辆在某次绕行中走出的一个大弧。这两件事常被混为一谈, 但它们回答的是完全不同的问题:

指标在问什么量级
最小转弯半径 / 直径车本身能拐多急
实际轨迹半径这次它拐了多急取决于当时怎么开

如果验收表上写的是前者,那你不能拿后者的数据去填。 我在便签里给这段起的标题就是"转弯直径"—— 现在回头看,这个命名本身就在诱导我把两个概念混起来。

06代码之外的两件事

这段代码本身没什么特别的,真正值得留下的是两条:

  1. 拟合类算法,必须输出"拟合得好不好",而不只是"结果是什么"。 我顺手算的 R_outer / R_inner 本来只是想让输出好看点, 结果它们成了唯一能暴露问题的地方。如果我只返回 r, 这个 79.678 会被我原样写进报告。
  2. 数字越精确,越要问它的量级对不对。 0.6 毫米的残差很精确,但它精确到了不该精确的地方; 159 米的直径也很精确,但它的量级回答不了"最小转弯"这个问题。 两个数字都在提醒同一件事:先看量级,再看精度
关于本文
整理自 2025 年 9 月 8 日的一段便签,代码与输出为当时的原文(含真实的返回 JSON)。 原始场景是给一条车辆轨迹算转弯半径。
2026.09.20 整理
补上了第 04、05 两节。原始便签里只有代码和一行结果 JSON, 当时我觉得"跑通了"就收工了——重读时才发现那两个数字一直在报警。