搞水产养殖的朋友应该都有过这种经历:鱼塘里每天都有鱼莫名其妙地不吃食、浮头、身上长白毛或者烂鳍,等肉眼能清楚判断出病况时,往往已经传染开了一片。传统做法是靠老师傅的经验肉眼判断,可经验这东西没法批量复制,而且人在塘边连续盯几个小时也很难保证判断稳定。我之前接过一个类似的活儿,客户希望做一套能自动识别常见鱼病害的系统,拍一张照片或者传一段视频,程序能框出病灶区域并且给出类别和置信度。这个项目最终落地成了一套基于YOLOv10的鱼病害检测系统,配套了UI界面、Python推理脚本和一套标注好的数据集。今天把整个开发过程、踩过的坑和能直接复用的代码逻辑,一次说清楚。
这套系统的核心组成其实不复杂:采集和标注鱼类病害图像,做成YOLO格式的数据集;用YOLOv10模型做目标检测训练;把训练好的权重接到Python后端;再套一个可视化UI界面方便非技术用户操作。它解决的问题很直接——用深度学习模型替代人工肉眼巡检,把病害检测从“靠经验”变成“靠模型”。适合水产养殖技术员、做智慧农业项目的开发者、以及刚入门想拿一个完整项目练手的目标检测学习者参考。如果你手里已经有了一批鱼类图像,只是想快速搭建一套检测demo,这篇文章可以直接照着操作,路径是通的。
1. 整体设计与YOLOv10选型逻辑
1.1 为什么是YOLOv10而不是v5、v8
先说模型选型。项目启动前我对比过几个主流目标检测框架,YOLOv5生态成熟、资料多,YOLOv8在精度上更均衡,但最终选了YOLOv10,核心原因是它在推理速度上的优势太明显了。YOLOv10在2024年发布,最大的改动是去掉了传统目标检测里的NMS(非极大值抑制)后处理步骤,靠模型本身的双标签分配策略和一致性匹配损失来直接输出检测结果。
这里解释一下NMS是什么。传统检测模型在推理时会产生大量重叠的候选框,同一个目标可能被几十个框同时框住,NMS的作用就是把这些重复框压缩成一个最优框。这个过程本身有计算开销,而且NMS阈值设得不好还会误删小目标。YOLOv10把这一步省掉了,推理管线更短,延迟更低。我在实际测试中,同样一张640x640的图片,YOLOv10s的推理耗时比YOLOv8s大约低20%到30%,这在需要接实时视频流的场景里差别很明显。
另外一个实际考虑是部署环境。养殖场的机器大概率没有高端显卡,很多还是CPU推理。YOLOv10提供的nano和small版本参数量非常小,nano版本只有大约2.3M参数,在CPU上跑也能勉强达到实时。我做这个项目时目标机器是一台几年前的中端笔记本,没有独立GPU,所以模型轻量化是刚需,这也是选YOLOv10的第二个理由。
1.2 系统模块划分与技术栈确定
整个系统我拆成了四个独立模块,方便单独调试和替换:
- 数据层:负责图像采集、预处理、病害类别标注、数据增强和数据集划分。
- 模型层:基于Ultralytics框架的YOLOv10训练、验证和导出的完整流程。
- 推理层:加载训练好的权重文件,对图片、视频和摄像头画面执行检测,并输出结构化结果。
- UI层:基于PyQt5的桌面界面,用户通过按钮加载图片或打开摄像头,检测结果实时显示在窗口里。
技术栈选得比较保守,深度学习部分用PyTorch和Ultralytics YOLO库,界面部分用PyQt5。这套组合的资料多、坑少,对做项目交付来说是最稳妥的。如果选Streamlit或者Gradio做界面会更快,但交互体验和打包成exe的方便程度都不如PyQt5。尤其是客户那边要求双击运行、不要命令行操作,PyQt5几乎是唯一合理选择。
模型文件管理上,我单独建了一个weights目录,把训练好的best.pt放进去,推理脚本只负责加载这个文件。训练和推理环境尽量保持一致,避免模型能训练但加载时报错的尴尬情况。
2. 数据准备:鱼病害数据集的采集、标注与增强
2.1 病害类别定义与图像采集要点
做这类检测项目,第一步不是写代码,而是确认病害分类。我参考了常见淡水鱼养殖病害资料,结合客户实际需求,最终确定了白点病、水霉病、烂鳃病、赤皮病和肠炎病五个类别。
这里必须提醒一句:每个类别的定义一定要有典型图像依据,不能只是文字描述。因为模型学习的是像素特征,如果“白点病”的标注框里既有白点病也有其他寄生虫感染的特征,模型就会学混乱。我的做法是先收集一批权威病害图谱,和养殖基地的技术员一起筛查确认,确定每个类别下视觉特征比较统一、能被肉眼识别的图像才进入数据集。
图像采集主要有三个渠道:网络公开数据集、养殖基地实地拍摄、实验室培育感染样本的拍摄记录。实地拍摄要注意光线问题,鱼塘或鱼缸里的照片常常有水面反光、玻璃缸壁折射,这些干扰会让模型学到错误的特征。我的建议是尽量使用水下摄像设备或者顶拍方式,减少环境干扰。如果只能用普通手机拍,也务必保证鱼体主体清晰、病灶区域可见,模糊的图宁可删掉也不要勉强标注。
2.2 标注工具选择与YOLO格式说明
标注工具我推荐LabelImg,它虽然界面朴素,但胜在稳定,导出YOLO格式非常直接。每张图片生成一个同名的txt文件,文件里每行代表一个目标框,格式是:
class_id center_x center_y width height注意这里的四个坐标值都是归一化后的,范围在0到1之间,不是像素坐标。LabelImg会自动帮你算好,但自己写程序时很容易在这个格式上栽跟头。比如一张1280x720的图,框左上角坐标是(200, 150),宽高是(320, 240),那么:
center_x = (200 + 320 / 2) / 1280 = 0.28125 center_y = (150 + 240 / 2) / 720 = 0.375 width = 320 / 1280 = 0.25 height = 240 / 720 = 0.3333写代码做数据检查时,最好加一步校验,确保所有坐标值都在0到1区间内,否则训练时会直接报错。
标注的精细程度直接影响模型上限。我的经验是:一个目标只框主体和病灶区域,不要把整个鱼缸都框进去。比如烂鳃病,检测目标应该是鳃盖区域而不是整条鱼,否则模型会认为“鱼就是病害”,完全失去检测意义。这个细节我一开始没注意,第一批标注做完后训练出来的mAP极低,排查时发现问题全出在标注框范围太大,后来重新标了一遍,效果立竿见影。
2.3 数据增强策略与数据集划分
数据增强是提升泛化能力的关键手段,尤其是病害检测这种样本量本身不大的场景,增强几乎是必须的。我使用的增强策略包括:
- 水平翻转和垂直翻转,注意垂直翻转对鱼体图像来说不会产生不自然感,可以放心用。
- 随机旋转,角度控制在正负30度以内,过大容易让目标框溢出图像边界。
- 亮度对比度扰动,模拟不同养殖环境下的光照差异。
- 高斯模糊,模拟摄像头对焦不准时的画面质量。
- Mosaic增强,把四张图拼成一张,提升模型对小型目标和小样本类别的检测能力。
Ultralytics框架自带这些增强功能,不需要自己写代码,但要注意的是Mosaic在前几个epoch会打乱原图分布,如果数据集很小,建议训练后期关闭Mosaic,让模型在接近真实分布的数据上收敛。
数据集划分我按8:1:1的比例分成训练集、验证集和测试集。一个常见的错误是整个数据集直接随机划分,没有做类别均衡,导致某些小众病害类别在验证集里一个都没分到,训练时看起来mAP很高,实际验证集反馈很差。我写了一个简单的脚本,按类别比例做分层抽样,保证每个类别在每个集合里都有分布。
最终数据集规模在2400张左右,每类大约400到600张不等。对于检测任务来说这个量级不算多,但因为病害特征的视觉显著性较强,配合数据增强和迁移学习,效果可以达到实用水平。
3. 模型训练全流程与关键参数调优
3.1 环境配置与预训练权重选择
环境配置这部分很基础但容易出问题。用Anaconda创建独立Python环境,版本选3.9或3.10,然后安装PyTorch、Ultralytics和必要的依赖。
安装CUDA版PyTorch前先确认显卡驱动支持什么版本的CUDA。之前一次装机,因为驱动只支持CUDA 11.8,我却装了需要CUDA 12的PyTorch,结果torch.cuda.is_available()始终返回False,排查了很久才发现是版本不匹配。稳妥做法是去PyTorch官网,用对应的CUDA版本生成安装命令,不要随手复制一段命令就装。
预训练权重我选的是yolov10s.pt。为什么不选n?nano模型太小,在病害检测这种需要捕捉细节纹理的任务上,特征表达能力可能不足。为什么不选m或l?项目的目标机器是CPU推理,模型过大根本跑不动。s是精度和速度的平衡点,实测在验证集上mAP50能达到88%左右,CPU单张推理时间在2到3秒,处于可接受范围。
3.2 训练命令与关键超参数解读
训练前先准备好数据配置文件,假设项目根目录下有一个fish.yaml,内容大致如下:
path: /path/to/your/dataset train: images/train val: images/val test: images/test nc: 5 names: ['WhiteSpot', 'Saprolegniasis', 'GillRot', 'RedSkin', 'Enteritis']注意path最好写绝对路径。我之前用过相对路径,换了一台机器后训练时一直报找不到图片,排查半天发现是路径拼接出了问题,改成绝对路径后就不再出现。
训练命令:
yolo detect train \ model=yolov10s.pt \ data=fish.yaml \ epochs=150 \ batch=16 \ imgsz=640 \ optimizer=AdamW \ lr0=0.01 \ device=0 \ project=fish_disease_train \ name=exp_yolov10s几个关键参数说明一下:
epochs=150,对于小数据集来说150轮足够收敛。如果训练到后期mAP还在缓慢上升,可以适当延长到200轮,但要注意过拟合风险。batch=16,这个值取决于显存大小。我的训练显卡是8GB显存,s模型640分辨率下batch=16刚好放得下,如果显存不足会报OOM,最简单的处理是按2的倍数往下调,比如8或者4。imgsz=640是默认值,病害区域在图像中占比往往不大,如果分辨率足够,也可以用768甚至1024,但训练和推理时间会明显增加。我的建议是优先保持640,通过数据增强来补充细节。optimizer=AdamW,Ultralytics默认的优化器是SGD,但AdamW对新手更友好,超参数不敏感,在小数据集上收敛更稳定,不容易出现loss爆炸。
还有一个容易被忽略的参数是patience,它控制早停机制。如果连续多少轮验证集mAP没有提升,训练就会提前停止。默认值是50,我认为对小数据集来说太宽松了,设置成30比较合理,可以节省时间。
3.3 训练结果监控与模型验收标准
训练过程中,在控制台和runs/detect/exp_yolov10s/目录下能看到实时输出的指标,包括每个epoch的box_loss、cls_loss、precision、recall和mAP50。我的习惯不是只看最终结果,而是重点观察训练过程中loss的下降曲线是否平滑。
如果loss曲线在某个阶段出现剧烈震荡,大概率是学习率偏高或者batch太小。处理办法是把lr0降到0.001,或者略微增大batch。如果训练集loss下降但验证集loss反弹,那就要考虑过拟合,通过增加数据增强强度或者提前停止来解决。
训练完成后,验证集上的几个核心指标我的验收底线是:
- mAP50在85%以上,低于这个数值说明模型学到的特征还不够稳定。
- 每个类别的AP值相差不要超过15个百分点,否则说明个别类别的样本量太少或者特征区分度不足。
- 混淆矩阵对角线占比高。如果白点病经常被识别成水霉病,说明这两个类别的训练样本在视觉特征上太接近,需要进一步筛选图像。
模型验证命令:
yolo detect val \ model=best.pt \ data=fish.yaml \ split=val验证完以后,最终的权重文件是best.pt,它在验证集上表现最好。测试集上的结果可以放到后续UI里实测,因为真实养殖环境的图像和训练集分布存在差异,这个阶段的实测反馈才最有说服力。
4. UI界面开发与推理管线实现
4.1 界面功能规划与PyQt5布局方案
桌面端UI我用PyQt5开发,整体界面分成三个区域:左侧是操作区,中间是图像显示区,右侧是检测结果列表。操作区放三个按钮:打开图片、打开摄像头、开始检测。图像显示区使用QLabel显示图片和绘制了检测框的结果。结果列表用QTableWidget展示每个检测框的类别、坐标和置信度。
界面设计看起来像传统桌面软件,但恰恰是这种风格最容易被养殖场的操作人员接受。他们不习惯网页风格的交互逻辑,反而觉得按钮式桌面界面直观、不容易误操作。摄像头检测我放在后台线程处理,避免主界面因为模型推理阻塞导致假死。
这里有一个核心的代码结构问题:PyQt的界面线程和模型推理线程必须分离。如果直接在按钮点击回调里加载模型并执行检测,界面会卡住几秒,用户会以为程序崩溃了。我用QThread写了一个专门的工作线程,通过信号把检测结果传回主界面线程更新显示。信号槽机制是PyQt的基石,这里只要确保别在子线程里直接操作UI控件就行。
4.2 推理代码实现与关键函数解析
模型加载和推理的代码本身不复杂,核心部分如下:
from ultralytics import YOLO model = YOLO("weights/best.pt") def run_inference(img_path=None, frame=None, conf_thres=0.35): if img_path is not None: results = model.predict(img_path, conf=conf_thres, imgsz=640, verbose=False) elif frame is not None: results = model.predict(frame, conf=conf_thres, imgsz=640, verbose=False) else: return None boxes = [] for r in results: for box in r.boxes: cls_id = int(box.cls[0]) conf = float(box.conf[0]) x1, y1, x2, y2 = box.xyxy[0].tolist() boxes.append({ "class": model.names[cls_id], "confidence": round(conf, 4), "bbox": [int(x1), int(y1), int(x2), int(y2)] }) return boxesconf_thres是置信度阈值,默认设成0.35。这是我在实际测试中调整出来的经验值。如果阈值调得太高,比如0.7,小而浅的病灶区域很容易被过滤掉;调得太低,比如0.1,又会输出大量误检框。0.35在多数光照条件下表现均衡。使用时要引导用户通过UI上的滑块动态调整阈值,不同养殖环境的光线和图片质量差异很大,固定阈值没法适配所有场景。
摄像头检测循环里面,一个实用的优化是跳帧处理。模型推理在CPU上耗时两秒多,而摄像头是30帧每秒的实时画面,硬撑实时检测没有意义。我的做法是每3帧采样一次送进模型,中间帧直接显示原画面。这样既保证了检测的连续性,又不会让程序因为推理阻塞而卡顿。实测下来检测频率大约每秒0.5次到1次,对病害识别这种低频变化场景完全够用。
4.3 检测结果可视化与打包部署细节
在图像上绘制检测框,我选择用OpenCV的rectangle和putText函数,因为推理结果本来就是numpy数组格式,直接在数组上绘制比用QPainter更简单。绘制完成后,把numpy格式的BGR图像转换回RGB,再转成QPixmap显示到QLabel上。
import cv2 from PyQt5.QtGui import QImage, QPixmap # img_bgr 是绘制完检测框的numpy数组,假设为BGR格式 img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) h, w, ch = img_rgb.shape qimage = QImage(img_rgb.data, w, h, ch * w, QImage.Format_RGB888) pixmap = QPixmap.fromImage(qimage).scaled(ui_label.width(), ui_label.height(), Qt.KeepAspectRatio) ui_label.setPixmap(pixmap)注意QImage这里第五个参数ch * w,表示每行字节数。这个参数如果写错,图像显示出来会歪斜或者颜色混乱。我两次踩坑都是在这里,后来直接固定写成ch * w不再改动。
项目打包成exe用PyInstaller,有几个细节容易漏:
best.pt模型文件要加进打包目录,PyInstaller默认不会识别.pt文件,需要手动指定--add-data。- 模型文件路径最好是动态获取,不要写死绝对路径。打包后程序运行目录和开发目录不一样,可以用
sys._MEIPASS处理临时解压路径,或者把模型放在exe同级的weights目录下,用相对路径加载。 - 打包体积预估在150MB左右,因为要附带PyTorch和CUDA运行库,这个客户能接受,如果追求更小体积可以考虑用ONNX Runtime推理引擎,但会牺牲一部分模型兼容性。
5. 常见报错与排查技巧实录
5.1 训练阶段典型报错与解决思路
训练阶段的报错是最多的,而且很多错误信息看起来完全不知所云。这里整理几个我最常遇到的问题。
报错一:CUDA out of memory
这个最直接,就是显存不够。错误信息很长,一般会告诉你需要多少显存、当前剩余多少。处理方法按优先级排序:先调小batch,从16降到8,再不行降到4;其次调小imgsz,从640降到512或者480;最后再考虑改用更小的模型,从yolov10s降到yolov10n。记住先调batch再调imgsz,因为batch对收敛效果的影响比imgsz更小。
报错二:No labels found in ...
训练一开始就提示数据集里没有找到标签。排查步骤:先看labels目录下有没有txt文件,再看文件名是否和对应图片名完全一致,包括后缀。一张001.jpg的图,对应的标签文件必须是001.txt,不能是001_jpg.txt或者001_副本.txt。另外确认fish.yaml里的train和val路径是否指向了包含images的目录,而不是labels目录。
报错三:loss不下降且数值很大
这种情况我遇到过两次。一次是标注框坐标值异常,有个框的w或h写成了负数,check数据的时候没发现;另一次是数据类别ID和yaml里names的顺序对不上,模型训练时把类别学乱了。检查办法是写一个脚本,逐条读取每个txt标签,检查坐标是否在0到1之间、类别ID是否小于nc值。
import os labels_dir = "labels/train" for txt in os.listdir(labels_dir): with open(os.path.join(labels_dir, txt), "r") as f: for line in f: parts = line.strip().split() if len(parts) != 5: print(f"Invalid line: {txt}: {line}") continue class_id, cx, cy, w, h = parts if not (0 <= float(cx) <= 1 and 0 <= float(cy) <= 1): print(f"Out of range: {txt}: {line}")5.2 推理与UI界面阶段的高频问题
问题一:模型加载后预测结果全部为空
最常见原因是置信度阈值设置过高。用model.predict(img, conf=0.7)时,如果图像里的病灶特征本来就不明显,所有检测框都会被过滤掉。排查方法是先调低conf=0.1看一下是否有大量低置信度框,如果有,说明阈值没选对;如果还是没有,再检查图像预处理,确认输入尺寸和模型训练时一致。之前我在摄像头帧上做推理时,忘了把BGR转成RGB,模型输入的颜色通道翻转导致检测结果异常,这个错非常隐蔽,因为图片人眼看着没问题,颜色就是反的。
问题二:摄像头检测画面卡顿
核心原因是推理占用了太多时间。我推荐的跳帧策略已经说过。另外把推理分辨率从640降到480也能显著提速,代价是检测精度略微下降。如果部署机器有GPU,记得确认torch.cuda.is_available()为True,很多人程序装了GPU版PyTorch但实际在用CPU跑,速度差几十倍。检查命令:
import torch print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU only")问题三:检测框在视频画面里抖动严重
这个问题的根源是模型在连续帧上输出的框位置有微小波动。轻微抖动是正常的,如果抖动严重到影响查看,可以考虑在推理输出后加一个简单的跟踪逻辑,用类别和坐标做帧间匹配,取最近几帧的平均值作为稳定框。不需要引入复杂的Track算法,因为病害目标是相对静止的,简单的滑动平均就能有效抑制抖动。
5.3 数据集与模型泛化性的经验补遗
最后补充一个关于模型泛化能力的经验。YOLOv10在训练集上表现好不代表在真实养殖环境里同样好,我遇到过验证集mAP达到90%,但拿去新鱼塘实测时效果明显下降的情况。原因在于训练图像主要来自室内鱼缸,背景颜色单一、光线稳定,而实际养殖场景背景复杂、水面波动、反光强烈。
解决这个问题有两种思路。第一种最实用:采集一批不同环境下的小样本,做半天的增量训练,也就是用新采集的数据继续微调模型。第二种是增加推理端的预处理,对图像做对比度增强或者去雾处理,减少环境干扰。两种方法不冲突,可以组合使用。
这个项目做完以后,我最大的感触是:模型训练只是整个系统里比较小的一个环节,真正花时间的是数据标注和边界情况处理。尤其是病害检测这种垂直场景,没有现成的大规模公开数据集,一切都要从原始图像和人工标注开始。如果准备接手类似项目,建议把时间预算重点放在数据处理上,模型部分反而是最快能跑通的。对自己动手做一套完整系统的人来说,拿到一份标注数据、跑通训练和推理、再套一个界面,这个流程本身也比任何单点技术更有价值。