OpenCV 是一个功能强大、应用广泛的计算机视觉库,提供了大量的计算机视觉算法和图像处理工具,广泛应用于图像和视频的处理、分析以及机器学习领域

使用pip安装是最简单和直接的方法,只需要在命令行中输入以下命令:

1
pip install opencv-python

安装后导入包:

1
import cv2

图像理论

图像在计算机中本质上是以数字矩阵的形式存储的,其核心概念包括像素网格和颜色通道

像素网格:

  • 像素(pixel):图片的最小单元,每个像素有位置(行、列)和颜色值
  • 分辨率:宽×高(例如1920×1080),表示像素矩阵的尺寸
  • 像素矩阵:一张图片在内存里通常就是一个二维或三维数组(矩阵),H行xW列(x3or4通道),坐标系通常是左上角为原点(行号向下增大)

颜色与通道:

  • RGB:最常见,基于人眼感知颜色的方式,每个像素由3个通道(红、绿、蓝)组合成颜色
  • RGBA:RGB + Alpha(透明度),Alpha 通道决定像素的透明程度
  • 灰度:只有一个通道(亮度),常用公式把 RGB 转成灰度:Y = 0.299 R + 0.587 G + 0.114 B(ITU标准) 0为黑,255为白
  • CMYK:印刷领域(青、品红、黄、黑),基于减色法
  • YCbCr:主要用于JPEG,RGB转YCbCr,再对Cb、Cr下采样(4:2:0常见),再做DCT压缩

Pillow/Matplotlib用RGB顺序,OpenCV默认用BGR,注意转换

位深与数据类型:

  • 常见:8位每通道(uint8),范围0–255,RGB24(3×8=24 位)是最常见的普通图像

  • 高精度:16位每通道(uint16),范围0–65535,或32位浮点(float32)用于科学/HDR

常见的存储方式:

格式 压缩方式 是否有损 支持透明度 特点与应用场景
BMP 无损 支持 简单直白,文件大
PNG DEFLATE 无损 支持 Alpha 体积比BMP小
常用于需要透明的图形
GIF LZW 无损 1 位透明 支持简单动画,但颜色数少
JPEG DCT+量化 有损 不支持 照片最常用格式,高压缩比
反复保存会劣化
TIFF 多种 均可 支持 专业图像格式,高位深
摄影、医学、印刷常用
WebP VP8/VP8L压缩 均可 支持 Alpha 压缩比优于JPEG/PNG,Web 常见

代码性能评估

1
2
3
4
5
6
import cv2

start = cv2.getTickCount()
# 这里写测试代码...
end = cv2.getTickCount()
print((end - start) / cv2.getTickFrequency())

也可以用 time 模块计时

1
2
3
4
5
6
import time

start = time.clock()
# 这里写测试代码...
end = time.clock()
print(end - start)

数据元素少时用 Python 语法,数据元素多时用 Numpy

1
2
3
4
5
6
7
8
x = 10
z = np.uint8([10])
start = cv2.getTickCount()
y = z*z*z # 5.46e-05(最慢)
# y = x*x*x # 2.14e-05
# y = x**3 # 4.55e-05
end = cv2.getTickCount()
print((end - start) / cv2.getTickFrequency())

图像基础操作

读取图片

1
2
3
4
img = cv2.imread('Lena.bmp', 0)
if img is None: # 读取保护
print("Cannot load image")
exit()

参数1:图片的文件名

参数2:读入方式,省略即采用默认值

  • cv2.IMREAD_COLOR:彩色图,默认值(1)
  • cv2.IMREAD_GRAYSCALE:灰度图(0)
  • cv2.IMREAD_UNCHANGED:包含透明通道的彩色图(-1)

显示图片

1
2
3
cv2.imshow("Lena",img) # 参数1是窗口的名字,参数2是要显示的图片
cv2.waitKey(0) # 等待键盘输入(毫秒),0 表示无限等待
# plt.imshow(img, 'gray') # 也可以用plt的imshow

保存图片

1
cv2.imwrite("Lena_gray.bmp",img)

可以传入第三个参数:

  • cv2.IMWRITE_JPEG_QUALITY:jpg 质量控制,取值 0~100,值越大质量越好,默认为 95
  • cv2.IMWRITE_PNG_COMPRESSION:png 质量控制,取值 0~9,值越大压缩比越高,默认为 1
1
2
3
4
cv2.imwrite('img_jpg20.jpg',img, [int(cv2.IMWRITE_JPEG_QUALITY), 20]) # 9.74kB
cv2.imwrite('img_jpg100.jpg',img, [int(cv2.IMWRITE_JPEG_QUALITY), 100]) # 137kB
cv2.imwrite('img_png.png',img) # 134kB
cv2.imwrite('img_png9.png',img,[int(cv2.IMWRITE_PNG_COMPRESSION),9]) # 132kB

图像矩阵

OpenCV读进来的图像是NumPy数组,shape通常是(H, W, C),可以通过img.shape输出

1
2
height, width, channels = img.shape
# img 是灰度图的话:height, width = img.shape

图像是由像素组成的矩阵,每个像素都有一个或多个值,表示颜色或灰度

OpenCV默认的颜色空间为BGR

1
2
3
4
5
img = cv2.imread("LenaRGB.bmp",1)
px = img[99,99]
print(px) # 输出BGR[79 59 177]
px_blue = img[99,99,0]
print(px_blue) # 输出B 79

ROI(Region of Interest):利用:,也就是numpy的切片,将图片中的区域裁切出来

1
2
3
4
# 截取脸部 ROI
face = img[239:388, 238:356]
cv2.imshow("face",face)
cv2.waitKey(0)

颜色空间

OpenCV 支持多种颜色空间的转换,通过cv2.cvtColor(img, code)

常用转化code

  • cv2.COLOR_BGR2GRAY: BGR彩色 -> 灰度
  • cv2.COLOR_BGR2RGB: BGR彩色 -> RGB彩色(用于Matplotlib等显示)
  • cv2.COLOR_BGR2HSV: BGR彩色 -> HSV(色相、饱和度、亮度)
  • cv2.COLOR_GRAY2BGR: 灰度 -> BGR彩色 (单通道转三通道)

颜色通道的分离与合并:

1
2
3
4
# 分离通道
b, g, r = cv2.split(image)
# 合并通道
merged_image = cv2.merge([b, g, r])

单通道显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
img = cv2.imread("LenaRGB.bmp",1)
b, g, r = cv2.split(img)
# 单通道保留,其他置零
zeros = np.zeros_like(b)
blue_img = cv2.merge([b, zeros, zeros]) # 蓝色图
green_img = cv2.merge([zeros, g, zeros]) # 绿色图
red_img = cv2.merge([zeros, zeros, r]) # 红色图
plt.subplot(2,2,1)
plt.imshow(cv2.cvtColor(img,cv2.COLOR_BGR2RGB))
plt.subplot(2,2,2)
plt.imshow(blue_img)
plt.subplot(2,2,3)
plt.imshow(green_img)
plt.subplot(2,2,4)
plt.imshow(red_img)
202509241324

RGB 调色板

首先需要知道如何创建滑动条(滑块最小值固定为0)

1
cv2.createTrackbar(trackbarName, windowName, value, max_value ,call_back)

实现一个 RGB 的调色板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 创建回调函数
def nothing(x):
pass
# 创建一个黑色的画布
img = np.zeros((300,500,3), np.uint8)
cv2.namedWindow("RGB Palette")
# 创建三个滑动条,分别对应 R/G/B
cv2.createTrackbar("R","RGB Palette", 0, 255, nothing)
cv2.createTrackbar("G","RGB Palette", 0, 255, nothing)
cv2.createTrackbar("B","RGB Palette", 0, 255, nothing)
while True:
# 读取滑动条的值
r = cv2.getTrackbarPos("R", "RGB Palette")
g = cv2.getTrackbarPos("G", "RGB Palette")
b = cv2.getTrackbarPos("B", "RGB Palette")
# 更新画布颜色(注意 OpenCV 是 BGR 顺序)
img[:] = [b, g, r]
# 显示结果
cv2.imshow("RGB Palette", img)
# 按下 ESC 键退出
if cv2.waitKey(1) & 0xFF == 27:
break
cv2.destroyAllWindows()

几何变换

在cv2的函数中输入的一般是(w,h),虽然在numpy输出的shape是(h,w)

仿射变换

cv2.warpAffine():仿射变换是一种保持直线和比例关系的线性几何变换

长度/角度可能变化,但相对位置关系不变

常见的仿射变换包括:缩放、翻转、平移、旋转

1
dst = cv2.warpAffine(img, M, dsize)

其中M是变换矩阵,dsize是输出图像大小(width, height)

变换矩阵可以通过cv2.getAffineTransform()求得,只需要知道变换前后对应三个点的坐标即可

1
2
3
4
5
6
7
8
img = cv2.imread("Lena.bmp")
rows, cols = img.shape[:2] # 获取原图(height, width)
pts1 = np.float32([[50, 50], [100, 50], [50, 200]]) # 原图 3 点
pts2 = np.float32([[0, 0], [150, 50], [100, 250]]) # 目标 3 点
# 生成变换矩阵
M = cv2.getAffineTransform(pts1, pts2)
dst = cv2.warpAffine(img, M, (cols*2, rows*2)) # 输入(width,height)
plt.imshow(dst,'gray')
20259242130

缩放

cv2.resize():调整图像大小(放大或缩小)

1
2
3
4
5
# 指定尺寸(width,height)
resized_img = cv2.resize(img, (new_width,new_height))
# 按比例缩小
scale_factor = 0.5
resized_img = cv2.resize(img, None, fx=scale_factor, fy=scale_factor, interpolation=cv2.INTER_AREA)

interpolation(插值方法):

  • cv2.INTER_LINEAR 双线性(默认,放大推荐)
  • cv2.INTER_AREA 区域插值(缩小推荐)
  • cv2.INTER_CUBIC 三次插值(更平滑,慢)
  • cv2.INTER_NEAREST 最近邻(最快,但可能马赛克)

翻转

cv2.flip()

1
2
flipped_img = cv2.flip(image, flip_code)  
# flip_code: 0 (垂直翻转), 1 (水平翻转), -1 (双向翻转)

平移

使用仿射变换函数cv2.warpAffine()

需要定义一个变换矩阵,$tx ,ty$是向$x$和$y$方向平移的距离,$M=[[1,0,t_x],[0,1,t_y]]$

1
2
3
4
(h, w) = img.shape[:2]  # 输出高与宽
# 向右移100像素,向下移50像素
translation_matrix = np.float32([[1, 0, 100], [0, 1, 50]])
shifted_img = cv2.warpAffine(img, translation_matrix, (w, h))

旋转

使用仿射变换函数cv2.warpAffine()

绕某个点旋转,可伴随缩放,也需要定义一个变换矩阵

通过cv2.getRotationMatrix2D()函数来生成这个矩阵,该函数有三个参数

  • 参数1:图片的旋转中心(一般是(w//2,h//2))
  • 参数2:旋转角度(正:逆时针,负:顺时针)
  • 参数3:缩放比例,0.5 表示缩小一半
1
2
3
center = (w//2, h//2)
rotation_matrix = cv2.getRotationMatrix2D(center, 45, 0.5)
rotated_img = cv2.warpAffine(img, rotation_matrix, (w, h))

图像加减法

相加减两幅图片的形状(高度/宽度/通道数)必须相同

1
2
result = cv2.add(img1, img2) # 相加
result = cv2.subtract(img1, img2) # 相减

numpy中可以直接用 res = img + img1 相加,但这两者的结果并不相同

如果像素值相加后超过255,OpenCV 会自动将其截断为255(注意,必须是二维矩阵)

1
2
3
4
x = np.uint8([[250]]) # 注意是 2D
y = np.uint8([[10]])
print("cv2.add:", cv2.add(x, y)) # [[255]]
print("numpy + :", x + y) # [[4]]

图像位运算

图像位运算是将两幅图像的每个像素值转为二进制以后进行位操作

图像必须是相同大小和通道数,否则不能直接做按位运算

主要用于二值图像处理以及掩膜运算(掩膜mask是对一幅图片进行局部的遮挡)

函数 功能 应用场景
cv2.bitwise_and(img1,img2) 按位与操作 掩膜交集
cv2.bitwise_or(img1,img2) 按位或操作 掩膜并集
cv2.bitwise_not(img) 按位取反操作 反转掩膜
cv2.bitwise_xor(img1,img2) 按位异或操作 掩膜并集-交集

利用图片自身按位与掩膜上为255的保留原值,为0的区域置0

按位与

1
2
3
4
5
6
img1 = cv2.imread("LenaRGB.bmp", 1)
img2 = cv2.imread("OpenCV_logo_no_text.png", 1)
img2 = cv2.resize(img2,(img1.shape[1],img1.shape[0])) # 匹配大小
# 转灰度图好创建掩膜
img2_gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
plt.imshow(img2_gray, cmap='gray')
202509241712
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建掩膜
# 主体掩膜,背景全0,主体255,保留主体
_, mask = cv2.threshold(img2_gray, 10, 255, cv2.THRESH_BINARY)
# 反转,背景掩膜,主体为0,背景255,保留背景
mask_inv = cv2.bitwise_not(mask)
# 保留img1的背景
img1_bg = cv2.bitwise_and(img1, img1, mask=mask_inv) # 自身取与
# 提取img2主体
img2_fg = cv2.bitwise_and(img2, img2, mask=mask) # 自身取与
res = cv2.add(img1_bg,img2_fg) # 加法融合
# cv2.imshow("result", res)
# cv2.waitKey(0)
res = cv2.cvtColor(res, cv2.COLOR_BGR2RGB)
plt.imshow(res)
202509241257

按位或

按位或并不等同于图像叠加

1
2
3
4
5
6
7
8
9
img1 = cv2.imread("Lena.bmp", 0)
img2 = cv2.imread("OpenCV_logo_no_text.png", 0)
img2 = cv2.resize(img2,(img1.shape[1],img1.shape[0])) # 匹配大小
res1 = cv2.bitwise_or(img1, img2) # 无法形成透明效果
res2 = cv2.addWeighted(img1, 0.5, img2, 0.5, 0) # 产生自然的图像融合
plt.subplot(2,2,1)
plt.imshow(res1, cmap='gray')
plt.subplot(2,2,2)
plt.imshow(res2,cmap='gray')
202509241733

图像融合

阿尔法混合(加权混合)

图像混合就是把两张图像的像素值按照一定比例组合
$$
dst(x,y) = \alpha \cdot img1(x,y) + \beta \cdot img2(x,y) + \gamma
$$
$\gamma$ 为一个偏移量(调亮/调暗整体效果用)

1
2
3
4
5
6
7
8
9
10
11
img1 = cv2.imread("Barbara.bmp", 0)
img2 = cv2.imread("Baboon.bmp", 0)
# # 调整两张图大小一致
img2 = cv2.resize(img2,(img1.shape[1],img1.shape[0]))
# 做一个淡入淡出
for alpha in np.linspace(0, 1, 20):
beta = 1 - alpha
blended = cv2.addWeighted(img1, alpha, img2, beta, 0)
cv2.imshow("Blending", blended)
cv2.waitKey(200)
cv2.destroyAllWindows()

拉普拉斯金字塔融合

如果直接把两张图拼接,边界很明显:

  • 颜色/亮度突变
  • 纹理不连续

即使用线性渐变(alpha blending),仍可能出现模糊边界或鬼影

金字塔融合的核心思想:在不同的尺度(分辨率)上融合图像

适合那些既要平滑过渡,又要细节自然的场景,但是会损失细节

图像金字塔有两种:高斯金字塔拉普拉斯金字塔

高斯金字塔中较高层中的每个像素都是由其下一层中的5个像素以高斯权重贡献形成的

对于$M\times N$的图像,通过cv.pyrDown()[下采样]变成$M/2\times N/2$,获得金字塔的上层
$$
G_0,G_1,G_2,\cdots,G_n
$$

1
2
3
4
5
6
7
8
9
# 创建高斯金字塔
def generate_gaussian_pyramid(img, level=6): # 这里level=最高层数
G = img.copy()
gp = [G]
for i in range(level):
G = cv2.pyrDown(G)
gp.append(G)
# gp: G0,G1...Gn
return gp
1
2
3
4
5
6
7
8
img = cv2.imread("imgs/funingna.png", 0)
gp = generate_gaussian_pyramid(img, level=5)
plt.figure(figsize=(12, 6))
for i in range(len(gp)):
plt.subplot(2,3,i+1)
plt.imshow(gp[i], cmap='gray')
plt.title(f"G{i}")
plt.tight_layout()
202509261408

拉普拉斯金字塔由高斯金字塔该层与上层cv.pyrUp()[上采样]之间的差值形成
$$
L_i = G_i - \mathrm{Expand}(G_{i+1})
$$

也可以写成(代码循环常这么写)
$$
L_{i-1} = G_{i-1}-\mathrm{Expand}(G_{i})
$$
循环到高斯金字塔的最后一层时,没有更小的高斯层了,无法继续计算,就直接保留高斯金字塔的最小层图像(将$G_n$放到$L_n$的位置)

1
2
3
4
5
6
7
8
9
10
11
12
# 创建拉普拉斯金字塔
def generate_laplacian_pyramid(gp):
N = len(gp)
lp = [gp[N-1]] # 最高层直接放入,没有上层了
for i in range(N-1 ,0,-1):
GE = cv2.pyrUp(gp[i])
# resize 这步是因为如果图像的size不是偶数,上填充或下填充后尺寸不匹配
GE = cv2.resize(GE,(gp[i-1].shape[1],gp[i-1].shape[0]))
L = cv2.subtract(gp[i-1], GE)
lp.append(L)
# lp: Gn,Ln-1,...L0
return lp

需要注意的是,由于计算的时候是从Gn开始计算,所以生成的拉普拉斯金字塔列表是反转过来的

1
2
3
4
5
6
7
8
lp = generate_laplacian_pyramid(gp)
lp = lp[::-1] # 反转,最高层放在末尾
plt.figure(figsize=(12, 6))
for i in range(len(lp)):
plt.subplot(2,3,i+1)
plt.imshow(lp[i],cmap='gray')
plt.title(f"L{i}")
plt.tight_layout()
202509261409

融合两张图片的拉普拉斯金字塔,获得最终图的拉普拉斯金字塔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 融合两个拉普拉斯金字塔(简单拼接,各取一半)
def blend_half(lpA, lpB):
LS = []
for la, lb in zip(lpA, lpB):
rows, cols, dpt = la.shape
ls = np.hstack((la[:,:cols//2], lb[:, cols//2:]))
LS.append(ls)
return LS

# 融合两个拉普拉斯金字塔(渐变拼接)
def blend_gradient(lpA, lpB):
LS = []
for la, lb in zip(lpA, lpB):
rows, cols, dpt = la.shape
# 维度大小为3, 维度1为1,维度2自动计算,维度3为1
mask = np.linspace(1, 0, cols).reshape(1, -1, 1) # 从左到右渐变
mask = np.repeat(mask, rows, axis=0)
ls = la * mask + lb * (1-mask)
LS.append(ls.astype(np.uint8))
return LS

重建图样从最小的高斯图(最高层)开始,逐层往大推
$$
G_i = L_i+\mathrm{Expand}(G_{i+1})
$$

1
2
3
4
5
6
7
8
# 从拉普拉斯金字塔重建图像
def reconstruct_from_laplacian(LS):
G = LS[0]
for i in range(1, len(LS)):
GE = cv2.pyrUp(G)
GE = cv2.resize(GE,(LS[i].shape[1],LS[i].shape[0])) # 匹配尺寸
G = cv2.add(GE, LS[i])
return G

因为在生成拉普拉斯金字塔时把Gn放在了列表头部,所以就不需要反转了!

1
2
res = reconstruct_from_laplacian(lp[::-1]) # 反转回来,叠加从最高层开始
plt.imshow(res, cmap='gray')
2025092614010

官方demo:苹果+橘子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
apple = cv2.imread("imgs/apple.jpg", 1)
orange = cv2.imread("imgs/orange.jpg", 1)
# 创建高斯金字塔
gpA = generate_gaussian_pyramid(apple)
gpB = generate_gaussian_pyramid(orange)
# 创建拉普拉斯金字塔
lpA = generate_laplacian_pyramid(gpA)
lpB = generate_laplacian_pyramid(gpB)
# 融合拉普拉斯金字塔
LS = blend_half(lpA, lpB)
# LS = blend_gradient(lpA, lpB)
# 从拉普拉斯金字塔重建图像(从最小图样开始)
blended = reconstruct_from_laplacian(LS)
# 直接拼接图样
direct = np.hstack((apple[:, :apple.shape[1]//2],
orange[:, orange.shape[1]//2:]))
202509261401

图像平滑

首先讲一下图像噪声,常见噪声如下:

  • 高斯噪声:常见于传感器噪声,整幅图像像素轻微抖动、模糊,$n\sim N(\mu,\sigma^2)$,高斯滤波效果最好
  • 椒盐噪声:像素随机变成黑点(0)或白点(255),常见于图像传输干扰,数据丢包,中值滤波效果最好

卷积填充

用3×3的核对6×6的图像进行卷积,得到的是4×4的图,图片缩小了

可以把原图扩充一圈,再卷积,这个操作叫填充padding

OpenCV中有好几种填充方式,都使用cv2.copyMakeBorder()函数实现

1
2
3
4
5
6
7
8
9
dst = cv2.copyMakeBorder(
src, # 输入图像
top, # 上边界填充像素数
bottom, # 下边界填充像素数
left, # 左边界填充像素数
right, # 右边界填充像素数
borderType, # 边界填充类型
[, value] # CONSTANT类型使用的颜色值
)

其中固定值填充和默认填充(镜像填充)最常用

填充类型 (borderType) 描述
cv2.BORDER_REFLECT_101
(cv2.BORDER_DEFAULT)
镜像填充(不包含边界像素)
cv2.BORDER_CONSTANT 固定值填充,用 value 指定颜色
cv2.BORDER_REPLICATE 复制边缘像素,边界处像素往外扩展
cv2.BORDER_REFLECT 镜像反射边界(包含边界像素)
cv2.BORDER_WRAP 环绕填充,从另一边取值
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
# 创建3x3测试矩阵(使用ASCII值)
matrix = np.array([
[97, 98, 99], # a,b,c
[100, 101, 102], # d,e,f
[103, 104, 105] # g,h,i
], dtype=np.uint8)

def print_border(matrix, border_type, name):
padded = cv2.copyMakeBorder(matrix, 2, 2, 2, 2, border_type)
print(f"\n{name}:")
rows, cols = padded.shape
for i in range(rows):
row_chars = [] # 存储打印字符
for j in range(cols):
ch = chr(padded[i, j]) # 将数字转为chr类型
# 判断是否属于原矩阵区域
if 2 <= i < 2 + matrix.shape[0] and 2 <= j < 2 + matrix.shape[1]:
row_chars.append(f"\033[31m{ch}\033[0m") # 红色
# \033[31m → 设置红色 \033[0m → 重置回默认颜色
else:
row_chars.append(ch)
print(" ".join(row_chars)) # 把 row_chars 里的字符用空格拼接成一行

# 测试不同填充方式
print("原始矩阵:")
print("a b c\nd e f\ng h i")

print_border(matrix, cv2.BORDER_CONSTANT, "BORDER_CONSTANT")
print_border(matrix, cv2.BORDER_REPLICATE, "BORDER_REPLICATE")
print_border(matrix, cv2.BORDER_REFLECT, "BORDER_REFLECT")
print_border(matrix, cv2.BORDER_REFLECT_101, "BORDER_REFLECT_101 (DEFAULT)")
print_border(matrix, cv2.BORDER_WRAP, "BORDER_WRAP")

卷积滤波

卷积滤波就是用一个核(kernel, mask, filter)在图像上滑动,对每个位置进行加权求和,生成新的像素值
$$
g(x,y)=\sum_{i=-k}^k\sum_{j=-k}^kf(x+i,y+j)\cdot h(i,j)
$$
通用卷积函数cv2.filter2D(),可以用任意核

1
dst = cv2.filter2D(img, ddepth, kernel)
  • ddepth:输出图像深度(-1表示和输入一样)

常用于图像锐化操作:

1
2
3
4
kernel = np.array([[0,-1,0],
[-1,5,-1],
[0,-1,0]], np.float32)
sharpened = cv2.filter2D(img, -1, kernel)

均值滤波、高斯滤波、中值滤波、双边滤波都属于卷积滤波,不过python都有对应的函数,不使用filter2D来实现

方法 特点 优点 缺点
均值滤波 平均邻域 简单 模糊边缘严重
高斯滤波 高斯加权 平滑自然 仍有模糊
中值滤波 取中值 对椒盐噪声好 模糊细节
双边滤波 空间+像素相似度 边缘保留最好 计算量大

均值滤波

均值滤波是一种最简单的滤波处理,将图像中每个像素的值替换为其周围像素的平均值,可以有效地去除噪声,但可能会导致图像变得模糊

1
dst = cv2.blur(img, (5, 5)) # (5,5)为滤波核的大小

高斯滤波🔥

卷积核是一个二维高斯函数,邻域中心权重大,越远权重越小

对随机噪声(高斯噪声)效果比较好,边缘比均值滤波保留得更自然

1
2
dst = cv2.GaussianBlur(img, (5, 5), 0) 
# 第三个参数为高斯核的标准差,如果为0,则根据核大小自动计算

中值滤波🔥

中值滤波是一种非线性平滑处理方法,将图像中每个像素的值替换为其周围像素的中值

中值滤波在去除椒盐噪声(即图像中随机出现的黑白点)时非常有效

1
2
dst = cv2.medianBlur(img, 5)  
# 第二个参数为滤波核大小,必须为奇数

双边滤波

双边滤波是一种非线性的平滑处理方法,结合了空间邻近度和像素值相似度

考虑空间距离 + 像素值差异,既能平滑噪声,又能保留边缘

1
2
3
4
dst = cv2.bilateralFilter(img, 9, 75, 75)
# 第二个参数为滤波核大小,不一定奇数
# 第三个参数为颜色空间的标准差,控制像素值相似度的权重
# 第四个参数为坐标空间的标准差,控制空间距离的权重

噪声添加与消除

skimage.util.random_noise是一个方便函数,可以直接给图像添加多种常见噪声

1
2
3
from skimage.util import random_noise
gaussian_noise = random_noise(img, mode='gaussian', mean=0, var=0.01) # 高斯噪声
sp_noise = random_noise(img, mode='s&p', amount=0.02) # 椒盐噪声

random_noise 返回的图像是 浮点型,范围在 [0,1],所以需要转回OpenCV常用的uint8

1
gaussian_noise = (gaussian_noise * 255).astype(np.uint8)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from skimage.util import random_noise
# 读取图像
img = cv2.imread("LenaRGB.bmp",1)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
# 添加高斯噪声
gaussian_noise = random_noise(img, mode='gaussian', var=0.01) # var 控制噪声强度
gaussian_noise = (gaussian_noise * 255).astype(np.uint8)
# 添加椒盐噪声
sp_noise = random_noise(img, mode='s&p', amount=0.02) # amount 控制噪声比例
sp_noise = (sp_noise * 255).astype(np.uint8)
# 显示带噪声图样
plt.figure(figsize=(10, 5))
plt.subplot(121)
plt.imshow(gaussian_noise)
plt.title("Gaussian Noise")
plt.subplot(122)
plt.imshow(sp_noise)
plt.title("S&P Noise")
202509252044

通过各种滤波方式可以对噪声进行去除

1
2
3
4
# 高斯噪声处理
gauss_mean = cv2.blur(gaussian_noise, (5, 5))
gauss_gaussian = cv2.GaussianBlur(gaussian_noise, (5, 5), 0)
gauss_median = cv2.medianBlur(gaussian_noise, 5)
202509252045

均值滤波会平滑图像,使得图像稍微模糊一些,高斯滤波并不能完全去除高斯噪声

这是因为它只是一个低通滤波器,主要削弱高频分量,高斯噪声的高频部分会被削弱,但低频部分的噪声依然保留,所以它不能“专门去掉高斯噪声”,而只是做了一次模糊平均

1
2
3
4
# 椒盐噪声处理
sp_mean = cv2.blur(sp_noise, (5, 5))
sp_gaussian = cv2.GaussianBlur(sp_noise, (5, 5), 0)
sp_median = cv2.medianBlur(sp_noise, 5)
202509252046

可以看到中值滤波对椒盐噪声的处理非常好,基本恢复原图的状态了

阈值分割

固定阈值分割

将灰度图像二值化或多阈值化,通过cv2.threshold()来实现阈值分割

1
cv2.threshold(img, thresh, maxval, type)

thresh: 阈值

maxval: 当像素值超过阈值时赋予的新值[用于THRESH_BINARY],一般为255

type:阈值类型(最重要)

阈值类型 高于阈值 低于阈值 应用
cv2.THRESH_BINARY(二值化) 赋值为 maxval 赋值为 0 前景白、背景黑
cv2.THRESH_BINARY_INV(反二值化) 赋值为 0 赋值为 maxval 背景白、前景黑
cv2.THRESH_TRUNC(截断) 截断成阈值 保持原值 限制高光
cv2.THRESH_TOZERO 保持原值 赋值为 0 滤掉低强度背景
cv2.THRESH_TOZERO_INV 赋值为 0 保持原值 滤掉高亮区域

返回retval, dst

  • retval: 实际使用的阈值(在使用 THRESH_OTSUTHRESH_TRIANGLE 时特别有用)
  • dst: 阈值化后的图像
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import matplotlib.pyplot as plt
img = cv2.imread("gradient_gray.png", 0)
_, dst1 = cv2.threshold(img,127,255,cv2.THRESH_BINARY)
_, dst2 = cv2.threshold(img,127,255,cv2.THRESH_BINARY_INV)
_, dst3 = cv2.threshold(img,127,255,cv2.THRESH_TRUNC)
_, dst4 = cv2.threshold(img,127,255,cv2.THRESH_TOZERO)
_, dst5 = cv2.threshold(img,127,255,cv2.THRESH_TOZERO_INV)
titles = ['Original', 'BINARY', 'BINARY_INV', 'TRUNC', 'TOZERO', 'TOZERO_INV']
images = [img, dst1, dst2, dst3, dst4, dst5]
for i in range(6):
plt.subplot(2, 3, i + 1)
plt.imshow(images[i], 'gray')
plt.title(titles[i], fontsize=8)
plt.xticks([]), plt.yticks([]) # 隐藏坐标轴
plt.show()
image-20250922215427237

自适应阈值

cv2.adaptiveThreshold()自适应阈值会每次取图片的一小部分计算阈值,这样图片不同区域的阈值就不尽相同

1
thresholded_image = cv2.adaptiveThreshold(img, maxval, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, block_size, C)

maxval:最大阈值,一般为 255

  • 参数3:小区域阈值的计算方式
    • ADAPTIVE_THRESH_MEAN_C:小区域内取均值
    • ADAPTIVE_THRESH_GAUSSIAN_C:小区域内加权求和,权重是个高斯核
  • 参数4:阈值方法,只能使用THRESH_BINARYTHRESH_BINARY_INV
  • 参数5:小区域的面积(正方形),输入边长(像素)
  • 参数6:最终阈值等于小区域计算出的阈值再减去此值
1
2
3
4
5
6
7
8
9
10
11
12
img = cv2.imread("Cameraman.bmp", 0)
_, dst1 = cv2.threshold(img,127,255,cv2.THRESH_BINARY)
dst2 = cv2.adaptiveThreshold(img,255,cv2.ADAPTIVE_THRESH_MEAN_C,cv2.THRESH_BINARY,11,4)
dst3 = cv2.adaptiveThreshold(img,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY,11,4)
titles = ['Original', 'BINARY(127)', 'Adaptive Mean', 'Adaptive Gaussian']
images = [img, dst1, dst2, dst3]
for i in range(4):
plt.subplot(2, 2, i + 1)
plt.imshow(images[i], 'gray')
plt.title(titles[i], fontsize=8)
plt.xticks([]), plt.yticks([])
plt.show()
autothreshold

Otsu’s阈值

在前面cv2.threshold() 用的是固定阈值,比如thresh=127

在很多场景下,图像亮度分布不均匀,固定阈值效果不好

Otsu’s方法是一种自适应阈值选择算法,通过分析图像灰度直方图,自动确定最佳分割阈值

核心思想是最大化前景(目标)与背景之间的类间方差

1
2
thresh_val, otsu_img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) 
# 写法基本固定,第2个参数必须设为0,真正的阈值由算法计算
1
2
3
4
5
6
7
8
9
10
img = cv2.imread("Cameraman.bmp", 0)
_, dst1 = cv2.threshold(img , 127, 255, cv2.THRESH_BINARY)
_, dst2 = cv2.threshold(img, 0 , 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
imgs = [img, dst1, dst2]
titles = ["Original", "BINARY(127)", "OTSU"]
for i in range(3):
plt.subplot(2, 2, i + 1)
plt.imshow(imgs[i], cmap='gray')
plt.title(titles[i])
plt.xticks([]), plt.yticks([])
202509252101

边缘检测

图像边缘检测是计算机视觉和图像处理中的一项基本任务,它用于识别图像中亮度变化明显的区域,这些区域通常对应于物体的边界

边缘检测通常基于梯度(Gradient),它表示图像强度变化的方向和大小

  • 一维情况下:边缘 = 信号一阶导数极大值位置

  • 二维情况下
    $$
    \nabla f(x,y)=\left[\frac{\partial f}{\partial x},\frac{\partial f}{\partial y}\right]
    $$
    梯度幅值(边缘强度)
    $$
    G=\sqrt{\left(\frac{\partial f}{\partial x}\right)^2+\left(\frac{\partial f}{\partial y}\right)^2}
    $$
    梯度方向
    $$
    \theta = \arctan\left(\frac{\partial f}{\partial y}/\frac{\partial f}{\partial x}\right)
    $$

常用的梯度算子如下:

算法 核心 适用场景 缺点
Sobel算子 一阶导数(差分)+平滑 检测水平和垂直边缘 边缘定位精度一般,特别是对斜向边缘不够准确
Scharr算子 优化的Sobel 检测细微的边缘 计算量略高于Sobel,低分辨率图片差别不大
Laplacian算子 二阶导数 检测边缘和角点 对噪声非常敏感,检测结果往往是“双边缘”

数值转化

常见算子通常用 CV_64FCV_32F 保存结果,计算出来的结果是float类型,包含负数

OpenCV的imshow和Matplotlib的imshow(cmap="gray") 都假定数据范围在[0,255](或 [0,1]浮点)

如果直接显示float图像,会导致数据被自动线性拉伸,结果和实际数值分布不符

如果为了清晰判断与分析边缘,需要对其进行cv2.convertScaleAbs()处理,这样能保证边缘效果直观可见

如果后续还需要进行一些运算,就不能取cv2.convertScaleAbs(),因为符号信息在一些算法里是有意义的,此时保留float 原始结果更合理

1
magnitude = cv2.magnitude(sobelx, sobely)  # 准确梯度幅值

Sobel算子

Sobel算子是一种基于梯度的边缘检测算子,它通过计算图像在水平和垂直方向上的梯度来检测边缘,结合了高斯平滑和微分操作,因此对噪声具有一定的抑制作用

水平方向卷积核 [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]

垂直方向卷积核 [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]

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
img = cv2.imread("Lena.bmp", 0)
# sobel输出的是float64,包含负数
sobelx = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=3) # x方向
sobely = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=3) # y方向
# 将输出转换为 CV_8U 图像(绝对值),丢掉“方向信息”
sobelx_abs = cv2.convertScaleAbs(sobelx)
sobely_abs = cv2.convertScaleAbs(sobely)
# 添加两个方向梯度来近似梯度(不是精确的计算)
grad = cv2.addWeighted(sobelx_abs, 0.5, sobely_abs, 0.5, 0) # 常用
# 准确的梯度幅值
sobel = cv2.magnitude(sobelx, sobely)
# 显示结果
plt.subplot(221)
plt.imshow(sobelx_abs, cmap='gray')
plt.title('Sobelx')
plt.subplot(222)
plt.imshow(sobely, cmap='gray')
plt.title('Sobely')
plt.subplot(223)
plt.imshow(grad, cmap='gray')
plt.title('Approx gradient')
plt.subplot(224)
plt.imshow(sobel_abs, cmap='gray')
plt.title('Amplitude gradient')
plt.tight_layout()
202592511

Scharr算子

是Sobel的改进版,权重更大,在小核(3×3)时效果优于 Sobel

水平方向卷积核 [[-3, 0, 3], [-10, 0, 10], [-3, 0, 3]]

垂直方向卷积核 [[-3, -10, -3], [0, 0, 0], [3, 10, 3]]

1
2
scharrx = cv2.Scharr(img, cv2.CV_64F, 1, 0) # 核大小不可调,只支持3*3
scharry = cv2.Scharr(img, cv2.CV_64F, 0, 1)

Laplacian算子

Laplacian算子是一种二阶微分算子,它通过计算图像的二阶导数来检测边缘,但对噪声比较敏感,因此通常在使用之前会对图像进行高斯平滑处理

卷积核 [[0, 1, 0], [1, -4, 1], [0, 1, 0]]

1
laplacian = cv2.Laplacian(img, cv2.CV_64F, ksize=1, scale=1, borderType=cv2.BORDER_DEFAULT)
  • ksize:Laplacian核的大小,默认为 1

    并不是代表卷积核大小为1,而是最基本的二阶差分算子
    $$
    f_{xx}(x,y)=f(x+1,y)+f(x-1,y)-2f(x,y)\
    f_{yy}(x,y)=f(x,y+1)+f(x,y-1)-2f(x,y)
    $$
    Laplacian就变成了一个固定的 3×3 卷积核

  • scale:缩放因子,默认为 1

1
2
3
4
img = cv2.imread("Lena.bmp", 0)
laplacian = cv2.Laplacian(img, cv2.CV_64F,ksize=3)
laplacian_abs = cv2.convertScaleAbs(laplacian)
plt.imshow(laplacian_abs, cmap='gray')
202509251429

Canny边缘检测🔥

稳定、精确,最常用

由John F. Canny提出,主要包括以下几个步骤:

  1. 噪声抑制:使用高斯滤波器对图像进行平滑处理,以减少噪声的影响
  2. 计算梯度:使用Sobel算子计算图像的梯度幅值和方向
  3. 非极大值抑制(NMS):沿着梯度方向,保留局部梯度最大的像素点,删除不被认为是边缘一部分的像素,只有细线候选边缘将保留
  4. 双阈值检测:使用两个阈值(低阈值和高阈值)来确定真正的边缘
    • 如果像素梯度高于高阈值,则该像素被接受为边缘
    • 如果像素梯度值低于下限阈值,则将其拒绝
    • 如果像素梯度介于两个阈值之间,则只有当它连接到高于上限阈值的像素时,才会被接受
1
edges = cv2.Canny(img, threshold1, threshold2, apertureSize=3, L2gradient=False)

img:必须是单通道的灰度图像

threshold1:低阈值越低,检测到的候选点越多,但噪声也会增多

threshold2:高阈值越高,检测到的边缘越少,但更可靠,高阈值通常是低阈值的2-3倍

apertureSize:Sobel算子的孔径大小,默认为3

L2gradient:是否使用 L2 范数计算梯度幅值,默认为 False

1
2
3
img = cv2.imread("LEna.bmp", 0)
edges = cv2.Canny(img, 100, 200)
plt.imshow(edges, cmap='gray')
202509242233

创建滑动条

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建回调函数
def nothing(x):
pass
img = cv2.imread("Lena.bmp", 0)
# img = cv2.resize(img,None,fx=0.1, fy=0.1)
plt.imshow(img, cmap='gray')
cv2.namedWindow("Canny Edge Detection")
# 创建滑动条,分别对应 threshold1, threshold2
cv2.createTrackbar("threshold1","Canny Edge Detection", 0, 255, nothing)
cv2.createTrackbar("threshold2","Canny Edge Detection", 0, 255, nothing)
while True:
threshold1 = cv2.getTrackbarPos("threshold1", "Canny Edge Detection")
threshold2 = cv2.getTrackbarPos("threshold2", "Canny Edge Detection")
edges = cv2.Canny(img, threshold1, threshold2)
cv2.imshow("Canny Edge Detection", edges)
if cv2.waitKey(0) & 0xFF == 27: # 按esc退出
break
cv2.destroyAllWindows()

自适应阈值设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 基于图像中值自动设置阈值
def auto_canny(img, sigma=0.33):
# 计算图像中值
v = np.median(img)
# 自动设置阈值
lower = int(max(0, (1.0 - sigma) * v))
upper = int(min(255, (1.0 + sigma) * v))
edges = cv2.Canny(img, lower, upper)
return edges, lower, upper

img = cv2.imread("Lena.bmp", 0)
adaptive_edges, lower, upper = auto_canny(img)
plt.imshow(adaptive_edges, cmap='gray')
print(f"threshold1: {lower}, threshold2: {upper}")
# threshold1: 86, threshold2: 171

Otsu计算最佳阈值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 使用Otsu方法计算最佳阈值
def otsu_canny(img):
# 计算Otsu阈值
otsu_thresh, _ = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 设置Canny阈值
lower = int(otsu_thresh * 0.5) # 确保阈值为整数
upper = int(otsu_thresh) # 确保阈值为整数
edges = cv2.Canny(img, lower, upper)
return edges, lower, upper

img = cv2.imread("Lena.bmp", 0)
otsu_edges, lower, upper = otsu_canny(img)
plt.imshow(otsu_edges, cmap='gray')
print(f"threshold1: {lower}, threshold2: {upper}")
# threshold1: 58, threshold2: 117

实时边缘检测

结合滑动条,控制Canny的两个阈值,视频部分后面章节具体讲

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
# 创建回调函数
def nothing(x):
pass
cv2.namedWindow("Overlay") # 创建窗口
# 创建滑动条,分别对应 threshold1, threshold2
cv2.createTrackbar("threshold1","Overlay", 0, 255, nothing)
cv2.createTrackbar("threshold2","Overlay", 0, 255, nothing)
# 读取视频
cap = cv2.VideoCapture("camera_vedio.mp4")
while True:
ret, frame = cap.read()
# ret: 标识符,表示是否成功读取
# frame: 当前帧图像
threshold1 = cv2.getTrackbarPos("threshold1", "Overlay")
threshold2 = cv2.getTrackbarPos("threshold2", "Overlay")
if not ret: # 视频结束
# break # 结束
cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # 重置到第0帧
continue # 跳过这次循环,重新读
gray = cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY) # Canny需要灰度图像
edges = cv2.Canny(gray, threshold1, threshold2)
edges_color = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR) # 转到3通道才能叠加
overlay = cv2.addWeighted(frame, 0.8, edges_color, 0.5, 0)
cv2.imshow("Overlay", overlay)
if cv2.waitKey(30) & 0xFF == 27: # 按esc退出,每帧停留30ms
break
cap.release()
cv2.destroyAllWindows()

或者也可以利用edges实现掩膜上色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cap = cv2.VideoCapture("camera_vedio.mp4")
while True:
ret, frame = cap.read()
if not ret: # 视频结束
break
gray = cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 100, 200)
mask = edges > 0
frame[mask] = [0,0,255] # 掩膜上色
cv2.imshow("Edges on Original", frame)
if cv2.waitKey(30) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()

形态变换

形态学变换是一些基于图像形状的简单操作,它通常在二值图像上执行

它需要两个输入,一个是原始图像,第二个称为结构元素或内核,它决定操作的性质

两种基本的形态学算子是腐蚀和膨胀

操作 函数 应用场景
腐蚀 cv2.erode() 去除噪声、分离物体
膨胀 cv2.dilate() 连接断裂的物体、填充空洞
开运算 cv2.morphologyEx() 去除小物体、平滑物体边界
闭运算 cv2.morphologyEx() 填充小孔洞、连接邻近物体
形态学梯度 cv2.morphologyEx() 提取物体边缘

腐蚀

腐蚀操作是一种缩小图像中前景对象的过程,其原理是在原图的小区域内取局部最小值,小区域内有一个是0该像素点就为0(用numpy实现就是遍历选一个region取其中min)

1
cv2.erode(img, kernel, iterations=1)
  • kernel: 结构元素,可以自定义或使用 cv2.getStructuringElement() 生成

    1
    2
    3
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))  # 矩形结构
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) # 椭圆结构
    kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (5, 5)) # 十字形结构
    getStructuringElement
  • iterations: 腐蚀操作的次数,默认为1

1
2
3
4
img = cv2.imread("imgs/j.png",0)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) # 定义结构元素
erosion = cv2.erode(img, kernel, iterations=1)
plt.imshow(erosion,cmap="gray")

膨胀

膨胀操作与腐蚀相反,它是一种扩大图像中前景对象的过程

1
cv2.dilate(src, kernel, iterations=1)
1
2
3
img = cv2.imread("imgs/j.png",0)
dilate = cv2.dilate(img, kernel, iterations=1)
plt.imshow(dilate,cmap="gray")

开运算

开运算只是先腐蚀后膨胀的另一种说法,用于去除小的白色噪声点

cv2.MORPH_OPEN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from skimage.util import random_noise
img = cv2.imread("imgs/j.png",0)
# 创建一个黑色背景存放椒盐噪声
noise_full = random_noise(np.zeros_like(img), mode='s&p', amount=0.01)
noise_full = (noise_full * 255).astype(np.uint8) # 转回 0-255
# 选取图片中黑色部分产生椒盐噪声
mask = (img == 0)
# 生成对应图
sp_noise = img.copy()
sp_noise[mask] = noise_full[mask]
# 开运算
open = cv2.morphologyEx(sp_noise, cv2.MORPH_OPEN, kernel)
plt.subplot(121)
plt.imshow(sp_noise, cmap="gray")
plt.subplot(122)
plt.imshow(open,cmap="gray")
202509261440

闭运算

闭运算是开运算的逆运算,先膨胀后腐蚀,用于填充白色区域内部的小黑洞

cv2.MORPH_CLOSE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from skimage.util import random_noise
img = cv2.imread("imgs/j.png",0)
# 创建一个白色背景存放椒盐噪声
white_bg = np.ones_like(img, dtype=np.float32)
noise_full = random_noise(white_bg, mode='s&p', amount=0.01)
noise_full = (noise_full * 255).astype(np.uint8)
# 选取图片中白色部分产生椒盐噪声
mask = (img == 255)
# 生成对应图
sp_noise = img.copy()
sp_noise[mask] = noise_full[mask]
# 开运算
close = cv2.morphologyEx(sp_noise, cv2.MORPH_CLOSE, kernel)
plt.subplot(121)
plt.imshow(sp_noise, cmap="gray")
plt.subplot(122)
plt.imshow(close,cmap="gray")
202509261446

形态学梯度

形态学梯度是膨胀图像与腐蚀图像的差值,主要用于提取图像中前景对象的边缘

cv2.MORPH_GRADIENT

1
2
3
img = cv2.imread("imgs/j.png",0)
gradient = cv2.morphologyEx(img, cv2.MORPH_GRADIENT, kernel)
plt.imshow(gradient, cmap="gray")
image-20250926145056930

小总结

一般常见的图像处理流程:

  1. 读取+灰度化:减少计算量,很多算子只需要单通道;特殊情况保持彩色(比如分割 RGB/HSV特征)

  2. 降噪(平滑):均值滤波/高斯滤波/中值滤波,让后续边缘检测或分割更稳定

  3. 增强(对比度提升,锐化):直方图均衡化/CLAHE → 提升对比度;卷积或Unsharp Mask → 突出细节;[会造成噪声加剧]

  4. 阈值分割:固定阈值、Otsu、自适应阈值,得到前景/背景的mask

  5. 边缘检测:Canny(最主要),可直接基于分割结果做轮廓提取

    方法 结果特点 适用场景
    灰度图做边缘检测 包含更多细节(比如纹理),但噪声多 需要提取细微边缘(如纹理、阴影)
    阈值图做边缘检测 轮廓更简洁(闭合),噪声少 需要提取物体轮廓(如零件、数字)
  6. 形态学处理:膨胀/腐蚀:强化结构特征;形态学梯度:得到轮廓;开/闭运算:去小噪点/填小孔洞[针对的是二值图,无论阈值分割还是边缘检测得到的结果都是二值图]

这些步骤并不是固定且必须的

图像增强:读取 → 灰度化 → 去噪/平滑 → 对比度/亮度增强 → 锐化

图像恢复:读取 → 灰度化 → 退化模型估计 → 去噪/去模糊 → 恢复图像

图像分割:读取 → 灰度化 → 阈值分割或边缘检测 → 形态学处理 → 区域标记

特征提取:读取 → 灰度化 → 平滑 → 边缘/角点检测 → 特征提取

图像轮廓

轮廓可以简单地解释为连接所有连续点(沿边界)的曲线,这些点具有相同的颜色或强度

轮廓和边缘很像,不过轮廓是连续的,边缘并不全都连续

轮廓是用于形状分析和对象检测与识别的有用工具

为了获得更高的精度,需要使用二值图像

寻找轮廓是针对白色物体的,一定要保证物体是白色,而背景是黑色

主要流程及函数:

步骤 函数
图像预处理(转灰度) cv2.cvtColor()
二值化处理 cv2.threshold()
查找轮廓 cv2.findContours()
绘制轮廓 cv2.drawContours()
计算轮廓面积 cv2.contourArea()
计算轮廓周长 cv2.arcLength()
计算边界矩形 cv2.boundingRect()
计算最小外接矩形 cv2.minAreaRect()
计算最小外接圆 cv2.minEnclosingCircle()
多边形逼近 cv2.approxPolyDP()

代码框架:

1
2
3
4
5
img = cv2.imread("imgs/match_shape.jpg",1)   # 存为彩色
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 转灰度,使得灰度和彩色图样都可
_, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU) # 二值化
contours, hierarchy = cv2.findContours(thresh, 3, 2) # 查找轮廓
cv2.drawContours(img, contours, -1, (0,255,0),2) # 在原图上绘制轮廓(绿色)

寻找轮廓

cv2.findContours()用于在二值图像中查找轮廓

1
2
3
4
5
contours, hierarchy = cv2.findContours(
image, # 输入图像(必须是二值或边缘图像)
mode, # 轮廓检索模式
method # 轮廓近似方法
)
  • mode: 轮廓检索模式,常用的有:

    • cv2.RETR_EXTERNAL/1: 只检测最外层轮廓
    • cv2.RETR_LIST/2: 检测所有轮廓,但不建立层次关系
    • cv2.RETR_TREE/3: 检测所有轮廓,并建立完整的层次结构(常用)
    • cv2.RETR_CCOMP:把所有的轮廓只分为2个层级,不是外层的就是里层的
  • method: 轮廓近似方法,常用的有:

    • cv2.CHAIN_APPROX_NONE/1: 存储所有的轮廓点,轮廓很密

    • cv2.CHAIN_APPROX_SIMPLE/2: 压缩水平、垂直和对角线段,只保留端点(常用)

      第一张图像显示使用cv.CHAIN_APPROX_NONE获得的点(734)

      第二张图像显示了使用cv.CHAIN_APPROX_SIMPLE获得的点(4)

返回值:

  • contours: 检测到的轮廓列表,以数组形式存储,记录了每条轮廓的所有像素点的坐标
  • hierarchy: 轮廓的层次结构信息
1
2
3
4
img = cv2.imread("imgs/2&5.png",1)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
contours, hierarchy = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

绘制轮廓

cv2.drawContours()用于在图像上绘制检测到的轮廓

1
cv2.drawContours(img, contours, contourIdx, color, thickness)
  • contours: 轮廓列表
  • contourIdx: 要绘制的轮廓索引,如果为负数,则绘制所有轮廓
  • thickness:线宽,设为-1时将填充轮廓

无返回值,直接在输入图像上绘制轮廓

1
2
cv2.drawContours(img, contours, -1, (0,255,0)) # 绘制所有轮廓	
cv2.drawContours(img, contours, 3, (0,255,0)) # 绘制第四个轮廓

但很多情况下,会以以下方式绘制单个轮廓

1
2
cnt = contours[3]
cv2.drawContours(img, [cnt], 0, (0,255,0)) # 绘制第四个轮廓
1
2
3
4
cv2.drawContours(img, contours, -1, (0,255,0),2)
cv2.imshow("Contours", img)
cv2.waitKey(0)
cv2.destroyAllWindows()
202509271112

轮廓特征

轮廓属性

1
2
cnt = contours[0]
M = cv2.moments(cnt)

图像矩可以帮助计算一些特征,例如轮廓的质心:

1
2
3
cx = int(M['m10']/M['m00'])
cy = int(M['m01']/M['m00'])
print(cx,cy) # 379 445

轮廓的面积:

1
2
area = cv2.contourArea(cnt) 
area = M['m00']

计算轮廓的周长或弧长:

1
length = cv2.arcLength(cnt, True)   # True 表示闭合

边界矩形

1
2
x, y, w, h = cv2.boundingRect(cnt)
cv2.rectangle(img,(x,y),(x+w,y+h),(0,255,0),2) # 绘制矩形函数,输入两个对角点
202509271212

最小外接矩形

1
2
3
4
rect = cv2.minAreaRect(cnt) # 返回一个旋转矩形
box = cv2.boxPoints(rect) # 把旋转矩阵的信息转换成4个角点坐标,即轮廓
box = box.astype(int) # 转回int对象,不然报错
cv2.drawContours(img, [box], -1, (0, 255, 0),2) # 图上绘制轮廓
202509271412

最小外接圆

1
2
3
4
(x,y), radius = cv2.minEnclosingCircle(cnt) # 返回圆心坐标以及半径
center = (int(x),int(y)) # 转回int对象,不然报错
radius = int(radius) # 转回int对象,不然报错
cv2.circle(img,center,radius,(0,255,0),2)
202509271442

椭圆拟合

1
2
3
img = cv2.imread("imgs/2&5.png",1)
ellipse = cv2.fitEllipse(cnt) # 返回椭圆内切的旋转矩阵
cv2.ellipse(img,ellipse,(0,255,0),2)
202509271513

多边形逼近

根据指定的精度将轮廓形状逼近到具有较少顶点的另一个形状,这是Douglas-Peucker算法的一种实现

1
approx = cv2.approxPolyDP(cnt, epsilon, True)  # 返回近似后的多边形点集(轮廓)
  • epsilon:轮廓到逼近轮廓的最大距离,值越小,近似越精确
1
2
3
4
5
img = cv2.imread("imgs/2&5.png",1)
for contour in contours:
epsilon = 0.01*cv2.arcLength(contour,True) # 一般取轮廓长度的1/10作为精度
approx = cv2.approxPolyDP(contour,epsilon,True) # True表示闭合曲线
cv2.drawContours(img,[approx],0,(0,255,0),2)
202509271447

凸包

在二维平面里,给定一组点,凸包就是能把这些点“包”起来的最小凸多边形

凸包看起来类似于轮廓逼近,但并非如此(在某些情况下,两者可能提供相同的结果)

凸包的边界都是凸的,没有凹进去的部分

OpenCV 提供了 cv2.convexHull 来计算凸包

1
hull = cv2.convexHull(points[, hull[, clockwise[, returnPoints]]])

points:输入点集(通常是 contour)

hull:输出索引,通常避免使用它

clockwise:方向标志,True 表示顺时针,False 表示逆时针

returnPointsTrue(默认):返回点坐标;False:返回的是点的索引

1
2
3
img = cv2.imread("imgs/2&5.png",1)
hull = cv2.convexHull(cnt) # 返回点坐标,即轮廓
cv2.drawContours(img,[hull],0,(0,255,0),2)
202509271512

如果想查找凸性缺陷,则需要将 returnPoints 设置为 False

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# True 返回
[[[390 289]]
[[395 290]]
[[399 291]]
...]
# False 返回
[[521]
[519]
[517]
...]
print(cnt[hull[:3]])
[[[[390 289]]]
[[[395 290]]]
[[[399 291]]]]

可以通过cv.isContourConvex()判断曲线是否为凸

1
is_convex = cv2.isContourConvex(cnt)

轮廓属性

长宽比:边界矩形的宽高比

1
2
x,y,w,h = cv.boundingRect(cnt)
aspect_ratio = w/h # 宽高比

范围:轮廓面积与边界矩形面积的比值

1
2
3
4
area = cv.contourArea(cnt)
x,y,w,h = cv.boundingRect(cnt)
rect_area = w*h
extent = area/rect_area # 范围

实体度:轮廓面积与其凸包面积的比值

1
2
3
hull = cv2.convexHull(cnt)
hull_area = cv2.contourArea(hull)
solidity = area/hull_area # 实体度

等效直径:与轮廓面积相同的圆的直径

1
equi_diameter = np.sqrt(4*area/np.pi)

掩模

1
2
3
4
5
mask = np.zeros(gray.shape,np.uint8) # 构建同图片的全0数组
cv2.drawContours(mask,[cnt],0,255,-1) # 在其上绘制轮廓,用白色填充轮廓内容
# 相当于获得了一个二值掩膜
pixelpoints = np.transpose(np.nonzero(mask)) # 记得转置
# pixelpoints = cv2.findNonZero(mask) # 结果相同

Numpy以**(行,列)格式给出坐标,而OpenCV以(x,y)**格式给出坐标

获得掩膜以后可以利用它进行一些计算

最大值、最小值及其位置:

1
min_val, max_val, min_loc, max_loc = cv.minMaxLoc(gray, mask = mask) # 只可以灰度

平均颜色或平均强度:

1
mean_val = cv.mean(img, mask = mask) # 可输入三通道图样

极值点:

极值点是指对象的最高点、最低点、最右点和最左点

1
2
3
4
5
6
7
8
9
10
leftmost   = tuple(cnt[cnt[:,:,0].argmin()][0])
rightmost = tuple(cnt[cnt[:,:,0].argmax()][0])
topmost = tuple(cnt[cnt[:,:,1].argmin()][0])
bottommost = tuple(cnt[cnt[:,:,1].argmax()][0])

cv2.circle(img, leftmost, 8, (0,0,255), -1) # 红色
cv2.circle(img, rightmost, 8, (0,255,0), -1) # 绿色
cv2.circle(img, topmost, 8, (255,0,0), -1) # 蓝色
cv2.circle(img, bottommost, 8, (0,255,255), -1) # 黄色
plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
202509271613

简单案例(数硬币)

写一个函数,统计图像中“物体的数量和面积分布”(比如数硬币)

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
import cv2
import numpy as np

def count_objects(img_path, show = False, min_area=100):
img = cv2.imread(img_path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 阈值分割
_ , thresh = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)
# 开运算去除噪声点
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
open = cv2.morphologyEx(thresh, cv2.MORPH_OPEN,kernel)
# 找轮廓
contours, _ = cv2.findContours(open, 3, 2)
# 统计面积和数量
areas = []
valid_contours = []
for cnt in contours:
area = cv2.contourArea(cnt)
if area >= min_area:
areas.append(area)
valid_contours.append(cnt)

object_num = len(valid_contours)

if show:
cv2.drawContours(img, valid_contours, -1, (0, 255, 0))
cv2.imshow('Objects', img)
# 创建空白画布画轮廓
contour_img = np.zeros_like(img)
cv2.drawContours(contour_img, contours, -1, (0, 255, 0))
cv2.imshow('Contours', contour_img)

cv2.waitKey(0)
cv2.destroyAllWindows()

return object_num, areas

if __name__ == '__main__':
object_num, areas = count_objects('imgs/coins.webp',True)
print(f"The num of coins: {object_num}")
print(f"The area of each coin: {areas} ")

形状匹配

cv2.matchShapes()可以检测两个形状之间的相似度,返回值越小,越相似

1
2
3
4
5
6
7
8
9
10
11
12
13
img = cv2.imread("imgs/match_shape.jpg",1)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
contours, hierarchy = cv2.findContours(thresh, 3, 2)
cnt_a, cnt_b, cnt_c = contours[0], contours[1], contours[2]
cv2.drawContours(img, [cnt_a], 0, (255, 0, 0), 2)
cv2.drawContours(img, [cnt_b], 0, (0, 255, 0), 2)
cv2.drawContours(img, [cnt_c], 0, (0, 0, 255), 2)
plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
plt.axis("off")
print(cv2.matchShapes(cnt_a, cnt_b, 1, 0.0)) # 0.4185301853277106
print(cv2.matchShapes(cnt_a, cnt_c, 1, 0.0)) # 0.41705272670009474
print(cv2.matchShapes(cnt_b, cnt_c, 1, 0.0)) # 0.0014774586276158352
image-20250927161329110

bc的输出最低,根据颜色,bc对应绿色和红色,符合预期

直方图

在图像处理中,直方图是一种非常重要的工具,它可以帮助我们了解图像的像素分布情况

三个主要概念:

  • BIN(区间): 如果统计0~255每个像素值,BIN=256;如果划分区间,比如0~15,16~31...,那么BIN=16,BIN在OpenCV文档中由histSize表示

  • DIMS(维度): 要计算的通道数,对于灰度图为1,普通彩色图为3

  • RANGE(范围): 要计算的像素值范围,一般为[0,256],即所有强度值

计算直方图

使用 cv2.calcHist() 函数来计算图像的直方图

1
cv2.calcHist(imgs, channels, mask, histSize, ranges)
  • imgs: 输入的图像列表,通常是一个包含单通道或多通道图像的列表,通常输入[img]

  • channels:需要计算直方图的通道索引,灰度图像为[0],彩色图像选择[0/1/2]|(BGR)

  • mask: 掩膜,指定掩膜后只计算掩膜内的像素,如果没有输入None

    可以用阈值分割后的二值图也可以自己创建,比如

    1
    2
    mask = np.zeros(img.shape[:2], np.uint8)
    mask[100:300, 100:300] = 255
  • histSize:直方图的BIN数量,灰度图像通常输入[256]

  • ranges: 像素值的范围,对于灰度图像,通常设置为[0, 256]

灰度:

1
2
3
img = cv2.imread("imgs/Lena.bmp", 0)
hist = cv2.calcHist([img],[0],None,[256],[0,256]) # 运行时间 0.0001872
plt.plot(hist)
202509291122

彩色:

1
2
3
4
5
img = cv2.imread("imgs/LenaRGB.bmp", 1)
colors = ('b', 'g', 'r')
for i, colors in enumerate(colors): # 遍历序列返回索引和值
hist = cv2.calcHist([img],[i],None,[256],[0,256])
plt.plot(hist, color = colors)
20250929

Numpy还提供了一个函数np.histogram(),在这里还要将将多维数组展平(np.ravel())

1
hist,bins = np.histogram(img.ravel(),256,[0,256])  # 运行时间0.0025162

还有一种针对灰度图的更高效方式:

1
hist = np.bincount(img.ravel(), minlength=256)  # 运行时间 0.0007413

但其实还是cv2的性能高

绘制直方图

刚刚直接用的plot将数据以曲线的形式绘制出来,但这并不是常见直方图的模样

Matplotlib 带有一个直方图绘图函数:matplotlib.pyplot.hist()

可以直接输入图像,不需要先cv2.calcHist()

1
2
img = cv2.imread("imgs/Lena.bmp", 0)
plt.hist(img.ravel(), 256, [0, 256])
202509291123

但对于彩色图样,选择普通绘图反而是更好的选择,因为这样可以很容易看出不同颜色的成分

直方图均衡化

直方图均衡化是一种增强图像对比度的方法,通过重新分配像素强度值,使直方图更加均匀,改善图像的全局亮度和对比度

1
eq_img = cv2.equalizeHist(img)
1
2
3
4
5
6
7
8
9
10
11
12
img = cv2.imread("imgs/Lena.bmp", 0)
eq_img = cv2.equalizeHist(img)
plt.subplot(221)
plt.imshow(img,'gray')
plt.subplot(222)
plt.imshow(eq_img,'gray')
plt.subplot(223)
plt.hist(img.ravel(), 256, [0, 256])
plt.subplot(224)
plt.hist(eq_img.ravel(), 256, [0, 256])
plt.tight_layout()
plt.show()
202509291145

自适应均衡化

直方图均衡化是应用于整幅图片的,但是这可能导致局部细节丢失,自适应均衡化就是用来解决这一问题的,它在每一个小区域内(默认 8×8)进行直方图均衡化

当然,如果有噪声的话也会被放大,所以需要对对比度进行限制,所以这个算法全称叫对比度受限的自适应直方图均衡化CLAHE

1
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
202509291505~1

可以看到不会有过曝区域

直方图比较

cv2.compareHist() 函数,用于比较两个直方图的相似度

1
similarity = cv2.compareHist(hist1, hist2, method)
  • method: 比较方法

比较方法主要有四种:

方法 代码标识 范围 相似性判断 衡量目标
相关性 cv2.HISTCMP_CORREL|0 [-1,1] 越大越相似 直方图形状相似性
卡方 cv2.HISTCMP_CHISQR|1 [0,+∞] 越小越相似 概率分布差异
相交 cv2.HISTCMP_INTERSECT|2 [0,sum] 越大越相似 重叠部分
巴氏距离 cv2.HISTCMP_BHATTACHARYYA|3 [0,1] 越小越相似 概率分布相似性

举例,直方图A = [1, 2, 3],直方图 B = [3, 2, 1]

Correlation: -0.999999999999998 (负相关)
Chi-Square: 0.8888887630568671
Intersection: 0.6666666865348816
Bhattacharyya: 0.298858482412642

方法 常见场景
相关性 检测线性相关性(亮度/对比度变化),找风格相似的图片
卡方 目标识别时发现差异
相交 直方图快速匹配
巴氏距离 目标跟踪,适合高精度相似性度量

在进行比较之前,一般都要进行归一化

使用cv2.normalize()

1
cv2.normalize(src, dst=None, alpha=1, beta=0, norm_type)
  • src:输入数组,可以是图像/直方图

  • dst:输出数组,如果写入None则覆盖输入

  • alpha:L1,L2归一化需设为1,beta为无作用冗余参数

    beta:最大最小归一化时使用,归一化数值最大值

  • norm_type:归一化方式

归一化常见方式:

函数 目的 常见用途
cv2.NORM_MINMAX 把数据线性拉伸到[alpha, beta]区间 图像对比度拉伸
cv2.NORM_L1|2 所有值除以绝对值和 直方图归一化为概率分布
cv2.NORM_L2(默认)|4 所有值除以平方和的平方根 把向量长度归一到 1

匹配比较方法和归一化方式

方法 归一化方式 原因
相关性 无需 相关性看趋势,线性缩放不影响结果
卡方 L1 通常用于比较概率分布,需要归一化到概率分布
交集 不归一化/L1 不归一化为绝对重叠数量;
归一化后为“重叠比例”含义
巴氏距离 L1 定义基于概率分布,需要归一化到概率分布

对于直方图均衡化后的图片,这四个方法其实都不太能看出是一张图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
img = cv2.imread("imgs/Lena.bmp", 0)
eq_img = cv2.equalizeHist(img)
hist_img = cv2.calcHist([img], [0], None, [256], [0, 256])
cv2.normalize(hist_img, hist_img, alpha=1, beta=0, norm_type=cv2.NORM_L1)
hist_eq = cv2.calcHist([eq_img], [0], None, [256], [0, 256])
cv2.normalize(hist_eq, hist_eq, alpha=1, beta=0, norm_type=cv2.NORM_L1)
# 四种相似性计算
methods = {
"Correlation": cv2.HISTCMP_CORREL,
"Chi-Square": cv2.HISTCMP_CHISQR,
"Intersection": cv2.HISTCMP_INTERSECT,
"Bhattacharyya": cv2.HISTCMP_BHATTACHARYYA
}
for name, method in methods.items():
score = cv2.compareHist(hist_img, hist_eq, method)
print(f"{name}: {score}")
# Correlation: 0.007809369904676664 均衡化后拉伸平铺,相关性基本消失
# Chi-Square: 40.33161269091677 不同BIN的差异很大
# Intersection: 0.5334510803222656 还有大约一半的直方图“重叠部分”
# Bhattacharyya: 0.5778464606235761 两个分布整体重叠度不高

模板匹配

模板匹配是一种在较大的图像中搜索和查找模板图像位置的方法

cv2.matchTemplate()实现模板匹配,返回的是一副灰度图,最白的地方表示最大的匹配

1
cv2.matchTemplate(img, templ, method)

method:匹配方法,有几种不同的计算方式

  • cv2.TM_CCOEFF / cv2.TM_CCOEFF_NORMED (相关系数,常用,越大越像)

  • cv2.TM_CCORR / cv2.TM_CCORR_NORMED (相关匹配,越大越像,效果不好,用的少)

  • cv2.TM_SQDIFF / cv2.TM_SQDIFF_NORMED (平方差,数值越小越像)

使用cv2.minMaxLoc()函数可以得到匹配值极值的坐标,以这个点为左上角角点,模板的宽和高画矩形就是匹配的位置了

1
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)

如果用的是平方差类方法TM_SQDIFF,数值越小越好,所以取min_loc,其他方法数值越大越好,所以取 max_loc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
img = cv2.imread('imgs/Lena.bmp', 0)
template = cv2.imread('imgs/face.bmp', 0)
h, w = template.shape[:2] # rows->h, cols->w
# 相关系数匹配方法:cv2.TM_CCOEFF
res = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
left_top = max_loc # 左上角
right_bottom = (left_top[0] + w, left_top[1] + h) # 右下角
cv2.rectangle(img, left_top, right_bottom, 255, 2) # 画出矩形位置
plt.subplot(221)
plt.imshow(template, 'gray')
plt.subplot(222)
plt.imshow(res, 'gray')
plt.subplot(223)
plt.imshow(img, 'gray')
plt.tight_layout()
plt.show()
202509291525

多物体匹配

在这个例子中,将使用著名游戏马里奥的截图,并在其中找到金币

1
2
3
4
5
6
7
8
9
10
11
12
img = cv2.imread("imgs/mario.jpg",1)
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
template = cv2.imread("imgs/mario_coin.jpg", 0)
w, h = template.shape[::-1]
res = cv2.matchTemplate(img_gray, template, cv2.TM_CCOEFF_NORMED)
threshold = 0.8
loc= np.where(res>=threshold)
for pt in zip(*loc[::-1]):
cv2.rectangle(img, pt, (pt[0]+w,pt[1]+h), (0,0,255), 1)
plt.imshow(cv2.cvtColor(img,cv2.COLOR_BGR2RGB))
plt.axis('off')
plt.show()
202509291538

图像拼接

图像拼接的基本流程可以分为以下几个步骤:

  1. 图像读取:读取需要拼接的图像
  2. 特征点检测:在每张图像中检测出关键点(特征点)
  3. 特征点匹配:在不同图像之间匹配这些特征点
  4. 计算变换矩阵:根据匹配的特征点计算图像之间的变换矩阵
  5. 图像融合:将图像按照变换矩阵进行拼接,并进行融合处理以消除拼接痕迹

特征点检测是图像拼接的关键步骤,OpenCV 提供了多种特征点检测算法,如SIFT、SURF、ORB 等,其中SIFT和SURF是浮点描述子,ORB是二进制描述子

SIFT长期被认为鲁棒性最好

1
2
3
4
# 创造SIFT检测器
sift = cv2.SIFT_create()
kps1, des1 = sift.detectAndCompute(img1, None)
kps2, des2 = sift.detectAndCompute(img2, None)

detectAndCompute() 函数会返回两个值:关键点(keypoints)和描述符(descriptors),关键点是图像中的显著点,描述符是对这些关键点的描述,用于后续的匹配

OpenCV 提供了 BFMatcherFlannBasedMatcher 来进行特征点匹配

匹配方法 描述 SIFT/SURF ORB/BRIEF
BFMatcher 暴力匹配,逐个对比计算距离 欧式距离(L2范数) 汉明距离(二进制取与)
FlannBasedMatcher 近似快速匹配,使用ANN(近似最相邻)搜索算法 KD-Tree(K维树) LSH(局部敏感哈希)

BFMatcher简单直接,但计算量大,速度慢;

FlannBasedMatcher匹配速度更快,更适合大规模特征点匹配,虽然结果近似最近邻,但是在实际应用中几乎不影响效果

1
2
3
4
5
6
7
8
# --- BFMatcher ---
bf = cv2.BFMatcher() # L2范数
matches_bf = bf.knnMatch(des1, des2, k=2)
# --- FLANN ---
index_params = dict(algorithm=1, trees=5) # KDTree
search_params = dict(checks=50)
flann = cv2.FlannBasedMatcher(index_params, search_params)
matches_flann = flann.knnMatch(des1, des2, k=2)

FLANN的创建需要输入参数

  • SIFT/SURF → algorithm=1, trees=5 (KDTree)

  • ORB → algorithm=6, table_number=6, key_size=12, multi_probe_level=1 (LSH)

  • checks=50 → 表示搜索时在多少个叶节点里查找候选(常用 32~128 之间)

knnMatch() 函数会返回每个特征点的两个最佳匹配,通过比率测试(Lowe’s ratio test)来筛选出好的匹配点

1
2
3
4
5
6
good_matches = []
for m, n in matches_flann:
if m.distance < 0.75 * n.distance:
good_matches.append(m)
if len(good_matches) < 10:
raise ValueError("匹配点太少,无法拼接")

在得到好的匹配点后,可以使用这些点来计算图像之间的变换矩阵

1
2
3
4
5
# 提取匹配点的坐标
src_pts = np.float32([kps1[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
dst_pts = np.float32([kps2[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
# 计算单应性矩阵
H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)

最后使用计算出的单应性矩阵将图像进行拼接,并进行融合处理以消除拼接痕迹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 透视变换 + 融合
h1, w1 = img1.shape[:2]
h2, w2 = img2.shape[:2]
# 计算输出画布
pts_img1 = np.float32([[0,0], [0,h1], [w1,h1], [w1,0]]).reshape(-1,1,2)
# OpenCV要求数组是N×1×2的格式
# 用单应矩阵 H 把角点 投影到 img2 的坐标系里
pts_img1_trans = cv2.perspectiveTransform(pts_img1, H)
# 把变换后的 img1 的四个角点和原始 img2 的四个角点拼在一起
pts_all = np.concatenate((pts_img1_trans, np.float32([[0,0],[0,h2],[w2,h2],[w2,0]]).reshape(-1,1,2)), axis=0)
[xmin, ymin] = np.int32(pts_all.min(axis=0).ravel() - 0.5)
[xmax, ymax] = np.int32(pts_all.max(axis=0).ravel() + 0.5)
# 平移变换,确保坐标正值
t = [-xmin, -ymin] # 定义平移向量,因为xmin和ymin可能是负数
H_trans = np.array([[1,0,t[0]], [0,1,t[1]], [0,0,1]]) # 把所有点整体平移(tx, ty)
# warp
result = cv2.warpPerspective(img1, H_trans.dot(H), (xmax-xmin, ymax-ymin))
result[t[1]:h2+t[1], t[0]:w2+t[0]] = img2 # 把 img2 直接贴到大画布的正确位置
# 因为整个画布已经被平移过,所以 img2 也要偏移

拼接效果可能不是特别好

简单滤镜

主要滤镜效果:

滤镜效果 实现方法
灰度滤镜 cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
模糊滤镜 cv2.GaussianBlur(image, (15, 15), 0)
怀旧滤镜 通过调整色彩通道的权重,模拟老照片效果
浮雕滤镜 使用卷积核 [[-2, -1, 0], [-1, 1, 1], [0, 1, 2]] 进行卷积操作
锐化滤镜 使用卷积核 [[0, -1, 0], [-1, 5, -1], [0, -1, 0]] 进行卷积操作
边缘检测滤镜 cv2.Canny(gray_image, 100, 200)

怀旧滤镜

1
2
3
4
5
6
7
8
9
10
img = cv2.imread("imgs/LenaRGB.bmp")
# 分离 BGR 通道
b,g,r = cv2.split(img)
# 调整通道强度
r = np.clip(r*0.393+g*0.769+b*0.189,0,255).astype(np.uint8)
g = np.clip(r*0.349+g*0.686+b*0.168,0,255).astype(np.uint8)
b = np.clip(r*0.272+g*0.534+b*0.131,0,255).astype(np.uint8)
# 合并通道
img = cv2.merge([b,g,r])
plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
202510041051

浮雕滤镜

浮雕滤镜通过计算图像中相邻像素的差值,生成一种类似于浮雕的效果,这种滤镜通常用于增强图像的边缘和纹理

1
2
3
4
5
6
7
8
9
10
11
img = cv2.imread("imgs/LenaRGB.bmp")
# 转换为灰度图像
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 定义卷积核
kernel = np.array([[-2, -1, 0],
[-1, 1, 1],
[0, 1, 2]])
# 应用卷积核
emboss_img = cv2.filter2D(gray, -1, kernel)
plt.imshow(cv2.cvtColor(emboss_img, cv2.COLOR_BGR2RGB))
plt.axis("off")
202510041056

霍夫变换

霍夫变换常用来在图像中提取直线和圆等几何形状

霍夫直线变换

直线的参数方程:$y=kx+b$

但是在 $k\rightarrow \infty$ 时将无效,霍夫变换用极坐标的形式
$$
\rho = x\cos \theta +y\sin \theta
$$
任何一条直线都能用一对$(\rho ,\theta)$唯一表示

cv2.HoughLines()在二值图上实现霍夫变换,函数返回的是一组直线的$(\rho ,\theta)$数据

1
lines = cv2.HoughLines(edges, 1, np.pi / 180, threshold)
  • 参数1:一般是边缘检测后的二值图
  • 参数2:距离$\rho$的精度,值越大,考虑越多的线,一般使用1像素
  • 参数3:角度$\theta $的精度,值越小,考虑越多的线,一般使用1度
  • 参数4:累加数阈值,值越小,考虑越多的线

标准霍夫变换会检测到整条无穷延伸的直线,而实际中更想要线段

OpenCV 提供 cv2.HoughLinesP(统计概率霍夫直线变换),这是一种改进算法,输出线段的起止点

1
linesP = cv2.HoughLinesP(edges, 1, np.pi/180, threshold, minLineLength=50, maxLineGap=10)

前面几个参数跟之前的一样,有两个可选参数,最短长度阈值以及同一直线两点间的最大距离

读取 → 转灰度 → Canny检测 → 霍夫变换

1
2
3
4
5
6
7
8
9
10
11
12
13
import cv2
import matplotlib.pyplot as plt
import numpy as np
img = cv2.imread("imgs/hough_test.jpg")
# 绘制黑背景用于显示
drawing = np.zeros(img.shape[:], dtype=np.uint8)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)
linesP = cv2.HoughLinesP(edges, 1, np.pi/180, 80, minLineLength=50, maxLineGap=10)
# 遍历,linesP.shape = (N, 1, 4)
for x1, y1, x2, y2 in linesP[:,0]: # 选出所有线段的第一个数组,压缩为(N,4)
cv2.line(drawing, (x1,y1), (x2,y2), (0,255,0), 2)
plt.imshow(drawing)
202510041328

霍夫圆变换

同理,霍夫变换也可以用于检测圆
$$
(x-a)^2+(y-b)^2 = r^2
$$
参数空间变成$(a, b, r)$

1
2
3
4
5
6
7
8
9
10
cv2.HoughCircles(
image, # 输入图像(必须是灰度图)
method, # 检测方法(通常用 cv2.HOUGH_GRADIENT)
dp, # 累加器分辨率的反比,1为等尺寸,2为图像的一半
minDist, # 检测到的圆之间的最小距离,通常为图像高度的若干份之一
param1=100, # Canny 边缘检测的高阈值,低阈值自动为其一半
param2=30, # 圆心累加器阈值(越小越容易检测到假圆)
minRadius=0, # 圆的最小半径,0自动搜索,已知物体建议精确设定
maxRadius=0 # 圆的最大半径,同理
)
1
2
3
4
5
6
7
8
9
10
11
img = cv2.imread("imgs/hough_test.jpg")
# 绘制黑背景用于显示
drawing = np.zeros(img.shape[:], dtype=np.uint8)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
h,w = gray.shape # 图像尺寸
circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp = 1, minDist= h/8, param2=30)
circles = np.uint16(np.around(circles)) # 检测到的圆心坐标和半径从浮点数转换成无符号整数
for (x, y, r) in circles[0, :]:
cv2.circle(drawing, (x, y), r, (0, 255, 0), 2)
plt.imshow(drawing)
plt.axis("off")
202510041338

视频处理

视频是由一系列连续的图像帧组成的,每一帧都是一幅静态图像,核心就是对这些图像帧进行处理

视频读取

要读取视频文件,首先需要创建一个 cv2.VideoCapture 对象,并指定视频文件的路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cap = cv2.VideoCapture("imgs/camera_vedio.mp4")
# 检查视频是否成功打开
if not cap.isOpened():
print("Error: Could not open video.")
exit()
while True:
ret, frame = cap.read()
# ret: 标识符,表示是否成功读取
# frame: 当前帧图像
if not ret:
break
cv2.imshow('Video', frame)
if cv2.waitKey(25) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()

除了读取视频文件,OpenCV 还可以直接从摄像头读取视频,只需要将 cv2.VideoCapture 的参数设置为摄像头的索引(通常为0)即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cap = cv2.VideoCapture(0)
# 检查摄像头是否成功打开
if not cap.isOpened():
print("Error: Could not open camera.")
exit()
# 读取视频帧
while True:
ret, frame = cap.read()
# 如果读取到最后一帧,退出循环
if not ret:
break
# 显示当前帧
cv2.imshow('Camera', frame)
# 按下esc 退出
if cv2.waitKey(25) & 0xFF == 27:
break
cap.release()
cv2.destroyAllWindows()

电脑没有摄像头的话可以参考这篇内容:虚拟摄像头构建

之后就可以对视频画面进行一些实时操作了

视频帧处理

在读取视频帧后,可以对每一帧进行各种图像处理操作,并进行保存

在读取后需要利用.get()获取视频的属性(如宽度、高度、帧率等),方便创建保存对象

1
2
3
fps = int(cap.get(cv2.CAP_PROP_FPS)) # 帧率
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) # 宽度
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # 高度

视频如果分辨率和帧率过高输出可能会出现掉帧,根据性能量力而行

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
import cv2
input_path = "imgs/tree.mp4"
output_path = "tree.avi"

cap = cv2.VideoCapture(input_path)
if not cap.isOpened():
print("Error: Could not open camera.")
exit()
# 获取视频的帧率和尺寸
fps = int(cap.get(cv2.CAP_PROP_FPS))
# width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
# height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# 创建 VideoWriter 对象,保存处理后的视频
fourcc = cv2.VideoWriter_fourcc(*'XVID') # 常见编码: "XVID", "MJPG", "mp4v"
out = cv2.VideoWriter(output_path, fourcc, fps, (1080, 720))
# out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))

while True:
ret, frame = cap.read()
if not ret:
break
frame = cv2.resize(frame, (1080,720)) # 改尺寸了要加上
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5,5), 0)
edges = cv2.Canny(blur, 50, 150)
edges = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
out.write(edges)
# cv2.imshow('Video', edges)
# if cv2.waitKey(25) & 0xFF == 27:
# break
cap.release()
out.release()
cv2.destroyAllWindows()
print("视频处理完毕")

摄像头实时处理读出:

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
import cv2
output_path = "res/camera_output.mp4"
cap = cv2.VideoCapture(0)
if not cap.isOpened():
print("Error: Could not open camera.")
exit()
# 获取摄像头参数
fps = 30.0 # 摄像头一般不一定能准确取到帧率,可以手动设定
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# 创建 VideoWriter
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
print("按 ESC 退出录制...")
while True:
ret, frame = cap.read()
if not ret:
break
# 图像处理:灰度化 + 边缘检测
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)
edges = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
# 写入文件
out.write(edges)
cv2.imshow("Camera Processed", edges)
if cv2.waitKey(1) & 0xFF == 27:
break
cap.release()
out.release()
cv2.destroyAllWindows()
print("录制结束,视频保存为:", output_path)

物体检测

OpenCV提供了多种物体检测算法,如 Haar 特征分类器、HOG + SVM 等

Haar特征分类器

Haar 特征分类器是一种基于 Haar-like 特征的机器学习方法,用于检测图像中的目标

OpenCV 提供了预训练的 Haar 特征分类器,cv2.CascadeClassifier用于加载分类器,参数是分类器文件的路径

模型文件 检测目标 描述
haarcascade_frontalface_default.xml 正面人脸 最常用
haarcascade_profileface.xml 侧面人脸
haarcascade_eye.xml 眼睛检测 需要配合人脸使用
haarcascade_smile.xml 微笑检测

进行人脸检测:

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
import cv2
# 加载 Haar 特征分类器
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
cap = cv2.VideoCapture(0)
while True:
ret, frame = cap.read()
if not ret:
break
# 将帧转换为灰度图像
gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 检测人脸
faces = face_cascade.detectMultiScale(
gray_frame,
scaleFactor=1.1,
minNeighbors=5,
minSize=(30, 30))
# 在帧上绘制矩形框标记人脸
for (x, y, w, h) in faces:
cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
# 显示带有人脸标记的帧
cv2.imshow('Face Detection', frame)
if cv2.waitKey(25) & 0xFF == 27:
break
cap.release()
cv2.destroyAllWindows()

detectMultiScale会返回所有检测到的矩形框 (x, y, w, h)

  • scaleFactor:表示图像尺寸的缩小比例,>1缩小图像,常用1.1

    计算方法:缩小比例 = 1 - (1/scaleFactor)

    scaleFactor=1.1 时,每次缩放后的新尺寸 = 原尺寸×(1/1.1) ≈ 原尺寸×0.909

  • minNeighbors:表示在当前强度中心周围有多少个目标同时检测到才算有效,值越高越严格

  • minSize:最小检测窗口,表示目标的最小尺寸

Haar属于传统CV算法,速度快,适合实时,但检测精度不如深度学习模型,对光照、角度变化不鲁棒

YOLOv5

相比Haar,Yolov5可同时检测80+类别物体,输出带类别标签的边界框,对部分遮挡、光照变化、背景杂乱有较强鲁棒性,训练后的代码简洁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import cv2
import torch
import warnings
# torch 更新导致会出一些warming,眼不见为净
warnings.filterwarnings("ignore", category=FutureWarning, message=".*torch.cuda.amp.autocast.*")
# 加载YOLOv5模型(采用官方训练权重)
model = torch.hub.load('ultralytics/yolov5', 'yolov5s')
cap = cv2.VideoCapture(0)
while True:
ret, frame = cap.read()
if not ret:
break
results = model(frame)
# 渲染结果(模型自带的画框函数)
annotated_frame = results.render()[0]
cv2.imshow("YOLOv5 Real Time Detection", annotated_frame)
if cv2.waitKey(30) & 0xFF == 27:
break
cap.release()
cv2.destroyAllWindows()

运动检测

帧差法

通过计算帧之间的差异来检测运动物体,最直观

特点:简单,适合物体比较大、背景稳定的场景

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
import cv2
cap = cv2.VideoCapture(0)
# 读取第一帧
ret, prev_frame = cap.read()
prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
prev_gray = cv2.GaussianBlur(prev_gray, (5, 5), 0)

while cap.isOpened():
ret, frame = cap.read()
if not ret:
break

gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
# 帧差
diff = cv2.absdiff(prev_gray, gray)
# 对差异图像进行二值化处理
_, thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
# 膨胀操作,去除噪声
dilated = cv2.dilate(thresh, None, iterations=2)
# 找轮廓
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 遍历轮廓进行挑选
for cnt in contours:
if cv2.contourArea(cnt) < 500: # 忽略太小的移动
continue
x, y, w, h = cv2.boundingRect(cnt)
cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2)
cv2.imshow("motion detection", frame)
prev_gray = gray # 更新前一帧

if cv2.waitKey(1) & 0xFF == 27: # 按 ESC 退出
break
cap.release()
cv2.destroyAllWindows()

背景减除法

背景减除法是一种更为精准和鲁棒的运动检测技术,通过学习视频的静态背景,然后将当前帧与背景模型进行比较,从而识别出前景(即运动的物体),它比帧差法稳定,对光照变化更鲁棒

其基本流程如下:

  1. 背景建模:通过分析视频序列中的多帧图像,建立一个背景模型
  2. 前景检测:将当前帧与背景模型进行比较,找出与背景差异较大的区域,这些区域即为前景对象。
  3. 背景更新:随着时间的推移,背景可能会发生变化(如光照变化、背景物体的移动等),因此需要不断更新背景模型

OpenCV 提供了多种背景减除算法,其中MOG和MOG2是最常用的两种方法

MOG2是MOG的改进版本,主要区别在于它能够自动选择高斯分布的数量,并且能够更好地适应背景的变化

1
fgbg = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=16, detectShadows=True)
  • history:训练的帧数(默认500),值大表示模型记忆更久,适合稳定场景;值小更灵敏,适合背景经常变化的环境

  • varThreshold:像素和背景模型的阈值(默认 16)

    值小 → 更容易检测出前景,但噪声也多;值大 → 只检测明显运动的物体

  • detectShadows:是否检测阴影,默认True,如果只想要前景物体,可以关掉它

1
fgmask = fgbg.apply(frame)

输入一帧图像,输出前景掩码(mask),掩码是单通道图像:背景0,前景255,阴影127

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
import cv2
import numpy as np

cap = cv2.VideoCapture(0)
# 创建背景减除器
fgbg = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=50, detectShadows=False) # 这里不要阴影
while True:
ret, frame = cap.read()
if not ret:
break
# 应用背景减除,获得前景掩码
fgmask = fgbg.apply(frame)
# 对掩码进行形态学操作,以消除噪声
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
fgmask = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel)
# 膨胀操作
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
fgmask = cv2.dilate(fgmask, kernel)
# 查找轮廓
contours, hierarchy = cv2.findContours(fgmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 遍历所有轮廓
for contour in contours:
# 忽略面积过小的轮廓,以减少误报
if cv2.contourArea(contour) < 500:
continue
# 获取轮廓的边界框
(x, y, w, h) = cv2.boundingRect(contour)
# 在原始帧上绘制边界框
cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2)
# 显示结果
cv2.imshow('Original Frame', frame)
cv2.imshow('Foreground Mask', fgmask)
if cv2.waitKey(1) & 0xff == 27: # 按esc退出
break
cap.release()
cv2.destroyAllWindows()

卷积核大小不同是为了先用最小的代价去除噪声,再用足够的强度来恢复和增强目标

会发现在检测时一个人可能出现多个框,是因为这个算法并不会把人当作一个整体来看待

相比YOLO这只是一种比较简单的低级检测,只会发现哪里在动,不关心这是不是一个整体

车道检测

无人车上的相机拍摄的视频中,车道线的位置应该基本固定在某一个范围内

手动把这部分 ROI 区域抠出来,就会排除掉大部分干扰

利用霍夫变换检测直线,但 ROI 区域内的边缘直线信息还是很多。考虑到只有左右两条车道线,一条斜率为正,一条为负,可将所有的线分为两组,每组再通过均值或最小二乘法拟合的方式确定唯一一条线就可以完成检测

总体步骤如下:

  1. 读取视频帧(逐帧处理)
  2. 灰度化 + 高斯滤波(降噪)
  3. Canny边缘检测(提取边缘)
  4. 定义ROI(只保留车道区域)
  5. 霍夫直线变换(检测车道线)
  6. 线段拟合与绘制(平滑显示结果)

图像预处理

1
2
3
4
5
6
7
8
9
10
11
# 定义参数
blur_ksize = 5
canny_low = 50
canny_high = 150

def process_img(img):
# 灰度化
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (blur_ksize,blur_ksize), 0)
edges = cv2.Canny(blur, canny_low, canny_high)
return edges
202510041350

ROI截取

创建一个梯形的 mask 掩膜,然后与边缘检测结果图混合运算

掩膜中白色的部分保留,黑色的部分舍弃

1
2
3
4
5
6
7
8
9
10
11
def roi_mask(img, vertices):
# 创建掩膜
mask = np.zeros_like(img)
cv2.fillPoly(mask, vertices, 255)
masked_image = cv2.bitwise_and(img, mask)
return masked_image

# 定义ROI
h, w = edges.shape[:2]
roi_vertices = np.array([[(0,h),(460, 325), (520, 325),(w,h)]])
roi = roi_mask(edges, roi_vertices)
202510041408

霍夫直线提取

使用统计概率霍夫直线变换,因为后续还需要处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def draw_lines(img, lines, color=[255, 0, 0], thickness=1):
if lines is None:
return
for x1, y1, x2, y2 in lines[:,0]:
cv2.line(img, (x1, y1), (x2, y2), color, thickness)
# 霍夫变换参数
rho = 1
theta = np.pi / 180
threshold = 15
min_line_len = 40
max_line_gap = 20
lines = cv2.HoughLinesP(roi, rho, theta, threshold, minLineLength=min_line_len, maxLineGap=max_line_gap)
drawing = np.zeros(img.shape[:], dtype=np.uint8)
draw_lines(drawing, lines)

车道计算

前面通过霍夫变换得到了多条直线的起点和终点

目的是通过某种算法只得到左右两条车道线

  1. 根据斜率正负划分某条线是左车道还是右车道
    $$
    k = \frac{y_2-y_1}{x_2-x_1}
    $$
    左车道斜率小于0,右车道斜率大于0

  2. 迭代计算各直线斜率与斜率均值的差,排除掉差值过大的异常数据

  3. 最小二乘法拟合左右车道线

    Python 中可以直接使用np.polyfit()进行最小二乘法拟合

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
def clean_lines(lines, threshold):
# 迭代计算斜率均值,排除掉与差值差异较大的数据
slopes = []
for line in lines:
# 规范为一维 4 元素数组
arr = np.array(line).reshape(4)
x1, y1, x2, y2 = arr
if x2 == x1:
# 垂直线略过(避免除零)
continue
slope = (y2 - y1) / (x2 - x1)
slopes.append(slope)
slopes = np.array(slopes)
mean_slope = np.mean(slopes)
mask = np.abs(slopes - mean_slope) < threshold
# 返回剔除异常值的lines
return [line for line, sign in zip(lines, mask) if sign]

def least_squares_fit(point_list, ymin, ymax):
if not point_list or len(point_list) < 2: # 没点或点数太少
return None
# 最小二乘法拟合
x = [p[0] for p in point_list]
y = [p[1] for p in point_list]

# polyfit 第三个参数为拟合多项式的阶数,所以 1 代表线性
fit = np.polyfit(y, x, 1)
fit_fn = np.poly1d(fit) # 获取拟合的结果
xmin = int(fit_fn(ymin))
xmax = int(fit_fn(ymax))
return [(xmin, ymin), (xmax, ymax)]

def draw_lanes(img, lines, color=[0, 255, 0], thickness=8):
h = img.shape[0]
# 划分左右车道
left_lines, right_lines = [], []
for line in lines:
for x1, y1, x2, y2 in line:
if x2 == x1: # 避免垂直线
continue
k = (y2 - y1) / (x2 - x1)
if k < 0:
left_lines.append(line)
else:
right_lines.append(line)

if not left_lines or not right_lines:
return

# 清理异常数据
left_lines = clean_lines(left_lines, 0.1)
right_lines = clean_lines(right_lines, 0.1)

# 得到左右车道线点的集合,拟合直线
left_points = []
for l in left_lines:
x1, y1, x2, y2 = l.reshape(4) # 不会出现元组报错
left_points.append((x1, y1))
left_points.append((x2, y2))

right_points = []
for l in right_lines:
x1, y1, x2, y2 = l.reshape(4)
right_points.append((x1, y1))
right_points.append((x2, y2))
# 这里写325是因为之前限制325
left_results = least_squares_fit(left_points, 325, h)
right_results = least_squares_fit(right_points, 325, h)
if left_results is None or right_results is None:
return # 没法画,直接跳过
# 注意这里点的顺序(左上 → 左下 → 右下 → 右上)
vtxs = np.array([[left_results[0], left_results[1], right_results[1], right_results[0]]])
# 填充车道区域
cv2.fillPoly(img, vtxs, color)
# 或者只画车道线
# cv2.line(img, left_results[0], left_results[1], color, thickness)
# cv2.line(img, right_results[0], right_results[1], color, thickness)

视频处理

搞定图以后就是视频帧的提取和合成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 处理视频
cap = cv2.VideoCapture("Lane_Detection/cv2_yellow_lane.mp4")
# 视频参数
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
out = cv2.VideoWriter("Lane_Detection/output.mp4", fourcc, fps, (w, h))
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
# 处理帧
result = process_img(frame)
# 播放
cv2.imshow("Lane Detection", result)
out.write(result)
# 按 esc 退出
if cv2.waitKey(30) & 0xFF == 27:
break
cap.release()
out.release()
cv2.destroyAllWindows()

也可以利用Python 的视频编辑包moviepy

1
2
3
4
output = 'Lane_Detection/output.mp4'
clip = VideoFileClip("Lane_Detection/cv2_yellow_lane.mp4")
out_clip = clip.fl_image(process_img)
out_clip.write_videofile(output, audio=False)

全代码

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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import cv2
import numpy as np
from moviepy.editor import VideoFileClip

# 定义参数
blur_ksize = 5
canny_low = 50
canny_high = 150
# 霍夫变换参数
rho = 1
theta = np.pi / 180
threshold = 15
min_line_len = 40
max_line_gap = 20

def roi_mask(img, vertices):
# 创建掩膜
mask = np.zeros_like(img)
cv2.fillPoly(mask, vertices, 255)
masked_image = cv2.bitwise_and(img, mask)
return masked_image

def draw_lines(img, lines, color=[255, 0, 0], thickness=1):
if lines is None:
return
for x1, y1, x2, y2 in lines[:,0]:
cv2.line(img, (x1, y1), (x2, y2), color, thickness)

def clean_lines(lines, threshold):
# 迭代计算斜率均值,排除掉与差值差异较大的数据
slopes = []
for line in lines:
# 规范为一维 4 元素数组
arr = np.array(line).reshape(4)
x1, y1, x2, y2 = arr
if x2 == x1:
# 垂直线略过(避免除零)
continue
slope = (y2 - y1) / (x2 - x1)
slopes.append(slope)
slopes = np.array(slopes)
mean_slope = np.mean(slopes)
mask = np.abs(slopes - mean_slope) < threshold
# 返回剔除异常值的lines
return [line for line, sign in zip(lines, mask) if sign]

def least_squares_fit(point_list, ymin, ymax):
if not point_list or len(point_list) < 2: # 没点或点数太少
return None
# 最小二乘法拟合
x = [p[0] for p in point_list]
y = [p[1] for p in point_list]

# polyfit 第三个参数为拟合多项式的阶数,所以 1 代表线性
fit = np.polyfit(y, x, 1)
fit_fn = np.poly1d(fit) # 获取拟合的结果
xmin = int(fit_fn(ymin))
xmax = int(fit_fn(ymax))
return [(xmin, ymin), (xmax, ymax)]

def draw_lanes(img, lines, color=[0, 255, 0], thickness=8):
h = img.shape[0]
# 划分左右车道
left_lines, right_lines = [], []
for line in lines:
for x1, y1, x2, y2 in line:
if x2 == x1: # 避免垂直线
continue
k = (y2 - y1) / (x2 - x1)
if k < 0:
left_lines.append(line)
else:
right_lines.append(line)

if not left_lines or not right_lines:
return

# 清理异常数据
left_lines = clean_lines(left_lines, 0.1)
right_lines = clean_lines(right_lines, 0.1)

# 得到左右车道线点的集合,拟合直线
left_points = []
for l in left_lines:
x1, y1, x2, y2 = l.reshape(4) # 不会出现元组报错
left_points.append((x1, y1))
left_points.append((x2, y2))

right_points = []
for l in right_lines:
x1, y1, x2, y2 = l.reshape(4)
right_points.append((x1, y1))
right_points.append((x2, y2))

# 这里写325是因为之前限制325
left_results = least_squares_fit(left_points, 325, h)
right_results = least_squares_fit(right_points, 325, h)
if left_results is None or right_results is None:
return # 没法画,直接跳过
# 注意这里点的顺序(左上 → 左下 → 右下 → 右上)
vtxs = np.array([[left_results[0], left_results[1], right_results[1], right_results[0]]])
# 填充车道区域
cv2.fillPoly(img, vtxs, color)
# 或者只画车道线
# cv2.line(img, left_results[0], left_results[1], color, thickness)
# cv2.line(img, right_results[0], right_results[1], color, thickness)

def process_img(img):
h, w = img.shape[:2]
# 灰度化、滤波和Canny
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (blur_ksize,blur_ksize), 0)
edges = cv2.Canny(blur, canny_low, canny_high)
# 提取ROI
roi_vertices = np.array([[(0,h),(460, 325), (520, 325),(w,h)]])
roi = roi_mask(edges, roi_vertices)
# 霍夫直线提取
lines = cv2.HoughLinesP(roi, rho, theta, threshold, minLineLength=min_line_len, maxLineGap=max_line_gap)
# 车道拟合计算
drawing = np.zeros_like(img)
draw_lanes(drawing, lines)
# 最终将结果合在原图上
result = cv2.addWeighted(img, 0.9, drawing, 0.4, 0)
return result

if __name__ == "__main__":
# 处理图片
# img = cv2.imread("Lane_Detection/img1.jpg",1)
# res = process_img(img)
# cv2.imshow("Lane_Detection", res)
# cv2.waitKey(0)
# cv2.destroyAllWindows()

# 处理视频
cap = cv2.VideoCapture("Lane_Detection/cv2_yellow_lane.mp4")
# 视频参数
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
out = cv2.VideoWriter("Lane_Detection/output.mp4", fourcc, fps, (w, h))
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
# 处理帧
result = process_img(frame)
# 播放
cv2.imshow("Lane Detection", result)
out.write(result)
# 按 esc 退出
if cv2.waitKey(30) & 0xFF == 27:
break
cap.release()
out.release()
cv2.destroyAllWindows()
"""
output = 'Lane_Detection/output.mp4'
clip = VideoFileClip("Lane_Detection/cv2_yellow_lane.mp4")
out_clip = clip.fl_image(process_img)
out_clip.write_videofile(output, audio=False)
"""