简介:这是一个基于机器学习的人脸发型推荐算法研究与应用实现项目,面向机器学习初学者、计算机视觉研究者和 Flask Web 开发者,解决根据用户面部形状自动推荐适配发型的问题。资源按数据、模型、应用三个层次组织:数据集收集了约 74 位名人的近 1500 张人脸图像,并标注长形、圆形、椭圆形、心形、方形等脸型;模型部分实现多层感知机、K近邻、随机森林、梯度提升、线性判别分析等多种分类器,完成特征标准化、降维和性能对比;应用层使用 Flask 开发,支持上传照片后输出脸型分类结果和推荐发型。rar 压缩包共 2002 个文件,其中 1937 张 jpg 为图像数据集,7 个 Python 脚本是核心训练与预测代码,另有 CSS、JavaScript、HTML 前端资源和 JSON、XML 配置文件,整包约 705.67MB,目录结构清晰,便于按模块阅读理解。目前已有 270 人学习使用,适合希望从数据处理、模型训练到 Web 应用部署完整走通一个机器学习项目的读者。
1. 基于机器学习的人脸发型推荐算法,先从“特征”而不是“生成”说起
美发门店的导购终端、相册类 App 的换装模块、线上预约工具里的发型试戴,核心场景都是同一件事:用户拍一张正脸照,系统在几秒内给出几款适合的发型,供人对比决策。很多初版做法直接让模型把头发“换”到人脸上,但真实环境里,刘海边缘、颧骨弧度、发际线位置只要有一处偏差,视觉上就完全不可信。更稳的落地方式是把“基于机器学习的人脸发型推荐算法研究与应用实现”拆成三个可验证环节:人脸属性建模、发型编码与相似推荐、轻量服务化部署。这也是当前工程界比较通行的做法,尤其适合刚入门人脸项目、或者有一定图像基础但没接触过推荐系统的开发者参考。
整条链路里,真正决定推荐质量的是特征怎么定义、距离怎么度量,而不是那层推荐壳子。下面按数据准备、模型训练、在线实现和验证调优的顺序展开,给出的命令和参数可以直接拷到项目里改动。
2. 数据准备与特征表示:对齐、关键点和发型属性建模
发型推荐的数据问题比通用人脸识别更麻烦。通用人脸识别只要回答“这是不是同一个人”,发型推荐却要回答“这副五官适合什么头发”,属于主观性很强的细粒度属性问题。常见做法是组合两类数据:一类是公开的人脸属性数据集,像 CelebA、FFHQ 这类,有大量对齐后的人脸图像和标签可用于预训练;另一类是业务侧自采的标注集,针对门店或线上渠道的真实用户。正式实施时,可以先把公开数据做通用特征预训练,再把业务标注集拿来做属性微调。
2.1 发型标签的拆法:不要只标“长发”“短发”一个类别
单一的发型类别对深度学习模型来说太粗。两个人都是“中长发”,一个适合方脸,一个适合圆脸,差异往往体现在刘海形态、层次感和卷曲度这些细节上。实际项目里,我一般把标签拆成一组离散属性,形成多标签标注任务,字段大致如下:
| 属性字段 | 取值范围 | 说明 |
|---|---|---|
| fringe_type | 0=无刘海, 1=空气刘海, 2=厚刘海, 3=斜分 | 影响额头露出的比例 |
| hair_length | 0=超短, 1=短发, 2=中发, 3=长发 | 影响脸型拉长效果 |
| curl_level | 0=直发, 1=微卷, 2=大卷 | 卷度改变脸部轮廓的视觉宽度 |
| color_tone | 0=黑色, 1=棕系, 2=浅色 | 与肤色亮度有关 |
| forehead_expose | 0=遮住, 1=部分, 2=全露 | 发际线高低与脸长相关 |
| face_shape | 0=圆, 1=方, 2=长, 3=瓜子 | 推荐排序的主要分组依据之一 |
| sideburn | 0=无鬓角, 1=短鬓角, 2=长鬓角 | 修饰下颚线 |
| style_attr | 0=休闲, 1=商务, 2=甜美等 | 用于千人千面的业务排序 |
标注时要让同一张图由 3 个人分别打,采用投票一致的标签进入训练集,不一致的放入人工复核。这样能明显减少“标注员对发型风格的主观分歧”带来的噪声,比直接让模型硬学一个统一答案更稳。
2.2 预处理流水线:OpenCV 检测、人脸关键点对齐、统一裁剪
图像预处理的目标是把所有人脸摆到大致相同的位置和尺度,避免模型把“脸偏了”当成特征。常用的方案是先用 OpenCV 的 DNN 人脸检测器或 RetinaFace 拿到 bounding box 和关键点,再根据双眼位置做仿射变换。给出一段基于 face_recognition 的最小可用对齐代码,底层的 dlib 模型已经在 68 点关键点任务上很成熟,适合快速起步:
import face_recognition import cv2 import numpy as np def align_face(image_bgr, target_size=112): rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) face_locations = face_recognition.face_locations(rgb, model="hog") landmarks_list = face_recognition.face_landmarks(rgb, face_locations) if len(landmarks_list) == 0: return None landmarks = landmarks_list[0] left_eye = np.mean(landmarks["left_eye"], axis=0) right_eye = np.mean(landmarks["right_eye"], axis=0) dx = right_eye[0] - left_eye[0] dy = right_eye[1] - left_eye[1] # 以双眼连线为水平基准,计算旋转角 angle = np.degrees(np.arctan2(dy, dx)) center = (image_bgr.shape[1] / 2, image_bgr.shape[0] / 2) matrix = cv2.getRotationMatrix2D(center, angle, scale=1.0) aligned = cv2.warpAffine(image_bgr, matrix, (image_bgr.shape[1], image_bgr.shape[0])) return cv2.resize(aligned, (target_size, target_size))逻辑说明:代码先用人脸定位接口拿到所有人脸区域,再取第一个人的左右眼平均坐标。双眼连线的水平倾角就是图像需要旋转的角度,warpAffine负责做旋转矫正,最后统一缩放到 112×112。这个尺寸是常见的人脸识别模型输入尺寸,训练和推理保持一致即可。
参数说明:model="hog"在 CPU 上速度更快,适合做离线数据处理;如果场景里有大量侧面或仰头照片,可以改用model="cnn",但显存占用和耗时都会上升。target_size建议训练时用 112,入库时用 224 以上保留发丝纹理,两个尺寸各存一份,避免推荐阶段损失太多细节。
2.3 特征表示:关键点几何特征、人脸嵌入向量和发型属性向量
对齐之后,数据要转成特征才能被机器学习模型消费。这一步有两条路线:一是只做几何特征,把 68 点关键点坐标归一化后作为脸型信息;二是用深度网络提取的人脸嵌入向量,也就是把整张脸压成一个 512 维左右的浮点向量。单纯的关键点坐标容易受遮挡影响,但语义非常直接;深度嵌入向量对身份信息保持得更好,却不能直接告诉你“这个人适合露出额头吗”。
实际工程中,我一般把这两种特征和发型属性向量一起使用:人脸嵌入负责“找到长得像的人”,发型属性向量负责“发型是否适合这个人”。三者对比关系如下:
| 特征类型 | 维度 | 对发型的相关度 | 标注成本 | 主要用途 |
|---|---|---|---|---|
| 68 点几何特征 | 136 维 | 低 | 已有模型可生成 | 辅助脸型判断 |
| 人脸嵌入向量 | 128~512 维 | 中 | 无需人工标注 | 相似人脸召回 |
| 发型属性向量 | 8~12 维 | 高 | 需人工标注 | 发型适配排序 |
建模时还有一个容易踩的坑:发型特征必须从“带完整头部范围的图”里提取,不能只裁人脸框。人脸检测器的框通常到发际线就停了,头发会被切掉一半,此时提取出来的特征根本看不出卷度和长度。操作方法是把人脸检测框按比例向上向外扩展,外扩比例一般取 0.3 到 0.5,再送进后续的特征提取网络。
3. 模型设计与训练:单分类、多属性和度量学习的前后顺序
特征表示准备好之后,进入模型核心环节。发型推荐这个任务适合用“分类 + 度量学习”的组合结构,而不是一上来就上生成式模型。常见做法是把骨干网络做成共享结构,输出端分成两个分支:一个分支输出发型属性类别概率,另一个分支输出用于相似度比较的嵌入向量。两分支协同训练,推理时只保留嵌入向量,这样既保持可解释性,又不牺牲检索性能。
3.1 先用 ResNet 做属性分类基线
先跑通一个最简单但有效的属性分类模型,用它确认标签质量是否足够,再接更复杂的结构。基于 PyTorch 的实现起来很直接:
import torch.nn as nn from torchvision import models class HairAttributeModel(nn.Module): def __init__(self, num_attr=8, num_classes=4): super().__init__() base = models.resnet18(pretrained=True) self.backbone = nn.Sequential(*list(base.children())[:-1]) # 每个属性一个独立分类头 self.attr_heads = nn.ModuleList([ nn.Linear(512, num_classes) for _ in range(num_attr) ]) def forward(self, x): feat = self.backbone(x).flatten(1) return [head(feat) for head in self.attr_heads]逻辑说明:代码去掉 ResNet18 自带的全局池化和分类层,只保留卷积骨干,输出 512 维特征,然后再接 8 个独立的线性分类头,每个头对应一种发型属性。损失函数分别计算 8 个交叉熵再取平均,这样“长度分错了”不会拖累“卷度分类”的梯度。
参数说明:pretrained=True会让模型加载 ImageNet 预训练权重,在数据量不大时能显著加快收敛。num_attr=8要与第 2 章里的属性字段数量对应;如果业务实际只有 6 个可标注属性,就把它改成 6,不要硬凑。
这一步跑完,要在验证集上看每个属性的准确率和混淆矩阵。如果“刘海类型”和“发际线暴露度”两个属性混淆严重,说明标注定义有重叠,需要回到数据标注阶段修口径,而不是继续加模型复杂度。
3.2 从分类到度量学习:让“相似脸”找到相似发型
属性分类能解释“这个人的额头适不适合刘海”,但它解决不了“这个人整体气质和哪个发型匹配”的问题。审美型任务没有唯一的正确答案,同一张脸配三种发型都可能不错。这时候度量学习更合适:训练一个嵌入模型,让同一发型标签下的样本在向量空间里距离更近,不同发型标签的样本距离更远。
三元组损失是度量学习里最常用的形式,核心代码可以精简成下面这样:
import torch.nn.functional as F def batch_triplet_loss(anchor, positive, negative, margin=0.3): # 每个样本都用欧氏距离衡量 pos_dist = F.pairwise_distance(anchor, positive, p=2) neg_dist = F.pairwise_distance(anchor, negative, p=2) loss = torch.clamp(pos_dist - neg_dist + margin, min=0.0) return loss.mean()逻辑说明:函数接收三组特征向量,计算正样本对和负样本对的距离,目标是让负样本距离至少比正样本距离大margin,否则产生损失。0.3 是一个比较常用的初始值,先粗后细调。
参数说明:margin不要设置太大,太大会让训练早期梯度持续很大、难收敛;也不要小于 0.1,否则同类样本区分度过低。数据采样上,负样本不要纯随机取,训练到中期之后,随机负样本大多数都离得很远,损失为 0,梯度消失。常见做法是每 5 个 epoch 做一次特征预计算,给每个 anchor 找一批难负样本重新组 batch。
3.3 联合训练时的三个实践决策
训练时我会同时保留属性分类分支和三元组分支,但两者损失权重不同。属性分类损失权重设为 1.0,三元组损失权重设为 0.3,并让两个分支从固定的第 5 个 ResNet block 之后分叉,而不是从一开始就分。这个设定的原因是:底层卷积特征同时服务于属性识别和相似度度量,强行拆开会让两个任务学到的特征相互割裂。
还要注意类别不平衡问题。“黑色直发”样本可能占到 60%,“浅色大卷”可能只有 1%。直接用交叉熵会让模型产生严重的多数类偏向。缓解方法有 3 个常用手段:按样本数做重采样、对少数类提高损失权重、或者在数据增强里对少数类别做过采样。实际操作上,重采样的效果最稳定,我一般先把每类样本数压到最大类样本数的 60% 以内,再做随机增强。
最后一坑是学出来的嵌入向量有“身份泄露”。同一人不同照片会被模型认为是同一个簇,导致检索结果里反复出现同一个人的脸。解决办法是在组 batch 时不把同一个 ID 的样本放入同一 batch,或者在损失函数中对相同 ID 对加上惩罚权重。这属于训练数据组织问题,很多机器学习项目里被归为“数据 pipeline 没写对”,但它对推荐结果的多样性影响非常明显。
| 模型结构 | 优势 | 劣势 | 适用阶段 |
|---|---|---|---|
| 属性分类单模型 | 简单、可解释性强 | 无法表达“多种发型都合适” | 基线验证、冷启动 |
| 纯度量学习模型 | 检索效果好、相似语义强 | 分类解释弱 | 发型库数量大之后 |
| 属性分支 + 嵌入分支联合 | 两者兼顾,线上最常用 | 训练复杂度高 | 正式上线 |
4. 推荐算法落地:向量召回、规则排序与一个小型推理服务
模型训练完只是中点,真正决定项目能否被业务用起来的是推荐链路的设计。发型推荐不适合直接拿模型输出的相似度 Top1 作为唯一结果,因为审美主观性太强,单一答案会让用户失去选择感。实际系统通常拆成召回、排序、后处理三层,最后通过接口把结果交给前端展示。
4.1 先用 NumPy 跑通 KNN 召回
发型库的规模通常在几千到几万条,用暴力 KNN 完全可以满足。如果只有 5000 条向量,一次全量距离计算在 CPU 上也就是几毫秒。先不用直接上复杂检索数据库,把逻辑跑通再优化架构。召回代码可以这样写:
import numpy as np def knn_recall(query_embedding, gallery_embeddings, top_k=30): # 归一化不是必须的,但用了余弦距离就必须做 query_norm = query_embedding / (np.linalg.norm(query_embedding) + 1e-9) gallery_norm = gallery_embeddings / (np.linalg.norm(gallery_embeddings, axis=1, keepdims=True) + 1e-9) # 余弦相似度等价于归一化后的内积 scores = gallery_norm @ query_norm top_indices = np.argsort(-scores)[:top_k] return top_indices, scores[top_indices]逻辑说明:代码先分别归一化查询向量和候选库向量,再用矩阵乘法一次性算出所有候选的发型和目标脸的相似分数,最后取分数最高的前 30 个作为召回候选。为什么不用欧氏距离?因为不同批次提取的嵌入向量,其绝对数值分布会受模型权重影响,归一化后计算余弦相似度对尺度更鲁棒。
参数说明:top_k=30是召回量,不是最终展示量。召回量设太大会让后续排序压力上升,太小会把正确结果挡在门外。在实际业务里,30 到 50 是常见区间,排序之后只保留 5 到 8 个结果。
等发型库量级超过 10 万条以后,可以把gallery_embeddings构建成 faiss 的 IVF 索引,检索耗时能从几十毫秒降到个位数毫秒,但一开始完全没必要。
4.2 排序层:相似度、属性和业务规则的三段式融合
KNN 召回之后,候选集合里可能存在大量相似风格相近的发型,这不利于用户体验。排序阶段要综合三部分信号:
- 余弦相似度:召回阶段的核心分数;
- 属性匹配度:用户的人脸属性与发型属性之间的匹配;
- 业务得分:新品权重、门店流行度、运营置顶位。
我给出一套极简排序函数:
def rerank(cand_ids, cand_scores, user_attr, hair_attr_map, hot_scores, style_penalty=0.05): results = [] for hair_id, sim in zip(cand_ids, cand_scores): # 属性差异:只取和脸型、长度、刘海相关的五个字段 attr_diff = 0.0 for key in ["face_shape", "hair_length", "fringe_type"]: attr_diff += abs(user_attr[key] - hair_attr_map[hair_id][key]) # 连续热门排序会让结果趋同,这里做成可调节参数 score = 0.6 * sim - 0.3 * attr_diff + 0.1 * hot_scores[hair_id] # 同风格候选只保留分数最高的一个 style = hair_attr_map[hair_id]["style_attr"] results.append((hair_id, score, style)) results.sort(key=lambda x: -x[1]) deduped = {} for hair_id, score, style in results: if style not in deduped: deduped[style] = (hair_id, score) return [v[0] for v in deduped.values()][:8]逻辑说明:排序分等于 0.6 倍的相似度分,减去 0.3 倍的属性差异,再加上 0.1 倍的热门分。属性差异直接取三个关键字段的绝对值相加,差异越大说明发型越不适合当前脸型。最后用style_attr做同风格去重,保证展示结果覆盖多种路线。
参数说明:0.6、0.3、0.1 这三个权重不是固定值,也不需要一开始调得很精细。先跑离线评测,把“相似度得分高但用户根本不点”的样本捞出来,看是属性差异权重太低还是热门分干扰太大,再逐项调整。某个业务里,如果门店希望让新品得到更多曝光,可以把热门分的来源从点击量改为“点击率 + 新品时间衰减”,这样既能保证折损可控,也不用改排序逻辑。
4.3 用 FastAPI 封装一个最小可用的“应用实现”服务
链路验证完毕后,要用 Web 服务把功能接出去。FastAPI 是目前比较合适的轻量方案,异步支持好,自带参数校验和 OpenAPI 文档。一个最小可用的推理服务长这样:
from fastapi import FastAPI, UploadFile import numpy as np import cv2 app = FastAPI() gallery_embeddings = np.load("gallery.npy") hair_meta = load_hair_meta() # 发型属性与业务信息字典 @app.post("/api/v1/hair/recommend") async def recommend(file: UploadFile): raw = await file.read() img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) aligned = align_face(img, target_size=112) # 第2章的对齐函数 if aligned is None: return {"code": 400, "msg": "no face detected"} embedding = extract_embedding(aligned) # 模型推理函数 cand_ids, cand_scores = knn_recall(embedding, gallery_embeddings, top_k=30) user_attr = predict_attributes(aligned) # 属性分类分支的输出 final_ids = rerank(cand_ids, cand_scores, user_attr, hair_meta) return { "code": 0, "data": [ {"hair_id": str(i), "cover": hair_meta[i]["cover_url"], "reason": "相似度匹配", "order": idx} for idx, i in enumerate(final_ids) ] }启动服务用一行命令:
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2逻辑说明:接口先读取上传图片,做人脸对齐,再同时做嵌入向量提取和属性预测,最后调用排序函数返回发型 ID 列表。这里“对齐”和“推理”拆成两步,是为了方便在 GPU 机器上把推理并行化,而 CPU 只处理预处理。
参数说明:--workers 2代表启动两个进程,模型会在每个进程里加载一份,内存翻倍。如果显卡显存不够放两份模型,可以先设成 1,或改用共享内存加载权重的方式。返回数据里的cover_url是指向前端静态资源的地址,一般是对象存储链接,前端拿到后直接渲染图片。
模型在启动阶段加载一次,不要在每个请求里重新torch.load,否则高并发时磁盘 I/O 会成为瓶颈。必要的时候加一层 Redis 做结果缓存,以人脸的感知哈希值作为 key,把同一张照片的重复请求直接打在缓存上。
5. 上线后真正要盯的三个技巧:遮挡兜底、A/B 分流与特征缓存版本化
模型上线不代表工作结束,发型推荐这类主观性强的业务,最怕的不是算法精度低,而是用户对推荐结果没有感知、也无从反馈。上点技巧:验证流程和缓存要提前设计好。
遮挡是真实场景里出现频率最高的问题。用户在地铁、门店随手一拍,可能存在刘海遮挡眉眼、手部遮挡下颚这类情况。此时人脸对齐仍能成功,但关键点误差变大,嵌入向量会产生偏移,推荐结果随之漂移。在服务里加入姿态角判断,当检测到的左右眼连线超过 30 度、或关键点置信度低于阈值时,不再进入个性化推荐链路,而是返回运营配置的热门发型。热门榜可以按发型库自身点击率生成,保证推荐流程在异常输入下不出现空结果,也不出现离谱结果。
线上验证采用 A/B 分流时,分流键要选稳定的用户 ID,而不是每次请求动态生成。常见做法是取user_id后 7 位哈希,哈希值落在[0, 500)的进实验组,其余进对照组;实验组返回算法推荐结果,对照组返回热门发型排序。核心指标不要直接看点击率,发型推荐里用户点进详情不代表满意,要看“点击后停留时长”和“收藏率”,这两个指标能更真实反映发型和用户的匹配度。实验至少跑两周,覆盖一个完整周末,因为周末用户活跃形态和工作日有显著差异。
最后是特征缓存的版本化问题。模型迭代时,新旧版本的嵌入向量不在同一语义空间,直接把新旧数据混在一起做 KNN 会让结果乱掉。推荐做法是给每个版本的模型分配一个版本号,缓存 key 写成emb:{user_hash}:model_v{version}这样的结构。比如model_v3上传后,先把离线发型库的向量全部重新提取一遍,存入新 key;线上请求进来后,首先检查当前用户有没有新版本的缓存,没有就现场推理并回填。这样任何时刻线上只消费同一版本的向量,旧版本缓存自然淘汰,也不会因为模型热更新影响到正在进行的 A/B 实验。
本文还有配套的精品资源,点击获取