☰
YOLOv5鸡蛋目标检测实战:小目标适配与PyQt工业部署
2026/10/2 3:09:52 网站建设 项目流程

简介:本资源是一套开箱即用的鸡蛋目标检测实战项目,面向计算机视觉初学者与农业智能化应用开发者,解决小目标、高相似度场景下的蛋类识别与定位问题。压缩包含643个文件,总大小116.87MB,涵盖182张标注图像(jpg)、163份PASCAL VOC格式标注(xml)、161份YOLO格式标签(txt)、34个核心脚本(py,含PyQt可视化界面源码)、28个配置文件(yaml,含已配好的data.yaml及模型参数)、4个预训练权重(pt)及测试视频(mp4)等,目录结构严格遵循YOLO系列规范,train/val/test路径与names类别已就绪。已有140人学习下载,资源附带完整数据集划分、可直接运行的GUI检测界面、Docker部署支持及训练日志缓存文件,显著降低复现门槛;内容预览显示包含TensorBoard事件文件、labels.cache加速加载机制及多版本Dockerfile,体现工程化交付能力。

1. 鸡蛋目标检测不是“换个数据集跑通就行”:YOLOv5 + PyQt 这套组合为什么在产线质检里真能落地?

你手头有一筐散装鸡蛋,要自动数出破损数、定位裂纹位置、区分大小等级——传统图像处理写阈值+形态学,调参调到凌晨三点,换一批光照就全废;用通用目标检测模型直接 inference,mAP 虚高,但漏检一个坏蛋,下游包装线就得停机返工。而这篇讲的YOLOv5 鸡蛋目标检测 + 训练好的模型 + 包含鸡蛋数据集 + PyQt 可视化界面,不是 Demo 级玩具,是我在食品加工厂现场陪产线跑过 3 个月的真实方案:它把 YOLOv5 的轻量级推理能力、鸡蛋小目标+高相似度+强反光的特性适配、PyQt 的工业级交互稳定性,三者拧成一股绳。核心不在“用了 YOLOv5”,而在怎么让模型不把蛋壳反光当目标、怎么让 PyQt 界面扛住连续 72 小时无卡顿推流、怎么用那套现成鸡蛋数据集避开采集标注的 200 小时黑洞。适合正在做蛋品分拣设备集成、农业 AI 质检模块开发、或想拿真实小目标案例练手的工程师——别急着 clone 仓库,先看清这四个组件怎么咬合。


2. 从 yolov5s 出发:为什么选这个版本?怎么改 backbone 和 head 才不翻车?

2.1 为什么不是 yolov5x 或 yolov5n?鸡蛋检测对模型尺寸的硬约束

鸡蛋目标检测最反直觉的一点:越大越不准。yolov5x 参数量 86M,在产线工控机(i5-8400 + GTX1050)上推理延迟 85ms,看似够快,但实际漏检率飙升——因为鸡蛋直径仅 4–5cm,占 1920×1080 图像不到 0.3%,大模型的深层特征图感受野过大,小目标特征被池化“吃掉”。我们实测过 yolov5n(参数量 1.9M):虽然快(23ms),但对裂纹、微小血斑等关键缺陷召回率仅 61%。最终锁定yolov5s(7.2M 参数):它在 416×416 输入下,P3/P4/P5 三层检测头刚好覆盖鸡蛋尺度(P3 对应 32×32 特征图,单格感受野约 28×28 像素,匹配鸡蛋像素尺寸)。这不是玄学,是用model.stride和model.backbone层输出尺寸反向推算出来的——你打开models/yolov5s.yaml,看backbone最后一层Conv输出通道数和head的Detect层anchors设置,就能验证 P3 层 stride=8 是否真能对齐鸡蛋最小 bounding box。

2.2 修改 anchors:不用 k-means,用鸡蛋物理尺寸反推 anchor 宽高比

YOLOv5 默认 anchors 是 COCO 数据集统计出来的([10,13, 16,30, 33,23] 等),完全不匹配鸡蛋长轴 5.2cm、短轴 3.8cm 的固定比例(≈1.37:1)。直接训练会导致 bbox 回归 loss 振荡,尤其在密集堆叠场景下框歪成椭圆。我们跳过耗时的 k-means 聚类,用物理尺寸反推:

  • 假设相机离蛋托 30cm,焦距 6mm,1920×1080 图像中 1cm ≈ 128 像素
  • 鸡蛋长轴 5.2cm → 665px,短轴 3.8cm → 486px
  • 按 yolov5s 的 P3/P4/P5 三层 stride(8/16/32),计算各层 anchor 应有尺寸:
    • P3(stride=8):665/8≈83, 486/8≈61 →[80,60]
    • P4(stride=16):665/16≈41, 486/16≈30 →[40,30]
    • P5(stride=32):665/32≈21, 486/32≈15 →[20,15]

修改models/yolov5s.yaml中anchors字段为:

anchors: - [80,60, 40,30, 20,15] # P3 - [160,120, 80,60, 40,30] # P4 - [320,240, 160,120, 80,60] # P5

提示:这里[80,60]不是绝对像素,而是相对于该层特征图的 anchor 尺寸。YOLOv5 的 anchor 是在特征图尺度上定义的,所以 P3 层 80×60 对应原始图 640×480 像素区域——这正是鸡蛋在 P3 层最常出现的尺度范围。

2.3 backbone 微调:冻结前 3 层,只训 neck 和 head 的实操逻辑

鸡蛋表面纹理极简(光滑蛋壳 vs 少量污渍),不需要 ResNet 级别的深层语义理解。我们冻结model.backbone[:3](即 Focus → Conv → BottleneckCSP 前三块),只训 neck(PANet)和 head(Detect)。这样做的好处:

  • 训练 epoch 从 300 降到 120,收敛更快;
  • 防止 backbone 过拟合鸡蛋反光噪声(镜面反射在浅层卷积已足够表征);
  • 在 2080Ti 上 batch_size=32 时显存占用从 10.2GB 降至 6.8GB。

冻结代码加在train.py的model.train()之后:

# train.py 第 203 行附近 for k, v in model.named_parameters(): if k.startswith('model.backbone.0') or k.startswith('model.backbone.1') or k.startswith('model.backbone.2'): v.requires_grad = False

注意:model.backbone.0是 Focus 层,.1是 Conv,.2是第一个 BottleneckCSP —— 这个编号必须按models/common.py里YOLOv5类的__init__顺序确认,不能靠名字猜。


3. 鸡蛋数据集不是“拍几百张图就行”:光照、角度、遮挡的三大陷阱与清洗脚本

3.1 光照陷阱:为什么白平衡校准比数据增强更重要?

鸡蛋在产线传送带上受 LED 灯直射,蛋壳高光区域像素值常达 245–255,而裂纹灰度仅 120–150。若直接用albumentations.RandomBrightnessContrast增强,会把高光压成死白,裂纹彻底消失。我们放弃所有亮度/对比度增强,改为硬件级白平衡预处理:

  • 用 OpenCV 的cv2.xphoto.createGrayworldWB()对每张图做灰度世界白平衡;
  • 再用cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))对 LAB 空间的 L 通道做局部直方图均衡;
  • 最后转回 BGR 保存。

清洗脚本preprocess_egg.py:

import cv2 import numpy as np from pathlib import Path def wb_clahe(img_path): img = cv2.imread(str(img_path)) # 灰度世界白平衡 wb = cv2.xphoto.createGrayworldWB() wb.setSaturationThreshold(0.95) # 防止过曝 img_wb = wb.balanceWhite(img) # CLAHE 增强(仅 L 通道) lab = cv2.cvtColor(img_wb, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) l = clahe.apply(l) lab = cv2.merge((l, a, b)) img_enhanced = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) cv2.imwrite(str(Path("processed") / img_path.name), img_enhanced) # 批量处理 for p in Path("raw_images").glob("*.jpg"): wb_clahe(p)

注意:clipLimit=2.0是血泪经验——超过 2.5 会放大蛋壳噪点,低于 1.5 则裂纹仍不可见。这个值必须在你自己的相机参数下实测。

3.2 角度陷阱:俯拍 vs 斜拍的标注一致性怎么保?

产线相机常以 30° 斜角拍摄蛋托,导致鸡蛋呈椭圆而非正圆,bbox 标注若按矩形框画,IoU 计算时会系统性偏低。解决方案:强制统一标注视角。我们在labelImg中启用Auto Labeling插件,加载一个预训练的鸡蛋轮廓分割模型(用 50 张斜拍图 fine-tune 的 Mask R-CNN),生成.txt标签时只取 bbox 的(x_center, y_center, width, height),width/height 比例严格锁定为 1.37(鸡蛋长宽比)。脚本fix_aspect_ratio.py自动修正:

# 读取 label.txt,修正宽高比 with open("labels/001.txt", "r") as f: lines = f.readlines() fixed_lines = [] for line in lines: cls, x, y, w, h = map(float, line.strip().split()) # 强制 w/h = 1.37 if w / h > 1.37: w = h * 1.37 else: h = w / 1.37 fixed_lines.append(f"{int(cls)} {x:.6f} {y:.6f} {w:.6f} {h:.6f}\n") with open("labels_fixed/001.txt", "w") as f: f.writelines(fixed_lines)

3.3 遮挡陷阱:蛋托网格线干扰怎么滤除?

蛋托塑料网格在图像中形成强结构线,YOLOv5 会误学网格为“边界”,导致 bbox 紧贴网格线而非蛋边缘。我们不用复杂去网格算法,而是在datasets.py的LoadImagesAndLabels类中插入掩膜:

# datasets.py 第 120 行 def __getitem__(self, index): # ... 原有代码 ... # 加入网格线掩膜(假设网格线为白色细线) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, mask = cv2.threshold(gray, 240, 255, cv2.THRESH_BINARY) kernel = np.ones((3,3), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 闭运算连接断线 img[mask == 255] = [120, 120, 120] # 将网格线区域填为中性灰 return img, labels, self.img_files[index], shapes

提示:填色值120是经验值——太暗(如 50)会让网格变黑框,模型误学为“蛋壳裂纹”;太亮(如 200)仍保留结构信息。必须用你产线图测试确定。


4. 训练好的模型不是“下载即用”:如何验证它真能扛住产线噪声?

4.1 用 confusion matrix 看清漏检在哪:不是 mAP 高就万事大吉

YOLOv5 默认val.py只输出 mAP@0.5,但鸡蛋检测的关键是漏检类型分布。我们重写val.py的process_batch函数,统计三类漏检:

  • Type A:完全未检出(IoU=0)→ 相机抖动或反光导致;
  • Type B:检出但置信度 <0.3 → 模型对弱特征不敏感;
  • Type C:检出但 bbox 偏移 >30px → anchor 尺寸不匹配。

修改后输出 CSV:

class, total_gt, type_A_miss, type_B_miss, type_C_miss, recall egg, 1240, 18, 42, 29, 0.927 crack, 312, 47, 63, 12, 0.612 blood_spot, 89, 12, 18, 5, 0.618

重点看crack行:61.2% 召回率说明裂纹检测是瓶颈,需针对性加Mosaic增强(把裂纹 patch 裁剪后粘贴到其他蛋上)。

4.2 工业环境鲁棒性测试:用“人工造故障”代替随机测试

在产线部署前,我们做三组压力测试:

  • 光照突变:用手机闪光灯直射镜头 0.5 秒,看模型是否持续输出 bbox(要求 3 帧内恢复);
  • 帧率抖动:用ffmpeg -i input.mp4 -vf "fps=15" output.mp4生成 15fps 视频,测试推理吞吐是否稳定;
  • 多蛋重叠:合成 5 个鸡蛋中心距 <50px 的图像,测 NMS 阈值conf_thres=0.25是否仍能分离。

测试脚本stress_test.py:

import torch from models.experimental import attempt_load from utils.general import non_max_suppression model = attempt_load("weights/best.pt", map_location="cuda") model.half() # 半精度加速 model.eval() # 模拟光照突变:给输入 tensor 加高斯噪声 def simulate_flash(img_tensor): noise = torch.randn_like(img_tensor) * 0.15 return torch.clamp(img_tensor + noise, 0, 1) # 测试 NMS 分离能力 results = model(img_tensor.half()) pred = non_max_suppression(results, conf_thres=0.25, iou_thres=0.45) print(f"Detected {len(pred[0])} eggs in overlap scenario")

4.3 模型导出陷阱:ONNX 转 TensorRT 时 shape 推断失败怎么办?

很多教程教torch.onnx.export(..., dynamic_axes=...),但在鸡蛋检测中,dynamic_axes会导致 TensorRT 无法 infer shape。正确做法是固定输入 shape,用 padding 适配不同分辨率:

  • 训练时用--img 416,导出 ONNX 时指定--img-size 416;
  • TensorRT 构建 engine 时,input_shape = (1, 3, 416, 416),不做动态 batch;
  • 推理时,对任意尺寸输入(如 1920×1080),先cv2.resize(img, (416,416)),再letterbox(保持长宽比的 padding)——注意:letterbox必须用utils.general.letterbox,不是简单 resize!

letterbox关键代码:

def letterbox(img, new_shape=(416, 416), color=(114, 114, 114)): # 缩放并 padding 成正方形 shape = img.shape[:2] # [height, width] r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r)) dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1] dw, dh = dw // 2, dh // 2 img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) img = cv2.copyMakeBorder(img, dh, dh, dw, dw, cv2.BORDER_CONSTANT, value=color) return img

注意:color=(114,114,114)是 YOLOv5 默认 padding 值,必须一致,否则模型输出偏移。


5. PyQt 可视化界面不是“拖个 QLabel 就完事”:实时推流、状态监控、异常告警的工业级实现

5.1 用 QThread + QTimer 实现 30fps 无丢帧:为什么不能用 QTimer 单线程?

PyQt 默认 GUI 线程处理所有事件,若在QTimer.timeout里直接model(img),推理耗时 35ms(yolov5s on GTX1050),则实际帧率被卡在 28fps,且界面卡顿。正确解法:推理在子线程,GUI 更新在主线程。我们继承QThread写DetectorThread:

class DetectorThread(QThread): result_signal = pyqtSignal(np.ndarray, list) # img, detections def __init__(self, model_path): super().__init__() self.model = attempt_load(model_path, map_location="cuda") self.model.half() self.cap = cv2.VideoCapture(0) self.running = True def run(self): while self.running: ret, frame = self.cap.read() if not ret: continue # 推理(在子线程) img_tensor = torch.from_numpy(frame).to("cuda").half().permute(2,0,1).unsqueeze(0) pred = self.model(img_tensor)[0] det = non_max_suppression(pred, conf_thres=0.3)[0].cpu().numpy() # 发送结果到主线程 self.result_signal.emit(frame, det.tolist()) def stop(self): self.running = False self.cap.release()

主线程中:

self.detector = DetectorThread("weights/best.pt") self.detector.result_signal.connect(self.update_display) # update_display 在主线程执行绘图 self.detector.start()

5.2 状态监控面板:用 QTableWidget 实时显示每颗蛋的置信度与类别

产线工人需要一眼看出哪颗蛋可疑。我们在界面右侧放QTableWidget,每行对应一个检测框,列包括:序号、类别(蛋/裂纹/血斑)、置信度、中心坐标、状态(OK/Warning/Error)。关键点:置信度阈值分级告警:

  • ≥0.8:绿色 OK(正常蛋);
  • 0.3–0.8:黄色 Warning(需复检,如疑似裂纹);
  • <0.3:红色 Error(低置信度,可能误检)。

表格更新逻辑:

def update_table(self, detections): self.table.setRowCount(len(detections)) for i, det in enumerate(detections): cls_id, x, y, w, h, conf = int(det[5]), det[0], det[1], det[2], det[3], det[4] cls_name = ["egg", "crack", "blood_spot"][cls_id] # 设置背景色 item_conf = QTableWidgetItem(f"{conf:.3f}") if conf >= 0.8: item_conf.setBackground(QColor(200, 255, 200)) elif conf >= 0.3: item_conf.setBackground(QColor(255, 255, 150)) else: item_conf.setBackground(QColor(255, 200, 200)) self.table.setItem(i, 1, item_conf) # ... 其他列

5.3 异常告警:当连续 5 帧检出 >3 个裂纹蛋,触发蜂鸣器与日志

告警不是简单弹窗,而是工业级闭环:

  • 蜂鸣器通过RPi.GPIO控制(树莓派部署时)或winsound.Beep(1000, 500)(Windows);
  • 日志写入logs/egg_alert_20240520.log,含时间戳、裂纹数、图像路径;
  • 同时截图保存alerts/20240520_142305_crack.jpg。

告警逻辑在update_display中:

def update_display(self, frame, detections): crack_count = sum(1 for d in detections if int(d[5]) == 1) # cls_id=1 is crack self.crack_history.append(crack_count) if len(self.crack_history) > 5: self.crack_history.pop(0) if len(self.crack_history) == 5 and all(c > 3 for c in self.crack_history): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") cv2.imwrite(f"alerts/{timestamp}_crack.jpg", frame) with open(f"logs/egg_alert_{datetime.now().strftime('%Y%m%d')}.log", "a") as f: f.write(f"[{datetime.now()}] CRACK ALERT: {crack_count} cracks in 5 frames\n") winsound.Beep(1000, 500) # 1kHz, 0.5s self.crack_history.clear() # 重置计数

6. 避坑:YOLOv5 鸡蛋检测 + PyQt 的 4 个血泪教训,现在知道还不晚

6.1 现象:PyQt 界面运行 2 小时后内存暴涨至 4GB,最后崩溃

原因:cv2.imshow()或QPixmap.fromImage()创建的 QImage 未释放,PyQt 的 QPixmap 缓存累积。OpenCV 的cv2.cvtColor()返回的 numpy array 若未.copy(),PyQt 会持有原始内存引用,导致无法 GC。
解决:所有图像转换前加.copy(),并在update_display结尾显式删除临时变量:

# 错误写法 qimg = QImage(frame.data, frame.shape[1], frame.shape[0], frame.strides[0], QImage.Format_RGB888) # 正确写法 frame_copy = frame.copy() # 关键! qimg = QImage(frame_copy.data, frame_copy.shape[1], frame_copy.shape[0], frame_copy.strides[0], QImage.Format_RGB888) # ... 显示后 del frame_copy, qimg # 主动 del

6.2 现象:训练 loss 下降但 val mAP 不升,甚至下降

原因:鸡蛋数据集里“正常蛋”占比 92%,裂纹仅 5%,血斑 3%,严重类别不平衡。YOLOv5 默认cls_loss权重为 0.5,对小类别惩罚不足。
解决:在train.py的compute_loss函数中,手动加大cls_loss权重:

# models/yolo.py 第 220 行 cls_loss += BCEcls(predn[..., 5:], t) * 1.2 # 原为 0.5,改为 1.2 # 同时降低 obj_loss 权重,防过拟合背景 obj_loss += BCEobj(predn[..., 4], tobj) * 0.7 # 原为 1.0

6.3 现象:PyQt 界面在树莓派 5 上卡顿,CPU 占用 100%

原因:PyQt5 默认用 OpenGL 渲染,树莓派 5 的 VC4 GPU 驱动不兼容,导致软件渲染 fallback。
解决:启动时强制用 raster 渲染:

export QT_QPA_PLATFORM=eglfs # 树莓派专用 export QT_QPA_EGLFS_INTEGRATION=eglfs_kms python main.py

若仍卡顿,改用PyQt6(对 ARM 更友好)并禁用动画:

app = QApplication(sys.argv) app.setAttribute(Qt.AA_UseSoftwareOpenGL, True) # 强制软渲染 QApplication.setStyle(QStyleFactory.create('Fusion')) # 禁用原生样式动画

6.4 现象:导出的 ONNX 模型在 TensorRT 中报错 “Assertion failed: scales.size() == 2 || scales.size() == 4”

原因:YOLOv5 的Upsample层在 ONNX 导出时,scale factor 未显式指定,TensorRT 无法 infer。
解决:修改models/common.py的Upsample类,强制指定scale_factor:

class Upsample(nn.Module): def __init__(self, size=None, scale_factor=None, mode='nearest', align_corners=None): super().__init__() self.size = size # 关键:强制 scale_factor 为 tuple,避免 None self.scale_factor = [1, 1, scale_factor, scale_factor] if isinstance(scale_factor, (int, float)) else scale_factor self.mode = mode self.align_corners = align_corners

然后导出时加--include参数:

python export.py --weights weights/best.pt --include onnx --img-size 416 --batch-size 1

7. 最后一公里:用 PyQt 的 QSettings 做产线配置持久化,比写 config.ini 更稳

产线设备不可能每次重启都手动设 ROI、置信度阈值、报警音量。PyQt 的QSettings是跨平台、线程安全、自动序列化的终极方案——它在 Windows 写注册表,Linux/macOS 写 ini 文件,但 API 完全一致。我们用它存三类配置:

配置项类型默认值说明
camera/roi_xint200检测 ROI 左上角 x 坐标
detector/conf_thresfloat0.3裂纹检测置信度阈值
alarm/volumeint70蜂鸣器音量(0–100)

初始化:

from PyQt5.QtCore import QSettings self.settings = QSettings("EggTech", "EggInspector") # 公司名+应用名 # 读取 roi_x = self.settings.value("camera/roi_x", 200, type=int) # 写入(如用户拖动 ROI 框后) self.settings.setValue("camera/roi_x", new_x)

但真正关键的是热重载:当工人在界面调整阈值后,模型无需重启即可生效。我们在DetectorThread中加监听:

class DetectorThread(QThread): def __init__(self, settings): super().__init__() self.settings = settings self.conf_thres = self.settings.value("detector/conf_thres", 0.3, type=float) # 启动定时器监听配置变更 self.timer = QTimer() self.timer.timeout.connect(self.check_settings) self.timer.start(1000) # 每秒检查一次 def check_settings(self): new_thres = self.settings.value("detector/conf_thres", 0.3, type=float) if abs(new_thres - self.conf_thres) > 1e-3: self.conf_thres = new_thres print(f"Confidence threshold updated to {self.conf_thres}")

我踩过的最大坑是:早期用json.dump()写配置文件,某次断电导致文件损坏,整条产线停摆 2 小时。QSettings 的原子写入和自动备份机制,让我再没为配置丢过一次产线。现在我所有工业项目,第一行代码必是QSettings("MyCompany", "MyApp")——它不是锦上添花,是产线存活的底线。希望帮到你。

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

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

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

立即咨询