
简介面向具备深度学习基础、希望用卷积神经网络完成人脸识别实战的开发者与学习者这份代码包围绕Olivetti人脸数据集给出从模型训练到识别调用的完整实现可用于课程实验、算法复现和入门项目练习。压缩包共4个文件以2个Python脚本、1个pkl模型参数文件和1个gif图像文件为主整体约14.64MB分别对应训练与推理代码、已保存参数及数据素材结构精简便于直接上手。已有17754人学习说明其在CNN人脸识别入门场景中较受关注。读者可据此理解CNN特征提取与分类流程借助训练脚本复现实验、用保存参数快速验证识别效果并在此基础上替换数据或调整网络结构积累计算机视觉项目的调试与排错经验。1. 摄像头认出你的那一秒中间其实跑了四件事门禁机上刷脸从摄像头出图到屏幕上跳出名字通常不到 300 毫秒。这 300 毫秒里其实串了四段活先在画面里找人脸框再按眼睛和嘴角把脸摆正然后送进 CNN 抽出一个 128 维向量最后拿这个向量和库里存的向量算余弦相似度。很多人做人脸识别项目卡住不是因为 CNN 写不出来而是这四段里某一段的输入输出对不上分数就永远上不去。这份 CNN 人脸识别资源把四段拆成了独立脚本每段都能单独跑、单独看中间结果适合两类人一类是想把卷积网络真正跑通一遍的新手另一类是要在自建小规模人脸库上做二次开发的熟手。下面按我拆代码的顺序讲从环境、目录一路走到训练、比对和改造。2. 工程骨架一张人脸到一条相似度分数要过四道关这套代码的整体思路是「四段流水线 一个 embedding 模型」而不是端到端一个大网络一把梭。原因很实际端到端模型一旦分数不对你根本不知道是检测裁歪了、对齐歪了还是特征本身没学好整条链路就是个黑匣子。拆成四段之后每段都能落盘成图片或 npy 文件出了问题看一眼中间产物就定位了。这种拆分对二次开发也友好——想换检测器只动detect.py想换骨干只动model.py互不影响。整条链路的数据流是这样的原图H×W×3进来检测阶段吐出若干个(x, y, w, h)对齐阶段按关键点做相似变换统一裁成 112×112特征阶段把 112×112 送进 CNN输出 128 维 float32 向量比对阶段两边都做 L2 归一化后点积得到[-1, 1]的余弦相似度再和阈值比大小。四个阶段的坐标系、颜色通道、归一化方式必须严格一致这是后面所有坑的源头。2.1 依赖装到什么版本、为什么要这么配人脸识别这套东西对版本比一般项目敏感尤其是 TensorFlow 和 NumPy 的搭配。我一般直接把版本钉死省得在环境上浪费半天# 建议 Python 3.8 ~ 3.103.11 之后部分 TF 轮子还不齐 pip install tensorflow2.10.0 \ opencv-python4.8.1.78 \ numpy1.23.5 \ scikit-learn1.3.0 \ tqdm4.66.1选 2.10 是有原因的它是 Windows 上最后一个官方带 GPU 支持的 TensorFlow 版本手里有张消费级显卡就能直接吃上 CUDA不用折腾 WSL。NumPy 必须压在 1.23.x装到 1.24 以上会因为np.object这类别名移除报一堆警告虽然多数时候能跑但日志会脏得没法看。OpenCV 选 4.8 是因为 Haar 级联的 xml 文件随 wheel 一起发布cv2.data.haarcascades这个路径能直接拿到省掉手动下载模型的步骤。纯 CPU 也能跑通。112×112 输入、四层卷积的小网络batch 32 一次前向大概几百毫秒几千张图训练几十轮一两个小时能出结果不至于让人等到放弃。真要提速优先把图片预处理好落盘成 npy而不是一味加显卡——I/O 和实时解码往往才是瓶颈。2.2 目录结构数据和代码必须分开放这份资源的目录组织方式我比较认可原始数据、对齐后数据、模型产物、日志四者分开删错东西不至于把原图也带走face-cnn/ ├── config.py # 全局路径与超参改参数只动这一个文件 ├── requirements.txt ├── data/ │ ├── raw/ # 原始图按人名建子目录一个人一个文件夹 │ │ ├── person_a/*.jpg │ │ └── person_b/*.jpg │ └── aligned/ # 对齐后 112x112目录结构与 raw 一致 ├── src/ │ ├── detect.py # 人脸检测 │ ├── align.py # 五点对齐 │ ├── build_dataset.py # 批量跑检测对齐产出 aligned 目录 │ ├── dataset.py # 三元组采样、tf.data 封装 │ ├── model.py # CNN 骨干输出 embedding │ ├── losses.py # triplet loss │ ├── train.py # 训练入口 │ ├── gallery.py # 注册库构建与检索 │ └── infer.py # 单张图推理 demo ├── models/ # 权重、labels.json、gallery.npz └── logs/配置集中在一个文件里这点很关键。人脸项目里最容易出的事就是「训练时用的是 112、推理时忘了改回 96」改一处忘一处最后查半天。我把所有跟尺寸、维度、阈值相关的常量都放在config.py其它文件一律 import从根上断掉这种不一致# config.py from pathlib import Path ROOT Path(__file__).parent DATA_RAW ROOT / data / raw DATA_ALIGNED ROOT / data / aligned MODEL_DIR ROOT / models IMG_SIZE 112 # 对齐输出边长和 REFERENCE_5P 的坐标是绑定的 EMB_DIM 128 # 特征维度128 够用512 精度提升有限但检索成本翻四倍 MARGIN 0.3 # triplet 间隔太小不收敛太大训练震荡 BATCH_P 8 # 每个 batch 采几个人 BATCH_K 4 # 每个人采几张实际 batch 32 EPOCHS 60 LR 1e-3IMG_SIZE和后面align.py里的五点参考坐标是强绑定的改尺寸必须按比例重算坐标否则脸会被裁掉一截。这类隐性耦合在代码里加注释比写文档有用得多。2.3 四个阶段的输入输出契约把契约先定下来再写代码是我做这类流水线项目的固定习惯。四个人坐一起对一遍表比事后 debug 三天强阶段输入输出常见失败表现检测BGR 原图uint8N 个(x, y, w, h)漏检导致后面空跑多人时取错主体对齐原图 5 个关键点112×112×3 uint8歪脸、横向拉伸、裁掉下巴特征112×112归一化后 float32128 维 float32同人向量不聚集类内距离大比对两个 128 维向量余弦相似度[-1, 1]阈值定不准误识和拒识同时高这张表还有一层用处出问题时直接按行排查。分数低先看检测阶段是不是把侧脸当正脸裁了同一个人两次分数差很多先看对齐是不是每次都歪向不同方向所有人分数都接近那基本是特征没学好或者阈值区域本身就不对。四个阶段的实现顺序也要讲究。我的习惯是先把build_dataset.py跑通人工抽几十张对齐后的人脸看一眼确认没歪没糊再动模型。反过来的顺序——先写网络再补数据——十有八九要返工。3. 检测与对齐别把歪脸、半张脸喂进 CNN特征提取再强也救不回一张裁歪的脸。CNN 学到的是像素分布训练集里如果全是端正的正脸推理时给它一张倾斜 30 度、只有半张的照片embedding 会飘到完全不同的区域。检测和对齐这两步决定了模型输入的质量上限值得多花点时间。3.1 Haar 级联先做粗筛够用且零依赖检测我选的是 OpenCV 自带的 Haar 级联理由是零额外依赖、CPU 上快得离谱一张 1080P 图几十毫秒。它对付正脸、光照正常、分辨率不太低的场景完全够用代价是侧脸、大遮挡、逆光下漏检明显# src/detect.py import cv2 # 随 opencv-python 安装的级联文件路径由 cv2.data 提供不需要手动下载 _CASCADE cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) def detect_faces(image_bgr, scale_factor1.1, min_neighbors5, min_side80): 输入 BGR 原图返回 [(x, y, w, h), ...]坐标与原图一致 gray cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) gray cv2.equalizeHist(gray) # 光照不均先拉平灰度能救回一部分逆光脸 boxes _CASCADE.detectMultiScale( gray, scaleFactorscale_factor, minNeighborsmin_neighbors, minSize(min_side, min_side), flagscv2.CASCADE_SCALE_IMAGE, ) return [tuple(int(v) for v in b) for b in boxes]三个参数是调参的主战场。scaleFactor1.1控制每次图像金字塔缩放的比例越小越慢但越不容易漏小脸1.1 是速度和召回的常见折中1.05 会更准但耗时翻倍。minNeighbors5是邻域投票阈值调大误检少但漏检多人多的合影场景我会降到 3。minSize(80, 80)直接放弃小脸因为 40 像素的脸对齐到 112 之后全是插值糊出来的马赛克喂给模型只会污染训练集。equalizeHist这步有个副作用得说清楚它会把暗部噪声一起放大极端暗光下反而更容易误检。我的做法是在调用前先算一下灰度均值低于 40 就直接判定为无效帧跳过别硬检测。一张图多个人时按w * h从大到小排序取最大的当主体。如果场景里固定只有一个人这个策略基本不会错如果是多人合影要全认那就在这个函数外面套循环对每个框分别走后面的对齐和比对。如果项目场景里侧脸、口罩很常见把这里换成多任务级联的 CNN 检测器MTCNN 那一路会明显好转代价是推理慢五到十倍而且多一个依赖。我的建议是先上 Haar 把整条链路跑通等确认真的是检测拖后腿再换别一上来就堆最重的方案。3.2 五点仿射对齐把眼睛嘴角钉到固定位置对齐的目标很朴素让所有人的眼睛、鼻子、嘴角落在图像的同一批像素位置上。这样 CNN 看到的「脸」在空间分布上是一致的不用自己学各种角度和缩放收敛会快很多。做法是用五个关键点左右眼中心、鼻尖、左右嘴角到标准坐标做相似变换。注意是相似变换只允许旋转、等比缩放和平移不允许横向纵向各自拉伸否则脸型会被拉变形# src/align.py import cv2 import numpy as np # 112x112 输出图上的五点标准坐标顺序左眼、右眼、鼻尖、左嘴角、右嘴角 REFERENCE_5P np.array([ [38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041], ], dtypenp.float32) def align_face(image_bgr, landmarks_5p, out_size112): src np.asarray(landmarks_5p, dtypenp.float32).reshape(5, 2) # estimateAffinePartial2D 求的是相似变换不会把脸横向拉扁 M, _ cv2.estimateAffinePartial2D(src, REFERENCE_5P, methodcv2.LMEDS) if M is None: return None return cv2.warpAffine( image_bgr, M, (out_size, out_size), flagscv2.INTER_LINEAR, borderValue(0, 0, 0), )methodcv2.LMEDS是抗异常值的估计方式关键点偶尔有一个标偏了比如嘴角被遮挡LMEDS 能把它当外点剔掉比默认的最小二乘稳。返回值M为None说明五个点共线或者退化直接丢弃这张图不要硬裁。REFERENCE_5P这组数字不是随手编的它和 112×112 的输出尺寸是配套的。如果把out_size改成 96这五个坐标必须按 96/112 等比缩放否则眼睛位置会整体偏移对齐等于白做。这也是我把它和IMG_SIZE都放在config.py里的原因。关键点从哪来正规做法是检测器直接输出五点。Haar 只给框不给点所以最小可用版本是先按框扩边裁出来再resize效果比五点对齐差一截。我在build_dataset.py里就是这么兜底的注释里明确标了「上生产前换成 align_face」。3.3 扩边比例和输出尺寸这两个参数最容易拍脑袋Haar 给的框有个通病偏紧常常把下巴、耳朵、额头切掉。直接拿这个框resize到 112人脸比例会失真。所以我固定加一圈边距def expand_box(box, image_shape, ratio0.2): 按框的长边比例向外扩再裁回图像边界内 x, y, w, h box pad int(round(max(w, h) * ratio)) H, W image_shape[:2] x1 max(0, x - pad) y1 max(0, y - pad) x2 min(W, x w pad) y2 min(H, y h pad) return x1, y1, x2 - x1, y2 - y1ratio0.2是我试出来的经验值0.15 到 0.25 之间都行。低于 0.1 下巴基本保不住高于 0.3 背景占比太大模型会开始学背景而不是脸。这个值和检测器强相关——换成更宽松的检测器之后扩边比例要重新调不能照搬。输出尺寸我锁死在 112×112。为什么不更大人脸识别任务里 112 已经能保留足够的细节再往上加分辨率参数量和计算量是平方级涨收益却很有限。自建小库每人几十张的场景下数据量根本喂不饱大输入反而更容易过拟合。把这三步串起来的批量脚本大概长这样# src/build_dataset.py import json from pathlib import Path import cv2 import numpy as np from config import DATA_RAW, DATA_ALIGNED, IMG_SIZE from src.detect import detect_faces def build(): labels {} for person_dir in sorted(p for p in DATA_RAW.iterdir() if p.is_dir()): labels[person_dir.name] len(labels) out_dir DATA_ALIGNED / person_dir.name out_dir.mkdir(parentsTrue, exist_okTrue) for img_path in person_dir.glob(*.jpg): img cv2.imread(str(img_path)) if img is None: continue boxes detect_faces(img) if not boxes: continue # 检测不到就丢别拿整图去 resize 充数 x, y, w, h max(boxes, keylambda b: b[2] * b[3]) # 扩边后裁切最后一步 resize 到统一尺寸 x1 max(0, x - int(w * 0.2)); y1 max(0, y - int(h * 0.2)) x2 min(img.shape[1], x w int(w * 0.2)) y2 min(img.shape[0], y h int(h * 0.2)) face cv2.resize(img[y1:y2, x1:x2], (IMG_SIZE, IMG_SIZE)) cv2.imwrite(str(out_dir / img_path.name), face) Path(models).mkdir(exist_okTrue) json.dump(labels, open(models/labels.json, w), ensure_asciiFalse) if __name__ __main__: build()跑完之后我一定会做一件事随机抽 50 张对齐结果拼成一张网格图肉眼扫一遍。这一步五分钟能发现九成的数据问题——裁到耳朵的、把人名目录搞反的、灰度图被当彩色读进来的全在这一眼里。跳过这步直接训练后面就是无尽的血泪排查。4. CNN 特征提取从四层卷积到 128 维 embedding到这一步手上应该有一批规整的 112×112 人脸了。接下来要解决的核心问题是怎么让同一个人的不同照片在向量空间里靠得近不同人离得远。注意这不是分类问题是度量学习问题——测试时可能来一个训练集里根本没出现过的人模型必须能给出有意义的距离而不是只会输出训练时的那些类别。4.1 网络结构GlobalAveragePooling 比 Flatten 更稳骨干我用的是四层卷积堆叠配合 BatchNorm 和 GlobalAveragePooling最后接一层 Dense 输出 128 维。这个规模在自建小库上刚好一层层加上去看不到明显收益反而容易过拟合# src/model.py from tensorflow.keras import layers, Model def build_embedding_net(input_shape(112, 112, 3), emb_dim128): inputs layers.Input(shapeinput_shape) x layers.Conv2D(32, 3, paddingsame, use_biasFalse)(inputs) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling2D(2)(x) # 56x56 x layers.Conv2D(64, 3, paddingsame, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling2D(2)(x) # 28x28 x layers.Conv2D(128, 3, paddingsame, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling2D(2)(x) # 14x14 x layers.Conv2D(256, 3, paddingsame, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) # 用全局平均池化代替 Flatten参数量少一个数量级对小数据集更友好 x layers.GlobalAveragePooling2D()(x) x layers.Dense(512, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) # 输出层不做 L2 归一化归一化交给损失函数和推理阶段各自处理 outputs layers.Dense(emb_dim)(x) return Model(inputs, outputs, nameembedding_net)几个设计取舍值得说。卷积层全部use_biasFalse因为紧跟 BatchNorm偏置会被 BN 的减均值抵消掉留着纯属浪费参数。GlobalAveragePooling2D换掉Flatten是这份代码里最划算的一处改动Flatten 在 14×14×256 上会产生 5 万多维的全连接输入参数量爆炸小库上两轮就过拟合GAP 直接把每个通道压成一个数参数量降到零还自带一点正则效果。输出层刻意不做 L2 归一化。如果在这里归一化损失函数里再算欧氏距离时梯度会被压缩训练会变慢。正确做法是原始输出在损失函数内部和推理检索时各归一化一次。4.2 Triplet Loss 与半硬采样难样本怎么挑损失函数我用的是三元组损失逻辑直白锚点和同一个人的正样本距离要比重不同人的负样本距离近至少一个 margin。# src/losses.py import tensorflow as tf def triplet_loss(margin0.3): def loss(y_true, y_pred): # y_pred 形状 (3N, D)按顺序是 N 个 anchor、N 个 positive、N 个 negative anchor, positive, negative tf.split(y_pred, num_or_size_splits3, axis0) anchor tf.math.l2_normalize(anchor, axis1) positive tf.math.l2_normalize(positive, axis1) negative tf.math.l2_normalize(negative, axis1) pos_dist tf.reduce_sum(tf.square(anchor - positive), axis1) neg_dist tf.reduce_sum(tf.square(anchor - negative), axis1) # 只有 pos_dist margin neg_dist 的三元组才产生梯度 return tf.reduce_mean(tf.maximum(pos_dist - neg_dist margin, 0.0)) return lossmargin0.3是个需要试的量。太小0.1模型觉得差不多就行了类内类间拉不开太大1.0大部分三元组都在损失函数的有效区间里梯度方向互相打架训练震荡不收敛。0.2 到 0.5 之间扫一遍看验证集上的分数分布选。y_true在这里完全没用上因为三元组是数据侧构造好的。Keras 的fit又要求必须给标签所以数据管道里得塞一个形状对得上的全零占位。真正决定效果的不是损失函数本身是三元组怎么挑。随机挑负样本绝大多数情况负样本离锚点很远pos_dist - neg_dist margin直接小于零损失为 0梯度为零模型学不到东西。这就是所谓的「简单三元组塌陷」。工程上的解法是半硬采样挑那些「比正样本远但还没远过 margin」的负样本。批内采样时先算出所有正负距离再按条件筛。这份代码里留了钩子但默认走随机小库上能跑通数据量一上来就必须换# src/dataset.py 中的批内构造片段 import numpy as np def make_triplet_batch(x, y, batch_p8, batch_k4): P 个人 × K 张构造等量的 anchor/positive/negative classes np.random.choice(np.unique(y), sizebatch_p, replaceFalse) idx [] for c in classes: ci np.where(y c)[0] pick np.random.choice(ci, sizemin(batch_k, len(ci)), replaceFalse) idx.extend(pick.tolist()) anchors, positives, negatives [], [], [] for i in idx: same np.where(y y[i])[0] same same[same ! i] diff np.where(y ! y[i])[0] if len(same) 0: continue # 这个人只有一张图构不成正样本对直接跳过 anchors.append(x[i]) positives.append(x[np.random.choice(same)]) negatives.append(x[np.random.choice(diff)]) # 三段拼接和损失函数里的 tf.split(y_pred, 3) 严格对应 batch np.stack(anchors positives negatives).astype(np.float32) return batch, np.zeros((len(batch), 1), dtypenp.float32)batch_p和batch_k的乘积就是实际 batch size。P 太小比如 4一个 batch 里负样本种类不够P 太大显存吃不住。8×432 是 6G 显存上的舒服区间。这里还有个硬约束每个人的图片数如果少于 2压根构造不出正样本对这类人在入库前就得过滤掉否则你的采样函数每次都在静默跳过实际 batch 比你以为的小BN 统计量会跟着漂。4.3 训练循环和增强小数据集也能收敛训练脚本本身没什么花活关键是回调别乱加。人脸 embedding 任务没有「准确率」这种直观指标val_loss又不太能反映检索效果所以别指望靠 EarlyStopping 自动停# src/train.py import tensorflow as tf from config import IMG_SIZE, EMB_DIM, MARGIN, EPOCHS, LR, MODEL_DIR from src.model import build_embedding_net from src.losses import triplet_loss from src.dataset import load_aligned, make_tf_dataset def main(): x, y load_aligned(IMG_SIZE) # x: (N,112,112,3) float32已归一化到 [0,1] ds make_tf_dataset(x, y, batch_p8, batch_k4) model build_embedding_net((IMG_SIZE, IMG_SIZE, 3), EMB_DIM) model.compile(optimizertf.keras.optimizers.Adam(LR), losstriplet_loss(MARGIN)) # 每轮都存事后按验证集分数挑比只存最后一轮靠谱 ckpt tf.keras.callbacks.ModelCheckpoint( str(MODEL_DIR / embedding_net.h5), save_freqepoch) model.fit(ds, epochsEPOCHS, callbacks[ckpt]) if __name__ __main__: main()学习率 1e-3 配 Adam前 10 轮损失会掉得很快之后进入平台期。如果损失从第一轮就卡在 0 不动八成是三元的构造顺序和tf.split对不上或者负样本采样得太简单了。这种情况我一般会打印几个 batch 的距离分布看一眼比盲调学习率有效。数据增强要注意别加过头。水平翻转是可以的人脸左右对称但翻转后要同步交换五个关键点的顺序用固定边距裁剪的版本则无所谓。亮度、对比度微调也行幅度别超过 0.2否则肤色分布会偏移同一张脸被增强得跟另一个人似的。绝对不能做的是大角度旋转和随机裁剪——对齐的意义就是把姿态和位置固定住增强一加等于把这一步的努力全废掉。另外提一句 BN 和小 batch 的关系。batch 只有 32 的时候 BatchNorm 的统计量噪声不小如果后面你把 P 降到 4、batch 只剩 16训练会明显不稳。这种时候要么把 batch 撑上去要么把 BN 换成 GroupNorm别指望调学习率能解决。4.4 特征比对与阈值标定别把 0.5 当默认值模型训完下一步是把 embedding 落到业务里建一个注册库来一张新脸就算相似度。库里存的向量必须先归一化查询向量也一样两边都归一化之后点积就等于余弦相似度省掉除法# src/gallery.py import numpy as np import tensorflow as tf def l2norm(v): return v / (np.linalg.norm(v, axis-1, keepdimsTrue) 1e-12) class Gallery: def __init__(self, model_path): # compileFalse只做前向不需要损失函数和优化器加载快很多 self.model tf.keras.models.load_model(model_path, compileFalse) self.embs, self.names None, [] def build(self, x, names): raw self.model.predict(x, batch_size64, verbose0) self.embs l2norm(raw).astype(np.float32) self.names list(names) np.savez(models/gallery.npz, embsself.embs, namesnp.array(self.names)) def query(self, face, topk5): q l2norm(self.model.predict(face[None], verbose0))[0] sims self.embs q # 两边都归一化过点积即余弦 order np.argsort(-sims)[:topk] return [(self.names[i], float(sims[i])) for i in order]np.savez存成 npz 而不是 pickle加载快、跨版本稳几万条的库也就几十兆全量塞内存里做暴力检索完全够用。真到了十万级以上再考虑 FAISS 之类的近似检索别提前优化。阈值是这套系统里最容易被拍脑袋的地方。0.5 不是什么通用标准它完全取决于你的模型、数据分布和业务容忍度。正确做法是采一批标注好的正负样本对同一个人的两张算正不同人算负画 ROC 曲线找工作点from sklearn.metrics import roc_curve def best_threshold(same_scores, diff_scores): scores np.concatenate([same_scores, diff_scores]) labels np.concatenate([np.ones(len(same_scores)), np.zeros(len(diff_scores))]) fpr, tpr, thr roc_curve(labels, scores) idx int(np.argmax(tpr - fpr)) # Youden 指数最大的点 return float(thr[idx]), float(fpr[idx]), float(tpr[idx])正负样本各来几百对用没参与训练的人来构造否则阈值会偏乐观。同一个模型在不同摄像头、不同光照下最优阈值能差 0.15 以上所以换硬件、换场地之后必须重标一次不能拿老阈值硬套。这一点在落地阶段翻车的概率极高。5. 避坑与排查五条踩过的真实记录这套东西在 demo 上跑通和在真实场景里稳定跑中间隔着的全是细节。下面五条是我实际操作中反复遇到的按「现象 → 原因 → 解决」写每条都对应一个具体的检查动作。第一条同一个人两次刷脸分数忽高忽低。现象是同一张脸在两帧之间相似度能从 0.72 掉到 0.41肉眼完全看不出差别。原因是检测框每帧都在抖扩边之后裁出来的区域跟着抖人脸的平移量能有好几个像素而模型对这种位移没有不变性。解决办法是在检测之后加一层平滑对连续帧的框做指数移动平均或者干脆要求连续 3 帧的相似度都过阈值才判定通过。另一个更彻底的做法是把扩边改成按关键点对齐让每帧的裁剪结果稳定落在同一坐标系里抖动会小一个量级。第二条离线验证 99%上线只有 60%。这个最伤士气。查下来是数据划分出了问题——同一个人的照片来自同一次采集随机按图划分时几乎一样的两张分别进了训练集和验证集模型等于在背答案。正确做法是按「人 采集批次」划分验证集里的人一个都不能在训练集出现验证集里的拍摄时段也要和训练集错开。改完划分之后离线指标通常会掉一大截但那才是真实水平。宁可一开始就看到难看的数字也别带着虚高的指标上线。第三条训练时损失降得很好推理时所有人分数都挤在一起。现象是任何两张脸算出来的余弦相似度都在 0.8 以上完全分不开。八成是预处理不一致训练时用cv2.imread读的是 BGR做了/255归一化推理时从别的模块传进来的图已经是 RGB或者忘了除 255。BGR 和 RGB 通道本文还有配套的精品资源点击获取