0 前言
在相机美颜中,磨皮 是最基础也是最重要的功能之一。
从用户的角度来看,磨皮只是让皮肤更加细腻、去除毛孔和痘印,但对于开发者来说,它实际上属于图像平滑(Image Smoothing) 和 噪声去除(Denoising) 的问题。
我们可能会想到下面这些实现方式:
- 高斯滤波(Gaussian Filter)
- 均值滤波(Mean Filter)
这些算法虽然能够降低噪声,但是都会带来一个共同的问题:
边缘也一起被模糊了
因此一个真正能够用于磨皮的算法至少需要满足两个要求:
- 去除皮肤细小纹理
- 保留五官轮廓
而双边滤波(Bilateral Filter),正是目前各种磨皮算法的重要基础。
它最大的特点就是:
既能平滑皮肤,又能保留边缘
1 磨皮算法的本质
在理解双边滤波之前,需要先了解图像中的不同频率信息。一张数字图像可以表示为一个二维函数:
其中:
- 表示像素横坐标;
- 表示像素纵坐标;
- 表示该位置对应的像素颜色值。
从信号处理角度来看,一张图像可以看作低频信息和高频信息组成:
低频信息主要描述图像整体变化:
- 人脸整体颜色;
- 光照变化;
- 五官的大致结构。
而高频信息主要描述图像中的细节变化:
- 毛孔;
- 皱纹;
- 痘印;
- 图像噪声。
因此一张人脸图像可以近似表示为:
磨皮的目标并不是简单删除所有高频信息,而是:
去除皮肤区域中的无效高频纹理,同时保留人脸边缘产生的有效高频信息。
例如皮肤上的毛孔属于不需要保留的信息,但是眼睛边缘、嘴唇轮廓同样属于高频信息,却必须保留。 这也是传统模糊算法无法直接应用于美颜磨皮场景的原因。
2 从高斯滤波到双边滤波
2.1 高斯滤波存在的问题
在介绍双边滤波之前,我们先看一下传统高斯滤波。高斯滤波的核心思想是:
距离当前像素越近的像素,对当前像素的影响越大
二维高斯函数公式如下:
其中:
- 表示高斯函数标准差;
- 越大,平滑范围越大;
- 表示像素距离中心点的位置。
在进行图像处理时,对于当前像素 p,它的新颜色通常由周围像素 q 加权平均得到,高斯滤波结果可以表示为:
其中:
- 表示滤波后的像素值;
- 表示邻域像素;
- 表示空间距离权重。
简单来说就是当前像素的新颜色 = 周围像素颜色 × 距离权重,但是这里存在一个问题,高斯滤波只关注两个像素之间距离是否足够近,它并不知道这两个像素颜色是否属于同一个区域。
例如在人脸区域中,眼睛边缘和皮肤可能只相差几个像素。对于高斯滤波来说:它们距离很近,因此应该互相影响。最终结果就是眼睛边缘被皮肤颜色稀释。所以高斯滤波能够平滑图像,但是无法做到边缘保护。
2.2 双边滤波的核心思想
双边滤波针对高斯滤波的问题进行了改进,它认为一个像素是否应该影响当前像素,不应该只由空间距离决定,还应该考虑颜色差异。因此双边滤波引入两个权重:
空间权重
空间权重描述两个像素的位置关系,距离越近影响越大:
其中:
- 表示当前像素位置;
- 表示邻域像素位置;
- 控制空间影响范围。
简单来说就是当两个像素之间的距离越远,越大,也就越趋近于0,也就是权重趋近于0。
颜色权重
空间权重解决了距离问题,但是还不能保护边缘,因此双边滤波增加颜色权重。颜色权重表示两个像素颜色越接近,它们之间影响越大:
其中:
- 表示当前像素颜色;
- 表示邻域像素颜色;
- 控制颜色敏感程度。
如果两个像素颜色非常接近,约等于0,那么约等于1,说明两个像素之间可以相互平滑。但如果颜色差异很大约等于0的话,说明两个像素不属于同一个区域不应该互相影响。
权重结合
结合空间权重和颜色权重之后,双边滤波的权重由空间权重和颜色权重共同决定:
最终双边滤波输出像素为:
这个公式就是双边滤波的核心,相比高斯滤波的只考虑空间问题,双边滤波双重权重可以更好的实现磨皮的效果。因此在皮肤区域颜色相近 → 权重大 → 平滑,在人脸边缘颜色差异大 → 权重小 → 保留,这就是双边滤波能够实现自然磨皮的根本原因。
3 Objective-C实现双边滤波
3.1 前置说明
在 iOS 中,如果实现一个基础版本的双边滤波,通常需要经历以下流程:
首先,需要将 UIImage 转换成 Bitmap 数据。因为双边滤波最终操作的是像素数据,而不是 UIImage 对象。UIImage 本身只是图片资源的封装,它并不直接保存连续的 RGBA 像素。转换之后每个像素可以表示为:
R G B A
8bit 8bit 8bit 8bit
之后遍历每一个像素,计算它周围窗口内所有像素的权重。例如窗口大小意味着当前像素需要访问个邻居,对于每一个邻居首先计算空间距离:
然后计算颜色距离:
其中:
- 分别表示三个颜色通道。
最后计算最终的权重值,根据权重累加颜色即可。
3.2 代码实现
下面通过一个 CPU 版本的双边滤波,实现完整的图片磨皮流程。完整处理过程包括:
- 修正
UIImage的方向; - 将图片转换为连续的 RGBA 像素;
- 预计算空间权重和颜色权重;
- 对每个像素执行双边滤波;
- 使用肤色遮罩限制磨皮区域;
- 根据强度混合原图和滤波结果;
- 将处理后的像素重新生成
UIImage。
定义滤波接口
首先创建 SGLBilateralFilter 类,对外提供统一的磨皮接口:
@interface SGLBilateralFilter : NSObject
/// 基于双边滤波实现磨皮
///
/// @param image 输入图片
/// @param radius 滤波半径,推荐 2~5
/// @param sigmaSpace 空间标准差,推荐 2~6
/// @param sigmaColor 颜色标准差,推荐 15~50
/// @param intensity 磨皮强度,范围 0~1
/// @param skinOnly 是否只对检测到的肤色区域磨皮
+ (nullable UIImage *)beautifyImage:(UIImage *)image
radius:(NSInteger)radius
sigmaSpace:(CGFloat)sigmaSpace
sigmaColor:(CGFloat)sigmaColor
intensity:(CGFloat)intensity
skinOnly:(BOOL)skinOnly;
@end
几个参数分别控制不同的效果:
radius:采样窗口半径。radius = 4时,窗口大小为 ;sigmaSpace:控制空间距离对权重的影响;sigmaColor:控制算法对颜色差异的敏感程度;intensity:控制最终磨皮结果与原图的混合比例;skinOnly:是否只对可能属于皮肤的像素应用磨皮。
参数保护与图片方向修正
实现滤波之前,先对输入参数进行限制:
radius = MAX(1, radius);
sigmaSpace = MAX(0.001, sigmaSpace);
sigmaColor = MAX(0.001, sigmaColor);
intensity = MIN(MAX(intensity, 0.0), 1.0);
这里还需要注意 UIImage.imageOrientation。它只记录图片显示时的旋转方向,CGImage 中的原始像素不一定已经旋转到正确方向。
因此,在读取像素之前,需要把方向真正绘制到图片中:
+ (UIImage *)normalizedImage:(UIImage *)image {
if (image.imageOrientation == UIImageOrientationUp) {
return image;
}
UIGraphicsBeginImageContextWithOptions(image.size,
NO,
image.scale);
[image drawInRect:CGRectMake(0,
0,
image.size.width,
image.size.height)];
UIImage *normalizedImage =
UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return normalizedImage ?: image;
}
调用时先获取方向修正后的 CGImage:
UIImage *normalizedImage = [self normalizedImage:image];
CGImageRef sourceImageRef = normalizedImage.CGImage;
size_t width = CGImageGetWidth(sourceImageRef);
size_t height = CGImageGetHeight(sourceImageRef);
将 UIImage 转换成 RGBA 像素
双边滤波操作的是具体像素,而不是 UIImage 对象,因此需要创建连续的像素缓冲区:
const size_t bytesPerPixel = 4;
const size_t bitsPerComponent = 8;
const size_t bytesPerRow = width * bytesPerPixel;
const size_t bufferSize = bytesPerRow * height;
uint8_t *inputPixels = calloc(bufferSize, sizeof(uint8_t));
uint8_t *outputPixels = calloc(bufferSize, sizeof(uint8_t));
创建 Bitmap Context,并把图片绘制到输入缓冲区:
CGColorSpaceRef colorSpace =
CGColorSpaceCreateDeviceRGB();
CGBitmapInfo bitmapInfo =
kCGBitmapByteOrder32Big |
kCGImageAlphaPremultipliedLast;
CGContextRef inputContext =
CGBitmapContextCreate(inputPixels,
width,
height,
bitsPerComponent,
bytesPerRow,
colorSpace,
bitmapInfo);
CGContextDrawImage(inputContext,
CGRectMake(0, 0, width, height),
sourceImageRef);
CGContextRelease(inputContext);
完成绘制后,可以通过偏移量读取任意像素:
size_t offset =
y * bytesPerRow +
x * bytesPerPixel;
uint8_t red = inputPixels[offset];
uint8_t green = inputPixels[offset + 1];
uint8_t blue = inputPixels[offset + 2];
uint8_t alpha = inputPixels[offset + 3];
预计算空间权重
对于相同的 radius 和 sigmaSpace,每个像素邻域中相同位置的空间权重都是一样的,因此不需要在像素循环中重复计算。
空间权重公式为:
对应代码如下:
NSInteger diameter = radius * 2 + 1;
NSInteger kernelCount = diameter * diameter;
float *spatialWeights =
malloc(sizeof(float) * kernelCount);
CGFloat sigmaSpaceSquared =
sigmaSpace * sigmaSpace;
NSInteger weightIndex = 0;
for (NSInteger offsetY = -radius;
offsetY <= radius;
offsetY++) {
for (NSInteger offsetX = -radius;
offsetX <= radius;
offsetX++) {
CGFloat distanceSquared =
offsetX * offsetX +
offsetY * offsetY;
CGFloat exponent =
-distanceSquared /
(2.0 * sigmaSpaceSquared);
spatialWeights[weightIndex] =
expf((float)exponent);
weightIndex++;
}
}
这样在处理每个像素时,可以直接通过索引取得空间权重。
预计算颜色权重查找表
RGB 三个通道的最大颜色距离平方为:
如果在每一次邻域采样时都调用 exp,计算成本会非常高。因此可以按照颜色距离平方建立查找表:
const NSInteger maxColorDistanceSquared =
3 * 255 * 255;
float *rangeWeights =
malloc(sizeof(float) *
(maxColorDistanceSquared + 1));
CGFloat sigmaColorSquared =
sigmaColor * sigmaColor;
for (NSInteger distance = 0;
distance <= maxColorDistanceSquared;
distance++) {
CGFloat exponent =
-distance /
(2.0 * sigmaColorSquared);
rangeWeights[distance] =
expf((float)exponent);
}
后续只需要计算颜色距离平方,就能直接读取对应权重:
NSInteger deltaR = sampleR - centerR;
NSInteger deltaG = sampleG - centerG;
NSInteger deltaB = sampleB - centerB;
NSInteger colorDistanceSquared =
deltaR * deltaR +
deltaG * deltaG +
deltaB * deltaB;
CGFloat rangeWeight =
rangeWeights[colorDistanceSquared];
这里不需要计算颜色距离的平方根,因为公式最终使用的本身就是距离平方。
遍历像素并计算双边滤波结果
图片中的每一行会写入不同的输出区域,因此可以通过 dispatch_apply 并行处理:
dispatch_queue_t processingQueue =
dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);
dispatch_apply(height,
processingQueue,
^(size_t y) {
for (size_t x = 0; x < width; x++) {
// 处理当前像素
}
});
读取中心像素:
size_t centerOffset =
y * bytesPerRow +
x * bytesPerPixel;
uint8_t centerR = inputPixels[centerOffset];
uint8_t centerG = inputPixels[centerOffset + 1];
uint8_t centerB = inputPixels[centerOffset + 2];
uint8_t centerA = inputPixels[centerOffset + 3];
然后遍历邻域窗口:
CGFloat redSum = 0.0;
CGFloat greenSum = 0.0;
CGFloat blueSum = 0.0;
CGFloat totalWeight = 0.0;
NSInteger kernelIndex = 0;
for (NSInteger offsetY = -radius;
offsetY <= radius;
offsetY++) {
NSInteger sampleY =
(NSInteger)y + offsetY;
sampleY =
MAX(0,
MIN((NSInteger)height - 1,
sampleY));
for (NSInteger offsetX = -radius;
offsetX <= radius;
offsetX++) {
NSInteger sampleX =
(NSInteger)x + offsetX;
sampleX =
MAX(0,
MIN((NSInteger)width - 1,
sampleX));
size_t sampleOffset =
sampleY * bytesPerRow +
sampleX * bytesPerPixel;
uint8_t sampleR =
inputPixels[sampleOffset];
uint8_t sampleG =
inputPixels[sampleOffset + 1];
uint8_t sampleB =
inputPixels[sampleOffset + 2];
NSInteger deltaR =
(NSInteger)sampleR -
(NSInteger)centerR;
NSInteger deltaG =
(NSInteger)sampleG -
(NSInteger)centerG;
NSInteger deltaB =
(NSInteger)sampleB -
(NSInteger)centerB;
NSInteger colorDistanceSquared =
deltaR * deltaR +
deltaG * deltaG +
deltaB * deltaB;
CGFloat spatialWeight =
spatialWeights[kernelIndex];
CGFloat rangeWeight =
rangeWeights[colorDistanceSquared];
CGFloat finalWeight =
spatialWeight * rangeWeight;
redSum += sampleR * finalWeight;
greenSum += sampleG * finalWeight;
blueSum += sampleB * finalWeight;
totalWeight += finalWeight;
kernelIndex++;
}
}
图像边缘可能出现负坐标或超出图片尺寸,因此这里使用 clamp,将越界坐标限制到最接近的边缘像素。
完成邻域遍历后,根据总权重计算滤波颜色:
CGFloat bilateralR = centerR;
CGFloat bilateralG = centerG;
CGFloat bilateralB = centerB;
if (totalWeight > 0.000001) {
bilateralR = redSum / totalWeight;
bilateralG = greenSum / totalWeight;
bilateralB = blueSum / totalWeight;
}
这段代码对应的就是双边滤波公式:
根据强度混合原图
如果直接使用双边滤波结果,磨皮效果可能过强。因此增加 intensity 参数,在原图和滤波图之间进行线性插值:
对应代码如下:
CGFloat outputR =
centerR * (1.0 - intensity) +
bilateralR * intensity;
CGFloat outputG =
centerG * (1.0 - intensity) +
bilateralG * intensity;
CGFloat outputB =
centerB * (1.0 - intensity) +
bilateralB * intensity;
当 intensity = 0 时,完全使用原图;当 intensity = 1 时,完全使用双边滤波结果。
最后将结果写入输出缓冲区:
outputPixels[centerOffset] =
[self clampToByte:outputR];
outputPixels[centerOffset + 1] =
[self clampToByte:outputG];
outputPixels[centerOffset + 2] =
[self clampToByte:outputB];
outputPixels[centerOffset + 3] =
centerA;
其中颜色值需要限制在 0~255:
+ (uint8_t)clampToByte:(CGFloat)value {
value = MIN(MAX(value, 0.0), 255.0);
return (uint8_t)lrint(value);
}
基础双边滤波会处理整张图片。虽然它能够保护明显边缘,但仍然可能平滑头发、衣服和背景纹理。
为了让磨皮更接近实际美颜效果,可以先判断当前像素是否可能属于肤色。
将 RGB 转换为 YCbCr
相比直接使用 RGB,肤色在 YCbCr 色彩空间中的 Cb 和 Cr 通道通常更加集中:
CGFloat cb =
128.0
- 0.168736 * r
- 0.331264 * g
+ 0.500000 * b;
CGFloat cr =
128.0
+ 0.500000 * r
- 0.418688 * g
- 0.081312 * b;
然后结合 RGB 与 YCbCr 经验规则进行初步判断:
CGFloat maximum = MAX(r, MAX(g, b));
CGFloat minimum = MIN(r, MIN(g, b));
BOOL rgbRule =
r > 80.0 &&
r > g &&
r > b &&
maximum - minimum > 10.0;
BOOL yCbCrRule =
cb >= 77.0 &&
cb <= 135.0 &&
cr >= 125.0 &&
cr <= 180.0;
if (!rgbRule || !yCbCrRule) {
return 0.0;
}
这里并不是在做人脸识别,只是通过颜色规则排除明显不属于皮肤的像素。因此,它仍然可能把与肤色接近的背景识别为皮肤。
使用软遮罩
如果肤色判断只返回 0 或 1,区域边界容易出现突变。因此当前实现根据像素与肤色中心的距离生成软遮罩:
CGFloat cbCenter = 105.0;
CGFloat crCenter = 150.0;
CGFloat normalizedCbDistance =
fabs(cb - cbCenter) / 45.0;
CGFloat normalizedCrDistance =
fabs(cr - crCenter) / 50.0;
CGFloat distance =
sqrt(normalizedCbDistance * normalizedCbDistance +
normalizedCrDistance * normalizedCrDistance);
distance = MIN(distance, 1.0);
CGFloat mask = 1.0 - distance;
mask = 0.35 + mask * 0.65;
return MIN(MAX(mask, 0.0), 1.0);
最终磨皮强度由用户设置的强度和肤色遮罩共同决定:
CGFloat skinMask = 1.0;
if (skinOnly) {
skinMask =
[self skinMaskForRed:centerR
green:centerG
blue:centerB];
}
CGFloat finalIntensity =
intensity * skinMask;
再使用 finalIntensity 混合原图和滤波结果:
CGFloat outputR =
centerR * (1.0 - finalIntensity) +
bilateralR * finalIntensity;
CGFloat outputG =
centerG * (1.0 - finalIntensity) +
bilateralG * finalIntensity;
CGFloat outputB =
centerB * (1.0 - finalIntensity) +
bilateralB * finalIntensity;
这样,非肤色区域的 skinMask 为 0,会完整保留原图;越符合肤色特征的像素,应用的磨皮强度越高。
将像素重新生成 UIImage
滤波结束后,通过输出像素缓冲区创建新的 Bitmap Context:
CGContextRef outputContext =
CGBitmapContextCreate(outputPixels,
width,
height,
bitsPerComponent,
bytesPerRow,
colorSpace,
bitmapInfo);
CGImageRef outputImageRef =
CGBitmapContextCreateImage(outputContext);
UIImage *resultImage =
[UIImage imageWithCGImage:outputImageRef
scale:image.scale
orientation:UIImageOrientationUp];
处理完成后,需要释放手动申请的内存和 Core Graphics 对象:
CGImageRelease(outputImageRef);
CGContextRelease(outputContext);
free(spatialWeights);
free(rangeWeights);
CGColorSpaceRelease(colorSpace);
free(inputPixels);
free(outputPixels);
如果遗漏这些释放操作,每处理一次图片都会残留一部分内存。
在 Demo 中调用
双边滤波需要遍历图片中的每个像素,并为每个像素访问一个邻域窗口。
假设图片尺寸为 ,窗口半径为 ,它的时间复杂度大约为:
因此不能在主线程直接执行,否则会阻塞界面。
dispatch_async(
dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0),
^{
CFTimeInterval startTime =
CACurrentMediaTime();
UIImage *resultImage =
[SGLBilateralFilter beautifyImage:inputImage
radius:4
sigmaSpace:4.0
sigmaColor:30.0
intensity:intensity
skinOnly:YES];
CFTimeInterval duration =
CACurrentMediaTime() - startTime;
NSLog(@"双边滤波耗时:%.3f 秒", duration);
dispatch_async(dispatch_get_main_queue(), ^{
[self.indicatorView stopAnimating];
self.processButton.enabled = YES;
self.intensitySlider.enabled = YES;
if (resultImage) {
self.imageView.image = resultImage;
}
});
});
Demo 中使用的参数为:
radius = 4;
sigmaSpace = 4.0;
sigmaColor = 30.0;
skinOnly = YES;
同时通过滑块动态设置 intensity:
- (void)sliderValueChanged:(UISlider *)slider {
self.intensityLabel.text =
[NSString stringWithFormat:@"磨皮强度:%.2f",
slider.value];
}
对于尺寸较大的照片,可以先将最大边缩放到 1000 像素左右,再执行 CPU 滤波:
CGFloat currentMaximumSide = MAX(width, height);
if (currentMaximumSide > maximumSide) {
CGFloat scale =
maximumSide / currentMaximumSide;
CGSize targetSize =
CGSizeMake(floor(width * scale),
floor(height * scale));
UIGraphicsBeginImageContextWithOptions(targetSize,
NO,
1.0);
[image drawInRect:CGRectMake(0,
0,
targetSize.width,
targetSize.height)];
UIImage *result =
UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
}
需要注意,这仍然只是便于理解双边滤波原理的 CPU Demo。实时相机美颜磨皮通常需要使用 Metal、OpenGL ES Shader处理或 Core Image 在 GPU 上实现,否则很难稳定达到实时帧率。
3.3 效果
磨皮前:
磨皮后:
总结
双边滤波与普通模糊算法最大的区别,是它同时考虑了像素之间的空间距离和颜色差异。颜色相近的邻域像素会参与平滑,而颜色差异较大的像素权重较低,因此可以在弱化毛孔、痘印和噪声的同时,尽量保留眼睛、鼻翼和嘴唇等轮廓。
本文基于 Objective-C 完成了一个基础的 CPU 磨皮实现,包括 RGBA 像素转换、权重预计算、并行处理、肤色检测、强度混合以及图片输出。这个版本适合学习算法原理和验证参数效果,但计算量较大,不适合直接用于实时相机预览。
在实际项目中,可以进一步使用 Metal、Core Image 或 OpenGL ES Shader 将滤波过程迁移到 GPU,并结合人脸关键点、皮肤分割和高低频分离等技术,提升磨皮的准确性、自然度与实时性能。