news 2026/9/14 1:33:25

用ResNet18微调300张人脸图实现性别分类与检测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用ResNet18微调300张人脸图实现性别分类与检测

简介:面向深度学习算法训练的人脸性别检测与分类数据集,涵盖woman、man两类共300张真实手机采集的高质量人脸图片,均已人工分类标注,适合人脸检测、性别特征提取与分类模型的训练及评估。资源包共505个文件、约339.41MB,包含Python训练脚本、模型配置文件(config/pbtxt)、TensorFlow模型权重(pb/checkpoint/meta)、图像样本(jpg/png)、标注文件(xml/txt/record)以及说明文档(md/ipynb)等,可支撑从数据加载、模型训练到推理部署的完整流程。数据集图片源自真实拍摄环境,光线、角度、表情多样性较好,有助于提升性别分类模型的泛化能力;配套脚本与配置还能用于目标检测、人流统计等扩展任务。目前已有58人学习下载,适合从事计算机视觉研究的学生、算法工程师以及需要快速获取规范训练数据的开发者使用。

1. 300张人脸图做性别分类,为什么够用

拿到一份标注好 woman/man 两类的人脸性别检测分类数据集,第一反应通常是:300 张图,够深度学习算法用吗?我一开始也怀疑,直到用预训练模型做迁移学习试了一轮才确认,这个体量下决定泛化能力的不是绝对数据量,而是分布一致性。数据全部来自真实手机采集,光线、角度、遮挡都带着日常场景的噪声,这类“脏而真实”的样本,对微调的帮助比网上爬来的干净人头大得多。

真正值得花时间的路径是:先验证数据质量,再基于 ResNet 系模型微调性别分类器,然后把检测和分类串成完整管线,最后落到人流量统计这类应用上。下文从目录结构讲起,到硬样本挖掘收尾,每一步都有可直接运行的代码。

2. 数据体检:目录结构、标注完整性与类别平衡

2.1 目录结构与标签组织

这类人脸性别检测分类数据集常见组织方式是分成 train/val 两个根目录,内部再按 woman/man 分子目录。目录名就是标签,训练时借助 torchvision 的 ImageFolder 直接读取即可,不需要额外解析标注文件。拿到资源后先列出完整目录树,确认没有多余的隐藏文件或嵌套文件夹,避免 DataLoader 把非图片文件也扫进来。

find . -type f | sed 's|[^/]*/| |g'

上面命令把路径缩进打印,一眼能看出目录的嵌套层级。如果出现 .DS_Store、Thumbs.db 这类系统文件,训练前统一删掉。

2.2 标注体检:文件完整性、重复图片与类别比例

标题写“已做分类划分标注”,这一点必须先验证再信。手机采集的图片经常出现导出不全、EXIF 旋转信息丢失、同一张图被重复拷贝等问题,它们不会直接报错,但会悄悄拖低分类准确率。下面的脚本遍历两个子目录,统计数量、计算 MD5、并尝试用 PIL 打开每张图:

from pathlib import Path from PIL import Image from collections import Counter import hashlib data_root = Path("gender_dataset/train") counts = Counter() broken = [] seen = {} for label_dir in ["woman", "man"]: for img_path in (data_root / label_dir).glob("*.jpg"): counts[label_dir] += 1 digest = hashlib.md5(img_path.read_bytes()).hexdigest() if digest in seen: print(f"重复文件: {img_path} 与 {seen[digest]} 相同") else: seen[digest] = str(img_path) try: with Image.open(img_path) as im: im.load() except Exception as e: broken.append((str(img_path), e)) print("类别分布:", dict(counts)) print("损坏文件:", broken)

逻辑说明:MD5 比对只针对字节完全一致的副本,能抓出重复采集;PIL 的 load 会真正解码像素数据,能暴露扩展名伪装成 jpg 的损坏文件。运行后注意两个指标——两类的图片数量差最好别超过 15%,这是类别不平衡的简单判据;损坏文件出现一条就要排查原始采集批次。

结合性别分类任务,我一般还会记录每张图的宽高分布,用于决定 resize 策略。手机相机默认拍 3000×4000 左右的大图,原始分辨率差异不会影响最终训练,但 EXIF 旋转标记会导致同一张人脸被转成横竖两种方向,建议在预处理阶段按 orientation 字段修正后再使用。

2.3 训练集与验证集的划分方式

300 张样本不建议随机切分完事。性别分类的难点经常集中在特定年龄段和特定姿态上,随机划分可能让老人、侧脸样本全部掉进训练集或验证集,造成评估虚高或虚低。比较稳的做法是先按人分组——同一张照片里的多张脸不拆散,再按 7:1.5:1.5 划分成 train/val/test。

分组后验证集至少保留 40 张以上,否则单次评估的置信区间太宽,90% 和 95% 的准确率差异无法分辨。写个按文件名前缀分组的脚本是值得的:

import random from pathlib import Path from collections import defaultdict groups = defaultdict(list) for p in Path("gender_dataset/train").rglob("*.jpg"): person_id = p.name.split("_")[0] # 按命名前缀模拟人员分组 groups[person_id].append(p) all_persons = list(groups.keys()) random.seed(42) random.shuffle(all_persons) n = len(all_persons) train_ids = set(all_persons[:int(n*0.7)]) val_ids = set(all_persons[int(n*0.7):int(n*0.85)])

按人员分组的意义在于防止同一个人脸的不同帧同时出现在训练和验证里,模型一旦记住的是“这张脸”而不是“性别特征”,分数再高也失去迁移意义。划分完成后把路径列表写成 csv,方便后续恢复到同一实验环境。

检查项手段判定标准
文件可解码PIL open + load无异常
重复图片MD5 哈希比对无重复
类别平衡统计两子目录数量差值不超过 15%
分组不泄漏按 person_id 划分val/test 不出现训练身份

3. 用 ResNet18 微调性别分类模型

3.1 预训练权重加载与分类头替换

分类数据集只有 300 张,从零训练 CNN 必然过拟合。常见做法是加载在 ImageNet 上预训练过的 ResNet18,冻结前面几层,只微调最后几个残差块和分类头。性别识别依赖的眉眼、下颌线等纹理属于中高层特征,网络前几层学到的边缘和颜色结构不需要大改。

import torch import torch.nn as nn from torchvision import models model = models.resnet18(pretrained=True) # 替换最后一层全连接:原输出1000类,这里映射到2类 model.fc = nn.Linear(model.fc.in_features, 2) # 冻结前三个残差层,只训练 layer4 和 fc for name, param in model.named_parameters(): if not name.startswith("layer4") and not name.startswith("fc"): param.requires_grad = False

参数说明:pretrained=True拉取的是 ImageNet 权重,之后的微调相当于把“猫狗分类”里学到的通用特征迁移到性别判断上;model.fc.in_features动态读取原全连接输入维度,避免手工写死 512,换成 ResNet50 时这段代码也不需改。冻结前三个残差层的理由是 ImageNet 预训练模型底层对纹理、边缘的表达已经足够通用,在 300 张小数据集上继续调整反而容易过拟合到训练集的光线分布上。

3.1.1 数据增强策略

小数据集必须配合强增强。除了常规的随机水平翻转和随机旋转,我建议加上 ColorJitter 和 RandomErasing。手机照片存在大量室内暖光、夜间闪光灯、逆光背光场景,ColorJitter 能模拟不同白平衡下的肤色偏移,RandomErasing 模拟发丝遮挡和眼镜反光——这两类噪声在手机人脸里非常常见。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2), transforms.RandomErasing(p=0.3, scale=(0.02, 0.15)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])

参数说明:RandomResizedCrop 的 scale 下限 0.7 保证裁剪后仍然保留大部分人脸区域,太小的裁剪会切掉眼睛或下巴,放大到 224 后已经丢失判别信息;RandomErasing 的 scale 控制在 0.02~0.15 之间,遮挡面积超过 15% 就不像真实发丝或反光了。Normalize 用的是 ImageNet 统计量,因为预训练权重就是在这个分布下学出来的,换用自定义 mean/std 会导致微调初期 loss 剧烈震荡。

3.2 训练循环与超参配置

300 张训练集、batch size 取 16 比较合适:再小的话 BatchNorm 的统计量不稳定,再大则每个 epoch 参数更新次数太少。优化器我用 SGD 加 momentum,原因在于迁移学习微调阶段 SGD 对学习率的衰减更敏感,配合 cosine 退火能在小数据上收敛到更平缓的极小值。

import torch.optim as optim from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder train_loader = DataLoader( ImageFolder("gender_dataset/train", train_transform), batch_size=16, shuffle=True, num_workers=4, drop_last=True) val_loader = DataLoader( ImageFolder("gender_dataset/val", val_transform), batch_size=32, shuffle=False, num_workers=4) optimizer = optim.SGD(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, momentum=0.9, weight_decay=5e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = nn.CrossEntropyLoss() best_acc = 0.0 for epoch in range(30): model.train() running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) scheduler.step() # 每个 epoch 结束做一次验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: preds = model(images).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "gender_resnet18.pt") print(f"epoch={epoch} loss={running_loss/len(train_loader.dataset):.4f} val_acc={acc:.4f}")

逻辑说明:每个 epoch 清空梯度、前向、反向、更新、调度器退火,验证集不参与反向传播,只统计 argmax 后的类别是否与标签一致。filter(lambda p: p.requires_grad)保证冻结层不会出现在 optimizer 里,否则 weight_decay 依然会对冻结参数产生不必要的衰减。CosineAnnealing 在 30 个 epoch 内把学习率从 1e-3 平滑降到接近 0,相比固定学习率省去了手动找下降时机的麻烦。

训练时盯着 val loss 而不只看 val acc。300 张图上准确率只有 5% 的波动都很正常,但 loss 的变化更早暴露拟合趋势。如果 train loss 持续下降而 val acc 停滞,优先考虑增强强度不够,而不是增加模型容量。

3.3 评估:混淆矩阵与类别独立指标

整体准确率在男女比例不平衡时没有参考价值。验证集如果 woman 占 60%,模型全猜 woman 也有 60%,跟踪 Precision/Recall 才能看出哪一类出错。性别分类的典型错误方向是“男性被错判为女性,尤其是长发年轻男性”,因此重点看 man 类的 recall 和 woman 类的 precision。

from sklearn.metrics import confusion_matrix, classification_report y_true, y_pred = [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: y_true.extend(labels.tolist()) y_pred.extend(model(images).argmax(dim=1).tolist()) print(classification_report(y_true, y_pred, target_names=["woman", "man"])) print(confusion_matrix(y_true, y_pred))

运行后会输出每类的 precision、recall、f1-score。当 man 类 recall 明显低于 woman 类时,说明分类器把不少男性认成了女性。接下来要做的不一定是加数据,而是回到数据增强:给 man 类的训练样本提高 RandomRotation 的度数范围,或者对眉骨、下颌区域做随机裁剪放大。

4. 把检测和分类串成完整管线

4.1 config 文件与检测模型的对应关系

项目包里出现的一串配置文件,像 facessd_mobilenet_v2_quantized_320x320_open_image_v4.config、ssd_mobilenet_v2_quantized_300x300_coco.config,是 TensorFlow Object Detection API 的 pipeline 配置,文件名把骨干网络、输入分辨率和训练数据集都写清楚了。facessd 这组在 Open Images 上专门针对人脸做了微调,适合直接作为性别分类前的人脸检测器;后面几个是通用目标检测基线,用途是验证检测模块能否被整体替换。

配置文件输入分辨率骨干网络适用任务
facessd_mobilenet_v2_quantized_320x320_open_image_v4.config320×320MobileNetV2人脸检测
ssd_mobilenet_v2_quantized_300x300_coco.config300×300MobileNetV2COCO 通用目标
ssdlite_mobilenet_v2_coco.config320×320MobileNetV2轻量级部署
ssd_mobilenet_v1_coco.config300×300MobileNetV1CPU 推理

理解这些配置的意义在于推理阶段的输入尺寸必须与训练一致。config 里写 320×320,推理时 blobFromImage 就 resize 到 320,否则检测框位置和置信度都会漂移。

4.2 用 OpenCV DNN 加载检测器并接上分类模型

有配套 checkpoint 时,最快的方式是用 TensorFlow Object Detection API 导出 frozen_inference_graph.pb,再由 OpenCV 的 DNN 模块加载。省去 TensorFlow 依赖的同时保留 SSD 的检测能力。完整管线分四步:读图、SSD 出框、裁脸、ResNet18 分类,按帧循环即可处理一组手机照片。

import cv2 import torch from torchvision import transforms from PIL import Image import numpy as np # 1. 人头检测:SSD MobileNet,从 pb 加载 det_net = cv2.dnn.readNetFromTensorflow("frozen_inference_graph.pb") # 2. 性别分类:上一章保存的 ResNet18 cls_model = models.resnet18(num_classes=2) cls_model.load_state_dict(torch.load("gender_resnet18.pt")) cls_model.eval() def inference(img_bgr, conf_thresh=0.6): h, w = img_bgr.shape[:2] blob = cv2.dnn.blobFromImage(img_bgr, 1.0, (320, 320), (127.5, 127.5, 127.5), swapRB=True) det_net.setInput(blob) detections = det_net.forward() # shape: [1,1,N,7] results = [] for i in range(detections.shape[2]): confidence = detections[0, 0, i, 2] if confidence < conf_thresh: continue x1 = int(detections[0, 0, i, 3] * w) y1 = int(detections[0, 0, i, 4] * h) x2 = int(detections[0, 0, i, 5] * w) y2 = int(detections[0, 0, i, 6] * h) x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w, x2), min(h, y2) face = img_bgr[y1:y2, x1:x2] if face.size == 0: continue face_rgb = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) face_pil = Image.fromarray(face_rgb).resize((224, 224)) tensor = val_transform(face_pil).unsqueeze(0) with torch.no_grad(): logits = cls_model(tensor) gender = "woman" if logits.argmax(1).item() == 0 else "man" prob = torch.softmax(logits, dim=1).max().item() results.append((x1, y1, x2, y2, gender, prob)) return results

参数说明:blobFromImage 里的(127.5,127.5,127.5)对应 SSD MobileNet 的均值缩放,换用 COCO 版检测器时该项不变,但换用别的网络要查它的预处理定义;detections 每个候选框的第 3 号元素是置信度,4~7 号元素是归一化后的左上角、右下角坐标,乘回原图宽高才得到像素坐标。分类前裁剪人脸区域,是最容易出错的一步——如果直接把整张图丢给分类器,背景中的长发、衣领颜色会主导预测结果,性别分类就退化成“发型分类”了。这里建议框边适当外扩 5% 左右,把下颌和鬓角包含进来,避免检测框贴脸太紧丢掉判别区域。

4.3 置信度阈值与多框去重

SSD 在侧脸、低头场景中可能出现一个头给出多个重叠框。简单阈值的写法在手机照片上往往误报较多,实际项目里大多会再加一步 NMS。OpenCV 的 NMSBoxes 接收的是像素框和置信度,在把结果 append 进 results 前做过滤:

boxes = [] confidences = [] for i in range(detections.shape[2]): confidence = detections[0, 0, i, 2] if confidence < 0.5: # 先放宽阈值,交给 NMS 筛 continue x1 = int(detections[0,0,i,3]*w) y1 = int(detections[0,0,i,4]*h) x2 = int(detections[0,0,i,5]*w) y2 = int(detections[0,0,i,6]*h) boxes.append([x1, y1, x2, y2]) confidences.append(float(confidence)) idx = cv2.dnn.NMSBoxes(boxes, confidences, score_threshold=0.5, nms_threshold=0.4)

NMS 参数里 score_threshold 用 0.5,nms_threshold 用 0.4 是一个比较通用的起点。nms_threshold 越大保留的框越多,适合多人密集场景;越小越容易把同一张脸的重复框合并干净,代价是两个人挨得太近时可能漏掉其中一个。先放宽阈值再交给 NMS,比只靠一个高置信度阈值更稳,因为 NMS 会保留最高置信度的框,而不是简单丢弃所有低分框。

提示:检测器输出的人脸框如果出现负坐标,一定要做max(0, x1)这类裁剪,否则后续 PIL 的 crop 直接抛异常中断整个推理管线。

5. 进阶:性别比例统计与硬样本挖掘

5.1 从逐帧检测到人群计数

有了第 4 章的管线,可以进一步做性别比例统计,这也正是“人流统计”的简化落法。跟 UCF101 那种视频动作分类关注时序语义不同,这里只关心画面中出现的人和他们的性别,不需要建模具体动作。最简单的做法是按帧统计检测到的人脸数,再用滑窗中位数消掉检测器单帧跳变:

from collections import deque frame_history = deque(maxlen=15) gender_counter = {"woman": 0, "man": 0} def process_frame(frame): dets = inference(frame, conf_thresh=0.6) counts = {"woman": 0, "man": 0} for x1, y1, x2, y2, gender, prob in dets: counts[gender] += 1 for k in counts: gender_counter[k] += counts[k] frame_history.append(len(dets)) return int(np.median(frame_history)) # 稳定的人数估计

中位数滤波对 15 帧窗口的离群值不敏感,一个人突然检测失败一帧不会让计数冲高回落。正式做人群计数时再把帧级统计升级成卡尔曼滤波跟踪轨迹,这里的数据集规模更适合先把检测和分类准确率做实。

5.2 硬样本挖掘:用错题集反向提升准确率

当整体准确率达到 90% 以上后,光调学习率收益很小。可以把验证集里预测错误的样本单独导出,观察共性——这类手机采集数据里比较常见的错误是光线很暗的室内侧脸,以及戴深色墨镜的样本。把这些难例像下面这样找出来:

hard_samples = [] model.eval() with torch.no_grad(): for img_path, label in val_samples: img = load_image(img_path) pred = model(img.unsqueeze(0)).argmax(1).item() if pred != label: hard_samples.append(img_path) for i, p in enumerate(hard_samples): shutil.copy(p, f"debug/hard_{i}_{Path(p).stem}.jpg")

人工看过难例后,把其中 10~20 张加入训练集并配上更强的遮挡增强,比盲目加预训练数据更有效。错题集的分布往往能看出数据采集的盲区,比如漏检的全部是逆光场景,那就回到第 4 章调检测器的预处理,而不是动分类模型。

5.3 常见失败模式与参数调整

失败现象可能原因调整方向
长发男性判为 woman发型特征主导,模型没看眉骨下颌训练时对 man 类做局部裁剪增强,强化面部结构
侧脸漏检检测器对夸张角度召回不足置信度阈值从 0.6 降到 0.45,并加入 NMS 去重
肤色偏色导致误判室内暖光/闪光灯白平衡漂移ColorJitter 的 brightness 上限提到 0.4,重跑微调
同一个人重复计数检测框在相邻帧抖动用 5.1 的中位滤波,或升级为按检测框 IoU 的轨迹匹配

一次只改一个参数。最常见的问题是同时调低了置信度、又加了增强、又换了优化器,出了 bug 也不知道是谁的问题。收敛不理想时先把增强降到最低验证模型能拟合训练集,再逐项加回来。

硬样本挖掘后训练时,把 cosine 周期换成阶梯下降、每 5 个 epoch 学习率减半,在难例二次微调里比 cosine 更容易稳定,因为难例集太小,平滑退火的收益有限。盯着 val loss 找到学习率下降点,通常在跃升前两个 epoch 出现,截断训练能省下不少调参时间。训练时盯着 val loss 而不是 train loss,收敛不明显就把 lr 除以 5 再跑 15 轮,这个策略在任何小数据集微调里都通用。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 1:32:40

飞鼠格式实测:本地离线转换工具的能力边界与GPL-3.0许可证解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 1:32:24

AI辅助硬件设计全流程实战:从原理图到量产落地的经验总结

做硬件设计这行&#xff0c;很多人对AI辅助这件事的态度经历了从“看不上”到“真香”的转变。我算走得比较早的——去年下半年开始&#xff0c;我把自己一个量产项目的完整流程全部尝试用AI工具过了一遍&#xff1a;从最初的需求拆解、原理图框架搭建&#xff0c;到PCB布局布线…

作者头像 李华
网站建设 2026/9/14 1:32:18

混合粒子群算法求解TSP的Matlab实现与参数调优

简介&#xff1a;Matlab混合粒子群算法&#xff08;HPSO&#xff09;求解TSP的完整代码实例&#xff0c;面向智能优化算法初学者、Matlab开发者、运筹优化课程设计等场景。算法在标准粒子群基础上引入遗传操作或局部搜索&#xff0c;以更有效地逼近旅行商问题的最短路径&#x…

作者头像 李华
网站建设 2026/9/14 1:31:49

从NLP到LLM:全栈技术演进与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 1:31:42

植物大战僵尸为何悄然退场?数字文化消隐的三阶段观察

1. 这不是游戏下架&#xff0c;而是一场文化层面上的“植物退场”事件 “当植物大战僵尸从世界消失后”——这句话乍看像一句游戏圈的玩笑话&#xff0c;或是某部同人小说的开篇设定&#xff0c;但如果你最近打开过主流应用商店、翻过几条游戏社区动态、甚至留意过身边青少年的…

作者头像 李华
网站建设 2026/9/14 1:31:35

Android Camera预览优化:SurfaceTexture缓冲区与性能调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华