ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenCV美颜算法实战:人脸关键点检测与美白瘦脸扩眼实现

OpenCV美颜算法实战:人脸关键点检测与美白瘦脸扩眼实现 简介这套OpenCV人脸美颜算法实践资料适合图像处理开发者和算法学习者。内容围绕美白、扩眼、瘦脸三大核心功能同时涵盖人脸检测、关键点定位、肤色检测、磨皮、大眼缩放、下巴调整、瘦鼻等完整处理工序。配套的OpenCV contrib依赖包可解决扩展功能编译问题作者实测效果良好代码与配置均可直接复用。资料包共含八十五个文件总体积约520MB主要类型包括十五份PDF课程讲义、十一个C工程源码、CMake编译配置、YAML与XML格式的模型特征文件以及任务笔记、JSON配置和可执行程序等从理论讲解到运行环境均有覆盖。目前已有九十六人学习下载。学习者既能通过讲义逐步理解各算法原理又能打开OpenCVBeauty目录下按章节组织的工程直接阅读或修改源码配合opencv411_contrib压缩包快速搭好开发环境将美颜技术应用到真实照片处理中。对有项目落地需求的开发者而言这是一份难得的参考资料。1. 先把话说明白这是一份把 OpenCV 美颜算法跑通的可复现资源做图像处理的人十有八九被问过能不能给我做一个一键美颜OpenCV 算法这条路最容易被低估。大家总觉得美颜是深度学习的事要上 GAN、要上人脸生成模型但实际上用 OpenCV 自带的滤波、变形、关键点检测三件套美白、扩眼、瘦脸都能做到肉眼可见的有效果而且单帧处理速度能压进 50 毫秒以内。这份资源的核心价值就是把三个传统算法和一个配套可用的 OpenCV contrib 包放在一起已经联调通过不是概念演示而是能直接跑出效果图的完整代码。适合两类人一类是做实时美颜工具、想快速出 demo 约谈需求的产品型开发者另一类是刚入门图像处理、想理解人脸局部形变和肤色调整原理的学生。接下来我按思路 → 实现 → 踩坑的顺序把每个算法拆开讲。2. 从选型到落地为什么是 OpenCV 3.4.6 搭配 contrib以及人脸关键点选哪家2.1 先定版本再谈算法次级版本间的 API 差异是天坑这份资源里最让我省心的一个决定就是把 OpenCV 锁死在 3.4.6 这个版本上而不是随手装个最新版。很多人不看版本直接用 pip install opencv-python结果后面读 Facemark 模型的时候才发现编译参数对不上contrib 模块的名空间也不一样。伤害不大但非常磨心态。为什么选 3.4.6因为 opencv-contrib-python 3.4.6 是最后一个能用普通 pip 安装方式获得完整 contrib 模块插件的版本而它带的 xfeatures2d 模块里的 SIFT、SURF 这些函数还对外开放。升级到 4.x 之后虽然也能装 contrib但部分专利算法要单独编译Facemark 这类模块的使用方式也做了调整而你手上的教程、网上的博客大多还是 3.4.x 的写法。落地时我一般这样搭环境提示Python 版本建议 3.6 或 3.7过新的解释器反而会出现 dlib 编译失败或 numpy 版本不兼容。# 创建虚拟环境避免污染全局 conda create -n beauty python3.7 -y conda activate beauty # 严格按顺序安装 pip install opencv-python3.4.6.27 pip install opencv-contrib-python3.4.6.27 pip install numpy1.17.4 pip install dlib19.19.0这里做了三个额外的限制都是从实操里换来的教训。第一opencv-python 和 opencv-contrib-python 两个包要装同一个小版本号不能一个 3.4.6 一个 3.4.9因为这两个包会互相覆盖同名文件版本不齐会导致模块加载时报出 has no attribute 这种伪错误。第二numpy 不能大于 1.19因为新版 numpy 对 flann 索引的数据类型校验更严格在用 SIFT 或者人脸对齐做特征匹配的时候会报断言错误。第三dlib 固定用 CPU 版因为 GPU 版要单独编译 CUDA 支持而我们的美颜算法瓶颈根本不在人脸关键点检测上CPU 的模型推理已经完全够用。2.2 关键点选择的两种路线dlib 与 Facemark各自的边界条件扩眼和瘦脸的本质都是大圆半径形变而形变的核心控制点来自眼睛轮廓和脸部轮廓的关键点。所以需要实现的是人脸特征点的定位。目前常见的实现路线有两条。第一路线是 dlib 的 68 点模型。资源里附带了这个模型的训练文件使用方式固定import dlib # 加载预训练模型文件在资源包的 models/ 目录下 predictor_path models/shape_predictor_68_face_landmarks.dat detector dlib.get_frontal_face_detector() predictor dlib.shape_predictor(predictor_path) def get_landmarks(image_gray): faces detector(image_gray, 0) if len(faces) 0: return None # 取最大人脸 face max(faces, keylambda r: r.width() * r.height()) shape predictor(image_gray, face) # 坐标点转为 numpy 数组格式方便后续矩阵运算 points [] for i in range(68): points.append((shape.part(i).x, shape.part(i).y)) return np.array(points, dtypenp.float32)这段代码的逻辑值得展开细说。detector 是正脸检测器它的人脸框是计算形变区域时的重要参考尺度。关键点数组的坐标是相对整张图的绝对坐标不是相对人脸框的归一化坐标这决定后面计算形变半径时要用人脸框宽度而不是原始图宽度做归一化。参数0是 upsampling 次数意思是检测前对原始图像做 0 次上采样。如果你处理的输入图很小比如手机截图那种 720 宽的小图建议改成 1能显著提升小脸召回率代价是每帧多花 3 到 5 毫秒。第二条路线是 OpenCV contrib 里的 Facemark 模块。它和 dlib 最大的差别是Facemark 是按眼睛中心、鼻尖、嘴角这些抽象语义点组织输出的更加细节完整。但是它有个致命问题训练极其痛苦。要先用公开数据集训自己的模型或者拿现成 LBF 模型来跑而一旦人脸姿态旋转超过 30 度检测质量下降很快。所以我最终选择 dlib 作为主方案Facemark 作为备选。如果你是做视频流美颜强烈建议直接上 dlib 而不是去啃 Facemark 的训练流程少走弯路。3. 美白算法从 YCrCb 空间分离亮度到肤色保护3.1 为什么在 YCrCb 空间操作而不是 RGB美白最容易翻车的地方是整图提亮提完之后头发、眉毛、瞳孔全部发灰观感像蒙了一层雾。正路是只对皮肤区域操作而皮肤区域在 RGB 空间里很难精准分割因为不同人种的肤色在 R、G、B 三个通道上的阈值区间有重叠且不线性。换到 YCrCb 空间之后Y 通道是纯粹的亮度信息Cr 和 Cb 是色度信息肤色在 Cr 和 Cb 上的聚类非常集中业内经验范围是 133173 和 77127这个范围做掩膜已经相对可靠。实现分三步走。第一步做肤色掩膜第二步对亮度通道做提亮和保细节的双边滤波第三步把原始色度信息融合回去。具体代码与逻辑说明如下import cv2 import numpy as np def skin_whiten(img, intensity20): # 1. 转换到 YCrCb 空间 img_ycrcb cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) y, cr, cb cv2.split(img_ycrcb) # 2. 根据 Cr 和 Cb 范围生成肤色掩膜 skin_mask cv2.inRange(cr, 133, 173) skin_mask_b cv2.inRange(cb, 77, 127) skin_mask cv2.bitwise_and(skin_mask, skin_mask_b) # 3. 形态学开闭运算清除掩膜噪点 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) skin_mask cv2.morphologyEx(skin_mask, cv2.MORPH_CLOSE, kernel) skin_mask cv2.morphologyEx(skin_mask, cv2.MORPH_OPEN, kernel) # 4. 对 Y 通道做保边提亮 y_blur cv2.bilateralFilter(y, d15, sigmaColor80, sigmaSpace80) y_enhance cv2.addWeighted(y, 1.0, y_blur, -0.2, intensity) # 5. 合回 YCrCb 并转回 BGR merged cv2.merge([y_enhance, cr, cb]) result cv2.cvtColor(merged, cv2.COLOR_YCrCb2BGR) # 6. 用掩膜做软过渡避免肤色区和背景之间出现硬边界 skin_mask cv2.GaussianBlur(skin_mask, (0, 0), 15).astype(np.float32) / 255.0 result (result.astype(np.float32) - img.astype(np.float32)) * skin_mask[..., None] result (img.astype(np.float32) result).astype(np.uint8) return result这段代码里最值得琢磨的是第 4 步的addWeighted参数。y是原亮度y_blur是双边滤波后的亮度负的权重 -0.2 意味着我们在用原图减去一部分模糊图这个操作提取出了亮度里的高频细节把它们叠加回原图之后皮肤纹理不会被磨平而是保持质感。如果你把 -0.2 改成 0就变成纯提亮效果像贴了一层塑料。intensity控制提亮强度一般 15 到 30 之间表现自然超过 40 会开始丢失高光区域的细节。3.2 掩膜的三种翻车形态眼眶发白、嘴唇发灰、肤色不均掩膜并不能一劳永逸地解决所有问题实际测试资源包里的 sample 图片时我观察到三种必须处理的瑕疵。第一是眼眶周围发白原因是 Cr/Cb 阈值把眼睛下方的暗影区也算进了肤色区提亮时顺带把卧蚕和微血管的层次感抹掉了。解决方案是做完掩膜后用关键点 36 到 41 围成的左眼区域以及 42 到 47 围成的右眼区域从掩膜里减去一个固定的椭圆区域。第二是嘴唇发灰因为嘴唇的 Cr 值通常超过 173本来就不该被纳入肤色掩膜但由于边缘过渡一部分嘴唇边界被腐蚀掉了。常见的做法是把掩膜下采样再上采样让边界收缩 5 个像素左右。第三是肤色不均出现一块一块的色斑。这是形态学操作的 kernel 尺寸太小导致的当你发现斑块有规律地分布在额头和脸颊时把 kernel 从 5x5 改成 11x11。这部分的最终测试结论是固定阈值能覆盖 80% 的常规自拍场景但是当照片里有大面积红色背景墙时Cr 通道的阈值会把它误判为皮肤区域。在不引入深度学习分割的前提下一个有效的补救方案是先做人脸检测只取人脸框向外扩大 30% 的区域作为 ROI再在这个 ROI 内做肤色阈值基本能避开背景干扰。4. 瘦脸与扩眼的局部形变圆半径扭曲的实现细节4.1 逐像素圆半径形变原理从坐标映射到插值瘦脸和扩眼虽然效果完全不同但底层算法同源都是以某个控制点为中心在给定半径范围内做局部坐标拉伸或收缩。实现上不是把像素抠出来搬动而是反向计算目标图中每个像素在原图中对应哪个位置的像素再取颜色值。这个反向映射的思路很关键能保证输出图像中没有空洞。用瘦脸来举例子目标是让下巴两侧的轮廓点向中心收拢。它的坐标变换公式分三段控制点内部的像素向控制点方向移动控制点到边界之间做衰减函数边界以外的像素保持原坐标不动。经典的代码写法如下def local_translation_warp(src_img, start_pt, end_pt, radius): 局部平移变形可用于瘦脸或扩眼 start_pt: 控制点起点脸部轮廓点 end_pt: 控制点终点轮廓点向中心移动后的位置 radius: 影响半径 h, w src_img.shape[:2] map_x np.zeros((h, w), dtypenp.float32) map_y np.zeros((h, w), dtypenp.float32) start_x, start_y start_pt end_x, end_y end_pt # 生成像素坐标网格 grid_y, grid_x np.mgrid[0:h, 0:w].astype(np.float32) # 计算每个像素到控制点的距离的平方 dist_sq (grid_x - start_x) ** 2 (grid_y - start_y) ** 2 # 原图坐标的初始值 map_x[:] grid_x map_y[:] grid_y # 在半径范围内的像素重新计算映射 mask dist_sq radius * radius dist np.sqrt(dist_sq[mask]) # 衰减因子从中心 1.0 衰减到边界 0.0 factor 1.0 - (dist / radius) ** 2 factor factor * factor # 二次衰减过渡更柔 # 位移向量 dx (end_x - start_x) * factor dy (end_y - start_y) * factor map_x[mask] grid_x[mask] dx map_y[mask] grid_y[mask] dy # 用映射矩阵重采样 warped cv2.remap(src_img, map_x, map_y, interpolationcv2.INTER_LINEAR, borderModecv2.BORDER_REFLECT) return warped公式里的二次衰减factor factor * factor是我调参后最满意的系数。如果只用一次衰减变形的边界会出现一个明显的圆圈过渡带用二次衰减后中心区域移动距离最大向外快速衰减到零视觉上更接近液化滤镜的渐变手感。radius的取值以关键点之间的距离为参考瘦脸时取左右下颌点间距的 1.5 倍左右扩眼时取眼睛高度的 3 倍左右取值不合理时会出现脸部零件互相错位的观感。4.2 关键点配对与流程编排瘦脸是多次局部变形的叠加知道了单个点的变形方式剩下的问题是对哪两个点变。dlib 给的脸部轮廓点是从 0 到 16其中 0 是左侧太阳穴8 是下巴尖16 是右侧太阳穴。瘦脸的实现策略是对这些点分组分组思路是把靠近下巴的两侧轮廓点分别向中心线方向移动固定比例。先看分组和移动比例的代码# 轮廓点左右各取 3 个关键点做瘦脸 # 右侧点 4(40%)、点 5(30%)、点 6(20%) 向左移动 # 左侧点 12(40%)、点 11(30%)、点 10(20%) 向右移动 def thin_face(img, landmarks): result img.copy() # 以眉心到下巴的距离做归一化基准 face_h abs(landmarks[8][1] - landmarks[27][1]) # 右侧轮廓向中间收目标点 中心线位置偏移 left_center landmarks[8][0] - int(face_h * 0.15) right_center landmarks[8][0] int(face_h * 0.15) for idx, ratio in zip([6, 5, 4], [0.2, 0.3, 0.4]): start_pt (landmarks[idx][0], landmarks[idx][1]) # 目标点位于出发点和中心点之间 end_pt (start_pt[0] int((right_center - start_pt[0]) * ratio), start_pt[1]) radius int(face_h * 0.32) result local_translation_warp(result, start_pt, end_pt, radius) for idx, ratio in zip([10, 11, 12], [0.2, 0.3, 0.4]): start_pt (landmarks[idx][0], landmarks[idx][1]) end_pt (start_pt[0] int((left_center - start_pt[0]) * ratio), start_pt[1]) radius int(face_h * 0.32) result local_translation_warp(result, start_pt, end_pt, radius) return result这段逻辑容易理解错的地方在于整张图共用了两只参数face_h是人脸纵向基准长度radius取它的固定倍数这保证了不同分辨率、不同人脸大小的照片都能得到一致的形变强度。但注意ratio 越大目标越靠近中心不过数值变化的节奏要均衡否则一边脸收缩过分另一边没变化。实际处理时我把左右脸分开做先右边再左边因为后一次 warp 会影响前一次处理过的区域顺序不对时下巴会歪。三个点的选择是有讲究的。0 到 3 是颧骨上方区域变形的视觉效果不明显而去掉太阳穴区域可以让整体脸型更自然。真正影响瘦观感的是下颌线也就是点 4 到 6 和点 10 到 12 所在的位置因为这条曲线的弧度决定了脸的宽窄。实际操作时我先把 6 个固定点以 0.15 的比例向中心移动然后检查变形后的下颌曲线是否与下巴尖点 8 平滑连接如果出现折角需要手动把点 8 也向中心移动 5% 左右。4.3 扩眼算法对称映射与眼部区域保护扩眼和瘦脸的差异在控制点方向。瘦脸是从外向内收缩扩眼是从眼外角向外拉伸。眼部的关键点编号是 36 到 47其中 36 是左眼外眼角39 是左眼内眼角42 是右眼外眼角45 是右眼内眼角。扩眼的正确方式是保持内眼角位置不变让外眼角向外移动这样眼睛的眼裂长度增加视觉效果最自然。如果反过来把眼睛整体向外移观感就是火星人。def enlarge_eyes(img, landmarks): result img.copy() # 左眼 left_eye_outer (landmarks[36][0], landmarks[36][1]) left_eye_inner (landmarks[39][0], landmarks[39][1]) # 眼睛高度参考点取上下眼睑中间位置 left_eye_h abs(landmarks[37][1] - landmarks[41][1]) left_move int(left_eye_h * 0.6) # 外眼角向外移动同时略微向下保持眼睛弧度 left_target (left_eye_outer[0] - left_move, left_eye_outer[1]) radius int(left_eye_h * 5) result local_translation_warp(result, left_eye_outer, left_target, radius) # 右眼对称处理 right_eye_outer (landmarks[42][0], landmarks[42][1]) right_eye_inner (landmarks[45][0], landmarks[45][1]) right_eye_h abs(landmarks[43][1] - landmarks[47][1]) right_move int(right_eye_h * 0.6) right_target (right_eye_outer[0] right_move, right_eye_outer[1]) radius int(right_eye_h * 5) result local_translation_warp(result, right_eye_outer, right_target, radius) return result参数radius取眼睛高度的 5 倍是比较安全的选择。它覆盖了眼角周围包括颧骨上缘的区域但避开了鼻梁中线避免左右眼变形区互相重叠造成鼻梁扭曲。left_move的移动量如果超过眼睛高度的 0.8 倍眼睛会变成梭子形因为宽度被拉大了但上下眼睑的弧度没有跟上。扩眼的二次细节是推挤时下眼睑区域容易产生褶皱因为下眼睑的皮肤纹理本来比较松。处理时我会对下眼睑的 3 个关键点40、41、46、47额外做一次轻微的上推 2 到 3 个像素抵消拉伸产生的下坠感。这里提一个血泪经验扩眼和瘦脸的顺序不要反过来。先瘦脸再扩眼因为瘦脸会改变眼角到下巴的相对位置如果先扩眼后续瘦脸时的距离计算会瞬间改变目标眼角位置导致眼睛被二次拉拽。先瘦脸相当于地基先打好扩眼时只需要在已固定的人脸轮廓上微调局部整体更稳。5. 常见问题与避坑记录三份真实翻车案例整个资源除了算法代码还附带了我整理的 opencv contrib 包和 model 文件里面除了前面提到的模型文件外还有三份真实运行时的现场记录这里挑出三条最典型的问题完整展示现象、原因与解决方式。案例一程序跑起来之后控制台报错AttributeError: module cv2.cv2 has no attribute face。原因排查结果是 opencv-contrib-python 与 opencv-python 的版本不兼容contrib 模块没有正确覆盖基础包。解决方法是先pip uninstall opencv-python opencv-contrib-python把两个包都卸干净再按前面的版本顺序重装装完后运行cv2.__version__确认输出 3.4.6。这个校验很关键千万不要加一个face cv2.face的测试代码就以为装好了要实际调用一次cv2.face.LBPHFaceRecognizer_create()才算验证通过。案例二dlib 模型加载报错RuntimeError: Unable to open shape_predictor_68_face_landmarks.dat。检查发现文件放在了压缩包内的子目录实际工作路径不对。解决方式是把 model 文件放到项目根目录的 models/ 文件夹下并在入口函数顶部写一个路径存在性检查如果os.path.exists(predictor_path)为 False直接抛出带完整路径的提示字符串。否则在部署到别的环境时这个报错会消耗很多时间去确认文件到底在不在。案例三扩眼之后人眼出现边缘断层。原因是cv2.remap的borderMode用了默认的BORDER_CONSTANT生成的是黑色填充像素。现象是眼睛外部轮廓线上有明显一圈黑线尤其在眼角延展方向。解决方式是把borderMode参数改成cv2.BORDER_REFLECT并在 remap 后加一片与脸部肤色相近的中性值。这个问题用肉眼在单张图上不容易发现但一旦跑视频流黑边帧浮现异常会非常显眼。解决方式是把 borderMode 改成 BORDER_REFLECT同时边缘往内收缩一个像素的范围再做变形。案例四与性能相关对 1080p 图片做全图像素循环处理单帧耗时高达 800 毫秒。原因是我最初把local_translation_warp里的两层 for 循环用于整张图坐标计算消耗了大量时间。解决方式是把循环改成向量化操作也就是用 numpy 的广播生成网格再通过布尔掩膜做批处理。改完之后单帧耗时降到 45 毫秒性能提升接近 17 倍。这是整个资源里优化收益最明显的一次调整。# 性能优化前的逐像素循环版留档对比 for y in range(h): for x in range(w): dx x - start_x dy y - start_y if dx*dx dy*dy radius*radius: dist math.sqrt(dx*dx dy*dy) factor (1.0 - (dist / radius) ** 2) ** 2 map_x[y, x] x (end_x - start_x) * factor map_y[y, x] y (end_y - start_y) * factor另外一个隐蔽的坑是 dlib 的人脸检测器对侧脸的鲁棒性。如果测试图像是偏侧脸姿态关键点自动检测失败返回空数组程序直接崩溃。解决方式是检测到的人脸框宽度小于图像宽度的 0.2 时自动切到 OpenCV 自带的级联检测器备选并用 Opencv 的CascadeClassifier返回的人脸框手动切出脸部区域后再调关键点预测器。这个方法不能保证精确匹配侧脸的全部 68 点但至少能保证下巴尖和眼角这两个核心点可用勉强支撑起形变逻辑。6. 进阶验证交叉验证三种指标让美颜效果客制化落地资源包里的 demo 默认参数是针对亚洲女性脸型调的换一批数据后盲目跑默认参数会出现过度变形。落地经验是每跑一批新图片都要做一次固定的验证流程通过三组指标来反推参数是否合理。第一组是几何指标。瘦脸和扩眼本质上改变了关键点之间的欧氏距离。我用脚本把变形前后的关键点距离矩阵导出成 CSV然后对比三组距离两眼外眼角到内眼角的距离、左右下颌点间距、下巴尖到眉心的距离。正常情况下瘦脸后下颌点间距缩小的比例应该在 4% 到 10% 之间超出 12% 人眼就能感觉不自然。扩眼后眼裂长度增加比例应控制在 10% 到 15% 之间超过 20% 时眼睛会变形。这个比例就是调整 ratio 参数的依据。第二组是肤色连续性指标。美白后的皮肤区域应该保留纹理细节不能成为油漆质感。做法是对处理后图像的肤色 ROI 区域做拉普拉斯方差计算数值在 10 到 30 之间说明纹理适中低于 5 说明磨皮过度需要调小intensity或者把双边滤波的 sigmaSpace 从 80 降到 50。第三组是时间稳定性指标。把单张图片测试扩展到 30 秒的视频流逐帧记录处理耗时并计算相邻帧之间关键点抖动幅度。如果抖动幅度超过 2 个像素说明参数里的衰减函数太陡形变区域边缘在相邻帧之间跳变。处理方法是把 radius 数值调大 10%让衰减过程更缓。最后养成一个固定习惯每次把参数调完我会把源代码和资源包里的示例输出一起放到一个名为 output_compare 的文件夹里面同时保留处理前的原图和处理后的效果图并在文件名上注明当日日期和使用的参数快照。这套做法让我能够在后续回查时快速判断某项参数调整是否真的提升了效果而不是依靠短时记忆猜来猜去。希望这份 OpenCV 美颜资源能让你在传统图像处理这条路上少踩几个洞把该踩的坑变成自己工具箱里的经验。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进