边缘检测和高斯模糊入门版

每次等知识真的用上了才会想为啥当初不再认真点听课,卷积暂时也没有整的很明白,尽力去理解和表达一下。

卷积?卷鸭!

边缘检测

边缘检测本质是高通滤波器,下面的图用于说明一张图片上高通和低通部分的区别。

先说说卷积和卷积核

卷积就是用卷积核对图像中每个像素进行一系列操作,而卷积核的中心是放在当前要处理的像素上,对于每一个当前像素,计算领域像素和滤波器矩阵对应元素的乘积,然后求和就得到了当前像素的值。

边缘检测的原理就是找到有着明显差别的颜色、亮度或纹理等属性的相邻像素,对应到数学知识上,这种差值可以用梯度 (gradient)来表示,即边缘处的梯度是比较大的。进而我们引入了边缘检测算子的概念,而我们常用的几个算子都包含了两个方向上的卷积核,即水平和数值;在进行边缘检测时,我们对每个像素进行一次卷积计算,得到两个方向上的梯度值Gx和Gy,然后通过下面的公式计算整体梯度

有时候出于性能考虑,可以使用绝对值代替开根号的操作:

梯度方向:

得到梯度G后,我们可以来判断哪些像素对应了边缘,即梯度值越大越可能是边界。

几种常见的边缘检测算子

Roberts算子

在进行Roberts算子的实现的时候,《Unity Shader入门精要》上给出的矩阵是

而在ITMO的Image Processing课程上给出的是符号和位置不同的

我们直接在OpenCV-Python中看一下三种形式算子得到的结果,可以看出第一张图和后两张的边界清晰度是不同的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def roberts():
# 第一张图
G_x = np.array([[1, -1], [0, 0]])
G_y = np.array([[1, 0], [-1, 0]])

# 第二张图
G_x = np.array([[-1, 0], [0, 1]])
G_y = np.array([[0, -1], [1, 0]])

# 第三张图
G_x = np.array([[1, 0], [0, -1]])
G_y = np.array([[0, 1], [-1, 0]])

if img.dtype == np.uint8:
img_filter = img.astype(np.float32) / 255
else:
img_filter = np.copy(img)

I_x = cv.filter2D(img_filter, -1, G_x)
I_y = cv.filter2D(img_filter, -1, G_y)

img_filter = cv.magnitude(I_x, I_y)

if img.dtype == np.uint8:
img_filter = (255 * img_filter).clip(0, 255).astype(np.uint8)

cv.imshow("Image1", img)
cv.imshow("Result1", img_filter)
cv.waitKey()

为了探究Roberts算子的值的位置所造成的不同结果,我们先要明白下这个二阶算子的原理(按第一种矩阵来说明)

Roberts算子是基于交叉差分的梯度算法;本质是计算左上角和右下角的差值,乘以右上角和左下角的差值,作为评估边缘的依据;常用于处理具有陡峭低噪声的图像,当边缘比较接近45度的时候算法处理比较理想,缺点是对边缘的定位不准确,提取的边缘线条比较粗。

因此我们谈到Roberts算子,一般使用的是具有对角值的矩阵,而非对角值矩阵所产生的边界会没有那么明显。

Prewitt算子及效果图

Prewitt算子对噪声有抑制作用,通过先对图像进行一个方向的归一化均值平滑,然后进行该方向的差分;但是Prewitt对于边缘的定位没有Roberts算子准确。

Sobel算子及效果图

在Unity中实现Sobel算子

以Sobel算子为例

  • 创建EdgeDetection的C#脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EdgeDetection : PostEffectsBase
{
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material {
get {
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}

[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;
public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;

void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);

Graphics.Blit(src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
}

PostEffectBase基类提供检测相关shader和材质的函数,实现 OnRenderImage获取当前屏幕渲染的纹理,将参数设置为相应的数值,调用Graphics.Blit使用特定的unity shader对当前图像进行处理,再把返回的渲染纹理显示到屏幕上

  • 创建相应的shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
Shader "Unity Shaders Book/EdgeDetection"
{
Properties
{
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_EdgeOnly ("Edge Only", Float) = 1.0
_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
_BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
}
SubShader
{
Pass {
ZTest Always Cull Off ZWrite Off

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;

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;

//定义维数为9的纹理数组,对应使用卷积核采样时所需要的9个邻域纹理坐标
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;
}

half Sobel(v2f i) {
const half Gx[9] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
const half Gy[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};

half texColor;
half edgeX = 0;
half edgeY = 0;
for (int it = 0; it < 9; it++) {
texColor = luminance(tex2D(_MainTex, i.uv[it]));
edgeX += texColor * Gx[it];
edgeY += texColor * Gy[it];
}

half edge = 1 - abs(edgeX) - abs(edgeY);

return edge;
}

fixed4 frag(v2f i) : SV_Target {
//利用Sobel函数计算当前像素的梯度值,并用这个梯度值分别计算背景为原图和纯色下的颜色值,利用_EdgeOnly在两者之间进行插值得到最终的像素值
half edge = Sobel(i);

fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}

ENDCG
}
}
FallBack Off
}

_MainTex_TexelSize是Unity为提供的访问某纹理对应的每个纹素的大小:如一张512x512的纹理,值为1/512;因为卷积需要对邻近区域内的纹理进行采样,所以需要用到这个值来计算各个相邻区域的纹理坐标

  • 将脚本附到摄像机上

edgesOnly参数用于调整边缘线强度:当值为0时,边缘会叠加在原渲染图像上;当值为1时,只会显示边缘,不显示原渲染图像

EdgesOnly为0时:

EdgesOnly为1时:

在Unity中实现Roberts算子

  • 脚本代码在Sobel算子的基础上添加一些新的属性,用于控制采样距离以及对深度和法线进行边缘检测时的灵敏度参数,并在后续OnRenderImage函数中将参数传递给材质
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;
public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;

public float sampleDistance = 1.0f;
public float sensitivityDepth = 1.0f;
public float sensitivityNormals = 1.0f;

void OnEnable() {
GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
}

[ImageEffectOpaque]
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
material.SetFloat("_SampleDistance", sampleDistance);
material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));

Graphics.Blit(src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}

[ImageEffectOpaque]属性使OnRenderImage函数在不透明的pass执行完毕后调用,而不对不透明物体产生影响;因此在对不透明物体描边而不希望透明物体也被描边时添加。

  • 对应shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
Shader "Unity Shaders Book/Edge Detection Normals And Depth" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_EdgeOnly ("Edge Only", Float) = 1.0
_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
_BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
_SampleDistance ("Sample Distance", Float) = 1.0
_Sensitivity ("Sensitivity", Vector) = (1, 1, 1, 1)
}
SubShader {
CGINCLUDE

#include "UnityCG.cginc"

//_Sensitivity的xy分量分别对应法线和深度的检测灵敏度;_CameraDepthNormalsTexture声明了需要获取的深度和法线纹理
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
sampler2D _CameraDepthNormalsTexture;

struct v2f {
float4 pos : SV_POSITION;
half2 uv[5]: TEXCOORD0;
};

v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

half2 uv = v.texcoord;
o.uv[0] = uv;

#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
uv.y = 1 - uv.y;
#endif

o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1,1) * _SampleDistance;
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1,-1) * _SampleDistance;
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1,1) * _SampleDistance;
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1,-1) * _SampleDistance;

return o;
}

//CheckSame函数用于计算对角线上两个纹理值的差值,返回值为0代表两点之间存在边界,否则为1;首先通过使用xy分量获得采样点的法线和深度值
half CheckSame(half4 center, half4 sample) {
half2 centerNormal = center.xy;
float centerDepth = DecodeFloatRG(center.zw);
half2 sampleNormal = sample.xy;
float sampleDepth = DecodeFloatRG(sample.zw);

half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
int isSameDepth = diffDepth < 0.1 * centerDepth;

return isSameNormal * isSameDepth ? 1.0 : 0.0;
}

fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target {
half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);

half edge = 1.0;

edge *= CheckSame(sample1, sample2);
edge *= CheckSame(sample3, sample4);

fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);

return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}

ENDCG

Pass {
ZTest Always Cull Off ZWrite Off

CGPROGRAM

#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal

ENDCG
}
}
FallBack Off
}

高斯模糊

高斯模糊本质也是卷积计算,使用的卷积核为高斯核——一个正方形大小的滤波器,每个元素基于以下方程计算,σ为标准差(一般取1),x和y分别对应当前位置到卷积核中心的整数距离。

计算得到卷积核中各个位置的高斯值后,为保证滤波后图像不会变暗,需要将核中的权重进行归一化——让每个权重除以所有权重的和,保证所有权重的和为1。高斯方程模拟了邻域像素离当前像素越近影响越大,因此高斯核维数越多,模糊程度越大。使用一个NxN的高斯核对图像进行卷积滤波,就需要NxNxWxH次纹理采样,因此当维数越多,采样次数会十分巨量。因此,可以将二维的高斯函数拆分成两个一维函数,即使用两个一维高斯核先后对图像进行滤波,采样次数减少为2xNxWxH。

在OpenCV-Python中实现

1
img_gauss = cv2.GaussianBlur(img_gray, (5, 5), 0)

效果图:

在Unity中实现高斯模糊

  • 创建高斯模糊对应的c#脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GaussianBlur : PostEffectsBase
{
public Shader gaussianBlurShader;
private Material gaussianBlurMaterial = null;

public Material material {
get {
gaussianBlurMaterial = CheckShaderAndCreateMaterial(gaussianBlurShader, gaussianBlurMaterial);
return gaussianBlurMaterial;
}
}

//三个参数分别对应高斯模糊迭代次数、模糊范围和缩放系数的参数
[Range(0, 4)]
public int iterations = 3;

[Range(0.2f, 3.0f)]
public float blurSpread = 0.6f;

[Range(1, 8)]
public int downSample = 2;

void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
int rtW = src.width/downSample;
int rtH = src.height/downSample;
//GetTemporary函数分配了一块小于屏幕图像尺寸的缓冲区,因为高斯模糊的shader中需要调用两个pass,使用一块中间缓存来存储第一个pass执行完毕后得到的模糊结果
RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0);
//将临时渲染纹理的滤波模式设置为双线性
buffer.filterMode = FilterMode.Bilinear;

//第一次调用使用shader中第一个pass(即使用竖直方向的一维高斯核进行滤波)对src进行处理,并将结果存储在bufffer中
Graphics.Blit(src, buffer, material, 0);
//第二次调用是使用shader中的第二个pass(即使用水平方向的一维高斯核进行滤波)对buffer进行处理,返回最终屏幕的图像
Graphics.Blit(buffer, dest, material, 1);

RenderTexture.ReleaseTemporary(buffer);
} else {
Graphics.Blit(src, dest);
}
}
}

高斯核维数相同的情况下,_BlurSize越大模糊程度越高,但采样数不会受到影响;但过大的BlurSize值会造成虚影;而downSample越大,需要处理的像素数越少(性能越好),也能进一步提高模糊程度,但若这个值过大会使图像像素化。

  • 创建对应的处理高斯模糊的shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
Shader "Unity Shaders Book/GaussianBlur"
{
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}
SubShader {
CGINCLUDE

#include "UnityCG.cginc"

sampler2D _MainTex;
half4 _MainTex_TexelSize;
float _BlurSize;

struct v2f {
float4 pos : SV_POSITION;
half2 uv[5]: TEXCOORD0;
};

//定义一个5维的纹理坐标数组(二维高斯核被拆成两个一维高斯核);uv[0]为当前的采样纹理,剩下四个坐标则是对邻域采样时使用的纹理坐标
v2f vertBlurVertical(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

half2 uv = v.texcoord;

o.uv[0] = uv;
o.uv[1] = uv + float2(0.0, _MainTex_TexelSize.y * 1.0) * _BlurSize;
o.uv[2] = uv - float2(0.0, _MainTex_TexelSize.y * 1.0) * _BlurSize;
o.uv[3] = uv + float2(0.0, _MainTex_TexelSize.y * 2.0) * _BlurSize;
o.uv[4] = uv - float2(0.0, _MainTex_TexelSize.y * 2.0) * _BlurSize;

return o;
}

v2f vertBlurHorizontal(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

half2 uv = v.texcoord;

o.uv[0] = uv;
o.uv[1] = uv + float2(_MainTex_TexelSize.x * 1.0, 0.0) * _BlurSize;
o.uv[2] = uv - float2(_MainTex_TexelSize.x * 1.0, 0.0) * _BlurSize;
o.uv[3] = uv + float2(_MainTex_TexelSize.x * 2.0, 0.0) * _BlurSize;
o.uv[4] = uv - float2(_MainTex_TexelSize.x * 2.0, 0.0) * _BlurSize;

return o;
}

//共用的片元着色器
fixed4 fragBlur(v2f i) : SV_Target {
//由上面的高斯核原理图可以看出它是具有对称性的,因此只需要记录3个高斯权重,将结果值sum初始化为当前像素值乘以它的权重值;根据对称性,需要进行两次迭代,并把像素值和权重相乘后的结果叠加到sum中
float weight[3] = {0.4026, 0.2442, 0.0545};

fixed3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0];

for (int it = 1; it < 3; it++) {
sum += tex2D(_MainTex, i.uv[it*2-1]).rgb * weight[it];
sum += tex2D(_MainTex, i.uv[it*2]).rgb * weight[it];
}

return fixed4(sum, 1.0);
}

ENDCG

ZTest Always Cull Off ZWrite Off

Pass {
NAME "GAUSSIAN_BLUR_VERTICAL"

CGPROGRAM

#pragma vertex vertBlurVertical
#pragma fragment fragBlur

ENDCG
}

Pass {
NAME "GAUSSIAN_BLUR_HORIZONTAL"

CGPROGRAM

#pragma vertex vertBlurHorizontal
#pragma fragment fragBlur

ENDCG
}
}
FallBack "Diffuse"
}
  • 导入摄像机中

  • 模糊结果