☰
线激光器手眼标定:从AX=XB到激光平面的完整避坑指南
2026/10/14 13:13:33 网站建设 项目流程

简介:手眼标定是机器人视觉中连接传感器与机器人运动学的基础技术,本质是通过多组位姿求解坐标变换方程。单目相机标定常归结为AX=XB,而线激光器构成的轮廓测量系统还要同时估计激光平面,两个未知量相互耦合,直接导致点云拼接误差放大。理解眼在手上与眼在手外的方程差异、旋转与平移分步求解的Tsai两步法,是保证精度的前提。在焊缝跟踪、轮廓测量和在线检测中,若点云拼接偏差持续在0.5mm以上,大概率是标定环节出了问题。围绕棋盘格标定板布置、条纹提取与联合求解,梳理出完整可复现的工程流程,并给出五个高频踩坑场景及验证清单,帮助视觉工程师将手眼标定与激光平面标定一次做对。

1. 线激光器手眼标定:点云拼接差 0.5mm 的时候,问题多半出在标定

把线激光器装到机械臂末端后,我第一轮手眼标定忙了两天,点云拼接缝隙还是压在 0.5mm 下不去。后来想明白一个关键点:线激光器的手眼标定和单目相机手眼不是一回事。单目相机只要解一个 AX=XB 的 4x4 矩阵,线激光却要同时解出手眼矩阵和激光平面两个未知量,两个量耦合在一起,任何一个偏了,后端点云坐标就跟着偏。

网上能搜到的 halcon 手眼标定教程、各类开源手眼脚本,大多是单目相机场景,直接搬到线激光项目上容易翻车。这篇文章把我调试时踩过的坑、能直接跑的 Python 流程和现场验证清单完整写出来,适合做焊缝跟踪、轮廓测量、在线尺寸检测的视觉工程师照着走一遍。

2. 先算坐标系再动手:眼在手上和眼在手外的标定方程怎么选

2.1 线激光测量的本质:二维图像约束出一个一维平面

先统一概念。分体式线激光测量系统由普通工业相机和一字线激光器组成,相机看到的是激光在物体表面打出的条带。这条带上的像素点,经相机内参投影后是一条射线,射线与激光平面的交点才是目标点。也就是说,线激光比普通相机多出一个未知量:激光平面在相机坐标系下的空间位姿。这个平面不标定出来,像素坐标根本无法转换成三维坐标。

一体式激光轮廓传感器略有不同,厂家出厂前已经标定好内部光平面,输出的是矫正过的二维轮廓,这时只需标传感器坐标系到机器人末端的变换,也就是常规手眼标定。判断方法很简单:如果软件直接给出 (X, Z) 轮廓点,按一体式处理;如果拿到的只有原始图像、需要自己做条纹提取,就走分体式流程。下面默认分体式方案,一体式可以跳到 4.4 的坐标变换链。

我一般开工前会花半小时确认三个坐标系:相机坐标系按 OpenCV 惯例,X 向右、Y 向下、Z 向前;机械臂末端坐标系以示教器定义为准;机械臂基座坐标系按机器人出厂约定。很多翻车现场是坐标系手性没对齐,标定结果旋转部分差 180°,点云整体镜像。这个问题在避坑章还会重点说。

2.2 眼在手上与眼在手外:方程形式与适用选型

手眼标定按安装方式分两种。线激光器装在机械臂末端、标定板固定,叫眼在手上(Eye-in-Hand),要解的是激光器坐标系到末端坐标系的变换,记为 X。相邻两个末端位姿之间的运动记为 A_i,相邻两个标定板相对相机的运动记为 B_i,每个姿态对给出一个 4x4 方程:

A_i X = X B_i

这里的 A_i 由示教器读数计算,B_i 由标定板图像计算,X 是待求的固定变换。位姿对越多,方程越冗余,工程上一般采集 15 到 20 组。

激光器固定安装、标定板装在机械臂末端,叫眼在手外(Eye-to-Hand),方程多了第二个未知矩阵:激光器到基座的 Z、标定板到末端的 X,写成 A_i X = Z B_i。相比 AX=XB,它要同时解两个矩阵,除非先用激光跟踪仪把 Z 测出来,否则稳定性和可操作性都差一截。

安装方式未知量方程形式典型场景
眼在手上激光器到末端 XA X = X B线激光装在机械臂末端扫描
眼在手外激光器到基座 Z、标定板到末端 XA X = Z B线激光固定、工件由机器人带动

线激光系统的选型建议很明确:焊缝跟踪、轮廓扫描这类需要机械臂带动激光的项目,选眼在手上;工件在传送带上移动、激光支架固定不动的项目,才考虑眼在手外。两者在数据采集和求解代码上差别很大,开工前先把这个定死,别中途切换。

2.3 求解顺序:先内参、再手眼、还是先激光平面

标定顺序我踩过不少坑,先把结论写出来:相机内参永远是第一步,无论后续怎么做,内参畸变误差会被手眼和平面一起放大,这是没有后悔药的一步。第二步先做激光平面还是先做手眼?两种思路都有,效果差别很大。

思路一:先标手眼,再用标定板姿态配合激光条纹拟合平面。优点是每一步独立,排错简单;缺点是激光平面拟合用的标定板平面位姿里已经混入相机外参误差和手眼误差,流程走完出了偏差,很难定位是手眼还是平面。

思路二:同一个标定板姿态,棋盘格解算标定板到相机的位姿,激光条纹投影到同一个已知平面上求三维点,最后把所有姿态的激光点拟合成平面。这一步不依赖手眼结果,误差链路短。我现在的习惯是先做平面标定,再做手眼,最后做联合残差验证。如果精度要求在 0.1mm 量级,还可以把手眼矩阵和平面当作初值做整体非线性优化。

3. 解 AX=XB:Tsai 两步法与一套可复现的 Python 脚本

3.1 为什么旋转部分要单独解

AX=XB 看上去是 4x4 矩阵方程,真正求解时可以先砍掉一半复杂度。把 A、B、X 都按旋转加平移拆开,展开后得到两套等式:

R_a R_x = R_x R_b (R_a - I) t_x = R_x t_b - t_a

第一行只包含旋转矩阵,第二行才是平移。所以标定算法的标准做法是先用一组位姿把 R_x 解出来,再代入平移方程求 t_x。这就是 Tsai 两步法的核心,Halcon 里的手眼标定算子、OpenCV 里的 calibrateHandEye,底层都是同一条路线。

旋转矩阵不能直接做线性最小二乘,因为九个元素之间有正交约束。Tsai 的办法是把 R_x 写成旋转向量形式,让旋转向量满足 p_a = R_x p_b,再构造 Kronecker 积方程组,用 SVD 解出 R_x 的九个元素,最后按约束做正交化。Park 的差异是用李群对数映射替代旋转向量,对噪声敏感度不同,OpenCV 里两个方法都能选,工程上我默认 Tsai。

这里说清一个关键问题:为什么至少需要三个以上位姿对?旋转方程实质上是三个独立约束,一个位姿对给不出足够的秩,所以最少三个位姿才可解。但三个位姿对噪声毫无抵抗,现场实际采集 15 到 20 组,是为了让旋转轴在三维空间里铺开,降低 SVD 解的局部极小风险。

3.2 环境准备与核心代码

整个求解只需要 numpy 和 opencv-python 两个包:

pip install opencv-python numpy

代码里输入两个列表:gripper_poses 是机械臂末端相对基座的位姿,cam_poses 是标定板相对相机的位姿,都保存为 4x4 齐次矩阵。示教器读出来如果是轴角加平移,先用 cv2.Rodrigues 转成 3x3 旋转矩阵再组织成 4x4。

import cv2 import numpy as np # gripper_poses: List[np.ndarray(4,4)],末端相对基座位姿,来自示教器 # cam_poses: List[np.ndarray(4,4)],标定板相对相机位姿,来自 solvePnP gripper_poses = [...] # 长度 N >= 15 cam_poses = [...] # 长度 N >= 15 R_gripper2base = np.stack([p[:3, :3] for p in gripper_poses]) t_gripper2base = np.stack([p[:3, 3] for p in gripper_poses]) R_target2cam = np.stack([p[:3, :3] for p in cam_poses]) t_target2cam = np.stack([p[:3, 3] for p in cam_poses]) R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, method=cv2.CALIB_HAND_EYE_TSAI ) X = np.eye(4) X[:3, :3] = R_cam2gripper X[:3, 3] = t_cam2gripper.flatten() print("相机在末端坐标系下的位姿 X:\n", X)

这段代码的关键在输入顺序。R_gripper2base 是末端相对基座的旋转,不管示教器显示成欧拉角还是轴角,转成旋转矩阵时手性必须和相机坐标系一致。R_target2cam 是标定板相对相机的旋转,由每个位姿的图像用 solvePnP 得到。输出 R_cam2gripper 表示相机坐标系在末端坐标系下的姿态,拼回 4x4 后就是手眼矩阵 X,单位统一用毫米。

我一般会额外把 gripper_poses 里第一个和最后一个位姿对调再跑一遍脚本,看输出 X 是否稳定,这是后面排查问题时的基线。

3.3 采集数据的参数要求

位姿质量直接决定标定结果质量,给一组现场经验参数:

参数建议值说明
位姿组数15~20少于 10 组基本不可用
相邻末端旋转15°以上太小则旋转方程病态
旋转轴覆盖至少两个不同轴不要绕同一个轴转
标定板视野占比60%以上角点外推误差大
标定板倾角与光轴夹角 20°~45°垂直正拍容易退化

这些参数不是严格数值,是经验边界。相邻旋转小于 10° 时,calibrateHandEye 解出的矩阵会在同一组数据上反复跳动;标定板占比太小,棋盘格角点亚像素精度再高也会被外推误差吞掉。

3.4 标定结果自检:残差怎么算

拿到 X 之后别急着接激光器,先把残差算出来:

residuals = [] for i in range(len(gripper_poses) - 1): A = np.linalg.inv(gripper_poses[i]) @ gripper_poses[i + 1] B = cam_poses[i] @ np.linalg.inv(cam_poses[i + 1]) err = np.linalg.norm(A @ X - X @ B, ord="fro") residuals.append(err) print("mean:", np.mean(residuals), "max:", np.max(residuals))

这里 A 的构造方式是相邻末端位姿求逆后相乘,B 是相邻相机位姿直接相除,两者的物理含义是同一个刚体运动在两个坐标系下的描述。残差是方程左右两边矩阵差的 Frobenius 范数,量纲接近毫米。残差压不进 1mm 以内,基本可以确定是采集数据有问题,先回去补位姿,不要继续做激光平面。

4. 激光平面与手眼一起标:标定板设计、采图策略和求解顺序

4.1 标定板与现场布置:漫反射表面和控制曝光

分体式线激光标定用的标定板要同时承担两个任务:给棋盘格角点解算位姿,给激光条纹提供已知平面。标定板不能选镜面不锈钢或高光铝板,激光条打上去产生高亮点,条纹中心提取直接崩。优先选陶瓷基板加印刷棋盘格,表面漫反射强、温度稳定性好,也不会像亚克力那样受热变形。

现场有两个容易被忽视的调节点。第一个是激光功率,能调就尽量调低,只要相机图像里条纹对比度足够就行,不必追求亮到发白。第二个是相机曝光和增益,固定曝光时间,不要开自动曝光,否则每张图的条纹宽度和灰度响应都不一样,灰度重心法提取的中心位置会系统性偏移。我习惯先把相机设为固定曝光,增益压到最低,再用激光功率去凑亮度。

标定板摆位要复现真实扫描范围:激光器装好后,标高、中、低三个高度,水平方向左中右三个位置,覆盖点云扫描区域至少三分之二的范围。如果只在机械臂旁边随便放几块位置,标定结果小范围自洽,大范围扫描时偏差会以厘米级暴露出来。

4.2 采图策略:九宫格加姿态微调

采集位姿的策略直接影响第 3 章的稳定性,我在现场固定的套路是九宫格加姿态微调。先把标定板放在九个位置,每个位置让末端从基准姿态绕 X、Y、Z 轴各偏约 15°,得到九组基础数据。再在最高和最低两个位置额外加两组大角度俯仰,凑到 20 组左右。

配置项设定
位置网格水平 3 x 垂直 3
每处姿态绕末端 X/Y/Z 各偏 15°
附加位姿最高、最低位置各加大俯仰 25°
总计18~20 组

每组保存四样信息:原始图像、当前末端位姿 4x4、曝光参数、采集时间戳。位姿保存完整是为了排查时能回放,别只存一个编号,事后没法定位问题环节。

采集时有一个容易忽略的细节:每移动到一个新姿态,等机械臂停稳 1 秒再拍照,避免惯性抖动造成图像模糊。激光器如果是间歇出光,还要确保采集瞬间激光已经点亮。

4.3 激光条纹提取与平面拟合代码

分体式方案要做两段图像处理。第一段是找棋盘格角点,直接调 cv2.findChessboardCorners 加 solvePnP 得到 rvec、tvec。第二段是提取激光条纹中心,我常用灰度重心法,逐列计算条纹在列方向的灰度加权中心:

def laser_center(gray, roi, thresh=160): x0, x1, y0, y1 = roi pts = [] for col in range(x0, x1): seg = gray[y0:y1, col].astype(np.float32) if seg.max() - seg.min() < thresh: continue seg = seg - seg.min() s = seg.sum() if s < 1.0: continue yc = float((np.arange(y0, y1) * seg).sum() / s) pts.append((col, yc)) return np.array(pts)

灰度重心法比直接找最大值点有两点优势:一是抗噪声,二是能达到亚像素精度。thresh 取激光亮度和背景亮度的中间值,如果背景有环境光干扰,先把 ROI 裁到激光线附近,避免把边缘高光算进去。ROI 可以手选一次,也可以粗阈值自动找激光亮带后取外接框,现场手选更省事。

拿到条纹像素点后,要把它投影到标定板平面上,得到相机坐标系下的三维点。标定板位姿由 solvePnP 给出,标定板平面在相机坐标系下就是 z=0 平面经过 R、t 变换后的平面:

def project_to_board(pts2d, K, dist, rvec, tvec): R, _ = cv2.Rodrigues(rvec) t = tvec.flatten() normal = R[:, 2] # 板平面法向量在相机系下的表达 pts3d = [] for (u, v) in pts2d: uv_norm = cv2.undistortPoints( np.array([[[u, v]]], dtype=np.float32), K, dist ).reshape(-1, 2)[0] ray = np.array([uv_norm[0], uv_norm[1], 1.0]) denom = normal @ ray if abs(denom) < 1e-8: continue lam = (normal @ t) / denom # 射线与平面交点参数 pts3d.append(lam * ray) return np.array(pts3d)

这里的关键推导是射线与平面求交:平面法向量是旋转矩阵第三列,平面过点就是标定板坐标系原点在相机系下的位置,也就是平移向量 t。射线方向由去畸变后的归一化像素坐标得出,交点参数 lam 就是射线参数方程里的缩放系数。得到的每一个三维点同时满足「在相机射线上」和「在标定板平面上」,误差小,适合做平面拟合。

所有姿态的激光三维点收集完后,用特征值法拟合激光平面:

def fit_plane(points): centroid = points.mean(axis=0) _, _, vh = np.linalg.svd(points - centroid, full_matrices=False) normal = vh[2] # 最小奇异值对应的右奇异向量 d = -np.dot(normal, centroid) return normal, d

SVD 拟合平面的原理不复杂:点集去质心后的协方差矩阵,最小特征值对应的特征向量就是法向。注意正常的光平面拟合残差应该在亚毫米级,如果超过 0.5mm,大概率是条纹提取偏了或标定板平面解算不准,先回头修图像那一步。

4.4 把两个标定结果装进同一坐标链

激光平面拟合完成后,分体式标定的完整输出有两个:手眼矩阵 X(相机在末端下)和平面的法向 n、截距 d(相机坐标系下)。实际测量时,像素点 p 先与激光平面求交得到相机系三维点 P_cam,再通过坐标变换进机器人基座:

P_base = T_base2end * X * P_cam

这个链里的 T_base2end 是机械臂实时给出的末端位姿。如果线激光装在外置轴或龙门架上,把链替换成对应运动学,本质不变。一体式轮廓传感器只需要把输出轮廓转成传感器坐标系三维点,再乘 X 和 T_base2end,过程更短。

到这里标定流程就走完了。如果精度要求高,我会把手眼 X 和平面参数当成初值,把所有姿态下激光点在标定板平面上的综合重投影误差做成目标函数,做一轮非线性优化。联合优化的坑在目标函数里同时有旋转参数和平移参数,量纲不同,必要时给旋转加权重。日常项目分开标定加上 3.4 节残差验证已经够用。

5. 手眼标定避坑记录:五个现场踩过的坑和修正方法

这五个坑是我在多个线激光项目里反复遇到过的,按发生率从高到低排。它们不看残差大小,只在实际扫描时现原形。

5.1 残差很漂亮,实测拼接差 0.5mm:小范围标定外推到大范围扫描

现象:标定残差平均值 0.6mm 以内,看起来一切正常,机械臂把线激光带到工件另一端扫描,点云错位直接到 1mm 以上。

原因:采集位姿集中在机械臂工作范围的一小块区域,手眼矩阵对这一小块附近是过拟合的。离采集区域越远,坐标链的偏差被末端位姿的平移差放大得越明显,残差只反映采集数据本身的自洽,不反映绝对精度。

解决:重新采集时让标定板覆盖实际扫描空间的高、中、低三层,并且保证最远采集点和最近采集点之间的末端位姿差异足够大。判断标准是标定完成后,把采集位姿按时间顺序画出来,看末端位置在三维空间里是否铺开。补采后重算 X,把 X 应用到远端点云,标准块往返差降到指标内才算通过。

5.2 位姿几乎绕同一根轴:旋转病态,结果每次跑都不一样

现象:同一批图像和位姿,连续运行两次标定脚本,旋转部分差 1° 以上,平移部分也跟着跳。

原因:所有位姿都在绕同一个旋转轴转,旋转向量方程缺秩,SVD 解在局部极小附近跳动,这是典型的病态问题。

解决:采集时强制让末端绕三个不同轴分别转,控制每组相邻位姿的旋转角度大于 15°。我一般会在采集完立刻写一段检查代码,计算每组相邻位姿旋转角度,低于 15° 的直接提示补采。修好后连续跑两次脚本,旋转部分差应小于 0.1°。

5.3 激光线过曝:条纹中心系统性偏移,不是随机噪声

现象:平面拟合残差小,但做标准块验证时高度有一致的 0.2mm 偏置,换一个位置还是偏同一个方向。

原因:激光线过曝后 CCD 出现亮斑饱和,灰度重心被拉向饱和区边缘,形成系统性偏移。这不是随机噪声,加再多帧平均也消不掉。

解决:降低激光功率或加装中性密度滤光片,保证条纹峰值灰度在 180 到 220 之间,不要顶到 255。再固定相机曝光,重新做一次条纹提取,确认每一条激光线的重心在列方向平滑变化,没有阶梯状跳变。

5.4 换了工具坐标系:标定结果全部作废

现象:标定完一周,现场说机器人撞过或换过末端夹爪,重新走了几圈,点云整体错位。

原因:手眼矩阵是相机坐标系到末端坐标系的固定变换,但末端坐标系本身由工具坐标系约定。工具坐标系一变,T_base2end 就变了,X 必废。

解决:标定文档必须和当时的机器人工作目录、工具坐标系编号绑定记录。以后每次更换工具坐标、末端夹具、法兰盘,都强制把信息写进标定台账。哪怕只是改了一个 TCP 数值,也要重新走一遍验证流程再上线。

5.5 眼在手上和眼在手外公式用反:出现镜像矩阵

现象:标定结果 X 的旋转部分接近单位阵,但手眼变换后激光点云的左右方向整体反了,像照镜子。

原因:A、B 的构造顺序和公式约定不一致。眼在手上方程是 A_i X = X B_i,如果把同一位姿对的 A_i、B_i 构造方向写反,解出的 X 会变成镜像解。这类问题残差看不出,镜像解能同样满足残差等式。

解决:完成 X 解算后,先用环境里一个固定标准点,让机器人运动到两个姿态分别测量,判断 X 是否物理一致。脚本里加一步可视化校验,把标定板角点通过 X 投影到机器人坐标系,看是否与实际位置重合。镜像解在这个校验下会直接暴露。

6. 最终验证三板斧:标准块往返、数据回放和一份检查清单

标定结果从数值上说得过去不等于现场能用,我的习惯是上机前先做三次硬校验。第一次是标准块往返,找一块已知高度和形状的量块放在扫描范围内,机器人带激光用两个差异较大的姿态扫同一个横截面,把两条轮廓统一变换到基座坐标系后做差,高度差应小于项目公差的三分之一。第二次是数据回放,把标定时保存的图像和位姿再喂回标定脚本,确认结果能稳定复现,顺手检查激光平面的法向与标定板平面是否相交成预期角度。第三次是覆盖验证,机械臂走到工作空间最远端靠近边界的位置,再扫一次标准块,看这个位置是否和中央位置一致。

校验项通过判据失败时先查
标定残差平均小于 1mm位姿分布、角点精度
标准块往返高度差小于公差三分之一条纹提取、激光平面
数据回放两次 X 旋转差不大于 0.1°位姿是否共轴、数据是否完整
边界覆盖远端与中央一致采集范围是否过小

这三板斧做完,我才会把手眼矩阵和激光平面烧进现场的测量程序。从那以后,每次线激光器手眼标定,我都强制自己先摆好标准块再动手采数据,残差再漂亮也不许跳步验证,要么当场发现问题,要么等着现场返工后悔。希望这套流程帮你也少走几个弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询