Metal基本图像处理实例

z_zombie· 2014-10-20
本文来自 metalbyexample ,作者 z_zombie

(在开始之前你可以阅读我们之前发布的两篇文章了解一下Metal:iOS 8 Metal Swift教程 :开始学习Metal Framework基础使用教程)

这篇文章探究了如何使用Metal来做图像处理。我们将构建一个包含多种图像滤波的框架,并且完成有关模糊与饱和度的两个滤波器,最后呈现的结果会是一个可以实时调整渲染参数的图像处理应用。

你也可以先下载一份初始工程

先睹为快

figure-38.png

(实例的UI,允许实时调节滤波器)

下面是工程中的一段代码。添加了饱和度和模糊滤波器的控制,就像上面那张UI图中所示那样:

context = [MBEContext newContext];
imageProvider = [MBEMainBundleTextureProvider textureProviderWithImageNamed:@"mandrill"
                                                                    context:context];
desaturateFilter = [MBESaturationAdjustmentFilter filterWithSaturationFactor:0.75
                                                                     context:context];
desaturateFilter.provider = imageProvider;
blurFilter = [MBEGaussianBlur2DFilter filterWithRadius:0.0
                                               context:context];
blurFilter.provider = desaturateFilter;
imageView.image = [UIImage imageWithMTLTexture:blurFilter.texture];

图像处理框架

在谈论五花八门的滤波器之前,我们需要为着色程序准备一个高效的渲染框架。每个滤波器都会根据其输入输出的贴图来配置各自的运算管线,然后执行核心功能。

贴图提供器(Providers)和编辑器(Consumers)

由于我们要以贴图的形式来处理图像,所以需要抽象的对象来生成和编辑贴图。比如说在需要对应用包里面的一张图片进行滤波的时候,我们就需要一个类来专门把这张图像转化为贴图。

这里使用MBETextureProvider协议来生成贴图:

@protocol MBETextureProvider @property (nonatomic, readonly) id texture;
@end

当编辑器想要操作贴图的时候,就需要从生成器请求一张贴图然后处理它。

图片滤波器基类

理论上,随便一个滤波器都能对贴图进行处理并生成新图,这里的MBEImageFilter类做了很多对着色程序的调用来实现图像处理。

图片滤波器基类遵守的是我们刚刚所提到的提供器和编辑器协议,于是我们可以将所有滤波效果叠加起来对图片逐一进行大量操作。图像处理的过程中由于存在同步限制,每一个滤波器处理图像之前都需等待前一个处理完毕。

下面是与MBEImageFilter类的接口的相关代码:

@interface MBEImageFilter : NSObject @property (nonatomic, strong) MBEContext *context;
@property (nonatomic, strong) id pipeline;
@property (nonatomic, strong) id internalTexture;
@property (nonatomic, assign, getter=isDirty) BOOL dirty;
- (instancetype)initWithFunctionName:(NSString *)functionName context:(MBEContext *)context;
- (void)configureArgumentTableWithCommandEncoder:(id)commandEncoder;

滤波器必须通过一个方法名还有一个MBEContext参数来完成实例化。其中pipeline属性所包含的内容被用于计算管线状态。

图像滤波器内部维护有一个贴图,当它的核心功能运行的时候会利用这张贴图来生成新图并输出。因此滤波器可以存储它的计算结果并将其传递给下一个滤波器,或者传递到显示屏,还可以转换为一张图片。

此外,滤波器的多个属性值会直接影响其计算结果,如果这些属性值改变,那么内置的贴图便会失效并且需要重新执行才能奏效。dirty标志值被其子类用于指明滤波是否可用,滤波的计算只会在dirty值为YES的时候才会被执行,这些都是由自定属性的设置在内部完成的。

图像滤波器基类里面包含了一个-applyFilter方法,调用时会根据当前的dirty值输出一个处理后的贴图。这一方法会创建指令缓冲和指令编码,然后才实现其功能。

现在万事俱备,接下来我们谈谈有关滤波器的事。

饱和度的控制

第一个滤波器用来调节图片的饱和度。饱和度范围的控制值是从0到1,当为0时,图片会被转换为灰度图,饱和值往0和1两端图片的颜色分别会变得越来黯淡或者亮丽,在这里用的方法就是对原图或是灰度图进行差值运算。

figure35.png

(饱和度从0到1持续递增的渲染效果)

RGB三原色灰度计算

RGB颜色中每个通道都有一个灰度值,其合成比例如下面的公式:

QQ截图20141020153843.png


你会发现在公式右边的每一项前的权值和为1,这是根据人眼对三原色的的不同敏感度决定的(想知道这些权值的具体推算过程可以看这里)。

降低饱和度的过程就是在原图的基础上把每一个像素的三色灰度值替换成原有值乘以相应比值之后的灰度值,这也是这饱和度滤波器的核心功能,我们在下面会提到。

着色程序

为了能够使饱和度滤波器的变化范围可用我们定义了一个 AdjustSaturationUniforms结构体,他只有一个成员saturationFactor:

struct AdjustSaturationUniforms
{
    float saturationFactor;
};

核心的函数部分涉及输入输出的贴图,一个结构体,整型的2D向量,以及一个我们从没见过的thread_position_in_grid。

thread_position_in_grid属性会告知Metal计算出我们在整个配置的工程中的2D坐标里的位置,也就是我们当前处于源贴图中坐标位置。

我们指定了贴图的接入方式,对于输入贴图来说只能用access::read,而对于输出贴图而言则是access::write,这边限制了这些参数的设置不会被更改。

kernel void adjust_saturation(texture2d inTexture [[texture(0)]],
                              texture2d outTexture [[texture(1)]],
                              constant AdjustSaturationUniforms &uniforms [[buffer(0)]],
                              uint2 gid [[thread_position_in_grid]])
{
    float4 inColor = inTexture.read(gid);
    float value = dot(inColor.rgb, float3(0.299, 0.587, 0.114));
    float4 grayColor(value, value, value, 1.0);
    float4 outColor = mix(grayColor, inColor, uniforms.saturationFactor);
    outTexture.write(outColor, gid);
}

这段代码读入了源贴图的颜色并按公式计算出灰度值,这里的dot方法使得我们不用重复计算三次乘法和两次加法来得出结果。接着不断重复这一过程后生成了新颜色,会使新图看上去会有一些灰。

如果想在输出的贴图中产生偏色效果,可以使用Metal标准库里的方法:mix,这一方法设计两个颜色和一个在0和1之间的值。如果该值为0,那么输出的只有第一种颜色,如果为0则是第二种,如果处于0和1中间,输出颜色就是两种颜色的混合。

最后我们在输出的贴图中写入新颜色。

饱和度调节基类

为了使用饱和度调节功能,我们需要扩展一下滤波器基类,下面的这个子类的名称叫做MBESaturationAdjustmentFilter:

@interface MBESaturationAdjustmentFilter : MBEImageFilter
@property (nonatomic, assign) float saturationFactor;
+ (instancetype)filterWithSaturationFactor:(float)saturation context:(MBEContext *)context;
@end

这个子类中的关于saturationFactor属性的设置会导致滤波器修改dirty的值,当有请求获取输出贴图的时候图片的处理会被再次执行。

子类中有关-configureArgumentTableWithCommandEncoder的实现

并没有贴出来,它的作用是将饱和度系数按制定格式拷贝进Metal缓冲区中。

这样我们就有一个完整的滤波器用来调节图片的饱和度了。

模糊

我们要看的下一个滤波器的功能是对图片进行模糊。

模糊涉及到混合每个像素与其周围像素的颜色值,从数学上说模糊滤波其实就是相邻像素灰度的加权平均数(也是卷积的一种)。相邻像素的范围被称作模糊半径,半径越小,参与运算的像素越少而模糊效果也越弱。

均值模糊

最简单的模糊就是均值模糊,均值模糊中所有相邻像素的权值都是相等的,也就是计算它们的算术平均数。虽然计算简单但是极为差劲,因为对于噪点来说均值模糊很不合适。

假如均值模糊的半径为1,那么其计算的就是它和相邻8个像素的算术平均数,每一个像素的权值就是1/9。

figure-36.png

(均值模糊的效果)

均值模糊虽然简单但是效果并不令人满意,其实我们还可以用另外一种更为细致的模糊算法来替换均值——高斯模糊。

高斯模糊

相比于均值模糊而言,高斯模糊就是为邻接像素附上不同的权值,越近的权值越大,越远的越小。事实上这些权值的分配时根据2D环境下正态分布的公式来推算的,公式如下:

QQ截图20141020154546.png

x和y值分别代表的是到X和Y轴的距离,换言之就是在两个反向上与被处理像素的距离。σ表示的是分布数据的标准差,默认值为模糊半径的二分之一。

figure-37.png

(高斯模糊效果)

模糊程序

要为一个高斯滤波去专门计算权值的话耗费会相当严重,尤其在模糊半径较大时更甚。正因如此,我们可以采用贴图的形式,在高斯模糊的核心功能里提供一张权值表。这张贴图的类型为单通道32位浮点型的MTLPixelFormatR32Float像素图。每一像素中有一个0到1之间的值,贴图中所有像素的权值加起来为1.

核心的部分就是迭代当前像素的每一个邻接像素,然后在权值表中找到其相应的权值。然后不断累加所有已经加权的颜色值,最后把所有加权颜色值的累加数写入到输出的贴图中,代码如下:

kernel void gaussian_blur_2d(texture2d inTexture [[texture(0)]],
                             texture2d outTexture [[texture(1)]],
                             texture2d weights [[texture(2)]],
                             uint2 gid [[thread_position_in_grid]])
{
    int size = blurKernel.get_width();
    int radius = size / 2;
 
    float4 accumColor(0, 0, 0, 0);
    for (int j = 0; j < size; ++j)
    {
        for (int i = 0; i < size; ++i)
        {
            uint2 kernelIndex(i, j);
            uint2 textureIndex(gid.x + (i - radius), gid.y + (j - radius));
            float4 color = inTexture.read(textureIndex).rgba;
            float4 weight = weights.read(kernelIndex).rrrr;
            accumColor += weight * color;
        }
    }
 
    outTexture.write(float4(accumColor.rgb, 1), gid);
}

滤波器类

高斯滤波器,MBEGaussianBlur2DFilter继承于图片滤波器基类。其实现的-configureArgumentTableWithCommandEncoder:方法相当简单的生成了模糊权值,并将权值表贴图作为commandEncoder设置的blurWeightTexture的第三个参数(参数表序列为2)。

- (void)configureArgumentTableWithCommandEncoder:(id)commandEncoder
{
    if (!self.blurWeightTexture)
    {
        [self generateBlurWeightTexture];
    }
    [commandEncoder setTexture:self.blurWeightTexture atIndex:2];
}

上述代码中的-generateBlurWeightTexture方法会使用之前贴出的2D正态分布公式来算出一个权值矩阵,并将这些值传入一张Metal贴图中。

现在我们已经完成了高斯模糊的模块。那么接下来我们再来看一下之前提到的如何把滤波器效果叠加起来并将处理后的图片显示在屏幕上。

图片滤波器叠加

来看一下之前提到的滤波器效果叠加具体是怎么实现的:

context = [MBEContext newContext];
imageProvider = [MBEMainBundleTextureProvider textureProviderWithImageNamed:@"mandrill"
                                                                    context:context];
desaturateFilter = [MBESaturationAdjustmentFilter filterWithSaturationFactor:0.75
                                                                     context:context];
desaturateFilter.provider = self.imageProvider;
blurFilter = [MBEGaussianBlur2DFilter filterWithRadius:0.0
                                               context:context];
blurFilter.provider = desaturateFilter;
imageView.image = [UIImage imageWithMTLTexture:blurFilter.texture];

图片提供器的作用就是将一张图片加载为Metal贴图并作为整个叠加效果的最开始。这个图片提供器为饱和度调节提供输入的贴图,而饱和度滤波器又为模糊滤波器提供源贴图。

实际操作的时候模糊滤波器对图片的处理是实时响应的,这说明了模糊滤波器处理了来自饱和滤波器提供的贴图,也就是说,饱和度的调节和模糊是同时在进行的。一旦有所操作,都会导致模糊滤波器立即调用饱和滤波器的输出贴图并进行处理。

最后模糊滤波器输出一张贴图,转化为UIImage类型之后用于显示在用户界面。

将贴图转换为UIImage

因为最后我们要把滤波器处理生成的贴图在UIImage view中显示给用户,因此需要将它先转换成UIImage格式。最有效的方式则是采用Core Graphics框架中的公用图片处理方法,首先新建一个临时缓冲区并写入Metal贴图的数据,然后可以将其包装为CGDataProviderRef对象,之后用于生成CGImageRef。最后,就可以用CGImageRef直接生成UIImage了。

图像异步处理

我们刚刚所说的处理中对于滤波器的调用是同时进行的。而因为图像处理的计算量相当集中,所以我们需要在后台线程中完成这些处理并及时响应用户操作。

当然,如果要做到在后台随时响应前台用户的操作,大部分熟悉iOS开发的人都会想到GCD(Grand Central Dispatch)。

在下面的代码中我们引入了一个无符号的64位整型属性,以便于判断视图控制器的每次响应(编者按:其实就是判断用户是否还在持续操作,原作者在此处没有说明太清楚,建议看代码宜于理解)。

- (void)updateImage
{
    ++self.jobIndex;
    uint64_t currentJobIndex = self.jobIndex;
 
    // Grab these values while we're still on the main thread, since we could
    // conceivably get incomplete values by reading them in the background.
    float blurRadius = self.blurRadiusSlider.value;
    float saturation = self.saturationSlider.value;
 
    dispatch_async(self.renderingQueue, ^{
        if (currentJobIndex != self.jobIndex)
            return;
 
        self.blurFilter.radius = blurRadius;
        self.desaturateFilter.saturationFactor = saturation;
 
        id texture = self.blurFilter.texture;
        UIImage *image = [UIImage imageWithMTLTexture:texture];
 
        dispatch_async(dispatch_get_main_queue(), ^{
            self.imageView.image = image;
        });
    });
}

结语

编者推荐下载最后的实例代码,阅读源码是学习的最好方式。

这篇博文介绍了Metal中基本的并行运算,我们也可以看到实例中的滤波器可以在GPU上高效运行。关于苹果新推的Metal技术,如果你还有任何意见或者推荐,可以在评论区里回复给我们。想了解更多,还可以查阅苹果的官方文档