简介:面向ROS机器人开发者与视觉伺服研究者,这份资料围绕YOLOv5目标识别、MoveIt动作规划与Gazebo仿真环境,提供eye-in-hand视觉伺服系统的完整工程实现。方案将相机安装于机械臂末端,通过YOLOv5实时检测目标位置,再交由MoveIt生成运动轨迹,并在Gazebo中完成物理仿真验证,适合学习计算机视觉与机器人控制融合的实践项目。
资源包共105个文件,压缩后8.02MB。其中20个launch文件用于启动系统与仿真节点,19个xml和14个yaml定义模型、参数与配置,13个stl和8个xacro构成机械臂与场景模型,另有12个json、6个python脚本及rviz、srv等辅助文件,结构清晰,便于按功能模块查阅。
目前已有89人学习。通过该资源可以获取项目源码、仿真场景配置及视觉伺服实现流程,快速复现“识别-规划-控制”闭环;包内还包含说明文档与演示幻灯片,有助于理解各模块之间的数据传递与调试思路,适合作为课程设计或科研入门的参考范例。
1. 一套能跑通的 eye-in-hand 视觉伺服,为什么非要用仿真先试
把 yolov5 目标识别、moveit 动作规划、gazebo 仿真拼成一个 eye-in-hand 视觉伺服(Image-Based)系统,很多人的第一反应不是报错,而是机械臂追着目标跑,目标一但偏离图像中心就飞出去,最后整个系统在仿真里翻车。真正的问题往往不在某个单独模块,而在「图像特征 → 末端速度 → 关节运动」这条链路的符号、延迟和限幅上。用 gazebo 仿真,可以在不碰真实设备、不承担撞机风险的前提下,把这条链路的每个环节拆开验证,也能提前暴露相机曝光、手眼外参、规划状态同步这类实机才有的坑。这套方案适合有 ROS 2 基础、想把目标检测和机械臂运动控制真正闭环起来的开发者,也适合做视觉伺服课题但不想反复折腾真机调试的人。仿真是成本最低的试错场,前提是你要知道每一步在算什么。
2. 视觉伺服的原理与系统架构:先从误差定义说起
2.1 Image-Based 视觉伺服在做什么:误差定义与闭环逻辑
视觉伺服不是让机械臂走到一个预设的笛卡尔位姿,而是让「图像里的某个特征」到达期望位置。以 eye-in-hand 相机拍到的目标物体为例,选图像特征为目标的像素中心 (u, v) 和包围框面积 s,期望特征记为 (u_d, v_d, s_d),当前特征由 yolov5 检测得到。误差向量就是:
e = [u - u_d, v - v_d, s - s_d]
闭环逻辑是:图像误差 e 通过伺服律计算末端速度指令,机械臂运动后相机位姿改变,图像特征随之变化,直到误差收敛到零。常见伺服律是 v = -λ · J⁺ · e,其中 J 是图像雅可比矩阵,J⁺ 是它的伪逆,λ 是增益。这里的 v 是相机坐标系下的末端速度,方向与误差方向相反,所以公式里有个负号。仿真里最容易翻车的地方就在这个负号上——符号反了,机械臂不是去追目标,而是把目标推出视野。
Image-Based 与 Position-Based 的差别在于:后者要先从图像估计目标的 6D 位姿,再规划位姿误差;前者直接拿图像特征做控制,省掉位姿估计这一步。在 gazebo 仿真里,相机模型通常带噪声,目标也可能被部分遮挡,IBVS 对这类误差更宽容,因为控制量直接来自图像,不经过姿态求解的中间环节。代价是它需要一个较为准确的深度信息来归一化图像雅可比,否则收敛性和稳态精度都会变差。
2.2 eye-in-hand 与 eye-to-hand:为什么仿真里选 eye-in-hand
eye-in-hand 相机装在机械臂末端,跟着末端一起动;eye-to-hand 相机固定在环境某处。标题定了 eye-in-hand,选它的理由在仿真里也很明确:目标在运动过程中始终处于相机视野内,特征分辨率高,且末端运动与图像变化的几何关系更直观。对 IBVS 来说,相机系下的一阶运动模型比固定相机简单,图像雅可比不需要额外乘以一个与机械臂结构相关的观察矩阵。
eye-in-hand 的缺点是目标容易出视野,所以伺服律里必须加约束和限幅,这在仿真里恰好可以反复测试。仿真环境里建立 eye-in-hand 系统非常直接:在机械臂末端 link 上挂一个 camera sensor 就行,不需要考虑真实安装的机械干涉和线缆。想验证不同相机安装角度对伺服收敛的影响,也只需要改一下相机 link 的坐标偏移。
还有一点值得注意:eye-in-hand 的手眼标定(camera 到 end-effector 的外参 T_c_e)在仿真里是可以拿到精确真值的,这在实机上很难获得。先用仿真里的精确外参把伺服逻辑跑通,再换成带误差的外参,就能测试出系统对外参误差的敏感度。这个测试顺序是我最推荐的,能帮你区分「控制律不对」和「标定不准」这两类问题。
2.3 坐标系与数据流:从像素误差到关节速度的变换链
整套系统涉及四个坐标系:图像像素系、相机系、末端系、基座系。像素坐标 p = (u, v) 与相机系坐标的关系由内参矩阵 K 决定;相机系到末端系由手眼外参 T_c_e 决定;末端系到基座系由运动学正解 T_b_e 决定。视觉伺服有两种实现路径,你需要先想清楚走哪条:
第一类是纯视觉伺服,误差直接在相机系里计算,速度指令也发到相机坐标系,由底层控制器转换到关节速度。第二类是把图像误差先转换成末端位姿增量,再用 moveit 做一次位姿规划。前者的控制频率一般在 20 Hz 以上,适合快速收敛;后者规划一次可能耗时几百毫秒,适合作为粗定位阶段的引导。常见做法是两者结合:先用 moveit 规划到初始观测位姿,让目标进入视野,然后再启动视觉伺服闭环。
数据流是这样的:gazebo 相机发布图像话题 → yolov5 检测节点推理并提取特征 → 伺服节点计算速度指令 → 速度指令传给 moveit servo 或底层速度控制器 → ros_control 把速度指令转成关节力矩 → gazebo 物理引擎更新机械臂位姿 → 相机图像变化。这条链路里,每一步的话题名称、坐标系、更新率都可能成为问题源头。建议先画一张数据流图再写代码,后面排查会快很多。
3. gazebo 仿真环境搭建:机械臂、末端相机与控制器
3.1 以常见六轴机械臂模型为例准备 URDF 与 xacro
搭建这套仿真环境,我一般用常见的 panda 机械臂模型作为示例,因为它在 gazebo 下的控制器支持比较完善,URDF 也相对完整。如果你的模型是其他六轴臂,同样的做法也适用,只是关节名和连杆名需要替换。在 URDF 里加 eye-in-hand 相机,最干净的做法是写一个 xacro 宏,避免把相机参数散落到各个文件里。
先给机器人加一个末端相机 link,并指定坐标系朝向,让相机光轴指向工作台方向。参考如下 xacro 片段:
<!-- eye-in-hand 相机宏 --> <xacro:macro name="hand_camera"> <link name="hand_camera_link"> <visual> <origin xyz="0 0 0" rpy="0 0 0"/> <geometry> <mesh filename="package://my_robot/meshes/camera.dae" scale="0.01 0.01 0.01"/> </geometry> </visual> <inertial> <mass value="0.05"/> <inertia ixx="1e-5" iyy="1e-5" izz="1e-5" ixy="0" ixz="0" iyz="0"/> </inertial> </link> <joint name="hand_camera_joint" type="fixed"> <origin xyz="0.05 0 0.03" rpy="0 0 0"/> <parent link="tool0"/> <child link="hand_camera_link"/> </joint> </xacro:macro>origin 里的 xyz 是相机相对末端法兰的安装偏移,rpy 决定光轴朝向。这里把相机装在 tool0 的 Z 轴前方 5 cm、上方 3 cm 处,光轴默认沿 X 轴正方向。如果你的目标物体在机械臂下方,需要把 rpy 改成绕 Y 轴旋转约 -90 度,让相机朝下看。这个安装偏移就是手眼外参 T_c_e 在模型层面的定义,它和后面视觉伺服里用的外参必须保持一致,很多奇奇怪怪的收敛失败都源于这里对不上。
3.2 末端相机与目标物体的 gazebo 配置:camera plugin 的关键参数
光有 link 还不行,gazebo 里要有 sensor 才会发布图像话题。给 hand_camera_link 挂一个 camera sensor,参数里最重要的几个是分辨率、更新率和噪声。分辨率决定 yolov5 推理的像素输入质量,更新率决定视觉伺服的控制频率上限,噪声决定检测框的抖动程度。
<gazebo reference="hand_camera_link"> <sensor name="hand_camera" type="camera"> <always_on>1</always_on> <update_rate>30</update_rate> <camera_name>hand_camera</camera_name> <image> <width>640</width> <height>480</height> <format>R8G8B8</format> </image> <distortion> <k1>0</k1> <k2>0</k2> <k3>0</k3> <p1>0</p1> <p2>0</p2> </distortion> <noise> <type>gaussian</type> <mean>0</mean> <stddev>0.007</stddev> </noise> </sensor> </gazebo>update_rate 设为 30 Hz,视觉伺服的控制闭环通常跑 20 Hz 左右,留出余量避免图像积压。噪声 stddev 设为 0.007,这是模仿真实相机传感器噪声的经验值,不要设成 0,否则仿真图像太干净,测试出来的检测稳定性到了实机完全不可信。distortion 系数全设 0 可以,因为 yolov5 对轻微镜头畸变不敏感,但如果你想验证相机标定对伺服的影响,再慢慢加 k1、k2。
目标物体模型放置时要注意贴图和材质:高反光材质在相机视角下会产生过曝区域,检测框会抖;纯色无纹理的物体又容易让检测器过拟合背景。常见做法是给目标物体贴上纹理贴图,材质参数里把 specular 调低,漫反射保持适中。这样 gazebo 渲染出来的图像接近真实场景,yolov5 在仿真里学到的特征才有迁移价值。
3.3 ros_control 控制器与话题检查:动起来的最小命令
gazebo 里机械臂要动起来,靠的是 ros_control 接口。URDF 里需要加 gazebo_ros2_control 插件,并给每个驱动关节配置 role 为 joint。控制器一般需要两类:joint_state_broadcaster 用来发布关节状态,joint_trajectory_controller 用来接收 moveit 的轨迹。伺服闭环阶段还需要一个 accepts velocity command 的控制器,很多 ros_control 控制器组合里会用 velocity controller 或者 effort controller 配合前馈。
启动完 launch 文件后,先做三件事检查,不要急着跑视觉伺服:
ros2 controller list ros2 controller list --configured ros2 topic hz /joint_states第一行看控制器有没有被 spawn,第二行看是否 active,第三行确认关节状态在实时发布。如果 /joint_states 的 hz 是 0 或者长时间没有更新,说明控制器没有激活,moveit 拿到的是静止的状态数据,规划出来必然和你看到的不一致。这一步是最容易跳过的,但也是后面 moveit 与 gazebo 同步问题的根源。
4. yolov5 目标识别:从模型训练到图像特征提取
4.1 用自己的目标物体制作训练数据集:仿真截图与标注流程
标题里的 yolov5 要识别什么目标,取决于你的伺服任务。如果你只想验证伺服算法,那么要吸引注意识别一个简单的物体,比如彩色方块、圆柱或球体。数据集从哪里来?我见过不少人在网上找现成的数据集,但仿真里的渲染图像和真实照片差异很大,直接迁移会降低检测率。更好的做法是在 gazebo 里自己生成数据集:把机械臂末端相机或者一个固定相机对准目标,在多个角度、距离、光照条件下截图,收集几百到一千张图就够了。
标注格式用 YOLO 的 txt 格式:每个图像文件对应一个同名 txt,每行是 class_id x_center y_center width height,坐标值归一化到 0~1。如果你做过交通标志识别这类 yolov5 项目,流程几乎是同一套,差别只在数据集和类别数。标注工具用常见图形标注工具就行,标注时注意框要贴合目标主体,不要把阴影包进框里。阴影边界在仿真里很锐利,检测器会把阴影当特征学进去,伺服时目标一动阴影变化,检测框中心就会偏。
4.2 训练与超参数调整:影响伺服稳定性的三个超参数
yolov5 的自定义训练命令并不复杂,重点是超参数。默认的 hyp.scratch.yaml 里有一组基于 COCO 调出来的参数,直接用于你的小数据集往往过拟合或者收敛不稳。伺服场景里我最关注三个超参数:
第一是 mosaic 数据增强。默认在训练前 70% 的 epoch 里开启,它对提升泛化能力很有帮助,但代价是目标的尺度分布被人为放大,对伺服这种对中心坐标精度敏感的任务来说,关闭或调 low 能让模型更适应目标在固定工作距离下的真实尺寸。第二是学习率 lr0,数据集只有几百张时,用默认 lr0=0.01 容易震荡,我会降到 0.005 以下。第三是 imgsz,训练和推理都用 320 而不是 640,伺服任务里目标在图像中占比通常较大,320 输入能明显降低推理延迟,对控制闭环的帮助远大于那一点 mAP 的提升。
训练自己的数据集,命令参考:
python train.py --data my_data.yaml --weights yolov5s.pt --img 320 \ --epochs 150 --batch-size 16 --hyp hyp.scratch.yaml --project runs/trainmy_data.yaml 里写明 train/val 路径和 nc 类别数。训练结束后,把 runs/train/exp/weights/best.pt 拷贝到你的推理节点目录,后面加载模型时就用这个权重。150 轮对小数据集足够,再多就会过拟合到仿真纹理上,换个光照就失效。
4.3 检测结果转图像特征:中心坐标与面积的实时计算
yolov5 输出的原始检测结果是坐标框,但视觉伺服需要的不是框,而是可用的标量特征。我通常取三个:目标中心的 u、v 像素坐标,以及包围框面积 s。s 可以用宽乘高,也可以用面积,用宽乘高对深度变化的响应更平滑。
推理节点在 ROS 2 里的代码骨架如下:
# 视觉伺服特征提取节点:订阅相机图像,发布 [u, v, area] import rclpy import torch import numpy as np from rclpy.node import Node from sensor_msgs.msg import Image from std_msgs.msg import Float32MultiArray from cv_bridge import CvBridge class YoloFeatureNode(Node): def __init__(self): super().__init__('yolo_feature_node') self.bridge = CvBridge() # 加载自定义训练权重 self.model = torch.hub.load('ultralytics/yolov5', 'custom', path='best.pt', force_reload=False) self.model.conf = 0.35 # 置信度阈值 self.model.iou = 0.45 # NMS 阈值 self.model.classes = [0] # 只识别目标物体类别 self.sub = self.create_subscription(Image, '/hand_camera/image_raw', self.callback, 1) self.pub = self.create_publisher(Float32MultiArray, '/servo/feature', 1) def callback(self, msg): img = self.bridge.imgmsg_to_cv2(msg, 'bgr8') results = self.model(img, size=320) # 用小尺寸推理降延迟 det = results.pandas().xyxy[0] if len(det): row = det.iloc[0] # 取置信度最高的目标 u = (row['xmin'] + row['xmax']) / 2.0 v = (row['ymin'] + row['ymax']) / 2.0 s = (row['xmax'] - row['xmin']) * (row['ymax'] - row['ymin']) self.pub.publish(Float32MultiArray( data=[float(u), float(v), float(s)])) def main(): rclpy.init() node = YoloFeatureNode() rclpy.spin(node) if __name__ == '__main__': main()关键点在 det.iloc[0] 取的是 pandas DataFrame 里按置信度排序后的第一条,也就是当前图像里最可靠的目标。如果场景里出现多个同类物体,你需要加一个基于像素坐标的筛选逻辑,比如选择离上次目标最近的。还有一个容易被忽略的问题:模型在 ROS 2 节点里用 torch.hub.load 加载,每次节点启动都要检查本地缓存,如果目标机上没有网络,缓存缺失会导致启动失败。提前把 weights 下载到本地目录,然后用 path 直接加载就避免了这个问题。
5. moveit 动作规划与视觉伺服闭环:把误差变成运动
5.1 moveit2 与 gazebo 同步:状态同步与规划场景更新
moveit 规划出一个轨迹的前提是它知道机械臂当前在哪。moveit2 的规划场景从 /joint_states 获取关节状态,然后通过 robot_state_publisher 维护 TF。gazebo 里如果没有控制器发布 joint_states,moveit 规划用的还是初始位形,规划结果在 gazebo 里执行时机械臂就会从一个错误的状态开始跳变。这是「moveit2 的 rviz 与 gazebo 不同步」最常见的现象:rviz 里规划很漂亮,一执行机械臂就抽风。
同步检查就那么几步,但很多人漏掉。启动后先确认 joint_state_broadcaster active,再确认 /joint_states 有数据,最后在 rviz 里看机械臂模型是否与 gazebo 里的位姿一致。如果 rviz 里模型一直保持初始姿势不变,说明 TF 树不完整,常见原因是 robot_state_publisher 没有正确加载 xacro,或者 fixed frame 设错了。
5.2 伺服闭环节点:从图像特征到速度指令的代码骨架
闭环节点是整套系统的心脏。它订阅第 4 章发布的 /servo/feature,算出与期望特征的误差,再转换成速度指令发布到 moveit servo 对应的话题。这里的速度指令是相机坐标系下的 TwistStamped,frame_id 设为 hand_camera_link。手动模式下用速度控制,比每步调 moveit 规划一次要顺滑得多,规划开销也小很多。
# IBVS 视觉伺服节点:订阅图像特征,发布末端速度指令 import numpy as np import rclpy from rclpy.node import Node from std_msgs.msg import Float32MultiArray from geometry_msgs.msg import TwistStamped class IbvsNode(Node): def __init__(self): super().__init__('ibvs_node') self.sub = self.create_subscription( Float32MultiArray, '/servo/feature', self.feat_cb, 1) self.pub = self.create_publisher( TwistStamped, '/servo/cmd_vel', 1) # 期望特征:目标中心在图像中心,面积达到设定值 self.u_d, self.v_d = 320.0, 240.0 self.s_d = 30000.0 self.lambda_ = 0.3 # 伺服增益 self.max_vel = 0.15 # 末端最大线速度,单位 m/s self.feat = None def feat_cb(self, msg): self.feat = np.array(msg.data) # [u, v, area] def control_loop(self): while rclpy.ok(): if self.feat is None: continue e = np.array([self.feat[0] - self.u_d, self.feat[1] - self.v_d, self.feat[2] - self.s_d]) # 归一化像素误差,方向是让目标回到期望位置 twist = -self.lambda_ * e / (np.linalg.norm(e) + 1e-6) twist = np.clip(twist, -self.max_vel, self.max_vel) msg = TwistStamped() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'hand_camera_link' msg.twist.linear.x = float(twist[0]) msg.twist.linear.y = float(twist[1]) msg.twist.linear.z = float(twist[2]) self.pub.publish(msg) self.get_logger().info(f'err: {e[0]:.1f} {e[1]:.1f} {e[2]:.1f}') def main(): rclpy.init() node = IbvsNode() node.control_loop() if __name__ == '__main__': main()这个节点做的是三自由度伺服:两个平移自由度控制目标中心到图像中心,一个平移自由度控制相机沿光轴靠近目标让面积达到期望值。速度方向算出来是在相机系下的,如果机械臂要执行,可以交给 moveit servo 按 body frame 解释,或者在控制器里根据当前运动学转换到关节速度。视线方向速度直接映射到工具 Z 轴,符号需要根据你的相机安装方向确认,这是最容易出错的地方,务必先用 0.05 这样的小增益开环测试一次。
5.3 伺服参数表与调参顺序:增益、频率、限幅
伺服参数直接影响收敛曲线,抄代码之前先把参数表过一遍。下表是我常用的初始值:
| 参数 | 初始值 | 说明 |
|---|---|---|
| lambda(增益) | 0.3 | 太小收敛慢,太大会振荡 |
| max_vel(线速度限幅) | 0.15 m/s | 防止机械臂猛冲 |
| 控制频率 | 20 Hz | 与 yolov5 推理帧率匹配 |
| 期望面积 s_d | 30000 | 根据实际目标尺寸调整 |
| 目标中心死区 | 5 px | 误差小于死区就停止伺服 |
调参顺序也有讲究。第一步是验符号,用一个固定误差手动发一条速度指令,确认机械臂运动方向能缩小误差而不是扩大。第二步是纯比例控制,lambda 从 0.1 开始,观察误差是否单调下降,出现振荡就减半。第三步才是加积分,面积误差因为有深度耦合,往往存在稳态误差,加一个小的积分项能消除,但积分增益不要超过比例的十分之一。最后才是调限幅,限幅不是越大越好,它决定的是机械臂在目标突然丢失时的行为,限幅过大,目标重新出现时机械臂已经在很远的位置了。
6. 视觉伺服避坑与收敛验证:仿真里翻车最频繁的几个环节
6.1 图像发黑导致检测率骤降
现象:gazebo 里相机图像正常显示,但 yolov5 的检测框时有时无,目标稍远就完全检测不到。原因:默认场景光照强度不够,或者目标朝向相机的面正好背光,图像整体偏暗,检测器在低曝光下的特征响应变差。解决:在 world 文件里加一个强度足够的方向光,或者调整相机 sensor 的曝光参数。我一般会把场景光照从默认的 0.5 提到 0.9,再观察检测置信度变化。还有一个习惯:训练数据里刻意加入随机亮度和对比度增强,这样检测器对光照变化的鲁棒性会好很多。
6.2 目标飞出视野,机械臂发散
现象:伺服启动后机械臂越动越快,目标从图像边缘飞走,再也没回来。原因:增益 lambda 太大,像素误差到速度的映射没有限幅,机械臂在小误差时输出速度就已经很猛;另一个常见原因是速度指令符号配反了,本来该减小误差,结果在放大误差。解决:先开环验证符号,再把 lambda 降到 0.1 量级,同时加 max_vel 限幅。血泪经验是,代码里看起来越简单的那一行负号,越值得先测试再跑闭环。
6.3 rviz 与 gazebo 机械臂状态不同步
现象:moveit 在 rviz 里规划的轨迹很标准,但 gazebo 里机械臂执行时直接跳变或乱动。原因:joint_states 没有从 gazebo 发布出来,moveit 的规划场景一直停留在初始位形。解决:先查 controller list 确认 joint_state_broadcaster 在 active 状态,再查 /joint_states 话题频率。这类问题 80% 是控制器 spawn 顺序错误,launch 文件里必须先启动 gazebo 和 ros_control,再启动 moveit,否则 moveit 拿到的状态是空数据。
6.4 误差收敛但不为零,停在某个像素区域
现象:目标中心停在距离期望位置几十像素的地方不动了,误差不再减小。原因:深度 Z 估计得不准导致图像雅可比归一化偏差,或者检测框的中心与实际目标重心不重合(比如目标有遮挡、阴影包含在框内)。解决:在仿真里用真值深度替代估计深度排除问题,如果误差消除说明是深度估计问题;如果还在,就检查标注框是否严格贴合目标主体。还有一个容易忽略的点:期望特征 s_d 设得比相机能达到的最大面积还大,伺服会一直推着机械臂贴近目标直到碰撞,这种不收敛本质上是期望设定不合理。
6.5 验证伺服收敛的四类指标
视觉伺服是否真的收敛,不要只凭眼睛看机械臂停没停。我会记录四个指标:误差下降曲线、目标在图像中的像素占比、收敛时间、稳态误差。误差曲线应当单调下降并有明显指数衰减的趋势;目标像素占比反映整个过程中目标是否稳定留在视野内,低于阈值就说明伺服轨迹规划有缺陷;收敛时间设定一个硬上限,仿真里超过 5 秒不收敛就意味着控制律参数不对;稳态误差则对应伺服精度的最终水平。把这四个指标记录下来,每次改参数都有对比依据。等仿真稳定之后,如果想进一步优化增益调度,也可以考虑把策略交给强化学习去学,gazebo 的物理环境与 RL 训练接口本来就适配得不错。
第一次调这套系统时,我把 lambda 直接调到 0.8,结果目标三秒内飞出视野,追都追不回来。后来总结成一句话:先验符号、再调增益、最后加限幅,顺序不能乱。希望这几条避坑经验帮到你。
本文还有配套的精品资源,点击获取