基于DoG的2D卡通化渲染实现流程和原理

  • Post author:
  • Post category:其他


背景和目标

卡通渲染是图形学里非真实感渲染的一种。卡通化分为很多流派,比如美式动画/日式动画等。我们的目标效果是AE风格化里的卡通化效果。

效果图: 只画线稿:
604ec89253d7abeae49a0a8334f93242.jpeg

画线稿并着色:
7db889cb114743abacb937b5fcd62dbd.jpeg

左图是原图,右图是我们自制AE卡通化插件的效果,和AE原版卡通化效果比较接近。

渲染流程

分为三步,本文依次标记为”前菜”(预处理 平滑图片), “主餐”(重头戏 描边) 和 “甜点”(颜色处理 色块化);

前菜

双边过滤

卡通化时,我们既想看到平滑的图像,又需要有清晰的描边,所以会先选择一个合适的滤波器来预处理图像。这个滤波器就是双边过滤。AE双边过滤的效果图: 平滑图像,保留边缘信息
7a31309385c3b71fc34ff2925efbc747.jpeg

双边的意思是,滤波器通过位置权重和像素颜色相似度两个维度来做模糊平滑。因此,双边过滤能在平滑图像的同时,保留剧烈变化的像素区域,也就是边缘细节被保留了。核心代码:

centralColor = SHDR_TEXTURE2D(uTexture, blurCoordinates[4]);
gaussianWeightTotal = 0.18;
sum = centralColor * 0.18;
sampleColor = SHDR_TEXTURE2D(uTexture, blurCoordinates[0]);
//计算采样点和中心点的像素颜色差距
distanceFromCentralColor = min(distance(centralColor, sampleColor), uThreshold)/uThreshold;
// 颜色权重,颜色差别越大,权重越低
gaussianWeight = 0.05 * (1.0 - distanceFromCentralColor); 

gaussianWeightTotal += gaussianWeight;
sum += sampleColor * gaussianWeight;
。。。
。。。
sampleColor = SHDR_TEXTURE2D(uTexture, blurCoordinates[7]);
distanceFromCentralColor = min(distance(centralColor, sampleColor), uThreshold)/uThreshold;
// 位置权重,这个位置离中心点更近,所以第一个系数比上面的大
gaussianWeight = 0.09 * (1.0 - distanceFromCentralColor);
gaussianWeightTotal += gaussianWeight;
sum += sampleColor * gaussianWeight;

sampleColor = SHDR_TEXTURE2D(uTexture, blurCoordinates[8]);
distanceFromCentralColor = min(distance(centralColor, sampleColor), uThreshold)/uThreshold;
gaussianWeight = 0.05 * (1.0 - distanceFromCentralColor);
gaussianWeightTotal += gaussianWeight;
sum += sampleColor * gaussianWeight;
outColor = sum / gaussianWeightTotal;

主餐

高斯差分(DoG)描边

几种常见的边缘检测方法
d39a33d19349bbcd58bb1a5eea40475a.jpeg

Sobel检测就是用一个小的基于位置加权的一阶卷积核,在x轴和y轴方向分别逐像素计算灰度的梯度值, 满足阈值的像素就标定为边缘。速度比较快。但因为卷积核比较小,所以只能找到局部的边缘,比较粗的边缘可能会漏掉
e81594f687e1c6f493713383f595f126.png

Canny检测

  1. 先通过高斯模糊预处理

  2. 用非极大值抑制的方法筛选出像素色值梯度变化较大的点,也就是最有可能为边缘的点。

  3. 通过双阈值检测,保留高置信度的点,舍弃置信度的点。中等置信度的点如果跟高置信度的点挨着,则也当做是边缘点,以防止边缘断断续续

选择恰当的阈值参数后,会得到比较清晰准确的描边。但由于在梯度方法非极大值抑制,只会取梯度变化最大的像素,所以画出来的线条是单像素宽度。也就是线条的宽度无法反映真实边缘的粗细

2bbb314040b49ed5b232b45e53f697d0.jpeg

Canny.png


再次对比Sobel / Canny / DoG的效果差异

5369c7403732d3e10a7b078c19b5b665.jpeg

高斯差分在计算效率和保留边缘特征上,取得了较好的平衡。原理解释:


先来复习高斯模糊:


我们知道,高斯模糊能够平滑图像,弱化噪点。边缘,噪点,颜色跳跃等图像变化剧烈的区域,相邻像素点之间颜色差异较大,是图像里的高频信号,在高斯模糊后被弱化。而色块/背景等颜色变化缓慢的区域对应的像素点,是低频信号,在高斯模糊后变化不大。所以高斯模糊是一个低通滤波器,保留低频信号,去除或者衰减高频信号。

再来理解高斯差分:图示
b19799dd5200cd9b9c49e8934147f9cb.jpeg

公式: D0(coord, σ, k, I) = G(coord, σ, I) − tau * G(coord, k · σ, I)



I表示Image,即输入图



G表示高斯函数



coord表示二维坐标



σ 表示高斯分布标准差, 标准差约大,函数曲线越扁平,一般k大于1,也就是高抖的Center高斯函数减去平坦的Surround高斯函数



k用来控制两个高斯函数的差距,当k = 1.6时,DoG的结果和高斯拉普拉斯算子的结果差不多,具有较好的描边效果



tau 扩展参数,用于调整两个高斯滤波器的权重,为线条增加更多变化空间

高斯差分的意思,简单概况,就是用一个窄高斯核减去一个宽高斯核,得到的是一个带通滤波器。窄高斯核模糊强度小,去掉了噪点等非常高频的信号,保留了较多的原始图像信息。宽高斯核模糊强度大,去掉了噪点等很高频的信号的同时,也把边缘点等比较高频的信号也去掉了。两者相减,留下的就是边缘了

DoG核心代码:

void main(void){
    vec3 destColor = vec3(0.0);
        // 参数初始化
        float tFrag = 1.0 / cvsHeight;
        float sFrag = 1.0 / cvsWidth;
        vec2  Frag = vec2(sFrag,tFrag);
        vec2 uv = vec2(gl_FragCoord.s, cvsHeight - gl_FragCoord.t);
        float twoSigmaESquared = 2.0 * sigma_e  * sigma_e;
        float twoSigmaRSquared = 2.0 * sigma_r  *  sigma_r;
        int halfWidth = int(ceil( 2.0 * sigma_r ));
        const int MAX_NUM_ITERATION = 99999;
        vec2 sum = vec2(0.0);
        vec2 weightSum = vec2(0.0);
        //卷积核
        for(int cnt=0;cnt<MAX_NUM_ITERATION;cnt++){ 
            if(cnt > (2halfWidth+1)(2*halfWidth+1)){break;}
            int i = int(cnt / (2*halfWidth+1)) - halfWidth
            int j = cnt - halfWidth - int(cnt / (2halfWidth+1))  (2*halfWidth+1);// 近似的高斯模糊采样
            float d = length(vec2(i,j));
            // 两个高斯核,位置权重,标准差不同,赋值给kernel的x变量和y变量
            vec2 kernel = vec2( exp( -d * d / twoSigmaESquared ), 
                                exp( -d * d / twoSigmaRSquared )); 
            vec2 L = texture2D(src, (uv + vec2(i,j)) * Frag).xx;  
            weightSum += 2.0 * kernel;
            // sum保存两个高斯模糊的结果
            sum += kernel * L; 
        }
        sum /= weightSum;
        //差分运算
        float H = 100.0  (sum.x - tau * sum.y); 
        // 检测结果小于0位为黑色边缘。通过映射函数确定灰度程度,比如为过渡浅色边缘
        float edge = ( H > 0.0 )? 1.0 : 2.0 * smoothstep(-2.0, 2.0, phi  * H ); 
        destColor = vec3(edge);
    gl_FragColor = vec4(destColor, 1.0);
}

甜点

色彩量化(posterization)

色差量化是减少图像中颜色数量的过程, 会让图像色块化,呈现出卡通化的风格。
8e47d0c18fa2b398bf1391b7fc8e6887.jpeg

我们选择比较简单的,根据灰色值来做色彩量化。核心代码:

// gamma矫正 
posterizedColor = pow(posterizedColor, vec3(GAMMA_INVERSE));
//计算像素的灰度值
float greyscale = 0.299 * posterizedColor.r + 0.587 * posterizedColor.g + 0.114 * posterizedColor.b;
//uLevel 控制的是灰色值分为几级,级数越小,色块越明显
//四舍五入归到对应的离散的灰度值
float level = floor(greyscale * uLevel + 0.5) / uLevel;
//计算灰度值被调整的幅度
float adjustment = level / greyscale;
//像素的RGB值乘以幅度,就是像素被色彩量化后的结果,就会有色块的风格了
posterizedColor *= adjustment;
// gamma矫正 是为了让色彩量化的效果更接近AE的效果
posterizedColor = pow(posterizedColor, vec3(GAMMA));

优化

  1. 双边过滤时,因为顶点着色器的执行次数更少,所以在顶点着色器里做采样坐标的计算,作为varying变量。然后在光栅化阶段,硬件会把这些坐标值自动线性插值到片段着色器里

代码示例:

void main() {
    gl_Position = aPos;
    vTexCoord = aTexCoord.xy;

    int multiplier = 0;
    vec2 blurStep;

    for (int i = 0; i < GAUSSIAN_SAMPLES; i++) {
        multiplier = (i - ((GAUSSIAN_SAMPLES - 1) / 2));
        blurStep = float(multiplier) * uStep;
        blurCoordinates[i] = aTexCoord.xy + blurStep;
    }
}
  1. DoG时,将卷积里的指数/开根号等运算在CPU里算好,然后以uniform数组的形式传到片段着色器里,作为卷积时的运算系数,提高了渲染速度

代码示例:

//优化前的卷积运算
        for(int cnt=0;cnt<MAX_NUM_ITERATION;cnt++){ 
            if(cnt > (2halfWidth+1)(2*halfWidth+1)){break;}
            int i = int(cnt / (2*halfWidth+1)) - halfWidth
            int j = cnt - halfWidth - int(cnt / (2halfWidth+1))  (2*halfWidth+1);// 近似的高斯模糊采样
            float d = length(vec2(i,j));
            // 两个高斯核,位置权重,标准差不同
            vec2 kernel = vec2( exp( -d * d / twoSigmaESquared ), 
                                exp( -d * d / twoSigmaRSquared )); 
            vec2 L = texture2D(src, (uv + vec2(i,j)) * Frag).xx;  
            norm += 2.0 * kernel;
            // 两个高斯模糊的结果
            sum += kernel * L; 
        }
  
  //优化后的卷积运算
  for (int cnt = 0; cnt < maxNumberIteration; ++cnt) {
                 int i = int(params[cnt].x);
                int j = int(params[cnt].y);
                 vec2 kernel = vec2(params[cnt].z, params[cnt].w);
                 vec4 c = texture2D(uTexture, (uvPixel + vec2(i, j)) * Frag);
                 vec2 L = vec2(0.299 * c.r + 0.587*c.g + 0.114*c.b);
                 sum += kernel * L;
}
// norm是uniform值,已经在cpu里算好
sum /= norm;
  1. 高斯模糊具有可分性,也就是可以通过分别对 X 轴和 Y 轴进行两次高斯模糊来提高性能。而差分高斯模糊

D0(coord, σ, k, I) = G(coord, σ, I) − tau * G(coord, k · σ, I) 是高斯模糊的乘法和减法运算,仍然具有x/y方向可分的性质。这点我在做本文档的时候才发现,以后抽空可以尝试优化,看看效果

扩展-基于DoG扩展的XDoG

在DoG的基础上,再添加一些参数,能表现更多的风格化类型。
b98bd7119d8699f3d028eed548cf9b18.jpeg

可以看到,设置e为较小的值,则更多的像素点会变成边缘。最后一个参数ϕ影响描边深浅的过渡梯度。所以增加的参数和tanh双曲正切函数计算增加了更多的风格化效果类型。变换不同参数,风格化结果示例如下:
63f020f523583a4431ab6e4453663d5d.jpeg

参考文章:

XDoG: Advanced Image Stylization with eXtended Difference-of-Gaussians

GPU-Based-Image-Processing-Tools


技术问答,学习成长,欢迎加入音视频开发进阶知识星球

f246b970e25072e470c30f727a791ef3.jpeg

0493913205dd53e17bb42e05760e077b.jpeg

技术交流,欢迎加我微信:

ezglumes

,拉你入技术交流群。

1e46232b82c6b801d19b428c640127bf.jpeg

私信领取相关资料


推荐阅读:


音视频开发工作经验分享 || 视频版


OpenGL ES 学习资源分享


开通专辑 | 细数那些年写过的技术文章专辑


Android NDK 免费视频在线学习!!!


你想要的音视频开发资料库来了


推荐几个堪称教科书级别的 Android 音视频入门项目

觉得不错,点个在看呗~

12f9f0ac173996fa989071e116bda1da.gif