背景和目标
卡通渲染是图形学里非真实感渲染的一种。卡通化分为很多流派,比如美式动画/日式动画等。我们的目标效果是AE风格化里的卡通化效果。
效果图: 只画线稿:
画线稿并着色:
左图是原图,右图是我们自制AE卡通化插件的效果,和AE原版卡通化效果比较接近。
渲染流程
分为三步,本文依次标记为”前菜”(预处理 平滑图片), “主餐”(重头戏 描边) 和 “甜点”(颜色处理 色块化);
前菜
双边过滤
卡通化时,我们既想看到平滑的图像,又需要有清晰的描边,所以会先选择一个合适的滤波器来预处理图像。这个滤波器就是双边过滤。AE双边过滤的效果图: 平滑图像,保留边缘信息
双边的意思是,滤波器通过位置权重和像素颜色相似度两个维度来做模糊平滑。因此,双边过滤能在平滑图像的同时,保留剧烈变化的像素区域,也就是边缘细节被保留了。核心代码:
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)描边
几种常见的边缘检测方法
Sobel检测就是用一个小的基于位置加权的一阶卷积核,在x轴和y轴方向分别逐像素计算灰度的梯度值, 满足阈值的像素就标定为边缘。速度比较快。但因为卷积核比较小,所以只能找到局部的边缘,比较粗的边缘可能会漏掉
Canny检测
-
先通过高斯模糊预处理
-
用非极大值抑制的方法筛选出像素色值梯度变化较大的点,也就是最有可能为边缘的点。
-
通过双阈值检测,保留高置信度的点,舍弃置信度的点。中等置信度的点如果跟高置信度的点挨着,则也当做是边缘点,以防止边缘断断续续
选择恰当的阈值参数后,会得到比较清晰准确的描边。但由于在梯度方法非极大值抑制,只会取梯度变化最大的像素,所以画出来的线条是单像素宽度。也就是线条的宽度无法反映真实边缘的粗细
再次对比Sobel / Canny / DoG的效果差异
高斯差分在计算效率和保留边缘特征上,取得了较好的平衡。原理解释:
先来复习高斯模糊:
我们知道,高斯模糊能够平滑图像,弱化噪点。边缘,噪点,颜色跳跃等图像变化剧烈的区域,相邻像素点之间颜色差异较大,是图像里的高频信号,在高斯模糊后被弱化。而色块/背景等颜色变化缓慢的区域对应的像素点,是低频信号,在高斯模糊后变化不大。所以高斯模糊是一个低通滤波器,保留低频信号,去除或者衰减高频信号。
再来理解高斯差分:图示
公式: 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)
色差量化是减少图像中颜色数量的过程, 会让图像色块化,呈现出卡通化的风格。
我们选择比较简单的,根据灰色值来做色彩量化。核心代码:
// 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));
优化
-
双边过滤时,因为顶点着色器的执行次数更少,所以在顶点着色器里做采样坐标的计算,作为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;
}
}
-
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;
-
高斯模糊具有可分性,也就是可以通过分别对 X 轴和 Y 轴进行两次高斯模糊来提高性能。而差分高斯模糊
D0(coord, σ, k, I) = G(coord, σ, I) − tau * G(coord, k · σ, I) 是高斯模糊的乘法和减法运算,仍然具有x/y方向可分的性质。这点我在做本文档的时候才发现,以后抽空可以尝试优化,看看效果
扩展-基于DoG扩展的XDoG
在DoG的基础上,再添加一些参数,能表现更多的风格化类型。
可以看到,设置e为较小的值,则更多的像素点会变成边缘。最后一个参数ϕ影响描边深浅的过渡梯度。所以增加的参数和tanh双曲正切函数计算增加了更多的风格化效果类型。变换不同参数,风格化结果示例如下:
参考文章:
XDoG: Advanced Image Stylization with eXtended Difference-of-Gaussians
GPU-Based-Image-Processing-Tools
技术问答,学习成长,欢迎加入音视频开发进阶知识星球
技术交流,欢迎加我微信:
ezglumes
,拉你入技术交流群。
私信领取相关资料
推荐阅读:
觉得不错,点个在看呗~