41b9585b6479472db45a1fe667ad8c07
Metal【9】—— 风格化滤镜(下)

上篇我们介绍了简单的颜色滤镜,同时提到了,滤镜效果大致分为这么几类:

  • 独立像素点变换,包括亮度、对比、饱和度、色调等
  • 像素卷积变换,包括边缘检测、浮雕化、模糊、锐化等
  • 仿射矩阵变换。包括缩放、旋转、倾斜、扭曲、液化等
  • ..

之前介绍的颜色滤镜,即独立像素点变换,按照一定规律,修改当前像素点的色值。每个像素点都是独立的,不相互依赖。

相对的,其他几类,我们可以统一归类为风格化滤镜,这类滤镜有一个显著的特点:当前点的最终色值,需要依赖其他位置点的色值,来共同决定。

所以,风格化滤镜,所展现的效果,会更加的惊艳、惊喜。不同的图片,同一个滤镜,处理出来的效果可能截然不同。

这一篇中,会介绍这么两个滤镜 Zoom Blur 和 Toon 的具体实现,大体效果如下:

另外,会稍微介绍下更复杂的滤镜,比如之前很火的风格转换【A Neural Algorithm of Artistic Style】,效果如下:

当然,这部分的实现,并不是通过 shader 来直接编写。会简单介绍下,如何找到,并借助相关训练好的模型,通过 Core ML 来实现这个效果。

PS:

订阅后的朋友,可以加我微信:wxidlongze,拉你进群。交流,扯淡,学习资源分享~

最后,源码在文末~

考虑到篇幅问题,我会把这个系列拆成【上】【下】两篇来完成,

  • 上篇介绍常见模糊的原理和 Zoom Blur 的具体实现,以及系统拍照的景深信息等
  • 下篇会介绍 Toon 效果的具体实现,以及借助 Core ML 模型的一些方式

那么,开始吧。


2. Toon

首先,我们看一下整体的调整效果:

不难发现,所谓的 Toon 滤镜,即将图像转成类型卡通风格的效果。它有两个显著特点:

  • 边缘黑色描边,凸显对应元素的形象
  • 图像颜色的种类越来越少,相似的颜色都统一起来

要想满足这两个特点,就要借助边缘检测(Edge Detection)以及颜色量化(Color Quantization)这两项技术,下面我们着重介绍下它们。

2.1 Edge Detection 边缘检测

在介绍边缘检测之前,先理几个基本概念,避免下文描述过程中存在知识缺漏。

【1】可以把图像看成二维离散函数

计算机视觉旨在从图像中提取有用的信息,这已经被证实是一个极具挑战性的任务。

那么图像是什么?或者说我们把图像看作什么?

有人说图像就是一张图片,一个场景,一个矩形(rectangle),一个矩阵(matrix),一张纹理(texture)...

我们先看一个图像实例:

这是一张黑白图像,也就是常说的灰度图。

PS:

灰度图处理起来更加简单方便,它是单通道,另外之后我们也需要用到灰度图,因此这里使用灰度图像,重在理解。

我们把这幅图像加上坐标刻度,如下图所示:

放到坐标系中后,我们能把一副图像看作是一个二维函数,定义成 f(x, y),需要强调的是把图像作为二维函数时,它是一个离散函数,且取值范围有所限定,比如 x, y 轴的坐标值,函数取值也限定在某个区间之内。

【2】图像梯度

所谓梯度,实际上就是用来描述陡峭程度。

同理,对应来图像,梯度可以用来描述图像本身对应的某种变化率。即在坐标(x,y)处指向 f 最大变化率的方向的向量。

我们学过微积分,知道微分就是求函数的变化率,即导数(梯度),那么图像的梯度就是这个二维离散函数的求导。


好了,了解完这两个基础知识后,我们再来看边缘检测。

边缘是指图像局部强度变化最显著的部分。主要存在于目标与目标、目标与背景、区域与区域(包括不同色彩)之间,是图像分割、纹理特征和形状特征等图像分析的重要基础。

图像局部强度的显著变化,又可以表示成不连续处的两边的像素灰度值有着显著的差异。

所以,边缘检测的目的是标识数字图像中灰度变化明显的点。

结合上面介绍的梯度,当图像中存在边缘时,一定有较大的梯度值(前后变化剧烈),相反,当图像中有比较平滑的部分时,灰度值变化较小,则相应的梯度也较小。

图像处理中把梯度的模(梯度是向量)简称为梯度。所以下文如果直接表示为梯度,指的是具体的值。

PS:

为什么这里要将彩色图像,转成灰度图,通过灰度变化来确定边缘呢?

这主要是为了便于处理,毕竟彩色图像就要分析 3 组原色的梯度,而灰度图像只要 1 组。
另外由于边缘检测基本是用梯度算子完成的,,而彩色图像实际是由若干种原色(如 RGB )构成的,如果直接检测彩色图像边缘也就是对每种色彩单独检测,但是各原色在一点处的梯度方向可能不同,从而得到的边缘也不同,会发生错误。

所以为了准确性和效率,一般都是先转成灰度图,然后通过灰度的变化来确定边缘。

看个直观的效果,如下,从左到右分别是原图,原图和边缘检测后的图 50% 混合的效果图,边缘检测后的图:

其中,白色的程度表示边缘的深浅。

综上,为了实现边缘检测,关键是找到周围像素灰度急剧变化的那些像素的集合。

业界已经有许多成熟的方案,这里选择比较常用的 Sobel 算子(Sobel Operator)来实现。

PS:

算子是一个函数空间到函数空间上的映射 O:X→X。

广义的讲,对任何函数进行某一项操作都可以认为是一个算子,甚至包括求幂次,开方都可以认为是一个算子。

Sobel 算子 也叫 Sobel 滤波,是两个 3*3 的矩阵,主要用来计算图像中某一点在横向/纵向上的梯度。

Sobel 算子是典型的基于一阶导数的边缘检测算子,由于该算子中引入了类似局部平均的运算,因此对噪声具有平滑作用,能很好的消除噪声的影响。

这里以 Wiki 资料为准,Sobel 算子 有两个滤波矩阵:Gx 和 Gy。

Gx 用来计算横向的梯度,Gy 用来计算纵向的梯度,下图就是具体的滤波器:

而最终的梯度大小,则可以通过向量求模公式计算获得:

PS:

对应的滤波矩阵是怎么来的,感兴趣可以自行了解 Sobel 算子的推导过程,这里不展开说明。

下面我们结合实际例子看下:

假设待处理图像的某个像素点周围的像素如下:

左上像素 上边像素 右上像素
左边像素 中心像素(待处理) 右边像素
坐下像素 下边像素 右下像素

那么结合上述公式,
Gx = (-1) x [左上] + (-2) x [左] + (-1) x [左下] + 1 x [右上] + 2 x [右] + 1 x [右下]

Gy = (-1) x [左上] + (-2) x [上] + (-1) x [右] + 1 x [左下] + 2 x [下] + 1 x [右下]

结合 Gx 和 Gy,求模,即可得到最终的梯度大小 G,即灰度值的变化率。

然后可以依赖它,就可以确定边缘了。(通过和预设的阈值做对比,判断有没有达到边缘的条件)

嗯,就是这样~


2.2 Color Quantization 颜色量化

颜色量化是利用人眼对颜色的惰性,将原图像中不太重要的相似颜色合并为一种颜色,减少图像中的颜色,而使量化前后的图像对于人眼的认识误差最小,即量化误差最小。

如下,分别是原图和减少颜色数量后的结果图。可以发现细节处的色值差异被大大降低:

另外,从右图底部的色栏,我们不难发现,颜色量化也是提取图片中主要色值的有效方式。

颜色量化是数字图像处理的基本技术之一,同样,也已经有很多成熟的算法。比如:

  • 统一颜色量化(Uniform Quantization)
  • 流行色算法(Popularity Algorithm)
  • 中值切割法(Median Cut Algorithm)
  • 八叉树颜色量化(Octree)
  • ...

这里不一一介绍,感兴趣可以自行了解。简单讲一下,我们在 Toon 中会用到,最简单的统一颜色量化算法

在 RGB 颜色模型中,颜色可以表示成三维空间中的一个坐标,颜色空间可以表示为 X,Y,Z 轴都在 [0,1] 范围的值,这样颜色空间就相当于一个正方体。

统一颜色量化的基本思想就是独立的看待颜色空间中的每个坐标轴,把它们平均分成N条线段,这样就能形成一个个小方块,每个方块当作一种颜色。比如常见的 RGB24(2 的 24 次方),N=256,则有256×256×256=16777216 种颜色。

如果把 R、G 的坐标轴分成 8 段,把 B 的坐标轴分成 4 段,这样就可以生成 256(8×8×4)个小方块,即 256 种颜色。

除此之外,当然还有别的划分方法,例如可以把 R、G 分成 6 段,B 分成 7 段,这样就能产生 252 种颜色。

其中,每个方块内的颜色值,可以取该方块所有颜色值的平均值。

这种方法实现非常的简单快速,但是产生的结果并不好。


2.3 Toon 原理

了解完上述核心算法以后,我们可以总结 Toon 滤镜对应的实际原理,如下:

  • 计算每个像素对应的亮度值
  • 使用 Sobel 算子进行边缘检测,并获得对应的梯度值
  • 判断梯度值是否大于预设阈值,
    • 如果大于,则将色值改成黑色,描边。
    • 反之,对原始色值,执行量化计算。
  • 输出计算后的色值。

这样,一个简单的 Toon 效果就实现了,下面我们对照具体代码再看看。


2.4 Toon 实现

对应的具体 shader 如下:

#include <metal_stdlib>
#import "ShaderType.h"
using namespace metal;

typedef struct
{
    float magTol;
    float quantize;
} ZoomBlurUniform;

fragment half4 toonFragment(SingleInputVertexIO fragmentInput [[stage_in]],
                            texture2d<half> inputTexture [[texture(0)]],
                            constant ZoomBlurUniform& uniforms [[ buffer(1) ]])
{    
    constexpr sampler quadSampler;
    float2 texCoord = fragmentInput.textureCoordinate;
    half4 originColor = inputTexture.sample(quadSampler, texCoord);

    // Sobel Operator
    float2 resolution = float2(inputTexture.get_width(), inputTexture.get_height());

    float2 stp0 = float2(1./resolution.x, 0.);
    float2 st0p = float2(0., 1./resolution.y);
    float2 stpp = float2(1./resolution.x, 1./resolution.y);
    float2 stpm = float2(1./resolution.x, -1./resolution.y);

    float im1m1 = dot(inputTexture.sample(quadSampler, texCoord-stpp).rgb, luminanceWeighting);
    float ip1p1 = dot(inputTexture.sample(quadSampler, texCoord+stpp).rgb, luminanceWeighting);
    float im1p1 = dot(inputTexture.sample(quadSampler, texCoord-stpm).rgb, luminanceWeighting);
    float ip1m1 = dot(inputTexture.sample(quadSampler, texCoord+stpm).rgb, luminanceWeighting);
    float im10 = dot(inputTexture.sample(quadSampler, texCoord-stp0).rgb, luminanceWeighting);
    float ip10 = dot(inputTexture.sample(quadSampler, texCoord+stp0).rgb, luminanceWeighting);
    float i0m1 = dot(inputTexture.sample(quadSampler, texCoord-st0p).rgb, luminanceWeighting);
    float i0p1 = dot(inputTexture.sample(quadSampler, texCoord+st0p).rgb, luminanceWeighting);

    float Gx = -1.*im1p1 - 2.*i0p1 - 1.*ip1p1 + 1.*im1m1 + 2.*i0m1 + 1.*ip1m1;
    float Gy = -1.*im1m1 - 2.*im10 - 1.*im1p1 + 1.*ip1m1 + 2.*ip10 + 1.*ip1p1;
    float GValue = length(float2(Gx, Gy));

    if (GValue > uniforms.magTol) {
        return half4(0., 0., 0., 1.);
    } else {
        // Color Quantization
        originColor.rgb *= uniforms.quantize;
        originColor.rgb += half3(.5, .5, .5);
        int3 intrgb = int3(originColor.rgb);
        originColor.rgb = half3(intrgb) / uniforms.quantize;
        return half4(originColor.rgb, 1.);
    }
}

如果上述的原理都理解了,那么再看这段脚本,就很简单了。

首先是两个控制变量 magTol 和 quantize。

  • magTol 表示梯度阈值,越小,描边越明显
  • quantize 表示量化级别。越小,颜色数量越少
constexpr sampler quadSampler;
float2 texCoord = fragmentInput.textureCoordinate;
half4 originColor = inputTexture.sample(quadSampler, texCoord);

这段,基本操作,取得对应的纹理坐标和原始色值。

接下去一大段,则是在执行 Sobel Operator:

```c++
float2 resolution = float2(inputTexture.get_width(), inputTexture.get_height());

float2 stp0 = float2(1./resolution.x, 0.);
float2 st0p = float2(0., 1./resolution.y);

top Created with Sketch.