疲劳与打哈欠图像分类数据集详解:20,000张图像与ResNet18实战 简介面向疲劳驾驶预警与驾驶员状态监测的图像分类数据集围绕疲劳、打哈欠两个已标注类别构建标注字段清晰适用于卷积神经网络分类入门也可迁移到YOLOv5等检测模型做二分类改进与效果评估。包内共2000个文件包括1998张JPG样本图片、1个show.py可视化脚本和1个记录类别映射的JSON文件压缩包约334.72MB整体规模适中便于快速下载和本地训练。数据集已划分训练集与验证集同类图片按目录存放加载方便show.py可随机抽样预览各类别样本帮助检查标签质量部分图片文件名还携带戴镜状态、瞌睡组合等属性信息为细粒度分析和组合场景实验提供了扩展空间。已有158人学习/下载既能作为教学实验的基础数据也适合科研人员验证疲劳检测算法的泛化性能。1. 疲劳与打哈欠图像分类数据集20,000张已标注图像能支撑到什么程度驾驶疲劳检测里打哈欠是少有的“动作特征强、误判率低”的视觉信号。很多做车载DMS或者安全监控的项目最后都会回到图像分类这个最基础的任务给一张脸部图像判断这人是疲劳状态还是在打哈欠。这个资源把这条链路的前半段打包好了——约两万张已标注图像、两个分类目标、train和val已按类别分好还额外带了一个show脚本方便直接看图。对刚入门图像分类的人来说它省掉了爬数据、清洗、手工标注三大苦活对熟手来说它最大的价值是当基线数据用用来对比ResNet、MobileNet这类网络在不同亮度、不同遮挡条件下的分类表现。这套数据解决的是很实际的工程问题你不需要为了验证一个分类思路先花两周整理数据。取到压缩包后解压、读标注、跑基线、出结论整个过程最多一个下午。比较适合三类人想拿现成数据跑通图像分类全流程的初学者做疲劳检测课题、需要标准二分类数据做实验比对的在校学生以及在企业里要快速判断某个网络结构值不值得继续投入的算法工程师。下文从目录结构开始拆解这套数据一步步带你把它跑起来。2. 摸清目录结构与JSON标注训练前先读对两样东西数据类资源最怕的不是模型调不好而是标注读错。疲劳与打哈欠分类数据集的分类目标看着简单可标注信息实际上分散在JSON文件和文件命名两处。如果训练前不把它们对齐后面所有的准确率数字都可能建立在一个错误映射上。2.1 目录层级与命名字段先看懂再动手解压后常见结构是train和val两个一级目录各自下面按类别存放图片。总量在约两万张这个量级但具体每类多少张、类别文件夹叫什么建议先用脚本数一遍不要凭感觉。图片文件名是类似这样的002_noglasses_nonsleepyCombination_830_notdrowsy.jpg这个文件名可以拆成四个信息段序号位“002”条件位“noglasses_nonsleepy”表示无眼镜、非困倦的采集条件组合编号“Combination_830”代表这批图来自某个组合条件下的采集批次最后是语义标签位“notdrowsy”。这类命名在多条件采集的数据集里很常见前缀字段主要给采集端做追溯用未必等于训练标签。真正训练时最终分类依据要看JSON不能只靠文件名末段去猜类别。我拿到数据集的第一件事不是打开图片看而是先做文件计数。这种数据量如果拷贝或者解压中途断了最明显的特征就是某个类别目录的图片数明显偏少。下面两个命令在Linux或macOS终端里直接跑即可。# 统计train下所有jpg文件总数 find train -type f -name *.jpg | wc -l # 统计val下所有jpg文件总数 find val -type f -name *.jpg | wc -l这里统计的是文件总数没有按类别细分。想细到“每个类别多少张”可以配合ls和sort循环实现但我更建议直接写一段Python脚本去遍历统计因为统计的同时还能顺手把JSON里的标注一起校验掉这就是下一节要说的内容。2.2 读取JSON建立标签映射避开ImageFolder的自动排序资源说明里专门提了一句“具体查看json文件”也就是说标注真相在JSON里不在文件名里。通常JSON会包含类别名称、类别索引以及train/val的样本信息。写个一次性脚本把它完整打印出来最稳妥。import json import os from collections import Counter # 假设json文件和train/val目录在同一级文件名是dataset_info.json with open(dataset_info.json, r, encodingutf-8) as f: info json.load(f) print(json.dumps(info, ensure_asciiFalse, indent2)) for split in [train, val]: counter Counter() if os.path.exists(split): for cls_name in os.listdir(split): cls_path os.path.join(split, cls_name) if os.path.isdir(cls_path): counter[cls_name] len( [x for x in os.listdir(cls_path) if x.lower().endswith((.jpg, .jpeg, .png))] ) print(f[{split}] {dict(counter)})这个脚本的作用分两层。第一层是print(json.dumps(...))把JSON里的类别结构完整打出来确认类别id和类别名的对应关系。第二层是遍历train和val下的所有子目录统计每个类别目录的图片数counter用collections.Counter是为了后面dict(counter)输出方便。后缀判断那里先转小写再比较避免漏掉.JPG这类大写后缀。跑完后你应该看到两类结果JSON中声明的类和目录里实际存在的类能对上train和val的图片总数加起来在约两万张这个量级。这里有个容易踩坑的细节PyTorch的ImageFolder会用子目录名的排序自动生成class_to_idx。目录里如果是“fatigue”和“yawning”这两个英文名按字母序排列时fatigue在前碰巧和大多数人的预期一致但万一类别目录名带数字前缀、中文名或者你重新排了目录顺序自动生成的索引次序就未必和JSON一致。torchvision不会帮你做任何对齐训练前必须手动覆盖。from torchvision import datasets, transforms normalize transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) train_ds datasets.ImageFolder( roottrain, transformtransforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), normalize ]) ) # 用JSON确认过的映射覆盖ImageFolder自动生成的结果 train_ds.class_to_idx {fatigue: 0, yawning: 1} train_ds.classes [fatigue, yawning] # 关键改完映射后必须同步重建targets train_ds.targets [ train_ds.class_to_idx[os.path.dirname(p).split(os.sep)[-1]] for p, _ in train_ds.samples ]这段代码的关键在于最后三行。直接改class_to_idx只是改了索引字典ImageFolder内部的targets列表还是按旧目录排序生成的数字。如果新映射和旧排序顺序相反图像和标签会整体错位训练出来的模型准确率看着正常实际类别全反了。所以每次覆盖class_to_idx都必须用目录名反查新映射重建一遍targets。提示如果资源里的JSON文件名不叫dataset_info.json不管它叫什么都先用上一段脚本打印一遍再做别的操作。3. 可视化与数据校验跑通show脚本再开始训练训练前必做的一个动作是“亲眼看一下图”否则你不知道这套数据的亮度、清晰度、遮挡情况到底如何。资源里自带show脚本这就是为了让你少走弯路设计的。3.1 用show脚本做拼接可视化数据集的show脚本一般是用来把数据拼接成网格图展示的。入口文件通常叫show.py如果资源解压后文件名不完全一致打开文件看一眼argparse或者入口函数就能确认。大部分这类脚本的接口区别不大我一般这样用python show.py --data_dir ./train --num_samples 20 --save_dir ./vis参数里--data_dir指定图像目录--num_samples指每个类别抽取多少张--save_dir是输出文件夹。脚本内部的大致逻辑是按类别遍历目录每个类别随机抽20张图用matplotlib或PIL拼成一张大图保存到vis目录。跑完打开生成的图一眼就能看出两个类别的视觉差异是否明显、脸部区域在画面里占比多大。这些信息直接影响模型选型脸部占比小就适合Resize到224这类均衡尺寸整体亮度偏低就要考虑训练时加亮度抖动而不是直接套标准归一化。如果是在没有显示器的服务器上运行遇到matplotlib后端报错先设置MPLBACKENDAgg再执行比如在命令行前面加环境变量或者直接import matplotlib后手动切换Agg都能解决。3.2 做一次坏图扫描和数量平衡核对可视化只能看抽样不能看全局。开始训练前我还会做一次全量校验顺便把坏图和类别分布一起摸清楚。下面这段脚本会遍历train和val下所有图片检查文件能否被PIL完整解码。from PIL import Image import os from collections import Counter bad_images [] counts Counter() for split in [train, val]: if not os.path.exists(split): continue for cls in os.listdir(split): cls_dir os.path.join(split, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if not fname.lower().endswith((.jpg, .jpeg, .png)): continue fpath os.path.join(cls_dir, fname) counts[(split, cls)] 1 try: with Image.open(fpath) as img: img.load() except Exception: bad_images.append(fpath) print(类别统计:, dict(counts)) print(坏图数量:, len(bad_images)) for p in bad_images[:10]: print(bad:, p)这段脚本用img.load()把像素数据真正加载进内存文件内部编码不完整或截断时异常会在这里抛出被记录到bad_images。counts的key是(split, cls)二元组这样能区分坏图集中在train还是val。扫描完成后把坏图名单导出最好建一个quarantine文件夹将坏图移出去不要留在原目录里否则训练到一半再报解码错误就麻烦了。这个统计还有一个作用检查数据切分是否合理。如果某一类在train里有一万张val里只有一百张最终评估结果的波动会非常大。遇到这种情况我会按类别比例重新切分一次而不是直接拿現成的train/val开始训练保证每个类别在验证集里都有足够样本。4. 用ResNet18训练疲劳与打哈欠分类器迁移学习与关键参数分类器是这个数据集的核心产出。约两万张、两个类别这个尺度下不用把问题复杂化重点是选对模型和参数跑出一条能复现的基线。4.1 模型选型为什么先看ResNet18再看Transformer数据量约两万张、类别只有两个这个尺度下我第一选择是预训练的ResNet18。原因不是它理论最强而是它在小规模分类数据上收敛稳定、显存占用友好、调参空间清晰。MobileNetV3同样值得考虑它更轻量适合后续要部署到边缘设备的情况做对比实验时MobileNetV3和ResNet18可以组成一组很好的轻量级对照。近几年图像分类领域Transformer很火像ViT、Swin Transformer在大规模数据集上表现确实亮眼。但在两万张这种中小规模数据上不预训练直接训ViT很容易得到“验证集准确率卡在七成再也上不去”的结果用上ImageNet预训练权重后效果会提升但训练成本明显高于ResNet18。我的建议是先把ResNet18的基线跑出来再用预训练ViT做微调对比。如果你看的论文或竞品方案是用transformer图像分类模型刷的指标你至少需要一份CNN基线来横向比较不能直接拿别人在大数据集上的结论套在自己数据上。还有一点容易混淆YOLOv5、YOLOv8这类网络做的是目标检测不是图像分类。分类任务给的是整张图的标签没有目标框拿去做检测需要先标注人脸或嘴巴区域那是另一个标注量级的工程。你完全可以在分类模型验证通过后用同一套数据去训练YOLOv8做目标定位但这是两个阶段的任务别一开始就用错工具。4.2 数据增强、损失函数与训练循环确定了模型接下来就是数据流和训练参数。注意这里的数据增强要和第2章的只读版本区分开训练集需要增强验证集只做Resize和Normalize不能加随机翻转否则验证结果每次都会有随机抖动。import os import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import models, transforms, datasets device torch.device(cuda if torch.cuda.is_available() else cpu) # 训练集resize 增强 归一化 train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) train_ds datasets.ImageFolder(roottrain, transformtrain_transform) train_ds.class_to_idx {fatigue: 0, yawning: 1} # 以资源内json为准 train_ds.targets [ train_ds.class_to_idx[os.path.dirname(p).split(os.sep)[-1]] for p, _ in train_ds.samples ] train_loader DataLoader( train_ds, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue )这里每个参数都有实际意义。Resize((224, 224))是ResNet的默认输入尺寸不能随便改否则要跟着改网络结构。RandomHorizontalFlip对左右对称的脸部图像有效p0.5表示一半概率翻转。RandomRotation(10)旋转范围控制在10度以内角度太大会把脸部结构转歪模型反而学到变形特征。ColorJitter用来模拟不同光线条件这对疲劳检测场景很重要现实中驾驶舱光照变化极大。Normalize用的mean和std是ImageNet标准值因为后面要加载ImageNet预训练权重输入分布必须一致。batch_size32在12G显存上跑ResNet18很稳num_workers4要看CPU核心数pin_memoryTrue能减少GPU拷贝耗时。模型结构和优化器的选择直接影响收敛速度。下面是迁移学习的标准做法先冻结backbone只训练分类头等分类头收敛后再解冻整个网络用更小学习率微调。model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, 2) model model.to(device) # 先冻结backbone只训练fc分类头 for name, param in model.named_parameters(): if fc not in name: param.requires_grad False optimizer torch.optim.AdamW( [p for p in model.parameters() if p.requires_grad], lr1e-3 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max10) criterion nn.CrossEntropyLoss()weightsIMAGENET1K_V1表示加载ImageNet预训练权重这是迁移学习的核心。model.fc替换成输出维度为2的全连接层对应疲劳、打哈欠两个类别。冻结backbone后反向传播只更新fc层参数量很少训练速度快也不容易在小数据集上过拟合。AdamW的lr设置1e-3适合从零训练的分类头如果后续解冻backbone学习率要降到1e-4。T_max10和后面的epoch数对应表示10个epoch内学习率从1e-3余弦退火到接近0。冻结阶段训练10个epoch每轮打印loss观察是否稳定下降for epoch in range(10): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) scheduler.step() print(fepoch {epoch1}, loss{running_loss / len(train_ds):.4f})这里没有在每轮结束后做验证是因为冻结阶段的主要目的是让分类头先稳定下来。如果前两个epoch loss一点不降多半是学习率不合适或者标签映射错了——回去检查class_to_idx和targets是否真的同步了。loss降到0.5以下后解冻backbone把学习率降到1e-4再训练5个epoch。# 解冻backbone for param in model.parameters(): param.requires_grad True optimizer torch.optim.AdamW(model.parameters(), lr1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max5) for epoch in range(5): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) scheduler.step() print(fepoch {epoch1}, loss{running_loss / len(train_ds):.4f})先冻结再解冻的顺序是有讲究的。预训练模型的特征提取层已经能识别通用视觉结构直接整体微调学习率稍大就会破坏这些特征先让分类头适应当前数据再放开全模型用更小学习率精修收敛更稳。这个流程在二分类任务上效果明显避开了从头训练ViT那种容易过拟合的坑。5. 常见问题排查训练疲劳分类数据集的五个典型坑这些坑按大多数人第一次跑这套数据时遇到的顺序排列从数据解压到训练过程每个都附我的处理思路。5.1 数据侧问题少文件与坏图问题1某个类别图片数量为0或者train/val总和比预期少很多。现象ImageFolder能正常加载但打印类别统计时发现某类只有0张或者总数只有几千张。原因多数是解压中断或传输时遗漏了部分小文件也有可能是macOS解压产生了__MACOSX隐藏目录被误认为类别。解决先检查目录下是否有__MACOSX、.DS_Store这类文件并删除然后跑第2章的计数脚本核对总数是否接近约两万这个量级。如果数量不对重新解压一次不要手工去其他来源补图片因为你无法保证补进去的图与原始数据同分布。问题2训练到中途报PIL.UnidentifiedImageError或解码失败。现象epoch跑到某个时刻训练中断报错指向一张jpg无法打开。原因单张图片在传输过程中损坏或者文件虽然以jpg结尾内部编码已经不完整。解决用第3.2小节的坏图扫描脚本把坏图全部找出来移到单独目录训练时直接从原数据目录排除。不建议在Dataset的__getitem__里用try/except返回None来绕过DataLoader处理None会出各种奇怪错误还会让batch通道缺失后续更容易踩到shape不匹配的坑。5.2 训练侧问题过拟合、显存不足与结果波动问题3训练loss降到很低验证准确率却卡在七成附近不动。现象train loss掉到0.05以下val acc却像被焊死在某个区间怎么调都上不去。原因典型过拟合多半是数据增强做得太轻只做了Resize和ToTensor就开始训练网络把背景、光照这类环境特征一并记住了。解决给训练集加上RandomHorizontalFlip、RandomRotation、RandomResizedCrop这一类常见增强同时观察train和val的loss gap一旦gap明显拉大就提前停。不要全指望自动早停主动控制gap比被动等停更有效。问题4batch_size设到64直接CUDA out of memory。现象第一个训练batch就显存溢出。原因GPU显存不够224×224输入下ResNet18跑batch64对大多数消费级显卡都不轻松。解决batch_size降到32或16如果调小batch后训练不稳定用梯度累积来近似大batch每4个step更新一次参数。对这个数据集来说ResNet18配batch32已经足够没必要为了追求大batch牺牲稳定性。问题5相同脚本跑两次验证准确率波动超过几个百分点。现象完全相同的代码前后两次训练结果不一致val acc差3到5个点。原因随机性主要来自数据打乱顺序、数据增强概率、以及模型初始化的随机种子。二分类验证集如果只有几百张图acc的小波动会被放大。解决脚本开头固定torch.manual_seed和torch.cuda.manual_seed_allDataLoader设置worker_init_fn固定worker随机种子验证时保持批次顺序固定。做模型对比时不要只看单次准确率连续跑三次取均值更可靠。6. 精度之外混淆矩阵与一秒窗口的疲劳判定技巧总准确率能反映“整体怎么样”但反映不了“哪一类更容易漏”。真实场景里疲劳和打哈欠这两类错误的代价完全不同漏掉一次打哈欠可能意味着错过一次关键报警而误报只是让人反感。所以训练结束后我固定会做一件事打印混淆矩阵。6.1 用混淆矩阵替代单一准确率用验证集跑一遍推理把预测结果和真实标签都收集起来再用sklearn出混淆矩阵和分类报告。from sklearn.metrics import classification_report, confusion_matrix import torch model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in valid_loader: images images.to(device) outputs model(images) preds torch.argmax(outputs, dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm confusion_matrix(all_labels, all_preds) print(cm) print(classification_report(all_labels, all_preds, target_names[fatigue, yawning]))confusion_matrix的行代表真实类别列代表预测类别对角线之外就是误判。当疲劳类被误判为打哈欠时cm里对应位置的值会直接显示出来。classification_report除了整体准确率还会给出每个类别的precision、recall、f1其中recall在疲劳检测场景里永远是优先指标宁可多报不能漏报。6.2 从单帧分类走向一秒窗口的连续判定模型部署后会出现一个新的现象验证集准确率不错现场却总是闪报。原因是单帧分类就像只看一个字就判断整句话的意思误差难免。疲劳是持续状态打哈欠是几秒内的连续动作用一两帧去判定“他在打哈欠”太单薄需要引入时间维度的平滑。常见做法是给分类结果加滑窗平均。DMS摄像头帧率通常是25到30帧每秒取最近30帧的打哈欠概率做平均窗口均值超过阈值且持续一秒以上才输出一次有效事件window deque(maxlen30) # 30帧约等于1秒 for each frame: prob_yawn softmax(model(frame))[yawn_idx] window.append(prob_yawn) if mean(window) 0.6: trigger_event(yawn)deque的maxlen30控制窗口长度mean是当前窗口内所有帧概率的平均值0.6这个阈值建议根据验证集的precision-recall曲线去选不要拍脑袋定。经过这样处理后单帧偶发的误报会被平均掉换来更可控的延迟和更低的闪报频率。我在这个数据集上第一次跑出验证准确率0.93时一度以为可以收工。后来把样本逐帧放回模拟视频里看发现三处误报都是短促闭嘴被判定成打哈欠。那时我才意识到分类准确率只是第一步平滑策略必须配套跟进。从那以后我每次做疲劳检测项目都会强制走一遍先打印混淆矩阵看单类召回再配上至少一秒的时间窗口平滑最后才交给上层业务系统。这个习惯帮我避开了很多次“指标很高、落地乱报警”的返工。希望帮到你。本文还有配套的精品资源点击获取