基于Bilateral Filter双边滤波算法实现人脸磨皮效果

0 阅读3分钟

0 前言

在相机美颜中,磨皮 是最基础也是最重要的功能之一。

从用户的角度来看,磨皮只是让皮肤更加细腻、去除毛孔和痘印,但对于开发者来说,它实际上属于图像平滑(Image Smoothing)噪声去除(Denoising) 的问题。

我们可能会想到下面这些实现方式:

  • 高斯滤波(Gaussian Filter)
  • 均值滤波(Mean Filter)

这些算法虽然能够降低噪声,但是都会带来一个共同的问题:

边缘也一起被模糊了

因此一个真正能够用于磨皮的算法至少需要满足两个要求:

  • 去除皮肤细小纹理
  • 保留五官轮廓

双边滤波(Bilateral Filter),正是目前各种磨皮算法的重要基础。

它最大的特点就是:

既能平滑皮肤,又能保留边缘

1 磨皮算法的本质

在理解双边滤波之前,需要先了解图像中的不同频率信息。一张数字图像可以表示为一个二维函数:

I(x,y)I(x,y)

其中:

  • xx 表示像素横坐标;
  • yy 表示像素纵坐标;
  • II 表示该位置对应的像素颜色值。

从信号处理角度来看,一张图像可以看作低频信息和高频信息组成:

低频信息主要描述图像整体变化:

  • 人脸整体颜色;
  • 光照变化;
  • 五官的大致结构。

而高频信息主要描述图像中的细节变化:

  • 毛孔;
  • 皱纹;
  • 痘印;
  • 图像噪声。

因此一张人脸图像可以近似表示为:

Image=LowFrequency+HighFrequencyImage = LowFrequency + HighFrequency

磨皮的目标并不是简单删除所有高频信息,而是:

去除皮肤区域中的无效高频纹理,同时保留人脸边缘产生的有效高频信息。

例如皮肤上的毛孔属于不需要保留的信息,但是眼睛边缘、嘴唇轮廓同样属于高频信息,却必须保留。 这也是传统模糊算法无法直接应用于美颜磨皮场景的原因。


2 从高斯滤波到双边滤波

2.1 高斯滤波存在的问题

在介绍双边滤波之前,我们先看一下传统高斯滤波。高斯滤波的核心思想是:

距离当前像素越近的像素,对当前像素的影响越大

二维高斯函数公式如下:

G(x,y)=12πσ2ex2+y22σ2G(x,y) = \frac{1}{2\pi\sigma^2} e^{-\frac{x^2+y^2}{2\sigma^2}}

其中:

  • σ\sigma 表示高斯函数标准差;
  • σ\sigma 越大,平滑范围越大;
  • x,yx,y 表示像素距离中心点的位置。

在进行图像处理时,对于当前像素 p,它的新颜色通常由周围像素 q 加权平均得到,高斯滤波结果可以表示为:

I(p)=qG(pq)I(q)I'(p) = \sum_q G(p-q)I(q)

其中:

  • I(p)I'(p) 表示滤波后的像素值;
  • I(q)I(q) 表示邻域像素;
  • G(pq)G(p-q) 表示空间距离权重。

简单来说就是当前像素的新颜色 = 周围像素颜色 × 距离权重,但是这里存在一个问题,高斯滤波只关注两个像素之间距离是否足够近,它并不知道这两个像素颜色是否属于同一个区域。

例如在人脸区域中,眼睛边缘和皮肤可能只相差几个像素。对于高斯滤波来说:它们距离很近,因此应该互相影响。最终结果就是眼睛边缘被皮肤颜色稀释。所以高斯滤波能够平滑图像,但是无法做到边缘保护。

2.2 双边滤波的核心思想

双边滤波针对高斯滤波的问题进行了改进,它认为一个像素是否应该影响当前像素,不应该只由空间距离决定,还应该考虑颜色差异。因此双边滤波引入两个权重:

空间权重

空间权重描述两个像素的位置关系,距离越近影响越大

Gs(p,q)=epq22σs2G_s(p,q) = e^{-\frac{||p-q||^2}{2\sigma_s^2}}

其中:

  • pp 表示当前像素位置;
  • qq 表示邻域像素位置;
  • σs\sigma_s 控制空间影响范围。

简单来说就是当两个像素之间的距离越远,pq|p-q|越大,Gs(p,q)G_s(p,q)也就越趋近于0,也就是权重趋近于0。

颜色权重

空间权重解决了距离问题,但是还不能保护边缘,因此双边滤波增加颜色权重。颜色权重表示两个像素颜色越接近,它们之间影响越大

Gr(I(p),I(q))=eI(p)I(q)22σr2G_r(I(p),I(q)) = e^{-\frac{||I(p)-I(q)||^2}{2\sigma_r^2}}

其中:

  • I(p)I(p) 表示当前像素颜色;
  • I(q)I(q) 表示邻域像素颜色;
  • σr\sigma_r 控制颜色敏感程度。

如果两个像素颜色非常接近,I(p)I(q)2||I(p)-I(q)||^2约等于0,那么GrG_r约等于1,说明两个像素之间可以相互平滑。但如果颜色差异很大GrG_r约等于0的话,说明两个像素不属于同一个区域不应该互相影响。

权重结合

结合空间权重和颜色权重之后,双边滤波的权重由空间权重和颜色权重共同决定:

W(p,q)=Gs(p,q)×Gr(I(p),I(q))W(p,q) = G_s(p,q) \times G_r(I(p),I(q))

最终双边滤波输出像素为:

I(p)=qSGs(p,q)Gr(I(p),I(q))I(q)qSGs(p,q)Gr(I(p),I(q))I'(p) = \frac{ \sum_{q\in S} G_s(p,q) G_r(I(p),I(q)) I(q) } { \sum_{q\in S} G_s(p,q) G_r(I(p),I(q)) }

这个公式就是双边滤波的核心,相比高斯滤波的Weight=GsWeight=G_s只考虑空间问题,双边滤波双重权重Weight=Gs×GrWeight=G_s \times G_r可以更好的实现磨皮的效果。因此在皮肤区域颜色相近 → 权重大 → 平滑,在人脸边缘颜色差异大 → 权重小 → 保留,这就是双边滤波能够实现自然磨皮的根本原因。

3 Objective-C实现双边滤波

3.1 前置说明

在 iOS 中,如果实现一个基础版本的双边滤波,通常需要经历以下流程:

首先,需要将 UIImage 转换成 Bitmap 数据。因为双边滤波最终操作的是像素数据,而不是 UIImage 对象。UIImage 本身只是图片资源的封装,它并不直接保存连续的 RGBA 像素。转换之后每个像素可以表示为:

R G B A

8bit  8bit  8bit  8bit

之后遍历每一个像素,计算它周围窗口内所有像素的权重。例如窗口大小5×55 \times 5意味着当前像素需要访问2525个邻居,对于每一个邻居首先计算空间距离:

Ds=(x1x2)2+(y1y2)2D_s = \sqrt{ (x_1-x_2)^2 + (y_1-y_2)^2 }

然后计算颜色距离:

Dr=(R1R2)2+(G1G2)2+(B1B2)2D_r = \sqrt{ (R_1-R_2)^2 + (G_1-G_2)^2 + (B_1-B_2)^2 }

其中:

  • R,G,BR,G,B 分别表示三个颜色通道。

最后计算最终的权重值,根据权重累加颜色即可。

Weight=eDs22σs2×eDr22σr2Weight = e^{ - \frac{D_s^2}{2\sigma_s^2} } \times e^{ - \frac{D_r^2}{2\sigma_r^2} }

3.2 代码实现

下面通过一个 CPU 版本的双边滤波,实现完整的图片磨皮流程。完整处理过程包括:

  1. 修正 UIImage 的方向;
  2. 将图片转换为连续的 RGBA 像素;
  3. 预计算空间权重和颜色权重;
  4. 对每个像素执行双边滤波;
  5. 使用肤色遮罩限制磨皮区域;
  6. 根据强度混合原图和滤波结果;
  7. 将处理后的像素重新生成 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 时,窗口大小为 9×99 \times 9
  • 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,每个像素邻域中相同位置的空间权重都是一样的,因此不需要在像素循环中重复计算。

空间权重公式为:

Gs(p,q)=epq22σs2G_s(p,q) = e^{-\frac{||p-q||^2}{2\sigma_s^2}}

对应代码如下:

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 三个通道的最大颜色距离平方为:

2552+2552+2552=195075255^2+255^2+255^2=195075

如果在每一次邻域采样时都调用 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;
}

这段代码对应的就是双边滤波公式:

I(p)=qSW(p,q)I(q)qSW(p,q)I'(p) = \frac{ \sum_{q\in S}W(p,q)I(q) }{ \sum_{q\in S}W(p,q) }

根据强度混合原图

如果直接使用双边滤波结果,磨皮效果可能过强。因此增加 intensity 参数,在原图和滤波图之间进行线性插值:

Result=Original×(1Intensity)+Bilateral×IntensityResult = Original \times (1-Intensity) + Bilateral \times 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 中调用

双边滤波需要遍历图片中的每个像素,并为每个像素访问一个邻域窗口。

假设图片尺寸为 W×HW \times H,窗口半径为 rr,它的时间复杂度大约为:

O(W×H×(2r+1)2)O(W \times H \times (2r+1)^2)

因此不能在主线程直接执行,否则会阻塞界面。

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,并结合人脸关键点、皮肤分割和高低频分离等技术,提升磨皮的准确性、自然度与实时性能。