YOLO人脸年龄性别识别:双头模型训练调参与部署指南

发布时间:2026/10/1 1:31:09
YOLO人脸年龄性别识别:双头模型训练调参与部署指南
简介这份压缩包是一套基于YOLO的人脸年龄性别识别系统实践代码面向计算机视觉方向的毕业设计、课程设计以及希望快速搭建人脸检测与年龄性别分类原型的学习者。系统以YOLO模型完成人脸检测再由分类模块对检测到的人脸进行年龄和性别预测覆盖图像输入、人脸对齐、模型推理、指标评估等环节清晰地展示了目标检测与属性分类如何在一个完整流程中衔接。压缩包共包含28个文件整体仅1.1MB以Python源码、pyc编译文件与Jupyter Notebook为主并附有说明文档、依赖清单和环境配置示例方便快速安装运行。四个Notebook分别对应检测模型、人脸对齐、分类模型和最终流水线适合按步骤复现与调参核心脚本则按YOLO加载、MTCNN对齐、分类器封装、主程序入口等模块拆分目录结构简洁易读。目前已有46人学习浏览对于需要结合代码理解端到端识别流程、参考模块划分和数据组织方式的开发者来说是一份紧凑且实用的参考实现。1. 拿到zip先冷静这个基于YOLO的人脸年龄性别识别系统解决什么问题一个zip发到手里名字叫“基于YOLO的人脸年龄性别识别系统”解压后是Python脚本、权重文件和一份README。第一反应是又一个给YOLO加分类头的包装项目但真正深入后会意识到这套系统的难点从来不在人脸检测而是年龄怎么回归、性别类别怎么平衡、模型怎么从训练环境迁到门禁机这类边端设备。它的标准做法是用YOLO系列先框出人脸再在同一个backbone上接两个分支一个分支输出性别一个分支输出年龄段或具体年龄。适合客流画线、互动广告屏、无人货架这类只需要匿名属性的业务不适合“这个人是谁”的身份确认场景。适合的读者是手里有一批人脸数据、想自己复现训练和部署而不是只跑一个demo的工程师。2. 拆开模型共享backbone的双头结构为什么比两个级联模型更适合年龄性别识别2.1 YOLO原始输出只有框年龄性别必须另接双头跑过YOLO的人都知道detect头的输出是“bounding box 类别 置信度”它根本不理解一张人脸是男还是女。强行把年龄性别做成检测类别比如“person_male_30”会在数据侧制造灾难类别数量爆炸每个类别的样本量却小得可怜。我自己见过有人用这种方案在自采数据上训练mAP只有0.3本质上是把属性分类问题错误地建模成目标检测问题。常规做法是在YOLO的检测头旁边插入两个轻量分类头共享backbone提取的特征。选型上要看你的落地设备RK3588这类端侧设备建议用YOLOv8n或者YOLOv8mv5和v8在工具链成熟度上差别不大但v8的anchor-free输出在导出ONNX时少很多麻烦。如果做服务端高精度可以考虑YOLOv8x但双头结构带来的参数量增量会让显存压力明显上升这也是很多人坚持用单模型双头而不是两个模型级联的原因。双头结构的核心代码通常长这样import torch.nn as nn class AgeGenderHead(nn.Module): def __init__(self, in_channels512, num_age8, mid128): super().__init__() self.pool nn.AdaptiveAvgPool2d(1) self.shared nn.Sequential( nn.Conv2d(in_channels, mid, 1), nn.BatchNorm2d(mid), nn.ReLU(), ) self.sex nn.Linear(mid, 2) self.age nn.Linear(mid, num_age) def forward(self, x): x self.shared(self.pool(x)) x x.flatten(1) return self.sex(x), self.age(x)逻辑说明AdaptiveAvgPool2d(1)把任意空间尺寸的特征图压成 1x1解决YOLO多尺度特征图空间尺寸不一致的问题。两分支先经过一个共享的 Bottleneck再分别接sex和age两个全连接层。num_age8表示把年龄切成 8 个区间比如 0-5、6-12、13-17、18-25 这样。mid128是中间层宽度对 YOLOv8n 这类小模型建议不要超过 256否则额外算力会拖慢端侧部署。参数说明in_channels要和 backbone 输出对齐YOLOv8n 的 P5 层一般是 512如果接入 P3/P4层需要把尺度先上采样或下采样对齐。AdaptiveAvgPool2d的好处是不管输入图片是 640 还是 768分支都能正常工作坏处是会丢掉一部分空间细节所以年龄头往往比性别头更难收敛。2.2 为什么不用两阶段级联延迟、显存和特征复用都要算账最朴素的方案是 YOLO 检测人脸再把人脸裁剪出来喂给一个 MobileNet 做属性分类。我在早期项目里就是这么干的后来被现场人员吐槽延迟太高才改成单模型双头。其实两阶段级联单帧多一次前向分辨率又小信息损失很明显更重要的是属性分类需要的发际线、眼眶纹理、皮肤状态这些中层特征和人脸检测框定位需要的低层特征高度重合拆成两个模型等于让两个网络各学一遍重复的特征。方案额外延迟特征复用显存开销小样本表现两个模型级联8-15ms无两套模型常驻显存属性分支容易过拟合单模型双头2-5ms强共享backbone只多30%检测梯度也会更新共享层泛化更稳上表中的延迟差异在服务端 GPU 上感知不明显但在 RK3588 或者树莓派这类设备上有质的区别。共享backbone的另一个隐藏好处是训练时可以用两阶段法先冻结backbone只训练两个属性头再解冻整个模型微调。这样属性头在前面几十个epoch里不会因为检测任务的梯度方向变化太大而震荡后面再统一调整个网络。需要注意单模型双头也有边界。如果同一张图里出现多张人脸检测框有多个属性头只在整图特征上做推理没有逐框 RoI 处理就做不到“每个框都有自己属性”。要支持多目标人的属性必须把检测框对应的特征区域裁剪出来或者给分支加 RoIAlign。大多数项目默认一图一人所以双头方案才成立。3. 跑通最小训练yolo环境搭建、预训练模型下载和一份可改的train.py3.1 从零搭环境版本锁定比“最新版”更重要关于 yolo环境搭建网上教程一堆但最容易翻车的是版本不锁定。ultralytics 迭代非常快两周前能跑的脚本更新到最新版后可能直接报AttributeError: module ultralytics has no attribute YOLO。我一般用一个固定版本号创建一个 conda 环境conda create -n yoloface python3.10 -y conda activate yoloface pip install ultralytics8.0.200,8.2.0 opencv-python pyyaml逻辑说明把 ultralytics 锁在 8.0/8.1 大版本区间避免 yolo最新版本更新内容 带来的破坏性改动。Python 3.10 对 ONNX 和 RKNN 工具链兼容较好不要图新鲜用 3.12。安装成功后先跑一句yolo check确认 torch 和 CUDA 可用。参数说明opencv-python一定要装很多后处理脚本隐式依赖它。如果用 NVIDIA GPU建议手动装对应 CUDA 的 torch不要全交给 ultralytics 自动装否则可能装到 CPU 版。3.2 预训练模型下载别指望训练时自动完成训练时不加pretrained也可以但 yolo预训练模型下载 这一步如果你提前做能省掉大量等待。ultslrytics 默认会从官方资产下载网络差的时候容易卡死。我习惯先去官方 Release 页把yolov8n.pt下载到项目根目录然后在代码里显式指定路径from ultralytics import YOLO model YOLO(yolov8n.pt) # 优先读本地权重不存在才会尝试网络下载逻辑说明YOLO(yolov8n.pt)会先加载检测和 backbone 的预训练权重属性头是随机初始化的所以训练初期属性分类 loss 会比较大这是正常现象。如果你已经有一个人脸检测模型比如yolov8n-face.pt也可以用它作为起点因为人脸域比 COCO 域更贴近业务。参数说明预训练权重里如果不包含属性头加载时会因为参数字典缺失报错常见做法是在load时加strictFalse让模型跳过未匹配的权重层。3.3 数据集准备检测框和属性标签要拆开存放人脸年龄性别数据集通常有两种标签来源一种是标注工具输出的框坐标另一种是每张图的年龄、性别。我建议把它们拆成两份YOLO 格式的labels目录只放检测框属性单独用一个 JSON 文件记录。这样做的好处是你要更新年龄标注时不需要重新生成检测框。目录结构通常是这样的datasets/face_age_gender/ ├── images/ │ ├── train/ │ ├── val/ ├── labels/ │ ├── train/ │ ├── val/ ├── attr_train.json └── attr_val.jsonJSON 里面记录的是图片文件名到属性的映射{ train/00001.jpg: {sex: 0, age: 3}, train/00002.jpg: {sex: 1, age: 5} }逻辑说明sex用 0/1 表示男/女age用年龄段的索引。这里一个前提是一张图只有一个主体人脸如果有多个脸你要把 JSON 的 value 改成列表并且保证列表顺序和labels里的框顺序一致否则属性会错位。顺序错位是训练属性头时最常见的数据 bug我见过同事查了一天才发现是 JSON 里是倒序。训练时用 ultralytics 的 API 传入数据配置# age_gender.yaml path: /absolute/path/to/datasets/face_age_gender train: images/train val: images/val names: 0: face逻辑说明数据配置里只写检测类别face年龄性别的标签不走这个 yaml而是在自定义 Dataset 里读 JSON。如果你把 age/sex 硬塞进namesultralytics 会自动把它当成检测类别反而给训练带来巨大噪声。训练命令我习惯写在一个小的train.py里from ultralytics import YOLO model YOLO(yolov8n.pt) model.train( dataage_gender.yaml, epochs60, imgsz640, batch16, lr00.01, device0, projectruns/face_age_gender, nameexp1, )参数说明epochs60对小数据集足够超过 60 后属性头容易过拟合验证集 loss 回升。lr00.01是 ultralytics 默认如果你发现性别 loss 在两三个 epoch 后完全不降把它降到 0.005。batch16在 8G 显存上比较稳显存不够就降到 8但要注意 batch 太小时 BN 层不稳定后面避坑章节会专门讲。imgsz640是速度和精度的中间值门禁现场人脸远建议改 768。4. 调参的关键点yolo损失函数在年龄性别头上的改动与混淆矩阵怎么读4.1 在自有 Trainer 里拼损失别只靠默认 cls_lossYOLO 原生的 yolo损失函数 由三部分组成box_loss、cls_loss 和 dfl_loss。但在双头模型里cls_loss 只管检测框的类别年龄和性别两个属性分支的损失必须额外加进去。直接在 ultralytics 默认训练循环上改通常要继承DetectionTrainer重写loss方法。import torch.nn as nn from ultralytics.models.yolo.detect.train import DetectionTrainer class AgeGenderTrainer(DetectionTrainer): def get_model(self, cfgNone, weightsNone, verboseTrue): model super().get_model(cfg, weights, verbose) # 给性别分支设置类别权重解决样本不平衡 self.sex_ce nn.CrossEntropyLoss(weighttorch.tensor([1.0, 3.0])) self.age_ce nn.CrossEntropyLoss() return model def loss(self, batch, preds): loss_items, loss super().loss(batch, preds) # 这里拿到的是检测loss属性分支的logits需要通过额外前向解析 sex_logits preds[sex] age_logits preds[age] sex_loss self.sex_ce(sex_logits, batch[sex]) age_loss self.age_ce(age_logits, batch[age]) loss loss 0.25 * sex_loss 0.25 * age_loss return loss, loss_items逻辑说明重写loss方法时先调用父类拿到检测损失再把属性分支的损失加权拼接。注意sex_ce的weight参数传入一个张量对应性别的两个类各自损失权重。这里给女性样本更大的权重缓解训练集里男性占比过高导致的“所有输出都是男性”现象。参数说明0.25是属性损失的加权系数。属性头在训练初期梯度很大如果权重太高会把检测部分的梯度淹没导致人脸框漂移。我一般先设 0.25观察两个分支 loss 的下降节奏如果属性 loss 下不去改为 0.5如果检测框质量明显下降改回 0.1。4.2 年龄用分类还是回归先解决相邻类模糊问题年龄识别和性别识别有个根本差别性别是干净的二分类而年龄是高度模糊的连续性标签。用硬分类做 8 个年龄段模型会在相邻年龄段的边界上反复震荡。一个 28 岁的人标成 25-30 和 30-35 其实都说得过去但交叉熵会强逼模型 “二选一”。这会导致训练曲线看着收敛验证准确率却只有五成多。比较实用的做法是给年龄标签做平滑def smooth_targets(y, num_classes, alpha0.1): n y.size(0) smooth torch.zeros(n, num_classes) smooth.fill_(alpha / (num_classes - 1)) smooth.scatter_(1, y.unsqueeze(1), 1.0 - alpha) return smooth逻辑说明原本的 one-hot 标签把正确类别设为 1.0其余为 0.0平滑后正确类别设为 0.9其余类别共享 0.1。这使得模型不再追求输出绝对置信度相当于告诉它“年龄本身就是模糊的”。逻辑说明里num_classes8alpha 通常落在 0.1 到 0.2太小没作用太大模型会欠拟合。参数说明smooth_targets返回的矩阵要传给nn.KLDivLoss或直接作为软标签参与交叉熵计算。如果你嫌麻烦也可以直接用nn.CrossEntropyLoss(label_smoothing0.1)效果接近但没法对相邻年龄区间做差异化权重。后面第 6 章我会给出更满足业务需求的回归改法。4.3 混淆矩阵总合不唯一的真相先归一化再谈总合训练结束后很多人会看trainer.validator.confusion_matrix然后发现打印出来的矩阵所有格子加起来不是 1甚至大于 1于是怀疑自己模型有问题。这个现象在 yolo混淆矩阵总合不唯一 这个热词下被大量讨论其实它不是 bug是纵轴和横轴的代表意义不同纵轴是真值类别横轴是预测类别每一格统计的是“真值属于 A 类、预测属于 B 类的样本数”且后台没有对全表做统一的归一化。如果你想得到总和为 1 的归一化矩阵自己动手除一下cm trainer.validator.confusion_matrix.matrix total cm.sum() normalized cm / total print(normalized.sum()) # 1.0逻辑说明cm是原始累计计数矩阵cm.sum()得到所有预测和真值匹配的样本数作除法后每个格子表示“该真值-预测组合在全部样本中占的比例”这时总和必然为 1。要注意验证集图像里的背景区域也会参于计数背景这一类占比大会压低人脸类比例所以看对角线的比例比看绝对数值有意义得多。参数说明如果你的验证集里每张图都有多张人脸cm的统计会和单脸场景不一致因为每个目标独立进入混淆矩阵而背景列仍然按图计数。建议先用纯单脸测试集验证再放多脸场景。5. 避坑排查从bn崩溃到RK3588部署翻车的五条血泪经验5.1 BN层训练崩溃loss突然NaN几百步后权重全是脏值现象训练到约 500 步loss 突然从 2 跳到 NaN中断后重启还是会在同一位置附近崩把best.pt用于推理输出全部为 0。原因batch size 太小比如 8 或 4导致 BatchNorm 的均值方差在持续波动再加上学习率 0.01 对双头模型来说偏大某一批数据的梯度异常直接把 BN 层的 running_mean 带飞。这行现象在 yolo训练中bn崩溃 里被讨论很多本质不是模型结构问题而是优化器参数和 BN 不兼容。解决先把batch提高到 16 以上如果显存不允许在模型里给属性头的 BN 层加上momentum0.1并做梯度裁剪。ultralytics 没有暴露clip_grad_norm参数需要自己在 trainer 的_setup_train里挂torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm10.0)逻辑说明梯度裁剪限制了每个 step 权重更新的最大梯度范数防止单个失控 batch 把 BN 参数推到 NaN。max_norm10.0是个保守值对于人脸属性任务已经足够。如果你发现裁剪后 loss 不降把lr0从默认 0.01 降到 0.005再做一轮。5.2 属性头不收敛检测mAP很高性别准确率却一直在0.6附近现象模型训练 30 个 epochs 后人脸检测的 mAP 到 0.95但性别验证精度停在 0.6像随机猜。原因检测任务收敛太快检测梯度主导了共享backbone的权重更新属性头回传的梯度相对太小尤其出生阶段sex_logits随机初始化对共享层的贡献可以忽略导致属性头只在接近输出层的小范围优化。最终backbone没有学到对性别有用的纹理特征。解决不要直接从随机初始化开始联合训练。先把 FEMALE_HEAD 之外的检测层冻结单独训练属性头 10 个 epochs再解冻全模型微调。在 ultralytics 里设定freeze参数例如前 12 层固定model.train(..., freeze12)。逻辑说明freeze12冻结 backbone 前 12 层不让检测任务的梯度干扰属性头学习。等属性头先建立从特征到性别的映射再解除冻结统一微调最终性别正确率能到 0.88 以上。注意这个策略只解决收敛问题不解决数据本身就严重失衡的问题。5.3 性别全部输出男性样本不平衡比想象中更狠现象训练集男性人脸占 80%女性占 20%训练后的模型在验证集里把女性也判成男性精确率和召回率都高但业务完全没法用。原因交叉熵损失对所有样本同等看待模型发现把所有样本都预测为“男性”也能拿到很低的 loss这是因为女性样本占比太低单独优化女性的收益远小于噪声影响。解决除了在 4.1 节给的weight更直接的办法是数据层面的重新采样。在训练 DataLoader 里按sex做分层采样保证每个 batch 的男女比例接近 1:1from torch.utils.data import WeightedRandomSampler weights [1.0 if a[sex] 0 else 3.0 for a in attr_values] sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue)逻辑说明WeightedRandomSampler按每个样本的权重决定采样概率女性样本权重为 3.0男性样本为 1.0batch 里男女比例会往均衡方向靠。这个坑的麻烦在于换 trainer 后容易忘记传入 custom sampler。解决后性别精度通常能提升 12 个点左右其他指标不变。5.4 门禁远场漏框小目标人脸直接被跳过现象现场在 3 米外人脸只有 24x24 像素大小模型检不出来年龄性别自然也出不来本地测试时人脸占图 50%怎么测怎么准。原因训练集里的人脸框普遍占图 20%-40%模型学到的人脸特征偏大目标imgsz640时小目标在深特征图里已经退化到 1-2 个像素检测头很难有响应。解决训练时开启augmentTrue并调高imgsz到 768用 mosaic 增强把同一张图里的人脸缩到各个尺寸。同时把推理时的conf阈值降到 0.25iou阈值设为 0.45 以容忍部分重叠人脸。如果你的部署允许直接把输入分辨率提到 960远场检出率提升明显但 RK3588 上的耗时可能翻倍。提示远场小目标问题没有免费的午餐提升分辨率是性价比最高的方案如果必须维持 640就得考虑在数据集中专门加入小尺寸人脸样本并在损失里提高小目标的 box 权重。5.5 RK3588部署翻车ONNX导出成功但NPU跑起来极慢现象本地 TensorRT 推理 5ms到 RK3588 上转成 RKNN 后单帧 200ms完全没法用排查发现瓶颈不在卷积而是属性头里的flatten和 softmax 算子被放在了 NPU 的 CPU fallback 路径上。原因属性头用了AdaptiveAvgPool2d(1)flatten(1)这个组合在 RKNN 工具链里拼接成多个小算子NPU 支持度差只能在 CPU 上跑。年龄头的 softmax 也是同样的坑。解决导出 ONNX 时把池化改写成固定大小的平均池化比如nn.AvgPool2d(7)代替AdaptiveAvgPool2d(1)并让属性头最后的输出直接连两个全连接不在模型内部做 softmax把归一化放到后处理代码里。这样 RKNN 转换时几乎所有算子都落在 NPU 上单帧从 200ms 降到 45ms。注意如果是 RKNN 的模型转换设备端推理时输入输出通道顺序要对应。YOLOv8 原始输出是 NCHW部分工具链默认要求 NHWC转换前先确认。6. 把年龄从分类改成回归MAE验证与边端部署的一次性改造年龄段分类方案在演示时够用但真要交付我习惯把年龄分支从多分类改成单输出回归。年龄本身就是连续量分类强行把它离散化会引入人为的边界噪声。比如业务方要求显示“28 岁”分类头只能给“25-30”区间体验差很多。改成回归后最后一个全连接输出 1 个神经元用 SmoothL1Loss 训练预测值直接是一个 0-100 的数字。age_out outputs[age].squeeze() age_out torch.clamp(age_out, 0, 100) age_mae (age_out - age_gt).abs().mean().item() age_bucket (age_out // 5).clamp(0, 19)逻辑说明clamp把回归输出限制在合法年龄范围避免极端值MAE是年龄识别最常用的指标比分类准确率更直观也更容易和业务对齐。age_bucket是为了兼容现有展示层而做的区间映射回归结果可以同时满足“精确年龄”和“年龄段显示”两种需求。训练时把损失换成nn.SmoothL1Loss()权重系数跟 4.1 节的age_loss一样保留 0.25。解锁冻结后继续微调 20 个 epochs你会发现 MAE 从分类模型的 6.3 岁降到 4.5 岁左右性别分支不受影响。部署阶段反而更干净回归头在 RK3588 上不需要 softmax 和 argmax 算子输出直接是一个 float32 标量CPU 后处理也少了很多判断逻辑。最后说个自己的教训早期我也迷信过 8 分类年龄头测试报告上分类准确率 62%看起来不差可业务方一算平均绝对误差直接让我换方案。改成回归头后模型文件没变大训练时间还短了三分之一。技术方向没有绝对的对错但“年龄是连续值”这个直觉值得你在设计系统时多考虑一次。希望这些拆解和踩坑记录帮到你。本文还有配套的精品资源点击获取