《图形着色器的理论与实践(第2版)》翻译——第11章:着色器和图像处理(1)(Image Manipulation and Shaders)

本书是图形学编程的不错读物,通过浅显易懂,理论结合实践的方式介绍着色器的使用,我在翻译的过程中尽量保持原文的段落和含义,会删除比较无关的内容。

  1. 专有词汇会附加英文原词,用标签的形式表示,如 着色器(shaders
  2. 原文附录引用编号会放上引用书的Google搜索结果链接
  3. 如有异议或对书中技术感兴趣的欢迎留言讨论

译注:本章太长了,内容很多,分两部分翻译

OpenGL 计算机图形学 API 的主要目的是从几何学原理中渲染出3D合成场景,但一些图像处理的能力在一开始就被构建到了这个系统当中。随着着色器能力的增强,现在 OpenGL 可以通过纹理访问(texture access) 和处理手段来执行一些新的图像功能。在本章中,我们介绍了一些这类功能的方法。这些方法提供了从纹理中直接获取纹素(texels)和对这些纹素进行算术运算的能力。

下面展示了一个通用 GLIB 文件的形式,包括一个 uniform slider 类型的变量 T,可以用在如图像混合的参数化处理中,和为图像文件准备的参数。因为本章一些后续示例是基于两个纹理进行处理,每个纹理都需要被分配到一个纹理单元(texture unit) 中。当然如果是对单张图片(仅一个文件)进行处理、或有其他 uniform 参数用来支持运算,就需要对文件做一些调整。

译注:slider 类型的变量来源于 glman 的操作界面,不用太在意

##OpenGL GLIB
Ortho -1. 1. -1. 1.
Texture 5 sample1.bmp
Texture 6 sample2.bmp
Vertex sample.vert
Fragment sample.frag
Program Sample uT <0. 0. 5.> \

QuadXY .2 5.

uImageUnit 5 uImage2Unit 6

这个 GLIB 把两个纹理图片分别分配在纹理单元5和6上。在显卡所支持的数量范围内,你可以使用任意的纹理单元。

如果你使用 glman,不要使用纹理单元2和3,因为 glman 把内置的 2D 3D 噪声纹理放在这两个单元上。

所需的顶点着色器很短,里面的参数都是我们已经很熟悉的了。


out vec2 vST;

void main( ) {

    vST = aTexCoord0.st;
    gl_Position = uModelViewProjectionMatrix * aVertex;
}

在这章中,我们会研究不同的图像处理方法,并把它们构建到片元着色器中去。

基础概念

GLSL把图像作为纹理,并通过对纹理的访问和处理去把一个个像素的颜色设置到颜色缓存中。这个颜色缓存可能在之后会被显示,这样你就能看到操作的结果,或者会被保存至另外一个纹理或文件中。
在这里插入图片描述

在图 11.1 中,可以看到一张已经从图片文件中读取出来的纹理。这个纹理文件被逐纹素地处理成了光栅图像。GLSL的内建方法 textureSize() 可以获取纹理的分辨率,在图 11.1 中是 ResSResT

有两种访问纹理中单个纹素的方法。因为所有 OpenGL 纹理的坐标范围都是从 0.0 到 1.0,于是图像的中心点就是 vec2(0.5, 0.5),想要从一个纹素横向或纵向得到下一个纹素,可以把坐标点递增 1./ResS 或 1./ResT。另一种方法是:如果你的环境是 GLSL 1.50(OpenGL 3.2) 或更高版本,Ni可以通过 texelFetch() 方法直接访问任何纹素。我们将利用这些 GLSL 纹理访问能力在片元着色器中定位和计算像素颜色,以输出到颜色缓存里。

为了尽可能地通用化,我们用实数(译注:浮点数)代替整数来定位和递增纹理坐标,尽管这种方式因为可能会导致一些不符合预期的像素插值而有其弱点。

单图像处理

在下面几个小节里,我们对单张图片进行处理,利用图像本身所包含的信息来计算输出像素的颜色。与之不同,本章后续几个小节会把两张不同图片作为纹理加载到纹理单元中进行计算。

明度(Luminance)

所谓颜色的明度就是颜色的整体亮度,和颜色的色相没有关系。明度是个比它看上去可能更为复杂的属性,因为人眼对不同原色的反应有所不同。研究明度是为了给那些对弱视的人亮度提示,而且在一个同时支持黑白电视和彩色电视的色彩系统中,明度是必不可少的。

sRGB标准(即 IEC 61966-2-1) 是作为一种跨显示器和应用的标准颜色表示法而出现的 41。在 sRGB 中,明度的定义是 红、绿、蓝 数值的线性组合。sRGB 中的明度权重向量是:

const vec3 w = vec3(0.2125, 0.7154, 0.0721);

在之前很多示例的顶点着色器中,我们都用这组权重值和一个 rgb 颜色向量点乘来计算像素的明度值:

vec3 irgb = texture(uImageUnit, vST).rgb;
float luminance = dot(irgb, w);

注意权重 W 中的三个数值加起来刚好是 1.0000,这样在和一个规范的 RGB 向量点乘后,算出的明度值会在 0 和 1 之间。明度在一些图像处理技术中是一个很重要的概念,如灰度处理。灰度转换是将一张图像所有像素的颜色用其明度值替换。当用上面那段代码计算像素的明度值后,你可以把值设置给像素的颜色来生成一张灰度图:

fFragColor = vec4(luminance, luminance, luminance, 1.);

图 11.2 中展示了一张彩色图像和其灰度图的对比:
在这里插入图片描述

CMYK 转换 (CYMK Conversions)

在用标准打印流程来输出图形时, 将 RGB 色彩转换成 CMYK 色彩是其中常见的一步。RGB 色彩模型是基于给黑色添加彩色部分这种反射颜色模式的,电脑显示器用的就是这种模式。而 CMYK 色彩模型是基于从白色中减去颜色部分这种透射模式的。标准打印过程采用四种减去色:青、洋红、黄、黑(cyan, magenta, yellow and black)。将 RGB 色彩转换到 CMYK 色彩并输出这四个单色图像的过程叫做 CMYK 分离生成,四个单色图像被用来生成对应的印版。尽管有不同的实现方式,不过从 RGB 色彩空间到 CMYK 色彩空间的转换还是很简单的。(以下这些例子来源于 [5]

  1. 首先,从白色中减去 RGB 颜色,将 RGB 转换成 CMY。
  2. 接着用每个色值中的黑度值计算出 K
  3. 然后调整 CMY 中每个分量的值以匹配 K 值的分量

译注:原书这里说的比较含糊,具体的过程还是参照下面的复杂公式就好。

下面是将 vec3 color 转换到 vec4 cmykcolor 的片元着色器示例代码:

vec3 cmycolor = vec3(1., 1., 1.) – color;
float K = min( cmycolor.x, min(cmycolor.y, cmycolor.z) );
vec3 temp = (cmycolor – vec3(K,K,K,) )/(1.0 – K);
vec4 cmykcolor = vec4(temp, K);

另一个稍微复杂,但更令人满意的做法是,通过调整用来转换到 cmykcolor 的 K 值,来缩放 cmycolor 的值。这种做法可以达到和 Adobe Photoshop 的 CMYK 转换很近似的效果
在这里插入图片描述
其中 fUCR 和 fBG 的公式为:
在这里插入图片描述
这里 SK = 0.1, K0 = 0.3, and Kmax = 0.9。这种方法的处理结果如图11.3
在这里插入图片描述
这四个分离也是原图在 C,M,Y,K 四个部分的灰度图表示,它们会在电影或数码工业的打印过程中被使用到。在上面的代码之后,需要将每个像素颜色替换成灰度单色来生成对应的分离图,比如生成一个洋红分离图,就要这样:

fFragColor = vec4( cmykcolor.yyy, 1.);

因为 GLSL 中没有叫做 “cmyk” 的命名集,并且命名集本来就和其子项没什么含义上的关系,我们就用 xyzw 命名集来表示 vec4 cmykcolor。

图11.3中已经展示了分离图像生成技术的计算结果。被以灰度模式表示的分离图可以强调打印它们需要多少墨水——灰度越深就表示在这个点上需要更多该分离图颜色的墨水。原图中最显眼的部分是水果中的黄色,接着是洋红色。

以下给出这种 CMYK 转换方法的片元着色器代码,上面提到的一些参数值被硬编码在了代码中:

#define CYAN 
#undef MAGENTA 
#undef YELLOW 
#undef BLACK
uniform sampler2D uImageUnit;
in vec2 vST;
out vec4 fFragColor;
void main( ) {
  vec3 irgb = texture( uImageUnit, vST ).rgb;
  vec3 cmycolor = vec3( 1., 1., 1. ) - irgb;
  float K = min( cmycolor.x, min(cmycolor.y, cmycolor.z) );
  vec3 target = cmycolor - 0.1 * K;
  if (K < 0.3) K = 0.;
  else K = 0.9 * (K - 0.3)/0.7;
  vec4 cmykcolor = vec4( target, K );
#ifdef CYAN
  fFragColor = vec4( vec3(1. - cmykcolor.x), 1. );
#endif
#ifdef MAGENTA
  fFragColor = vec4( vec3(1. - cmykcolor.y), 1. );
#endif
#ifdef YELLOW
  fFragColor = vec4( vec3(1. - cmykcolor.z), 1. );
#endif
#ifdef BLACK
  fFragColor = vec4( vec3(1. - cmykcolor.w), 1. );
#endif 
}

色相调节(Hue Shifting)

就像转换成 CMYK 色彩一样,也可以将颜色转换到其他的主要色彩模型中。这里我们假设你已经对 HLS 和 HSV 色彩模型比较熟悉了 [14],接下来我们会从 RGB 转换到 HLS 或 HSV,再在这个新的色彩模型中改变色相(hue)值,最后转换回 RGB,来实现色相调节。图11.4 中展示了色相调节的效果:
在这里插入图片描述
以下是实现这个过程的片元着色器代码。 使用 HSV 色彩模型是因为色相是一个角度函数,可以很简单地通过调节色相值并对360取模来改变颜色。RGB 到 HSV 的转换方法来自于 [18]。色相调节着色器代码用到了 glman 的 slider 变量 T,范围是 [0, 360],表示了可调节范围。

uniform float uT;
uniform sampler2D uImageUnit;
in vec2 vST;
out vec4 fFragColor;
vec3 convertRGB2HSV( vec3 rgbcolor ) {
    float h, s, v;
    float r = rgbcolor.r;
    float g = rgbcolor.g;
    float b = rgbcolor.b;
    float v = float maxval = max( r, max( g, b ) );
    float minval = min( r, min( g, b ) );
    if (maxval==0.) s = 0.0;
    else s = (maxval – minval)/maxval;
    if (s == 0.)
        h = 0.; // actually h is indeterminate in this case
    else {
        float delta = maxval – minval;
        if ( r == maxval ) h = (g – b)/delta;
        else if (g == maxval) h = 2.0 + (b – r)/delta;
        else if (b == maxval) h = 4.0 + (r – g)/delta;
        h *= 60.;
        if (h < 0.0) h += 360.;
    }
    return vec3( h, s, v );
}

vec3 convertHSV2RGB( vec3 hsvcolor ) {
    float h = hsvcolor.x;
    float s = hsvcolor.y;
    float v = hsvcolor.z;
    if (s == 0.0) // achromatic– saturation is 0
    {
        return vec3(v,v,v); // return value as gray
    }
    else // chromatic case
    {
        if (h > 360.0) h = 360.0; // h must be in [0, 360)
        if (h < 0.0) h = 0.0; // h must be in [0, 360)
        h /= 60.;
        int k = int(h);
        float f = h - float(k);
        float p = v * (1.0 – s);
        float q = v * (1.0 - (s * f));
        float t = v * (1.0 - (s * (1.0 - f)));
        if (k == 0) return vec3 (v, t, p);
        if (k == 1) return vec3 (q, v, p);
        if (k == 2) return vec3 (p, v, t);
        if (k == 3) return vec3 (p, q, v);
        if (k == 4) return vec3 (t, p, v);
        if (k == 5) return vec3 (v, p, q);
    } 
}

void main( ) {
    vec3 irgb = texture( uImageUnit, vST ).rgb; 
    vec3 ihsv = convertRGB2HSV( irgb );
    ihsv.x += uT;
    if (ihsv.x > 360.) ihsv.x -= 360.; //add to hue 
    if (ihsv.x < 0.) ihsv.x += 360.; //add to hue 
    irgb = convertHSV2RGB( ihsv );
    fFragColor = vec4( irgb, 1. );
}

这个例子中包括了 RGB 和 HSV 色彩之间的隐式转换函数,表示了色彩转换是一件很通用化的事情。

图像过滤(Image Filtering)

一些图像处理是基于过滤操作的,过滤是指对一个像素点用权重参数矩阵和其邻接像素点进行卷积的过程。过滤卷积的大小、权重取值、过滤后计算值的含义,在不同的算法中都不一样。

译注:这里原文的意思应该是仅仅通过改变过滤算法中卷积的形式和参数值,就能达到很多样化的过滤效果。

正如下面两个过滤器的例子,一个是用来做横向边缘检测的 3×3 索贝尔算子(Sobel),另一个是用来做平滑(或模糊)的 5×5 模糊滤镜:
在这里插入图片描述
这些过滤器作为权重值被用来算出毗邻像素的加权结果。对一个像素值 Pij 和一个宽度是 2*n + 1 的滤镜元素 Fij ,可以这样表示加权结果:在这里插入图片描述
过滤器具有一些通用属性,它们通常是一个奇数大小的方形矩阵,这个大小通常是 3×3 或 5×5,矩阵中的加权值通常是1,特别是当需要保留一个数组中的整体内容时,所以应用一个过滤器通常不会改变图像的整体数值规模。

图像模糊(Image Blurring)

图像模糊可以通过应用一个简单的对称过滤器来实现,每个像素都会被它周围像素的颜色影响。下面这个 3×3 算子或者上面的 5×5 算子都是很简单的模糊卷积核:
在这里插入图片描述在这里插入图片描述
图11.5 展示了原图和应用了这两个过滤算子的模糊结果图,可以看到不同算子对图像模糊程度的影响。因为在一些自然写实图片中不太好观察到模糊效果,我们选择了一张具有显著边缘的原图。

下面是用这个 3×3 卷积算子来模糊处理的片元着色器代码,因为计算过程很简单,所以这里并没有用正式的矩阵乘法运算。 5×5 模糊算子的代码和这个很相似,只不过需要4个额外的像素地址,和获取总共25个独立像素点,而不是下面的9个。

uniform sampler2D uImageUnit;
in vec2 vST
out vec4 fFragColor;
void main( ) {
    ivec2 ires = textureSize( uImageUnit, 0 );
    float ResS = float( ires.s );
    float ResT = float( ires.t );
    vec3 irgb = texture( uImageUnit, vST ).rgb;
    vec2 stp0 = vec2(1./ResS, 0. ); // texel offsets
    vec2 st0p = vec2(0. , 1./ResT);
    vec2 stpp = vec2(1./ResS, 1./ResT);
    vec2 stpm = vec2(1./ResS, -1./ResT);
    // 3x3 pixel colors next
    vec3 i00 = texture( uImageUnit, vST ).rgb;
    vec3 im1m1 = texture( uImageUnit, vST-stpp ).rgb;
    vec3 ip1p1 = texture( uImageUnit, vST+stpp ).rgb;
    vec3 im1p1 = texture( uImageUnit, vST-stpm ).rgb;
    vec3 ip1m1 = texture( uImageUnit, vST+stpm ).rgb;
    vec3 im10 = texture( uImageUnit, vST-stp0 ).rgb;
    vec3 ip10 = texture( uImageUnit, vST+stp0 ).rgb;
    vec3 i0m1 = texture( uImageUnit, vST-st0p ).rgb;
    vec3 i0p1 = texture( uImageUnit, vST+st0p ).rgb;
    vec3 target = vec3(0.,0.,0.);
    target += 1.*(im1m1+ip1m1+ip1p1+im1p1); //apply blur filter
    target += 2.*(im10+ip10+i0m1+i0p1);
    target += 4.*(i00);
    target /= 16.;
    fFragColor = vec4( target, 1. );
}

色键图像(Chromakey Images)

色键图被用在“绿幕”或“蓝幕”的抠像处理中。这让你可以从任意的图像中替换掉所有和键值颜色相同或相近的区域,这个色键图可以一张背景纹理图,或是某张其他图片的一部分。图11.6 展示了色键替换效果。
在这里插入图片描述
色键图的计算需要两张纹理,一张“图纹理”(image texture),一张“前纹理”(before texture)。“图纹理”中包括了会被替换掉的色键颜色像素,“前纹理”就是用来替换色键颜色的像素。接下来的过程就相对简单了:

  1. 读取图纹理
  2. 对近似色键颜色的像素,用前纹理中的对应像素替换
  3. 保留其他不近似色键颜色的像素

下面的片元代码用纯绿作为色键颜色,模拟绿幕的处理过程。值 uT 表示色键颜色被替换的宽容范围,这个值一般来说很小,因为只有那些非常接近绿色,也就是 vec3(0., 1., 0.) 的颜色,会通过限制校验并被图纹理颜色所替换。

下面是这个过程的片元着色器代码,使用了 GLIB 文件中的 uT 和 uAlpha 参数。BeforeUnit 是前景图,AfterUnit 是背景图,参数 uAlpha 控制了前景图的透明度。

译注: image texturebefore texture 这两个词造得并不形象,在其他资料中没有查到这种描述来形容抠像的资源图,也和后面的描述对应不上,“前景图” 和 “背景图” 在原文里对应的分别是 前者和后者。

uniform float uT;
uniform float uAlpha;
uniform sampler2D uBeforeUnit, uAfterUnit;
in vec2 vST;
out vec4 fFragColor;
void main( ) {
    vec3 brgb = texture( uBeforeUnit, vST ).rgb;
    vec3 argb = texture( uAfterUnit, vST ).rgb;
    vec4 color;
    float r = brgb.r;
    float g = brgb.g;
    float b = brgb.b;
    color = vec4( brgb, 1. );
    float rlimit = uT;
    float glimit = 1. - uT;
    float blimit = uT;
    if( r <= rlimit && g >= glimit && b <= blimit )
         color = vec4( argb, 1. );
    else
         color = vec4( uAlpha*brgb + (1.-uAlpha)*argb, 1. );
    fFragColor = color;
}

立体影像(Stereo Anaglyphs)

基于图像的片元着色器中一个很有趣的用途是生成 立体影像。这在漫画书和电影中经常用到,并且在今天依然很流行。这有时候被叫做 “红蓝立体影像”(red-blue stereo),尽管现在的3D眼镜实际上是 红-青的,通常左眼镜片盖有红色滤镜,有眼镜片盖有青色滤镜。

在开始写着色器代码之前,我们需要搞明白3D眼镜的原理是什么。着色器的输出是一张同时涵盖了左右眼视角的组合图像,当从红色滤镜观察组合图像时,只想要看到左眼图像,有眼图像需要被遮挡住,同样地,当组合图像透过青色滤镜观察时,左眼图像要被遮挡住,只需要看到右眼视角的图像。红色滤镜会让红光通过,而阻挡青光,右眼的青色滤镜阻挡了红光,让滤光和蓝光通过,这意味着左眼图要用红色输出,右眼图要用青色(绿和蓝)输出。这样最终的组合图像就是将左眼图的红色部分和右眼图的绿蓝色部分组合起来。

图11.7 展示了这个过程,用红-青眼睛能看到这个效果。
在这里插入图片描述
生成立体影像图有几点策略需要注意:

  1. 采集3D场景的左右眼图。这可以用相机在拍摄过程完成,或者从一张现有图像中分别取出两个视角的图。[27] 这本书中讨论了很多关于采集的方法。对拍摄过程的方法,两次拍摄点相距 4 到 6 英寸,拍近景或中景会比较好。
  2. 用左眼图的红色部分和右眼图的绿蓝色部分生成组合图像。
  3. 为了消除立体图像,特别是用手持相机拍摄的源图生成的立体图中经常会出现的纵向视差,可能需要对其中一张图进行纵向位置调整。
  4. 左右眼图中同一位置的任何物体都要在屏幕中的同一平面上,这个景深深度叫做零视差平面(plane of zero parallex)。尽管把零视差平面向后推移使大部分场景都像是悬在半空中的感觉很酷,但这并不好。我来告诉你为什么。在日常生活中,会有物体出现在我们前方的半空中,比如一直正在飞行的鸟,整体场景图像的上左下右都会被裁剪,但空中的鸟不会。所以出现在眼前的场景图像如果被没有缘由地裁剪成选在半空的状态的话,可能会看上去很不真实。另一种看似更自然的方式是把零视差平面往前移,这样大部分3D场景看起来就像是在显示器或一本书中(译注:内容被包含在了一个有方框限制的画幅内)。如果我们通过一扇窗户去看半空中的鸟,然后这只鸟突然消失在窗户的边框外,我们并不会觉得有什么奇怪的。同理,如果场景图像沉入画框(page)内而被裁剪的话,看起来像会我们已经习惯的场景。所以在创作这种影像时,允许对图像进行横向位置调整以调整零视差平面也是一个不错的建议。
  5. 左右眼图的颜色控制需要有一定的宽容范围,因为眼镜上的红色滤镜和青色滤镜通常不会达到完美的平衡状态。

下面的 GLIB 文件把两个眼图作为输入,并设置滑条:

##OpenGL GLIB
Texture 5 left.bmp
Texture 6 right.bmp
Vertex anaglyph.vert
Fragment anaglyph.frag
Program Anaglyph
QuadXY .2 5.

正向本章其他例子那样,大部分工作是在片元着色器中完成的:

uniform sampler2D uLeftUnit, uRightUnit;
uniform float uOffsetS, uOffsetT;
uniform float uRed, uGreen, uBlue;
in vec2 vST;
out vec4 fFragColor;

void main( ) {
    vec4 left = texture(uLeftUnit, vST );
    vec4 right = texture(uRightUnit,vST+vec2(uOffsetS,
    vec3 color = vec3( left.r, right.gb );
    color *= vec3( uRed, uGreen, uBlue );
    color = clamp( color, 0., 1. );
    fFragColor = vec4( color, 1. );
}

注意这个片元着色器用到了GLIB文件中设置的五个滑条参数。参数 uoffsetS 和 uoffsetT 控制了右眼图的偏移,以产生不同的视差效果,另外三个一致变量 uRed、uGreen 和 uBlue 用来应对不同眼镜的色差。当生成影像图时,你会想要通过调节这些参数来得到最好的效果。

图11.8 展示了生成立体影像图的另一个例子。
在这里插入图片描述
图译:这是一张由一对火星立体图生成的立体影像,来自 NASA 网站上的。注意在图中上半部分,左眼图在右边,右眼图在左边。如果你善于使用交叉眼(crossing your eyes),就能裸眼3D(free-viewing)看这些立体图了。如果你善于使用平行眼(parellel free-viewing),也可以用图11.7中的例子尝试一下。

译注:交叉眼平行眼是两种裸眼3D方式,分别是使双眼的聚焦点前置和后置于呈现平面,这个链接 的说明很形象,里面还有两种方式的练习。

3D电视(3D TV)

因为我们正好讨论到立体影像的话题,那就更进一步吧。“平滑图像”(SmoothPicture)是一种实现3D电视(“3D TV”)的技术,它通过空间插值(spatially interlacing)将左右两张图合成到一张立体图中,就像图11.9 展示的那样。在合成之前,两张分离图被以国际象棋棋盘那种交错形式被打散。支持3D模式的数字电视把单张图像分解成这种左右图,将其刷新率翻倍至120赫兹,然后按 左-右-左-右……的顺序交替显示。通过快门式的立体眼镜(shutterglass stereo),两张图像会被过滤到观众对应的眼睛中去。

幸运的是,已经有用来生成这种空间插值信号的程序可以很简单地用上。左右眼视图需要被分别渲染到它们自己的纹理中去。类似下面的片元着色器可以生成棋盘式交错样式。一种简单的方法是用内置的 gl_FragCoord 窗口相关像素空间坐标系,来决定着色器当前是应该接收左眼图还是右眼图。

uniform sampler2D uLeftUnit, uRightUnit;
in vec2 vST;
out vec4 fFragColor;

void main( ) {
    int row = int( gl_FragCoord.y );
    int col = int( gl_FragCoord.x );
    int sum = row + col;
    vec4 color;
    if( ( sum % 2 ) == 0 )
        color = texture( uLeftUnit, vST );
    else
        color = texture( uRightUnit, vST );
    fFragColor = vec4( color.rgb, 1. );
}

图11.10 的例子说明了这看起来是什么样的。其中的左右眼图(这里的图是用立体摄像机拍的,但也可以用电脑生成那种方式简单处理。)例子中也展示了它们的空间插值结果图,和一张能更清楚看到棋盘交错样式的放大图。
在这里插入图片描述
快门式立体眼镜,即双眼通过偏振镜分别观察到图像,多年来一直是一种主流的视觉系统,在建筑、生物、化学、计算机辅助设计、地理等领域有着重要的应用。除了理所当然的被应用在娱乐应用中外,3D电视也会是科学研究和工程设计中的重要工具。

边缘检测(Edge Detection)

边缘检测是一种经典的图像处理技术,而且可以很容易地在片元着色器中实现。我们将在这里介绍的边缘检测过程会用到一对索贝尔算子(Sobel filter),一个用于横向处理,一个用于纵向处理。下面左边是横向 Sobel 算子,纵向 Sobel 算子是一样的,只不过经过了90度旋转。
在这里插入图片描述
Sobel 算子的效果是一列列(或一行行,取决于正在用哪种算子)地对比两列(或行)数据,如果颜色相当接近,就说明没有边缘,算子会返回一个很小的值;如果返回值“很大”,说明这里有一个边缘被检出。这个检测既可以对原图,也可以对明度图进行。
在这里插入图片描述
注意图11.11 中最右边的那张图,算子计算结果被黑白色所表示,没有边缘的区域,输出颜色偏黑;有边缘的区域,输出颜色偏亮。尽管在大多应用中,相对于简单的渲染显示,你会需要基于这些边缘的计算结果决定更进一步的处理,但这个可视化结果已经充分表明了边缘检测的概念。

下面是实现这个过程的片元着色器代码。

  1. 从图像纹理中取出 3×3大小像素集合的颜色;
  2. 通过对每个色值用明度权重向量进行点乘,将其转化成一张 3×3 的灰度图;
  3. 应用横向纵向 Sobel 算子得到两个结果,并通过组合向量计算长度的方式得到一个灰度值;
  4. 最后,用 glman 变量 uT 和原图混合成输出结果。
ivec2 ires = textureSize( uImageUnit, 0 );
float ResS = float( ires.s );
float ResT = float( ires.t );
vec3 irgb = texture( uImageUnit, vST ).rgb;

vec2 stp0 = vec2(1./ResS, 0. );
vec2 st0p = vec2(0. , 1./ResT);
vec2 stpp = vec2(1./ResS, 1./ResT);
vec2 stpm = vec2(1./ResS, -1./ResT);

const vec3 W = vec3( 0.2125, 0.7154, 0.0721 );
float i00 = dot( texture( uImageUnit, vST ).rgb, W );
float im1m1 = dot( texture( uImageUnit, vST-stpp ).rgb, W );
float ip1p1 = dot( texture( uImageUnit, vST+stpp ).rgb, W );
float im1p1 = dot( texture( uImageUnit, vST-stpm ).rgb, W );
float ip1m1 = dot( texture( uImageUnit, vST+stpm ).rgb, W );
float im10 = dot( texture( uImageUnit, vST-stp0 ).rgb, W );
float ip10 = dot( texture( uImageUnit, vST+stp0 ).rgb, W );
float i0m1= dot( texture( uImageUnit, vST-st0p ).rgb, W );
float i0p1 = dot( texture( uImageUnit, vST+st0p ).rgb, W );
float h= -1.*im1p1-2.*i0p1-1.*ip1p1+1.*im1m1+2.*i0m1+1.*ip1m1;
float v= -1.*im1m1-2.*im10-1.*im1p1+1.*ip1m1+2.*ip10+1.*ip1p1;

float mag = length( vec2( h, v ) );
vec3 target = vec3( mag, mag, mag );
fFragColor = vec4( mix( irgb, target, uT ), 1. );

压印效果(Embossing)

我们对边缘检测的想法做一点调整,基于边缘的角度来高亮原图的明度图。这个调整得到的结果就是在图像处理程序中经常出现的压印操作。图11.12展示了压印操作的结果,后面是实现该效果的片元着色器
在这里插入图片描述
这段代码包括了一个用来区分生成灰度或者彩色压印图的宏定义

#define GRAY
uniform sampler2D uImageUnit;
in vec2 vST;
out vec4 fFragColor;

void main( ) {
    ivec2 ires = textureSize( uImageUnit, 0 );
    float ResS = float( ires.s );
    float ResT = float( ires.t );
    vec3 irgb = texture( uImageUnit, vST ).rgb;
    vec2 stp0 = vec2(1./ResS, 0. );
    vec2 stpp = vec2(1./ResS, 1./ResT);
    vec3 c00 = texture( uImageUnit, vST ).rgb;
    vec3 cp1p1 = texture( uImageUnit, vST + stpp ).rgb;
    vec3 diffs = c00 - cp1p1; // 
    vector difference float max = diffs.r;
    if ( abs(diffs.g)) > abs(max) ) max = diffs.g; 
    if ( abs(diffs.b)) > abs(max) ) max = diffs.b;
    float gray = clamp( max + .5, 0., 1. );
    vec3 color = vec3( gray, gray, gray );
    fFragColor = vec4( color, 1. );
}

卡通化着色器(Toon Shader)

有许多种着色器被称为卡通着色器。其中一种着色器是来自3D图形领域的,它会把图像色值离量化,用黑色增强边缘,这种“卡通着色器”被很多商用3D图形包所使用,之所以这么命名是因为处理结果看上去像是手绘卡通。
在这里插入图片描述
这种着色器的工作原理相对简单(译注:吐槽一下,原文是 operate in a relatively simple fashion,又是之前没有过的短语。作者真是喜欢花式表达各种算法都很“简单”,可能真的想让读者更容易接受这些代码实现过程),其利用了上面一节的边缘检测算子,和一些颜色量化方法。在3D着色器中,这两个模型都作为图像增强的方式存在。在一个较高的级别上,2D卡通着色器的工作过程是:

  1. 计算每个像素的明度值;
  2. 应用 Sobel 算子以得到一个量级数值;
  3. 如果数值 > 阈值,把颜色置黑;
  4. 否则,量化像素色值;
  5. 输出处理后的像素色值。

下面的片元着色器代码展示了这个过程,通过设置给 glman 的变量 MagTol 和 Quantize 来控制图像处理过程。代码首先获取到 3×3 算子所需的九个纹理像素值,转换成饱和度数值(译注:与明度点乘),然后分别应用横向和纵向 Sobel 算子,组合后做边缘检测。最后色值被量化,以模拟手绘卡通的行为。

uniform sampler2D uImageUnit, uBeforeUnit, uAfterUnit;
uniform float uMagTol;
uniform float uQuantize;
in vec2 vST;
out vec4 fFragColor;

void main( ) {
    ivec2 ires = textureSize( uImageUnit, 0 );
    float ResS = float( ires.s );
    float ResT = float( ires.t );

    vec3 irgb = texture( uImageUnit, vST ).rgb;
    vec3 brgb = texture( uBeforeUnit, vST ).rgb;
    vec3 argb = texture( uAfterUnit, vST ).rgb;

    vec3 rgb = texture( uImageUnit, vST ).rgb;
    vec2 stp0 = vec2(1./uResS, 0. );
    vec2 st0p = vec2(0. , 1./uResT);
    vec2 stpp = vec2(1./uResS, 1./uResT);
    vec2 stpm = vec2(1./uResS, -1./uResT);

    const vec3 W = vec3( 0.2125, 0.7154, 0.0721 );
    float i00 = dot( texture( uImageUnit, vST).rgb, W );
    float im1m1= dot( texture( uImageUnit, vST-stpp ).rgb, W ); 
    float ip1p1= dot( texture( uImageUnit, vST+stpp ).rgb, W ); 
    float im1p1= dot( texture( uImageUnit, vST-stpm ).rgb, W ); 
    float ip1m1= dot( texture( uImageUnit, vST+stpm ).rgb, W ); 
    float im10 = dot( texture( uImageUnit, vST-stp0 ).rgb, W ); 
    float ip10 = dot( texture( uImageUnit, vST+stp0 ).rgb, W ); 
    float i0m1 = dot( texture( uImageUnit, vST-st0p ).rgb, W ); 
    float i0p1 = dot( texture( uImageUnit, vST+st0p ).rgb, W );

    // next two lines apply the H and V Sobel filters at the pixel 
    float h= -1.*im1p1-2.*i0p1-1.*ip1p1+1.*im1m1+2.*i0m1+1.*ip1m1; 
    float v= -1.*im1m1-2.*im10-1.*im1p1+1.*ip1m1+2.*ip10+1.*ip1p1; 
    float mag = length( vec2( h, v ) ); // how much change is there?

    if( mag > uMagTol ) { 
        // if too much, use black
        fFragColor = vec4( 0., 0., 0., 1. );
    } else {
        // else quantize the color
        rgb.rgb *= uQuantize;
        rgb.rgb += vec3( .5, .5, .5 ); // round 
        ivec3 intrgb = ivec3( rgb.rgb ); // truncate 
        rgb.rgb = vec3( intrgb ) / Quantize; 
        fFragColor = vec4( rgb, 1. );
    }
}

译注:代码中的量化过程,即在某个级别上对色值离散化。当参数为10时,效果等同于在小数点后一位的级别上进行四舍五入。

艺术化效果(Artistic Effects)

如果你用过诸如 Photoshop 这种商用,或者 GIMP 这种免费分发的图像处理软件,会发现一些使图片看上去像是一幅画,或是糅合了好几种其他处理效果的 “艺术化滤镜”。考虑我们自己如何用 GLSL 片元着色器来创造这些艺术效果是很有趣的。

一种常见的做法是,取出和当前纹素相邻区域的几个纹素,经过某种类型的处理,得到一个结果色值。举例来说,你可以选择区域中具有最大明度的纹素。在图11.14 中我们对在这一章中反复出现的樱花图应用了这个处理,处理过程简单直接:我们对每个像素,找出周围 5×5 大小的纹素矩形 R,和一个同样 5×5 大小的遮罩矩形 M,M中的值是简单的0或1。接着对集合 R*M ,取其中明度值最大的纹素,替换掉当前的处理像素。因为在编写本书时,GLSL 不支,索引数组形式的参数,所以着色器代码很长,就不在这里给出了,你们可以在本书附带资源中找到它。

对于遮罩矩形,可以随意选择其中的值哪些是0,哪些是1,而改变遮罩的“形状”,会使滤镜效果发生变化。除了最大明度值,你可以同时采用一些其他的方法来共同决定像素色值。这其中有足够的试验田来产出丰富的效果!
在这里插入图片描述

图像翻转、旋转和扭曲(Image Flipping, Rotation, and Warping)

在之前的单图像处理示例中,我们都做的是点内的像素处理。我们也可以通过操作点坐标,取到图像中其他像素点的颜色来计算色值。

在我们生成特定图像的扭曲图以获得独特的效果的过程中,拥有不同类型的基准图,对扭曲的检测是很有用的。取决于你想看到的效果,有很多这样的基准图,图11.15 给出了一个简单的矩形网格图,将用于检测图像细节变化。
在这里插入图片描述
正如本章一直在用的那样,我们用纹理映射的方式来处理像。当得出一个像素点的原始坐标后,我们给这个坐标应用一个函数。因为纹理空间是一个单元化的正方形,就须要使函数在 [0, 1]×[0, 1] 的范围内做空间映射,有时候我们也可能想放大并平移这个范围到 [-1, 1]×[-1, 1] 来使那些常见的计算函数(如三角函数)更容易被应用。后面的示例和练习会帮助你弄明白我们为什么这么说。

其中一个最为简单的基于坐标地址的图像处理是 图像翻转。有横向和纵向这两种翻转,在纵向翻转中,我们交换图像上方和下方的像素值,结果就得到图像绕横向中轴线的镜像图。在横向翻转中,我们交换图像左边和右边的像素值,得到绕纵向中轴线的镜像图。

在纹理坐标中,用很简单的计算就能翻转图像。因为纹理坐标系的范围是 [0, 1],函数 t = 1 - t 会反转这个范围内的坐标值 t 的顺序。如果这个函数被用在片元着色器的纹理坐标系中:

vec2 st = vST;
st.t = 1. - st.t;
vec3 irgb = texture( uImageUnit, st ).rgb;
fFragColor = vec4( irgb, 1. );

那么结果图就会是“上下颠倒”,或者说是纵向翻转的。同理可以很简单地得出通过操作纹理坐标的横向翻转的实现代码。

简单的图像旋转(90度旋转)的实现方式比较类似。比如你想把图像逆时针旋转90度,可以简单地把原始坐标中的 s 换成 t,把 t 换成 s。(看看你是否能快速知道这里为什么需要一个“1-”操作)用一个双参数函数表示这个过程就是 f(st) = (t, 1 - s)。其他的简单旋转和这类似。通常旋转处理被简单地用做图形旋转功能,但实际应用中因为需要保留矩形的形状而变得复杂化了,这里我们不考虑这点。

用图像中其他某个地方的点来填充当前像素的玩法会更有趣。你可以应用想要的任何函数或者计算过程来计算任何点的像素值,只要计算结果还在像素空间的单元方形之内。这种通过对像素空间地址应用计算函数的图像处理过程叫做 图像扭曲 [46],它有很多潜在用途。

在大多数图像扭曲应用中,随着函数参数值的变化,函数会有很大的效果影响范围。幸运的是,在 glman 中可以很容易地通过滑条变量来控制这些参数。例如,我们把函数 x = x + t * sin(π * x) 作用于纹素的两个坐标值上,可以在图11.16 中看到这种扭曲在两个参数t值时被分别用在网格基准图和樱花图的结果。
在这里插入图片描述
下面是实现这种效果的片元着色器代码:

const float PI = 3.14159265;
uniform sampler2D uImageUnit;
uniform float uT;
in vec2 vST;
out vec4 fFragColor;

void main( ) {
    vec2 st = vST;
    vec2 xy = st;
    xy = 2. * xy - 1.;    // map to [-1,1] square
    xy += uT * sin(PI*xy);
    st = (xy + 1.)/2.;    // map back to [0,1] square
    vec3 irgb = texture( uImageUnit, st ).rgb;
    fFragColor = vec4( irgb, 1. );
}

有些图像扭曲对像素坐标的运算更加复杂。转动变换twirl transformation)是其中的一个,其他的会在练习题中呈现。对于转动变换,我们将纹理坐标转换到像素坐标,应用转动变换函数,然后转换回纹理坐标,并得到实际像素值。

转动变换的效果是,围绕一个给定的锚点 (xc , yc) ,以某个角度对图像进行旋转,这个角度值在从中心点向外 rmax 的距离范围内,从 α 开始线性递减到0。在半径 rmax 之外的图像保持不变。下面的公式中,(x′, y′) 是原始像素坐标,(x, y) 是变换后的像素坐标,可以在色器代码里查看公式的实现。这个变换的反向映射公式也一并给出:
在这里插入图片描述
图11.17 展示了网格图和樱花图的扭曲结果。
在这里插入图片描述
在下面的转动变换片元着色器中,观察像素坐标的变化,注意角度 α 和限制半径 rmax 两个一致变量。

const float PI = 3.14159265;
uniform sampler2D uImageUnit;
uniform float uD, uR;
in vec2 vST;
out vec4 fFragColor;

void main( ) {
 ivec2 ires = textureSize( uImageUnit, 0 );
 float Res = float( ires.s ); // 假设这是个方形纹理图

 vec2 st = vST;
 float Radius = Res * uR;
 vec2 xy = Res * st; // 从 纹理坐标系 转换 到 像素坐标系

 vec2 dxy = xy - Res/2.; // 转动中心是 (Res/2, Res/2) 
 float r = length( dxy );
 float beta = atan(dxy.y,dxy.x) + radians(uD)* (Radius-r)/Radius;

    vec2 xy1 = xy;
    if (r <= Radius) {
       xy1 = Res/2. + r * vec2( cos(beta), sin(beta) );
    }
    st = xy1/Res; // 坐标系恢复

    vec3 irgb = texture( uImageUnit, st ).rgb;
    fFragColor = vec4( irgb, 1. );
  }

当然,图像扭曲不是必须有一致性,你可以用前面章节中提到的噪声函数来修改图像的原像素地址(译注:不具有一致性即变量类型不必是 uniform 的,下面的代码里有一个用来读取噪声的变量,但在 draw call 中没发生变化,不明白这么用的原因是什么,需要回看讲噪声函数的章节)。下面是示例的片元着色器代码,图11.18 是其结果图示。
在这里插入图片描述

uniform sampler2D uImageUnit;
uniform float uT;
uniform sampler3D Noise3;
in vec3 vMCposition;
in vec2 vST;
out vec4 fFragColor;

void main( ) {
    vec2 st = vST;
    float x = st.x;
    float y = st.y; // extract coordinates
    vec4 noisevecx = texture( Noise3, vMCposition );
    vec4 noisevecy = texture( Noise3, vMCposition+vec3(noisevecx) );
    x += uT*(noisevecx[.r]-noisevecx[.g]+noisevecx[.b]+noisevecx[.a]-1.);
    y += uT*(noisevecy[.r]-noisevecy[.g]+noisevecy[.b]+noisevecy[.a]+1.);
    st = vec2( x, y ); // restore coordinates
    vec3 irgb = texture( uImageUnit, st ).rgb;
    fFragColor = vec4( irgb, 1. );
}

有一种结合了图像扭曲和图像混合的效果是 图像渐变image morphing),随着时间推移从一张图渐变到另一张。这个效果中的图像混合部分在下面的几个小节中会进行讲解,然后图像扭曲部分是一个非常特定的过程,从图像中一组固定像素点映射到另一张图的一组固定像素点,同时图像混合由参数控制,这样在最初是图一的几何形状,最后会变成图二的几何形状。这种效果超出了本书的讨论范围,不过可以从 [46] 中了解到更多内容。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值