手眼标定:相机与机械臂如何建立坐标关系?从 AX=XB 到 OpenCV 实践

相机已经能估计标定板的位置,机器人也能返回末端位姿,为什么把视觉点交给机械臂后,仍然对不上?一个常见原因是:相机里的三维点与机器人基座中的点,属于不同坐标系,两者之间还缺少可验证的刚性关系。

手眼标定就是建立这种关系的一类方法。它不是把两组位置相减,也不是随便找到一个旋转矩阵,而是借助多次运动与观测,求出相机和机器人相关坐标之间的固定变换。本文从闭环关系推导 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 的价值,在于把机器人运动与视觉观测之间的固定关系写成可求解的约束。真正容易出错的地方,往往是进入求解器之前的方向、单位、末端定义和采样,以及离开求解器之后的验证。

先画清坐标链,再做合成检查,最后用独立物理任务验证。这样得到的不只是一个能被程序读入的矩阵,而是一段有条件、有依据、可追溯的空间关系。

参考资料

OpenCV:calibrateHandEye 与相关接口

OpenCV:PnP pose computation

Modern Robotics:Homogeneous Transformation Matrices

SciPy:Rotation.from_euler

Park 与 Martin:Robot Sensor Calibration

写在最后:在动手与思考之间建立闭环

"学而不思则罔,思而不学则殆。"

------ 孔子,《论语·为政》

读懂一个公式,亲手验证它,再回过头检查假设。让学习、推导与实验相互印证,复杂的空间关系也能一步步变得清楚。

相关推荐
晨间晓1 小时前
图片是什么色调?用华为云码道开发 OpenCV HSV 色调分布分析工具
人工智能·opencv·华为云
把我格子衫拿来1 小时前
相机标定误差很小,为什么测量仍不准?从 OpenCV 重投影误差到独立验证
python·数码相机·opencv·计算机视觉·相机标定
goehou1 小时前
流式输出实战:SSE 解析、chunk 拼装与断流重试
python·ai·llm·sse·流式
SamChan902 小时前
图片翻译后文字挤出边框?用 Pillow 测量译文,不再靠字符数猜宽度
图像处理·python·pillow·本地化·图片翻译
2601_962780912 小时前
2027风险管理岗准备路线:数学与应用数学专业如何补齐金融、SQL与Python
python·sql·金融
老唐7772 小时前
Transformer原理大白话讲解
人工智能·python·深度学习·神经网络·计算机视觉·ai·transformer
佳木逢钺2 小时前
从火灾模拟到系统热寂:一个双非运维如何用零 GPU 推导出多智能体系统的终极死锁
人工智能·python·算法·多智能体
fwc2 小时前
第T9周:猫狗识别2
python·深度学习
砚底藏山河2 小时前
数据新鲜度 SLA 监控
java·大数据·python·maven