ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

水下机器人仿真:Stonefish模拟器与ROS集成实践详解

水下机器人仿真:Stonefish模拟器与ROS集成实践详解

简介:smarc_stonefish_sims 是一套基于 Python 的石鱼生态仿真资源,压缩包以 zip 格式提供,面向海洋生物学、机器人学以及仿真开发相关研究人员,特别适合对机器人操作系统和生态模拟交叉方向感兴趣的进阶学习者。资源共包含 223 个文件,主体为 205 个 obj 三维模型,涵盖锚链、场景物体等几何资源;同时搭配 py 脚本、scn 场景文件、jpg 贴图、yaml 与 launch 配置、csv 藻类数据,以及 README、LICENSE 等说明文档,整体大小约 1.7MB,体积精简却覆盖了模型、纹理、配置和数据多个层面。项目依托 ROS Noetic 构建,在环境建模、物理模拟、生物行为建模、事件驱动编程、多线程并发处理、日志记录与数据可视化等关键知识点上均有体现,并展示了 PyBullet、Pygame 等工具在生态模拟中的实际运用方式。已有 179 人浏览学习,通过该资源可以对照藻类信息与锚链模型理解石鱼伪装、捕食、繁殖等行为规则的设计思路,也可在后续扩展仿真场景、调试 ROS 节点、验证多学科算法时作为轻量级参考框架。

1. 石鱼的世界模拟:把真机验证成本压到零的虚拟海

水下机器人调试有个共识:真机下水一次很贵,一根密封圈老化就可能让半天工作白费。smarc_stonefish_sims 这份资源把 Stonefish 模拟器变成了一片可以反复折腾的虚拟海——里面预置了水下世界、载具模型和传感器配置,你在 Python 里订阅话题就能拿到声呐、IMU、深度计数据,发布推进器指令就能让虚拟载具动起来。它适合两类人:做水下视觉与路径规划算法的开发者,以及想先跑通闭环再碰真机的 ROS 工程向同学。下面从环境搭建开始,把这份资源拆开讲透。

2. 环境搭建与技术选型:从依赖安装到编译通过

2.1 Stonefish 的架构:物理引擎、渲染器与传感器仿真

Stonefish 的架构可以切成三层看。最底层是物理层,刚体动力学交给 Bullet Physics 处理,载具与海底、障碍物的碰撞接触都发生在这里;中间层是仿真核心,负责实现浮力、附加质量、流体阻力这类水动力效应,并根据海流和海浪模型实时更新载具受力;最上层是传感器与渲染,OpenGL 负责出图,摄像头、声呐、IMU、DVL 这些传感器从当前仿真状态里采集数据。

这套分层和游戏引擎很像,但差别在水动力层。普通引擎里的浮力只是一个简单的阿基米德公式,而 Stonefish 会按载具的排水体积、浸没深度、表面朝向逐面计算压力,甚至能模拟接近水面时的兴波阻力。我一般会分开理解「物理步长」和「渲染帧率」:物理步长决定动力学精度,渲染帧率决定画面跟手程度。调试时载具动作异常抖动,先看物理步长;只是画面卡,那是渲染层的事,别混着调。

Python 侧的边界非常清楚:能读写传感器数据和载具状态,不能直接碰物理引擎内部。脚本写久了容易产生一种错觉,以为自己是在控制真实载具,其实只是通过 ROS 话题在跟仿真核心通信。保持这层认知,出问题时排查方向会清晰很多——是算法的问题、通信的问题,还是仿真模型的问题,先归因再动手。

GPU 方面,Stonefish 的渲染依赖 OpenGL,集成显卡也能跑,但场景复杂时明显掉帧。我一般建议至少有一张支持 OpenGL 3.3 以上的显卡;硬件妥协时,把窗口分辨率调低或者关掉水面反射能救回不少帧率,代价只是画面丑一点,数据链路完全不受影响。

2.2 目录结构:clone 之后先认路

拿到资源包先别急着编译,用 tree 看一眼布局。这套包的目录结构大致是这样的:

smarc_stonefish_sims/ ├── worlds/ # .scn 场景定义文件 │ ├── sam_coastal.scn │ └── sam_harbor.scn ├── launch/ # ROS launch 启动脚本 │ ├── sam_sim.launch │ └── sam_sim_rviz.launch ├── robots/ # 载具模型描述 │ ├── sam_vehicle.urdf │ └── sam_vehicle.sdf ├── data/ # 网格、纹理与水动力参数 │ ├── meshes/ │ ├── textures/ │ └── hydro/ ├── config/ # 传感器与控制器参数 │ └── sam_sensors.yaml └── scripts/ # Python 辅助脚本 ├── check_topic_rate.py └── set_thruster.py

worlds 目录是入口,一个 .scn 文件对应一整个仿真场景,海洋参数、载具、传感器全写在里面。launch 目录是 ROS 侧的门脸,日常跑仿真基本都从这里进。robots 目录放 URDF 和 SDF,URDF 管运动学树;载具的质量、浮力、阻力这些动力学属性则在 .scn 的 BODY 段和 data/hydro 参数文件里定义。

config 目录值得单独说。里面的 yaml 会被 Python 节点和桥接节点读取,常见内容是传感器话题名、采样率、噪声参数。改这些参数不需要重新编译,改完重启 launch 就生效,做参数扫描时非常方便。scripts 目录通常备着几个辅助脚本,比如检查话题频率、手动发推进器指令,先跑一遍脚本再写自己的逻辑,能省不少事。

2.3 依赖安装与编译:三步走

装依赖时我习惯把包都列在一个命令里,减少反复输入 sudo 的次数:

sudo apt install build-essential cmake libboost-all-dev \ libeigen3-dev libyaml-cpp-dev libopencv-dev \ libglew-dev libbullet-dev python3-pip

这里有个高频翻车点:如果机器之前装过别的机器人仿真环境,apt 里可能已经有一份 libbullet-dev,版本往往偏旧。Stonefish 对 Bullet 的接口版本有要求,旧版本会在编译中段报出一堆找不到符号的错误。所以装完先查一遍版本:

dpkg -l | grep bullet pkg-config --modversion bullet

版本明显偏旧的话,建议直接从源码编译新版 Bullet 装到 /usr/local,再回来编工作空间。血泪经验是:不要在一个缺依赖的环境里反复重跑 catkin_make,报错信息会叠加到让你怀疑人生——先花十分钟把依赖清干净,比反复重编一小时划算得多。

依赖就位后编译整个工作空间:

cd ~/ros_ws catkin_make source devel/setup.bash

编译输出重点看两个包:stonefish 核心库和 stonefish_ros 桥接包。前者是 C++ 仿真库,后者负责把传感器数据映射成 ROS 话题。如果桥接包编译时报找不到 stonefish 头文件,多半是 CMake 没找到库路径,把环境变量里源码目录相关路径导一遍再重编。我一般单独编这两个包而不是一次性编全工作空间,定位报错会快很多。

3. 核心模块拆解:世界文件、载具模型与传感器链路

3.1 .scn 世界文件:场景的一站式入口

Stonefish 用文本描述整个场景,所有要素——海洋、大气、光照、载具、传感器——都写进一个 .scn 文件。拿一个海岸场景举例,核心段落长这样:

OCEAN { SIZE_X 500.0 SIZE_Y 500.0 DEPTH 30.0 WAVE_HEIGHT 0.3 WAVE_LENGTH 2.0 CURRENT_VEL 0.15 CURRENT_DIR 45.0 } BODY { NAME "sam_vehicle" MESH "data/meshes/sam_hull.obj" POS 0.0 0.0 -1.5 RPY 0.0 0.0 0.0 DENSITY 940.0 HYDRO "data/hydro/sam.hydro" } SENSOR { TYPE "CAMERA" NAME "forward_cam" PARENT "sam_vehicle" POS 1.2 0.0 0.35 RPY 0.0 0.0 0.0 RESOLUTION 640 480 FOV_H 60.0 }

不用死记字段名,理解三件事就行。第一,OCEAN 段决定环境难度:浪高、波长、海流速度和方向直接影响载具漂移。做路径规划算法验证时,把 CURRENT_VEL 从 0 调到 0.5,相当于从泳池换到开放海域,难度直接拉高一个档位。第二,BODY 段决定载具动力学:DENSITY 比水略小才能半浮在水面,调太小载具会一直飘在水上下不去,调太大直接沉底——这是调载具手感的第一入口。

第三,SENSOR 段决定数据源:PARENT 指定挂在哪艘载具上,POS/RPY 是安装位置和姿态,RESOLUTION 和 FOV_H 决定图像分辨率与视野。改完 .scn 不用重新编译,重启 launch 就重新加载。这个「文本即场景」的设计让迭代特别快——我调视觉算法时经常三个终端来回切:一个改传感器安装角度,一个重启仿真,一个看算法输出。

3.2 载具模型与参数:运动学、动力学各管各的

载具描述分两层。URDF 文件描述运动学——连杆、关节、坐标系,ROS 里的 tf 树由它生成,供给导航和可视化使用。动力学信息在 .scn 的 BODY 段和水动力参数文件里:质量分布、浮力中心、各个自由度的阻力系数。两者靠名称对齐,.scn 里叫 sam_vehicle 的刚体,对应 URDF 里的 base_link。

水动力参数文件是纯数据,按六自由度给出阻力系数和附加质量系数。一个合格的水下仿真不能只给浮力——旋转阻尼和附加质量必须给,否则载具在水里的动作像在真空中一样飘,算法验证完全没有参考价值。常见做法是把船模实验数据或 CFD 结果换算成系数填进去;没有实测数据就先给经验值,再看仿真里载具对阶跃指令的响应曲线,手调阻尼项直到姿态变化符合直觉。这个环节是仿真「像不像」的分水岭,也是之后二次开发最值得投入的地方。

还有一个容易被忽略的点:载具重心和浮心的高度差。重心低、浮心高,载具天然自稳;反过来就会像倒扣的不倒翁,稍微给个力就翻转。调姿态控制算法之前,先确认这两个点的相对位置——很多「控制发散」其实是模型本身就扶不正,换什么控制器都白搭。

3.3 传感器到 ROS 话题的映射链路

stonefish_ros 桥接包把每个传感器映射成固定命名的话题。摄像头出图像话题,声呐出点云或声呐图像,IMU 出 sensor_msgs/Imu 消息(内含三轴线加速度与角速度),深度计出单值 Float64,DVL 出速度矢量。映射关系写在桥接配置里,改话题名前缀不影响仿真器本体,只需要同步改订阅方代码。

验证链路通没通,我习惯用一条命令:

rostopic hz /sam/imu/data

能稳定输出 50Hz 以上,说明传感器链路是通的;0Hz 或断续输出,问题大概率出在桥接配置或场景文件里传感器没挂对。第四章就沿这条链路走一遍完整的启动、订阅与控制流程,把数据从仿真器一路送到 Python 回调函数里。

4. 跑通完整链路:启动仿真世界并用 Python 操作载具

4.1 用 launch 文件把世界拉起来

启动仿真的标准姿势是走 launch 文件。一个典型的 launch 会同时拉起三件事:Stonefish 仿真主节点、stonefish_ros 桥接节点、Rviz 可视化窗口。命令行长这样:

roslaunch smarc_stonefish_sims sam_sim.launch world:=sam_coastal.scn

launch 文件本身不长,核心就三块:加载场景文件路径、启动仿真主节点、启动桥接节点。自己写 launch 的话可以参考这个简化骨架:

<launch> <arg name="world" default="sam_coastal.scn"/> <node name="stonefish_sim" pkg="stonefish_ros" type="stonefish_ros_node" output="screen"> <param name="world_file" value="$(find smarc_stonefish_sims)/worlds/$(arg world)"/> </node> </launch>

实际资源包里的 launch 会比这个复杂,会带上 Rviz 配置和话题重映射,但理解这三块就够了。日志里出现 Simulation initialized 字样后主循环就起来了,此时仿真窗口里应该能看到载具浮在海面上,周围有波浪。看不到就先回第五章的渲染排查,别带着一个黑屏窗口往下走。

如果是在无显示器的服务器上跑,也可以在 launch 里关掉可视化窗口,只留仿真主循环和桥接节点。这样做的缺点是没法直观确认场景长什么样,但好处是稳定——我在调参期间基本都这么跑,最后需要出图时才把可视化打开。

4.2 Python 节点:订阅传感器、发布推进器指令

跑通链路的核心是「读数据」和「发指令」两个方向都打通。下面这段 Python 脚本做的事很直接:订阅 IMU 和深度计,同时发布推进器指令,让载具保持在一个目标深度附近。

#!/usr/bin/env python3 # depth_hold.py —— 订阅传感器并发送推进器指令的示例节点 import rospy from sensor_msgs.msg import Imu from std_msgs.msg import Float64, Float32MultiArray target_depth = 2.0 # 目标深度,单位米 def imu_cb(msg): # IMU 数据包含三轴线加速度和角速度,这里取 x/z 分量做示例 ax = msg.linear_acceleration.x az = msg.linear_acceleration.z rospy.loginfo_throttle(2.0, f"accel: x={ax:.2f} z={az:.2f}") def depth_cb(msg): # 深度计返回单值,单位是米 depth = msg.data # 简单比例控制:误差越大,垂直推进器出力越大 err = target_depth - depth thrust_cmd = max(-100.0, min(100.0, err * 12.0)) cmd_msg = Float32MultiArray() cmd_msg.data = [thrust_cmd, thrust_cmd] # 左右垂直推进器 pub.publish(cmd_msg) rospy.init_node("depth_hold_demo") rospy.Subscriber("/sam/imu/data", Imu, imu_cb) rospy.Subscriber("/sam/depth", Float64, depth_cb) pub = rospy.Publisher("/sam/thruster_setpoints", Float32MultiArray, queue_size=1) rospy.spin()

这段代码有三个值得注意的细节。第一,订阅前最好先用rostopic type /sam/imu/data确认消息类型,类型不匹配时订阅会静默失败,节点日志没有任何提示,非常坑。第二,推进器话题的消息类型和数组长度必须跟载具模型一致——两个垂直推进器就发长度为 2 的数组,多一个少一个都会被桥接层丢弃,而且同样是静默失败。第三,高频传感器话题的日志一定要限频,loginfo_throttle的第二个参数是秒数,这里设 2 秒打印一次,否则终端会被 IMU 消息刷爆,连 Ctrl+C 都要等好几秒才能响应。

比例控制系数err * 12.0也不是随便写的。系数太小,载具会慢慢悠悠很久才到目标深度;系数太大,垂直推进器会反复过冲,深度曲线变成锯齿状来回震荡。我一般先给一个保守值跑起来,观察深度曲线再逐步加大,直到出现临界震荡再退回 80%,这个经验值基本能覆盖大部分仿真场景。

4.3 闭环验证:从数据曲线看仿真有没有跑对

脚本跑起来后,验证闭环的方式是看深度是否收敛。Rviz 里加一个 Plot 面板订阅 /sam/depth 话题,如果曲线在 2 米附近小范围波动,说明订阅、发布、控制链路全通。如果深度纹丝不动,先停掉脚本,手动发布一个大的推进器指令,看载具位置话题有没有反应——这一步能区分是「控制没生效」还是「载具根本没动」。

提示:Stonefish 默认尽量跑实时,机器性能不够时仿真时间会变慢,IMU 话题频率也会跟着掉。算法在仿真里正常、真机上完全不对的时候,先查物理时间与真实时间的比例,这是最容易被忽略的一个变量。

位置话题建议同时订阅一下,观察载具坐标是否随时间连续变化。连续变化说明动力学在正常推进;数值跳变则说明物理稳定性有问题——最常见原因是物理步长设得太大,把步长调小一个量级再看。还有个小技巧:把深度计数据和位置话题的 z 轴放在同一张图里对比,能直接看出深度传感器是跟随真实位置还是存在固定偏差,这对后面做传感器模型校准特别有用。

5. 常见问题与避坑:编译失败、黑屏和话题静默的排查记录

下面四条是这份资源最常见的翻车现场,每一条我都踩过不止一次,按「现象 → 原因 → 解决」的顺序写,方便你直接对照。

5.1 编译报错:Bullet 版本不一致

现象:catkin_make 编到一半,报一串找不到 btMultiBodyConstraint 之类的头文件或链接错误,报错位置全在外层模板代码里,读起来很痛苦。

原因:系统里的 libbullet-dev 版本与 Stonefish 源码期望的版本不匹配。发行版仓库里的 Bullet 往往停留在较老版本,而仿真核心用了较新的多体约束接口。

解决:从 Bullet 源码编译新版并安装到 /usr/local,然后确认pkg-config --modversion bullet输出新版本号。编译前先看版本,不对就先处理依赖再回来编,不要在错误叠加的状态下反复重跑。这条经验帮我省掉了至少一下午的无效排查时间。

5.2 渲染窗口黑屏或白屏

现象:launch 正常启动,节点日志全绿,但仿真窗口一片黑或一片白,载具完全看不到。

原因:八成出在 OpenGL 上下文上。两种情况最常见:一是 SSH 远程跑在没有显示器的机器上;二是显卡驱动不支持 Stonefish 所需的 OpenGL 版本。

解决:远程场景改用虚拟显示,或者直接关掉可视化窗口跑 headless 模式,数据链路不受影响;本地场景先执行glxinfo | grep "OpenGL version"确认驱动版本。注意区分:光照参数没调好时画面通常还能看到物体轮廓,只是偏暗或过曝;驱动问题则是连轮廓都没有,表现完全不同,别误判成场景配置问题。

5.3 传感器话题一直不出数据

现象:rostopic list里能看到话题名,但rostopic hz输出 0Hz,订阅节点收不到任何消息。

原因:三种可能性——场景文件里传感器 PARENT 写错、桥接配置里话题名和代码对不上、传感器坐标超出仿真范围(比如把相机挂在水下 100 米而场景水深只有 30 米)。

解决:先在 launch 日志里搜传感器初始化信息,确认传感器是否成功挂载;再用rostopic echo看话题上有没有零散消息;最后回头查 .scn 里传感器的 PARENT 和坐标。我遇到最多的是 PARENT 名称不匹配——ROS 侧完全不报错,桥接层直接跳过无效传感器,从现象上很难猜原因。后来我把「查日志」列成了排查第一步,就不再靠玄学猜了。

5.4 仿真越跑越慢,物理时间跟不上

现象:一开始流畅,运行几分钟后载具动作变成慢镜头,IMU 话题频率掉到 10Hz 以下。

原因:通常是场景里物体数量过多或网格面数过高,物理层每一步的碰撞检测都在变重;另一个隐藏原因是日志文件越写越大,机械盘 IO 拖慢了主循环。

解决:如果只是调算法,把场景里纯装饰的静态物体删掉;必须保留则优先精简单个高面数网格。日志问题换 SSD 或者降低记录频率。判断技巧是看 CPU 和 IO 占用:CPU 吃满说明碰撞计算太重,IO 等待高则是磁盘问题,对症下药才有用。

6. 二次开发:往石鱼世界里加自己的载具与传感器

6.1 新增载具的步骤

先放网格文件和水动力参数,再在 .scn 里加 BODY 段,最后在 URDF 里补对应的 base_link。顺序不能反——先有动力学实体才有运动学描述,否则 tf 树缺根节点,导航栈直接罢工。新载具的 DENSITY 建议从 900 开始试,90% 的异常漂浮问题都能靠这个参数拉回来。

6.2 自定义传感器输出的常见做法

内置传感器不够用时,先别急着改仿真器源码。常见做法是订阅现有话题,在 Python 节点里做后处理:把声呐点云转栅格占用图,把相机图像送进视觉模型推理,把 DVL 速度融合进状态估计。收益是仿真器保持稳定,算法逻辑全部留在 Python 侧,迁移真机时只需替换话题来源。

6.3 改完之后的验证顺序

我一般按固定顺序过一遍:编译不报错、launch 能起、rostopic list 能看到新话题、rostopic hz 频率稳定、物理时间接近实时、跑一段闭环看数据曲线。任何一步不过就停在那里排查,不带着疑问往下走。从那以后,我每次改完场景都强制走一遍「改配置 → 重启 launch → 查话题 → 看频率 → 跑闭环」的流程,这套习惯让后续算法验证省掉了大量返工。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进