Skip to content

Latest commit

 

History

History
2088 lines (1377 loc) · 32.2 KB

File metadata and controls

2088 lines (1377 loc) · 32.2 KB

一、图像处理中的基本概念:前景、背景与噪声

对于一张图像而言,我们通常不会对所有像素一视同仁。

在一个具体任务中,我们总有某些内容是希望保留下来的,也总有某些内容是不关心的,还有某些内容甚至会干扰我们的判断。

因此,可以首先把图像中的信息粗略分成三类。

1. 前景

所谓前景(Foreground),就是当前任务中我们真正关心的目标。

例如,把一个红色苹果放在桌子上,如果我们现在的任务是检测苹果,那么:

  • 苹果就是前景;
  • 苹果的位置、颜色、形状、轮廓等,就是我们希望提取的特征。

因此,“前景”并不是图像本身固有的属性,而是由任务决定的。

如果我们换一个任务,例如希望检测桌面,那么刚才作为背景的桌面,就变成了新的前景。

2. 背景

**背景(Background)**就是当前任务中除前景之外、不希望作为主要目标分析的部分。

例如在苹果检测任务中:

  • 苹果是前景;
  • 桌面;
  • 墙面;
  • 地板;
  • 房间中的其他物体;

都可以看作背景。

需要特别注意:

前景和背景不是根据“物体本身是什么”划分的,而是根据“我们当前想研究什么”划分的。

3. 噪声与图像退化

第三类信息通常被统称为噪声。

严格来说,在信号处理里面,“噪声”一般指随机扰动,例如:

  • 传感器热噪声;
  • 电子噪声;
  • 椒盐噪声;
  • 光子噪声;

这些噪声可以在图像采集时就已经出现。例如光照不足时,传感器为了成像会放大微弱信号,随机波动也会随之变得明显。

而像:

  • 运动模糊;
  • 失焦模糊;
  • 强光光晕;
  • 镜头耀斑;

严格来说更适合称为图像退化(degradation),并不全部属于随机噪声。

还要注意:噪声和退化不一定只来自相机或拍摄环境,前面的图像处理步骤本身也可能把它们引入,或让原本很弱的问题变得明显。

例如:

  • 图像缩放、旋转或透视矫正时,需要插值;不合适的插值或多次重采样可能产生锯齿、振铃等伪影;
  • JPEG 等有损压缩会产生块效应、蚊式噪声等压缩伪影;
  • 过强的锐化会放大高频扰动,形成颗粒感或边缘光晕;
  • 多次量化、颜色转换或重复处理,会累积舍入误差和色带等问题;
  • 去噪参数设置不当也会抹掉细节,形成过度平滑的退化。

所以一幅送入当前算法的图像,既可能含有采集阶段带来的随机噪声,也可能含有前序处理留下的伪影或细节损失。后者未必是严格意义上的随机噪声,但同样会干扰阈值分割、边缘检测和特征提取。

可以把这个过程粗略理解为:

[ \text{原始场景} \xrightarrow{\text{成像}} \text{含采集噪声的图像} \xrightarrow{\text{前序处理}} \text{可能带有处理伪影或额外退化的图像} ]

但是在工程处理中,我们常常会比较宽泛地把所有“不希望出现、并且会干扰特征提取的信息”都称为噪声。

因此可以作一个直观的区分:

前景和背景通常具有一定稳定的空间结构,而噪声往往缺少我们关心的稳定结构。

例如一个苹果有明确的面积、轮廓和颜色,而一个孤立的白色噪点通常既没有稳定面积,也没有稳定形状。

这正是后面许多去噪算法能够发挥作用的重要原因。

二、信号的表达:一张图像究竟是什么?

在计算机中,一张图像最直接的理解方式,是把它看作一个二维离散函数:

[ I(x,y) ]

其中:

  • (x) 表示横向像素位置;
  • (y) 表示纵向像素位置;
  • (I(x,y)) 表示该位置保存的信息。

如果是灰度图,那么

[ I(x,y)\in[0,255] ]

可能只是一个数。

如果是彩色图像,那么一个像素一般对应三个数,例如 RGB:

[ I(x,y)= \begin{bmatrix} R(x,y)\ G(x,y)\ B(x,y) \end{bmatrix}. ]

因此,从数学上看:

一幅彩色图像可以看作定义在二维离散空间上的向量值函数。

1. RGB、HSV :不同的颜色表达方式

最常见的颜色表达方式是 RGB:

[ (R,G,B) ]

分别表示红、绿、蓝三个通道的强度。

但 RGB 并不是唯一的颜色表示方法。

OpenCV 中常见的颜色空间还包括:

  • HSV;
  • HLS;
  • Lab;
  • XYZ;
  • YCrCb。

OpenCV 使用 cv.cvtColor() 在这些颜色空间之间进行转换。官方文档中提供了 COLOR_BGR2HSV、COLOR_BGR2Lab、COLOR_HSV2BGR 等大量转换方式。

需要特别注意一点:

OpenCV 默认读取的彩色图像通道顺序通常是 BGR,而不是 RGB。

例如:

import cv2 as cv

img = cv.imread("apple.jpg")

hsv = cv.cvtColor(img, cv.COLOR_BGR2HSV)
lab = cv.cvtColor(img, cv.COLOR_BGR2Lab)
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)

RGB

RGB 很适合描述显示设备中的颜色混合:

[ Color=(R,G,B) ]

但颜色信息与亮度信息混合得比较严重。

例如阴影可能导致三个通道同时发生明显变化。

HSV

HSV 把颜色理解成:

[ (H,S,V) ]

其中:

  • (H):Hue,色相,表示“是什么颜色”;
  • (S):Saturation,饱和度,表示颜色有多纯;
  • (V):Value,明度。

因此,如果我们希望寻找“红色物体”,HSV 往往比 RGB 更直观。

例如:

hsv = cv.cvtColor(img, cv.COLOR_BGR2HSV)

lower = (0, 100, 100)
upper = (10, 255, 255)

mask = cv.inRange(hsv, lower, upper)

这里的 mask 就表示:

HSV 落在指定范围内的位置设为白色,其余位置设为黑色。

我们可以直观地说:

RGB、HSV、Lab 都是在用不同的坐标描述颜色。

这个说法是合理的。

但是如果进一步说:

它们就是线性代数里面同一个向量在不同基下的表达。

就不完全准确了。

真正的“换基”通常是一个线性变换:

[ \mathbf y=A\mathbf x. ]

而 RGB 到 HSV 的转换包含:

  • 最大值;
  • 最小值;
  • 除法;
  • 分段函数;

因此它并不是简单的线性换基。

Lab 的转换中同样存在非线性过程。

所以更准确的说法是:

不同颜色空间是对颜色信息的不同坐标化、不同参数化,其中有些变换包含线性部分,但很多转换整体上是非线性的。

另外,在数学上的连续实数模型中,一些颜色转换可以近似逆转;但是在 OpenCV 的 uint8 图像中,由于存在:

  • 量化;
  • 舍入;
  • 截断;

所以反复

[ BGR\rightarrow HSV\rightarrow BGR ]

并不能保证每一个像素都严格恢复原值。

2. 从空间域到频率域:傅里叶变换

前面讨论 RGB、HSV,是在改变“一个像素里面如何描述颜色”。

我们还可以从另外一个完全不同的角度重新表示整幅图像:

不再直接讨论每一个位置是什么颜色,而讨论整幅图像包含多少不同空间频率的变化。

这就引出了傅里叶变换。

对于二维图像 (f(x,y)),二维傅里叶变换可以写成:

[ F(u,v)

\sum_x\sum_y f(x,y) e^{-j2\pi \left( \frac{ux}{M}+\frac{vy}{N} \right)}. ]

这里不必一开始纠结公式本身,可以先从一个直观例子理解。

假设有一个一维信号:

[ f(x)= 3\sin x + 0.5\sin 10x. ]

在空间域观察它,我们看到的只是一个不断上下变化的曲线。

而傅里叶变换之后,我们可以得到:

这个信号里面包含多少频率为 (1) 的成分,又包含多少频率为 (10) 的成分。

二维图像也是类似的。

OpenCV 提供 cv.dft() 计算离散傅里叶变换,官方教程通常还会配合 getOptimalDFTSize()、magnitude()、log() 和 normalize() 来观察频谱。

可以把傅里叶变换理解为:

[ \boxed{ \text{从“哪里发生了什么”转换为“包含哪些变化尺度”} } ]

2.1 图像中的高频和低频

频率描述的是:

[ \boxed{\text{图像变化得有多快}} ]

例如一块几乎纯白的墙:

100 101 100 101 100

相邻像素变化很慢,因此主要属于低频信息。

而一个黑白交替区域:

0 255 0 255 0 255

变化非常剧烈,因此包含大量高频信息。

所以可以建立下面的直观对应关系:

[ \boxed{ \text{平坦区域} \longleftrightarrow \text{低频} } ]

[ \boxed{ \text{边缘、细节、纹理、噪声} \longleftrightarrow \text{较高频率} } ]

需要注意,高频不等于噪声。

例如物体真实边缘本身就是重要的高频信息。

这件事情在后面的去噪中非常重要:

如果单纯去掉所有高频,噪声虽然减少了,但是边缘也会一起变模糊。

三、选择性丢失信息:前景、背景和噪声的分离

很多传统图像处理方法,都可以从一个共同角度理解:

[ \boxed{ \text{主动丢失无关信息,保留任务需要的信息} } ]

一幅原始彩色图片的信息很多:

  • 颜色;
  • 亮度;
  • 纹理;
  • 位置;
  • 边缘;
  • 形状;
  • 高频信息;
  • 低频信息。

但是我们的任务往往只需要其中一小部分。

于是 OpenCV 中很多操作实际上都在做:

[ \text{原始图像} \rightarrow \text{信息筛选} \rightarrow \text{更简单的表示} \rightarrow \text{目标提取}. ]

1. 前景和背景的分离

1.1 利用位置关系分离前景

最简单的情况,是我们已经知道目标大致会出现在哪里。

例如相机固定以后,我们知道目标只可能出现在中央区域。

那么完全没有必要处理整幅图像。

import cv2 as cv

img = cv.imread("image.jpg")

h, w = img.shape[:2]

roi = img[
    h // 4 : 3 * h // 4,
    w // 4 : 3 * w // 4
]

这里的 ROI 是:

[ \text{Region of Interest} ]

即感兴趣区域。

我们直接把周围部分舍弃。

这是一种非常重要的工程思想:

如果先验知识已经告诉我们某些区域一定没有目标,就没有必要设计复杂算法去“识别”这些区域。

有时候最简单的裁剪,反而是最鲁棒、最高效的方法。

1.2 利用明暗特征分离前景:灰度化与二值化

假设背景和前景之间具有明显的亮度差别。

这时颜色本身可能并不重要,我们可以首先把彩色图像转换成灰度图:

gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)

原本一个像素可能包含:

[ (B,G,R) ]

三个数。

现在变成:

[ I(x,y) ]

一个数。

显然我们丢失了大量颜色信息。

但是如果我们的任务只关心:

这个地方亮还是暗?

那么这些颜色信息本来就是多余的。

这就是所谓:

[ \boxed{\text{有目的的信息压缩}} ]

1.2.1 固定阈值

接下来还可以继续丢失信息。

例如:

_, binary = cv.threshold(
    gray,
    127,
    255,
    cv.THRESH_BINARY
)

数学上相当于:

[ g(x,y)= \begin{cases} 255,&f(x,y)>T\ 0,&f(x,y)\le T \end{cases}. ]

一张灰度图可能有:

[ 0\sim255 ]

共 256 个灰度等级。

经过二值化以后,只剩下:

[ 0,\ 255. ]

信息确实大幅减少了。

但是与此同时:

“亮区域在哪里”这件事情变得极其明确。

OpenCV 官方教程把固定阈值、自适应阈值以及 Otsu 阈值都统一放在 thresholding 中介绍。

1.2.2 自适应阈值

固定阈值存在一个明显问题。

假设图像左边由于阴影整体较暗,而右边由于灯光整体较亮:

左侧        右侧

背景  30    背景 150
目标  80    目标 210

如果使用:

[ T=100 ]

那么左侧目标也会被认为是黑色。

但是我们真正需要利用的特征其实不是:

[ \text{目标一定大于100} ]

而是:

[ \boxed{ \text{目标比自己周围的背景亮} } ]

于是就可以引入自适应阈值:

binary = cv.adaptiveThreshold(
    gray,
    255,
    cv.ADAPTIVE_THRESH_GAUSSIAN_C,
    cv.THRESH_BINARY,
    11,
    2
)

它并不是整幅图使用同一个阈值,而是根据一个局部邻域计算阈值。

例如可以近似理解为:

[ T(x,y)

\operatorname{mean} \bigl( \text{neighborhood}(x,y) \bigr) -C. ]

这样,就能够适应空间上缓慢变化的光照。

1.2.3 Otsu 大津法

还有一个问题:

固定阈值里面的 (T) 到底应该设成多少?

我们当然可以:

T = 50
T = 80
T = 127
T = 160

一个个尝试。

但更好的办法是让算法自动选择。

大津法(Otsu's Method)就是一种经典方法:

threshold, binary = cv.threshold(
    gray,
    0,
    255,
    cv.THRESH_BINARY + cv.THRESH_OTSU
)

它的核心思想可以直观理解为:

假设灰度直方图里面大致存在两类:

[ \text{背景}+\text{前景}. ]

我们希望找到一个阈值 (T),使得:

  • 每一类内部尽可能集中;
  • 两个类别之间尽可能分开。

通常可以表述为:

寻找使类内方差最小,等价地使类间方差最大的阈值。

因此 Otsu 并不是一种新的“二值化定义”,而是:

[ \boxed{\text{一种自动确定全局阈值的方法}} ]

1.3 利用颜色特征分离前景

如果前景与背景之间没有明显亮度差异,但是有明显颜色差异,那么灰度化反而会把重要信息丢掉。

例如:

一个红色苹果放在亮度相近的绿色背景上。

这时候颜色就是关键特征。

通常可以将图像转换到 HSV:

hsv = cv.cvtColor(img, cv.COLOR_BGR2HSV)

lower = (0, 80, 70)
upper = (10, 255, 255)

mask = cv.inRange(hsv, lower, upper)

于是:

[ (H,S,V) \in \text{指定范围} ]

的像素被保留下来。

实际工程中通常不会只判断 (H),还会同时限制:

[ H,\quad S,\quad V. ]

例如:

  • (H) 判断是不是红色;
  • (S) 排除接近灰白的区域;
  • (V) 排除过暗区域。

这也是为什么 HSV 经常用于传统颜色目标检测。

不过颜色阈值存在明显局限。

例如:

  • 光源颜色改变;
  • 曝光改变;
  • 阴影;
  • 高光;
  • 白平衡变化;

都会使颜色分布发生变化。

因此:

简单颜色阈值适合环境相对受控的任务,但在复杂自然环境中鲁棒性通常有限。

1.4 利用边缘与轮廓分离前景

如果颜色和亮度都不够稳定,我们还可以进一步研究:

[ \boxed{\text{物体在哪里发生了突变?}} ]

这就引出了边缘。

首先需要区分 OpenCV 中非常容易混淆的两个概念:

1.4.1 边缘(Edge)

边缘表示图像强度发生剧烈变化的位置。

例如:

30 30 30 30 | 220 220 220

从 30 突然跳到 220 的位置就是一个强边缘。

它不要求形成闭合结构。

1.4.2 轮廓(Contour)

轮廓是由一系列边界点组成的曲线。

OpenCV 的 findContours() 会从二值图像中提取连续的边界点序列。官方示例经常先使用 Canny 得到二值边缘图,再通过 findContours() 提取轮廓。

因此不能简单说:

轮廓按照定义必须闭合,而边缘不闭合。

更准确地说:

边缘是局部的强度变化;轮廓则是把空间上连续的边界点组织成曲线。实际目标的外边界往往形成闭合轮廓,但“闭合”并不是理解二者差别的唯一标准。

可以把整个过程理解成:

[ \text{图像} \rightarrow \text{边缘点} \rightarrow \text{边缘点之间的连接关系} \rightarrow \text{轮廓}. ]

1.4.3 Sobel 算子:从导数理解边缘

在数学分析里面,我们使用导数描述变化。

对于一维函数:

[ f(x) ]

如果

[ \left|\frac{df}{dx}\right| ]

很大,就说明函数变化非常剧烈。

图像也是一样的。

我们可以把灰度图写成:

[ I(x,y). ]

于是分别考虑:

[ \frac{\partial I}{\partial x}, \qquad \frac{\partial I}{\partial y}. ]

问题在于:

图像并不是连续函数,而是一个离散像素网格。

因此不能直接求微积分意义上的导数,只能构造一个离散近似。

最简单的思想就是:

[ \frac{\partial I}{\partial x} \approx I(x+1,y)-I(x,y). ]

Sobel 算子实际上就是一种更加稳定的离散微分算子。OpenCV 官方将 Sobel 描述为一种离散微分运算,并指出它结合了一定的平滑和微分作用。

例如经典的横向 Sobel 核:

[ G_x= \begin{bmatrix} -1&0&1\ -2&0&2\ -1&0&1 \end{bmatrix} ]

纵向为:

[ G_y= \begin{bmatrix} -1&-2&-1\ 0&0&0\ 1&2&1 \end{bmatrix}. ]

分别计算:

[ I_x=G_x*I ]

和

[ I_y=G_y*I. ]

就可以得到梯度大小:

[ G= \sqrt{I_x^2+I_y^2}. ]

OpenCV 中可以写成:

gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)

gx = cv.Sobel(gray, cv.CV_64F, 1, 0, ksize=3)
gy = cv.Sobel(gray, cv.CV_64F, 0, 1, ksize=3)

magnitude = cv.magnitude(
    gx.astype("float32"),
    gy.astype("float32")
)

因此 Sobel 本质上是在回答:

[ \boxed{ \text{这个像素附近变化到底有多剧烈?} } ]

1.4.4 Laplacian 算子:二阶导数

如果 Sobel 可以理解为一阶导数,那么 Laplacian 可以理解为二阶导数。

二维 Laplace 算子为:

[ \nabla^2 I

\frac{\partial^2 I}{\partial x^2} + \frac{\partial^2 I}{\partial y^2}. ]

OpenCV 提供:

lap = cv.Laplacian(
    gray,
    cv.CV_64F
)

官方文档也明确指出,OpenCV 的 Laplacian() 实现本质上利用了 Sobel 导数计算二阶微分。

一阶导数关注:

[ \text{哪里变化最大} ]

而二阶导数进一步反映:

[ \text{变化率本身如何变化}. ]

因此 Laplacian 同样可以用于边缘检测。

不过二阶导数对噪声通常更加敏感,所以实际使用时一般需要先进行平滑处理。

1.4.5 Canny:更完整的边缘检测算法

现代传统图像处理中,Canny 是最经典的边缘检测算法之一。

需要强调:

[ \boxed{ \text{Canny 是边缘检测器,而不是轮廓提取器} } ]

OpenCV 中真正把边界组织成轮廓的是:

cv.findContours()

而 Canny 负责生成一个边缘二值图。

Canny 是一个多阶段算法,OpenCV 官方教程也将其分成噪声抑制、梯度计算、非极大值抑制和双阈值滞后连接等步骤。

整个流程可以概括为:

[ \boxed{ \text{Gaussian} \rightarrow \text{Gradient} \rightarrow \text{NMS} \rightarrow \text{Double Threshold} \rightarrow \text{Hysteresis} } ]

第一步:高斯滤波

求导本身会放大高频信息。

而噪声往往也是高频信息。

因此如果直接求梯度:

[ \text{噪声} \rightarrow \text{大量伪边缘}. ]

所以首先进行高斯平滑:

blur = cv.GaussianBlur(
    gray,
    (5, 5),
    1.0
)
第二步:计算梯度

然后利用 Sobel 计算:

[ G_x,\qquad G_y. ]

得到梯度幅值:

[ G= \sqrt{G_x^2+G_y^2} ]

以及梯度方向:

[ \theta= \operatorname{atan2}(G_y,G_x). ]

梯度方向表示:

灰度增长最快的方向。

而真正的边缘方向与梯度方向垂直。

第三步:非极大值抑制

单纯求梯度会产生比较“粗”的边缘。

例如真实边缘可能只有一条线,但周围很多像素的梯度都比较大:

0 20 80 150 220 255
     ↑ ↑ ↑

Canny 希望最终边缘尽量只有一个像素宽。

因此在梯度方向上比较相邻像素。

只有当:

[ G(x,y) ]

是该方向上的局部最大值时,才保留下来。

否则:

[ G(x,y)=0. ]

这个过程叫:

[ \boxed{\text{Non-Maximum Suppression}} ]

即非极大值抑制。

它的作用就是把宽边缘压细。

第四步:双阈值

如果只设置一个阈值,又会出现问题。

阈值过高:

[ \text{真实但较弱的边缘被删除} ]

阈值过低:

[ \text{噪声又被保留下来}. ]

因此 Canny 使用两个阈值:

[ T_\text{low} < T_\text{high}. ]

若:

[ G>T_\text{high} ]

认为是强边缘。

若:

[ G<T_\text{low} ]

直接删除。

介于两者之间:

[ T_\text{low} < G < T_\text{high} ]

则称为弱边缘候选。

第五步:滞后连接

最后判断弱边缘是否与强边缘相连。

如果一个弱边缘:

[ \text{连接到强边缘} ]

则认为它很可能是真实边缘,因此保留。

否则删除。

因此 Canny 不仅考虑:

[ \text{一个像素自己的梯度大小} ]

还利用了:

[ \boxed{\text{边缘在空间上的连续性}} ]

这也是它相比简单 Sobel 阈值更加稳定的重要原因。

OpenCV 中最终只需要:

edges = cv.Canny(
    gray,
    80,
    160
)

其中:

80  → 低阈值
160 → 高阈值
1.4.6 从边缘到轮廓

得到二值边缘图以后,可以进一步寻找轮廓:

contours, hierarchy = cv.findContours(
    edges,
    cv.RETR_EXTERNAL,
    cv.CHAIN_APPROX_SIMPLE
)

这里的:

cv.RETR_EXTERNAL

表示主要寻找最外层轮廓。

而:

cv.CHAIN_APPROX_SIMPLE

会对轮廓点进行一定压缩。

例如一个矩形边界如果有几百个像素点,并不需要把所有共线点全部保存,只需要保存几个关键点即可。

1.5 利用形状特征进一步筛选目标

假设经过二值化、颜色阈值或者 Canny 以后,我们已经得到了很多候选目标。

这时还可以进一步问:

哪一个候选轮廓的形状最像我要找的目标?

OpenCV 提供了大量轮廓几何特征函数。官方 contour 教程中包含面积、周长、最小包围圆、椭圆拟合和直线拟合等操作。

1.5.1 面积
area = cv.contourArea(cnt)

例如我们知道苹果在当前距离下大概应该有:

[ 5000 < A < 20000 ]

那么:

if 5000 < cv.contourArea(cnt) < 20000:
    ...

就可以过滤大量噪声轮廓。

1.5.2 周长
perimeter = cv.arcLength(cnt, True)

其中 True 表示按照闭合曲线计算周长。

1.5.3 外接矩形

最简单的是:

x, y, w, h = cv.boundingRect(cnt)

得到一个轴对齐矩形:

[ (x,y,w,h). ]

如果目标会旋转,可以使用:

rect = cv.minAreaRect(cnt)
box = cv.boxPoints(rect)

得到最小旋转外接矩形。

1.5.4 多边形拟合

OpenCV 可以使用:

epsilon = 0.02 * cv.arcLength(cnt, True)

approx = cv.approxPolyDP(
    cnt,
    epsilon,
    True
)

得到一个近似多边形。

于是:

len(approx)

可以粗略表示顶点数量。

例如:

if len(approx) == 3:
    print("triangle")

elif len(approx) == 4:
    print("quadrilateral")

需要注意,实际图片存在噪声和透视变形,因此不能简单地认为“四个顶点就一定是正方形”。

还需要综合:

  • 边长;
  • 角度;
  • 长宽比;
  • 面积;
  • 凸性。
1.5.5 圆形

可以求最小包围圆:

(x, y), radius = cv.minEnclosingCircle(cnt)

也可以构造圆度:

[ C= \frac{4\pi A}{P^2}. ]

其中:

  • (A):面积;
  • (P):周长。

理想圆:

[ C=1. ]

形状越不规则,一般越小。

1.5.6 椭圆

如果轮廓点足够,可以使用:

ellipse = cv.fitEllipse(cnt)

得到:

  • 中心;
  • 长轴;
  • 短轴;
  • 旋转角。

然后:

cv.ellipse(
    img,
    ellipse,
    (0, 255, 0),
    2
)

即可进行可视化。

2. 去除噪声

2.1 滤波方法

前景与背景通常具有相对稳定的结构。

但是噪声往往表现为:

[ \boxed{ \text{某个像素与周围像素明显不一致} } ]

例如:

100 102 101
99  255 103
101 100 102

中间突然出现:

[ 255 ]

非常可疑。

因此,一个自然想法就是:

不要只相信当前像素,而是参考它周围的像素。

这就是局部滤波的基本思想。

很多滤波器都可以统一写成卷积:

[ g(x,y)

\sum_i\sum_j K(i,j) f(x-i,y-j). ]

其中 (K) 称为:

[ \boxed{\text{卷积核 Kernel}} ]

它本质上定义了:

当前输出像素应该如何参考周围像素。

因此我们可以说:

[ \boxed{ \text{卷积是一种利用局部邻域信息重新计算像素的方法} } ]

这也是大量传统图像处理算法的核心数学工具。

2.1.1 均值滤波

最简单的卷积核:

[ K= \frac19 \begin{bmatrix} 1&1&1\ 1&1&1\ 1&1&1 \end{bmatrix}. ]

于是当前像素变成周围 9 个像素的平均值。

OpenCV:

blur = cv.blur(
    img,
    (5, 5)
)

优点:

  • 简单;
  • 快。

缺点:

  • 所有邻居权重相同;
  • 很容易把边缘一起模糊掉。
2.1.2 高斯滤波

相比简单平均,高斯滤波认为:

离中心越近的像素应该越重要。

二维高斯函数为:

[ G(x,y)

\frac{1}{2\pi\sigma^2} e^{-\frac{x^2+y^2}{2\sigma^2}}. ]

因此:

[ \boxed{ \text{中心权重大,远处权重小} } ]

OpenCV:

gaussian = cv.GaussianBlur(
    img,
    (5, 5),
    1.2
)

官方 OpenCV smoothing 教程提供了 blur()、GaussianBlur()、medianBlur() 和 bilateralFilter() 等常用滤波方法。

高斯滤波可以理解为一种经典低通滤波器:

[ \text{低频保留} ]

[ \text{高频受到抑制}. ]

因此可以降低噪声,但是同样会牺牲部分边缘细节。

2.1.3 中值滤波:特别适合椒盐噪声

如果出现:

100 101 102
99  255 103
100 101 102

均值会受到 255 明显影响。

但是排序:

99
100
100
101
101
102
102
103
255

中位数仍然是:

[ 101. ]

因此中值滤波直接取邻域中位数:

median = cv.medianBlur(
    img,
    5
)

它特别适合处理:

[ \boxed{\text{椒盐噪声}} ]

需要注意:

中值滤波不是线性卷积,因为“取中位数”并不是线性运算。

2.1.4 双边滤波:既看距离,也看颜色

高斯滤波有一个问题:

它只知道两个像素离得近,却不知道它们是不是属于同一个物体。

假设:

黑色物体 | 白色背景

位于边缘两侧的两个像素空间距离很近,但是颜色差异巨大。

双边滤波同时考虑:

[ \text{空间距离} ]

和:

[ \text{颜色差异}. ]

可以简单理解为:

[ w_{ij}

w_\text{space} \cdot w_\text{color}. ]

如果两个像素:

  • 距离近;
  • 颜色又相近;

权重很大。

如果虽然距离近,但是颜色差别很大,则权重降低。

因此双边滤波具有:

[ \boxed{ \text{平滑噪声同时尽量保留边缘} } ]

的特点。

OpenCV:

bilateral = cv.bilateralFilter(
    img,
    9,
    75,
    75
)

不过代价就是计算量通常比高斯滤波大。

2.2 在频率域中理解平滑

前面讲到:

[ \text{噪声和细小纹理} ]

往往包含较高频率。

因此还有另一种去噪思路:

[ \text{图像} \rightarrow \text{傅里叶变换} \rightarrow \text{降低高频} \rightarrow \text{逆傅里叶变换}. ]

即:

[ F(u,v) \rightarrow H(u,v)F(u,v). ]

其中:

[ H(u,v) ]

就是频率域滤波器。

例如低通滤波:

[ H(u,v)\approx \begin{cases} 1,&\text{低频}\ 0,&\text{高频} \end{cases} ]

这样就可以达到平滑效果。

不过直接用一个非常锋利的矩形掩模把高频“一刀切掉”可能产生振铃现象。

因此实际设计低通滤波器时,经常使用更平滑的频率响应,例如高斯低通。

同时需要再次强调:

[ \boxed{ \text{高频}\neq\text{噪声} } ]

因为:

  • 物体边缘;
  • 文字;
  • 细小纹理;

本身同样属于高频信息。

所以过度低通会产生模糊。

2.3 形态学操作

2.3.1 二值图像为什么更适合形态学?

对于二值图像:

[ I(x,y)\in{0,1} ]

或者:

[ I(x,y)\in{0,255}. ]

这时候我们更关心的往往不是:

[ \text{像素数值应该平均成多少} ]

而是:

[ \boxed{ \text{一个白色区域具有怎样的几何结构?} } ]

所以虽然均值、高斯甚至中值滤波并不是“不能”用于二值图像,但在已经得到明确二值分割结果以后,它们往往不是最自然的工具。

更合适的是:

[ \boxed{\text{数学形态学}} ]

OpenCV 的官方形态学教程也主要围绕二值图像介绍腐蚀、膨胀、开运算和闭运算。

2.3.2 腐蚀与膨胀

假设规定:

[ \text{白色}=前景 ]

[ \text{黑色}=背景. ]

腐蚀(Erosion)

腐蚀会使白色区域缩小:

kernel = cv.getStructuringElement(
    cv.MORPH_RECT,
    (3, 3)
)

eroded = cv.erode(
    binary,
    kernel
)

它可以用来:

  • 去除孤立小白点;
  • 断开细小连接;
  • 缩小前景。
膨胀(Dilation)

膨胀则让白色区域扩大:

dilated = cv.dilate(
    binary,
    kernel
)

它可以:

  • 填补小裂缝;
  • 加粗目标;
  • 连接较近区域。
2.3.3 开运算与闭运算

单独腐蚀会缩小目标。

单独膨胀会放大目标。

因此我们通常把二者组合起来。

开运算

[ \boxed{ \text{Opening}

\text{Erosion} \rightarrow \text{Dilation} } ]

即:

opened = cv.morphologyEx(
    binary,
    cv.MORPH_OPEN,
    kernel
)

它特别适合删除:

[ \boxed{\text{小白点}} ]

因为小白点经过腐蚀以后消失,而较大的主体经过后续膨胀可以基本恢复。

OpenCV 官方教程也将开运算描述为先腐蚀后膨胀,并用于移除较小的亮色目标。

闭运算

[ \boxed{ \text{Closing}

\text{Dilation} \rightarrow \text{Erosion} } ]

OpenCV:

closed = cv.morphologyEx(
    binary,
    cv.MORPH_CLOSE,
    kernel
)

适合:

  • 填补白色目标中的小黑洞;
  • 连接狭小断裂。

因此可以建立一个非常好记的关系:

[ \boxed{ \text{开运算:去小白点} } ]

[ \boxed{ \text{闭运算:填小黑洞} } ]

当然,这个结论默认:

[ \text{白色代表前景}. ]

如果黑白含义反过来,解释也需要相应反转。

四、OpenCV 中的可视化:显示、绘制与调试

图像算法的调试不能只看最终输出。

因为假设最终目标没有被检测出来,你必须知道:

究竟是哪一步错了?

可能是:

颜色阈值错了

也可能是:

形态学把目标腐蚀没了

或者:

Canny 阈值过高

甚至:

findContours 正确,但是面积阈值设置错了

因此,图像处理程序应该尽可能把中间结果可视化出来。

1. 图像显示

OpenCV 最基础的可视化方法:

cv.imshow(
    "image",
    img
)

cv.waitKey(0)

cv.destroyAllWindows()

例如同时查看:

cv.imshow("original", img)
cv.imshow("gray", gray)
cv.imshow("binary", binary)
cv.imshow("edges", edges)

这样就可以观察流水线每一步发生了什么。

2. 在图像上绘制结果

OpenCV 的绘图接口包括:

  • cv.line();
  • cv.rectangle();
  • cv.circle();
  • cv.polylines();
  • cv.drawContours();
  • cv.putText()。

官方文档提供了这些基本绘图函数。

例如画矩形:

cv.rectangle(
    img,
    (100, 100),
    (300, 300),
    (0, 255, 0),
    2
)

在 OpenCV 的 BGR 顺序中:

(0, 255, 0)

表示绿色。

2.1 画圆

cv.circle(
    img,
    (200, 200),
    50,
    (0, 0, 255),
    2
)

其中:

(0, 0, 255)

表示红色。

2.2 画轮廓

cv.drawContours(
    img,
    contours,
    -1,
    (0, 255, 0),
    2
)

其中:

-1

表示绘制所有轮廓。

2.3 添加文字

cv.putText(
    img,
    "apple",
    (100, 80),
    cv.FONT_HERSHEY_SIMPLEX,
    0.8,
    (0, 255, 0),
    2
)

3. 为什么可视化不仅仅是“展示结果”?

可视化最重要的意义实际上是:

[ \boxed{\text{让算法内部状态变得可观察}} ]

例如一个目标检测流水线:

Original
   ↓
HSV
   ↓
Mask
   ↓
Morphology
   ↓
Edge
   ↓
Contour
   ↓
Final Result

我们可以把每一步都显示出来:

cv.imshow("01 original", img)
cv.imshow("02 hsv mask", mask)
cv.imshow("03 morphology", clean_mask)
cv.imshow("04 edges", edges)
cv.imshow("05 result", result)

于是如果最终结果错误,就可以直接定位:

错误究竟从哪一层开始出现?

因此对于传统视觉算法而言:

[ \boxed{ \text{可视化本身就是调试工具} } ]

而不仅仅是程序最后的展示功能。