我最早把yolov5接到ROS里跑通,是在一辆自制的差速小车上。当时花了一整个周末,被各种报错折磨得够呛——先是cv_bridge和系统里的OpenCV版本对不上,然后是模型在本地脚本里能正常推理,一放进ROS节点就报CUDA out of memory,最后好不容易跑起来了,帧率又低得让人崩溃。后来把整条链路彻底理清、重新封装了一遍,回头复盘才发现:这件事真正的难点根本不在深度学习部分,检测算法本身是现成的,难的是怎么把yolov5这个纯Python推理脚本,干净利落地嵌进ROS的话题通信体系,让它能持续处理相机或仿真发的图像话题,并吐出机器人真正需要的结构化检测结果。
这篇文章就围绕“ROS中集成yolov5实现目标检测”展开,从环境选型、核心节点编写、踩坑记录到自训练模型部署,完整走一遍。适合两类读者:一类是正在做ROS小车、机械臂视觉抓取或者仿真导航避障的开发者,想把深度学习检测能力加进去;另一类是已经能单独跑yolov5的脚本,但不太清楚怎么跟ROS的消息机制结合的朋友。看完你应该能自己写出一个可以实际运行的检测节点,而不是只会跑别人封装好的现成包。
1. 为什么要在ROS里集成yolov5:从“出框”到“用框”
1.1 机器人要的不是视频窗口,而是结构化数据
很多朋友在GitHub上看到yolov5的detect.py,跑通了就觉得自己“实现了目标检测”。但真放到机器人上,问题就来了:机器人不需要一个带框的视频窗口,它需要的是“前方3米有一个行人,置信度0.92”这种结构化数据。
单独跑脚本和接到ROS里,本质区别在于数据的去向。
- 单独脚本:输入一张图片文件,输出一张画了框的图片,结果到此为止。
- ROS集成:输入实时图像话题,输出检测结果话题,下游节点可以直接订阅消费。
画框只是给人看的,要让检测结果真正参与避障、导航、抓取,就必须把检测输出变成话题,让其他节点订阅。这是集成这件事最核心的价值所在——把“能检测”变成“能用检测结果做决策”。
1.2 与darknet_ros等现成封装包的取舍
如果你搜过,会发现社区里已经有darknet_ros、ros_yolov5这些现成包。它们的思路大致相同:把检测模型包在ROS节点里,订阅图像,发布结果。但实际用起来,有几个绕不开的问题。
第一,darknet_ros基于YOLOv3/v4的老模型结构,训练和部署体系跟yolov5差异很大。第二,这类包为了适配各种场景,代码往往很重,改一个类别列表都要翻半天。第三,也是最重要的一点,自己训练的yolov5权重,很难直接塞进这些封装里。
我当时的判断是:与其花时间研究别人的封装,不如自己写一个几十行的节点,把yolov5的推理封装成类,ROS部分只做话题订阅和发布。算法复杂度都在yolov5本身,ROS节点的工程复杂度其实很低。
1.3 硬件底线与模型规格的初步判断
在动手之前,先明确硬件条件,这决定了后面模型选型和性能调优的方向。yolov5官方提供了n/s/m/l/x五个规格,参数和速度差异很大。
| 模型 | 参数量 | 权重体积 | 推理速度 | 适合场景 |
|---|---|---|---|---|
| yolov5n | 1.9M | 约4MB | 最快 | Jetson Nano、树莓派、CPU |
| yolov5s | 7.2M | 约14MB | 快 | 桌面GPU、中端嵌入式板卡 |
| yolov5m | 21.2M | 约40MB | 中等 | 显存充裕的桌面GPU |
| yolov5l | 47M | 约89MB | 较慢 | 高算力服务器 |
| yolov5x | 89M | 约166MB | 慢 | 追求精度、不追求实时的场景 |
如果手头没有独立GPU,只有普通CPU,也不是完全不能跑。把推理尺寸从默认的640降到416,用yolov5n,在PC上能到两三帧的刷新率,做简单的静态检测演示是够的。但如果要做实时避障,没有GPU,体验会非常难受,这个要有心理准备。
2. 环境准备:版本匹配、安装顺序、验证方法
2.1 Ubuntu、ROS、Python三者怎么搭配
ROS集成yolov5,本质上是把Python生态的工具链嵌进ROS里,所以Python版本是否兼容、ROS版本是否匹配,决定了后续会不会踩坑。
| Ubuntu | ROS版本 | Python默认版本 | 说明 |
|---|---|---|---|
| 20.04 | Noetic(ROS1) | 3.8 | 教程最多,坑基本都被踩平了 |
| 20.04 | Foxy(ROS2) | 3.8 | ROS2,生命周期相对短 |
| 22.04 | Humble(ROS2) | 3.10 | 新项目推荐,生态越来越好 |
我的建议是:如果你只想尽快跑通集成,选20.04加Noetic,遇到问题搜索时能找到大量现成答案;如果你是在做新项目、长期维护,选22.04加Humble更合理。yolov5对Python版本的要求是3.8以上,上面三个组合都能满足。
2.2 安装ROS:手动装还是一键脚本
安装ROS这件事本身不难,难的是手动配置源、添加密钥、装一堆依赖包,对新手很容易在某个环节卡住。现在社区里不少人会用鱼香ROS的一键安装脚本,它能把ROS、ROS2、以及后面会用到的很多小工具一并装好,省去手动配置的琐碎。
装完之后有两点自己要心里有数:一是环境变量要source,装完一般会自动写入~/.bashrc,但要确认;二是后续创建工作空间、编译包的流程还是要会,不能依赖脚本一辈子。手动安装的官方步骤本质上是几个软件包加rosdep update,如果你已经装好了ROS环境,这部分可以跳过。
2.3 安装PyTorch与获取yolov5源码
ROS装好之后,需要单独准备深度学习环境。先确认显卡驱动正常,用nvidia-smi看驱动版本和CUDA version,然后根据CUDA版本安装对应版本的PyTorch。
以CUDA 11.8为例:
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu118如果是CPU环境:
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cpu国内网络环境下载PyTorch如果很慢,可以配置清华源或阿里源,但要注意PyTorch官方源的CUDA版本后缀和镜像源可能不同,推荐优先走官方源安装GPU版本。
yolov5源码的获取很简单:
git clone https://github.com/ultralytics/yolov5 cd yolov5 git checkout v6.0 # 建议锁版本,避免master分支动荡 pip3 install -r requirements.txt权重文件可以从GitHub release页面下载yolov5s.pt,几秒钟的事。下载后建议统一放在yolov5/weights目录下,后面自定义节点会频繁引用这个路径。
2.4 先用脚本验证环境,再谈集成
在写ROS节点之前,先用yolov5自带的detect.py跑一遍,确认整个环境是通的:
python3 detect.py --weights weights/yolov5s.pt --source data/images/bus.jpg能在runs/detect/exp下看到输出图片,就说明PyTorch和yolov5这边没问题了。这一步是后面所有工作的地基,这个阶段报错一定要解决完再进行下一步。
有个非常常见的报错是“AttributeError: 'Upsample' object has no attribute 'recompute_scale_factor'”,这是PyTorch版本太新和老版本yolov5代码冲突导致的。解法很简单:按requirements.txt里要求的范围装PyTorch,或者把yolov5切到较新的tag,二选一。
3. 核心实现:写一个能真正上车的ROS检测节点
3.1 消息流设计:图像进,检测结果出
整个检测节点的消息流可以用一条链路说清楚:相机节点或者仿真环境发布sensor_msgs/Image话题,检测节点订阅这个话题,通过cv_bridge把ROS图像消息转成OpenCV格式,送入yolov5推理,得到检测框、类别和置信度,组合成自定义消息发布出去,同时把画了框的图像也发布出来用于可视化。
- 订阅话题:
/usb_cam/image_raw(真实相机)或/camera/rgb/image_raw(Gazebo仿真) - 发布话题:
/yolo/detections(检测结果消息)、/yolo/visualization(可视化图像)
这样的好处是解耦:相机是谁不关心,下游任务是谁也不关心,检测节点只负责“图像进来,检测结果出去”。
3.2 图像从哪来:usb_cam与仿真相机
真实相机场景,最常用的是usb_cam驱动:
sudo apt install ros-noetic-usb-cam roslaunch usb_cam usb_cam-test.launch启动后用rostopic list确认话题名,一般是/usb_cam/image_raw。
Gazebo仿真场景,通常在URDF或SDF里给机器人加一个相机插件,话题名取决于插件的配置。常见的camera插件会发布/camera/rgb/image_raw、/camera/depth/image_raw等话题。可以用rostopic list和rostopic echo快速确认图像话题的编码格式。
3.3 把yolov5推理封装成Detector类
这是最关键的一步。yolov5官方detect.py是面向单张图片文件的,我们要把它改造成一个类,初始化时加载模型,提供一个infer(cv_image)方法供回调函数调用。核心原则是:模型只加载一次,推理无限次调用。
import sys sys.path.insert(0, '/home/user/yolov5') import cv2 import torch import numpy as np from models.experimental import attempt_load from utils.augmentations import letterbox from utils.general import non_max_suppression, scale_coords class YoloDetector: def __init__(self, weights='/home/user/yolov5/weights/yolov5s.pt', img_size=640, conf_thres=0.25, iou_thres=0.45): self.img_size = img_size self.conf_thres = conf_thres self.iou_thres = iou_thres self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') self.model = attempt_load(weights, device=self.device) self.model.eval() self.names = self.model.module.names if hasattr(self.model, 'module') else self.model.names def infer(self, cv_image): # letterbox预处理,将图像等比缩放并填充到640x640 img = letterbox(cv_image, new_shape=(self.img_size, self.img_size))[0] img = img[:, :, ::-1].transpose(2, 0, 1) # BGR转RGB,HWC转CHW img = np.ascontiguousarray(img) img = torch.from_numpy(img).to(self.device) img = img.float() / 255.0 if img.ndimension() == 3: img = img.unsqueeze(0) with torch.no_grad(): pred = self.model(img)[0] pred = non_max_suppression(pred, self.conf_thres, self.iou_thres) detections = [] if pred is not None and len(pred): for det in pred: if len(det): det[:, :4] = scale_coords(img.shape[2:], det[:, :4], cv_image.shape).round() for *xyxy, conf, cls in det: x1, y1, x2, y2 = [int(v) for v in xyxy] detections.append({ 'bbox': [x1, y1, x2, y2], 'cls': int(cls), 'label': self.names[int(cls)], 'conf': float(conf) }) return detections这段代码就是把官方detect.py的核心逻辑抽出来,变成可复用的类。值得注意的点有两个:
第一,letterbox之后,图像的长宽比例变了,检测出的坐标是在640x640坐标系里的,必须用scale_coords映射回原始图像尺寸,否则画出来的框会偏移。这个细节很多人会漏掉。
第二,sys.path.insert(0, '/home/user/yolov5')是让Python能找到models和utils这两个目录。这个方法简单有效,但要注意把路径改成你自己的yolov5仓库的实际位置。
3.4 检测结果怎么发:消息设计
检测结果需要携带的信息:类别名称、置信度、矩形框坐标、时间戳。最原生的方式是自定义消息,语义最清晰。在功能包里新建msg目录,创建BoundingBox.msg和Detections.msg:
# BoundingBox.msg Header header string label float32 confidence int32 xmin int32 ymin int32 xmax int32 ymax# Detections.msg Header header BoundingBox[] boxes然后在CMakeLists.txt里添加消息编译配置:
find_package(catkin REQUIRED COMPONENTS roscpp rospy std_msgs message_generation ) add_message_files( FILES BoundingBox.msg Detections.msg ) generate_messages( DEPENDENCIES std_msgs )编译:
catkin_make source devel/setup.bash如果觉得自定义消息麻烦,也有两个省事的备选方案。一个是直接用vision_msgs里现成的Detection2DArray,另一个是用std_msgs/String发布JSON字符串,调试阶段特别方便。实际项目里我还是建议自定义消息,编译期就能检查字段类型,下游节点不会因为字段名不匹配而报错。
3.5 完整的ROS节点实现
把上面所有部分串起来,就是一个完整节点:
#!/usr/bin/env python3 import rospy import cv2 from sensor_msgs.msg import Image from std_msgs.msg import Header from cv_bridge import CvBridge from yolo_detector import YoloDetector from detection_msgs.msg import BoundingBox, Detections class RosYoloNode: def __init__(self): rospy.init_node('ros_yolo_detect') self.bridge = CvBridge() self.detector = YoloDetector( weights='/home/user/yolov5/weights/yolov5s.pt', img_size=640, conf_thres=0.25, iou_thres=0.45 ) self.image_sub = rospy.Subscriber( '/usb_cam/image_raw', Image, self.image_cb, queue_size=1 ) self.vis_pub = rospy.Publisher('/yolo/visualization', Image, queue_size=1) self.det_pub = rospy.Publisher('/yolo/detections', Detections, queue_size=1) rospy.loginfo('YOLOv5 detection node started') def image_cb(self, msg): try: cv_img = self.bridge.imgmsg_to_cv2(msg, 'bgr8') except Exception as e: rospy.logerr(str(e)) return detections = self.detector.infer(cv_img) self.publish_detections(detections, msg.header) self.publish_visualization(cv_img, detections) def publish_detections(self, detections, header): det_msg = Detections() det_msg.header = header for det in detections: box = BoundingBox() box.header = header box.label = det['label'] box.confidence = det['conf'] box.xmin, box.ymin, box.xmax, box.ymax = det['bbox'] det_msg.boxes.append(box) self.det_pub.publish(det_msg) def publish_visualization(self, cv_img, detections): for det in detections: x1, y1, x2, y2 = det['bbox'] cv2.rectangle(cv_img, (x1, y1), (x2, y2), (0, 255, 0), 2) label = f"{det['label']} {det['conf']:.2f}" cv2.putText(cv_img, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) try: self.vis_pub.publish(self.bridge.cv2_to_imgmsg(cv_img, 'bgr8')) except Exception as e: rospy.logerr(str(e)) if __name__ == '__main__': try: RosYoloNode() rospy.spin() except rospy.ROSInterruptException: pass这里有一个非常重要的细节:订阅端的queue_size设置成1。原因是相机通常以30fps发布图像,而yolov5s在GPU上推理一帧大约需要30到50毫秒,远跟不上相机帧率。如果队列设大,会在内存里积压大量待处理的图像,造成越来越大的延迟。queue_size=1意味着只保留最新的一帧,旧帧直接丢弃,这种“憋帧处理”策略在机器人视觉里非常常用。
3.6 用launch文件统一管理
建议写一个launch文件,把相机和检测节点一起拉起来:
<launch> <node name="usb_cam" pkg="usb_cam" type="usb_cam_node" output="screen"> <param name="video_device" value="/dev/video0"/> <param name="image_width" value="640"/> <param name="image_height" value="480"/> </node> <node name="yolo_detect" pkg="your_package" type="ros_yolo_node.py" output="screen"/> </launch>启动之后,开两个终端分别验证:
rostopic echo /yolo/detections另一个终端打开可视化:
rqt_image_view /yolo/visualization看到话题里有数据、窗口里有画框的图像,整个链路就通了。
4. 实测踩坑清单:从依赖冲突到显存崩溃的完整排查链路
4.1 cv_bridge与系统OpenCV冲突
这个坑几乎每个做ROS视觉的人都会遇到,症状是:import cv_bridge时报错,或者一运行就Segmentation fault。
我当时遇到的情况是:系统里用apt装了python3-opencv,又用pip装了OpenCV,conda环境里还有一份OpenCV,三份OpenCV的ABI不同,cv_bridge在编译时链接的是系统库,运行时import的却是pip装的这个,不崩才怪。
排查链路是这样的:
- 先用
rospack find cv_bridge确认cv_bridge的安装位置; - 再用
ldd /opt/ros/noetic/lib/libcv_bridge.so | grep opencv查看它链接的OpenCV库文件路径; - 然后
python3 -c "import cv2; print(cv2.__file__)"看运行时Python加载的OpenCV路径。
对比之后就发现路径不一致。解决办法是:统一OpenCV来源。ROS1 Noetic的cv_bridge基于OpenCV 4,系统里直接用sudo apt install ros-noetic-vision-opencv装依赖,不要用pip或conda覆盖系统OpenCV。Python侧也建议使用apt版本的python3-opencv,保证跟cv_bridge用的是同一套库。
4.2 显存不足与进程崩溃
模型在单独脚本里跑得好好的,放进ROS节点就跑几步就崩,提示CUDA out of memory,这个问题当时也困扰了我很久。
原因是多方面的。第一,每次推理虽然显存占用不大,但PyTorch在初始化CUDA context时就会吃几十MB,如果节点里还有其他占显存的操作,容易撑爆小显存显卡。第二,我最初在手写板回调里把每次推理的tensor都存储了下来用于调试,没及时释放,长时间运行后显存越占越多。
解决思路:
- 模型初始化后在回调外做一次“预热推理”,用一张全黑图跑一遍,让CUDA context真正分配好,避免首次推理时卡顿或突然申请显存失败;
- 推理过程的中间tensor尽量局部化,函数返回后自动释放;
- 如果显存特别小,把img_size从640降到416,显存占用会明显下降;
- 在回调末尾调用
torch.cuda.empty_cache()可以及时回收碎片显存。
还有一个经验:不要在大循环里做torch.cuda.synchronize(),它会强制同步CPU和GPU,几乎每次都会引入毫秒级延迟。需要精确计时的时候再同步。
4.3 检测框偏移与类别错乱
检测框偏移这个问题,前面已经提过,根本原因是letterbox预处理改变了图像尺寸,而NMS之后的坐标没有映射回原始坐标系。解决办法就是在infer里调用scale_coords(img.shape[2:], det[:, :4], cv_image.shape),一个函数搞定。
类别错乱的场景则更加隐蔽:你换了自己的模型权重,但代码里用来显示类别的names列表还是COCO的80类。yolov5的模型自带names属性,从模型对象里取,不要自己在代码里硬编码一份类别列表。用self.model.names能保证和权重文件始终一致。
4.4 推理速度上不去的调优优先级
如果发现帧率不理想,按照优先级从高到低排查:
| 优先级 | 调优点 | 预期收益 | 成本 |
|---|---|---|---|
| 高 | queue_size改为1,丢帧策略 | 延迟显著下降 | 改一行 |
| 高 | img_size从640降到416 | 推理速度提升约50% | 改一个参数 |
| 中 | 换小模型(yolov5s换yolov5n) | 速度翻倍 | 改权重路径 |
| 中 | 关闭可视化、减少画框开销 | 省CPU | 注释几行 |
| 低 | 批量推理、TensorRT加速 | 吞吐量大增 | 工程复杂度高 |
实际项目中,先做前四个成本极低的优化,往往就能满足大部分场景的实时性要求了。
5. 训练自己的数据集:让检测器认识你的场景
5.1 数据采集与标注格式
yolov5官方预训练权重只能识别COCO的80类物体。你要识别水果、车牌、鸟或者某个工业零件,必须用自建数据集微调。
先说数据采集。如果是在实体机器人上,直接订阅图像话题录包:
rosbag record -O dataset.bag /usb_cam/image_raw或者用脚本把图像帧抽出来存成jpg。如果是仿真环境,直接在Gazebo里控制机器人移动,截取不同角度、不同光照的图像,采集效率反而更高。
标注工具推荐labelImg或x-anylabeling,导出格式为YOLO:
class_id x_center y_center width height注意这个坐标是归一化到0到1的,不是像素值。每张图片对应一个同名的txt文件。
目录结构如下:
dataset/ images/ train/ val/ labels/ train/ val/ data.yamldata.yaml内容:
train: /home/user/dataset/images/train val: /home/user/dataset/images/val nc: 2 names: ['person', 'dog']5.2 训练参数与结果评估
训练命令:
python3 train.py \ --data /home/user/dataset/data.yaml \ --weights weights/yolov5s.pt \ --img 640 \ --batch-size 16 \ --epochs 100几个关键参数的含义:
--img:训练时输入分辨率,越大概率越好,越小的硬件负担越低;--batch-size:显存越大可设越大,显存不足时报错会提示OOM,适当调小;--epochs:训练轮数,小数据集100轮左右基本收敛,可以配合--patience设置早停,连续若干轮没提升就自动停;--weights:用预训练权重做迁移学习,比从头训练收敛快得多。
训练结束之后,在runs/train/exp目录下能找到训练曲线、PR曲线、混淆矩阵。重点看两个指标:mAP@0.5和mAP@0.5:0.95。前者达到0.9以上说明检测效果已经不错,后者越高说明框的定位精度越好。如果mAP很低,优先检查标注有没有错漏,再检查训练集和验证集有没有数据泄露。
5.3 导出并替换模型
训练好之后,weights里有两个文件:last.pt和best.pt。best.pt是在验证集上表现最好的权重,部署首选。
把3.3节的YoloDetector里的weights路径改成best.pt,把names直接取模型的names属性,其他不需要改动。注意类别名称也变了,可视化时会自动显示成你data.yaml里定义的names。
5.4 从仿真到实体机器人的扩展思考
如果你手头是Jetson Nano这类嵌入式设备,GPU算力远不如桌面显卡,建议把模型导出成TensorRT的engine格式再部署。yolov5官方仓库提供了export.py脚本,可以把权重导出为ONNX或TensorRT:
python3 export.py --weights best.pt --include engine --device 0导出后的engine文件在Jetson上推理速度能提升好几倍。
另外,把这套检测结果用起来的思路也可以提前想:检测话题输出之后,下游可以做目标跟随、导航避障、机械臂抓取。比如在move_base的costmap里,把检测目标对应的区域标为障碍物,小车就能基于视觉做动态避障。这个话题可以再展开一篇,这里先点到为止。
最后分享一个小技巧:节点上线前,用rostopic hz /yolo/detections看一下话题频率,如果波动很大,多半是图像回调里的推理把主循环堵住了。我当时把queue_size改成1并从回调里剥离了可视化图像的编码发布,用了一个双缓冲队列,话题频率就稳定了。这套流程跑熟之后,ROS里接任何深度学习模型都是同一个套路:图像转OpenCV、模型推理、结果转ROS消息。换模型只是换权重路径和推理函数,整体架构可以一直复用。