
我在本地跑通这套基于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 lines2.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库就直接能用YOLOv10pip 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训练命令用官方CLIyolo detect train datadata/fish.yaml modelyolov10n.pt epochs200 imgsz640 batch16 device0我加了个早停参数连续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.2hyolov10s7.2M0.9340.741约6.8ms约4.5hyolov10m15.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_thr0.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, confself.conf_thr, imgsz640, verboseFalse) 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时打印详细日志verboseFalse可以避免控制台被刷爆也减少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 modelweights/best.pt formatonnx opset12导出之后最好做一次一致性检查拿几张典型的现场图片分别用原始PyTorch模型和ONNX模型跑一遍对比输出的检测框坐标和置信度确保精度没有明显下降。我遇到过一次导出后所有框的置信度都低了0.1到0.2的情况最后发现是opset版本和部署端不匹配换成opset 12才解决。如果以后要上更边缘的设备还可以继续转成TensorRT或其它硬件加速格式但在转之前建议想清楚现场是不是真的需要每毫秒抠性能我用YOLOv10s做桌面端推理已经非常流畅边缘设备如果算力不够换成YOLOv10n再量化同样能接受。这个取舍要按实际需求走不要为了技术而技术。最后说点个人体会。我把这套系统从一个训练Demo变成现场可用的工具最大的感受是模型训练只占整个项目三分之一的时间剩下的时间都在和业务侧对齐。病害检测的难点不是把准确率从90提到95而是让系统在真实的水花、反光、群游遮挡里保持稳定并且让养鱼的人愿意看界面上的提示。几个建议一是所有阈值都要去现场调不要迷信训练时的默认值二是检测结果只作为辅助参考用药和处理方案一定要请当地技术人员确认三是给系统留一个“导出当天检测日志”的功能半个月后回看你会发现它比人眼记录可靠得多。