环境:Python 3.8+、OpenCV、dlib、scikit-learn、Pillow
一个调用预训练模型完成推理,一个基于几何公式判断,两种思路对照学习
本文整理两个基于摄像头的 OpenCV 实战程序:年龄性别识别与疲劳检测。前者实时识别画面中人物的年龄区间与性别,可用于身份属性分析;后者通过眼睛开合程度判断受检者是否疲劳,可用于驾驶员监控、课堂走神提醒等场景。
一、准备工作
1.1 模型文件
两个程序共需 7 个模型文件:
| 程序 | 文件 | 说明 |
|---|---|---|
| 年龄性别 | opencv_face_detector.pbtxt + opencv_face_detector_uint8.pb | 人脸检测模型(结构 + 权重) |
| 年龄性别 | deploy_age.prototxt + age_net.caffemodel | 年龄分类模型 |
| 年龄性别 | deploy_gender.prototxt + gender_net.caffemodel | 性别分类模型 |
| 疲劳检测 | shape_predictor_68_face_landmarks.dat | dlib 68 点关键点模型 |
获取途径:人脸检测两个文件可在 OpenCV 官方 GitHub 的 samples/dnn 示例中找到;年龄性别四个文件出自 Levi 与 Hassner 的论文发布,搜索文件名即可获取;68 点模型在 dlib 官网的模型页面下载,全部文件合计几十 MB。
1.2摄像头
笔记本内置摄像头或任意 USB 摄像头均可,代码通过VideoCapture(0)打开默认设备。虚拟机环境下需先将摄像头设备挂载至虚拟机。
二、年龄性别识别
2.1 程序原理
整个流程分为四步,代码即按此流水线组织:
摄像头取帧 │ ▼ ① faceNet 人脸检测(SSD 模型,输入 300×300) │ 得到人脸框坐标 [x1, y1, x2, y2] ▼ ② 按坐标从原图裁出人脸区域 │ ▼ ③ 同一张人脸小图分别输入两个分类模型: ├── genderNet → 2 个概率 → argmax → '男性' / '女性' └── ageNet → 8 个概率 → argmax → '25-32岁' 等区间 │ ▼ ④ 绘制中文结果并显示阅读代码前需要明确三个问题。
其一,为何使用三个模型而非一个。"找到人脸位置"与"判断人脸属性"是两类任务,分工后每个模型更小也更准确。这种"先定位、再识别"的结构在视觉系统中十分常见。
其二,年龄为何按区间分类而非直接预测数值。衰老过程非线性,同龄人外貌差异大,直接回归年龄数值误差难以控制。将年龄划分为 8 个区间转化为分类问题后,模型容错率显著提高。
其三,分类结果如何产生。模型 forward 之后输出概率数组,例如性别输出[0.92, 0.08],argmax()返回最大值所在下标 0,再以该下标查询genderList得到"男性"。"概率 → 下标 → 查表"是所有分类模型输出结果的标准流程。
2.2 代码分段讲解
(1)导入与模型加载
importcv2fromPILimportImage,ImageDraw,ImageFont# pip install pillowimportnumpyasnp# ====== 模型初始化 ======faceProto="model/opencv_face_detector.pbtxt"faceModel="model/opencv_face_detector_uint8.pb"ageProto="model/deploy_age.prototxt"ageModel="model/age_net.caffemodel"genderProto="model/deploy_gender.prototxt"genderModel="model/gender_net.caffemodel"# 加载网络ageNet=cv2.dnn.readNet(ageModel,ageProto)# 年龄genderNet=cv2.dnn.readNet(genderModel,genderProto)# 性别faceNet=cv2.dnn.readNet(faceModel,faceProto)# 人脸每个模型由两个文件构成:权重文件(.pb、.caffemodel,存储训练得到的参数)与结构描述文件(.pbtxt、.prototxt,描述网络层数与连接方式)。readNet(权重, 结构)将二者组装为可推理的网络对象。
三个模型的来源框架并不相同:人脸检测模型由 TensorFlow 训练,年龄性别模型由 Caffe 训练,readNet均可自动识别。这也是 dnn 模块的便利之处——不依赖模型的原训练框架,加载即可推理。年龄性别模型出自论文《Age and Gender Classification using Convolutional Neural Networks》,后文主循环中 227×227 的输入尺寸即由该论文确定。
(2)类别表与均值
ageList=['0-2岁','4-6岁','8-12岁','15-20岁','25-32岁','38-43岁','48-53岁','60-100岁']genderList=['男性','女性']mean=(78.4263377603,87.7689143744,114.895847746)# 训练集均值两张列表负责将模型输出的下标翻译为可读文字。注意列表顺序必须与模型训练时的类别顺序完全一致,顺序错误会导致结果整体错位。
mean是训练集三个颜色通道的均值是OpenCV 官方 Caffe 年龄 / 性别预训练模型(age_net.caffemodel/gender_net.caffemodel)专用的图像通道均值,不可随意修改。
(3)getBoxes:人脸检测的实现
defgetBoxes(net,frame):frameHeight,frameWidth=frame.shape[:2]# 获取高度、宽度blob=cv2.dnn.blobFromImage(frame,scalefactor=1.0,size=(300,300),mean=[104,117,123],swapRB=True,crop=False)net.setInput(blob)# 输入图片进行人脸检测detections=net.forward()# 四维:批次、第一个表格、行数、列数faceBoxes=[]# 存储检测到的人脸框foriinrange(detections.shape[2]):# 每行 7 个数据:第 3 个为置信度,第 4~7 个为归一化坐标confidence=detections[0,0,i,2]ifconfidence>0.7:# 只保留置信度大于 0.7 的x1=int(detections[0,0,i,3]*frameWidth)y1=int(detections[0,0,i,4]*frameHeight)x2=int(detections[0,0,i,5]*frameWidth)y2=int(detections[0,0,i,6]*frameHeight)faceBoxes.append([x1,y1,x2,y2])cv2.rectangle(frame,pt1=(x1,y1),pt2=(x2,y2),color=(0,255,0),thickness=int(round(frameHeight/150)),lineType=6)returnframe,faceBoxes本函数是理解 dnn 推理过程的核心,分步说明如下。
首先是blobFromImage。神经网络无法直接接收 OpenCV 读出的图像数组,必须先转换为规定的四维输入,该行代码即完成此项预处理:
| 参数 | 作用 |
|---|---|
size=(300, 300) | 将任意尺寸画面缩放至 300×300,SSD 人脸检测模型的固定输入 |
mean=[104,117,123] | 各通道减去检测模型自身的训练均值(与 ageNet 的 mean 不是同一组数) |
scalefactor=1.0 | 减均值后再缩放的系数,此处不做额外缩放 |
swapRB=True | OpenCV 通道序为 BGR,模型训练使用 RGB,需交换 |
crop=False | 直接拉伸至目标尺寸,不裁剪 |
生成的 blob 形状为(1, 3, 300, 300),即 1 张图、3 个通道、300×300。
随后setInput(blob)输入数据,forward()执行一次前向计算并返回检测结果,形状为[1, 1, N, 7]:批次为 1,后一维固定为 1,共 N 个候选框,每个候选框由 7 个数字描述。这 7 个数字的含义是理解本段的关键:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| 含义 | 图片id | 类别id | 置信度 | x1 | y1 | x2 | y2 |
因此detections[0, 0, i, 2]即第 i 个候选框的置信度,下标 3 至 6 为框坐标。此处有两个细节需要注意。
一是坐标为 0 至 1 的归一化值(相对图片宽高的比例),必须乘回frameWidth、frameHeight才能得到像素坐标。这种设计与分辨率无关,同一套输出可适配任意尺寸的图像。
二是detections.shape[2]即候选框个数 N,遍历因此写作range(detections.shape[2])。初学时若对下标含义不确定,可在循环内将detections[0, 0, i]整行打印,与画面中的实际人脸框对照验证。
最后是过滤与绘制。模型会输出大量候选,其中包含误检,confidence > 0.7即淘汰线:调高则框更可靠但易漏检,调低则框增多且误检上升。绘制框的粗细取画面高度 ÷ 150,分辨率越高框越粗,窗口缩放后视觉比例始终合适。
(4)cv2AddChineseText:借助 PIL 绘制中文
defcv2AddChineseText(img,text,position,textColor=(0,255,0),textSize=30):"""借助 PIL 在图像上绘制中文"""ifisinstance(img,np.ndarray):# 判断是否为 OpenCV 图像img=Image.fromarray(cv2.cvtColor(img,cv2.COLOR_BGR2RGB))draw=ImageDraw.Draw(img)# 创建绘图对象fontStyle=ImageFont.truetype("simhei.ttf",textSize,encoding="utf-8")draw.text(position,text,textColor,font=fontStyle)# 绘制文本returncv2.cvtColor(np.asarray(img),cv2.COLOR_RGB2BGR)# 转回 OpenCV 格式cv2.putText不支持中文字符,直接绘制"男性"会显示为问号。该函数的处理方式是格式转换:先将 OpenCV 的 BGR 数组转为 PIL 图像,用 PIL 接口加载黑体字体完成中文绘制,再转回 OpenCV 格式返回。真正执行绘制的只有draw.text一行,前后各一次格式转换。
(5)主循环
cap=cv2.VideoCapture(0)# 装载摄像头whileTrue:_,frame=cap.read()frame=cv2.flip(frame,flipCode=1)# 镜像处理# 获取人脸包围框、绘制人脸包围框(可能多个)frame,faceBoxes=getBoxes(faceNet,frame)ifnotfaceBoxes:# 没有人脸时检测下一帧,后续循环操作不再继续print("当前镜头中没有人")continue# 遍历每一个人脸包围框forfaceBoxinfaceBoxes:# 裁出人脸,处理为符合 DNN 输入的格式x1,y1,x2,y2=faceBox face=frame[y1:y2,x1:x2]blob=cv2.dnn.blobFromImage(face,scalefactor=1.0,size=(227,227),mean)# 调用模型,预测性别genderNet.setInput(blob)genderOuts=genderNet.forward()gender=genderList[genderOuts[0].argmax()]# 调用模型,预测年龄ageNet.setInput(blob)ageOuts=ageNet.forward()age=ageList[ageOuts[0].argmax()]result="{} {}".format(gender,age)# 格式化文本(性别、年龄)frame=cv2AddChineseText(frame,result,position=(x1,y1-30))cv2.imshow(winname="result",frame)ifcv2.waitKey(1)==27:# 按下 Esc 键,退出程序breakcv2.destroyAllWindows()cap.release()cap.read()每次读取一帧,返回值中的下划线为读取成功标志,此处不使用,仅保留画面。cv2.flip(frame, flipCode=1)执行水平镜像:不镜像时人物移动方向与画面相反,镜像后与照镜子的直觉一致。
if not faceBoxes: continue:空列表在 Python 中为假值,未检测到人脸时跳过本帧剩余处理,直接读取下一帧。副作用是此时imshow不会执行,窗口画面停止刷新,属于写法上的取舍而非缺陷。
裁取人脸的frame[y1:y2, x1:x2]有一个易错点:NumPy 图像切片先行(y)后列(x),若写成frame[x1:x2, y1:y2]相当于裁取转置图像,后续预测结果将完全混乱。调试时可先打开注释中的cv2.imshow("face", face),确认裁出内容无误后再输入模型。
第二次blobFromImage与第一次不同:尺寸为 227×227(年龄性别网络的论文输入规格,并非常见的 224),均值改用变量mean。原则是每个模型的输入尺寸与均值均以其训练配置为准,模型之间不通用。
性别与年龄的预测代码结构一致:更换网络、forward、argmax、查表。position=(x1, y1 - 30)将文字绘制在人脸框上方 30 像素处,避免遮挡人脸。waitKey(1) == 27表示按下 Esc 键退出;waitKey同时承担刷新窗口的职责,缺少该调用时 imshow 画面不会更新。
运行效果:
三、疲劳检测
3.1 程序原理
疲劳检测采用另一条技术路线:不训练模型判断疲劳状态,而是将其转化为可测量的几何量——眼睛开合程度,即 EAR(Eye Aspect Ratio,眼睛纵横比)。
摄像头取帧 │ ▼ ① dlib 检测人脸位置(HOG 检测器) │ ▼ ② 68 点关键点模型回归出整脸 68 个点 │ 其中 36~41 与 42~47 分别对应两只眼睛 ▼ ③ 每只眼睛计算 EAR = 上下距离均值 ÷ 横向距离 │ ▼ ④ EAR < 0.3 判为闭眼 → 连续帧计数器 +1 │ 计数器 ≥ 50 帧 → 屏幕报警"疲劳" ▼ ⑤ EAR ≥ 0.3 视为睁眼 → 计数器清零EAR 的计算基于每只眼睛的 6 个关键点:
p2 p3 p1 p4 p6 p5EAR=∥p2−p6∥+∥p3−p5∥2×∥p1−p4∥EAR = \frac{\|p2-p6\| + \|p3-p5\|}{2 \times \|p1-p4\|}EAR=2×∥p1−p4∥∥p2−p6∥+∥p3−p5∥
分子为两条纵向线段的平均长度(上下眼睑间距),分母为横向眼角距离。睁眼时 EAR 约在 0.25 至 0.4 之间,闭眼时降至 0.1 左右。采用比值而非绝对像素距离,使检测结果不受人脸与摄像头距离的影响:距离变化时分子分母同步缩放,比值基本不变,单一阈值即可适配各种距离。
至于为何不能一检测到闭眼就报警:正常人眨眼同样产生短暂的闭眼状态,持续仅 100 至 400 毫秒(约数帧)。区分眨眼与疲劳的依据是持续时间,代码以 COUNTER 累计连续闭眼帧数,达到 50 帧(按 30 帧每秒约合 1.7 秒)才判定为疲劳。困倦状态的典型特征是眼睛闭合后长时间不睁开,持续状态才是有效信号。
3.2 代码分段讲解
(1)EAR 计算函数
defeye_aspect_ratio(eye):""" # ------- 计算眼睛纵横比 ------- # 1 2 # 0 3 <------ 眼睛的 6 个关键点 # 5 4 # ----------------------------- """A=euclidean_distances(eye[1].reshape(1,2),eye[5].reshape(1,2))B=euclidean_distances(eye[2].reshape(1,2),eye[4].reshape(1,2))C=euclidean_distances(eye[0].reshape(1,2),eye[3].reshape(1,2))ear=((A+B)/2.0)/C# 纵向距离均值 / 横向距离returnear参数eye为 6 行 2 列数组,每行是一个关键点的 (x, y) 坐标,eye[1]、eye[5]对应示意图中的 p2、p6。三次距离计算分别对应公式中的 A、B、C,(A+B)/2/C即公式本身。
reshape(1, 2)的原因是 sklearn 的euclidean_distances要求输入为二维数组(每行一个样本),单点[x, y]是一维的,需包裹为[[x, y]]。此外该函数返回[[距离]]形式的二维数组而非单个数值,因此主循环中取值须写作ear[0][0]。
(2)绘制眼睛轮廓
defdrawEye(eye):"""绘制眼睛轮廓(凸包)"""eyeHull=cv2.convexHull(eye)cv2.drawContours(frame,[eyeHull],-1,color=(0,255,0),thickness=1)convexHull计算 6 个点的凸包,即包围这些点的最小凸多边形。若按索引顺序直接连线,点的顺序可能杂乱,凸包可保证得到规整的眼睛外轮廓。drawContours负责绘制,参数-1表示绘制列表中的全部轮廓。该函数直接使用全局变量frame而未传参,可以运行,但自行封装时建议以参数传入,避免隐式依赖。
(3)初始化
COUNTER=0# 闭眼持续帧数统计detector=dlib.get_frontal_face_detector()# 人脸位置检测器predictor=dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")# 68 关键点模型cap=cv2.VideoCapture(0)get_frontal_face_detector()是 dlib 内置的 HOG+SVM 正脸检测器,无需下载文件;主循环中调用时传入的第二个参数 0 为图像金字塔上采样次数,0 表示不放大图像——放大会检出更小的人脸,但耗时增加。
shape_predictor加载 68 点模型(即 1.2 节下载的 .dat 文件),输入整幅图像与人脸框,输出 68 个关键点坐标。68 点的标准分布为:脸轮廓 0~16,眉毛 17~26,鼻梁 27~30,鼻底 31~35,两眼分别为 36~41 与 42~47,嘴部 48~67,本程序仅使用眼部的 12 个点。
(4)主循环
whileTrue:ret,frame=cap.read()faces=detector(frame,0)# 获取人脸forfaceinfaces:# 遍历每一张人脸shape=predictor(frame,face)# 获取关键点# 将关键点坐标转换为 (x, y) 的形式shape=np.array([[p.x,p.y]forpinshape.parts()])rightEye=shape[36:42]# 眼睛A:关键点索引从36到41(不包含42)leftEye=shape[42:48]# 眼睛B:从42到47(不包含48)rightEAR=eye_aspect_ratio(rightEye)# 计算一只眼纵横比leftEAR=eye_aspect_ratio(leftEye)# 计算另一只眼纵横比ear=(leftEAR+rightEAR)/2.0# 计算均值ifear<0.3:# 小于 0.3 认为闭眼,可调整阈值COUNTER+=1# 闭眼帧数 +1ifCOUNTER>=50:# 连续 50 帧都闭眼,报警提示frame=cv2AddChineseText(frame,text="!!!疲劳!!!",position=(250,250))else:# ear >= 0.3,视为睁眼,解除报警COUNTER=0# 计数器清零drawEye(leftEye)# 绘制眼睛轮廓drawEye(rightEye)# 绘制眼睛轮廓info="EAR: {:.2f}".format(ear[0][0])frame=cv2AddChineseText(frame,info,position=(0,30))# 左上角显示当前 EAR 值cv2.imshow(winname="Frame",frame)ifcv2.waitKey(1)==27:# 按下 Esc 键,退出程序breakcv2.destroyAllWindows()cap.release()shape.parts()返回 dlib 自带的点对象列表,每个对象携带.x与.y属性,列表推导式将其转换为 NumPy 数组,便于后续切片与距离计算。这是 dlib 结果接入 NumPy 生态的固定写法,多数使用 dlib 的程序中都能见到。
眼部切片使用shape[36:42],Python 切片含头不含尾,恰好取得 36 至 41 共 6 个点。左右眼的命名取决于参照系(镜像后视觉左右对调),命名不影响计算结果,关键是索引区间不可取错。
判定时对双眼 EAR 取平均再与阈值比较,比仅依据单眼更稳定,可抵御侧脸、单眼反光等干扰。阈值 0.3 出自 EAR 原论文(Soukupová 与 Čech,2016)的经验值,但个体眼睛形态存在差异,更可靠的做法是利用代码显示在左上角的实时 EAR 值:分别记录睁眼与闭眼时的典型数值,在两者之间取中间值作为自己的阈值。
计数器逻辑构成一个简单可靠的状态机:闭眼计数、睁眼清零,累计 50 帧后报警。注意cv2.imshow位于for face in faces外部,因此未检测到人脸时窗口仍会刷新原始画面,不会出现上一程序中画面停止刷新的情况。
四、两种思路对照
| 年龄性别识别 | 疲劳检测 | |
|---|---|---|
| 判断依据 | 三个预训练模型推理 | 几何公式与阈值 |
| 模型职责 | 定位 + 分类 | 仅定位(关键点) |
| 结果产生方式 | 概率 → argmax → 查表 | 坐标 → EAR → 阈值比较 |
| 抗干扰手段 | 置信度过滤 | 双眼平均 + 连续 50 帧确认 |
两种路线并无优劣之分。规律复杂、类别繁多的任务适合神经网络;几何关系清晰的规则化判断,公式方案更轻量、快速且易于解释。二者也可组合使用,例如在疲劳检测基础上增加"连续张嘴检测":检测模型定位,再对嘴部关键点套用张合比公式,即可同时运用两种思路。
五、常见问题与对策
| 问题 | 原因与对策 |
|---|---|
| 启动即报错,模型无法加载 | 模型文件缺失或路径不符。对照 1.2 节清点文件;注意相对路径取决于运行命令所在目录 |
| 中文显示为问号 | cv2.putText不支持中文,须改用文中的 PIL 方案,并确认 simhei.ttf 位于脚本目录 |
| 预测结果随机乱跳 | 人脸裁剪切片写反。NumPy 切片先行(y)后列(x),裁取后先 imshow 目检再输入模型 |
| 程序正常但输出完全错误 | blob 尺寸或均值用错。人脸检测为 300×300,年龄性别为 227×227,各以训练配置为准 |
EAR 显示为[[0.34]]或格式化报错 | sklearn 返回二维数组,取值使用ear[0][0] |
| 闭眼阈值 0.3 不适配 | 个体差异所致。依据左上角实时 EAR 值记录睁眼、闭眼典型数值,取中间值(一般在 0.2~0.3) |
| 报警等待时间忽长忽短 | 报警耗时 = 50 ÷ 实际帧率。按"期望秒数 × 实测帧率"设定计数阈值 |
| dlib 安装失败 | 缺少编译环境。优先使用预编译 whl 或 conda 安装,否则补装 CMake 与 Build Tools |
六、总结
两个程序的骨架都不复杂:年龄性别识别是"检测定位 → 裁图 → 分类查表",疲劳检测是"定位 → 计算比值 → 阈值判断 → 连续帧确认"。决定能否举一反三的是中间的细节——blob 如何构造、检测结果四维结构如何解读、EAR 为何采用比值、为何需要帧数确认。掌握这些内容后,可尝试将疲劳检测改造为"张嘴检测"或"点头检测",完成这一改造即说明两种思路均已理解到位。