☰
基于YOLOv10的鱼病害检测系统实战:从数据集标注到部署全记录
2026/10/17 16:32:55 网站建设 项目流程

我在本地跑通这套基于YOLOv10的鱼病害检测系统时,第一反应不是“模型终于训出来了”,而是“这玩意总算能拿给养殖现场的人用了”。之前看苗池里的鱼不对劲,全靠人蹲在池边捞起来凑近看,等肉眼看出来,往往已经传染了一小片。YOLOv10做鱼病害检测,本质上是把“发现异常”这件事从人工巡检变成持续视觉监测:检测鱼身上的溃烂、白点、鳍条缺损等可见症状,再通过UI界面把结果直观标出来。

这篇就完整记录我搭建这套系统的过程:从病害检测任务怎么拆解、YOLOv10为什么值得选,到数据集标注、训练调参、UI界面整合,再到部署现场遇到的真实坑。适合做目标检测入门项目的人、水产养殖信息化方向的开发者,以及想把手头模型包装成能演示、能实操的小系统的人。

1. 这个项目到底要解决什么问题:鱼病识别和普通检测有什么不一样

1.1 养殖现场的真实照片为什么难检

鱼病害检测和行人检测、车牌识别最大的区别在于:目标小、对比度低、背景极其不可控。鱼在游动,姿态会弯曲、翻转、重叠,病灶往往只占整张图像的百分之几。比如白点病,早期体表白点可能只有几个像素;水霉病远看是一团棉絮,近看菌丝和鱼体边界是糊在一起的。人眼都需要蹲下来仔细观察,模型想直接学会更是难事。

养殖池的拍摄环境也远没有实验室干净。水面反光、气泡、饲料残渣、悬浮有机碎屑都会出现在画面里。这些干扰往往和病灶在纹理上高度相似——水霉病的白色菌丝像一团气泡,白点病的小白点和漂浮的颗粒几乎无法从单帧区分。鱼群密度高的时候,后面的鱼被前面的鱼挡住,病灶被遮挡严重,模型只能依靠局部特征判断,这对小目标检测能力的要求非常高。

另外,病害之间还容易相互混淆。烂身和烂尾都可能出现皮肤发白、组织缺损,细菌感染和真菌感染在初期肉眼特征非常接近。所以这不是一个简单的“把鱼框出来”的任务,而是要精准地定位到鱼身上的异常区域,并且尽量区分是哪种类型的症状。这也是我后来在标注时花大量时间做标准统一的原因。

1.2 为什么最终选了YOLOv10而不是YOLOv8或更重模型

先说结论:我对比过YOLOv8和YOLOv10,也看过一些基于Transformer的检测方案,最终把训练重心放在YOLOv10上。YOLOv10最核心的变化是去掉了NMS后处理。传统目标检测在训练阶段会为同一个目标生成大量重合的候选框,推理时要用非极大值抑制把重叠框压成一个,这个过程既耗时,也容易在鱼群密集场景下误删正确框。YOLOv10采用了一致性匹配策略,训练时就让一个目标只对应一个正样本,推理时模型直接输出干净的检测结果,不需要额外维护NMS逻辑。

这一点对鱼病害场景特别有价值。鱼体姿态多变、鱼群密集,传统NMS在大量相近框同时出现时容易抖动,同一个病灶这一帧保留一个框,下一帧保留另一个框,检测结果会不稳定。YOLOv10把框生成的逻辑简化之后,连续帧之间的框位置更平滑,也更适合视频流监测。再加上它引入了轻量化分类头、空间通道解耦下采样和部分自注意力机制,在保持精度的同时明显降低了计算量,这对后续部署到现场小型工控机或边缘设备很关键。

我也认真考虑过要不要用更大规模的模型或Transformer方案。诊断精度的确可能更高,但水产养殖现场很少配高端GPU,一台能稳定跑实时推理的迷你主机就已经算不错的条件了。在算力有限的情况下,YOLOv10的n、s、m几个尺寸跨度很好,可以先拿nano模型做快速验证,再用small或medium模型追求精度,这个梯度非常实用。YOLOv8当然也成熟稳定,但既然v10在推理延迟和后处理上更占优势,我没理由抱着旧版本不放。

1.3 选型结论与本项目的实际取舍

这个项目的最终形态是一个桌面端工具:用户打开软件,选择本地图片或连接摄像头,系统实时在画面中画框并显示病害类别和置信度。整个项目我按这样的结构组织:

fish_disease_project/ ├── data/fish.yaml ├── datasets/fish_disease/ │ ├── images/train/ │ ├── images/val/ │ ├── labels/train/ │ └── labels/val/ ├── weights/best.pt ├── app/ │ ├── main_window.py │ ├── detect_thread.py │ └── config.py └── scripts/ ├── train.py └── export_onnx.py

我的取舍标准是“先能跑,再跑好”。第一版用YOLOv10n快速验证全流程,确认数据标注格式没问题、UI能读通模型之后,再换成YOLOv10s做最终版本。别一开始就追求最高精度,否则数据、代码、界面三个环节互相牵连,出了问题很难定位。项目做到后面你会发现,真正卡时间的不是模型训练,而是数据质量和现场适配。

2. 数据集里藏着系统80%的成败

2.1 类别怎么定、样本怎么配

标题里写的“YOLO数据集”,实际指的是YOLO格式的标注数据:每张图片对应一个txt文件,每行记录类别编号、归一化中心点坐标、宽高。数据质量直接决定模型上线后的表现,这一步投入的时间绝对值得。

分类一定要克制。我最初想把每类病害再按“轻度、中度、重度”细分,感觉这样更专业,但很快就发现样本量被严重稀释,模型反而被角度和光线干扰带偏。最后收敛成了5类:水霉病、烂身/烂尾、白点病、细菌性出血斑,外加一类健康鱼。健康鱼这个负样本类非常重要,它是压制误报的关键,不能省。

样本数量上,我给每类设置了底线:常见类别至少1000张,难辨别的类别多拍角度,健康鱼给到接近2000张。训练集原始图片一共6800张,通过数据增强扩到2万多张。划分比例是训练集72%、验证集20%、测试集8%。特别提醒一点:划分时按“视频片段”而不是按“单帧”来分,否则同一个场景的连续帧会同时出现在训练集和验证集里,评估结果虚高得离谱。

2.2 标注队列里最容易返工的三类样本

标注不是拿鼠标画框那么简单,最难的是定标准,而且要有书面标准。如果团队里几个人各标各的,后面返工到崩溃。我们最常返工的有三类。

第一类是边界框到底框住什么。我一开始习惯把整条鱼框进去,但发现模型对小病灶的定位精度不够。后来统一为:框住病灶部分,并保留一点边缘余量;如果病灶长出鱼体之外,比如水霉病的絮状菌丝,框就要包含菌丝部分。否则模型学到的是“一团鱼身上的东西”,而不是“附着在鱼体表的异常组织”。

第二类是相似样本是否要标。白点病的点很小,部分图片放大也要仔细看才敢确认,这时候最容易凭感觉标。我的处理方式是:看不清的图先丢进“待确认”文件夹,绝不硬标。宁可样本少一点,也不要错误标签混进训练集。

第三类是明明没有病却被标成病。这和标注人员的经验直接相关,水面光斑、鱼体上的反光、饲料残渣都可能被当成病灶。为了控制这种噪声,我们让两个人独立标注同一批图,不一致的地方全部拉出来人工复核。这个流程做下来,明显感觉到验证集的mAP和真实表现对得上号了。

标注工具选LabelMe或LabelImg都可以,导出格式是COCO或VOC的话,需要转成YOLO格式。核心转换逻辑不复杂,就是读取多边形顶点,换成归一化中心点、宽度、高度:

import json def convert_labelme_to_yolo(json_path, class_map): with open(json_path) as f: data = json.load(f) h, w = data["imageHeight"], data["imageWidth"] lines = [] for shape in data["shapes"]: label = shape["label"] pts = shape["points"] xs = [p[0] for p in pts] ys = [p[1] for p in pts] cx = ((min(xs) + max(xs)) / 2) / w cy = ((min(ys) + max(ys)) / 2) / h bw = (max(xs) - min(xs)) / w bh = (max(ys) - min(ys)) / h lines.append(f"{class_map[label]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") return lines

2.3 数据增强:把一池水的变化提前装进训练集

数据增强不是为了凑数量,而是把现场可能出现的拍摄条件提前模拟进训练集。水下低光、水面反光、鱼游动产生的动态模糊、不同水色的背景,这些都会影响实际表现。我用的是YOLO自带的增强组合:Mosaic、mixup、随机透视、HSV扰动、亮度对比度扰动。Mosaic能把四张图拼成一张,让模型看到更丰富的背景和鱼群密度变化;mixup适合让模型在两类特征叠加时不过度自信。

但增强有两个原则要守住。第一,验证集和测试集不要开Mosaic和mixup,否则模型在增强数据上见过类似合成图,验证得分会失真。第二,增强幅度不能无脑拉满。我试过把HSV饱和度调得很高,结果模型在正常水质下发懵,把健康鱼的颜色变化也当成了病害信号。合理的做法是只在训练阶段开启增强,并且每次调整增强参数后都跑一轮短训练,对比验证集损失变化,而不是一次性堆一堆参数。

注意:增强解决的是“拍摄多样性”问题,解决不了“样本标准错误”问题。先保证标签干净,再谈增强。

3. 训练实录:从损失曲线到一次典型误报修复

3.1 训练环境和fish.yaml配置

我的训练环境是Python 3.10、PyTorch 2.x、CUDA 12.x,安装Ultralytics库就直接能用YOLOv10:

pip install ultralytics

数据集配置文件放在data/fish.yaml,内容很简单:

path: /data/fish_disease/ train: images/train val: images/val names: 0: healthy 1: saprolegniasis 2: fin_rot 3: ichthyophthirius 4: bacterial_sore

训练命令用官方CLI:

yolo detect train data=data/fish.yaml model=yolov10n.pt epochs=200 imgsz=640 batch=16 device=0

我加了个早停参数,连续30轮验证集没有提升就自动停止。训练到140轮左右时发现验证集损失已经不再下降,就手动停了,取最佳权重作为后续版本。用预训练权重而不是从零训练,收敛速度快很多,对小型数据集尤其明显。

3.2 跑完200轮我盯住了哪些指标

训练时不能只看一眼验证集mAP就完事。我最关注四个东西:box_loss、cls_loss、dfl_loss三条曲线是否同步下降;mAP50和mAP50-95的差距;PR曲线在低召回区间的形态;置信度阈值对应的F1分数。

对鱼病害这种小目标任务,mAP50-95比mAP50更有参考价值。mAP50只要求预测框和真实框的IoU超过0.5就判定命中,而mAP50-95在0.5到0.95之间取多个档位,对框的位置精度要求苛刻得多。病灶区域本身就小,框偏移几个像素就可能导致后续判断不准,所以我在调参时更看重mAP50-95的数值变化。

训练早期mAP50涨得很快,前50轮基本能到0.8以上,之后进入平台期。如果发现分类损失下降但框位置抖动,说明模型在死记图像背景而不是病灶特征,这时候我会回去看标注质量,而不是继续加训练轮数。

3.3 案例:验证集mAP不错,一到现场就误报

这是我自己踩过最大的一个坑,值得单独说。训练完成后,验证集mAP50在0.92左右,数值非常好看。拿到现场一跑,问题立刻暴露:健康鱼经常被标成烂身,置信度最高能到0.7以上。

我的排查思路是这样的。首先怀疑过拟合,但通过观察误报样本发现,被误判的鱼身上往往沾着饲料残渣和污物,颜色纹理和病灶确实接近。然后我用脚本统计了训练集里各类别的分布,发现健康鱼只占15%,而且绝大多数健康鱼样本是在清澈水体、蓝色背景、正上方俯拍条件下拍的。现场水体偏黄绿,光线是从侧面直射进池里的,水面上还有漂浮物,模型根本没有见过这种环境下的健康鱼,于是就把水渍和纹理变化当成了病斑。

修复动作分三步:第一步,专门去现场补拍了一大批健康鱼照片,覆盖不同水色和光线角度,让负样本比例提升到接近30%;第二步,把现场水面的反光样本加入训练集,并且对健康鱼单独做了亮度扰动;第三步,把推理置信度阈值从默认的0.25调整到0.4,宁可漏掉低置信度的疑似框,也不要频繁打扰管理人员。改完之后,误报数量从每10分钟十几次降到了每10分钟两次以内,而且这些误报基本都集中在强反光区域,可以接受。

重要:验证集mAP高不代表现场可用。一定要设一个“现场测试集”,单独收集现场环境图片,用来做上线前的最后验证。

3.4 模型尺寸在本地硬件上的实测对比

我训练的硬件是本地一张12G显存的显卡,分别跑了YOLOv10n、YOLOv10s和YOLOv10m。参数设置相同,训练200轮左右,结果如下:

模型参数量mAP50mAP50-95单帧推理耗时200轮训练耗时
yolov10n2.3M0.9210.713约4.8ms约3.2h
yolov10s7.2M0.9340.741约6.8ms约4.5h
yolov10m15.4M0.9410.756约10.5ms约6.2h

最终我选YOLOv10s作为正式版本,因为精度和速度之间最均衡;nano模型作为备用快速版本,适合在CPU机器上做演示;medium模型在服务器上可以跑,但在现场工控机上风扇转得厉害,我放弃了。这个对比过程说明一个道理:不要盲目追求高精度模型,要根据部署硬件的实际情况倒推模型尺寸。

4. UI界面与推理链路:不能只会训练,还要能给人用

4.1 PyQt5界面与推理线程怎么分工

工程上最容易被忽略的问题是“UI会卡”。如果直接在界面线程里跑模型推理,视频帧率会掉到个位数,鼠标拖动窗口都困难。正确的做法是把推理放到单独的线程,主线程只负责刷新界面和接收信号。

我用的PyQt5做桌面端界面,没有选Web方案,是因为养殖场环境经常断网,桌面程序打开就能用,一个摄像头一根电源线就够。界面布局上,中间是大块视频预览区,左侧是操作面板,提供“打开图片”“打开摄像头”“开始检测”“停止检测”四个按钮,还有一个置信度滑块;右侧是检测结果列表,显示类别名称、置信度和时间。

用QThread实现时,核心信号就一个:把推理结果连同原始帧一起发回主线程。要特别注意在关闭窗口时请求线程中断并等待线程退出,否则程序会一直占用摄像头资源,导致下次启动设备被占用。

4.2 核心推理代码与检测框后处理

加载模型和推理的代码非常简洁,因为YOLOv10省掉了NMS这一步,整个推理循环里不再需要手动维护一堆候选框:

import cv2 from PyQt5.QtCore import QThread, pyqtSignal from ultralytics import YOLO class DetectThread(QThread): frame_signal = pyqtSignal(object, list) def __init__(self, model_path, source, conf_thr=0.4): super().__init__() self.model = YOLO(model_path) self.source = source self.conf_thr = conf_thr def run(self): cap = cv2.VideoCapture(self.source) while not self.isInterruptionRequested(): ret, frame = cap.read() if not ret: break results = self.model.predict(frame, conf=self.conf_thr, imgsz=640, verbose=False) boxes = [] for r in results[0].boxes: boxes.append((r.xyxy.tolist()[0], float(r.conf), int(r.cls))) self.frame_signal.emit(frame, boxes) cap.release()

绘制检测框是在主线程里做的,把框和标签画到一份拷贝的帧上,避免影响后续帧处理:

def draw_boxes(frame, boxes, names): for xyxy, conf, cls in boxes: x1, y1, x2, y2 = map(int, xyxy) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) label = f"{names[cls]} {conf:.2f}" cv2.putText(frame, label, (x1, max(0, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return frame

这里有两个细节值得注意。一是输入尺寸,模型默认用640x640,界面上传的高清图片会被压缩缩放,少量信息损失是正常的,但不要随意改成更大的输入,除非显存很充裕;二是不要在每次predict时打印详细日志,verbose=False可以避免控制台被刷爆,也减少I/O开销。

4.3 预警信息如何输出成养殖户看得懂的内容

UI界面如果只显示“class 2, 0.83”,对养殖户来说就等于没说。我把每个检测类别映射成一句人类可读的提示,并明确标注为“辅助参考”:

  • 水霉病:体表疑似真菌感染,建议先隔离观察,检查水体温度变化。
  • 烂身/烂尾:鳍条或体表存在组织缺损,注意水质和捕捞操作,持续观察。
  • 白点病:发现细小白色颗粒,警惕交叉感染,请知会技术人员。
  • 细菌性出血斑:体表出现红色淤血或溃烂,不建议自行用药,请当地专业人员确认病因。

这条逻辑简单但非常关键。系统本质是视觉筛查工具,不能变成“自动开药方”的机器,诊断结论一定要留给专业人员。界面右侧再加一个统计面板:当天检测帧数、各病害出现频次、最近一次告警时间。我再加了一行“保存告警图片”开关,只要触发病害类检测,就自动把当前帧存到本地目录。这样管理人员第二天回看日志,比靠记忆判断靠谱得多。

5. 落地部署阶段遇到的坑和我的处理方式

5.1 摄像头取流与画面调光的细节

部署到现场后,第一个问题居然是摄像头采集。USB摄像头直接cv2.VideoCapture(0)打开,默认使用YUYV格式,传输带宽占用大,画面容易卡顿和马赛克。我把采集格式改成MJPG,分辨率降到1280x720,帧率限制在15帧,画面瞬间就稳定了:

cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FPS, 15)

如果用的是网络摄像头或RTSP协议,需要在打开时设置缓冲区大小,否则画面延迟高达几百毫秒。现场光线的变化同样棘手,池边的自然光早晚差异很大,自动曝光会导致某一时段画面突然偏亮或偏暗。我的处理方法是锁定曝光时间和白平衡,固定一个适合当前池子环境的参数,然后定期手动校准,而不是让摄像头自己频繁调节。

安装角度也影响检测效果。俯拍能看到鱼背和整体分布,但体表的烂身病灶往往在鱼体侧面;平视能看到病灶,但鱼群重叠严重。我实测下来,摄像头以约45度倾角安装在池角,配合一盏漫反射补充光源,是信息量最大的组合。

5.2 反光、水泡、悬浮物带来的误报处理

训练时再怎么加数据增强,真实现场总会出现没见过的东西:水面漂浮的树叶、水管滴落激起的水花、鱼群甩尾打起的水泡。这些区域经常被模型当成低置信度的病灶,产生零散的小框。

我的处理方式是三层过滤。第一层,提高置信度阈值到0.4,过滤掉明显的噪声框;第二层,在代码里增加最小检测框面积限制,太小的框直接丢弃,因为病灶至少要占鱼体一定比例,不可能只有几个像素;第三层,对连续帧做简单的“确认后再提示”逻辑,同一病害类别至少连续出现3帧以上才在界面上触发告警。这个逻辑不复杂,但能极大减少偶发误报。

提示:少量误报是允许的,只要它不淹没真实告警。目标不是把误报降到零,而是让管理人员愿意信任这个系统,愿意每天打开它。

5.3 导出ONNX与边缘设备运行的一点记录

项目最后阶段,我把训练好的模型导出成ONNX格式,方便脱离PyTorch环境运行:

yolo export model=weights/best.pt format=onnx opset=12

导出之后最好做一次一致性检查:拿几张典型的现场图片,分别用原始PyTorch模型和ONNX模型跑一遍,对比输出的检测框坐标和置信度,确保精度没有明显下降。我遇到过一次导出后所有框的置信度都低了0.1到0.2的情况,最后发现是opset版本和部署端不匹配,换成opset 12才解决。

如果以后要上更边缘的设备,还可以继续转成TensorRT或其它硬件加速格式,但在转之前建议想清楚:现场是不是真的需要每毫秒抠性能?我用YOLOv10s做桌面端推理已经非常流畅,边缘设备如果算力不够,换成YOLOv10n再量化,同样能接受。这个取舍要按实际需求走,不要为了技术而技术。

最后说点个人体会。我把这套系统从一个训练Demo变成现场可用的工具,最大的感受是:模型训练只占整个项目三分之一的时间,剩下的时间都在和业务侧对齐。病害检测的难点不是把准确率从90提到95,而是让系统在真实的水花、反光、群游遮挡里保持稳定,并且让养鱼的人愿意看界面上的提示。

几个建议:一是所有阈值都要去现场调,不要迷信训练时的默认值;二是检测结果只作为辅助参考,用药和处理方案一定要请当地技术人员确认;三是给系统留一个“导出当天检测日志”的功能,半个月后回看,你会发现它比人眼记录可靠得多。

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

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

立即咨询