边缘检测是描边效果的一种实现方法,关于描边效果其实还有更好的基于深度+法线纹理实现的方法,这里就先以边缘检测为主进行学习。
1 理解卷积
哪位高手能解释一下卷积神经网络的卷积核? - 知乎 (zhihu.com)
卷积(Convolution),是图像处理中很常见的方法,平常也能在课程学习中(例如我《机器学习》这门课)看到它的身影(CNN,Convolutional neural network,即卷积神经网络)。但要注意,数学中的卷积和卷积神经网络中的卷积严格意义上是两种不同的运算方式。
1.1 数学中的卷积
数字图像处理中的卷积
数学中的卷积分为连续、离散两种卷积操作,但由于一般CNN都是离散卷积,所以数学卷积就拿二维离散卷积处理为例,我们可以拿数字图像处理为例,图像处理(Image Processing)是通过计算机技术将图像信号转化为数字信号,并利用数字信号对该图像进行处理的过程。
而图像处理中的卷积操作实际上是指使用一个卷积核对图像中的每个像素进行处理。卷积核(kernel)就是一个四方形网格结构(2X2、3X3的矩阵),网格的每个方格都有一个权重值,是一组权重的集合。
如何进行——当我们开始对一张图像的某个像素进行操作时,会把卷积核的中心放在该像素上,先翻转,再依次计算卷积核中每个权重值与覆盖像素值的乘积并求和,得到最终的新像素值。如果要对图像上多个像素处理,卷积核就先翻转,之后在进行平移、计算。
1.2 CNN中的卷积
卷积神经网络中的卷积,本质上是是信号处理中的互相关函数(Correlation)计算,或者说是相当于图像处理中的spatial filter。
跟数学上的卷积(Convolution)不同,CNN中的卷积更多的是为了提取图像的特征,仅仅借鉴了一个”加权求和“的概念。而且CNN本身就是一个寻求卷积核的过程,这个卷积核压根不像接下来的XX算子一样是给定的,而是未知、需要训练得出的,因此无需翻转。
2 边缘检测概述
边缘检测是图像处理和计算机视觉中一个常见的问题,目的是标出数字图像中亮度变化明显的点。参考高通滤波法、微分算子法、神经网络方法实现图像边缘检测,我们能够发现实际上实现边缘检测,有很多方法:高通滤波、微分算子法、神经网络方法。
其中,
- 高通滤波——检测图像某个区域,根据像素与周围像素的亮度插值来提升该像素亮度的滤波器
- 微分算子法——就是用一些特定的边缘检测算子(核)对图像进行卷积操作,我感觉就是高通滤波的一种具体的实现方法。对于边缘检测来说,这些算子(卷积核)在原始图像上移动,当图像上最亮的点经过核中央像素时,生成的新像素会比周围的更加突出,边缘这样被检测了出来,我们可以在后面的Sobel算子的代码去理解这个检测过程
(上述叙述参考了 计算机视觉(二):边缘检测)
2.1 边缘检测算子
首先,理解梯度
前面介绍微分算子法时就提到了图像的边缘是如何被检测出来的,换个思路还可以反过来想边缘是如何形成的?——边缘(edge)是指一个图像上局部特性的不连续性,图像上如果相邻像素之间存在显著的颜色、亮度、纹理等等属性的差别(突变),我们就会认为这里存在一条边界。从卷积角度理解就是:核经过亮像素点时,中间像素值和周围像素值会有一个突变,我们就用梯度(gradient)去描述这一个数值的突变。
不错!也就是说,边缘处的梯度总会是比较大的,那么现在目光聚焦到了:
获取梯度——算子
于是,为了获得这样一个梯度,几种不同的边缘检测算子(卷积核)相继被提出。详细一点可以直接参考这篇文章:常见边缘检测对比(Roberts算子、Prewitt算子、Sobel算子、Laplacian算子、Canny算子),这篇文章对几种常见的算子进行了公式上和优缺点的对比,同时给出了每种算子常用的场景,值得一看。
2.2 Sobel算子简介
参考了边缘检测算子(Roberts算子、Prewitt算子、Sobel算子 和 Laplacian算子)
由于后面实现边缘检测用到了Sobel算子,这之前必须要做一个简单的了解。
Sobel算子结合了高斯平滑和微分求导,充分考虑了位置对边缘影响——相邻点的距离远近对当前像素点的影响程度,距离越近的影响程度越大,从而可以实现图像锐化并突出边缘轮廓的效果。Sobel算子对噪声较多的图片进行边缘检测的效果更好!对灰度变化不敏感。
为什么算子的权重和都为0?
学着学着我发现用于边缘检测的卷积核,无论是简单的Roberts、Prewitt还是Sobel算子,核里的权重值和都为0,而且这几个卷积核是180°对称的(上下翻转是对称的),这是为啥?
找到了有人跟我有一样的疑问:为什么卷积核的系数和为零,卷积后的图像像素。也为零?有什么好处?
从数学角度,个人认为解释的较为贴切的回答:
如果理解的更浅显、通俗一点,还是拿Sobel算子为例,可以假设中间像素周围的像素值都十分相近(该像素并不在边缘位置),那么得到的梯度值一定是趋近于0的。
3 Unity中实现Sobel边缘检测
好了,上面一直介绍卷积、边缘检测,这里终于可以进行具体的实现了。与之前实现画面亮度、饱和度和对比度调整的过程类似,实现边缘检测也需要给Main Camera挂上一个特定的脚本,并给它相应的Material和Unity Shader。
3.1 摄像机挂上C#脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[ExecuteInEditMode]
public class edgeDetect : MonoBehaviour
{
public Material edgeDetectMaterial;
public Shader edgeDetectShader;
[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;
public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if(edgeDetectMaterial!=null)
{
edgeDetectMaterial.SetFloat("_EdgeOnly", edgesOnly);
edgeDetectMaterial.SetColor("_EdgeColor", edgeColor);
edgeDetectMaterial.SetColor("_BackgroundColor", backgroundColor);
Graphics.Blit(source, destination, edgeDetectMaterial);
}
else
{
Debug.LogWarning("Please input your Material");
Graphics.Blit(source, destination);
}
}
}
3.2 Shader代码
先放上完整代码:
Shader "Unity Shaders Book/Chapter 12/edgeDetectShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_EdgeOnly ("EdgeOnly", float) = 1.0
_EdgeColor ("EdgeColor", Color) = (0, 0, 0, 1)
_BackgroudColor ("BackgroundColor", Color) = (1, 1, 1, 1)
}
SubShader
{
Pass
{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment fragSobel
#include "UnityCG.cginc"
//properties
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroudColor;
struct v2f {
float4 pos : SV_POSITION;
half2 uv[9] : TEXCOORD0;
};
v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv + _MainTex_TexelSize.xy * half2(-1, -1);
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(0, -1);
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(1, -1);
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 0);
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(0, 0);
o.uv[5] = uv + _MainTex_TexelSize.xy * half2(1, 0);
o.uv[6] = uv + _MainTex_TexelSize.xy * half2(-1, 1);
o.uv[7] = uv + _MainTex_TexelSize.xy * half2(0, 1);
o.uv[8] = uv + _MainTex_TexelSize.xy * half2(1, 1);
return o;
}
fixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}
//自定义一个Sobel算子
half Sobel(v2f i) {
//定义卷积核:
const half Gx[9] =
{
-1, 0, 1,
-2, 0, 2,
-1, 0, 1
};
const half Gy[9] =
{
-1, -2, -1,
0, 0, 0,
1, 2, 1
};
half texColor;
half edgeX = 0;
half edgeY = 0;
for(int j=0;j<9;j++) {
texColor = luminance(tex2D(_MainTex, i.uv[j])); //依次对9个像素采样,计算明度值
edgeX += texColor * Gx[j];
edgeY += texColor * Gy[j];
}
half edge = 1 - abs(edgeX) - abs(edgeY); //绝对值代替开根号求模,节省开销
//half edge = 1 - pow(edgeX*edgeX + edgeY*edgeY, 0.5);
return edge;
}
fixed4 fragSobel(v2f i) : SV_Target {
half edge = Sobel(i);
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge); //4是原始像素位置
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroudColor, edge);
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}
ENDCG
}
}
FallBack Off
}
接下来挑我认为的重点细说一下。
计算梯度值
代码中依次计算9个方格覆盖的像素(包括当前中心像素)的明度值,分别与自己x和y方向的权重值相乘,叠加后,得到最终中心像素Gx和Gy的梯度值,接着计算edge值(绝对值代替了开根号,为了节省开销),edge越小(梯度越大),越有可能是边缘点。
为什么要给一个edge参数?
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge); //4是原始像素位置
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroudColor, edge);
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
我认为给一个edge值是为了实现用户通过调整_EdgeOnly的值控制边缘呈现样式的目的,我们知道:
- 当edge趋向于1时意味着当前像素是边缘点的几率小
- edge趋向于0时像素是边缘点的几率大;
从结果的lerp角度说:
- 当_EdgeOnly=0,非边缘点的颜色将会是像素本身的颜色,而边缘点的颜色将会是用户设定的边缘颜色(默认是黑色)
- 当_EdgeOnly=1,非边缘点的颜色将会是背景色(默认白色),而边缘点的颜色将会是用户设定的边缘颜色(默认是黑色)
3.3 最终效果
_EdgeOnly=0
_EdgeOnly=1
4 一些后话
通过结果图我们可以发现,通过Sobel算子进行的边缘检测操作,实现的描边效果其实不是很好,因为他把一些纹理、阴影等边界也给包括进去了。为了实现更好的描边效果,后面的博客将会介绍进阶版的结合深度和法线纹理的边缘检测实现描边效果的方案。