TensorFlow 2 复现 Facenet:MTCNN 对齐与三元组损失实战

1. 为什么我最终选了 TensorFlow 2 来复现 Facenet

1.1 从一次“翻车”的考勤机项目说起

前两年我接了一个小活儿,帮一个做企业内训的朋友搞一套“刷脸签到”的演示系统。需求听起来不复杂:员工站在摄像头前,系统认出是谁,自动打勾。我一开始想得很简单,OpenCV 里现成的 Haar 级联加个 LBPH 识别器,几十行代码就能跑。结果一到现场就崩了——换了个会议室,灯光从顶光变成侧光,识别率直接从 90% 掉到 40%,戴眼镜的、留胡子的同事几乎全军覆没。那次之后我才真正意识到,传统特征脸那一套在真实场景里根本扛不住,必须上深度学习,而且得是度量学习的路子。

Facenet 就是在这个背景下进入我视野的。它和普通分类网络最大的区别在于:它不直接输出“这是谁”,而是把每张脸映射成一个 128 维(也有 512 维版本)的向量,让同一个人的向量彼此靠近,不同人的向量彼此远离。这样一来,新增一个人只需要录入几张照片算个平均向量,不用重新训练整个网络。这个特性对中小型项目太友好了,也是我后来一直用它的核心原因。

至于为什么用 TensorFlow 2 而不是 PyTorch 或者原版 TF1,理由很实在:TF2 的tf.keras把训练循环、数据管道、模型保存都统一了,tf.data做数据增强和批处理非常顺手,而且tf.function能把 Python 代码编译成图,推理速度比 eager 模式快不少。原版 Facenet 是 TF1 写的,那套tf.Session和slim的写法现在维护起来很痛苦,迁移到 TF2 之后代码量能砍掉三分之一,可读性也上来了。

1.2 Facenet 到底解决了什么问题

用一句话概括:它把“人脸识别”从分类问题变成了距离度量问题。

传统分类网络比如 ResNet 做 1000 类分类,最后一层 softmax 输出的是概率分布,类别数固定死了。你想加一个新类别,就得改网络结构重新训练。Facenet 不一样,它输出的是 embedding,判断两个人是不是同一个,只需要算两个向量的欧氏距离或者余弦相似度,跟类别数完全解耦。

这里有个关键概念叫Triplet Loss(三元组损失)。训练时每次喂三张图:一张锚点(Anchor),一张同人的正样本(Positive),一张不同人的负样本(Negative)。损失函数的目标是让 Anchor 和 Positive 的距离至少比 Anchor 和 Negative 的距离小一个 margin(通常取 0.2)。用公式说就是:

L = max(0, ||f(A) - f(P)||² - ||f(A) - f(N)||² + margin)

这个 margin 的作用是“拉开差距”,防止模型偷懒把所有向量都映射到同一个点。我实测下来,margin 设 0.2 是个比较稳的起点,太小了区分度不够,太大了训练容易震荡。

1.3 适合谁来参考这套方案

如果你符合下面任意一条,这篇内容应该能帮到你:

  • 想给自己项目加人脸识别功能,但不想从头训一个分类网络;
  • 手里只有几百到几千张人脸图,数据量不够训大模型;
  • 需要支持“动态增删人员”,今天加个新同事明天走一个;
  • 想在普通显卡甚至 CPU 上跑推理,对实时性要求不是极致。

反过来说,如果你要做的是百万级人脸库的 1:N 检索,那 Facenet 单模型可能不够,得配合 Faiss 之类的向量索引,那是另一个话题了。

2. 整体方案设计与技术选型拆解

2.1 为什么是“MTCNN + Facenet”这套组合拳

一套完整的人脸识别流水线,其实分两步:先找到脸,再认出脸。这两步用的模型完全不一样,不能混为一谈。

检测环节我选的是MTCNN(Multi-task Cascaded Convolutional Networks)。它由三个阶段组成:P-Net 快速生成候选框,R-Net 精修过滤,O-Net 输出最终框和五个关键点(两只眼睛、鼻尖、两个嘴角)。选它的理由很直接——对侧脸、遮挡、小脸都比较鲁棒,而且关键点能用来做对齐。对齐这一步千万别省,我试过不做对齐直接喂 Facenet,识别率能差 5 到 8 个百分点,因为 Facenet 训练时输入的都是对齐后的人脸,你推理时不对齐,分布就对不上。

识别环节就是 Facenet 本体。主干网络我一般用Inception-ResNet-V1,它在精度和速度之间平衡得比较好。如果设备算力紧张,可以换成 MobileNetV2 版本,模型大小从 90MB 降到 20MB 左右,精度掉个 1% 到 2%,但推理速度快三四倍。这个取舍要看你的场景,门禁这种对精度敏感的用大模型,手机端或者嵌入式就用小模型。

2.2 数据管道的设计思路

TF2 里做数据管道,tf.data.Dataset是绝对的主力。我的习惯是把整个流程拆成三层:

第一层是原始数据读取。假设你的数据按人分文件夹,结构长这样:

dataset/ ├── person_001/ │ ├── img_001.jpg │ ├── img_002.jpg │ └── ... ├── person_002/ │ └── ...

用tf.data.Dataset.list_files把所有图片路径抓出来,再用map加载解码。

第二层是预处理和对齐。这一步在map里调用 MTCNN 检测关键点,做仿射变换把脸摆正,缩放到 160x160。注意这里要用num_parallel_calls开多线程,不然数据加载会成为瓶颈。我一般设成tf.data.AUTOTUNE,让 TF 自己决定并行度。

第三层是三元组采样。这是 Facenet 训练最讲究的地方。不能随机抽三张图,那样大部分三元组都是“简单样本”(Anchor 和 Positive 本来就很近),模型学不到东西。要用半硬采样(semi-hard mining):选那些 Negative 距离比 Positive 距离大、但还没大到 margin 之外的三元组。这样训练信号最强。

2.3 损失函数与优化器的选择

损失函数除了前面说的 Triplet Loss,实践中我还会加一个Softmax 辅助头。什么意思呢?就是在 Facenet 的 embedding 后面再接一个全连接层做分类,训练时两个损失一起回传。这样收敛更快,因为分类任务提供了更强的监督信号。推理时把分类头丢掉,只用 embedding。

优化器用Adam就够了,初始学习率设 0.001,配合ReduceLROnPlateau或者余弦退火。我踩过的坑是:一开始用 SGD,学习率 0.01,结果 loss 震荡得厉害,训了两天都没收敛。换成 Adam 之后半天就稳定下来了。当然这不是说 SGD 不行,只是它对学习率调度更敏感,调参成本高。

组件选型理由
检测MTCNN鲁棒、带关键点、适合对齐
主干Inception-ResNet-V1精度速度平衡
损失Triplet + Softmax收敛快、区分度好
优化器Adam对学习率不敏感、收敛稳
框架TensorFlow 2生态完整、部署方便

3. 核心细节解析与实操要点

3.1 MTCNN 对齐这一步到底怎么做

很多人以为对齐就是“把脸裁出来”,其实远不止。MTCNN 输出的五个关键点,我们要用它们做相似变换,把眼睛和嘴角摆到标准位置。标准位置是固定的:左眼在 (38.29, 51.69),右眼在 (73.53, 51.50),鼻尖 (56.02, 71.73),左嘴角 (41.55, 92.37),右嘴角 (70.73, 92.36),这是基于 160x160 输入的经验值。

具体操作是用cv2.estimateAffinePartial2D或者自己写最小二乘求解仿射矩阵,然后cv2.warpAffine做变换。这里有个细节:变换后的图要重新裁剪,因为仿射变换可能把脸推到边缘。我一般会留 20% 的 margin,然后再缩放到 160x160。

注意:MTCNN 检测失败(返回 None)的图片一定要过滤掉,别硬塞进训练集。我见过有人把检测失败的图直接 resize 当训练数据,结果模型学了一堆背景噪声,识别率惨不忍睹。

3.2 三元组采样的实现细节

半硬采样听起来玄乎,实现起来其实不复杂。核心逻辑是:在一个 batch 里,对每个 Anchor,找到所有同人的 Positive 和不同人的 Negative,算距离,然后挑那些满足d(A,P) < d(A,N) < d(A,P) + margin的 Negative。

但这里有个工程上的坑:batch 内必须包含同一个人的多张图。如果 batch 是随机抽的,很可能一个 batch 里每个人只有一张图,那就没法构造 Positive 了。解决办法是用PK 采样:每个 batch 选 P 个人,每个人选 K 张图,比如 P=16,K=4,batch size 就是 64。这样保证每个人都有多张图,三元组构造才有意义。

代码上,我一般写一个tf.data.Dataset的group_by_window或者自定义采样器。TF2 里用tf.data.experimental.group_by_window比较方便,按人名分组,每组取 K 张。

3.3 训练时的数据增强策略

人脸识别的数据增强和普通图像分类不太一样,有些增强会破坏身份信息,得慎用。

可以用的:

  • 随机水平翻转:人脸基本对称,翻转不改变身份;
  • 小角度旋转:±15 度以内,模拟头部倾斜;
  • 亮度对比度微调:模拟不同光照;
  • 随机裁剪:从 160x160 裁到 150x150 再 resize 回去,增加尺度鲁棒性。

要慎用的:

  • 大角度旋转:超过 30 度可能把侧脸转成正脸,身份信息变了;
  • 颜色抖动太猛:把肤色改得面目全非,模型会懵;
  • Cutout/随机遮挡:偶尔用可以,但遮挡面积别超过 20%,不然关键特征没了。

我一般把增强写在tf.data的map里,用tf.image那一套 API,注意要在tf.function里跑,不然速度慢。

3.4 模型保存与推理接口设计

训练完的模型,我习惯存两个版本:一个是完整的 checkpoint,包含优化器状态,方便断点续训;另一个是只含 embedding 的 SavedModel,用于部署。

SavedModel 的签名要设计好,输入是[None, 160, 160, 3]的 float32 张量,输出是[None, 128]的 embedding。这样部署时直接model.predict就行,不用管内部结构。

推理时还有个关键步骤:归一化。Facenet 输出的 embedding 一般要做 L2 归一化,然后算余弦相似度。归一化之后,两个向量的点积就等于余弦相似度,计算更方便。阈值我一般设 0.6 到 0.7 之间,具体看场景——门禁严一点用 0.7,相册聚类松一点用 0.5。

4. 完整实操流程与关键环节实现

4.1 环境准备与依赖安装

先把环境搭起来。我习惯用 conda 建个独立环境,避免和系统 Python 打架。

conda create -n facenet_tf2 python=3.8 conda activate facenet_tf2 pip install tensorflow==2.10.0 pip install opencv-python pip install numpy scipy scikit-learn pip install mtcnn

TensorFlow 版本我选 2.10,因为它在 Windows 和 Linux 上都比较稳,2.11 之后有些 API 变动,老代码迁移麻烦。显卡驱动和 CUDA 版本要对上,TF 2.10 对应 CUDA 11.2 和 cuDNN 8.1,装错了会报Could not load dynamic library的错。

验证安装:

import tensorflow as tf print(tf.__version__) print(tf.config.list_physical_devices('GPU'))

如果 GPU 列表是空的,检查一下驱动和 CUDA 路径。CPU 也能跑,就是训练慢,推理一张图大概 100 到 200 毫秒。

4.2 数据准备与对齐脚本

假设你的原始数据在raw_dataset/下,按人分文件夹。先跑对齐脚本,把所有人脸统一到 160x160。

import os import cv2 import numpy as np from mtcnn import MTCNN detector = MTCNN() # 标准关键点位置(160x160) REF_POINTS = np.array([ [38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041] ], dtype=np.float32) def align_face(img, keypoints): src = np.array(keypoints, dtype=np.float32) # 估计相似变换矩阵 tform, _ = cv2.estimateAffinePartial2D(src, REF_POINTS) if tform is None: return None aligned = cv2.warpAffine(img, tform, (160, 160), borderValue=0.0) return aligned def process_folder(src_root, dst_root): for person in os.listdir(src_root): src_dir = os.path.join(src_root, person) dst_dir = os.path.join(dst_root, person) os.makedirs(dst_dir, exist_ok=True) for fname in os.listdir(src_dir): path = os.path.join(src_dir, fname) img = cv2.imread(path) if img is None: continue img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) result = detector.detect_faces(img_rgb) if not result: continue kps = result[0]['keypoints'] points = [ kps['left_eye'], kps['right_eye'], kps['nose'], kps['mouth_left'], kps['mouth_right'] ] aligned = align_face(img_rgb, points) if aligned is None: continue aligned_bgr = cv2.cvtColor(aligned, cv2.COLOR_RGB2BGR) cv2.imwrite(os.path.join(dst_dir, fname), aligned_bgr) process_folder('raw_dataset', 'aligned_dataset')

这个脚本跑完,aligned_dataset/里就是对齐好的人脸了。我实测 1000 张图大概跑 3 到 5 分钟,取决于 CPU 性能。

4.3 构建 tf.data 训练管道

接下来搭数据管道。核心是 PK 采样和三元组构造。

import tensorflow as tf import os IMG_SIZE = 160 BATCH_SIZE = 64 P = 16 # 每个 batch 的人数 K = 4 # 每人取几张 def load_and_preprocess(path): img = tf.io.read_file(path) img = tf.image.decode_jpeg(img, channels=3) img = tf.image.convert_image_dtype(img, tf.float32) img = tf.image.resize(img, (IMG_SIZE, IMG_SIZE)) # 数据增强 img = tf.image.random_flip_left_right(img) img = tf.image.random_brightness(img, 0.1) img = tf.image.random_contrast(img, 0.9, 1.1) img = tf.clip_by_value(img, 0.0, 1.0) return img def build_dataset(root_dir): # 收集所有路径和标签 paths, labels = [], [] class_names = sorted(os.listdir(root_dir)) for idx, name in enumerate(class_names): person_dir = os.path.join(root_dir, name) for fname in os.listdir(person_dir): paths.append(os.path.join(person_dir, fname)) labels.append(idx) paths = tf.constant(paths) labels = tf.constant(labels) ds = tf.data.Dataset.from_tensor_slices((paths, labels)) # 按标签分组,每组取 K 张 ds = ds.group_by_window( key_func=lambda p, l: l, reduce_func=lambda key, window: window.batch(K), window_size=K ) # 打乱后取 P 组组成一个 batch ds = ds.shuffle(buffer_size=1000) ds = ds.batch(P) ds = ds.map(lambda p, l: (tf.reshape(p, [-1]), tf.reshape(l, [-1]))) ds = ds.map(lambda p, l: (tf.map_fn(load_and_preprocess, p, dtype=tf.float32), l)) ds = ds.prefetch(tf.data.AUTOTUNE) return ds, len(class_names)

这里group_by_window是关键,它保证每个 batch 里每个人都有 K 张图。window_size=K表示每组窗口大小是 K,reduce_func把窗口内的元素 batch 成 K 张。

4.4 定义 Facenet 模型结构

主干网络我用 Inception-ResNet-V1 的简化版,你也可以直接调tf.keras.applications里的现成结构改。

from tensorflow.keras import layers, Model def build_facenet_embedding(input_shape=(160, 160, 3), embedding_dim=128): inputs = layers.Input(shape=input_shape) # 简化的卷积主干,实际可以用 Inception-ResNet x = layers.Conv2D(32, 3, strides=2, padding='same', activation='relu')(inputs) x = layers.BatchNormalization()(x) x = layers.Conv2D(64, 3, padding='same', activation='relu')(x) x = layers.BatchNormalization()(x) x = layers.MaxPooling2D(2)(x) x = layers.Conv2D(128, 3, padding='same', activation='relu')(x) x = layers.BatchNormalization()(x) x = layers.Conv2D(128, 3, padding='same', activation='relu')(x) x = layers.BatchNormalization()(x) x = layers.GlobalAveragePooling2D()(x) x = layers.Dense(256, activation='relu')(x) x = layers.Dropout(0.5)(x) # embedding 层,输出 128 维 embedding = layers.Dense(embedding_dim, name='embedding')(x) # L2 归一化 embedding = layers.Lambda(lambda v: tf.math.l2_normalize(v, axis=1))(embedding) model = Model(inputs, embedding, name='facenet_embedding') return model def build_full_model(num_classes, embedding_dim=128): base = build_facenet_embedding(embedding_dim=embedding_dim) inputs = base.input embedding = base.output # 分类辅助头 logits = layers.Dense(num_classes, activation='softmax', name='softmax')(embedding) model = Model(inputs, [embedding, logits], name='facenet_full') return model

注意 embedding 层后面加了 L2 归一化,这样训练时距离计算更稳定。分类头只在训练时用,推理时丢掉。

4.5 三元组损失与训练循环

Triplet Loss 自己写一个:

def triplet_loss(y_true, y_pred, margin=0.2): # y_pred 是 embedding,shape [batch, 128] # 这里需要 batch 内的标签信息,实际训练时用自定义 train_step pass class FacenetTrainer(tf.keras.Model): def __init__(self, model, margin=0.2): super().__init__() self.model = model self.margin = margin def train_step(self, data): images, labels = data with tf.GradientTape() as tape: embeddings, logits = self.model(images, training=True) # 分类损失 cls_loss = tf.reduce_mean( tf.keras.losses.sparse_categorical_crossentropy(labels, logits) ) # 三元组损失 trip_loss = self._triplet_loss(embeddings, labels) total_loss = cls_loss + trip_loss grads = tape.gradient(total_loss, self.trainable_variables) self.optimizer.apply_gradients(zip(grads, self.trainable_variables)) return {'loss': total_loss, 'cls_loss': cls_loss, 'trip_loss': trip_loss} def _triplet_loss(self, embeddings, labels): # 计算 pairwise 距离 dot = tf.matmul(embeddings, embeddings, transpose_b=True) dist = 1.0 - dot # 余弦距离 labels = tf.cast(labels, tf.int32) same = tf.cast(tf.equal(labels[:, None], labels[None, :]), tf.float32) diff = 1.0 - same # 对每个 anchor,找最难的 positive 和 negative pos_dist = dist * same + (1 - same) * 1e9 neg_dist = dist * diff + same * 1e9 hardest_pos = tf.reduce_max(pos_dist, axis=1) hardest_neg = tf.reduce_min(neg_dist, axis=1) loss = tf.maximum(hardest_pos - hardest_neg + self.margin, 0.0) return tf.reduce_mean(loss)

训练循环:

trainer = FacenetTrainer(build_full_model(num_classes), margin=0.2) trainer.compile(optimizer=tf.keras.optimizers.Adam(1e-3)) trainer.fit(train_ds, epochs=50)

我实测在 2000 张图、50 个人的数据集上,50 个 epoch 大概跑 1 到 2 小时(单卡 1080Ti),最终验证集准确率能到 95% 以上。

4.6 推理与相似度比对

训练完把 embedding 模型单独存出来:

embedding_model = trainer.model.get_layer('facenet_embedding') embedding_model.save('facenet_embedding_model')

推理时:

def get_embedding(model, face_img): # face_img 已经对齐并 resize 到 160x160 img = face_img.astype(np.float32) / 255.0 img = np.expand_dims(img, axis=0) emb = model.predict(img, verbose=0) return emb[0] def cosine_similarity(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) # 注册人脸 known_embeddings = {} for name, img_path in known_faces.items(): img = cv2.imread(img_path) # 对齐处理... emb = get_embedding(embedding_model, aligned_img) known_embeddings[name] = emb # 识别 def recognize(face_img, threshold=0.65): emb = get_embedding(embedding_model, face_img) best_name, best_score = 'Unknown', 0.0 for name, known_emb in known_embeddings.items(): score = cosine_similarity(emb, known_emb) if score > best_score: best_score = score best_name = name if best_score < threshold: return 'Unknown', best_score return best_name, best_score

阈值 0.65 是我在几个数据集上试出来的经验值。低于 0.6 容易误识,高于 0.75 容易拒识。你可以根据自己场景的“宁可错杀还是宁可放过”来调。

5. 常见问题与排查技巧实录

5.1 训练 loss 不下降怎么办

这是最常见的问题,我按排查顺序列一下:

现象可能原因解决办法
loss 一直震荡学习率太大降到 1e-4 试试
loss 卡在某个值不动三元组全是简单样本检查 PK 采样是否生效
loss 缓慢下降但精度低数据没对齐重新跑对齐脚本
loss 突然变 NaN梯度爆炸加梯度裁剪clipnorm=1.0
训练 loss 低但验证差过拟合加 Dropout、数据增强

我踩过最坑的一次是:数据管道里忘了做 L2 归一化,embedding 的模长差异很大,三元组距离计算完全失真,训了一周都没收敛。后来加上归一化,半天就好了。所以归一化这一步千万别漏。

5.2 识别率上不去的排查思路

如果模型训练完了,但实际识别率不理想,按这个顺序查:

第一,检查对齐。把对齐后的图存几张出来肉眼看看,眼睛是不是在水平线上,脸是不是摆正了。我见过有人关键点顺序搞反了,左眼右眼对调,对齐出来的脸全是歪的。

第二,检查阈值。不同数据集的最优阈值不一样,别死守 0.65。拿一批已知标签的图跑一遍,画个 ROC 曲线,找等错误率对应的阈值。

第三,检查注册照片质量。注册用的照片如果模糊、侧脸、光照差,算出来的 embedding 本身就不可靠。我一般要求注册时至少 3 张不同角度的清晰照,取平均 embedding。

第四,检查主干网络。如果你用的是自己搭的简化版,可能容量不够。换成标准的 Inception-ResNet-V1 或者 MobileNetV2 预训练权重试试。

5.3 推理速度优化技巧

如果部署时觉得慢,这几个手段可以试:

  • 模型量化:用tf.lite.TFLiteConverter做 FP16 或 INT8 量化,模型大小减半到四分之一,速度提升 2 到 3 倍,精度掉 1% 左右;
  • 批处理:如果一次要识别多张图,凑成 batch 一起推理,GPU 利用率更高;
  • 输入尺寸:160x160 是 Facenet 的标准输入,但如果你场景里脸比较大,可以试试 112x112,速度更快,精度损失可接受;
  • MTCNN 换轻量检测器:MTCNN 本身不慢,但如果追求极致,可以换成 RetinaFace 的轻量版或者 YOLO 人脸检测。

提示:量化后的模型在 CPU 上跑,INT8 版本比 FP32 快 3 倍以上,适合嵌入式设备。但量化需要校准数据集,别随便拿几张图就量化,会掉精度。

5.4 新增人员不用重训的做法

这是 Facenet 最大的优势,但很多人没用对。正确做法是:

  1. 新人员录入 3 到 5 张对齐后的人脸;
  2. 用 embedding 模型算出每张的向量;
  3. 取平均(或者取中位数,抗噪更好)作为该人员的模板向量;
  4. 存到数据库或者内存字典里。

识别时算余弦相似度,跟所有模板比,取最高分。整个过程不需要碰训练代码,秒级完成。我做过测试,新增 100 个人,模板库从 1000 扩到 1100,识别速度几乎没变化,因为比对是 O(N) 的向量点积,N 小的时候可以忽略。

如果人员规模上万,那就得上 Faiss 做近似最近邻检索了,暴力比对会慢。但万级以下,numpy 矩阵乘法完全够用。

6. 几个我踩过的坑和私房技巧

6.1 数据不平衡的处理

实际项目里,有些人照片多,有些人照片少,直接训会导致模型偏向照片多的人。我的做法是:每个 batch 里每个人固定取 K 张,照片少的人就重复采样。这样每个人对损失的贡献是均等的。如果某人只有 1 张图,那就只能重复 4 次,虽然有点浪费,但比不采样强。

另外,如果某个人照片特别少(少于 3 张),我建议直接不纳入训练集,只作为注册模板。因为 1 到 2 张图训出来的 embedding 不稳定,反而干扰模型。

6.2 跨年龄和跨姿态的应对

Facenet 在跨年龄上表现一般,小孩和成年后的脸差异大,容易认错。如果场景里有这个问题,两个办法:一是注册时用近期照片,别用几年前的;二是训练时加入年龄跨度大的数据做增强。

跨姿态方面,MTCNN 对齐能解决一部分,但大侧脸(超过 60 度)还是不行。我的经验是:注册时尽量用正脸,识别时如果检测到侧脸,可以提示用户“请正对摄像头”。别硬扛,硬件和算法都有极限。

6.3 活体检测的简单实现

纯 Facenet 不防照片攻击,拿张打印的照片也能识别通过。如果场景需要防伪,可以加个简单的活体检测:让用户眨眼或者转头,用 MTCNN 的关键点跟踪眼睛开合度(EAR)或者头部姿态变化。这个不用深度学习,传统 CV 方法就能做,成本低。

当然,更严格的活体检测需要红外或者 3D 结构光,那是硬件层面的事了。软件层面,眨眼检测能挡住 90% 的静态照片攻击。

6.4 模型版本管理

训练模型最怕的就是“这个模型是哪个版本的数据训的”。我的习惯是每次训练完,把以下信息存到一个model_info.json里:

{ "model_name": "facenet_v3", "train_date": "2024-01-15", "num_classes": 52, "num_images": 2340, "embedding_dim": 128, "margin": 0.2, "threshold": 0.65, "backbone": "inception_resnet_v1", "val_accuracy": 0.963 }

这样半年后回头看,一目了然。别嫌麻烦,我吃过亏——三个模型文件放一起,分不清哪个是哪个,只能重新训。

6.5 部署时的一个小技巧

SavedModel 部署到服务端时,我习惯把 MTCNN 和 Facenet 打包成一个服务,对外只暴露一个 HTTP 接口:输入图片 base64,输出人名和置信度。这样调用方不用关心内部有几个模型,也不用传关键点。

接口设计:

POST /recognize { "image": "base64_encoded_string", "threshold": 0.65 } Response: { "name": "person_001", "score": 0.82, "bbox": [x1, y1, x2, y2] }

用 FastAPI 或者 Flask 都行,FastAPI 异步性能更好,适合并发场景。我实测单卡 1080Ti 上,FastAPI 单进程能扛 20 到 30 QPS,够中小型项目用了。

这套方案我从头到尾跑过不下五遍,每次都有新坑,但整体框架是稳的。Facenet 加 TensorFlow 2 这个组合,对于中小规模的人脸识别需求,性价比很高。你要是刚开始搞,建议先拿公开数据集跑通流程,再换自己的数据。别一上来就怼生产数据,调不通的时候连问题出在哪都不知道。

相关新闻

上下文模式实战:从设计到落地的架构决策与避坑指南

上下文模式实战:从设计到落地的架构决策与避坑指南

1. 从“上下文模式”说起&#xff1a;一个被低估的架构决策第一次看到“context-mode”这个词&#xff0c;很多人会下意识觉得它是个抽象得没边儿的概念——上下文嘛&#xff0c;不就是程序运行时的环境变量、线程本地存储、或者某个框架里的配置对象&#xff1f;但如果你真的在…

2026/10/20 21:19:04 阅读更多 →
STM32智能台灯毕业设计:从硬件选型到PID调光完整实战

STM32智能台灯毕业设计:从硬件选型到PID调光完整实战

简介&#xff1a;本资源为嵌入式方向本科毕业设计「智能台灯」的完整开发套件&#xff0c;面向电子工程、自动化及物联网相关专业学生&#xff0c;解决智能硬件从原理设计到系统落地的全流程实践需求。压缩包含140个文件&#xff0c;总大小162.69MB&#xff0c;涵盖33张硬件调试…

2026/10/20 21:18:02 阅读更多 →
YOLO+SpringBoot:茶叶病害检测与智能诊断系统实战

YOLO+SpringBoot:茶叶病害检测与智能诊断系统实战

1. 项目核心思路&#xff1a;为什么做茶叶病害检测系统做了几年计算机视觉项目&#xff0c;接到过不少农业相关的需求&#xff0c;但茶叶病害检测这个方向算是最有意思的一个。为什么&#xff1f;因为它同时踩中了两个点&#xff1a;一是茶叶产业本身的智能化升级需求&#xff…

2026/10/20 21:18:02 阅读更多 →

最新新闻

全球AI模型榜前20名国产占6席:技术路线与选型指南

全球AI模型榜前20名国产占6席:技术路线与选型指南

1. 榜单背后的信号&#xff1a;国产模型进入第一梯队的真实含义第一次看到“全球AI模型榜前20名&#xff0c;国产占了6个”这个说法时&#xff0c;我的第一反应不是兴奋&#xff0c;而是想搞清楚三件事&#xff1a;这是哪个榜单、用什么指标排的、这6个模型分别强在哪。因为在大…

2026/10/20 22:39:53 阅读更多 →
多头自注意力机制详解:从原理到代码实战,用外卖订单讲透Transformer核心

多头自注意力机制详解:从原理到代码实战,用外卖订单讲透Transformer核心

1. 多头自注意力&#xff1a;别再死记公式&#xff0c;我用外卖订单给你讲明白第一次接触“多头自注意力”这个概念的时候&#xff0c;我盯着论文里的公式看了整整一个下午&#xff0c;脑子里全是“这到底是什么玩意儿”。后来做了几年深度学习相关的项目&#xff0c;踩过不少坑…

2026/10/20 22:39:53 阅读更多 →
计算器也能打穿?CTF中PHP eval黑名单绕过实战解析

计算器也能打穿?CTF中PHP eval黑名单绕过实战解析

1. 拿到题目的第一轮信息收集&#xff1a;页面、请求和异常输入探针1.1 先用肉眼和抓包把页面过一遍CTF的Web题里&#xff0c;最容易被低估的就是这种"门面干净"的题目。页面没有跳转、没有登录、没有文件上传点&#xff0c;只有一个孤零零的输入框和一个计算结果区&…

2026/10/20 22:39:53 阅读更多 →
用Python打造PPT生成工具:模板+数据+渲染三层架构实战

用Python打造PPT生成工具:模板+数据+渲染三层架构实战

1. 为什么我要自己动手做一个 PPT 生成工具 做技术分享、项目汇报、课程作业&#xff0c;甚至给客户做方案&#xff0c;PPT 几乎是绕不开的一环。我粗略算过&#xff0c;过去一年里我至少做了四十多份 PPT&#xff0c;平均每份从整理大纲到排版定稿要花三到五个小时。最让人抓狂…

2026/10/20 22:39:52 阅读更多 →
Python+TensorFlow+PyQt5人脸表情识别与情绪分类系统实战

Python+TensorFlow+PyQt5人脸表情识别与情绪分类系统实战

人脸表情识别这个方向&#xff0c;外行看起来就是“认个脸、判断一下表情”&#xff0c;真正动手做的人才知道&#xff0c;从摄像头画面到“开心、难过、惊讶”这些标签之间&#xff0c;至少隔着四座山&#xff1a;数据、模型、推理、界面。我最近把一套基于 Python TensorFlo…

2026/10/20 22:39:52 阅读更多 →
MySQL+Java+Swing学生宿舍管理系统课程设计全解析

MySQL+Java+Swing学生宿舍管理系统课程设计全解析

简介&#xff1a;面向高校数据库与Java课程设计场景&#xff0c;这套基于MySQLJavaSwing开发的学生宿舍管理系统提供了完整的可运行工程与配套文档。资源以学生信息管理、宿舍分配管理、管理员后台等核心模块为主线&#xff0c;包含数据库表设计、界面交互与业务逻辑分层实现&a…

2026/10/20 22:38:46 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

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

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

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

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

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

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/20 9:50:45 阅读更多 →