资讯详情 OpenCV DNN年龄性别预测:人脸检测到实时客流统计
📅 2026/10/27 21:49:24
简介这份资源面向希望用 OpenCV 快速实现人脸属性分析的开发者聚焦通过 DNN 模块部署卷积网络从图像或摄像头画面中检测人脸并预测年龄区间与性别适合入门实践、课程演示和轻量级应用搭建。包内提供年龄、性别两类模型权重与网络配置附带级联人脸检测器、C 示例源码和工程文件可直接编译运行并含有测试图片便于对照复现实验。压缩包共 53 个文件以模型权重、网络配置、级联检测器、源码、工程文件及调试中间产物为主整体约 106.68MB。已有 338 人学习读者可据此理清人脸检测、模型加载、输入预处理、前向推理和结果展示的完整链路也能观察年龄区间与性别概率的输出形式并在此基础上替换模型、调整参数或迁移到其他视觉任务。1. 门店客流看板的那个下午OpenCV 年龄和性别预测能覆盖什么范围去年帮朋友做一个线下门店的客流看板需求朴素得不行不用认人屏幕上能滚动显示“进来的是男是女、大概多大”就够。试了几种方案最后落地的是 OpenCV 年龄和性别预测这条链路——DNN 模块加载三个现成的 Caffe 模型人脸检测先出框框里抠出来的脸送进两个分类头一个出年龄段一个出性别。普通笔记本 CPU 就能跑不用装深度学习框架也不用显卡。这份资源把整条流水线拆得比较完整摄像头、单张图片、视频文件三种输入都能喂输出是带框带标签的画面。适合手上有摄像头想做实时统计的人也适合刚啃完 OpenCV 基础 API、想找个完整 demo 练手的人。丑话说前面它不是人脸识别系统别指望它认清“这是谁”。2. 技术选型与目录结构三个模型怎么串成一条推理流水线这套东西的骨架其实很轻全部重量都在三个模型文件上。人脸检测一个 SSD性别分类一个 Caffe 分类网年龄分类再一个 Caffe 分类网。它们之间没有共享层也没有端到端的联合训练就是最朴素的串行调用检测器给框裁剪器按框抠图两个分类头各自跑一遍前向。理解这一点后面所有代码都好读了——它更像三条独立的推理被一段业务代码粘起来。2.1 为什么用 OpenCV DNN 而不是自己训一个网络最直接的理由是部署成本。自己训练一个多任务网络哪怕只是微调也要面对框架依赖、权重格式转换、推理后端适配这一串问题最后交付到别人机器上还得打包一堆运行库。OpenCV 的dnn模块提供readNetFromCaffe、readNetFromONNX这些入口把训练好的模型直接读进来前向运行期只依赖opencv-python一个包这对“装个 Python 就能跑”的场景太友好了。代价也要讲明白。现成模型的精度是有天花板的年龄段输出的是 8 个离散区间而不是连续年龄遇到侧脸、遮挡、强逆光结果会明显漂移。我做门店那个项目时就发现戴帽子的顾客年龄普遍被预测偏大一档这是训练数据分布决定的调参救不回来。所以这套东西适合做“分布统计”而不是“个体判断”——统计一百个人的性别比例大致靠谱但盯着某一个人看翻车概率不低。另一层考虑是硬件。dnn默认走 CPU也支持切到 CUDA 或 OpenVINO 后端。一块常见的边缘盒子跑 640×480 的画面检测加两个分类能到十几帧做人流统计完全够。真要上高分辨率多路视频再考虑setPreferableBackend换后端这个后面第 6 章会展开。2.2 目录结构把检测、裁剪、推理、绘制拆成四件事这类 demo 最常见的写法是把所有逻辑塞进一个main.py两百行流水账改起来很痛苦。这份资源的划分方式我比较认可按职责拆成四块每块单独能测路径职责关键内容models/模型文件存放人脸检测deploy.prototxtres10_300x300_ssd_iter_140000.caffemodel性别deploy_gender.prototxtgender_net.caffemodel年龄deploy_age.prototxtage_net.caffemodelsrc/detector.py只负责人脸检测封装FaceDetector输入一帧输出若干归一化转像素坐标的框src/predictor.py只负责属性推理封装AgeGenderPredictor输入一张裁剪好的人脸图输出性别、年龄段及各自置信度src/pipeline.py粘合层按框图裁剪、加边距、结果平滑、多目标追踪的占位逻辑src/visualize.py绘制画框、写标签、叠 FPS中文字体兜底configs/default.yaml参数外置置信度阈值、padding 比例、平滑窗口大小、跳帧间隔app.py入口解析命令行参数按图片/视频/摄像头三种模式分流这种拆法的好处是调试时能逐段验证。人脸框不准只改detector.py的阈值年龄乱跳只动pipeline.py的平滑窗口互不干扰。我一般还会在src/下额外放一个bench.py用一段固定视频跑循环计时改完参数先看这个数比肉眼盯着画面猜快得多。2.3 依赖与最小可跑环境依赖少是这套方案最大的优点requirements.txt里基本就三行opencv-python4.5 numpy pyyamlopencv-python是带 DNN 模块的完整包别装成opencv-python-headless后者在部分平台上裁掉了一些 GUI 相关的绑定imshow会直接报错。如果你想在服务端跑批处理不弹窗那反而应该用 headless 版本。这台机器上要跑起来Python 3.7 以上、内存 2GB 以上就够三个模型加起来体积不大加载时占几百兆内存。摄像头模式需要一个能被系统识别的视频设备Linux 下通常是/dev/video0Windows 下是设备索引 0。如果是在容器里跑记得把设备挂进去否则VideoCapture(0)会静默返回空帧——这个坑后面第 5 章单独说。3. 核心链路拆解从一帧原图到“Male (25-32)”的完整代码这一章是整份资源最有价值的部分。三段代码分别对应检测、裁剪推理、结果聚合每段都能独立跑也能拼起来。写的时候我尽量保留原实现的字段命名方便你对照着改。3.1 人脸检测SSD 出框与归一化坐标还原检测器用的是单阶段 SSD输入固定 300×300输出是一串[batch, 1, N, 7]的张量最后一维的 7 个数依次是图像 ID、类别 ID、置信度、x1、y1、x2、y2后四个是相对原图的归一化坐标。很多人第一次跑会画出一堆乱七八糟的框多半就是忘了把归一化坐标乘回原图宽高。import cv2 class FaceDetector: SSD 人脸检测器只出框不做识别 def __init__(self, prototxt_path, model_path, conf_threshold0.6): # readNetFromCaffe 读的是 Caffe 的 deploy 结构和权重 self.net cv2.dnn.readNetFromCaffe(prototxt_path, model_path) self.conf_threshold conf_threshold self.input_size (300, 300) def detect(self, frame): h, w frame.shape[:2] # blobFromImage 自己会做 resize不需要提前 cv2.resize 一遍 blob cv2.dnn.blobFromImage( frame, scalefactor1.0, sizeself.input_size, mean(104.0, 177.0, 123.0), # SSD 训练时的 BGR 均值写死不要改 swapRBFalse, cropFalse, ) self.net.setInput(blob) detections self.net.forward() boxes [] for i in range(detections.shape[2]): confidence float(detections[0, 0, i, 2]) if confidence self.conf_threshold: 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(( max(0, min(x1, w - 1)), max(0, min(y1, h - 1)), max(0, min(x2, w - 1)), max(0, min(y2, h - 1)), confidence, )) return boxes几个参数值得单独说。conf_threshold默认给 0.6是个偏保守的值调低到 0.4远处的小脸能检出来但误检会明显变多经常把衣领、手掌框成脸调到 0.8 以上画面里人一多就会漏。门店那种半身入镜的场景0.5 到 0.6 之间最稳。mean(104.0, 177.0, 123.0)是 SSD 作者训练时用的通道均值这三数字不是随便填的换成 ImageNet 的均值置信度会整体往下掉一截很玄学但确实这样。还有一个细节blobFromImage内部已经包含 resize外面再套一层cv2.resize是多余的不但慢还可能因为插值方式不同导致框位置有细微偏差。我在早期版本里就犯过这个错框总是比脸大一圈查了半天才发现是双重缩放。3.2 预处理227×227 与那组不能改的均值抠出来的脸不能直接送进分类网。两个 Caffe 模型的输入是 227×227 的 BGR 图减均值、不做通道交换、不做中心裁剪这三件事必须和训练时完全一致。性别和年龄这两个模型的均值是另一组数和检测器那组不一样混用是新手最容易踩的坑。import numpy as np AGE_BUCKETS [(0-2), (4-6), (8-12), (15-20), (25-32), (38-43), (48-53), (60-100)] GENDER_LIST [Male, Female] # 分类模型专用均值和检测器的 (104,177,123) 不是一回事 MODEL_MEAN (78.4263377603, 87.7689143744, 114.895847746) class AgeGenderPredictor: def __init__(self, age_prototxt, age_model, gender_prototxt, gender_model): self.age_net cv2.dnn.readNetFromCaffe(age_prototxt, age_model) self.gender_net cv2.dnn.readNetFromCaffe(gender_prototxt, gender_model) def predict(self, face_roi): if face_roi is None or face_roi.size 0: return None blob cv2.dnn.blobFromImage( face_roi, scalefactor1.0, size(227, 227), meanMODEL_MEAN, swapRBFalse, # 模型按 BGR 训练千万别设成 True cropFalse, # 直接拉伸不做中心裁剪 ) # 性别2 类取 argmax self.gender_net.setInput(blob) gender_preds self.gender_net.forward()[0] gender_idx int(np.argmax(gender_preds)) # 年龄8 个区间同样是 argmax self.age_net.setInput(blob) age_preds self.age_net.forward()[0] age_idx int(np.argmax(age_preds)) return { gender: GENDER_LIST[gender_idx], gender_conf: float(gender_preds[gender_idx]), age_range: AGE_BUCKETS[age_idx], age_conf: float(age_preds[age_idx]), }AGE_BUCKETS的顺序绝对不能动它对应模型输出层 0 到 7 号神经元改一个顺序整片结果全错。这个顺序来自训练集的年龄分组跨度不均匀是正常的0-2 和 4-6 这种小孩子区间切得细因为儿童面部特征变化快60-100 直接糊成一大段因为老年人特征区分度低模型本身也分不出来。swapRBFalse这行我见过太多次被改成True。改了之后代码不报错跑得也欢就是结果离谱——男性大面积被判成女性。因为 OpenCV 读进来是 BGR模型训练时也是 BGR中间转一次 RGB 等于把红蓝通道对调了模型看到的是“蓝色的人脸”。3.3 裁剪加边距框太紧会让分类精度掉一档检测器给出的框是贴着五官的直接拿去裁剪额头和下巴会被切掉一部分。分类网训练时看到的样本通常带一点背景框太紧反而是分布外输入。实际做法是按框的宽高各外扩一定比例再夹到图像边界内。def crop_with_padding(frame, box, pad_ratio0.15): 按检测框裁剪人脸四周外扩 pad_ratio返回裁剪图和修正后的框 h, w frame.shape[:2] x1, y1, x2, y2, conf box bw, bh x2 - x1, y2 - y1 px, py int(bw * pad_ratio), int(bh * pad_ratio) nx1, ny1 max(0, x1 - px), max(0, y1 - py) nx2, ny2 min(w, x2 px), min(h, y2 py) if nx2 - nx1 20 or ny2 - ny1 20: return None, None # 太小的脸直接放弃硬推也是垃圾结果 return frame[ny1:ny2, nx1:nx2], (nx1, ny1, nx2, ny2)pad_ratio给 0.15 是我在几个场景里试出来的折中值。给 0 的话框紧贴脸颊年龄预测经常偏小给到 0.4背景占比太高模型会开始受环境影响白墙前面的人容易全被判成同一档。0.1 到 0.2 之间差异不大选一个固定住就行别每帧动态变那样平滑逻辑会失效。那个尺寸下限也重要。视频里偶尔会检出十几个像素的小脸这种图送进 227×227 的网络等于把噪点放大输出基本是随机的还会把整帧的统计带偏。直接丢掉比硬推划算。3.4 结果平滑与绘制让年龄别每帧乱跳如果只用单帧 argmax你会看到一个人的年龄标签在两三档之间疯狂横跳。这不是模型坏了是相邻类别的概率本来就很接近。工程上的解法很土但有效给每个目标维护一个滑动窗口取窗口内出现次数最多的类别。from collections import deque, Counter class ResultSmoother: 按目标 ID 维护滑动窗口取众数压住标签抖动 def __init__(self, window12): self.window window self.buffers {} def update(self, track_id, result): buf self.buffers.setdefault(track_id, deque(maxlenself.window)) buf.append((result[gender], result[age_range])) gender Counter(g for g, _ in buf).most_common(1)[0][0] age Counter(a for _, a in buf).most_common(1)[0][0] return {gender: gender, age_range: age, stable: len(buf) self.window // 2}window12意味着大约最近 12 次推理参与投票。太小压不住太大则响应迟钝一个人走出画面了标签还挂着。stable这个字段是给自己看的窗口没攒够一半数据之前标签在 UI 上可以浅色显示避免刚出现就给出一个不可靠的结论。track_id从哪来是个问题。完整方案要接一个多目标跟踪器但这份资源里给了个轻量替代用上一帧的中心点距离做最近邻匹配实现二十来行够用。如果场景里人不多于三五个这个土办法的准确率完全能接受。绘制部分没什么好讲的cv2.rectangle加cv2.putText就完事。唯一要提醒的是中文标签。OpenCV 内置字体不支持中文直接写会变成一堆问号。常见做法是用PIL的ImageFont.truetype加载一个中文字体文件把 BGR 转成 RGB 交给 PIL 画完再转回来或者干脆全用英文标签省事。我一般选后者Male (25-32) 0.87这种格式业务方也完全能读。4. 跑起来三种输入模式的启动方式与结果解读代码看完了接下来是让它动起来。这一章按“准备模型 → 选输入模式 → 读输出”的顺序走每一步都给可复制的命令。4.1 模型文件摆放与加载检查模型是这套方案唯一的硬依赖缺一个都跑不起来。三个 prototxt 定义网络结构三个 caffemodel 存权重必须成对存在版本也要匹配——拿 A 版本的 prototxt 配 B 版本的权重readNetFromCaffe在加载阶段不一定报错但forward()时会抛出维度不匹配的异常错误信息还相当晦涩。我一般会在入口加一段启动自检import os MODEL_FILES [ models/deploy.prototxt, models/res10_300x300_ssd_iter_140000.caffemodel, models/deploy_gender.prototxt, models/gender_net.caffemodel, models/deploy_age.prototxt, models/age_net.caffemodel, ] def check_models(base_dir.): missing [p for p in MODEL_FILES if not os.path.isfile(os.path.join(base_dir, p))] if missing: raise FileNotFoundError(缺少模型文件\n \n.join(missing))这个检查看起来啰嗦但省下来的调试时间很值。模型文件下载不全的情况太常见了尤其是 caffemodel动辄几十兆网络一抖就是个残缺文件加载时才报“Unsupported Lua type”之类的怪错。路径尽量用相对项目根目录的写法别写绝对路径。我见过把/home/xxx/Downloads/...写死在代码里的版本换台机器直接全线崩溃属于典型的血泪教训。4.2 图片、视频、摄像头三种模式的参数设计入口脚本用argparse分三种模式靠--source一个参数区分传数字当摄像头索引传文件路径当图片或视频。判断逻辑是看扩展名图片走单帧流程其余走循环读帧。import argparse import cv2 def build_parser(): p argparse.ArgumentParser() p.add_argument(--source, default0, help0 表示默认摄像头也可传图片或视频文件路径) p.add_argument(--conf, typefloat, default0.6, help人脸检测置信度阈值) p.add_argument(--pad, typefloat, default0.15, help人脸框外扩比例) p.add_argument(--skip, typeint, default1, help每 N 帧推理一次1 表示不跳帧) p.add_argument(--save, default, help输出视频保存路径留空则不保存) return p def open_source(source): # 纯数字当成摄像头索引否则当成文件 if source.isdigit(): cap cv2.VideoCapture(int(source)) else: cap cv2.VideoCapture(source) if not cap.isOpened(): raise RuntimeError(f无法打开输入源{source}) return cap参数含义逐个说清。--conf就是 3.1 里那个检测阈值画面里人多或者光线差的时候往下调一点。--pad对应 3.3 的裁剪外扩一般不用动。--skip是个性能杠杆设成 3 表示每三帧才跑一次完整推理中间帧沿用上一次的框和标签帧率能翻两三倍代价是快速移动时框会滞后。做人流统计设 3 到 5 完全够用做实时交互就得设 1。摄像头模式记得设分辨率。默认的 640×480 在有些设备上会给出 1920×1080 的画面检测器内部虽然会缩到 300×300但每帧的blobFromImage和绘制开销都会上去白白浪费算力cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)4.3 输出怎么读年龄段是区间统计性别看的是置信度跑起来之后画面上的标签长这样Male (25-32) 0.87。后面那个数字是性别分类的 softmax 概率不是年龄的。年龄的置信度我没往画面上放因为它常年偏低且不具备解释性放上去只会让人误以为“0.3 就是不准”。年龄段要按区间理解(25-32)不是“这个人 28 岁”而是“模型认为他落在 25 到 32 这一段”。实际用的时候我一般把 8 档再归并成 4 组未成年、青年、中年、老年统计粒度粗一点但稳定性高得多。门店客流看板就是按这四组出图的业务方看着也直观。性别的置信度低于 0.7 时建议在 UI 上标注为“不确定”而不是硬给一个结论。侧脸、戴口罩、逆光这几类画面里置信度普遍掉到 0.6 以下这时候的 argmax 基本是抛硬币。加一个阈值兜底整体观感会好很多。如果开了--save输出视频的帧率和输入保持一致但要注意跳帧模式写出去的是“每帧都画框、部分帧才推理”的结果视觉上会有轻微滞后别以为是编码问题。5. 避坑与排查跑不通、框不准、结果乱跳的现场处理这一章记录的是实际调试里反复出现的几类问题按启动阶段、运行阶段、结果层三个层面分开。每条都按“现象 → 原因 → 解决”写遇到对得上的直接照着改。5.1 启动阶段模型加载失败与设备打不开现象一cv2.error: Unsupported Lua type或者Cannot determine the file type。原因是 prototxt 和 caffemodel 版本不匹配或者下载下来的 caffemodel 是个残缺文件。解决方式是先比对各文件的实际大小几个 caffemodel 加起来的体积和模型来源页面标注的数量级应该对得上明显偏小就是没下完。也可以直接用cv2.dnn.readNetFromCaffe单独加载某一对文件做隔离测试逐个排除。现象二RuntimeError: 无法打开输入源0。在容器或远程环境里跑摄像头时最常见。容器默认拿不到宿主机的视频设备VideoCapture(0)会静默失败。解决方式是启动容器时把设备映射进去命令行里加--device/dev/video0然后确认容器内用ls /dev/video*能看到设备节点。另一个可能是索引不对笔记本上外接 USB 摄像头时索引常常从 1 开始用--source 1试一下。现象三AttributeError: module cv2 has no attribute dnn。装成了精简版或者版本太老的 opencv 包。解决方式是卸掉重装opencv-python别装opencv-contrib-python-headless之外的那些变体。装完python -c import cv2; print(hasattr(cv2, dnn))打一下True才算对。5.2 运行阶段卡顿、坐标错位与内存增长现象四帧率只有个位数风扇狂转。不一定是模型太重。先确认是不是每帧都在重复调用readNetFromCaffe——把网络加载写在处理循环里的代码我见过不止一次那等于每帧都从磁盘读模型。解决办法是把FaceDetector和AgeGenderPredictor的实例化提到循环外面只做一次。其次确认画面分辨率1080p 的输入比 480p 慢三倍以上而检测器最终还是要缩到 300×300中间那步纯属浪费。现象五框画偏了整体往左上或右下平移。坐标系混了。检测器输出的框是相对原图归一化的但如果在推理前把帧缩放过框就得按缩放后的尺寸还原而裁剪取的是原图。解决方式是只保留一处缩放检测器内部由blobFromImage负责外层代码始终面对原图这样框的坐标和裁剪的坐标天然一致。这个坑我在第 3.1 节提过值得再强调一遍。现象六跑十来分钟内存涨到几个 G。滑动窗口的字典只增不减。ResultSmoother.buffers里每个track_id都持有一个固定长度的deque人走后不清理条目越攒越多。解决方式是加一个简易的过期机制记录每个 ID 最后一次出现的时间每隔一段时间扫一遍超过三十秒没更新的直接删掉。5.3 结果层年龄横跳与性别判反现象七年龄标签在两三档之间来回跳。单帧 argmax 的正常表现相邻类别概率接近时必然抖动。解决方式就是 3.4 节的滑动窗口取众数窗口给 10 到 15。如果加了平滑还跳检查一下track_id是不是每帧都在变ID 一换窗口就重置了等于没平滑。现象八明显是男性被判成 Female而且置信度很高。通道顺序搞反了。检查blobFromImage里是不是写了swapRBTrue改成False。另一可能是均值写错把检测器那组(104.0, 177.0, 123.0)用到了分类模型上这会让输入分布严重偏离训练分布输出的置信度还特别“自信”属于典型的黑匣子式错误。现象九一张图里三个人只有两个出标签。三种可能。一是--conf阈值太高把侧脸漏掉了往下调到 0.45 试试二是小脸被 3.3 节那个尺寸下限过滤掉了这个是有意为之不建议为了多检出而放开三是检测框之间有重叠可视化时后画的框盖住了前面的标签改一下标签的纵向偏移量就行。6. 二次开发从能跑的 demo 到勉强能交付的工具把这个 demo 改造成真正能用的东西我走过两条路线一条是性能路线一条是精度路线方向完全相反先想清楚要哪头。性能这块最见效的是跳帧加跟踪。--skip 3配合中心点最近邻匹配帧率能从 8 帧推到 20 帧以上而统计结果的偏差几乎看不出来因为统计本来就是按分钟聚合的。再进一步是把dnn后端切到 OpenVINOnet.setPreferableBackend(cv2.dnn.DNN_BACKEND_INFERENCE_ENGINE)加net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)在 Intel 平台上通常还有明显提升。不过这条路径绑定硬件换到别的芯片上就得重来做产品前先确认部署环境。精度这块有两条我试过有效的改动。一是把 SSD 检测器换成一个更稳的人脸检测方案检测质量直接决定后面两个分类的上限——框歪了再好的分类头也救不回来。二是把 8 档年龄归并成 4 组之后再输出同时在 UI 上只展示性别比例和年龄分布饼图不展示个体判断。这种“把不确定性藏起来”的做法听起来像在回避问题但对统计类场景来说结论的可信度反而更高。还有一个容易被忽略的改造点是配置外置。configs/default.yaml里那几个参数——置信度、padding、跳帧、平滑窗口——不同场景的最优值差别很大。门店入口光比较亮阈值可以给 0.65地下车库光线差0.45 才检得出来。把这些做成配置文件现场调试时改文件重启就行不用重新打包。detector: conf_threshold: 0.6 input_size: [300, 300] crop: pad_ratio: 0.15 min_face_px: 20 predict: smooth_window: 12 skip_frames: 3 output: show_fps: true label_lang: en最后给个验证方法。改完参数别只看一帧画面用一段固定的视频反复跑输出一个统计文本总帧数、平均推理耗时、检出人脸的帧占比、性别比例。这四组数字对比着看比肉眼判断靠谱得多。我在门店项目里就是靠这个表格发现把阈值从 0.6 降到 0.5 之后检出率涨了 18%但性别比例从 61:39 变成 55:45明显是误检把人脸框到了背景上最后又调回去了。这类模型驱动的项目参数之间全是耦合的改一个地方经常会牵动另一头。从那以后我每次调完参都强制走一遍固定素材的回归对比宁可多花二十分钟也不想在上线当天才发现比例全歪了。这套源码和模型放在一起就能跑拿去对着自己的场景慢慢磨比从零搭要省太多事。希望帮到你。本文还有配套的精品资源点击获取