拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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或者YOLOv8m,v5和v8在工具链成熟度上差别不大,但v8的anchor-free输出在导出ONNX时少很多麻烦。如果做服务端高精度,可以考虑YOLOv8x,但双头结构带来的参数量增量会让显存压力明显上升,这也是很多人坚持用单模型双头而不是两个模型级联的原因。

双头结构的核心代码通常长这样:

import torch.nn as nn class AgeGenderHead(nn.Module): def __init__(self, in_channels=512, num_age=8, mid=128): 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_age=8表示把年龄切成 8 个区间,比如 0-5、6-12、13-17、18-25 这样。mid=128是中间层宽度,对 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.py

3.1 从零搭环境:版本锁定比“最新版”更重要

关于 yolo环境搭建,网上教程一堆,但最容易翻车的是版本不锁定。ultralytics 迭代非常快,两周前能跑的脚本,更新到最新版后可能直接报AttributeError: module 'ultralytics' has no attribute 'YOLO'。我一般用一个固定版本号创建一个 conda 环境:

conda create -n yoloface python=3.10 -y conda activate yoloface pip install ultralytics>=8.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时加strict=False,让模型跳过未匹配的权重层。

3.3 数据集准备:检测框和属性标签要拆开存放

人脸年龄性别数据集通常有两种标签来源,一种是标注工具输出的框坐标,另一种是每张图的年龄、性别。我建议把它们拆成两份:YOLO 格式的labels目录只放检测框,属性单独用一个 JSON 文件记录。这样做的好处是你要更新年龄标注时不需要重新生成检测框。

目录结构通常是这样的:

datasets/face_age_gender/ ├── images/ │ ├── train/ │ ├── val/ ├── labels/ │ ├── train/ │ ├── val/ ├── attr_train.json └── attr_val.json

JSON 里面记录的是图片文件名到属性的映射:

{ "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 硬塞进names,ultralytics 会自动把它当成检测类别,反而给训练带来巨大噪声。

训练命令我习惯写在一个小的train.py里:

from ultralytics import YOLO model = YOLO('yolov8n.pt') model.train( data='age_gender.yaml', epochs=60, imgsz=640, batch=16, lr0=0.01, device=0, project='runs/face_age_gender', name='exp1', )

参数说明:epochs=60对小数据集足够,超过 60 后属性头容易过拟合,验证集 loss 回升。lr0=0.01是 ultralytics 默认,如果你发现性别 loss 在两三个 epoch 后完全不降,把它降到 0.005。batch=16在 8G 显存上比较稳,显存不够就降到 8,但要注意 batch 太小时 BN 层不稳定,后面避坑章节会专门讲。imgsz=640是速度和精度的中间值;门禁现场人脸远,建议改 768。

4. 调参的关键点:yolo损失函数在年龄性别头上的改动与混淆矩阵怎么读

4.1 在自有 Trainer 里拼损失,别只靠默认 cls_loss

YOLO 原生的 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, cfg=None, weights=None, verbose=True): model = super().get_model(cfg, weights, verbose) # 给性别分支设置类别权重,解决样本不平衡 self.sex_ce = nn.CrossEntropyLoss(weight=torch.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, alpha=0.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_classes=8,alpha 通常落在 0.1 到 0.2,太小没作用,太大模型会欠拟合。

参数说明:smooth_targets返回的矩阵要传给nn.KLDivLoss或直接作为软标签参与交叉熵计算。如果你嫌麻烦,也可以直接用nn.CrossEntropyLoss(label_smoothing=0.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 层加上momentum=0.1并做梯度裁剪。ultralytics 没有暴露clip_grad_norm参数,需要自己在 trainer 的_setup_train里挂:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10.0)

逻辑说明:梯度裁剪限制了每个 step 权重更新的最大梯度范数,防止单个失控 batch 把 BN 参数推到 NaN。max_norm=10.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(..., freeze=12)。

逻辑说明:freeze=12冻结 backbone 前 12 层,不让检测任务的梯度干扰属性头学习。等属性头先建立从特征到性别的映射,再解除冻结统一微调,最终性别正确率能到 0.88 以上。注意这个策略只解决收敛问题,不解决数据本身就严重失衡的问题。

5.3 性别全部输出男性:样本不平衡比想象中更狠

现象:训练集男性人脸占 80%,女性占 20%,训练后的模型在验证集里把女性也判成男性,精确率和召回率都高,但业务完全没法用。

原因:交叉熵损失对所有样本同等看待,模型发现把所有样本都预测为“男性”也能拿到很低的 loss,这是因为女性样本占比太低,单独优化女性的收益远小于噪声影响。

解决:除了在 4.1 节给的weight,更直接的办法是数据层面的重新采样。在训练 DataLoader 里按sex做分层采样,保证每个 batch 的男女比例接近 1:1:

from torch.utils.data import WeightedRandomSampler weights = [1.0 if a['sex'] == 0 else 3.0 for a in attr_values] sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True)

逻辑说明:WeightedRandomSampler按每个样本的权重决定采样概率,女性样本权重为 3.0,男性样本为 1.0,batch 里男女比例会往均衡方向靠。这个坑的麻烦在于换 trainer 后容易忘记传入 custom sampler。解决后性别精度通常能提升 12 个点左右,其他指标不变。

5.4 门禁远场漏框:小目标人脸直接被跳过

现象:现场在 3 米外人脸只有 24x24 像素大小,模型检不出来,年龄性别自然也出不来;本地测试时人脸占图 50%,怎么测怎么准。

原因:训练集里的人脸框普遍占图 20%-40%,模型学到的人脸特征偏大目标;imgsz=640时小目标在深特征图里已经退化到 1-2 个像素,检测头很难有响应。

解决:训练时开启augment=True并调高imgsz到 768,用 mosaic 增强把同一张图里的人脸缩到各个尺寸。同时把推理时的conf阈值降到 0.25,iou阈值设为 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%,看起来不差,可业务方一算平均绝对误差,直接让我换方案。改成回归头后,模型文件没变大,训练时间还短了三分之一。技术方向没有绝对的对错,但“年龄是连续值”这个直觉值得你在设计系统时多考虑一次。希望这些拆解和踩坑记录帮到你。

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

返回列表