把两组标志点送进配准程序,界面显示“RMS 误差 0.6 mm”。点云叠加也很好看,于是我们去检查远处的目标点,却发现位置偏了几毫米。继续删除残差大的点,训练分数下降,目标位置却未必更准确。
这里有一个关键区别:参与配准的点之间还差多少,与某个独立目标点经过变换后离真值多远,回答的是不同问题。本文从 FLE、FRE、TRE 的定义出发,用刚体点配准和可复现的合成实验,解释为什么小残差不能独自证明目标准确。
阅读范围:面向三维视觉、医学影像和空间定位软件开发者。本文仅讨论已知点对应关系的三维刚体配准,示例不读取患者数据,不处理组织形变,也不提供临床误差阈值。所有实验数值来自合成数据,不代表实际设备精度。资料核对日期:2026 年 10 月 15 日。
一、先分清 FLE、FRE 与 TRE
图 1:三类误差对应三个不同问题(原创教学示意图,合成数据)
| 名称 | 关注什么 | 需要什么信息 |
|---|---|---|
| FLE:Fiducial Localization Error | 标志点的观测位置与真实位置之间的偏差 | 真实位置或可解释的定位误差模型 |
| FRE:Fiducial Registration Error | 配准后,参与拟合的对应标志点之间的残差 | 观测点与估计变换,可直接计算 |
| TRE:Target Registration Error | 目标点经估计变换后,相对于真实对应位置的偏差 | 独立目标的真实对应关系或适当参考 |
这几个术语是点配准误差分析的基础。经典研究讨论了定位误差、标志点布局和目标位置与配准精度之间的关系;相关来源包括 Fitzpatrick、West 与 Maurer 的刚体点配准研究 和 The Role of Registration in Accurate Surgical Guidance。
1.1 本文明确使用的计算定义
x_i_obs:源空间中观测到的标志点 y_i_obs:目标空间中对应的观测标志点 T_hat:从源空间到目标空间的估计刚体变换 FRE_RMS = sqrt(mean(||T_hat(x_i_obs) - y_i_obs||^2)) q_true:未参与求解的源空间目标点真值 T_true:真实的源→目标变换 TRE(q) = ||T_hat(q_true) - T_true(q_true)||本文用 FRE_RMS 表示一组训练标志点上的三维欧氏距离均方根。对某个目标,TRE 是一个位置误差长度;后文会再对多次随机试验中的 TRE 做均方根统计。两种聚合层次必须分别写清。
1.2 独立检查点的测量残差,不一定是纯 TRE
实际工作中,我们通常没有目标真值,只能在两边再次测量检查点。此时残差同时包含配准变换误差与检查点自身定位误差,甚至还包含参考或时间变化。它可以成为有用的验证指标,但不应不加说明地把全部差值解释为纯配准 TRE。
同样,整个导航或测量系统的最终偏差还可能包含工具标定、追踪、坐标变换和形变等因素。FRE、TRE 与系统总误差不能互换。
二、为什么训练残差很小,目标误差仍可能很大?
2.1 求解器只看到了观测点
刚体点配准通常寻找一个旋转 R 与平移 t,使已知对应的点尽量接近。它优化的是输入观测上的目标函数,不知道每个点真正应该在哪里。如果观测中存在共同偏差,估计变换可能很好地解释这份有偏数据。
minimize sum(||R @ x_i_obs + t - y_i_obs||^2) subject to R.T @ R = I and det(R) = +1这个问题的最优解可以通过质心、协方差矩阵与奇异值分解求得。下面实现的是不允许尺度变化、也不允许镜像反射的刚体拟合。Kabsch-Umeyama 相关代数依据可参阅 NIST 的算法研究。
2.2 点布局影响旋转信息,目标位置影响误差放大
如果所有标志点挤在很小的范围内,轻微旋转造成的点位变化也很小,旋转估计更容易受到定位扰动影响。远处目标受到同样角度误差时,位置变化却可能明显放大。
这种放大还与方向有关:绕某个轴旋转时,靠近该轴的点与远离该轴的点受到的影响不同。因此“离质心越远一定越差”不是适用于每个点、每种误差的完整定律,但目标位置与布局确实不能从验证中省略。
图 2:对比点数相同但空间分布不同的布局(原创教学示意图,合成数据)
2.3 增加点数与改善点布局不是同一件事
在同一小区域反复采集,可能有利于观察部分随机波动,却不会自动获得更广的几何覆盖。重复定位偏差、相关噪声和共同参考漂移也可能被多次重复采样带入。评价方案时,需要同时记录点数、分布、目标区域和定位条件。
更大分布不意味着可以忽略参考稳定性或随意扩展采样。实际点的可见性、定位质量和刚性假设也会变化。本文比较刻意固定点数、真实变换和随机噪声样本,只观察布局改变的影响。
三、先明确变换方向与模型边界
3.1 本文 T 把源点变成目标点
p_target = R @ p_source + t # 点按行存储为 Nx3 数组时: target_points = source_points @ R.T + t数学上使用列向量,与代码中按行存储 N 个点并不矛盾。矩阵乘法需要相应转置,不是把变换方向反过来。求逆时也不能只把平移取负,应使用完整刚体逆变换。
不同库的变换方向可能不同。例如 SimpleITK 图像配准框架通常返回从 fixed 物理坐标映射到 moving 物理坐标的变换,这与很多人根据“把 moving 画到 fixed 上”产生的直觉不一致。使用前应查 API,而不是只看变量名字。来源:SimpleITK Registration Overview。
3.2 刚体、相似与仿射不能随意切换
刚体只有旋转与平移;相似变换增加统一尺度;仿射还能表示更多线性变化。更灵活的模型可以降低某些训练残差,但也可能掩盖单位错误或不适当的数据对应。不能为了“把分数压下来”而不说明地改变模型。Slicer 的标志点配准工具也区分这些变换类型,见 Fiducial Registration 文档。
本文拒绝重合或共线布局,因为这些布局不足以唯一确定一般三维刚体变换。平面但不共线的已知三维对应点可以确定保持方向的刚体变换,不能把“共面”直接等同于“必然不能配准”。接近退化时,数值和噪声敏感性仍需另外检查。
四、完整 Python 实验:相近 FRE,不同目标误差
实验使用八个立方体顶点作为标志点。小布局半边长为 5 mm,大布局半边长为 60 mm;二者使用完全相同的 1000 组随机定位噪声。源空间与目标空间各坐标分量的独立高斯噪声标准差均为 0.30 mm。
这里的 0.30 mm 是每个空间中每个坐标分量的标准差,不是三维误差向量长度,也不是两空间合并后的误差。目标点真值设为中心 (0,0,0) 和 (150,0,0) mm;两者都不参与拟合。
python -m pip install numpy python registration_demo.py"""Synthetic rigid point registration: training FRE is not target truth.""" from pathlib import Path import json import numpy as np def apply(T, points): points = np.asarray(points, float) return points @ T[:3, :3].T + T[:3, 3] def fit_rigid(source, target): source, target = np.asarray(source, float), np.asarray(target, float) if source.shape != target.shape or source.ndim != 2 or source.shape[1] != 3: raise ValueError('Expected corresponding Nx3 point arrays') if len(source) < 3 or not np.isfinite(source).all() or not np.isfinite(target).all(): raise ValueError('Need at least 3 finite pairs') mu_s, mu_t = source.mean(axis=0), target.mean(axis=0) x, y = source - mu_s, target - mu_t for centered in [x, y]: singular = np.linalg.svd(centered, compute_uv=False) if singular[0] == 0 or singular[1] <= 1e-8 * singular[0]: raise ValueError('Coincident or collinear layout') U, _, Vt = np.linalg.svd(x.T @ y) R = Vt.T @ U.T if np.linalg.det(R) < 0: # exclude reflection Vt[-1] *= -1 R = Vt.T @ U.T T = np.eye(4) T[:3, :3], T[:3, 3] = R, mu_t - R @ mu_s assert np.allclose(R.T @ R, np.eye(3), atol=1e-10) assert np.isclose(np.linalg.det(R), 1.) return T def rms_distance(a, b): return float(np.sqrt(np.mean(np.sum((a-b)**2, axis=1)))) def rotation_z(degrees): r = np.deg2rad(degrees) return np.array([[np.cos(r), -np.sin(r), 0.], [np.sin(r), np.cos(r), 0.], [0., 0., 1.]]) rng = np.random.default_rng(20261015) T_true = np.eye(4) T_true[:3, :3] = rotation_z(20.) T_true[:3, 3] = [40., -25., 70.] cube = np.array([[x,y,z] for x in [-1.,1.] for y in [-1.,1.] for z in [-1.,1.]]) fiducials = cube * 20. exact = fit_rigid(fiducials, apply(T_true, fiducials)) np.testing.assert_allclose(exact, T_true, atol=1e-10) np.testing.assert_allclose(apply(np.linalg.inv(exact), apply(exact, fiducials)), fiducials) # Planar but non-collinear layouts are allowed; collinear ones are rejected. plane = np.array([[0.,0.,0.],[10.,0.,0.],[0.,10.,0.],[10.,10.,0.]]) np.testing.assert_allclose(fit_rigid(plane, apply(T_true, plane)), T_true, atol=1e-10) try: fit_rigid(np.array([[0.,0.,0.],[1.,0.,0.],[2.,0.,0.]]), np.array([[0.,0.,0.],[1.,0.,0.],[2.,0.,0.]])) except ValueError: pass else: raise AssertionError('Collinear input should be rejected') # Compare geometry using exactly the same localization-noise draws. trials, sigma_mm = 1000, 0.30 # componentwise noise SD in each space source_noise = rng.normal(0., sigma_mm, (trials, 8, 3)) target_noise = rng.normal(0., sigma_mm, (trials, 8, 3)) check_points = np.array([[0.,0.,0.], [150.,0.,0.]]) check_truth = apply(T_true, check_points) summary = [] for half_extent in [5., 60.]: clean_source = cube * half_extent clean_target = apply(T_true, clean_source) fre_values, tre_values = [], [] for e_s, e_t in zip(source_noise, target_noise): observed_source, observed_target = clean_source+e_s, clean_target+e_t estimated = fit_rigid(observed_source, observed_target) fre_values.append(rms_distance(apply(estimated, observed_source), observed_target)) tre_values.append(np.linalg.norm(apply(estimated, check_points)-check_truth, axis=1)) fre_values, tre_values = np.array(fre_values), np.array(tre_values) summary.append(dict(half_extent=half_extent, mean_fre=float(fre_values.mean()), tre_rms=np.sqrt(np.mean(tre_values**2, axis=0)).tolist(), far_tre_p95=float(np.percentile(tre_values[:,1],95)))) assert abs(summary[0]['mean_fre']-summary[1]['mean_fre']) < .03 assert summary[0]['tre_rms'][1] > 4*summary[1]['tre_rms'][1] # A correlated observation-bias counterexample: measured targets are rotated # by 2 degrees around the true fiducial centroid, with no random noise here. small_source = cube * 5. clean_target = apply(T_true, small_source) center = clean_target.mean(axis=0) bias_R = rotation_z(2.) biased_target = (clean_target-center) @ bias_R.T + center biased_T = fit_rigid(small_source, biased_target) bias_fre = rms_distance(apply(biased_T,small_source),biased_target) far_point = np.array([[300.,0.,0.]]) bias_tre = float(np.linalg.norm(apply(biased_T,far_point)-apply(T_true,far_point))) expected = 2*300*np.sin(np.deg2rad(1.)) assert bias_fre < 1e-10 and abs(bias_tre-expected) < 1e-9 result = dict(trials=trials, per_space_component_sigma_mm=sigma_mm, layouts=summary, correlated_bias_fre=bias_fre, correlated_bias_far_tre=bias_tre) Path(__file__).with_name('results.json').write_text(json.dumps(result,indent=2)) for row in summary: print(f'half extent {row["half_extent"]:.0f} mm: mean FRE={row["mean_fre"]:.4f} mm; ' f'TRE RMS center/far={row["tre_rms"][0]:.4f}/{row["tre_rms"][1]:.4f} mm') print(f'Correlated bias: FRE={bias_fre:.8f} mm; far TRE={bias_tre:.4f} mm') print('PASS: exact recovery, inverse, planar layout, collinear rejection, layout effect, bias counterexample')4.1 本次实际输出
| 布局半边长 | 平均 FRE_RMS(mm) | 中心目标 TRE RMS(mm) | 远目标 TRE RMS(mm) | 远目标 TRE 第95百分位(mm) |
|---|---|---|---|---|
| 5 mm | 0.6299 | 0.2664 | 4.5536 | 7.9906 |
| 60 mm | 0.6300 | 0.2664 | 0.4623 | 0.7444 |
图 3:1000 次合成试验的统计结果(原创教学示意图,合成数据)
两种布局的平均 FRE 都约为 0.63 mm,看起来几乎没有差别。但在距离中心 150 mm 的目标处,小布局的 TRE RMS 约为 4.55 mm,大布局约为 0.46 mm。中心目标的两种结果则都约为 0.27 mm。
这不是用单次“最好”或“最坏”结果挑出的对比。两种布局复用了相同噪声样本,再对全部试验统计;关键差异来自布局与目标位置,而不是更换了一组更有利的随机数。
4.2 如何读表中的平均值、RMS 与百分位?
平均 FRE_RMS 的计算方式是:每次试验先对八个训练残差求 RMS,再把 1000 次 RMS 求平均。目标 TRE RMS 则是:固定同一个目标,对 1000 次目标误差平方取平均再开方。第 95 百分位是这组误差分布中的样本分位值。
这些量不是同一个统计定义。样本第 95 百分位也不是“这台设备有 95% 置信度保证不超过此值”,更不是临床安全边界。报告中应保留统计层次、样本条件和单位,不宜都缩写成一个“平均误差”。
五、第二个反例:FRE 接近零,远处目标仍然明显偏
5.1 在观测中加入一个共同旋转偏差
第二个实验不再使用独立随机噪声,而是把目标端的八个观测点绕它们的真实质心统一旋转 2°。这些有偏观测仍然能被一个刚体变换完美解释,所以训练 FRE 数值上接近零。
但是,与真实变换相比,距离质心 300 mm、垂直于旋转轴方向的目标会产生约 10.471 mm 的误差。对应几何关系为 2·300·sin(1°)。局部标志点的偏移很小,并不阻止远处目标出现更大的偏差。
被共同偏差改变的目标观测: y_biased = R_bias @ (y_true - centroid) + centroid 训练:拟合 x_obs → y_biased,FRE 可以接近 0 验证:比较 T_hat(q_true) 与 T_true(q_true),目标仍然偏移这是刻意构造的相关误差反例,用来说明目标函数的边界;它不是前一节独立高斯误差模型的另一组随机样本,也不说明实际设备一定存在 2° 偏差。两类实验的误差假设必须分别说明。
5.2 不能把反例变成“FRE 没有用”
FRE 仍然有用:大残差可能提示点对应错误、定位异常、模型不适用或输入问题;逐点残差也能帮助追查数据。真正需要避免的是把训练 FRE 当作所有目标位置误差的充分证明。
同样,也不能从本文推导“FRE 与 TRE 在所有条件下完全没有统计关系”。两者关系需要结合定位误差模型与几何条件讨论。本文的可重复结论是:单个低 FRE 不足以替代对独立目标的验证。
六、为什么删点后分数更低,结果却未必更好?
如果某个点发生错配或严重定位失败,剔除或重新采集有合理依据。但如果只按训练残差不断删点,可能删掉了提供重要空间覆盖的点,也可能让模型只在更容易拟合的局部区域表现好。
应保存剔除理由、原始残差与剔除后布局,并在固定的独立检查点上比较结果。不能让参与选点和调参的同一批检查点最后又被称为“从未参与选择的验证集”。
加权配准同样需要依据。若点的定位不确定性不同,可以研究适当权重,但权重本身应来自可靠的信息或可检验的模型;为了得到漂亮分数随意降低困难点权重,无法建立准确性证据。本文代码采用等权重。
七、表面重合、图像相似与点配准残差也不能互换
点配准要求明确的点对应关系;表面配准可能依赖最近邻关系或其他距离;强度图像配准还可能优化相关性、互信息等指标。不同方法的代价函数不一定在衡量同一种几何误差。
两个表面看起来贴合,不一定意味着内部目标准确;两幅图像整体相似,也不自动证明每个解剖位置都正确对应。检查方法应匹配任务区域和模型,而不是把不同算法的单一分数拿到同一个排行榜中。
SimpleITK 的配准框架区分变换、相似性度量、优化器与插值器,这些组成部分各自有约定。算法收敛表示满足某种停止条件,不等于找到了已知真实解。参考:SimpleITK Registration Overview。
八、独立验证应该怎样设计?
图 4:从训练检查到独立物理验证(原创教学示意图,合成数据)
8.1 先明确目标区域,而不是只在标志点附近检查
把待验证的位置与实际工作范围对应起来,记录它们相对于标志点布局的距离和方向。中心、边缘、远处或其他关键区域,可能反映不同误差模式;不能只在最容易通过的位置测一次。
真实环境的点选择与布置受可见性、刚性、定位质量和任务要求约束。本文不提供面向患者的标志点放置操作方案;工程方案应依据其设备、专业流程和验证证据确定。
8.2 检查点不能参与求解,但“不参与”还不够
检查点不参与拟合,是区分训练与验证的起点。但它们仍可能与训练数据共享图像几何错误、同一追踪参考的漂移、同一标定板的尺寸偏差或同一次变形。验证报告需要说明参考来源和可能的共同误差。
要估计更接近真实的目标误差,参考本身的位置、稳定性和不确定性应能够解释。NIST 的 Guidelines for the Registration of Two Coordinate Frames 提供了坐标系配准问题的工程背景;具体应用仍需制定自己的测量与验收方案。
8.3 区分重复性、偏差与总链路误差
反复执行同一流程可以观察波动,却可能稳定复现共同偏差。独立参考可以帮助揭示偏差,但参考自身也有误差。如果最终任务还包括工具、追踪器、机械臂或额外变换,应记录整条链,不把全部结果归因于某一个配准算法。
对于非刚性对象,术前与术中形态变化还可能破坏刚体假设。配准时的训练误差再小,也不能证明目标后来仍处于相同空间关系。需要另行观察参考状态和模型适用条件。
九、一张实用排错表
| 现象 | 优先提出的问题 | 验证思路 |
|---|---|---|
| FRE 小,远处目标偏 | 布局是否局限?目标是否远离受约束区域? | 按目标位置分层检查,不只看总 RMS |
| 结果整体平移 | 参考漂移、方向或单位是否有误? | 已知点、参考稳定性与变换链复核 |
| 方向异常或出现镜像 | 左右约定、点序、反射或矩阵方向是否错误? | 检查 det(R)=+1、方向标记和点对应 |
| 删点后分数下降但目标变差 | 是否损失了关键几何覆盖? | 固定独立检查点比较,保存剔除原因 |
| 换一次采样差异很大 | 定位波动、近退化布局或错误点是否存在? | 逐点残差与布局敏感性分析 |
| 开始准确,之后变差 | 参考、对象形态或工具状态是否改变? | 检查时间相关变化和刚体假设 |
| 掩膜叠加错位 | 索引、LPS/RAS、原点与方向是否混用? | 物理坐标验证,避免只按数组形状对齐 |
这些是排错入口,不是由症状直接确认唯一原因。实际问题可能叠加多种误差,一次修复后仍应在原定工作范围内重新验证。
十、这份教学代码验证了什么,遗漏了什么?
已验证:已知刚体变换恢复、正逆映射、非共线平面点配准、共线输入拒绝、同噪声下的布局比较,以及共同旋转偏差反例。所有计算使用 NumPy,不依赖图像检测或外部标注。
未覆盖:真实图像几何与定位过程、外点鲁棒估计、异方差或各向异性误差、参考漂移、非刚性形变、实时同步、完整导航误差和临床安全评估。代码中的退化判据只服务于示例输入检查,不是应用中的误差验收门限。
反射修正用于将解约束为保持方向的旋转,它不负责发现所有错配。即使输出旋转矩阵合法,点对应关系仍然可能错;算法结构检查与数据语义检查需要分别存在。
结语:配准分数要与位置、模型和验证条件一起读
FRE 告诉我们观测标志点在估计变换下拟合得怎样;TRE 关注独立目标相对于真实对应位置的偏差。点布局、目标位置、共同偏差和参考条件,会让这两个问题得到不同答案。
把训练残差、独立检查与完整任务验证分开记录,比继续压低一个总分数更有价值。一个可信的结果,不只是“误差很小”,还应能说明:在哪里、相对于什么参考、在什么条件下小。
参考资料
Fitzpatrick 等:Predicting error in rigid-body point-based registration
The Role of Registration in Accurate Surgical Guidance
NIST:Kabsch-Umeyama 算法的代数解释
NIST:Guidelines for the Registration of Two Coordinate Frames
3D Slicer:Fiducial Registration
SimpleITK:Registration Overview
写在最后:从熟悉的知识里发现新的问题
“温故而知新,可以为师矣。”
—— 孔子,《论语·为政》
再看一次熟悉的误差指标,重新检查它的定义与边界,也许就能发现过去忽略的问题。把每一个结论建立在可重复的验证上,学习与工程都会更扎实。