3. 图像处理基础(下):图像滤波与去噪、边缘检测(Canny/Sobel)、形态学操作

好,咱们接着往下聊。上一节我们把图像的基本操作和色彩空间过了一遍,这一节要进入真正“干活”的部分了。

你想想看,一张从摄像头拍回来的交通标志照片,能直接用吗?大概率不行。路上有灰尘、光线不均匀、车牌反光、运动模糊……这些噪声不处理掉,后面的识别准确率会惨不忍睹。所以,滤波、去噪、边缘检测、形态学操作,这些就是咱们的“预处理三板斧”。

3.1 图像滤波与去噪:把脏东西洗掉

滤波,说白了就是用一个小窗口(核)在图像上滑动,把每个像素的值替换成它周围像素的某种“平均值”。不同的“平均”方式,就产生了不同的滤波效果。

3.1.1 均值滤波

最简单粗暴的方法。把核覆盖的所有像素加起来,除以像素个数。嗯,就像你拿一块抹布在图像上抹一下,模糊了,但噪声也淡了。

import cv2
import numpy as np

img = cv2.imread('traffic_sign.jpg')
# 5x5的核,核越大越模糊
blurred = cv2.blur(img, (5, 5))
cv2.imshow('Mean Blur', blurred)
注意:均值滤波有个致命缺点——它会把边缘也一起模糊掉。边缘是啥?是交通标志的轮廓啊!模糊了边缘,后面还怎么检测?所以,我个人习惯在去噪要求不高的时候才用它。

3.1.2 高斯滤波

高斯滤波比均值滤波聪明一点。它不再是一视同仁地平均,而是根据像素离中心点的距离,给不同的权重。离得近的权重大,离得远的权重小。这个权重分布,就是高斯分布。

我在项目中遇到过这样的情况:用均值滤波去噪,结果标志的白色边框和红色底色糊在一起了。换成高斯滤波,边缘保留得好很多。

# 高斯滤波,核大小5x5,sigmaX=0表示自动计算
gaussian = cv2.GaussianBlur(img, (5, 5), 0)
cv2.imshow('Gaussian Blur', gaussian)
小技巧:核大小必须是奇数。为什么?因为奇数才有中心像素点。3x3、5x5、7x7是常用尺寸。我一般先用5x5试试,效果不够再加大。

3.1.3 中值滤波

这个我要重点说一下。中值滤波不是算平均,而是把核覆盖的所有像素值排序,取中间那个值。它对椒盐噪声(就是那种黑白点点的噪声)有奇效。

我曾经处理过一张在强光下拍摄的限速标志,上面全是白点。均值滤波搞不定,高斯滤波也一般,换成中值滤波,一下就干净了。

# 中值滤波,核大小必须是奇数
median = cv2.medianBlur(img, 5)
cv2.imshow('Median Blur', median)
滤波方法 去噪效果 边缘保留 适用场景
均值滤波 一般 简单去噪,要求不高
高斯滤波 较好 中等 通用去噪,保留一定边缘
中值滤波 优秀(椒盐噪声) 椒盐噪声、脉冲噪声

3.2 边缘检测:找到物体的“骨架”

去完噪,下一步就是找边缘。边缘是图像中像素值发生剧烈变化的地方。说白了,就是物体的轮廓。交通标志的圆形、三角形、矩形,全靠边缘来提取。

3.2.1 Sobel算子

Sobel算子是一种基于一阶导数的边缘检测方法。它用两个3x3的核,一个检测水平方向的变化,一个检测垂直方向的变化。

# 先转成灰度图
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# Sobel在x方向
sobelx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
# Sobel在y方向
sobely = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)

# 取绝对值并转成uint8
sobelx = np.uint8(np.absolute(sobelx))
sobely = np.uint8(np.absolute(sobely))

# 合并
sobel_combined = cv2.bitwise_or(sobelx, sobely)
cv2.imshow('Sobel', sobel_combined)
核心要点:Sobel算子对噪声敏感。所以,用Sobel之前,一定要先做滤波。我通常的流程是:高斯滤波 → Sobel边缘检测。

3.2.2 Canny边缘检测

Canny是边缘检测的“扛把子”。它比Sobel复杂,但效果也更好。它做了四件事:去噪(高斯滤波)、计算梯度、非极大值抑制、双阈值检测。

非极大值抑制是啥意思?说白了,就是只保留梯度方向上的局部最大值,把那些“不够尖”的边缘去掉。双阈值检测呢?设定一个高阈值和一个低阈值,高于高阈值的肯定是边缘,低于低阈值的肯定不是,介于中间的,如果跟确定的边缘相连,就算它是边缘。

我记得第一次用Canny的时候,调阈值调了半天。后来发现,对于交通标志,低阈值设50,高阈值设150,效果通常不错。

# Canny边缘检测
# 参数:图像,低阈值,高阈值
edges = cv2.Canny(gray, 50, 150)
cv2.imshow('Canny Edges', edges)
调参建议:高阈值和低阈值的比例一般在2:1到3:1之间。比如(50, 150)或(30, 90)。如果检测到的边缘太多(噪声多),就提高阈值;如果边缘太少(漏检),就降低阈值。

3.3 形态学操作:修修补补的艺术

边缘检测完了,得到的二值图像往往不完美。边缘有断裂、有空洞、有多余的小点。这时候,形态学操作就派上用场了。它就像给图像做“整形手术”。

3.3.1 腐蚀与膨胀

腐蚀:让白色区域“瘦”一圈。可以去掉小的白点噪声。

膨胀:让白色区域“胖”一圈。可以连接断裂的边缘。

# 定义结构元素(核)
kernel = np.ones((5,5), np.uint8)

# 腐蚀
erosion = cv2.erode(edges, kernel, iterations=1)
# 膨胀
dilation = cv2.dilate(edges, kernel, iterations=1)

cv2.imshow('Erosion', erosion)
cv2.imshow('Dilation', dilation)

3.3.2 开运算与闭运算

开运算 = 先腐蚀后膨胀。用来去除小白点噪声,同时保持大物体形状不变。

闭运算 = 先膨胀后腐蚀。用来填充物体内部的小空洞,连接断裂的轮廓。

我曾经在处理一个“禁止停车”标志时,Canny检测出来的圆形边缘断成了好几段。用闭运算一搞,立马连成完整的圆。嗯,那种感觉,很爽。

# 开运算
opening = cv2.morphologyEx(edges, cv2.MORPH_OPEN, kernel)
# 闭运算
closing = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)

cv2.imshow('Opening', opening)
cv2.imshow('Closing', closing)
避坑指南:我曾经犯过一个错误——对一张已经很好的边缘图做了好几次闭运算,结果把两个相邻的标志连在了一起。所以,形态学操作要“见好就收”,迭代次数别太多。

3.4 实战小流程:从原图到干净边缘

好了,把今天学的串起来。一个典型的交通标志预处理流程是这样的:

  1. 读取图像,转成灰度图
  2. 高斯滤波,去除高斯噪声
  3. Canny边缘检测,得到初步边缘
  4. 闭运算,连接断裂的轮廓
  5. 开运算,去除小的噪声点
def preprocess_for_edge_detection(img_path):
    img = cv2.imread(img_path)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 1. 高斯滤波
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    
    # 2. Canny边缘检测
    edges = cv2.Canny(blurred, 50, 150)
    
    # 3. 形态学操作
    kernel = np.ones((3,3), np.uint8)
    closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations=1)
    cleaned = cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel, iterations=1)
    
    return cleaned

# 调用
result = preprocess_for_edge_detection('stop_sign.jpg')
cv2.imshow('Final Result', result)

这个流程,我用了好几年,在多个交通标志数据集上验证过,效果稳定。当然,具体参数要根据你的图像质量微调。但大方向,就是这个路子。

下一节,我们会基于这些干净的边缘,去提取轮廓、计算形状特征,然后真正开始识别交通标志。准备好了吗?