对于一张图像而言,我们通常不会对所有像素一视同仁。
在一个具体任务中,我们总有某些内容是希望保留下来的,也总有某些内容是不关心的,还有某些内容甚至会干扰我们的判断。
因此,可以首先把图像中的信息粗略分成三类。
所谓前景(Foreground),就是当前任务中我们真正关心的目标。
例如,把一个红色苹果放在桌子上,如果我们现在的任务是检测苹果,那么:
- 苹果就是前景;
- 苹果的位置、颜色、形状、轮廓等,就是我们希望提取的特征。
因此,“前景”并不是图像本身固有的属性,而是由任务决定的。
如果我们换一个任务,例如希望检测桌面,那么刚才作为背景的桌面,就变成了新的前景。
**背景(Background)**就是当前任务中除前景之外、不希望作为主要目标分析的部分。
例如在苹果检测任务中:
- 苹果是前景;
- 桌面;
- 墙面;
- 地板;
- 房间中的其他物体;
都可以看作背景。
需要特别注意:
前景和背景不是根据“物体本身是什么”划分的,而是根据“我们当前想研究什么”划分的。
第三类信息通常被统称为噪声。
严格来说,在信号处理里面,“噪声”一般指随机扰动,例如:
- 传感器热噪声;
- 电子噪声;
- 椒盐噪声;
- 光子噪声;
这些噪声可以在图像采集时就已经出现。例如光照不足时,传感器为了成像会放大微弱信号,随机波动也会随之变得明显。
而像:
- 运动模糊;
- 失焦模糊;
- 强光光晕;
- 镜头耀斑;
严格来说更适合称为图像退化(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}. ]
因此,从数学上看:
一幅彩色图像可以看作定义在二维离散空间上的向量值函数。
最常见的颜色表达方式是 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 很适合描述显示设备中的颜色混合:
[ Color=(R,G,B) ]
但颜色信息与亮度信息混合得比较严重。
例如阴影可能导致三个通道同时发生明显变化。
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 ]
并不能保证每一个像素都严格恢复原值。
前面讨论 RGB、HSV,是在改变“一个像素里面如何描述颜色”。
我们还可以从另外一个完全不同的角度重新表示整幅图像:
不再直接讨论每一个位置是什么颜色,而讨论整幅图像包含多少不同空间频率的变化。
这就引出了傅里叶变换。
对于二维图像 (f(x,y)),二维傅里叶变换可以写成:
\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{从“哪里发生了什么”转换为“包含哪些变化尺度”} } ]
频率描述的是:
[ \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{目标提取}. ]
最简单的情况,是我们已经知道目标大致会出现在哪里。
例如相机固定以后,我们知道目标只可能出现在中央区域。
那么完全没有必要处理整幅图像。
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} ]
即感兴趣区域。
我们直接把周围部分舍弃。
这是一种非常重要的工程思想:
如果先验知识已经告诉我们某些区域一定没有目标,就没有必要设计复杂算法去“识别”这些区域。
有时候最简单的裁剪,反而是最鲁棒、最高效的方法。
假设背景和前景之间具有明显的亮度差别。
这时颜色本身可能并不重要,我们可以首先把彩色图像转换成灰度图:
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)原本一个像素可能包含:
[ (B,G,R) ]
三个数。
现在变成:
[ I(x,y) ]
一个数。
显然我们丢失了大量颜色信息。
但是如果我们的任务只关心:
这个地方亮还是暗?
那么这些颜色信息本来就是多余的。
这就是所谓:
[ \boxed{\text{有目的的信息压缩}} ]
接下来还可以继续丢失信息。
例如:
_, 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 中介绍。
固定阈值存在一个明显问题。
假设图像左边由于阴影整体较暗,而右边由于灯光整体较亮:
左侧 右侧
背景 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
)它并不是整幅图使用同一个阈值,而是根据一个局部邻域计算阈值。
例如可以近似理解为:
\operatorname{mean} \bigl( \text{neighborhood}(x,y) \bigr) -C. ]
这样,就能够适应空间上缓慢变化的光照。
还有一个问题:
固定阈值里面的 (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{一种自动确定全局阈值的方法}} ]
如果前景与背景之间没有明显亮度差异,但是有明显颜色差异,那么灰度化反而会把重要信息丢掉。
例如:
一个红色苹果放在亮度相近的绿色背景上。
这时候颜色就是关键特征。
通常可以将图像转换到 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 经常用于传统颜色目标检测。
不过颜色阈值存在明显局限。
例如:
- 光源颜色改变;
- 曝光改变;
- 阴影;
- 高光;
- 白平衡变化;
都会使颜色分布发生变化。
因此:
简单颜色阈值适合环境相对受控的任务,但在复杂自然环境中鲁棒性通常有限。
如果颜色和亮度都不够稳定,我们还可以进一步研究:
[ \boxed{\text{物体在哪里发生了突变?}} ]
这就引出了边缘。
首先需要区分 OpenCV 中非常容易混淆的两个概念:
边缘表示图像强度发生剧烈变化的位置。
例如:
30 30 30 30 | 220 220 220
从 30 突然跳到 220 的位置就是一个强边缘。
它不要求形成闭合结构。
轮廓是由一系列边界点组成的曲线。
OpenCV 的 findContours() 会从二值图像中提取连续的边界点序列。官方示例经常先使用 Canny 得到二值边缘图,再通过 findContours() 提取轮廓。
因此不能简单说:
轮廓按照定义必须闭合,而边缘不闭合。
更准确地说:
边缘是局部的强度变化;轮廓则是把空间上连续的边界点组织成曲线。实际目标的外边界往往形成闭合轮廓,但“闭合”并不是理解二者差别的唯一标准。
可以把整个过程理解成:
[ \text{图像} \rightarrow \text{边缘点} \rightarrow \text{边缘点之间的连接关系} \rightarrow \text{轮廓}. ]
在数学分析里面,我们使用导数描述变化。
对于一维函数:
[ 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{这个像素附近变化到底有多剧烈?} } ]
如果 Sobel 可以理解为一阶导数,那么 Laplacian 可以理解为二阶导数。
二维 Laplace 算子为:
\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 同样可以用于边缘检测。
不过二阶导数对噪声通常更加敏感,所以实际使用时一般需要先进行平滑处理。
现代传统图像处理中,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 → 高阈值
得到二值边缘图以后,可以进一步寻找轮廓:
contours, hierarchy = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE
)这里的:
cv.RETR_EXTERNAL表示主要寻找最外层轮廓。
而:
cv.CHAIN_APPROX_SIMPLE会对轮廓点进行一定压缩。
例如一个矩形边界如果有几百个像素点,并不需要把所有共线点全部保存,只需要保存几个关键点即可。
假设经过二值化、颜色阈值或者 Canny 以后,我们已经得到了很多候选目标。
这时还可以进一步问:
哪一个候选轮廓的形状最像我要找的目标?
OpenCV 提供了大量轮廓几何特征函数。官方 contour 教程中包含面积、周长、最小包围圆、椭圆拟合和直线拟合等操作。
area = cv.contourArea(cnt)例如我们知道苹果在当前距离下大概应该有:
[ 5000 < A < 20000 ]
那么:
if 5000 < cv.contourArea(cnt) < 20000:
...就可以过滤大量噪声轮廓。
perimeter = cv.arcLength(cnt, True)其中 True 表示按照闭合曲线计算周长。
最简单的是:
x, y, w, h = cv.boundingRect(cnt)得到一个轴对齐矩形:
[ (x,y,w,h). ]
如果目标会旋转,可以使用:
rect = cv.minAreaRect(cnt)
box = cv.boxPoints(rect)得到最小旋转外接矩形。
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")需要注意,实际图片存在噪声和透视变形,因此不能简单地认为“四个顶点就一定是正方形”。
还需要综合:
- 边长;
- 角度;
- 长宽比;
- 面积;
- 凸性。
可以求最小包围圆:
(x, y), radius = cv.minEnclosingCircle(cnt)也可以构造圆度:
[ C= \frac{4\pi A}{P^2}. ]
其中:
- (A):面积;
- (P):周长。
理想圆:
[ C=1. ]
形状越不规则,一般越小。
如果轮廓点足够,可以使用:
ellipse = cv.fitEllipse(cnt)得到:
- 中心;
- 长轴;
- 短轴;
- 旋转角。
然后:
cv.ellipse(
img,
ellipse,
(0, 255, 0),
2
)即可进行可视化。
前景与背景通常具有相对稳定的结构。
但是噪声往往表现为:
[ \boxed{ \text{某个像素与周围像素明显不一致} } ]
例如:
100 102 101
99 255 103
101 100 102
中间突然出现:
[ 255 ]
非常可疑。
因此,一个自然想法就是:
不要只相信当前像素,而是参考它周围的像素。
这就是局部滤波的基本思想。
很多滤波器都可以统一写成卷积:
\sum_i\sum_j K(i,j) f(x-i,y-j). ]
其中 (K) 称为:
[ \boxed{\text{卷积核 Kernel}} ]
它本质上定义了:
当前输出像素应该如何参考周围像素。
因此我们可以说:
[ \boxed{ \text{卷积是一种利用局部邻域信息重新计算像素的方法} } ]
这也是大量传统图像处理算法的核心数学工具。
最简单的卷积核:
[ K= \frac19 \begin{bmatrix} 1&1&1\ 1&1&1\ 1&1&1 \end{bmatrix}. ]
于是当前像素变成周围 9 个像素的平均值。
OpenCV:
blur = cv.blur(
img,
(5, 5)
)优点:
- 简单;
- 快。
缺点:
- 所有邻居权重相同;
- 很容易把边缘一起模糊掉。
相比简单平均,高斯滤波认为:
离中心越近的像素应该越重要。
二维高斯函数为:
\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{高频受到抑制}. ]
因此可以降低噪声,但是同样会牺牲部分边缘细节。
如果出现:
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{椒盐噪声}} ]
需要注意:
中值滤波不是线性卷积,因为“取中位数”并不是线性运算。
高斯滤波有一个问题:
它只知道两个像素离得近,却不知道它们是不是属于同一个物体。
假设:
黑色物体 | 白色背景
位于边缘两侧的两个像素空间距离很近,但是颜色差异巨大。
双边滤波同时考虑:
[ \text{空间距离} ]
和:
[ \text{颜色差异}. ]
可以简单理解为:
w_\text{space} \cdot w_\text{color}. ]
如果两个像素:
- 距离近;
- 颜色又相近;
权重很大。
如果虽然距离近,但是颜色差别很大,则权重降低。
因此双边滤波具有:
[ \boxed{ \text{平滑噪声同时尽量保留边缘} } ]
的特点。
OpenCV:
bilateral = cv.bilateralFilter(
img,
9,
75,
75
)不过代价就是计算量通常比高斯滤波大。
前面讲到:
[ \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{噪声} } ]
因为:
- 物体边缘;
- 文字;
- 细小纹理;
本身同样属于高频信息。
所以过度低通会产生模糊。
对于二值图像:
[ I(x,y)\in{0,1} ]
或者:
[ I(x,y)\in{0,255}. ]
这时候我们更关心的往往不是:
[ \text{像素数值应该平均成多少} ]
而是:
[ \boxed{ \text{一个白色区域具有怎样的几何结构?} } ]
所以虽然均值、高斯甚至中值滤波并不是“不能”用于二值图像,但在已经得到明确二值分割结果以后,它们往往不是最自然的工具。
更合适的是:
[ \boxed{\text{数学形态学}} ]
OpenCV 的官方形态学教程也主要围绕二值图像介绍腐蚀、膨胀、开运算和闭运算。
假设规定:
[ \text{白色}=前景 ]
[ \text{黑色}=背景. ]
腐蚀会使白色区域缩小:
kernel = cv.getStructuringElement(
cv.MORPH_RECT,
(3, 3)
)
eroded = cv.erode(
binary,
kernel
)它可以用来:
- 去除孤立小白点;
- 断开细小连接;
- 缩小前景。
膨胀则让白色区域扩大:
dilated = cv.dilate(
binary,
kernel
)它可以:
- 填补小裂缝;
- 加粗目标;
- 连接较近区域。
单独腐蚀会缩小目标。
单独膨胀会放大目标。
因此我们通常把二者组合起来。
\text{Erosion} \rightarrow \text{Dilation} } ]
即:
opened = cv.morphologyEx(
binary,
cv.MORPH_OPEN,
kernel
)它特别适合删除:
[ \boxed{\text{小白点}} ]
因为小白点经过腐蚀以后消失,而较大的主体经过后续膨胀可以基本恢复。
OpenCV 官方教程也将开运算描述为先腐蚀后膨胀,并用于移除较小的亮色目标。
\text{Dilation} \rightarrow \text{Erosion} } ]
OpenCV:
closed = cv.morphologyEx(
binary,
cv.MORPH_CLOSE,
kernel
)适合:
- 填补白色目标中的小黑洞;
- 连接狭小断裂。
因此可以建立一个非常好记的关系:
[ \boxed{ \text{开运算:去小白点} } ]
[ \boxed{ \text{闭运算:填小黑洞} } ]
当然,这个结论默认:
[ \text{白色代表前景}. ]
如果黑白含义反过来,解释也需要相应反转。
图像算法的调试不能只看最终输出。
因为假设最终目标没有被检测出来,你必须知道:
究竟是哪一步错了?
可能是:
颜色阈值错了
也可能是:
形态学把目标腐蚀没了
或者:
Canny 阈值过高
甚至:
findContours 正确,但是面积阈值设置错了
因此,图像处理程序应该尽可能把中间结果可视化出来。
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)这样就可以观察流水线每一步发生了什么。
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)表示绿色。
cv.circle(
img,
(200, 200),
50,
(0, 0, 255),
2
)其中:
(0, 0, 255)表示红色。
cv.drawContours(
img,
contours,
-1,
(0, 255, 0),
2
)其中:
-1表示绘制所有轮廓。
cv.putText(
img,
"apple",
(100, 80),
cv.FONT_HERSHEY_SIMPLEX,
0.8,
(0, 255, 0),
2
)可视化最重要的意义实际上是:
[ \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{可视化本身就是调试工具} } ]
而不仅仅是程序最后的展示功能。