简介:使用OpenCV与SVM实现人脸识别,是许多开发者入门视觉分类任务时的典型实战项目。资源面向具备一定Python基础、希望将检测与分类算法落地的学习者,内容围绕人脸检测、特征提取、SVM模型训练与图片/视频识别展开,涵盖从数据集整理到实时推理的完整流程。资源包共31个文件,压缩后约47.72MB,主要包含jpg格式的人脸样本图像、py格式的训练与识别脚本、pickle格式的模型序列化结果,以及caffemodel、t7等预训练检测模型和配套PDF说明,便于对照代码逐模块复现。目前已有1454人学习下载,适合作为课程设计或入门参考项目。通过该资源,读者可获得可运行的训练与识别脚本、现成的人脸检测模型、多组标注数据集,以及从静态图片到视频流的识别演示,进而理解SVM在人脸识别任务中的实际调用方法与调参思路。
1. 当人脸识别门禁机已经满大街,为什么还值得自己搭一套 SVM 方案?
人脸识别门禁机已经便宜到几百块一个,很多人觉得再提 OpenCV 做人脸识别是“过时的玩具”。但你自己动手搭过就会明白,这套经典链路并没有过时,反而在很多场景里比端到端深度学习更可控:先用 OpenCV 的 DNN 模块框出人脸,再用 OpenFace 的预训练模型把人脸压缩成 128 维向量,最后由 SVM 分类器决定“这是谁”。整个流程跑在普通笔记本的 CPU 上就能处理视频流,不需要 GPU、不需要训练深度模型,适合毕设、课设、相册自动归类、边缘设备原型这类数据量不大但要离线可用的项目。这份实战包里的检测模型、嵌入模型、训练脚本、数据集和样例视频都齐了,照着顺序跑一遍,人脸识别实战的每个环节都能亲手摸到。
2. 先把链路看清楚:检测、嵌入、分类三关怎么串起来
整套系统拆成三个独立模块,正好对应人脸识别落地的三条必经之路。第一步人脸检测,从画面里把所有脸的位置框出来;第二步人脸嵌入,把每张脸变成一个定长向量;第三步分类,拿向量去比对“这是谁”。三个环节解耦的好处是每一环都能单独替换:检测模型可以换,嵌入模型可以换,SVM 也可以换成 KNN 或逻辑回归,其他环节完全不受影响。先把这个结构记住,后面读脚本的时候就不会迷路。
2.1 数据集文件夹的命名就是标签:dataset 目录约定
打开压缩包里的 dataset 目录,会看到 yangmi、chenglong、Trump、Biden、zhaoliying、mayun 这些子目录。每个子目录放某个人的若干张人脸照片,目录名就是这个人脸的标签,脚本直接读取目录名来生成训练标签。这种“目录即标签”的组织方式是入门级识别项目最省事的方案,不用维护 CSV 标注文件,不用统一图片尺寸,文件名和通道顺序都不需要额外操心。
自己往里加人的时候有两条具体建议。第一,目录名尽量用英文和数字,别用中文,Windows 下中文路径配合 OpenCV 读图偶尔会出乱码,一旦乱码 imread 返回 None,整批样本就静默丢掉了。第二,每个人至少放 8 到 10 张不同角度、不同光照的正脸照。样本太少时可以用水平翻转、小角度旋转和亮度扰动做扩充,但垂直翻转千万别用,人脸上下结构被破坏后嵌入质量会明显下降。明星数据集里的图本身比较规整,一到真实场景容易踩坑,所以数据质量要从源头抓。
2.2 为什么中间产物是 128 维向量,而不是把图片直接丢给 SVM
把原始像素直接丢给 SVM 是典型的初学者思路,效果通常很差。一张 300x300 的 RGB 图展平就是 27 万个特征,SVM 在这么高的维度上做小样本分类,要么被光照噪声带偏,要么计算开销大得离谱。嵌入模型的作用就是把“这张图长什么样”压缩成 128 个实数,而且压缩后保留的是人脸身份相关的信息,光照、角度、表情这些干扰被尽量剥离。
这 128 维向量来自 OpenFace 对 FaceNet 思路的复现:训练时用三元组损失拉近同一个人的嵌入向量、推远不同人的向量。因此训练好的模型天然让“同一人向量近,不同人向量远”,这个性质有两个直接好处。一是可以用欧氏距离快速判断两张脸像不像,二是在这个低维向量空间里,SVM 只需要画一个相对简单的边界就能把多个人分开。也就是说,SVM 学到的是“嵌入空间里的身份边界”,而不是“像素上的纹理模式”,前者泛化能力要强得多。
2.3 六个文件就是一条流水线:运行顺序和产物关系
资源包里的核心文件各司其职,按依赖关系排成下面这张清单:
| 文件 | 输入 | 输出 | 作用 |
|---|---|---|---|
| face_embeddings.py | dataset/、检测模型、nn4.small2.v1.t7 | embeddings.pickle | 批量为数据集提取 128 维人脸嵌入 |
| train_face.py | embeddings.pickle | le.pickle、recognizer.pickle | 训练 SVM 分类器并保存 |
| recognize_face.py | 任意图片、le.pickle、recognizer.pickle | 标注后的图片 | 单张图片人脸识别 |
| recognize_video.py | 1.mp4、le.pickle、recognizer.pickle | 标注后的视频帧 | 视频/摄像头实时识别 |
| deploy.proto.txt + res10_300x300_ssd_iter_140000_fp16.caffemodel | — | — | 人脸检测模型 |
| nn4.small2.v1.t7 | — | — | 人脸嵌入模型 |
运行顺序是固定的:先跑 face_embeddings.py 生成 embeddings.pickle,再跑 train_face.py 得到 le.pickle 和 recognizer.pickle,最后才轮到两个识别脚本。如果跳过了训练直接做识别,程序会因为找不到 recognizer.pickle 直接报错。整套流程里三个 pickle 文件都是中间产物,数据集不变的情况下随时可以重新生成,不用怕跑坏。配套 PDF 里把原理和参数都串了一遍,跟脚本对照着看会省不少力气。
3. 人脸检测:OpenCV 加载 Caffe 版 SSD 模型,参数和裁剪一次说清
检测环节是整个流程的地基。框不准,后面嵌入和分类都白搭。资源包里用的是一个 Caffe 格式的 SSD 模型,结构是 ResNet-10 骨干加 SSD 检测头,OpenCV 的 DNN 模块可以直接加载,不需要单独安装深度学习框架。这一章把加载方式、预处理参数和坐标换算讲透。
3.1 为什么选 SSD-ResNet10,而不是 Haar 级联检测器
很多 OpenCV 老教程还在用 Haar 联级检测器,但它的漏检率在模糊、侧脸、遮挡和暗光场景下非常高,而且检测框经常抖动。OpenCV 3.3 引入 DNN 模块之后,官方提供了多个预训练检测模型,其中 res10_300x300_ssd 就是专门为 CPU 实时推理设计的。它在单帧 300x300 输入下的推理耗时大约 30 到 80 毫秒,精度比 Haar 高一个档次,尤其在侧脸和弱光下优势明显。
资源包里有两个文件,deploy.proto.txt 是网络结构描述,res10_300x300_ssd_iter_140000_fp16.caffemodel 是训练好的权重。注意文件名里 fp16 表示半精度权重,体积比 float32 版本小一半,推理速度稍快。用 OpenCV 加载时两个文件缺一不可,只给权重不给结构一定会报错。如果用的是 opencv-python 库,4.x 以上版本都自带 DNN 模块,不需要额外装 opencv-contrib-python。
3.2 detect_faces:加载模型和检测代码
常见的加载和检测代码写法如下,直接在项目根目录下就能跑:
import cv2 prototxt = "face_dete_model/deploy.proto.txt" caffemodel = "face_dete_model/res10_300x300_ssd_iter_140000_fp16.caffemodel" detector = cv2.dnn.readNetFromCaffe(prototxt, caffemodel) def detect_faces(img, conf_thresh=0.5): h, w = img.shape[:2] blob = cv2.dnn.blobFromImage( cv2.resize(img, (300, 300)), # SSD 固定输入尺寸 1.0, # scale 系数 (300, 300), # 网络输入尺寸 (104.0, 177.0, 123.0) # 训练时用的 BGR 三通道均值 ) detector.setInput(blob) detections = detector.forward() # 维度 (1, 1, N, 7) boxes = [] 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) boxes.append((x1, y1, x2, y2, confidence)) return boxes代码里有几个关键点要展开说。detections 的维度是 (1, 1, N, 7),N 是网络输出的候选框数量,通常为 200 个;每个框有 7 个值,第一个是 batch 索引,第二个是类别索引,第三个是置信度,后四个是归一化坐标。归一化坐标的范围是 0 到 1,必须乘回原始图像的宽高才是真实像素位置,这一步漏了,画出来的框会完全错位。
blobFromImage 的参数也很讲究。这个 SSD 模型在 Caffe 训练时用的是 BGR 三通道输入,因此不要设置 swapRB=True,保持 OpenCV 默认的 BGR 顺序即可;均值 (104, 177, 123) 是三通道各自减去的固定值,不要换成 ImageNet 或者别的值。scale 参数设为 1.0 表示不缩放像素值,因为均值已经把像素搬到合适范围了。conf_thresh 默认 0.5,实际调参区间通常是 0.5 到 0.7,调太低会收获大量误检框,调太高会把模糊的小脸漏掉。
3.3 拿到检测框后别急着裁:margin 外扩才是正解
直接按检测框裁剪是新手最常见的错误。SSD 模型给出的框通常贴着脸的轮廓,额头和下巴经常被切掉一部分,而嵌入模型训练时看的是包含完整脸部的图像。人脸不完整,嵌入向量就会引入额外噪声,直接影响 SVM 的识别结果。
我一般会在裁剪时向四周外扩 20%,让脸部周围留出一点边缘空间,这样送入嵌入模型的内容更接近训练分布:
def crop_face(img, box, margin=0.2): x1, y1, x2, y2, _ = box h, w = img.shape[:2] dx = int((x2 - x1) * margin) dy = int((y2 - y1) * margin) x1 = max(0, x1 - dx) y1 = max(0, y1 - dy) x2 = min(w, x2 + dx) y2 = min(h, y2 + dy) return img[y1:y2, x1:x2]margin 参数取 0.2 是经验值。外扩比例太小起不到保护作用,超过 0.3 又会把背景、头发、肩膀带进来,嵌入向量被无关信息稀释。裁剪后的脸部区域不需要保持原尺寸,后续嵌入模型会把输入 resize 到 96x96,所以这里的宽高比例保持 1:1 即可。还有一点要注意:训练和推理时 margin 必须保持一致,否则同一张脸在训练和推理阶段得到不同的嵌入分布,模型精度会莫名其妙下降。
4. 提取 128 维嵌入:OpenFace 的 t7 模型在算一张脸的“指纹”
检测只是把脸找出来,接下来要把这张脸变成可计算的数字。资源包里用 nn4.small2.v1.t7 这个 Torch 格式的模型做嵌入提取,OpenCV 的 readNetFromTorch 可以直接读,不用装 PyTorch 环境。这一章讲清楚模型输入的预处理细节和 embeddings.pickle 的产出结构。
4.1 nn4.small2.v1.t7:从模型名字读出的关键信息
模型文件名 nn4.small2.v1.t7 里藏着不少信息。nn 是神经网络,small2 是网络结构版本,v1 是权重版本,t7 是 Torch 序列化格式。这个模型是 OpenFace 项目发布的预训练人脸嵌入模型,设计思路继承自 FaceNet,训练目标是让同一身份的人脸嵌入向量在欧氏空间里靠近、不同身份相互远离。输入要求是 96x96 的 RGB 人脸图,输出是 128 维的浮点向量。
预处理细节直接决定嵌入质量,OpenFace 模型期望的输入像素范围是 0 到 1,并且不减去均值。这和上一章 SSD 检测模型的预处理完全相反,SSD 要减均值,OpenFace 不期望减均值。很多人在这个岔路口翻车:把检测模型那套 mean 参数套用到嵌入模型上,嵌入向量整体漂移,识别率断崖式下跌。两个模型的预处理必须各自独立配置,不能图省事写一个通用函数。
4.2 face_embeddings.py:批量提取嵌入的核心逻辑
资源包里 face_embeddings.py 干的事就是把 dataset 里每个人的照片全部过一遍检测和嵌入,攒成一个字典存下来:
import os import cv2 import pickle import numpy as np embedder = cv2.dnn.readNetFromTorch("nn4.small2.v1.t7") def face_embedding(face_rgb): blob = cv2.dnn.blobFromImage( face_rgb, 1.0 / 255.0, # 像素归一化到 [0, 1] (96, 96), # 模型输入尺寸 (0, 0, 0), # 不减去均值 swapRB=True, # 保险起见保持 RGB 顺序 crop=False ) embedder.setInput(blob) vec = embedder.forward().flatten() # 128 维向量 return vec / np.linalg.norm(vec) # L2 归一化 embeddings = {} for name in os.listdir("dataset"): dir_path = os.path.join("dataset", name) if not os.path.isdir(dir_path): continue embeddings[name] = [] for f in os.listdir(dir_path): img = cv2.imread(os.path.join(dir_path, f)) if img is None: continue boxes = detect_faces(img, 0.5) if not boxes: continue face = crop_face(img, boxes[0], 0.2) face_rgb = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) embeddings[name].append(face_embedding(face_rgb)) with open("embeddings.pickle", "wb") as f: pickle.dump(embeddings, f)这段代码的预处理链路要拆开看。输入 img 是 OpenCV 读出来的 BGR 图像,crop_face 裁出的 face 仍然是 BGR 顺序,所以送入嵌入模型前必须用 cvtColor 转成 RGB。OpenFace 模型训练时用的是 RGB 三通道,顺序错了嵌入结果会差非常多。这边在 face_embedding 里还保留了 swapRB=True,因为传入的已经是 RGB,如果原图本来就是 BGR 顺序,这个参数会再反转一次造成色偏。两处冗余其实是我习惯性的双保险写法,底色是同一个原则:训练和推理的输入分布必须一致。
L2 归一化那一行很关键。虽然模型在训练时已经对输出做过归一化处理,但推理阶段输入图像的亮度和对比度不同,导致向量长度有波动。手动做一次 L2 归一化后,向量长度固定为 1,欧氏距离的数值才有稳定的可比性。如果不做,同一位置两个人的向量距离可能比同一个人不同照片还大。
每张图只取 boxes[0] 作为人脸样例,这是因为 dataset 里的照片都是单人照。如果你的数据集里有合照,常见做法是把所有检测框都按置信度降序提取,标签仍然取目录名。另外建议每处理完一个人打印一条日志,方便排查哪个人物的样本没有成功提取。
4.3 embeddings.pickle 的产出长什么样
跑完 face_embeddings.py 后,embeddings.pickle 是一个 Python 字典,键为人物目录名,值为该人物所有嵌入向量的列表。用三行代码就能检查产物的完整度:
with open("embeddings.pickle", "rb") as f: data = pickle.load(f) for name, vecs in data.items(): print(name, len(vecs), vecs[0].shape)输出大致是这样:
yangmi 12 (128,) chenglong 15 (128,) Trump 14 (128,) Biden 13 (128,) zhaoliying 11 (128,) mayun 16 (128,)每一行的 shape 都是 (128,),这是嵌入模型的固定输出维度。检查时重点看两件事:一是每个名字下的样本数不能过少,低于 5 张很难训练出稳定的分类边界;二是 shape 必须一致,如果有向量维度不是 128,说明那张图在某个环节被静默跳过或报错了,需要回头查日志。
5. 避坑清单:五个翻车现场,每一个都能省你半天时间
这套流程原理不复杂,但坑都埋在细节里。下面五个问题是我见过和踩过的高频事故,按现象、原因、解决三段写,遇到类似症状可以直接对照排查。
5.1 现象:readNetFromCaffe 直接报错
有些环境下加载 res10_300x300_ssd_iter_140000_fp16.caffemodel 会直接抛异常,错误信息里能看到 unsupported 或者 depth 相关字样。原因基本可以锁定在模型权重本身的 float16 半精度格式上,部分 OpenCV 版本对半精度的 Caffe 权重支持不完整,尤其 fp16 权重在 CPU 推理时容易出现数据类型不匹配。
解决方法是换一个 float32 版本的 res10_300x300_ssd_iter_140000.caffemodel,deploy.proto.txt 结构文件不用换,OpenCV 官方开源的 float32 权重兼容性要好得多。或者把 opencv-python 升级到尽量新的版本再试。这个坑跟代码逻辑无关,属于模型文件格式和运行库的兼容性问题,建议环境搭建阶段先跑一行 readNetFromCaffe 验证模型能正常加载,别等训练完才暴露。
5.2 现象:训练集准确率 100%,测试集直接全歪
如果 SVM 在训练集上表现完美,一到新图片就谁都不认识,先别急着怀疑分类器,大概率是嵌入质量出了问题。最常见的原因是检测框没做 margin 外扩,直接按 SSD 原始框裁剪,额头和下巴被切掉一部分。嵌入模型训练时看到的是完整脸部,推理时拿到残缺人脸,向量分布自然对不上。
遇到这个症状,先检查 crop_face 的 margin 参数是否在训练和推理两边保持一致,再对比同一个人的两张不同照片,打印出嵌入向量的欧氏距离。如果距离大于 0.6,基本可以确定是嵌入环节坏了,SVM 调参救不回来。把 margin 统一设成 0.2,重新生成 embeddings.pickle,再训练一次,一般会看到明显改善。
5.3 现象:嵌入模型和检测模型共用一套均值,识别结果一塌糊涂
检测模型要减均值,嵌入模型不期望减均值,这是最容易忽略的预处理冲突。有的教程把 SSD 模型的 mean 参数直接抄进嵌入模型的代码里,OpenFace 模型接收到的输入整体偏移了约一百多个像素值,嵌入向量的分布完全偏离训练时的分布。更隐蔽的是有些代码只在一个函数里做了减均值,另一个函数没改,两张脸看起来嵌入距离很大,但语义上完全是噪声。
正确的做法是写两个独立函数,一个函数专门处理检测模型的 blob,带均值和 BGR 顺序;另一个函数专门处理嵌入模型的 blob,用 scale=1/255、mean=0、RGB 顺序。我在自己的代码里会在函数上方加注释标明模型名称,防止以后维护时又混在一起。出现识别结果一塌糊涂的症状时,先逐行核对两处 blobFromImage 参数,这个坑一分钟就能定位。
5.4 现象:SVM 对没见过的人也能硬认一个答案
SVM 本质上是个判别式分类器,它只会回答“输入更接近训练过的哪一类”,不会主动说“这个输入我不认识”。所以当你拿一张完全陌生的人脸喂进去,模型也会输出一个最相近的名字,而且 predict_proba 给出的概率可能还不低。这在人脸识别落地场景里非常危险,门禁系统不可能只识别已知人员。
遇到这个问题要加两个兜底。第一个是概率过滤:用 SVC 的 predict_proba 拿到最大类别概率,低于 0.6 时直接标记 Unknown,不要显示预测名。第二个是距离过滤:计算嵌入向量到每个类别中心点的欧氏距离,最近的类别如果距离超过经验阈值(这个模型上 0.4 到 0.6 之间可调),同样归为 Unknown。要说明的是,SVC 的 predict_proba 是 Platt scaling 方法近似的,小样本下概率值不一定校准,所以它适合做粗过滤,不适合做严格概率比较。
另外注意,sklearn 的 SVC 默认 C=1.0,这是软间隔的常用起点,但 128 维小样本场景下 C=1.0 仍然可能过拟合。见过不少人把 C 调到 100 以上追求训练集满分,结果验证集惨不忍睹。对几十个样本的训练集,C 调到 0.1 到 0.5 之间,gamma 保持默认的 scale,效果往往更稳。
5.5 现象:视频识别里名字跳来跳去
跑 recognize_video.py 时,同一张脸几秒钟内被识别成不同人,名字在画面里闪跳。原因是每一帧独立做检测和分类,而相邻帧的人脸嵌入向量有轻微波动,SVM 的决策边界在靠近分类边界的地方就会来回横跳。再加上视频处理通常要跳帧节流,跳过的帧会沿用旧结果,闪烁感更明显。
解决思路是引入时间平滑,常见做法是维护一个长度为 5 到 10 的滑动窗口,窗口内存放最近几帧的分类结果,取众数作为当前显示名字。只有当窗口内同一名字出现次数超过阈值,才切换显示,否则保持上一帧的名字。这么做会增加一点代码量,但消除闪烁的效果非常明显。我在实际项目里还会对检测框做简单的中心点跟踪,跳过检测的帧直接沿用上一次的框位置,CPU 占用率也能降下来。
6. 识别落地与验证:从单张图片到视频流,最后还要算准确率
模型训练完只算完成一半,接上图片和视频才是真正干活。recognize_face.py 和 recognize_video.py 的骨架逻辑是一样的:加载 le.pickle 和 recognizer.pickle,对每一张人脸做嵌入、预测、过滤低置信度、画框标注。核心区别只是一个处理静态图,一个处理视频帧。
with open("le.pickle", "rb") as f: le = pickle.load(f) with open("recognizer.pickle", "rb") as f: clf = pickle.load(f) for (x1, y1, x2, y2, conf) in detect_faces(img, 0.5): face = crop_face(img, (x1, y1, x2, y2, conf), 0.2) face_rgb = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) vec = face_embedding(face_rgb) idx = clf.predict(vec.reshape(1, -1))[0] name = le.inverse_transform([idx])[0] proba = clf.predict_proba(vec.reshape(1, -1)).max() if proba < 0.6: name = "Unknown" cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, f"{name}: {proba:.2f}", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)predict 返回的是整数索引,必须用 le.inverse_transform 还原成原来的名字字符串,这一步漏掉,程序返回的是数字而不是人名。视频脚本里我会把识别封装成函数,每 3 帧调用一次,中间 2 帧直接沿用上一帧的框和名字,再叠加一个长度为 5 的结果窗口做众数投票,这样既省 CPU 又不抖。
最后一步是验证模型到底有多准,别只测训练集自欺欺人。数据量允许的情况下,做个简单的 train_test_split,留出 20% 的样本做验证,计算准确率。更快的体检方法是把同一个人所有嵌入向量两两算欧氏距离,如果均值大于 0.4,说明嵌入质量本身就不行,这时候应该回头检查预处理,而不是继续调 C 和 gamma。从那以后我每次跑人脸识别,都强制先走一遍这种距离检查,再决定要不要动分类器参数。这套 OpenCV 加 SVM 的老组合不吃 GPU,优点是边界清晰、每一步都能检查,代价就是预处理的一致性必须守得住。希望帮到你。
本文还有配套的精品资源,点击获取