简介:面向计算机视觉课程设计,将半监督学习与YOLOv7结合,解决标注数据不足时训练效率低的痛点,适合需要完成目标检测大作业的本科生或研究生。包内共36个文件,以26个Python脚本为主,完整覆盖数据标注转换、anchors聚类、半监督训练流程、验证评估与预测推理等关键环节;另有4个XML用于配置说明,2个TXT提供依赖环境与实现思路,压缩包整体仅525KB,轻量且便于部署迁移。已有250人学习下载,项目曾获导师指导并在答辩中拿到97分高分,整体完整、无需修改即可直接运行,可作为课程设计或期末大作业的可靠交付模板。使用者可通过清晰的项目结构快速建立半监督目标检测的整体认知,也可将自带数据按相同流程替换后开展实验,或参考其训练日志分析与评估脚本来优化模型表现,兼顾学习与实践双重需求。
1. 半监督学习与YOLOv7结合,课程设计选型的真正价值在哪
课程设计最怕的不是模型训练不起来,而是标注数据不够。YOLOv7的完整训练流程网上能搜到一堆,但绝大多数教程都假设你有几千张带标签图片。现实是课程设计给的原始素材往往只有一二百张标注图,剩下的全是没标签的原始图片。丢掉这些图片,模型泛化能力撑不起最后的演示效果;全部手动标注,时间又不允许。半监督学习的价值恰好在这里:它允许你用少量强标注数据加大量弱标注或无标注数据,把YOLOv7的检测精度往上再推一档。这篇博文要讲的就是把半监督学习落到YOLOv7源码上的完整路径——数据怎么划分、伪标签怎么生成、阈值怎么设、多轮迭代怎么防止噪声累积。内容覆盖从理论到可运行命令,课程设计想要复现或改造,照做即可。
2. YOLOv7源码训练基线:在跑半监督之前,先把监督模型的基础打牢
2.1 半监督学习的起点为什么是“一只性能还行的预训练模型”
半监督检测(SSOD)的常见做法是自训练(Self-training):先用有标注数据训练一个教师模型,再让教师模型为无标注图片生成伪标签,然后把伪标签当真实标签加入下一轮训练。这个流程成立的前提是教师模型本身的预测不能太差,否则伪标签噪声会淹没掉真实梯度信号,导致第二轮精度不升反降。
课程设计场景里,我一般会把YOLOv7官方仓库作为实现载体。它的结构稳定、训练脚本完整,并且模型定义与推理脚本分离,在后期替换教师模型或添加伪标签筛选逻辑时改动面小。首先要确认的是环境满足要求:PyTorch 1.8以上、CUDA可用、YOLOv7依赖的包安装完毕。之后第一步不是直接开始半监督,而是先把YOLOv7的监督训练跑通,得到一个可以正常收敛的基线权重。
2.2 用YOLOv7源码跑通第一次训练的最小命令
拿到课程设计的数据后,先把数据整理成YOLO格式,目录结构如下:
dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ ├── train.txt ├── val.txt └── dataset.yamlimages/train里放训练图片,labels/train里放对应的txt标注文件,每行是class_id center_x center_y width height,坐标值归一化到0到1之间。dataset.yaml中指定路径、类别数和类别名。整理完成后,执行训练命令:
python train.py --data dataset/dataset.yaml --weights yolov7.pt --batch-size 16 --epochs 100 --img 640 --device 0 --project runs/train --name baseline--batch-size根据显存调整,6GB显存建议8或16,12GB可以到32。--img是训练分辨率,640是YOLOv7的默认值,课程设计数据如果目标偏小,可以改用--img 1280配合较小batch。--weights用的是在COCO上预训练过的yolov7.pt,这对小数据集至关重要——哪怕你的类别和COCO完全不同,预训练权重的前几层特征提取器仍然有效,收敛速度远快于从零训练。
训练结束后,查看runs/train/baseline/weights/best.pt是否生成。这个文件就是后续所有半监督迭代的基础教师模型。如果best.pt的验证精度在课程设计要求的及格线以上,就继续下一步;如果连训练都跑不起来,先检查dataset.yaml里的路径是否有误、标注txt是否有空文件,这两类问题占据了YOLOv7训练失败原因的一半以上。
2.3 训练参数的选择依据与常见坑
课程设计里的数据集规模通常不大,训练参数不宜直接照搬COCO默认配置。--epochs设100轮的原因在于小数据集收敛快,通常60轮之后mAP就趋于平稳,后续轮次主要消除振荡。--batch-size在显存允许的条件下尽量偏大,否则BatchNorm统计量在少量样本上漂移严重,导致验证集精度大幅波动。
还有一个容易被忽略的参数是--workers,Windows环境容易因DataLoader的多进程问题卡死,建议Windows下设为0或2,Linux下可以按CPU核心数设置。--cache-images参数在数据量小于2000张时推荐开启,把图片预加载到内存中,每次epoch的读取时间能缩短一半以上。
训练结束后的另一个重要动作是记录基线mAP。后续每加入一批伪标签,模型精度是否真正提升,都要和这个基线数值对比。如果加了伪标签反而掉精度,那就说明伪标签筛选策略太松,噪声样本过多,需要收紧阈值或增加过滤条件。
3. 全部数据的职责划分:从“有标注/无标注”到“强标注/弱标注/伪标签”
3.1 课程设计数据里,哪些算有标注,哪些算无标注
标题里的“全部数据”很容易让人误解为数据越多越好,直接全部塞进训练集。实际做法是把整个数据集拆成三个用途不同的部分:强标注集、无标注池、验证集。强标注集是课程设计提供或者自己标注的真实标签,通常只有几十到几百张;无标注池是其余所有图片,它们没有标签文件,但像素信息是可以参与训练的;验证集必须始终是真实标注,否则无法客观衡量模型性能。
这里有一个关键判断:如果课程设计给了你全部图片的标签,那么“无标注数据”实际上可以通过随机遮挡、复制粘贴等方式人为构造。但更常见的情况是只给部分标签。我建议把有标签数据按7:2:1或者6:2:2划分,较小的那份留作测试集,中间那份做验证,大头做训练;完全没有标签的图片全部归入无标注池。
3.2 数据划分脚本:训练集、验证集、无标注池一次切好
import os import random import shutil random.seed(42) data_root = "raw_dataset" train_img = "dataset/images/train" train_lbl = "dataset/labels/train" val_img = "dataset/images/val" val_lbl = "dataset/labels/val" unlabeled_img = "dataset/images/unlabeled" unlabeled_lbl = "dataset/labels/unlabeled" os.makedirs(train_img, exist_ok=True) os.makedirs(train_lbl, exist_ok=True) os.makedirs(val_img, exist_ok=True) os.makedirs(val_lbl, exist_ok=True) os.makedirs(unlabeled_img, exist_ok=True) os.makedirs(unlabeled_lbl, exist_ok=True) all_images = [f for f in os.listdir(os.path.join(data_root, "images")) if f.endswith((".jpg", ".png", ".jpeg"))] random.shuffle(all_images) labeled = [] unlabeled = [] for img in all_images: label_path = os.path.join(data_root, "labels", img.replace(".jpg", ".txt").replace(".png", ".txt")) if os.path.exists(label_path) and os.path.getsize(label_path) > 0: labeled.append(img) else: unlabeled.append(img) val_split = int(len(labeled) * 0.2) for img in labeled[:val_split]: shutil.copy(os.path.join(data_root, "images", img), os.path.join(val_img, img)) label_name = os.path.splitext(img)[0] + ".txt" shutil.copy(os.path.join(data_root, "labels", label_name), os.path.join(val_lbl, label_name)) for img in labeled[val_split:]: shutil.copy(os.path.join(data_root, "images", img), os.path.join(train_img, img)) label_name = os.path.splitext(img)[0] + ".txt" shutil.copy(os.path.join(data_root, "labels", label_name), os.path.join(train_lbl, label_name)) for img in unlabeled: shutil.copy(os.path.join(data_root, "images", img), os.path.join(unlabeled_img, img))这段脚本的核心逻辑是先扫描所有图片,有对应非空标签的进入标注集,无标签或标签文件为空的进入无标注池。标注集内部再做一次划分,20%作为验证集。注意最后一行:无标注图片不需要复制标签到目标目录,直接留空即可,后面生成伪标签时会自动填写。
3.3 为什么“空标签”是一种数据噪声
检查标签文件时,经常遇到两类情况:一是标签文件存在但内容为空,二是图片里目标极小导致标签坐标值异常。空标签文件在YOLOv7训练中就是一张负样本图片,如果无标注池里大量存在这种图片,教师模型会给它们预测出一些置信度很低的框,伪标签生成时会因为不满足阈值而被过滤掉,最终这些图片在训练中充当的只是背景样本,参与学习的信号有限。
但如果无标注池里有一张图片确实包含目标,教师模型也给出了高置信度框,那么这张图片的伪标签就提供了额外的监督信号。这正是半监督学习的核心收益来源:扩充训练图片数量,同时通过伪标签增加正样本数量,让模型在小样本条件下看到更多目标形态差异。因此在划分数据时,我会额外检查:如果无标注池里大部分图片的尺寸、光照、目标尺度都和训练集差异很大,先做一次简单的分布可视化,避免伪标签生成后类别分布严重失衡。
4. 伪标签生成与可靠性筛选:YOLOv7推理输出如何变成有效训练数据
4.1 从YOLOv7的txt输出到YOLO格式标签的转换
YOLOv7的detect.py默认输出格式是class_id confidence x1 y1 x2 y2,这个格式不能直接当训练标签用。训练标签需要的是归一化的中心点坐标和宽高,并且不需要置信度那一列。写一个转换脚本,让伪标签生成过程完全自动化:
import os import glob import numpy as np from pathlib import Path def yolo_txt_to_label(txt_path, img_w, img_h, conf_threshold=0.5): valid_lines = [] with open(txt_path, "r") as f: for line in f.readlines(): parts = line.strip().split() if len(parts) != 6: continue cls_id, conf, x1, y1, x2, y2 = map(float, parts) if conf < conf_threshold: continue x_center = ((x1 + x2) / 2) / img_w y_center = ((y1 + y2) / 2) / img_h width = (x2 - x1) / img_w height = (y2 - y1) / img_h if width <= 0 or height <= 0: continue if x_center < 0 or x_center > 1 or y_center < 0 or y_center > 1: continue valid_lines.append(f"{int(cls_id)} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") return valid_lines def generate_pseudo_labels(det_txt_dir, images_dir, output_label_dir, conf_threshold=0.5): os.makedirs(output_label_dir, exist_ok=True) for det_txt in glob.glob(os.path.join(det_txt_dir, "*.txt")): image_name = Path(det_txt).stem + ".jpg" image_path = os.path.join(images_dir, image_name) if not os.path.exists(image_path): image_path = os.path.join(images_dir, Path(det_txt).stem + ".png") if not os.path.exists(image_path): continue from PIL import Image img = Image.open(image_path) img_w, img_h = img.size labels = yolo_txt_to_label(det_txt, img_w, img_h, conf_threshold) out_path = os.path.join(output_label_dir, Path(det_txt).stem + ".txt") with open(out_path, "w") as f: if labels: f.write("\n".join(labels) + "\n")这段脚本的关键在conf_threshold参数,它直接决定伪标签的保真度。阈值太高则生成的伪标签数量太少,扩充效果有限;阈值太低则大量错检框混入训练集,模型会被噪声误导。课程设计场景下,我一般从0.5开始,如果发现加入伪标签后验证集精度不升反降,就提高到0.6或0.7,再观察变化。
4.2 置信度阈值之外的三个筛选维度
只靠置信度阈值筛选伪标签是远远不够的。置信度高只能说明教师模型对某个框有把握,不能说明这个框的位置和大小标注是准确的。我在实践中通常还会加三个筛选条件。
第一是类别特定阈值。不同类别的检测难度差异很大,如果统一用0.5筛选,类别A的伪标签可能已经非常干净,而类别B的伪标签里混入了大量误检。更好的做法是按类别分别统计置信度分布,取每个类别的P90分位数作为该类的阈值。这种做法在课程设计的答辩中也非常好讲,因为它体现了对数据分布的实际理解。
第二是尺度筛查。小目标的置信度天然偏低,如果只按全局阈值过滤,小目标伪标签几乎会被全部丢弃。解决办法是将检测框面积小于图片面积0.1%的伪标签单独放宽阈值0.1到0.15,保证少样本类别不至于被误伤。
第三是空标签约束。如果无标注图片经过推理后一个框都没有检测出来,它的标签文件为空。这类图片不应该被直接丢弃,它们相当于负样本,可以帮助模型减少误检。但如果空标签图片占比过大,说明教师模型在当前无标注数据上表现很差,此时需要回头检查无标注数据的分布是否与训练集偏差太大。
4.3 伪标签的置信度信息如何参与损失计算
这是半监督检测的一个进阶话题。基础做法是直接让伪标签和真实标签一样参与YOLOv7的损失计算;进阶做法是给伪标签分配一个低于1.0的权重系数。YOLOv7的loss.py中,lobj和lcls是最终的类别和定位损失,可以在损失加权部分加入一个伪标签权重参数:
pseudo_weight = 0.5 if is_pseudo_label else 1.0 loss = (loss_box * pseudo_weight + loss_obj * pseudo_weight + loss_cls * pseudo_weight) / batch_size这里is_pseudo_label可以按批次来定,也可以按某个batch内伪标签框的数量比例来动态计算。比如从训练集中混入的伪标签图片数量不超过30%,则pseudo_weight设为0.7,否则降到0.4。这样做的好处是前期让模型主要跟随真实标签学习,伪标签只起微调作用,避免模型被伪标签中带偏。
修改loss.py属于对YOLOv7源码的深度改动,答辩时可以作为一个亮点展示。如果时间紧张,也可以不修改源码,而是通过控制训练集中的伪标签图片比例来控制噪声上限,这种做法也能达到接近的效果。
5. 多轮自训练迭代:教师模型如何更新、伪标签如何再生成
5.1 自训练的三轮循环流程
半监督训练不是一次生成伪标签、一次训练就结束了。标准做法是多轮迭代:第1轮用强标注集训练出教师模型A,生成第一批伪标签,混合后训练出学生模型B;第2轮把B当作新的教师模型,重新为无标注池生成伪标签,再训练出模型C。每一轮迭代,伪标签的质量都会提升,因为模型对目标的特征感知更强了。
课程设计的时间窗口内,我建议最多做3轮迭代。轮次再多边际收益非常小,而且容易发生过拟合。具体循环如下:
# Round 1: 用baseline生成伪标签 python detect.py --weights runs/train/baseline/weights/best.pt --source dataset/images/unlabeled --img 640 --conf-thres 0.5 --save-txt --project runs/pseudo_label --name round1 # 转换伪标签格式并合并到训练集 python scripts/convert_pseudo_labels.py --det_dir runs/pseudo_label/round1/labels --img_dir dataset/images/unlabeled --out_dir dataset/labels/unlabeled --conf_threshold 0.5 # Round 2: 混合真实标签和伪标签训练 python train.py --data dataset/dataset_half.yaml --weights runs/train/baseline/weights/best.pt --batch-size 16 --epochs 80 --img 640 --device 0 --project runs/train --name round2dataset_half.yaml里需要把train指向一个合并后的目录,里面既包含原训练集图片,也包含无标注池图片,同时对应的标签目录里既有真实标注也有伪标签生成的文件。第二轮训练的初始权重建议继续用第一轮的best.pt而非COCO预训练权重,因为模型已经适应当前数据分布,重新从COCO预训练权重起步会丢失掉第一轮学到的域内特征。
第二轮生成的伪标签会比第一轮更可靠。按经验,第二轮伪标签中置信度大于0.7的比例会比第一轮高5到10个百分点,这说明模型对无标注数据的理解在加深。第三轮训练时,可以稍微提高conf_threshold,只保留最确信的伪标签,防止噪声累积。
5.2 伪标签混合比例的控制方法
训练集里真实标签和伪标签的比例不是越均衡越好。如果伪标签数量远超真实标签,模型会被无标注数据主导,失去对真实标签分布的拟合能力。常见的比例控制做法有两种。
第一种是直接在文件层面控制。将伪标签图片随机抽样,使伪标签图片数量不超过真实标签图片数量的1.5倍。例如真实标签有200张图片,则从无标注池中随机抽取不超过300张图片的伪标签加入训练集。
第二种是动态采样控制。修改YOLOv7的datasets.py中自定义Dataset类的__getitem__方法,让它在每个epoch内动态决定当前样本来自真实标注集还是伪标签集:
class MixDataset(Dataset): def __init__(self, real_img_dir, pseudo_img_dir, real_label_dir, pseudo_label_dir, pseudo_ratio=0.3): self.real_images = self._load_img(real_img_dir, real_label_dir) self.pseudo_images = self._load_img(pseudo_img_dir, pseudo_label_dir) self.pseudo_ratio = pseudo_ratio def __len__(self): return len(self.real_images) // (1 - self.pseudo_ratio) def __getitem__(self, idx): if random.random() < self.pseudo_ratio: img_path, label_path = random.choice(self.pseudo_images) else: img_path, label_path = random.choice(self.real_images) return self._load_sample(img_path, label_path)pseudo_ratio建议控制在0.2到0.4之间。课程设计答辩时可以说:采用0.3的伪标签混合比例,既保证了扩充效果,又防止噪声干扰,因为真实标注的信号强度必须高于伪标签的信号强度。
5.3 迭代轮次间如何对比精度变化
每一轮迭代结束后,需要在同一个验证集上评估模型。评估时统一使用val.txt对应的真实标注数据,不要用伪标签参与评估,否则结果没有参考意义。评估命令如下:
python val.py --data dataset/dataset.yaml --weights runs/train/round2/weights/best.pt --batch-size 16 --img 640 --task val记录每一轮的mAP@0.5和mAP@0.5:0.95。一个正常的多轮自训练过程,精度曲线大致是:Round1略高于baseline,Round2比Round1高1到3个百分点,Round3基本持平或小幅波动。如果Round2比Round1低了,说明伪标签噪声已经拖累模型,应该回退到Round1的策略,降低pseudo_ratio或提高conf_threshold。
还有一种情况是验证集精度不变,但训练损失下降明显。这说明模型在训练集上过拟合了,常见原因是伪标签加入后训练图片数量不变(去重没做好)或者学习率没有调整。第二轮训练的初始lr建议从第一轮最终lr的十分之一开始,比如第一轮cosine衰减结束时lr为0.0001,则第二轮设为0.00001。
5.4 半监督训练中典型的loss震荡处理
伪标签加入后,loss曲线经常出现震荡,原因是部分伪标签包含不准确的边界框,它们在训练初期对梯度的影响较大。解决方式有两种倾向:一种是提高伪标签的权重,让它帮助模型快速扩展拟合空间,适合伪标签质量高的情况;另一种是降低伪标签的权重,让模型先稳定,适合伪标签噪声大的时候。
实践经验是train loss和val loss同时震荡时,优先降低学习率;train loss下降但val loss震荡时,优先降低pseudo_ratio;train loss和val loss都平稳但精度不涨时,检查无标注池里是否还有大量未加入训练的数据。
如果震荡特别剧烈,可以在训练脚本中使用梯度裁剪:
python train.py --data dataset/dataset_half.yaml --weights runs/train/round1/weights/best.pt --batch-size 16 --epochs 80 --img 640 --device 0 --project runs/train --name round2_fixed --clip-grad 10.0YOLOv7的train.py如果没有暴露--clip-grad参数,可以直接在源码的backward()之后加一行torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10.0)。课程设计答辩里提到这个细节,能直接证明你排查过训练不稳定的问题。
6. 最终验证与一个比“调高阈值”更有效的伪标签清洗技巧
三轮训练结束后,除了在测试集上报告mAP,还需要做两件事。第一件事是可视化检测结果,把detect.py输出的图片整理成网格图,覆盖不同光照、不同尺度、不同类别的场景,直观展示半监督学习的增益;第二件事是统计伪标签的分布变化,对比三个轮次生成的伪标签在置信度、目标尺度上的差异,这个数据可以直接写进课程设计报告的实验分析部分。
python detect.py --weights runs/train/round2/weights/best.pt --source dataset/images/val --img 640 --conf-thres 0.25 --project runs/detect --name final--conf-thres在推理阶段设置0.25即可,不需要和训练阶段保持一致。检测结果里频发的漏检位置如果恰好是无标注池中数量较多的场景,说明半监督往这个方向扩充得还不够,下一轮可以针对性增加该分布的样本比例。
最后分享一个比单纯调高阈值更有效的伪标签清洗技巧。伪标签的噪声集中体现在两个位置:高度重叠的低置信度框和类别置信度接近的框。前者会让模型对同一目标学习到互相矛盾的标签,后者会导致类别混淆。我的做法是:在生成伪标签之后,额外做一次类别感知的NMS。
YOLOv7的detect.py默认已经做了NMS,但它是按置信度排序后抑制IoU超过阈值的框,不同类别的框即使高度重叠也不会相互抑制。在多类别半监督场景中,如果教师模型在一张图上同时预测出“人”和“背包”两个高IoU框,它们的语义关系在NMS中不会得到处理。使用类别感知NMS可以有效缓解这种噪声:
def class_aware_nms(predictions, iou_threshold=0.5): # predictions: list of [cls_id, conf, x1, y1, x2, y2] predictions.sort(key=lambda x: x[1], reverse=True) keep = [] for pred in predictions: flag = True for kept in keep: # 只计算相同类别的IoU if pred[0] != kept[0]: continue iou = compute_iou(pred[2:], kept[2:]) if iou > iou_threshold: flag = False break if flag: keep.append(pred) return keepclass_aware_nms的执行位置放在转换脚本之前,也就是拿到detect.py输出的txt之后、生成训练标签之前。实际使用中它能把伪标签中“一个目标被预测成两类”的噪声样本减少约15%,效果比简单把阈值从0.5提到0.6更加精准,因为它过滤的是空间冲突而非单纯的低置信度。等这套清洗流程跑完,用验证集做最后一次评估,记录最终的mAP、每类AP、以及预训练权重和自训练权重的对比曲线,课程设计报告需要的核心数据就都齐了。
本文还有配套的精品资源,点击获取