简介:这是一套2021年中国大学生工程实践与创新能力大赛“智能+赛道”水下管道巡检赛项全国第八名/国银获奖作品完整源码,面向工训赛参赛队、嵌入式系统开发者及水下机器人爱好者。项目以STM32为控制中枢,K210承担AI视觉识别,涵盖推进控制、传感器采集、图像处理与通信等模块,可帮助读者理解水下管道巡检机器人的整体工程架构和调参思路。资源共410个文件,以C/H源码、Keil工程文件(uvprojx/uvoptx)、编译产物(axf/hex/map)及初始化配置文件(ioc)为主,整体仅3.41MB,结构紧凑、便于按模块研读。已有2204人学习下载,对备赛冲刺或入门水下机器人开发均有较高参考价值。 2021年那场全国水下机器人赛事,我们做的水下管道智能巡检机器人拿了全国第八名、银牌。名次不算差,但真正让我记住的不是领奖台,而是决赛第三轮里,我在电脑前按下“临时调低置信度阈值”那个键的两秒钟。那一瞬间,困扰我们两个月的视觉误检问题彻底暴露,比赛分也因此被扣掉不少。也是从那天起我才彻底想明白:水下管道巡检这个任务,真正的难点不在“造一台能潜水的机器”,而在于让它在看不清、站不稳、被水流推着走的环境里,稳定完成“找管道、贴管走、找缺陷”这三件事。这篇复盘就把这个项目从硬件架构、感知算法、运动控制到完整源码的组织方式,原原本本拆开讲一遍,给正在准备水下机器人赛项、或者想找一套完整源码做参考的同学一些能直接落地的经验。
1. 赛场上那次临时调参,暴露了整个系统的软肋
决赛第三轮的任务,是在浊度比训练池高出一截的水池里巡检一段模拟管道,找到法兰、阀门和泄漏标记。训练时我们的视觉模型在干净水质下的正检率在95%左右,误检率不到3%。可那天赛前试潜时我就发现,摄像头画面整体发白,像隔着一层磨砂玻璃,检测框明显变少,有的目标框只有0.3左右的置信度。按正常0.45的阈值,这些框会被全部过滤掉。
倒计时三分钟,我做了个决定:把置信度阈值从0.45调到0.30。听起来很合理对吧?目标分数变低了,那就放宽门槛,把目标捞出来。结果下水后,画面里确实多了一堆检测框,但很多是管壁上的水垢、锈斑、甚至气泡的反光。机器人在这些误检框的引导下频繁调整航向,最终在几个错误位置按下了“标记泄漏点”的提交键。整轮得分比保守策略还要低。
赛后我对着日志反复看,问题根本不在阈值本身,而在于我们的视觉模块没有任何“场景自适应”能力。它用的是固定预处理参数加固定阈值,检测结果没经过上下文过滤就直接送给了控制层。单帧里出现一个0.3置信度的框,到底是真的目标还是水底噪声?我们完全没有判断机制。
后续我在源码里补了两个关键机制。第一个是“时间域确认”,同一个目标连续三帧以上都出现、且位置移动符合运动约束,才认为是真目标。第二个是“位置先验”,管道巡检的检测目标几乎只会出现在管道轮廓附近,离管道中线太远的检测框直接丢弃,不管分数多高。这套逻辑用伪代码展示大概是这样的:
if det.conf < conf_thresh: continue if det.class_id in [FLANGE, VALVE, LEAK]: if abs(det.cx - last_pipe_cx) > 0.35 * frame_w: continue # 远离管道中线的目标直接丢掉从那以后我也养成一个习惯:改任何参数前,先想想它会影响precision还是recall,是要在哪个指标上让步。只用单帧分数来判断目标,在水下这种“脏数据”环境里,几乎必然翻车。
2. 带缆ROV双主控架构:为什么是 Jetson Nano + STM32
先回答最常被问的问题:为什么不做成无线水下机器人?因为2.4GHz信号在水里衰减极快,哪怕是浅水池,隔两三米就可能丢包。而管道巡检这个场景本来就强调稳定性和长时作业,有线系缆反而是最可靠的选择。我们用的是30米六类网线,里面走以太网和12V供电,水下端用一个PoE分离模块把电和网络分开。整套系统是典型的带缆ROV(遥控潜水器)架构。
控制架构上,我坚持用了“双主控”而不是一块高算力板子全干。Jetson Nano 2GB负责图像采集、YOLOv5推理和路径决策,STM32F407负责电机控制、姿态解算和深度闭环。两者之间用串口通信,115200波特率,50Hz周期上报状态。
为什么必须分开?视觉任务吃算力,但实时性要求不高,5到10Hz足够;而电机控制需要1kHz左右的快速响应,用来做姿态环和深度闭环。如果都塞进Jetson Nano,图像推理一卡,电机控制也跟着抖动,机器人会在水里打摆子。STM32跑RTOS调度,把控制周期固定住,才能保证稳定。
机械和水密方面,我们用的是600mm长、直径160mm的亚克力防水舱,两端6061铝端盖,配合两道O型密封圈,实测在5米水深下不漏水。四个推进器:左右两个水平推进器负责前进和转向,中间一个垂直推进器负责下潜和定深,再加一个侧推,用于贴管时横向移动。这个布局没有用标准的八推进器矢量方案,因为管道巡检是“贴着管慢慢走”的场景,四个足够,还能省重量和电调成本。每个推进器峰值推力2.5kgf,整机水中重量控制在7kg左右,舱体留约200g正浮力,保证停机时能自动上浮——这个安全策略在赛前检查时很加分。
传感器选型也值得说一下。摄像头是普通免驱USB摄像头,加两个LED补光灯;IMU用MPU9250;深度计用MS5837-30BA,气压式测深,精度能到毫米级别,定深控制全靠它。另外,我在舱内加了一路漏水检测回路,两根裸露导线贴在舱底,一旦进水,水把电极导通,立即触发报警并切断推进器电源。这个设计成本不到十块钱,但救过我们一次,第二版样机有一回端盖O圈没抹硅脂,下水三分钟就报警,避免了整套电子设备报废。
硬件清单大概是这样,给想复现的同学一个预算参考:
- Jetson Nano 2GB开发板:约800元
- STM32F407核心板:约80元
- 水下推进器×4(带封装):约4500元
- USB摄像头、LED补光灯:约200元
- MPU9250、MS5837模块:约150元
- 亚克力舱体、铝端盖、O圈、穿线管:约1500元
- 系缆、PoE模块、电源模块、4S锂电:约800元
不算加工费,整套物料在8000到10000元。比买成品水下机器人便宜不少,而且坏了能自己修,对学习来说价值更大。
3. 水下图像增强和目标检测:让YOLOv5s在水底下不翻车
水下图像和自然图像最大的区别,是它本质上就是“低质量图像”。光在水里会被吸收和散射:红光衰减最快,所以水下画面普遍偏蓝绿;悬浮颗粒造成类似雾霾的白蒙蒙效果;管道表面的金属或塑料反光又会带来高光区域。直接拿预训练模型跑这种图像,效果极差。我们试过OpenCV自带的Haar级联和MobileNet-SSD,在水下场景基本是废的。后来改成了“先增强,后检测”的管线。
预处理分三步,都是在推理之前对每一帧图像做的:
- 白平衡:以灰度世界假设校正色偏,把蓝绿拉回来一点。
- CLAHE(限制对比度自适应直方图均衡化):把管道边缘和焊缝细节从低对比度背景里提出来。
- 暗通道去雾(简化版):减轻水中悬浮颗粒造成的雾化感。
核心代码很短,在源码里就是增强模块:
def enhance_underwater(image): lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) l = clahe.apply(l) enhanced = cv2.merge([l, a, b]) return cv2.cvtColor(enhanced, cv2.COLOR_LAB2BGR)检测模型用的是YOLOv5s,但模型本身只是其中一环。关键在数据。我们自建了一个水下管道部件数据集,总共标注了大约6000帧图像,包含四类目标:法兰、阀门、泄漏标记(红色和黄色色块)、管壁裂缝。标注是团队三个人轮流干的,花了大半个月。数据增强用了mosaic、mixup、随机亮度、随机色温扰动,专门模拟不同水质和光照条件。训练在NVIDIA RTX 3060上大概跑了两个小时,batch size为16,输入尺寸640。训练完再用TensorRT转成engine格式,推理耗时从约50ms降到20ms左右,基本能满足实时巡检需求。
单帧检测结果仍然不可靠,我用一个目标候选列表做时间域滤波。每一帧检测出来的框先和已有的候选框做IOU匹配,同一个目标连续三帧都被检测到才确认输出;新目标在前两帧只进候选池,不进入控制逻辑。这个机制付出的代价是多约三帧延迟,但对水下贴管巡检这种慢速任务完全无所谓,换来的是误检率大幅下降。
再补一个很多人会踩的坑:水下距离估计。水的折射率约1.33,会让目标看起来比实际位置更近,单目相机估算距离本来就有多义性。我们的做法简单粗暴:在0.5米、1.0米、1.5米三个距离放标定管,记录目标框的像素高度,拟合出一条“像素高度-真实距离”的查表曲线。因为控制层贴近管壁后主要看的是管道中线的像素偏差,不依赖绝对距离,所以这个精度完全够用。
4. 运动控制:串级PID和贴管行走的调参经验
水下机器人运动控制的麻烦在于:推进器推力非线性、水流干扰随机、机体重心和浮心不完全重合。如果只用单环PID,参数稍微调大一点,深度和航向就会来回振荡,像在水里抽风。所以我在控制层用了串级PID。
先看定深控制。外环是深度环,输入目标深度和MS5837当前深度的偏差,输出一个期望垂直速度;内环是垂直速度环,输入期望速度和IMU加速度积分出来的实际垂直速度,输出推进器推力。写成公式就是:
depth_err = target_depth - current_depth vel_target = Kp_d * depth_err + Ki_d * integral_depth vertical_thrust = Kp_v * (vel_target - current_vel) + Kd_v * accel_z为什么用串级而不是单个深度PID?因为水越深,静水压力越大,推进器需要输出更大的推力才能维持深度,单环PID在浅水和深水表现差异会很大。内环先把速度稳住,外环只负责“缓慢趋近目标深度”,整个系统对扰动的抵抗能力强很多。
航向保持也是一样。用MPU9250的yaw角作为反馈,左右水平推进器差速控制。这里有个细节:螺旋桨高速旋转带来的震动会让yaw数据抖得厉害,必须先做低通滤波。我们用的是简单的一阶低通,cutoff频率设在5Hz左右,够用。
贴管行走是视觉和控制联动的地方。视觉模块输出管道中线在图像中的水平偏移量和角度偏差,控制层把这两个量换算成期望偏航角:
float desired_yaw = base_yaw + k1 * pipe_delta_x + k2 * pipe_delta_theta; float yaw_err = wrap_angle(desired_yaw - current_yaw); float diff_thrust = Kp_yaw * yaw_err + Kd_yaw * gyro_z; left_thrust = base_thrust + diff_thrust; right_thrust = base_thrust - diff_thrust;调参顺序是先内环后外环,先把深度环和yaw环分别调稳,再开视觉引导。视觉引导的Kp不要贪大,给一个能让机器人缓慢回中的值就够了,之后加一点点D抑制过冲。积分项只在长时间存在稳态误差时加入,而且必须限幅——如果入水时初始偏差很大,积分很容易饱和,那个瞬间推进器会猛推一下,把机器人推出航线。这个坑我们实机测试时遇到过,好好的直线巡管,下水十秒就原地转圈,后来查了几个小时才发现是积分饱和。
另外还有一个很实用的细节:视觉输出频率只有5Hz,控制频率是20Hz,每来一帧视觉数据,期望偏航角都会跳变一下。如果直接把跳变值丢给控制环,推进器会急加速,反而搅起水流把机器人推开。我在代码里对期望偏航角加了一阶惯性平滑,让目标值“软着陆”。
如果连续五秒检测不到管道中线,机器人会进入搜索状态:保持当前深度,正转五秒、反转五秒,同时用声光提示岸上操作员。这个逻辑在比赛中帮我们救回了好几次,因为池壁反光偶尔会让视觉短暂丢失目标。
5. 完整源码模块怎么组织:状态机驱动一切
很多新手拿到“完整源码”第一件事是去读main函数,但水下机器人这种系统,真正的主干是状态机。我们把整个工作流拆成这几个状态:INIT、MANUAL、AUTO_START、FIND_PIPE、ALIGN_PIPE、INSPECT_ALONG、LEAK_MARKING、RETURN、IDLE。每个状态都有entry、update、exit三个回调,状态切换只发生在update阶段,通过事件触发。这样好处非常明显:比赛现场出问题时,上位机上一眼就能看到当前停在哪个状态,操作员可以直接手动接管。
源码目录结构是分模块的,大概长这样:
project/ ├── main.py # 主入口,初始化状态机 ├── config/ │ └── params.yaml # 所有PID参数、阈值、标定表 ├── vision/ │ ├── camera.py # 摄像头采集线程 │ ├── enhance.py # 水下图像增强 │ ├── detector.py # YOLOv5推理封装 │ └── pipe_tracker.py # 管道中线提取、目标时间域确认 ├── control/ │ ├── depth_ctrl.py # 深度串级PID │ ├── heading_ctrl.py # yaw保持与视觉引导 │ ├── mixer.py # 推进器混控 │ └── state_machine.py # 状态机定义和切换 ├── comm/ │ ├── protocol.py # 二进制通信协议 │ ├── uplink.py # 上报状态和检测结果 │ └── downlink.py # 接收岸上指令 └── tools/ ├── calibrate_depth.py # 深度计标定 └── replay_dataset.py # 日志回放工具视觉、控制、通信分别跑在不同线程里,线程间通过带锁的全局变量共享数据,避免大图像在处理线程间反复拷贝。图像采集线程保持30fps,推理线程每次取最新一帧,控制线程固定20Hz,通信线程10Hz。这几个数字不需要太高,稳定比高帧率重要。
通信协议是自己定的轻量二进制协议:帧头0xAA 0x55加报文ID、数据长度、数据体,最后加CRC16校验。岸上的Qt上位机把深度、yaw、当前状态、视觉检测框全部实时显示出来。调试的时候,几个人蹲在池边看上位机,比拿防水电脑蹲在水边看串口日志效率高得多。
关于复现,我必须说一句大实话:这套源码直接拿去用,大概率会出问题。因为不同主控板、摄像头、光源、水体浊度都会影响参数。params.yaml里我统一放好了所有需要调的参,队伍拿到源码后第一件事不是跑起来,而是按tools里的脚本重新标定深度计、重新测量推进器推力曲线、重新采集一段视频做检测效果验证。我在源码里留了比较完整的日志系统,每个状态切换、每个控制输出、每帧检测结果都会落盘。复盘时用日志重放工具,比在水池边猜原因高效太多。
6. 从水池到赛场:那些烧钱烧时间才换来的注意事项
最后这部分不写代码了,全是钱和教训堆出来的东西。
第一是密封。每次下水前必须做干舱测试和泡水观察。干舱测试很简单:盖好端盖,放进清水箱里半小时,看有没有连续气泡,然后擦干、打开舱盖,检查O圈和干燥剂颜色。特别注意螺旋桨轴和穿线管,这两个地方是漏水高发区。我们的穿线管后来全部改成环氧灌封,而不是只靠热缩管,因为热缩管在水下长时间受压会慢慢渗水。
第二是电磁干扰。无刷电调和摄像头USB线如果靠太近,图像会随机花屏,有时候表现为“过几秒黑一下”。第一版我们在这个问题上耗了一周,后来把电调信号线尽可能远离USB线、给USB线加磁环、换成带屏蔽层的USB延长线,问题才彻底解决。水下机器人内部空间紧张,线缆整理不能只图好看,布局顺序直接影响信号稳定性。
第三是系缆拖拽。带缆ROV一定会被缆绳拽着走,缆绳在水中也有自重和阻力,机器人偏航时操作员感觉“明明给了修正,它还是慢慢偏回去”。我在控制层加了系缆补偿思路:当检测到持续的微小偏航误差时,让积分项缓慢积累,而不是立刻加大比例项,否则很容易在水流和缆绳的共同作用下振荡起来。
第四是光源角度。水下补光灯正对目标,表面反光会掩盖管壁裂缝的阴影细节,而裂缝识别恰恰需要阴影来凸显凹凸纹理。后来我们把两个补光灯改成45度侧向照明,检测效果明显提升。灯光的角度属于那种“原理上说不出大问题、实测差得离谱”的细节,必须试过才懂。
第五是现场策略。比赛池和训练池的水质、光线永远不可能完全一样。我们在正式流程里加了一个“首轮诊断”环节:开赛后先不急着巡检,下潜到目标深度,收集当前水体下的图像统计信息,比如平均亮度、对比度、以及所有目标类别置信度分布,然后根据这些统计数据选择预设参数组。这个步骤大概耗时四十秒,但能避免很多临场瞎猜。如果我们当时在决赛里按这个流程走完,就不会出现那个临时调阈值的决定了。
现在回看2021年这个项目,最值钱的不是那枚银牌,而是一堆“看起来和书里不一样”的教训。水下机器人这个方向没有标准答案,只能靠一次次下水喂出来。如果这篇复盘能帮你在做自己的水下管道智能巡检机器人时少走几个弯路,那就很值了。完整源码的模块思路和关键代码我都尽量往通用方向写,但请记住:别人的源码只是起点,真正要复现的是那套“先增强、再看目标、再贴管、再确认”的决策链路,以及每次下水前对参数和风险的尊重。
本文还有配套的精品资源,点击获取