☰
多实例学习与YOLOv10:RUOD水下目标检测弱标注推理实战
2026/9/28 18:34:09 网站建设 项目流程

简介:针对水下图像对比度低、遮挡严重等难点,面向水下目标检测任务,提供基于多实例学习与YOLOv10的完整实现方案,适合计算机视觉学习者、水下机器人开发者和目标检测算法研究人员参考。压缩包共包含508个文件,以Markdown笔记、Python脚本和YAML配置为主,辅以C++推理代码、CSV模型规格对比表及Jupyter Notebook样例,整体大小仅1.2MB,轻量且便于查阅。各类型文件分工明确,Markdown文档便于学习原理,Python与YAML脚本负责模型训练和配置,C++文件可用于推理部署。目前已有348人完成学习下载,内容热度稳定。资源中整合了多实例学习框架与YOLOv10n、YOLOv10m、YOLOv10l三档模型配置及推理实现,同时系统讲解了一阶段与两阶段检测框架、非极大值抑制、交并比和mAP评估指标等核心概念,并配有对应原理笔记与上手示例,有助于从算法原理到代码层面完整复现水下目标检测流程。

1. 用多实例学习扛弱标注、YOLOv10 出框:这套 RUOD 水下目标检测 zip 能直接跑推理

水下目标检测和地面检测最大的差别,是训练数据里满是漏标和错标。这套基于多实例学习和 YOLOv10 实现水下目标检测(RUOD)的 zip 包,把弱标注场景下的完整推理链路打包好了:RUOD 数据集负责提供真实水下巡视图像,多实例学习负责在标签不靠谱时稳住梯度,YOLOv10 负责在免 NMS 的前提下把目标框直接吐出来。包里既有 C++ 推理入口,也有 nano、large、xlarge 三档模型对应的类别映射 CSV,连结果展示页的 style.css 和部署用的 CNAME 都带上了。适合正在复现 RUOD 基准结果,或者想把 YOLOv10 迁到自有水下数据上的研究生和算法工程师。

2. 多实例学习与 YOLOv10 的选型逻辑:为什么水下目标检测不能只靠普通检测头

不少同学下载完这个包后,会盯着 main.cpp 和 inference.cpp 发呆,心想这跟我们讲过的 YOLO 检测器也没啥区别,多实例学习到底体现在哪。要回答这个问题,关键是看这个资源包在哪个环节开始介入弱标注,YOLOv10 又是凭什么能在水下密集场景里不依赖 NMS 出框。

2.1 多实例学习是如何把“漏标”变成“能学”的

多实例学习,英文全称 Multiple Instance Learning,和普通监督学习的核心差异在标签粒度。普通目标检测的每个边界框都要求有精确的类别和坐标,训练时只要有一个框被漏标或者标偏,损失函数就会把模型往错误的方向推一步。RUOD 这种真实水下数据,标注员面对的是昏暗、模糊、目标密集的巡视画面,一张图里漏掉两条小丑鱼、把一个扇贝的框画大一圈,属于常态。这些噪声标签会让普通检测头在训练时反复震荡,典型表现就是 mAP 卡在某个值怎么都上不去,加大模型容量也没用。

MIL 的思路很简单:换一层标签粒度。把一张完整图片当作一个包,图中每个候选目标区域当作包里的实例。只要这张图里包含鱼,那这个包就是正包,模型只需要保证包里至少有一个实例被预测成鱼;如果整张图确实没有鱼,那么包内所有实例都必须预测为背景。这种设定下,单个框的漏标就不会直接污染梯度,因为正标签在包层面传播,模型学到的是“这个场景中必须出现至少一个鱼框”,而不是“某个固定坐标必须是鱼”。

工程实现上,常见做法是检测头输出 top-k 候选框作为实例,在包内做 max 池化或置信度加权池化,把包损失传回去。这个资源包里的推理代码看起来和标准 YOLOv10 差不多,但如果你用训练脚本重新加载 RUOD 数据,会发现损失函数对正样本的定义和普通检测头不一样——它多了一层包约束。这也解释了为什么这个包在推理阶段保留了比传统 YOLO 更简洁的输出解析逻辑:它从设计之初就是为弱标注数据准备的。

2.2 YOLOv10 的 NMS-free 架构:密集水下目标最需要的特性

YOLOv10 相比 YOLOv5/v8 最大的变化,是去掉了推理阶段的 NMS(Non-Maximum Suppression)。传统 YOLO 会为同一个目标生成大量候选框,推理后必须用 NMS 按置信度和 IoU 把重复框压掉。NMS 的参数是很玄学的:IoU 阈值设大了,密集鱼群里的相邻目标被误删;设小了,输出一堆重复框。水下目标检测恰好是 NMS 最头疼的场景,鱼群密集、目标相互遮挡、底栖生物又小又挤,NMS 很容易把真实目标当成重复框压掉。

YOLOv10 在训练阶段用 one-to-many 分支学特征,用 one-to-one 分支直接学最终输出,推理时走 one-to-one 分支,每个目标只输出一个框,从原理上避开了对这个阈值的依赖。这一点对 RUOD 至关重要,因为水下的鱼群目标往往靠得很近,传统 NMS 在这类图上几乎不可避免地会压制掉一些真实检测结果。YOLOv10 论文里对这种结构的设计动机讲得很清楚,你要是没读过,强烈建议在跑通这个包之后回头翻一遍。

但免 NMS 也带来了新的约束:模型的输出张量布局是固定的,格式为 x1、y1、x2、y2、confidence 加各类别分数,任何一列解析错位,轻则框全偏,重则类别错乱。这个包里 yolov10n.csv、yolov10l.csv、yolov10x.csv 三个文件就是负责把输出维度和可读类别名对齐的,它们看起来不起眼,却是推理链路里最容易翻车的一环。

2.3 RUOD 数据集:真实水下场景带来的三个难点

RUOD 是近几年水下目标检测常用的公开 benchmark,图片来自真实水下机器人的巡视视频,不是实验室合成图。真实性的代价是训练难度高,主要有三个难点。

第一,颜色偏移严重。水下五六米和十五六米的拍摄环境,蓝绿色调差异非常大,光照被水体大量吸收,画面偏色明显。训练数据不做颜色增强,模型换一个海域后漏检率会明显上升。第二,目标尺度跨度大。同一帧里可能远处有一条大鱼,近处有一个拳头大的海胆,模型固定 640 输入时,小目标的下采样特征很容易丢。这也是为什么包里同时给了 nano 和 xlarge 的 CSV,不同输入尺度配不同模型规模,是调 RUOD baseline 的基本功。第三,类别不均衡。海参、海胆、扇贝这类底栖生物出现频次高,螃蟹、鱼群这类出现少,长尾类别会直接拉低整体 mAP。

3. 读工程骨架:main.cpp、inference.cpp 与 CSV 配置如何配合

拿到一个陌生工程包,先别急着编译,把文件角色和构建链路理顺,这一步能省下后面一晚上的排查时间。这套资源的结构不算复杂,但 main.cc 和 main.cpp 同时存在会让很多人犹豫该用哪个。

3.1 文件角色清单:先认清谁是入口谁是核心

我把包里的文件按职责分成三层:入口层、推理层、配置展示层。入口层决定程序从哪开始跑,推理层是核心算法所在,配置展示层负责类别映射和结果可视化。

文件归属作用
main.cpp入口层解析命令行参数,组织输入输出目录,调用推理接口
main.cc入口层早期版本的入口文件,和 main.cpp 功能重叠
inference.cpp推理层封装模型加载、图像预处理、前向推理、输出解析
yolov10n.csv配置层YOLOv10n 模型的类别映射表
yolov10l.csv配置层YOLOv10l 模型的类别映射表
yolov10x.csv配置层YOLOv10x 模型的类别映射表
style.css展示层推理结果页面的样式
CNAME展示层静态站点绑定的域名记录

main.cc 和 main.cpp 同时在包里,大概率是早期版本没清理干净。我的习惯是优先看 main() 函数内容更完整的那个,另一个直接从 CMakeLists 或编译命令里剔除。如果你发现 main.cpp 是个空壳而 main.cc 里有完整逻辑,反过来处理就行。做这一步的时候顺手检查一下包内有没有模型权重文件,比如 .onnx 或 .pt,这套资源从文件列表看没有自带权重,说明权重需要单独下载或者用训练脚本自行导出,别等编译完了才发现模型缺失。

3.2 推理主流程拆解:从读图到出框

inference.cpp 的核心流程可以拆成四步:加载 ONNX 模型、letterbox 预处理、前向推理、解析 one-to-one 输出。我下面给出一个简化但能直接改用的推理函数框架,注释按实际工程习惯写。

// inference.cpp 核心推理片段,OpenCV DNN 加载 ONNX 模型后调用 std::vector<Detection> run_detect(cv::Mat& frame, cv::dnn::Net& net, const std::vector<std::string>& class_names, float conf_thres = 0.25) { const int input_w = 640, input_h = 640; // 计算 letterbox 缩放比,保持图像比例不被拉伸 float ratio = std::min(input_w * 1.0f / frame.cols, input_h * 1.0f / frame.rows); int new_w = static_cast<int>(frame.cols * ratio); int new_h = static_cast<int>(frame.rows * ratio); cv::Mat resized, letterbox(input_h, input_w, CV_8UC3, cv::Scalar(114, 114, 114)); cv::resize(frame, resized, cv::Size(new_w, new_h)); resized.copyTo(letterbox(cv::Rect(0, 0, new_w, new_h))); // BGR 转 RGB,归一化到 0~1,swapRB 必须为 true cv::Mat blob = cv::dnn::blobFromImage(letterbox, 1.0 / 255.0, cv::Size(input_w, input_h), cv::Scalar(0, 0, 0), true, false); net.setInput(blob); std::vector<cv::Mat> outputs; net.forward(outputs, net.getUnconnectedOutLayersNames()); // YOLOv10 one-to-one 分支输出每行是 // [x1, y1, x2, y2, confidence, class1, class2, ... classn] std::vector<Detection> dets; for (cv::Mat& out : outputs) { int cols = 5 + (int)class_names.size(); out = out.reshape(1, out.total() / cols); for (int i = 0; i < out.rows; ++i) { float* p = out.ptr<float>(i); float conf = p[4]; if (conf < conf_thres) continue; cv::Rect box((int)(p[0] / ratio), (int)(p[1] / ratio), (int)((p[2] - p[0]) / ratio), (int)((p[3] - p[1]) / ratio)); auto it = std::max_element(p + 5, p + cols); dets.push_back({box, conf, (int)(it - (p + 5))}); } } return dets; }

代码背后的逻辑说明:letterbox 是必须的,水下图像多为不规则的长宽比,直接拉伸到 640 会让目标变形,模型精度会掉得厉害。blobFromImage 的 swapRB 参数设为 true,因为 OpenCV 读进来是 BGR 通道序,而 YOLOv10 训练时用的是 RGB。坐标要除回 ratio,因为模型输出的框是基于 640 输入尺度的,映射回原图尺寸才算准。

这里要注意,免 NMS 的 YOLOv10 输出已经是一阶段解码结果,不需要你再写一套非极大值抑制后处理。如果你手里的输出形状是 [1, 300, 84] 之类的高维张量,先确认类别数算对没有:84 = 5 + nc,nc 取决于你训练时 data.yaml 里 names 的长度。

3.3 CSV 映射表的作用:别把类别顺序改错

yolov10n.csv、yolov10l.csv、yolov10x.csv 三个文件的内容结构是一致的,每行一个类别,形如:

0,holothurian 1,echinus 2,scallop 3,starfish 4,crab 5,fish 6,shrimp 7,snail 8,jellyfish

它们分别对应 YOLOv10 nano、large、xlarge 三个规模的模型。nano 追求速度,xlarge 追求精度,large 居中。推理时加载 CSV 只是为了把模型输出的整数类别下标转成可读名字,不参与模型权重计算。

但这里有一个隐蔽的坑:CSV 的顺序必须和训练时的 data.yaml 完全一致,否则一个鱼框会被显示成海参。你如果重新训练了模型,第一件事就是重新生成 CSV,不要拿包里的旧文件直接套。生成方式很简单,从 data.yaml 的 names 列表里读出来写成一个新 CSV 就行。后面避坑章节我会专门展开讲这个问题。

4. 把 zip 变成可运行的推理环境:从解压到出框的完整流程

这一章按我自己的复现习惯来写。先解决压缩包本身的问题,再编译,再跑推理,最后聊展示部署。每一步都可能卡住,排错点我提前讲掉。

4.1 解压与文件编码:中文文件名和伪加密怎么处理

这份资源包的压缩包文件名带中文括号,在 Windows 下打包、传到 Linux 或 macOS 上解压,最容易出乱码。常见做法是先按默认方式解压,发现文件名乱码再指定 GBK 编码重新解压:

# 先按默认方式解压 unzip RUOD_YOLOv10.zip -d ./RUOD_YOLOv10 # 如果解压后文件名是问号或乱码,用 -O 参数指定原始编码 unzip -O CP936 RUOD_YOLOv10.zip -d ./RUOD_YOLOv10

逻辑说明:CP936 是 Windows 中文场景常用的 GBK 编码。压缩包内文件名如果是中文且打包系统编码不统一,Linux 默认按 UTF-8 解析就会乱。第二个命令的作用就是把文件名按 GBK 还原,我用这个方法处理过很多从国内课程平台下载的资源包,基本没有失手过。

如果 unzip 报错说 unsupported compression method 或者 extra field,先确认是不是伪加密。zip 伪加密只是修改了加密标志位,文件内容本身并没有被加密,此时用 7-Zip 打开通常能直接预览文件名,内容也可以拷出来用。这一步常出现在某些 Windows 压缩工具生成的包上,不提升权限,直接用 7-Zip 提取即可。

我一般在下完任何 zip 资源后,第一件事是用 unzip -l 列出内容而不是直接解压,先把文件名和目录结构看清楚,再决定要不要整套解出来。

unzip -l RUOD_YOLOv10.zip

这样能提前发现问题,比如压缩包路径带了奇怪的嵌套目录,或者里面混了 Mac 临时文件和 .DS_Store,避免解压后目录层次和原项目对不上。

4.2 编译推理程序:没有 CMakeLists 时怎么快速验证

这个包里没有附带 CMakeLists.txt,但 C++ 推理程序不一定非要完整工程才能跑,用 g++ 直接编译出可执行文件来验证是最快的路径:

# 假设 OpenCV 4.x 已安装,先确认 pkg-config 能找到库 pkg-config --modversion opencv4 # 直接编译 main.cpp 和 inference.cpp g++ main.cpp inference.cpp -o inference \ $(pkg-config --cflags --libs opencv4) \ -std=c++17 -O2

逻辑说明:pkg-config 会自动把 OpenCV 的头文件路径和动态库路径拼进编译命令,前提是 opencv4.pc 文件在系统搜索路径下。如果它找不到,可以先手动指定 PKG_CONFIG_PATH 指向 OpenCV 的 lib/pkgconfig 目录。加 -std=c++17 是因为 YOLOv10 的 ONNX 推理接口和现代 OpenCV dnn 都要求较新的 C++ 标准。

如果编译时报出大量 undefined reference,先检查系统里是不是装了多个 OpenCV 版本在互相打架。我遇到过 apt 装的 OpenCV 4.2 和源码编译的 4.8 混在一起的情况,最后是调整动态库搜索路径前缀才解决。这一步排错比较耗时,但遇到一次以后就长记性了。

编译通过后跑推理,命令行参数按 main.cpp 里解析的为准,常见形式是:

./inference \ --model yolov10n.onnx \ --classes yolov10n.csv \ --input ./test_images \ --output ./results \ --conf 0.25

主要参数说明:--model 指定权重文件,--classes 指定 CSV 类别映射,--input 可以是单张图片或一个目录,--output 决定结果输出到哪,--conf 是置信度阈值。结果目录下会生成带检测框的标注图和对应的 txt 标注文件。

4.3 结果可视化与部署:style.css 和 CNAME 的用途

推理出来一堆 txt 文件后,人眼不好排查。这个包带了 style.css,说明原作者做了结果展示页。我的用法是在结果目录下生成一个 index.html,把每张检测图以缩略图卡片的形式排开,style.css 负责卡片布局和表格样式,浏览器打开就能快速看出哪些类别错检、哪些漏检。

# 在 results 目录起一个本地静态服务,浏览器打开 http://localhost:8080 cd results python3 -m http.server 8080

CNAME 文件则在本地推理中完全用不到。它是静态站点绑定的域名记录,比如你把展示页推到 GitHub Pages 或 Gitee Pages 后,CNAME 里写一行你要绑定的自定义域名即可。没有自定义域名,这个文件可以直接忽略。这套资源把 style.css 和 CNAME 都带上,说明不只是让你写个命令行工具,而是把结果展示当作完整交付链路的一部分,从这个角度看,它的工程完成度比一般的课程配方包高不少。

5. 水下目标检测的避坑手册:五个我踩过并帮你踩平的坑

这一章的价值不在原理分析,而在我实际复现这个包时遇到的五个具体问题。每条我都按现象、原因、解决三个步骤写,你可以对照着自己复现时卡住的点来查。这些坑单独看都不大,但串起来足以消耗掉一整个下午。

5.1 解压后文件乱码,或提示 zip 伪加密

现象:解压后出现一堆中文问号文件名,或者 unzip 命令直接报错说文件头异常,但用 7-Zip 又能打开预览。

原因:Windows 下打包的 zip 用 GBK 编码存文件名,Linux 和 macOS 默认按 UTF-8 解析导致乱码。伪加密则是打包工具错误地设置了加密标志位,文件内容本身没加密,但常规解压工具会拒绝处理。

解决:指定编码解压,用 unzip -O CP936。伪加密的包优先用 7-Zip 打开并直接提取内容,因为 7-Zip 对伪加密的处理更宽松;也可以写个 Python 脚本用 zipfile 库读取,再把加密标志位清零后重新保存。我试过最省事的办法就是 7-Zip 直接拖出来,文件名乱码的情况再用 rename 脚本批量修正。

5.2 检测框全部偏移,目标对不准

现象:模型能检测出目标类别,置信度也不低,但画出来的框整体偏移,要么偏左上,要么被压缩变形。

原因:推理预处理阶段没有做 letterbox 等比缩放,直接把图像拉伸到 640。水下目标本身就存在透视变形,再被粗暴拉伸,模型输出的框坐标会系统性偏离真实位置。

解决:回到 inference.cpp,确认预处理里是否按比例缩放并在四周填充灰色。如果你修改了输入尺寸,比如从 640 改成 1280,ratio 计算必须同步更新。这个是推理代码里最容易改错的地方,我建议单独写一个可视化函数,把预处理后的图像保存下来看一眼,确认 letterbox 效果正常再跑完整推理。

5.3 所有目标类别都变成了第一个类

现象:推理结果里每个框的类别都相同,比如统统显示为 holothurian,但框的位置和置信度看起来是正常的。

原因:极大概率是输出张量解析的列数不对。YOLOv10 输出行长度是 5 + nc,如果你在代码里写死了 85 列,而模型实际输出是 84 列,解析错位后所有类别分数都会被截断或错位,最终 max_element 始终指向同一个位置,所有目标都落到第一个类。

解决:先打印出模型输出层的形状,确认 nc 和你实际类别数一致。然后逐个检查 CSV 类别顺序和训练时 data.yaml 的 names 是否对齐。我遇到过的情况是 CSV 里类别顺序和模型权重不一致,把 CSV 重新按 data.yaml 顺序生成就好了。这个问题的排查方法不算难,但需要你记得输出张量维度这个黑匣子。

5.4 怎么创建 yolov10 的 yaml 文件:先把 RUOD 的 VOC 标注转成 YOLO 格式

现象:RUOD 原始标注是 VOC 格式的 xml 文件,而 YOLOv10 训练要求每个图像对应一个 txt 文件,再加上一个 data.yaml 描述数据集路径和类别列表。很多人在这一步卡住,就是不知道 yaml 文件怎么创建。

原因:把 VOC 的 bndbox 坐标从绝对值转成归一化的中心点加宽高,再按类别映射关系写进 txt。YOLOv10 不认 xml,只认这种格式。

解决:下面这个转换脚本可以直接用,注意 names 列表的顺序必须和后面 data.yaml 完全一致:

# voc2yolo.py:把 RUOD 的 VOC 标注转换成 YOLOv10 训练格式 import os import xml.etree.ElementTree as ET # 这个顺序就是 data.yaml 里 names 的顺序,不要改 names = ['holothurian', 'echinus', 'scallop', 'starfish', 'crab', 'fish', 'shrimp', 'snail', 'jellyfish'] class_map = {name: idx for idx, name in enumerate(names)} def voc2yolo(xml_path, out_dir): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find('size/width').text) img_h = int(root.find('size/height').text) lines = [] for obj in root.iter('object'): name = obj.find('name').text if name not in class_map: continue box = obj.find('bndbox') x1 = float(box.find('xmin').text) y1 = float(box.find('ymin').text) x2 = float(box.find('xmax').text) y2 = float(box.find('ymax').text) cx = ((x1 + x2) / 2) / img_w cy = ((y1 + y2) / 2) / img_h bw = (x2 - x1) / img_w bh = (y2 - y1) / img_h lines.append(f"{class_map[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") out_path = os.path.join(out_dir, os.path.basename(xml_path).replace('.xml', '.txt')) with open(out_path, 'w', encoding='utf-8') as f: f.write('\n'.join(lines))

yaml 文件本身只需要六组键:path、train、val、nc、names。path 指向数据集根目录,train 和 val 分别是训练验证图片目录的相对路径,nc 是类别数,names 是关键,它的顺序就是转换脚本里 class_map 的赋值顺序。两边只要有一个不一致,训练出来的模型在推理阶段就会类别错乱。创建 yaml 不存在任何玄学,照着模板填空即可。

5.5 小目标全漏检,nano 和 xlarge 结果差距大

现象:用 yolov10n 跑 RUOD,小目标几乎全部漏检,换 yolov10x 明显改善,但推理耗时也显著增加。

原因:nano 参数量小,深层特征图的分辨率低,小目标特征在多次下采样后基本消失。这是模型容量带来的本质限制,不是代码 bug。

解决:如果对精度有要求,直接用 xlarge,并把推理输入尺寸从 640 提到 1280。输入尺寸增大后,小目标的特征图占位变大,模型有机会捕捉到细微纹理。但代价是推理时间翻倍,你在实时场景下需要自己权衡。我一般这样选:离线评测用 xlarge,实时部署用 nano 或 large,然后单独针对小目标做数据增强补偿。

6. 进阶调参套路:把置信度阈值当成 RUOD 的诊断工具

6.1 调参之前先想清楚:你要的是 mAP 还是实际召回率

RUOD 这类水下数据集的评测里,AP50 和 AP75 经常差十个点以上。AP75 低通常不只是因为定位不准,而是置信度整体偏低。海参的颜色和海底背景太过接近,模型给它的分数可能只有 0.1 到 0.2 之间,低于 YOLOv10 默认的 0.25。如果你一直用默认阈值看结果,会觉得模型漏检严重,其实框都在,只是分数没过线。所以我拿到任何水下检测权重,第一件事不是看 mAP,而是直接把置信度阈值从 0.25 一路扫到 0.05 观察输出框数量的变化。

6.2 用一个循环扫阈值,把每档召回率拉开看

具体操作很简单,脚本化跑多档阈值。

# 对同一模型、同一测试集,扫六档阈值批量出结果 for conf in 0.05 0.10 0.15 0.20 0.25 0.30; do ./inference --model yolov10x.onnx --classes yolov10x.csv \ --input ./test_images --output ./results_${conf} \ --conf ${conf} done

判断逻辑是我个人的经验:如果阈值从 0.25 降到 0.15,输出框数量增加了两三倍,说明模型本身是有输出的,只是阈值定得太保守,最终部署可以把阈值定在 0.1 附近。反过来,如果阈值降到 0.15 以下后,框数还在暴涨,但画面里出现一堆破碎的杂乱小框,说明模型把纹理误认成了目标,这时候要做的不是继续降阈值,而是回去检查训练数据里的类别顺序有没有对齐,或者考虑在预处理环节把图像的蓝色通道增强一下。

从那以后,我每拿到一套水下目标检测权重,第一件事永远是先跑一遍阈值扫描,用输出框数量的变化判断模型状态,而不是直接拿默认参数去评测。这套“阈值即探针”的习惯帮我省掉了至少两次因为类别映射错位而白跑的重训实验。希望帮到你。

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

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

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

立即咨询