相机已经能估计标定板的位置,机器人也能返回末端位姿,为什么把视觉点交给机械臂后,仍然对不上?一个常见原因是:相机里的三维点与机器人基座中的点,属于不同坐标系,两者之间还缺少可验证的刚性关系。
手眼标定就是建立这种关系的一类方法。它不是把两组位置相减,也不是随便找到一个旋转矩阵,而是借助多次运动与观测,求出相机和机器人相关坐标之间的固定变换。本文从闭环关系推导 AX=XB,给出 OpenCV 合成实践,并重点讨论方向、单位、采样退化和验证。
**阅读范围:**以"眼在手上"为主,同时解释"眼在手外"的模型差异。代码只操作合成矩阵,不连接机械臂,不执行运动,不模拟真实可达性或相机视野;实验结果不代表实物定位精度。资料核对日期:2026 年 10 月 15 日。
一、先分清两种配置:相机和标定板分别固定在哪里?
| 配置 | 相机安装 | 标定板安装 | 典型待求关系 |
|---|---|---|---|
| Eye-in-hand,眼在手上 | 刚性固定在机器人末端 | 固定在基座相关外部空间 | 相机到末端的固定变换 |
| Eye-to-hand,眼在手外 | 固定在基座相关外部空间 | 刚性固定在机器人末端 | 相机到基座的固定变换 |
"固定"是模型条件,不是安装时看起来没有动。支架松动、线缆拉扯、负载变形或标定板滑移,都可能破坏固定关系。若相机和标定板都按未知方式运动,不能继续沿用这里的简单闭环假设。
手眼标定也不等于相机内参标定、机器人运动学标定或 TCP 标定。这些环节可能共同影响最终结果,但它们估计的对象不同。处理末端坐标时,还需要确认机器人报告的是法兰、工具还是用户定义的末端坐标。
二、统一记号:让矩阵方向可以读出来
2.1 本文采用 T_ab:把 b 中的点变换到 a
p_a = T_ab @ p_b
T_ab = [[R_ab, t_ab],
[0, 0, 0, 1]]
T_ac = T_ab @ T_bc
T_ba = inverse(T_ab)
所有点使用齐次列向量,矩阵左乘。乘法从右向左执行;相邻坐标标签可帮助检查变换链是否连续。刚体逆变换的平移为 −Rᵀt,不能把 4×4 矩阵的平移列简单取负就认为完成求逆。齐次变换基础可参阅 Modern Robotics:Homogeneous Transformation Matrices。
2.2 四个坐标系与每次采样的数据
| 符号 | 含义 | 来源 |
|---|---|---|
| b | 机器人基座坐标 | 机器人系统定义 |
| g | 本文选定的末端坐标 | 机器人实际输出对应的坐标 |
| c | 相机三维坐标 | 视觉模型定义 |
| t | 标定板坐标 | 物点坐标与角点顺序定义 |
| G_i = T_bg(i) | 末端 → 基座 | 第 i 次机器人位姿 |
| C_i = T_ct(i) | 标定板 → 相机 | 第 i 次视觉位姿 |
| X = T_gc | 相机 → 末端 | 手眼标定待求 |
| Y = T_bt | 标定板 → 基座 | 眼在手上配置中的固定未知量 |

图 1:眼在手上的坐标链(原创教学示意图)
OpenCV 的 solvePnP 返回的通常是物体坐标到相机坐标的旋转和平移,因此当物体坐标就是标定板坐标时,它与 C_i 的方向一致。rvec 是旋转向量,不是默认的三个欧拉角;tvec 的单位继承物点单位。参考:OpenCV PnP 位姿说明。
三、从闭环推导 AX=XB,而不是背输入顺序
3.1 同一标定板在基座中的位姿应保持不变
G_i @ X @ C_i = Y
# 从右向左读:
# 标定板 → 相机 → 末端 → 基座
相机随末端运动,所以 G_i 与 C_i 会随采样变化;相机到末端 X 和标定板到基座 Y 保持固定。多次采样提供了区分这些未知量的约束。
3.2 取两次姿态,消去固定的 Y

图 2:相对运动方程的推导(原创教学示意图)
G_i @ X @ C_i = G_j @ X @ C_j
inverse(G_j) @ G_i @ X = X @ C_j @ inverse(C_i)
A = inverse(G_j) @ G_i
B = C_j @ inverse(C_i)
A @ X = X @ B
A 与 B 是按本文次序构造的相对运动。若交换 i、j,两边都应按同一推导改变;不能从另一篇文章抄一个 A,再从第三篇文章抄一个 B。不同作者用不同变换方向时,公式外观可能相似,实际输入却完全不同。
3.3 为什么只做平移不足以确定手眼平移?
把 AX=XB 的平移部分展开,可以得到 (R_A−I)t_X = R_X t_B−t_A。若所有机器人相对运动都没有旋转,R_A=I,左侧对 t_X 的约束消失。即使采集很多平移位置,也不能凭这些观测唯一确定这个平移分量。
代码后面构造了一个反例:把 X 的平移故意改动,所有纯平移训练样本仍然得到一致的闭环。这里不只是求解器"表现不好",而是观测中缺少相应信息。仅重复相似运动不会自动补出缺失约束。
单轴或几乎平行的旋转、过小的运动变化也可能带来退化或数值不稳定。应在设备允许和目标可观测的范围内采集有变化的姿态;姿态数只是条件之一,不是质量分数。
四、OpenCV 接口:输入绝对位姿,输出相机到末端
R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye(
R_gripper2base, t_gripper2base,
R_target2cam, t_target2cam,
method=cv2.CALIB_HAND_EYE_PARK
)
在本文眼在手上配置中,gripper2base 对应 G_i,target2cam 对应 C_i,输出 cam2gripper 对应 X=T_gc。传入的是逐次采样的绝对位姿列表,不要先算相对运动再不加区分地塞进同一接口。接口与方法定义见 OpenCV calibrateHandEye。
OpenCV 提供多种求解方法。本文固定使用 Park 方法便于复现,不做算法性能排名;它对应的经典研究为 Park 与 Martin:Robot Sensor Calibration: Solving AX=XB on the Euclidean Group。真实项目应在相同数据、相同任务和独立验证条件下比较,而不能根据某一次训练闭环最小就宣布某算法普遍最优。
4.1 名称方向和物理安装必须一起核对
"末端位姿"不保证 API 的矩阵方向与本文相同。机器人 SDK 可能报告另一种映射,也可能给出位置与姿态参数,需自行按其约定构造矩阵。应选一个已知点或基向量做正反变换验证,避免只凭变量名猜测。
如果机器人输出的是 TCP 到基座,而你把 g 写成法兰,求出的 X 会相对于实际输入的 TCP,而不是自动成为相机到法兰。标定文件应写清楚 g 的具体定义;工具配置改变后,原结果是否仍适用需要重新判断。
五、采样前的三项检查:表示、单位与配对

图 3:输入数据中最容易混淆的约定(原创教学示意图)
5.1 欧拉角、旋转向量与四元数不能混用
三个数可能表示欧拉角,也可能表示轴角旋转向量。欧拉角还涉及轴顺序、内禀/外禀旋转以及角度/弧度。即使都叫 XYZ,不同接口也不一定在描述同一个操作。不要用一个未经核对的"通用转换函数"替代 SDK 定义。
例如 SciPy 的 from_euler 使用大小写轴序列区分内禀与外禀约定,并有 degrees 选项;这只是该 API 的规则,不能推断机器人 SDK 采用同样规则。参考:SciPy Rotation.from_euler。四元数则需另外核对分量顺序与归一化。
5.2 平移单位必须统一
如果机器人平移单位为米,而标定板物点以毫米定义,视觉输出平移通常也按毫米解释。进入手眼求解前应把两组平移统一到同一单位。旋转矩阵没有长度单位,但这并不意味着平移混用会被求解器自动修正。
5.3 一组图像必须对应正确的机器人状态
视觉帧与机器人位姿需要按同一次观测配对。若在运动中采集,曝光时间、读出时间、位姿采样时间和传输延迟都会影响对应关系。应用程序接收到一帧图的时刻,不一定就是图像曝光的时刻。
静止后采样可简化部分时间问题,但仍需检查机械稳定、图像清晰和数据配对。具体采集与运动应由设备既有安全流程控制;本文代码不提供机械臂动作指令。
六、完整 Python 合成实验
为了先验证方向和接口,代码直接构造刚体矩阵,不经过真实机器人或图像检测。已知 X_true=T_gc 与 Y_true=T_bt 后,生成 G_i,再按 C_i=inv(X_true)·inv(G_i)·Y_true 合成视觉位姿。前 18 组用于拟合,另外 6 组用于未参与拟合的闭环检查。
建议使用独立虚拟环境。本次测试使用 opencv-python 4.13.0.92(cv2 版本字符串为 4.13.0);以下命令在已激活的虚拟环境中运行。若其他版本报告缺少接口,先核对版本与 API 可用性,不要把环境错误当成几何失败。
python -m pip install numpy opencv-python==4.13.0.92
python handeye_demo.py
"""Synthetic eye-in-hand calibration. Column vectors; T_ab maps b to a."""
from pathlib import Path
import json
import cv2
import numpy as np
def transform(rvec, tvec):
T = np.eye(4)
T[:3, :3] = cv2.Rodrigues(np.asarray(rvec, float))[0]
T[:3, 3] = np.asarray(tvec, float).reshape(3)
return T
def inverse(T):
result = np.eye(4)
result[:3, :3] = T[:3, :3].T
result[:3, 3] = -result[:3, :3] @ T[:3, 3]
return result
def validate(T):
if T.shape != (4, 4) or not np.isfinite(T).all():
raise ValueError('Expected finite 4x4 rigid transform')
R = T[:3, :3]
if not np.allclose(T[3], [0, 0, 0, 1], atol=1e-8):
raise ValueError('Invalid homogeneous row')
if not np.allclose(R.T @ R, np.eye(3), atol=1e-7):
raise ValueError('Non-orthogonal rotation')
if not np.isclose(np.linalg.det(R), 1., atol=1e-7):
raise ValueError('Rotation determinant is not +1')
def solve(G, C):
if len(G) != len(C) or len(G) < 3:
raise ValueError('Need paired transforms and at least three poses')
for T in G + C:
validate(T)
R, t = cv2.calibrateHandEye(
[T[:3, :3] for T in G], [T[:3, 3:4] for T in G],
[T[:3, :3] for T in C], [T[:3, 3:4] for T in C],
method=cv2.CALIB_HAND_EYE_PARK)
X = np.eye(4); X[:3, :3] = R; X[:3, 3] = t.ravel()
validate(X)
return X
def error(estimate, truth):
angle = np.linalg.norm(cv2.Rodrigues(estimate[:3, :3] @ truth[:3, :3].T)[0])
distance = np.linalg.norm(estimate[:3, 3] - truth[:3, 3])
return float(np.rad2deg(angle)), float(distance)
rng = np.random.default_rng(20261015)
X_true = transform([.18, -.12, .22], [45., -25., 85.]) # T_gc, mm
Y_true = transform([-.15, .1, -.08], [350., 100., 900.]) # T_bt
G, C = [], []
for _ in range(24):
G_i = transform(rng.uniform(-.7, .7, 3), rng.uniform([-150., -100., 100.], [150., 100., 350.]))
C_i = inverse(X_true) @ inverse(G_i) @ Y_true
G.append(G_i); C.append(C_i)
# First 18 poses fit; six unseen poses validate the chain.
X_hat = solve(G[:18], C[:18])
rotation_error, translation_error = error(X_hat, X_true)
assert rotation_error < 1e-5 and translation_error < 1e-6
heldout = [error(g @ X_hat @ c, Y_true) for g, c in zip(G[18:], C[18:])]
assert max(t for _, t in heldout) < 1e-6
# Explicitly check the relative-motion derivation.
i, j = 2, 7
A = inverse(G[j]) @ G[i]
B = C[j] @ inverse(C[i])
np.testing.assert_allclose(A @ X_true, X_true @ B, atol=1e-9)
# Eye-to-hand: camera fixed in base, target rigid on gripper.
# G_i @ Z_gt = Y_bc @ C_i; hence inv(G_i) @ Y_bc @ C_i = Z_gt.
Y_bc = transform([.12, -.2, .1], [600., -100., 900.])
Z_gt = transform([-.08, .13, .05], [10., 20., 100.])
C_eye_to_hand = [inverse(Y_bc) @ g @ Z_gt for g in G[:18]]
Y_bc_hat = solve([inverse(g) for g in G[:18]], C_eye_to_hand)
np.testing.assert_allclose(Y_bc_hat, Y_bc, atol=1e-7)
# Wrong output direction: using T_cg as though it were T_gc.
wrong_direction = inverse(X_hat)
wrong_heldout = [error(g @ wrong_direction @ c, Y_true)[1]
for g, c in zip(G[18:], C[18:])]
assert min(wrong_heldout) > 10.
# Separate noisy observation scenario. Left-multiplicative perturbations
# apply in the destination frames, with independent componentwise noise.
def perturb(T, rot_deg, trans_mm):
return transform(rng.normal(0., np.deg2rad(rot_deg), 3),
rng.normal(0., trans_mm, 3)) @ T
G_noisy = [perturb(T, .02, .10) for T in G[:18]]
C_noisy = [perturb(T, .05, .20) for T in C[:18]]
X_noisy = solve(G_noisy, C_noisy)
noisy_error = error(X_noisy, X_true)
assert noisy_error[0] < 1. and noisy_error[1] < 10.
noisy_heldout = [error(g @ X_noisy @ c, Y_true)[1]
for g, c in zip(G[18:], C[18:])]
# Pure translations cannot identify the translation component of X:
# every closure translation below is identical for any candidate t_x.
G_trans = [transform([0., 0., 0.], [x, 0., 0.]) for x in [0., 30., 60.]]
C_trans = [inverse(X_true) @ inverse(g) @ Y_true for g in G_trans]
X_alternative = X_true.copy(); X_alternative[:3, 3] += [100., -50., 70.]
closures = [g @ X_alternative @ c for g, c in zip(G_trans, C_trans)]
for value in closures[1:]:
np.testing.assert_allclose(value, closures[0], atol=1e-9)
assert np.linalg.norm(closures[0][:3, 3] - Y_true[:3, 3]) > 100.
result = dict(opencv=cv2.__version__, X_true=X_true.tolist(), X_hat=X_hat.tolist(),
exact_rotation_deg=rotation_error, exact_translation_mm=translation_error,
exact_heldout_max_mm=max(t for _, t in heldout),
wrong_direction_mean_mm=float(np.mean(wrong_heldout)),
noisy_rotation_deg=noisy_error[0], noisy_translation_mm=noisy_error[1],
noisy_heldout_mean_mm=float(np.mean(noisy_heldout)))
Path(__file__).with_name('results.json').write_text(json.dumps(result, indent=2))
print('OpenCV:', cv2.__version__)
print(f'Exact X error: {rotation_error:.8f} deg, {translation_error:.8f} mm')
print(f'Wrong-direction held-out mean: {np.mean(wrong_heldout):.3f} mm')
print(f'Noisy X error: {noisy_error[0]:.4f} deg, {noisy_error[1]:.4f} mm')
print(f'Noisy X on exact held-out chain: {np.mean(noisy_heldout):.4f} mm mean')
print('PASS: eye-in-hand, eye-to-hand, held-out chain, AX=XB, wrong direction, noisy case, translation degeneracy')
6.1 本次实际运行结果
| 检查 | 本次合成结果 | 正确解释 |
|---|---|---|
| 无噪声 X 恢复 | 旋转与平移误差在所设数值容差内接近零 | 验证理想模型、方向与求解接口 |
| 误用 X 的逆矩阵 | 未参与拟合姿态的目标原点误差均值约 420.997 mm | 是特定合成配置的反例,不是固定放大量 |
| 带扰动 X 的旋转误差 | 0.0311° | 与合成真值比较 |
| 带扰动 X 的平移列误差 | 0.1458 mm | 两个手眼变换的平移列之差 |
| 带扰动 X 用于无噪声留出链 | 目标原点位置误差均值 0.2338 mm | 隔离观察 X 估计误差,不是在线系统总误差 |
扰动采用左乘的小刚体变换,旋转向量各分量独立加噪,平移扰动也按分量生成;因此旋转扰动会作用于原位姿的平移。代码参数不能直接解释为真实机器人位置精度或相机单点误差。噪声模型、数据分布与结果必须一起说明。
代码还检查了 AX=XB 恒等关系、眼在手外的重解释、矩阵方向误用和纯平移退化。validate 只检查有限值与刚体矩阵结构,不负责证明采样足够可观测;结构合法的输入,仍可能在几何上退化。
6.2 为什么留出验证还不等于物理验证?
合成中 Y_true 是已知真值,可以直接比较;真实系统通常不知道标定板在基座中的真值。用若干观测得到闭环均值,再检查其他观测的一致性,有助于发现不稳定,却仍不能排除共同偏差。本文纯平移反例就说明,一致性不自动意味着绝对正确。
此外,合成姿态没有经过机械臂可达性、碰撞或相机视野检查,也没有引入实际运动学误差、板材形变、角点误检和 PnP 歧义。不能根据合成表格承诺实物精度。
七、眼在手外:重新推导,避免机械照搬
现在相机固定在基座相关空间,标定板刚性固定在末端。仍使用 G_i=T_bg(i)、C_i=T_ct(i),但待求相机到基座关系改记为 Y_bc=T_bc,固定板到末端关系为 Z_gt=T_gt。
G_i @ Z_gt = Y_bc @ C_i
inverse(G_i) @ Y_bc @ C_i = Z_gt
形式上,它与眼在手上的"已知位姿 × 未知固定变换 × 视觉位姿 = 固定量"相同。因此可以把 inv(G_i) 作为求解器第一个位姿列表,把 C_i 作为第二个列表,输出解释为 Y_bc。本文代码用已知真值检验了这个重解释。
这里不是说所有安装方式都只需要"机器人位姿取逆"。这个结论依赖刚才明确写出的固定关系、坐标方向和接口模型。若你的 SDK 已经给出 T_gb,或标定板安装方式不同,实际操作就会变化。先列闭环,再对齐接口,才不会把两次取逆互相抵消却毫无察觉。
八、为什么闭环误差小,最终点位仍然可能偏?

图 4:一致性与独立正确性需要分开检查(原创教学示意图)
8.1 手眼矩阵的误差与任务点误差不是一个量
旋转误差会随目标点相对参考原点的距离影响位置误差。假设某个旋转误差作用臂长约为 300 mm,角度为 0.1°,则小角度位移量级约为 300·sin(0.1°)≈0.524 mm。这只是几何量级示例,不是任何设备的精度参数。
所以评价手眼变换时,应分别报告旋转和位移指标,并进一步检查实际任务点。标定板原点附近误差小,不代表整个工具工作区域都同样准确。
8.2 机器人、视觉与工具的误差会继续进入链路
眼在手上时,相机点转换到基座可写为 p_b=G_i·X·p_c。X 即使准确,G_i 的运动学偏差、视觉点误差、时间不一致以及实际工具配置都仍然会影响结果。若后面还要变换到另一坐标系,应把那一段一起纳入验证。
相机内参或标定板尺寸错误可能产生共同偏差;更换镜头、对焦、相机固定件或工具配置后,也可能改变既有条件。不能因为手眼文件没有被修改,就认为整个测量链仍然适用。
8.3 不宜随意把毫米和角度加成一个分数
旋转与平移残差属于不同量纲。若为了优化或排序需要合并,应说明权重、尺度和任务依据。未经说明的"综合误差 0.3"既不能比较算法,也很难解释对实际点位的影响。
九、实用排错表与验证路线
| 现象 | 优先排查 | 验证方式 |
|---|---|---|
| 整体点位偏得很大 | 矩阵方向、单位或末端定义错误 | 逐段变换已知点;核对 SDK 约定 |
| 某些姿态明显异常 | 配对、PnP 错解、检测或采样问题 | 检查原图、位姿与时间记录 |
| 换一批姿态结果变化很大 | 运动退化、约束不足或数据偏差 | 留出不同旋转方向与工作范围验证 |
| 闭环稳定但物理点仍偏 | 公共偏差、工具配置或机器人误差 | 独立参考与实际任务点验证 |
| 静止时好、运动时差 | 同步与动态误差 | 受控时间链与动态条件测试 |
| 重新装配后结果失效 | 固定关系变化 | 安装状态复核与重新标定/验证 |
**第一层:先验证矩阵语义。**记录每个 T 的输入空间、输出空间、单位与来源,检查刚体结构,并用已知点做正反变换。
**第二层:检查训练与留出闭环。**观察目标在固定空间中的位置与方向是否稳定,检查留出姿态是否覆盖不同运动条件。训练和验证不应只是同一姿态的重复截图。
**第三层:独立物理验证。**使用可信参考点、距离或测量设备,明确其自身不确定性;检查工作范围内不同位置与姿态的实际点位误差。不得将合成容差直接用作实物验收门限。
**第四层:保存完整证据。**保存原始位姿与图像索引、角点与 PnP 结果、内参版本、配对规则、剔除理由、安装和工具状态、求解方法及验证报告。只保存一个 4×4 矩阵,通常不足以解释后续问题。
结语:手眼标定先是坐标问题,然后才是求解问题
AX=XB 的价值,在于把机器人运动与视觉观测之间的固定关系写成可求解的约束。真正容易出错的地方,往往是进入求解器之前的方向、单位、末端定义和采样,以及离开求解器之后的验证。
先画清坐标链,再做合成检查,最后用独立物理任务验证。这样得到的不只是一个能被程序读入的矩阵,而是一段有条件、有依据、可追溯的空间关系。
参考资料
Modern Robotics:Homogeneous Transformation Matrices
Park 与 Martin:Robot Sensor Calibration
写在最后:在动手与思考之间建立闭环
"学而不思则罔,思而不学则殆。"
------ 孔子,《论语·为政》
读懂一个公式,亲手验证它,再回过头检查假设。让学习、推导与实验相互印证,复杂的空间关系也能一步步变得清楚。