
简介面向具备一定Python与图像处理基础、希望动手实践人像美化算法的开发者与学习者这套基于Python的人工智能美颜系统可用于理解从人脸关键点检测到肤色增强、面部特征优化的实现路径。压缩包共10个文件、约68.95MB包含Python主程序与界面脚本、启动批处理、编译缓存、人脸68点特征模型数据、界面描述文件及说明文档等覆盖程序运行、桌面交互与模型数据几类要素。已有4942人学习下载。读者可结合关键点数据与图像处理逻辑研究预处理、模型推理和后处理的衔接方式并借助界面文件了解桌面端交互如何组织说明文档与配置脚本也有助于梳理环境搭建和二次开发思路适合作为课程设计、毕业项目或算法入门的实践参考。1. 一张自拍的三秒钟Python 人工智能美颜系统到底在算什么上个月有个做证件照工具的朋友找我说他的批量脚本被用户吐槽「磨得跟塑料模特一样」。他原来用固定半径高斯模糊再叠一层锐化肤色深一点的照片就直接糊成一片。后来我们把流程换成一套 Python 写的人工智能美颜系统先用轻量检测模型定位人脸和 68 个关键点再分区域做磨皮、美白、瘦脸、大眼每个环节独立给强度。同一张原图参数从「自然」拉到「精修」出来完全是两个东西。这套东西适合两类人。一类是想搞清楚美颜滤镜到底在像素上做了什么的学生和初级开发核心代码不到一千行每个算子都能单独跑出来看对比图另一类是要把美颜能力塞进自己后台服务、又不愿意按调用量付费的团队算法层和接口层是分开的换个壳就能用。下面按我实际拆解的顺序走先看架构和目录再逐个算子看代码然后是跑起来的命令和接口最后讲几个让成品率掉一半的坑。2. 架构与目录一次请求在检测、关键点、像素处理之间怎么流转2.1 从上传到出图的六段流水线整条链路是串行的六段任何一段出错都会让后面全废所以每段都留了可单独调用的入口方便定位问题。第一段是解码与方向修正把上传的字节流变成 BGR 的 ndarray同时按 EXIF 把手机竖拍的照片转正。第二段是人脸检测拿到一个或多个人脸框。第三段是关键点回归在每个框里回归 68 个坐标点。第四段是几何形变瘦脸和大眼都在这一步因为它们会做像素重采样。第五段是皮肤处理磨皮和美白按掩膜生效。第六段是编码输出压成 JPEG 或 PNG 返回。顺序不能随便换。我一开始把磨皮放在形变前面结果瘦脸做完被拉伸区域的磨皮效果被重采样打散了出现一条明暗不均的过渡带。正确顺序是先形变后磨皮形变产生的插值痕迹正好被后面的磨皮顺手压掉反过来做磨皮区域的一致性会被破坏。这条血泪经验写在pipeline.py的注释里。# core/pipeline.py import numpy as np from core.detector import FaceLandmarkDetector from core.smoother import skin_mask, smooth_skin from core.whitener import whiten_skin from core.warp import slim_face, enlarge_eyes class BeautyPipeline: def __init__(self, detector: FaceLandmarkDetector, cfg: dict): self.detector detector self.cfg cfg def run(self, bgr: np.ndarray) - np.ndarray: faces self.detector.detect(bgr) if not faces: return bgr # 没检到脸就原样返回不硬处理 out bgr.copy() p self.cfg for f in faces: pts f[landmarks] # 1) 几何形变重采样会留下插值痕迹交给后面的磨皮收尾 out slim_face(out, pts, p[slim_strength]) out enlarge_eyes(out, pts, p[eye_scale]) # 2) 皮肤处理掩膜必须用「形变后」的关键点重算否则位置对不上 mask skin_mask(out, pts) out smooth_skin(out, mask, p[smooth_strength]) out whiten_skin(out, mask, p[whiten_strength]) return outcfg是一个普通字典从 YAML 读进来。slim_strength取 0 到 1eye_scale取 1.0 到 1.25超过这个范围出来的就是外星人。注意skin_mask用的是形变后重新算的关键点这是个容易忽略的细节如果复用形变前的点瘦脸做完掩膜会偏出脸颊边缘磨皮就会在脸外面糊一块。2.2 目录结构与模块职责项目不大但模块边界划得很清楚算法层完全不依赖 Web 框架这样命令行和接口能复用同一套代码。ai-beauty/ ├── app/ │ ├── api.py # FastAPI 路由层只做参数校验和编解码 │ └── schemas.py # 请求/响应模型 ├── core/ │ ├── detector.py # YuNet 检测 68 点回归 │ ├── smoother.py # 皮肤掩膜 高频分离磨皮 │ ├── whitener.py # LAB 空间提亮 │ ├── warp.py # 局部平移 / 局部缩放 │ ├── pipeline.py # 六段流水线的串联 │ └── io_utils.py # 带 EXIF 修正的读写 ├── configs/ │ └── default.yaml # 三档预设自然 / 精修 / 证件照 ├── models/ │ ├── face_detection_yunet.onnx │ └── shape_predictor_68_face_landmarks.dat ├── tests/ │ ├── assets/ # 十张覆盖不同肤色、光照、侧脸的回归图 │ └── test_operators.py ├── cli.py ├── requirements.txt └── README.mdmodels/目录里的权重不进版本库只留一个说明文件写清文件名和放置路径。tests/assets那十张图是整个项目最值钱的部分后面讲二次开发还会用到。core/里任何模块都不许import fastapi这条约束保证了算法能被单独拿到脚本里跑。2.3 检测器选型YuNet 加 68 点的组合以及依赖怎么装检测这块有两条常见路线。一条是 dlib 的 HOG 检测器加 68 点预测器另一条是 OpenCV DNN 里的 YuNet 加 dlib 的 68 点。我选后者原因是 dlib 自带的 HOG 检测器在侧脸和小脸上召回率一般而 YuNet 是 ONNX 模型cv2.FaceDetectorYN直接加载CPU 上单张 1080P 图大概 10 毫秒还带 5 个五官关键点能顺手做个人脸对齐。关键点为什么用 68 点而不是 468 点的网格模型因为形变控制点不是越多越好。瘦脸只需要下颌轮廓的第 0 到 16 号点大眼只需要眼睛的 36 到 45 号点68 点刚好覆盖这些语义明确的部位。468 点虽然密但控制点一多参数就没法直观调改一个强度值要重算上百个约束调试成本反而高。依赖装起来主要是一个坑dlib 需要本地编译。python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # opencv-contrib 才有 ximgproc后面导向滤波要用 pip install opencv-contrib-python numpy pillow pyyaml pip install fastapi[all] uvicorn python-multipart pip install dlib # 需要 cmake 和 C 工具链装不上看第 5 章opencv-contrib-python和opencv-python不能同时装两个包都提供cv2模块混装会出现导入随机指向某一个的玄学问题卸载干净再装一个就行。模型文件按 README 里的文件名放到models/下YuNet 是 ONNX 格式dlib 的 68 点是 dat 格式两个加起来不到 100MB。3. 核心算法拆解磨皮、美白、瘦脸、大眼四个算子的代码实现3.1 皮肤掩膜颜色阈值和关键点凸包为什么必须一起用所有皮肤处理都必须先有掩膜否则头发、眉毛、背景墙会一起被磨掉。掩膜有两种取法单独用都不行。只用颜色阈值用 YCrCb 空间卡一个肤色范围问题是很吃光照暖色调的墙纸、木色桌面、偏黄的灯光都会漏进来实测一张室内照片能有 30% 的背景被误判成皮肤。只用关键点凸包问题是在凸包内部的非皮肤区域也会被算进去眉毛、嘴唇、鼻孔、眼镜框全在里面磨完眉毛会糊掉一半。所以我的做法是两者取交集再用形态学补一下边缘# core/smoother.py import cv2 import numpy as np def skin_mask(bgr: np.ndarray, pts: np.ndarray) - np.ndarray: 返回 uint8 掩膜255 表示可处理区域 h, w bgr.shape[:2] # 1) 颜色通道YCrCb 对光照变化的鲁棒性比 HSV 好 ycrcb cv2.cvtColor(bgr, cv2.COLOR_BGR2YCrCb) lower np.array([0, 133, 77], np.uint8) upper np.array([255, 173, 127], np.uint8) color cv2.inRange(ycrcb, lower, upper) # 2) 几何通道68 点里取脸部轮廓 额头做凸包 hull_pts np.vstack([pts[0:17], pts[17:27]]).astype(np.int32) geo np.zeros((h, w), np.uint8) cv2.fillConvexPoly(geo, cv2.convexHull(hull_pts), 255) # 3) 交集后再开运算去掉零碎噪点 mask cv2.bitwise_and(color, geo) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))) # 4) 羽化避免处理区边缘出现硬接缝 return cv2.GaussianBlur(mask, (0, 0), 7)lower和upper的 Cr、Cb 范围是常用经验值Cr 管红绿倾向Cb 管蓝黄倾向卡太窄会漏掉偏黑或偏白的肤色卡太宽会把橙色衣服拉进来。第 0 到 16 号是脸部外轮廓17 到 26 号是眉毛把眉毛一起圈进凸包是为了让额头也覆盖到不然额头会明显比脸颊暗一档。最后那步高斯核大小传(0, 0)表示让 OpenCV 按 sigma 自动算核尺寸7是 sigma值越大过渡越软。提示掩膜算完可以先存成图看一眼直接把 mask 叠到原图上三五秒就能判断阈值卡得对不对比反复调磨皮参数高效得多。3.2 磨皮高频分离保住纹理直接上高斯模糊或者双边滤波最大的问题是「磨过了」毛孔没了皮肤纹理也没了只剩一片均匀的色块。解决办法是高频分离把图像拆成低频和高频两层低频压噪高频保留纹理最后按强度回掺。def smooth_skin(bgr: np.ndarray, mask: np.ndarray, strength: float 0.6, d: int 15, sigma_color: float 75, sigma_space: float 75) - np.ndarray: # 1) 低频层双边滤波在压掉毛孔的同时保住五官边缘 low cv2.bilateralFilter(bgr, d, sigma_color, sigma_space) # 2) 高频层 原图 - 低频就是纹理和瑕疵 high cv2.subtract(bgr.astype(np.int16), low.astype(np.int16)) # 3) 按强度回掺高频strength1 不磨strength0 全糊 merged low.astype(np.int16) (high * (1.0 - strength)).astype(np.int16) merged np.clip(merged, 0, 255).astype(np.uint8) # 4) 只在掩膜内生效掩膜已经羽化过直接线性混合 m (mask.astype(np.float32) / 255.0)[..., None] return np.clip(bgr * (1 - m) merged * m, 0, 255).astype(np.uint8)d是双边滤波的邻域直径15 对应 1080P 图上大约 15 像素的作用范围图像越大这个值要跟着调我一般取长边的 1.5%。sigma_color控制颜色差异多大的像素才参与加权取大了会把眼睛和皮肤混在一起sigma_space控制空间衰减取大了边缘会糊。这两个值 75 是个稳妥的起点。strength是唯一暴露给用户的参数取值 0 到 1。0.6 大概是「看得出皮肤变好但不像修图」的程度0.85 以上适合证件照1.0 表示完全不磨用来做 A/B 对比。第 3 步那个回掺是整套磨皮的核心减掉高频就是磨皮加回一部分就是保留质感多少由 strength 决定逻辑上非常干净。如果觉得双边滤波速度不够可以换cv2.ximgproc.guidedFilter导向滤波是 O(N) 的在大图上快三到五倍。代价是要装 contrib 包而且参数语义不一样radius和eps得重新调。3.3 美白LAB 空间只提 L 通道美白最容易翻车的地方是在 BGR 或 RGB 里直接加常数加完整张图偏色脸发灰或者发红。正确做法是转到 LAB 空间只动亮度通道 L保留 a、b 两个色度通道。# core/whitener.py def whiten_skin(bgr: np.ndarray, mask: np.ndarray, strength: float 0.3, gamma: float 1.25) - np.ndarray: lab cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) l, a, b cv2.split(lab) # 1) 构造 gamma 查找表gamma1 表示提亮 lut np.array([((i / 255.0) ** (1.0 / gamma)) * 255 for i in range(256)], dtypenp.uint8) l_new cv2.LUT(l, lut) # 2) 按 strength 把提亮后的 L 和原始 L 混合 l_blend cv2.addWeighted(l, 1.0 - strength, l_new, strength, 0) # 3) a/b 通道原样保留避免整体偏色 out cv2.cvtColor(cv2.merge([l_blend, a, b]), cv2.COLOR_LAB2BGR) m (mask.astype(np.float32) / 255.0)[..., None] return np.clip(bgr * (1 - m) out * m, 0, 255).astype(np.uint8)gamma取 1.25 大约相当于提亮半档1.5 会明显发白。strength控制混合比例0.3 是比较克制的值。用 LUT 而不是逐像素算幂函数是因为cv2.LUT是查表256 个值算一次就行比 numpy 的**快一个数量级这在批量处理时差距很明显。只提 L 通道有个副作用人脸会显得有点灰缺血色。想再自然一点可以在 a 通道加一点点正值偏红或者在 b 通道加一点点正值偏黄幅度控制在 1 到 3 之间再多就假了。这个微调在whiten_skin里留了个可选参数默认不开。3.4 瘦脸与大眼remap 局部变形的正确方向瘦脸和大眼本质上是同一类操作在局部做像素位移。OpenCV 里用cv2.remap实现但它有个反直觉的地方——map_x和map_y记录的是「目标图的每个像素去源图的哪个位置取色」不是「源图的像素搬到目标图的哪里」。方向搞反了脸会往两边鼓而不是往中间收。先看局部平移这是瘦脸的基础# core/warp.py def local_translate(img: np.ndarray, center: tuple, radius: float, delta: np.ndarray) - np.ndarray: 把 center 周围 radius 内的内容沿 delta 方向整体推移 h, w img.shape[:2] yy, xx np.mgrid[0:h, 0:w].astype(np.float32) dist np.sqrt((xx - center[0]) ** 2 (yy - center[1]) ** 2) # 权重从圆心 1 衰减到半径处 0平方衰减让边缘过渡更自然 weight np.clip(1.0 - dist / radius, 0, 1) ** 2 # 目标像素取源图「反方向偏移」的位置这才是内容朝 delta 移动 map_x xx - weight * delta[0] map_y yy - weight * delta[1] return cv2.remap(img, map_x, map_y, cv2.INTER_LINEAR, borderModecv2.BORDER_REFLECT)center是要推动的部位坐标radius是影响范围delta是位移向量像素。weight那行的平方是关键线性衰减在边界处一阶导数不连续肉眼能看到一圈折痕平方衰减把切线拉平了过渡就看不出来。基于这个基础函数瘦脸就是沿着下颌线取六个点每个点朝面部中轴方向推一小段def slim_face(img: np.ndarray, pts: np.ndarray, strength: float 0.5) - np.ndarray: if strength 0: return img out img.copy() face_w float(pts[16][0] - pts[0][0]) # 左右脸轮廓的水平距离 if face_w 1: return out axis_x float((pts[0][0] pts[16][0]) / 2.0) # 面部中轴近似 max_shift face_w * 0.025 * strength # 位移上限约为脸宽的 2.5% for idx in (3, 5, 7, 9, 11, 13): px, py float(pts[idx][0]), float(pts[idx][1]) dist abs(axis_x - px) if dist face_w * 0.15: # 太靠中轴的点不参与避免拉扯嘴部 continue direction 1.0 if px axis_x else -1.0 delta np.array([direction * max_shift, 0.0], np.float32) out local_translate(out, (px, py), dist * 1.2, delta) return out选第 3、5、7 和 9、11、13 号点是左脸和右脸各三个下颌点跳过第 8 号下巴尖。radius取dist * 1.2的意思是影响范围略大于该点到中轴的距离这样中轴附近权重已经接近 0不会把鼻子和嘴带歪。max_shift用脸宽做归一化不管输入是 300 像素的小图还是 4000 像素的高清原图效果一致这是个很重要的尺度不变性设计。大眼用的是局部缩放数学上是把采样坐标朝中心收拢def local_scale(img: np.ndarray, center: tuple, radius: float, scale: float) - np.ndarray: radius 内按 scale 放大scale1 表示放大 h, w img.shape[:2] yy, xx np.mgrid[0:h, 0:w].astype(np.float32) dx, dy xx - center[0], yy - center[1] dist np.sqrt(dx * dx dy * dy) t np.clip(dist / radius, 0, 1) # 中心 factorscale边缘 factor1二次过渡 factor 1.0 (scale - 1.0) * (1.0 - t) ** 2 map_x center[0] dx / factor map_y center[1] dy / factor return cv2.remap(img, map_x, map_y, cv2.INTER_LINEAR, borderModecv2.BORDER_REFLECT) def enlarge_eyes(img: np.ndarray, pts: np.ndarray, scale: float 1.15) - np.ndarray: if scale 1.0: return img out img.copy() for a, b in ((36, 39), (42, 45)): # 左右眼的外眼角和内眼角 p1, p2 pts[a], pts[b] center ((p1[0] p2[0]) / 2.0, (p1[1] p2[1]) / 2.0) eye_w float(np.linalg.norm(p2 - p1)) out local_scale(out, center, eye_w * 1.1, scale) return outfactor的构造要注意方向map_x cx dx / factor当scale 1.15时中心区域factor是 1.15dx被除以 1.15 变小采样点向中心靠拢源图内容就被拉伸放大。我第一次写成了乘法结果眼睛越调越小对着公式推了半小时才发现。scale上限给到 1.25再大眼白和瞳孔的比例就失真了出来是鱼眼效果。半径取眼宽的 1.1 倍刚好把眼皮和眼袋包进去不至于带动眉毛。4. 跑起来与服务化命令行、YAML 预设和 FastAPI 接口4.1 三条命令跑通第一次环境装好、模型放好之后先用命令行验证算法链路别一上来就起服务出了问题分不清是算法错还是接口错。# 单张图用默认的「自然」预设 python cli.py --input demo.jpg --output out.jpg # 手动指定四个强度参数做参数扫描 python cli.py --input demo.jpg --output out_strict.jpg \ --smooth 0.85 --whiten 0.4 --slim 0.8 --eye 1.2 # 整个目录批量跑 python cli.py --input ./raw/ --output ./done/ --preset id_photo --workers 4--smooth控制磨皮强度0 到 1--whiten控制美白0 到 1--slim控制瘦脸0 到 1--eye是放大倍数1.0 表示不动。--preset会覆盖单参数指向configs/default.yaml里的某个预设。--workers是进程数因为 OpenCV 的大部分算子会释放 GIL但 Python 层的循环不会用多进程比多线程实在。cli.py本身很短二十来行负责解析参数、调用BeautyPipeline、写出文件。真正的逻辑都在core/里这也意味着你可以直接把cli.py删掉换成别的入口算法层不用动。# cli.py 的核心部分 import argparse, yaml from core.io_utils import read_image, write_image from core.detector import FaceLandmarkDetector from core.pipeline import BeautyPipeline def main(): ap argparse.ArgumentParser() ap.add_argument(--input, requiredTrue) ap.add_argument(--output, requiredTrue) ap.add_argument(--preset, defaultnatural) ap.add_argument(--smooth, typefloat, defaultNone) ap.add_argument(--whiten, typefloat, defaultNone) ap.add_argument(--slim, typefloat, defaultNone) ap.add_argument(--eye, typefloat, defaultNone) args ap.parse_args() with open(configs/default.yaml, r, encodingutf-8) as f: cfg yaml.safe_load(f)[args.preset] # 命令行参数优先级高于预设None 表示没传保留预设值 for k in (smooth, whiten, slim, eye): v getattr(args, k) if v is not None: cfg[f{k}_strength if k ! eye else eye_scale] v det FaceLandmarkDetector(models/face_detection_yunet.onnx, models/shape_predictor_68_face_landmarks.dat) pipe BeautyPipeline(det, cfg) write_image(args.output, pipe.run(read_image(args.input))) if __name__ __main__: main()参数合并这块的逻辑是「命令行覆盖预设」用None作为「没传」的标记。如果把default0.6直接写在add_argument里就无法区分「用户显式传了 0.6」和「用户没传用默认值」预设功能就废了这是个很常见的设计失误。4.2 配置项强度参数怎么调才不假预设用 YAML 存三个档位的差别不在算法只在参数# configs/default.yaml natural: # 日常社交图看不出修过 smooth_strength: 0.55 whiten_strength: 0.25 slim_strength: 0.45 eye_scale: 1.10 retouch: # 精修效果明显但要求五官还认得出 smooth_strength: 0.85 whiten_strength: 0.45 slim_strength: 0.75 eye_scale: 1.18 id_photo: # 证件照只磨皮美白不动几何形状 smooth_strength: 0.90 whiten_strength: 0.35 slim_strength: 0.0 eye_scale: 1.0证件照那一档把slim_strength和eye_scale归零因为证件照改了脸型会被人脸比对系统判定为不一致这个坑我踩过一次客户投诉说上传后认证失败。真实场景里形变类参数永远是最需要谨慎的磨皮美白只是观感问题瘦脸大眼可能影响业务合规。调参有个经验顺序先定smooth_strength它决定了整体质感再调whiten_strength它影响肤色但不影响结构最后才碰slim_strength和eye_scale因为形变会改变人脸的几何特征改完得重新看磨皮效果是否还合适。反过来调很容易在形变做完之后发现磨皮过度又得全部推倒重来。4.3 封装 HTTP 接口与并发处理接口层只做三件事收图、调 pipeline、返回图。算法对象是进程级的不能每次请求都重新加载模型一个 YuNet 加一个 dlib 预测器启动要一两秒每次都加载服务直接废掉。# app/api.py from functools import lru_cache from fastapi import FastAPI, File, UploadFile, Form from fastapi.responses import Response import cv2, numpy as np from core.detector import FaceLandmarkDetector from core.pipeline import BeautyPipeline from core.io_utils import decode_with_exif app FastAPI() lru_cache(maxsize1) def get_pipeline() - BeautyPipeline: det FaceLandmarkDetector(models/face_detection_yunet.onnx, models/shape_predictor_68_face_landmarks.dat) return BeautyPipeline(det, { smooth_strength: 0.55, whiten_strength: 0.25, slim_strength: 0.45, eye_scale: 1.10, }) app.post(/beauty) async def beauty(file: UploadFile File(...), smooth: float Form(0.55), whiten: float Form(0.25)): raw await file.read() bgr decode_with_exif(raw) if bgr is None: return Response(status_code400, contentbdecode failed) h, w bgr.shape[:2] if max(h, w) 1600: # 长边限到 1600避免大图拖垮并发 s 1600.0 / max(h, w) bgr cv2.resize(bgr, (int(w * s), int(h * s)), interpolationcv2.INTER_AREA) pipe get_pipeline() pipe.cfg[smooth_strength] max(0.0, min(1.0, smooth)) pipe.cfg[whiten_strength] max(0.0, min(1.0, whiten)) out pipe.run(bgr) ok, buf cv2.imencode(.jpg, out, [int(cv2.IMWRITE_JPEG_ p a hrefhttps://download.csdn.net/download/m0_38106923/11253733 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p