US3S10L6——边缘检测

边缘检测效果

边缘检测效果,是一种用于突出图像中的边缘,使物体的轮廓更加明显的图像处理技术
边缘检测的主要目的是找到图像中亮度变化显著的区域,这些区域通常对应于物体的边界
边缘检测相当于利用 Shader 代码自动给屏幕图像进行描边处理,下图就是边缘检测的例子:

image

image

边缘检测效果的基本原理

一句话概括 Unity Shader 中实现边缘检测效果的基本原理:

计算每个像素的灰度值,用灰度值结合卷积核进行卷积运算,得到该像素的梯度值,

  • 梯度值越大越靠近边界,越趋近于描边颜色
  • 梯度值越小表明不是边界位置,越趋近于原始颜色

关键知识点:灰度值、卷积、卷积核、梯度值

灰度值

灰度值指只含有亮度信息而没有色相和饱和度的颜色,即图像中的每个像素只有从黑到白的不同灰度级别。
在最简单的形式中,灰度图像的每个像素可以由一个值表示,这个值通常是从 0(黑色)到 255(白色)之间的整数。

利用图像颜色 RGB 计算一个平均值,即可得到一个灰度值,因为人眼对不同颜色敏感度不同,
因此会对三个颜色分别乘以不同的权数,用加权平均法来计算灰度值,下面是基于 Rec. 709 标准计算的灰度值:
灰度值(亮度)L=0.2126×R+0.7152×G+0.0722×BL = 0.2126 \times R + 0.7152 \times G + 0.0722 \times B

卷积

卷积是一种数学计算方式

在泛函分析中,卷积、旋积或褶积(英语:Convolution)是通过两个函数 ffgg 生成第三个函数的一种数学运算,
其本质是一种特殊的积分变换,表征函数 ffgg 经过翻转和平移的重叠部分函数值乘积对重叠长度的积分。
如果将参加卷积的一个函数看作区间的指示函数,卷积还可以被看作是“滑动平均”的推广。

卷积_百度百科

我们首先通过一个比喻来理解卷积在边缘检测中的作用,
它就像是要用一个放大镜(卷积核) 在图片上移动,放大镜(卷积核) 的作用是帮助我们看到图片上的细微变化。
当我们用这个放大镜(卷积核) 扫描整张图片时,它能帮助我们发现图片上哪些地方颜色突然变化,这些突然变化的地方往往就是物体的边缘了

假设我们有一张 5*5 的图像,每一个格子代表一个像素,格子中的数据表示该像素的灰度值

image

假设我们有一个 3*3 的卷积核(放大镜)

image

如果你想要求出图中红色格子的梯度值(值越大表示越靠近边缘),

image

那么只需要用卷积核和对应位置像素的灰度值,进行如下计算:

1×1+2×0+3×1+ 1×1+2×0+3×1+ 1×1+2×0+3×1=3\begin{align*} 1 \times 1 + 2 \times 0 + 3 \times -1 & \\ +\ 1 \times 1 + 2 \times 0 + 3 \times -1 & \\ +\ 1 \times 1 + 2 \times 0 + 3 \times -1 & = -3 \end{align*}

image

最终算出来的结果就表示该像素的梯度值,我们便可以用该值决定边缘效果了

从卷积的计算方式我们可以得知,其中卷积核(也被称为边缘检测因子)是非常重要的一个元素,
在图形学中,有三种常用的卷积核(边缘检测因子),他们分别是:

  • Roberts 算子:由拉里·罗伯茨(Larry Roberts)于1965年提出
  • Prewitt 算子:由约翰·普雷维特(John Prewitt)于1970年提出
  • Sobel 算子:由欧文·索贝尔(Irwin Sobel)于1968年提出

image

他们各有千秋,但是在图形学中最常用的还是 Sobel 算子,因为它更适合高精度的边缘检测

我们可以看到三种算子都包含了两个方向的卷积核,他们分别用来检测水平和竖直方向上的边缘信息
边缘检测的卷积计算时,只需要对每个像素进行两次卷积计算即可
这样就可以得到两个方向的梯度值 GxG_xGyG_y,而该像素整体梯度值 G=Gx+GyG = |G_x| + |G_y|

现在我们已经知道了通过卷积获取梯度值的基本原理,
那么我们只需要定义一个描边颜色,利用 Shader 中的内置函数 lerp
在 原始颜色 和 描边颜色 之间利用梯度值进行插值即可

最终颜色 = lerp(原始颜色, 描边颜色, 梯度值)

梯度值越大表明越接近边缘,则颜色越接近描边颜色;反之越接近原始颜色

基本原理总结:

  1. 得到当前像素以及其上、下、左、右、左上、左下、右上、右下共9个像素的灰度值
  2. 用这 9 个灰度值和 Sobel 算子进行卷积计算得到梯度值 G=Gx+GyG = |G_x| + |G_y|
  3. 计算 最终颜色 = lerp(原始颜色, 描边颜色, 梯度值)

访问纹理对应的每个纹素——得到当前像素周围8个像素位置

想要获取当前像素周围 8 个像素的位置,我们需要补充一个知识点,即:
Unity 提供给我们用于访问纹理对应的每个纹素(像素)的大小的变量:float4 纹理名_TexelSize
它类似 纹理名_ST​(用于获取纹理缩放偏移的变量),其中的 xyzw​ 分别代表(假设纹理宽高为 1024*768):

  • x​:1 / 纹理宽度 = 11024\frac{1}{1024}
  • y​:1 / 纹理高度 = 1768\frac{1}{768}
  • z​:纹理宽度 = 1024
  • w​:纹理高度 = 768

我们可以利用 float4 纹理名_TexelSize​ 纹素信息得到当前像素周围 8 个像素位置
可以进行 uv​ 坐标偏移计算,在顶点着色器函数或者片元着色器函数中计算都行
但是建议在顶点着色器函数中计算,可以节约计算量,片元着色器中直接使用插值的结果也不会影响纹理坐标的计算结果

1
2
3
4
5
6
7
8
9
10
11
half2 uv = v.texcoord;
// 对uv坐标偏移 1/纹理宽度 和 1 / 纹理高度,这就相当于偏移了一个像素
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);

边缘检测效果的具体实现

新建场景,在场景中随意使用一张风景图,作为 Sprite 对象,将其填充满 Game 窗口,用于测试,以下图作为测试图片:

image-20250105001900-zt1x9tp

实现边缘检测屏幕后期处理效果对应Shader

  1. 新建 Shader,取名边缘检测 EdgeDetection​,删除无用代码

  2. 声明属性,进行属性映射

    • 主纹理 _MainTex
    • 边缘描边用的颜色 _EdgeColor

    注意!在属性映射时使用内置纹素变量 _MainTex_TexelSize

    1
    2
    3
    4
    5
    Properties
    {
    _MainTex("Texture", 2D) = "white"{}
    _EdgeColor("EdgeColor", Color) = (0, 0, 0, 0)
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    #pragma vertex vert
    #pragma fragment frag

    #include "UnityCG.cginc"

    sampler2D _MainTex;
    float4 _MainTex_ST;
    half4 _MainTex_TexelSize;
    fixed4 _EdgeColor;
  3. 屏幕后处理效果标配

    • ZTest Always
    • Cull Off
    • ZWrite Off
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Pass
    {
    ZTest Always
    Cull Off
    ZWrite Off

    CGPROGRAM
    // ...
    ENDCG
    }
  4. 结构体相关

    • 顶点
    • uv 数组,用于存储 9 个像素点的 uv 坐标
    1
    2
    3
    4
    5
    struct v2f
    {
    half2 uv[9] : TEXCOORD0; // 用于存储9个像素UV坐标的变量
    float4 vertex : SV_POSITION;
    };
  5. 顶点着色器

    顶点坐标转换,用 uv 数组装载 9 个像素 uv 坐标

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    v2f vert (appdata_base v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex); // 将顶点坐标转换到裁剪空间下
    half2 uv = v.texcoord;
    // 对uv坐标(范围0~1)偏移 1/纹理宽度 和 1 / 纹理高度,这就相当于偏移了一个像素
    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;
    }
  6. 片元着色器

    利用卷积获取梯度值(可以声明一个 Sobel 算子计算函数和一个灰度值计算函数)
    利用梯度值在原始颜色和边缘颜色之间进行插值得到最终颜色

    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
    // 计算颜色的灰度值
    fixed calcLuminance(fixed4 color)
    {
    return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
    }

    // Sobel算子卷积计算,计算此像素的梯度值
    half Sobel(v2f i)
    {
    // Sobel算子的两个卷积核
    half Gx[9] = { -1, -2, -1,
    0, 0, 0,
    1, 2, 1 };
    half Gy[9] = { -1, 0, 1,
    -2, 0, 2,
    -1, 0, 1 };

    half L; // 灰度值
    half edgeX = 0; // 水平方向梯度值
    half edgeY = 0; // 竖直方向梯度值

    // 分别采样九个像素点的颜色,计算其灰度值,然后计算两个方向的梯度值
    for (int index = 0; index < 9; index++)
    {
    L = calcLuminance(tex2D(_MainTex, i.uv[index]));
    edgeX += L * Gx[index];
    edgeY += L * Gy[index];
    }

    // 返回最终的一个像素的梯度值
    return abs(edgeX) + abs(edgeY);
    }

    fixed4 frag (v2f i) : SV_Target
    {
    // 利用Sobel算子计算梯度值,再在原始颜色和边缘线颜色之间利用梯度值进行插值计算最终颜色
    half edge = Sobel(i);
    fixed4 color = lerp(tex2D(_MainTex, i.uv[4]), _EdgeColor, edge);
    return color;
    }
  7. FallBack Off

    这里不需要后备 Shader,因为如果 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
89
90
91
92
93
94
95
96
97
98
99
Shader "PostEffect/EdgeDetection"
{
Properties
{
_MainTex("Texture", 2D) = "white"{}
_EdgeColor("EdgeColor", Color) = (0, 0, 0, 0)
}
SubShader
{
Tags { "RenderType"="Opaque" }

Pass
{
ZTest Always
Cull Off
ZWrite Off

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
half2 uv[9] : TEXCOORD0; // 用于存储9个像素UV坐标的变量
float4 vertex : SV_POSITION;
};

sampler2D _MainTex;
float4 _MainTex_ST;
half4 _MainTex_TexelSize;
fixed4 _EdgeColor;

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex); // 将顶点坐标转换到裁剪空间下
half2 uv = v.texcoord;
// 对uv坐标(范围0~1)偏移 1/纹理宽度 和 1 / 纹理高度,这就相当于偏移了一个像素
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 calcLuminance(fixed4 color)
{
return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
}

// Sobel算子卷积计算,计算此像素的梯度值
half Sobel(v2f i)
{
// Sobel算子的两个卷积核
half Gx[9] = { -1, -2, -1,
0, 0, 0,
1, 2, 1 };
half Gy[9] = { -1, 0, 1,
-2, 0, 2,
-1, 0, 1 };

half L; // 灰度值
half edgeX = 0; // 水平方向梯度值
half edgeY = 0; // 竖直方向梯度值

// 分别采样九个像素点的颜色,计算其灰度值,然后计算两个方向的梯度值
for (int index = 0; index < 9; index++)
{
L = calcLuminance(tex2D(_MainTex, i.uv[index]));
edgeX += L * Gx[index];
edgeY += L * Gy[index];
}

// 返回最终的一个像素的梯度值
return abs(edgeX) + abs(edgeY);
}

fixed4 frag (v2f i) : SV_Target
{
// 利用Sobel算子计算梯度值,再在原始颜色和边缘线颜色之间利用梯度值进行插值计算最终颜色
half edge = Sobel(i);
fixed4 color = lerp(tex2D(_MainTex, i.uv[4]), _EdgeColor, edge);
return color;
}
ENDCG
}
}

Fallback Off
}

实现边缘检测屏幕后期处理效果对应C#代码

  1. 创建 C# 脚本,名为 EdgeDetection​(边缘检测)

  2. 继承屏幕后处理基类 PostEffect

    具体代码详见:US3S10L4——屏幕后处理基类

  3. 声明边缘颜色变量,用于控制效果变化

  4. 重写 UpdateProperty​ 方法,设置材质球属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using UnityEngine;

public class EdgeDetection : PostEffect
{
public Color edgeColor;

protected override void UpdateProperty()
{
if (PostEffectMaterial != null)
{
PostEffectMaterial.SetColor("_EdgeColor", edgeColor);
}
}
}

显示效果:

image

可以看到,颜色变化明显的部分,都有描边效果

加入纯色背景功能

我们在边缘描边时,有时只想保留描边的边缘线,不想要显示原图的背景颜色
比如把整个背景变为白色、黑色、等等自定义颜色
而抛弃掉原本图片的颜色信息,效果就像是一张描边图片

image

  • 在上节课的 Shader 代码中进行修改

    1. 新属性声明 属性映射

      • 添加背景颜色程度变量 _BackgroundExtent

        0 表示保留图片原始颜色,1 表示完全抛弃图片原始颜色,0~1 之间可以自己控制保留程度

      • 添加自定义背景颜色 _BackgroundColor​,定义用于替换图片原始颜色的颜色

      1
      2
      3
      4
      5
      6
      7
      Properties
      {
      _MainTex("Texture", 2D) = "white"{}
      _EdgeColor("EdgeColor", Color) = (0, 0, 0, 0) // 边缘颜色
      _BackgroundExtent("BackgroundExtent", Range(0, 1)) = 0 // 背景颜色程度,程度越大原始颜色抛弃越多
      _BackgroundColor("BackgroundColor", Color) = (1, 1, 1, 1) // 自定义背景颜色,定义用于替换原始颜色的颜色
      }
    2. 修改片元着色器

      • 利用插值运算,记录 纯色背景 中像素描边颜色
      • 利用插值运算,在 原始图片描边 和 纯色图片描边 之间用程度变量进行控制
      1
      2
      3
      4
      5
      6
      7
      8
      9
      fixed4 frag (v2f i) : SV_Target
      {
      // 利用Sobel算子计算梯度值,再在原始颜色和边缘线颜色之间利用梯度值进行插值计算最终颜色
      half edge = Sobel(i);
      fixed4 blendEdgeColor = lerp(tex2D(_MainTex, i.uv[4]), _EdgeColor, edge); // 混合原始颜色的边缘颜色
      fixed4 onlyEdgeColor = lerp(_BackgroundColor, _EdgeColor, edge); // 混合背景颜色的边缘颜色
      // 通过背景颜色程度来控制纯色描边和原始颜色描边两个颜色之间的过渡
      return lerp(blendEdgeColor, onlyEdgeColor, _BackgroundExtent);
      }
  • 在上节课的C#代码中进行修改

    • 添加背景颜色程度变量
    • 添加自定义背景光颜色
    • UpdateProperty​ 函数中添加属性设置
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    using UnityEngine;

    public class EdgeDetection : PostEffect
    {
    public Color edgeColor = Color.black;
    public Color backgroundColor = Color.white;
    [Range(0f, 1f)] public float backgroundExtent;

    protected override void UpdateProperty()
    {
    if (PostEffectMaterial != null)
    {
    PostEffectMaterial.SetColor("_EdgeColor", edgeColor);
    PostEffectMaterial.SetFloat("_BackgroundExtent", backgroundExtent);
    PostEffectMaterial.SetColor("_BackgroundColor", backgroundColor);
    }
    }
    }

显示效果(边缘颜色为黑色,背景颜色为白色,背景颜色程度为1):

image

完整 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
97
98
99
100
101
102
103
104
105
Shader "PostEffect/EdgeDetection"
{
Properties
{
_MainTex("Texture", 2D) = "white"{}
_EdgeColor("EdgeColor", Color) = (0, 0, 0, 0) // 边缘颜色
_BackgroundExtent("BackgroundExtent", Range(0, 1)) = 0 // 背景颜色程度,程度越大原始颜色抛弃越多
_BackgroundColor("BackgroundColor", Color) = (1, 1, 1, 1) // 自定义背景颜色,定义用于替换原始颜色的颜色
}
SubShader
{
Tags { "RenderType"="Opaque" }

Pass
{
ZTest Always
Cull Off
ZWrite Off

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
half2 uv[9] : TEXCOORD0; // 用于存储9个像素UV坐标的变量
float4 vertex : SV_POSITION;
};

sampler2D _MainTex;
float4 _MainTex_ST;
half4 _MainTex_TexelSize;
fixed4 _EdgeColor;
fixed _BackgroundExtent;
fixed4 _BackgroundColor;

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex); // 将顶点坐标转换到裁剪空间下
half2 uv = v.texcoord;
// 对uv坐标(范围0~1)偏移 1/纹理宽度 和 1 / 纹理高度,这就相当于偏移了一个像素
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 calcLuminance(fixed4 color)
{
return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
}

// Sobel算子卷积计算,计算此像素的梯度值
half Sobel(v2f i)
{
// Sobel算子的两个卷积核
half Gx[9] = { -1, -2, -1,
0, 0, 0,
1, 2, 1 };
half Gy[9] = { -1, 0, 1,
-2, 0, 2,
-1, 0, 1 };

half L; // 灰度值
half edgeX = 0; // 水平方向梯度值
half edgeY = 0; // 竖直方向梯度值

// 分别采样九个像素点的颜色,计算其灰度值,然后计算两个方向的梯度值
for (int index = 0; index < 9; index++)
{
L = calcLuminance(tex2D(_MainTex, i.uv[index]));
edgeX += L * Gx[index];
edgeY += L * Gy[index];
}

// 返回最终的一个像素的梯度值
return abs(edgeX) + abs(edgeY);
}

fixed4 frag (v2f i) : SV_Target
{
// 利用Sobel算子计算梯度值,再在原始颜色和边缘线颜色之间利用梯度值进行插值计算最终颜色
half edge = Sobel(i);
fixed4 blendEdgeColor = lerp(tex2D(_MainTex, i.uv[4]), _EdgeColor, edge); // 混合原始颜色的边缘颜色
fixed4 onlyEdgeColor = lerp(_BackgroundColor, _EdgeColor, edge); // 混合背景颜色的边缘颜色
// 通过背景颜色程度来控制纯色描边和原始颜色描边两个颜色之间的过渡
return lerp(blendEdgeColor, onlyEdgeColor, _BackgroundExtent);
}
ENDCG
}
}

Fallback Off
}