OpenCV-Python实战(24)——打造实时图像滤镜系统

OpenCV-Python实战(23)------打造实时图像滤镜系统

    • [0. 前言](#0. 前言)
    • [1. 本节目标](#1. 本节目标)
    • [2. 规划应用](#2. 规划应用)
    • [3. 创建黑白铅笔画效果](#3. 创建黑白铅笔画效果)
      • [3.1 减淡与加深技术](#3.1 减淡与加深技术)
      • [3.2 使用二维卷积实现高斯模糊](#3.2 使用二维卷积实现高斯模糊)
      • [3.3 应用铅笔画滤镜](#3.3 应用铅笔画滤镜)
      • [3.4 使用高斯模糊进行优化](#3.4 使用高斯模糊进行优化)
    • [4. 冷暖色调滤镜](#4. 冷暖色调滤镜)
      • [4.1 使用曲线偏移进行颜色操控](#4.1 使用曲线偏移进行颜色操控)
      • [4.2 使用查找表实现曲线滤波器](#4.2 使用查找表实现曲线滤波器)
      • [4.3 设计冷暖色调滤镜](#4.3 设计冷暖色调滤镜)
    • [5. 图像卡通化](#5. 图像卡通化)
      • [5.1 使用双边滤波器进行边缘感知平滑处理](#5.1 使用双边滤波器进行边缘感知平滑处理)
      • [5.2 检测与突出明显边缘](#5.2 检测与突出明显边缘)
      • [5.3 组合颜色和轮廓生成卡通图像](#5.3 组合颜色和轮廓生成卡通图像)
    • [7. 整合所有内容](#7. 整合所有内容)
      • [7.1 运行应用程序](#7.1 运行应用程序)
      • [7.2 GUI 基类](#7.2 GUI 基类)
      • [7.3 设计自定义滤镜布局](#7.3 设计自定义滤镜布局)
    • 小结
    • 系列链接

0. 前言

本节的目标是开发多种图像处理滤波器,并将其实时应用于网络摄像头的视频流中。这些滤波器依赖各种 OpenCV 函数,通过图像分割、合并、算术运算以及应用查找表来实现复杂功能。

我们将涵盖以下三种特效,帮助我们熟悉 OpenCV,并在后续学习中以此为基础进行扩展:

  • 冷暖色调滤镜:我们将使用查找表实现自己的曲线滤镜
  • 黑白铅笔画效果:我们将用到两种图像混合技术,即"减淡"与"加深"
  • 卡通化效果:我们将结合双边滤波、中值滤波和自适应阈值处理

1. 本节目标

OpenCV 是高级工具链。我们通常遇到的问题并不是"如何从零实现某个功能",而是"根据需求,选择哪种已有的实现方案"。如果有充足的算力,生成复杂效果并不困难。挑战通常在于找到一种方法,既能完成任务,又能及时完成。

我们将采用实践方法,开发一个端到端的应用,集成多种图像滤波技术。运用理论知识,得出一个不仅有效,而且能够实时生成这些效果的解决方案。学习这些内容将帮助我们熟悉如何在 OpenCV 中加载图像,以及如何使用 OpenCV 对这些图像应用不同的变换。本节将帮助我们掌握 OpenCV 的基本操作方式,以便我们在后续学习中能够专注于算法的内部实现。

我们首先规划要在本节中创建的应用程序。

2. 规划应用

最终的应用包含以下模块和脚本:

  • wx_gui.py:该模块是我们使用 wxPython 实现的一个基础 GUI。本专栏中将广泛使用此文件。该模块包含以下布局:
    • wx_gui.BaseLayout 通用布局类,可用于构建更复杂的布局
  • filters.py:本节的主要脚本。它包含以下函数和类:
    • filters.FilterLayout:基于 wx_gui.BaseLayout 的自定义布局,用于显示摄像头画面以及一排单选按钮,用户可以通过这些按钮选择要应用于摄像头每一帧的图像滤镜
    • filters.main:用于启动 GUI 应用程序并访问摄像头的主例程函数
  • tools.pyPython 模块,包含本节中将使用的辅助函数

下一节将介绍如何创建黑白铅笔素描。

3. 创建黑白铅笔画效果

为了获得摄像头画面的铅笔画效果(即黑白素描图),我们将使用两种图像混合技术------减淡与加深。这两个术语源自传统摄影中的印相工艺:摄影师通过控制暗房照片中某一区域的曝光时间,来使其变亮或变暗。减淡使图像变亮,加深则使图像变暗。那些不希望发生变化的区域则用遮罩进行保护。

如今,PhotoshopGIMP 等现代图像编辑程序提供了在数字图像中模拟这些效果的方法。例如,遮罩仍然被用来模拟改变图像曝光时间的效果:遮罩中像素值较高的区域会让图像曝光更多,从而使图像变亮。OpenCV 虽然内置了铅笔画效果;但本节中,我们将通过使用一些技巧,实现自定义方法,用来生成精美的铅笔画效果。

我们通常采用以下步骤,从 RGB (红、绿、蓝) 彩色图像生成铅笔画效果:

  1. 首先,将彩色图像转换为灰度图
  2. 然后,将灰度图反转,得到负片效果
  3. 对第 2 步得到的负片图像应用高斯模糊。
  4. 使用颜色减淡 (color dodge) 将灰度图(来自第 1 步)与模糊后的负片图像(来自第 3 步)进行混合

1 到第 3 步比较直接,但第 4 步可能稍微有些棘手,因此我们首先来处理这一步。下一小节将展示如何在 OpenCV 中实现减淡与加深。

3.1 减淡与加深技术

减淡 (dodging) 用于减少我们希望在图像 A 中变得更亮(相对于原图)区域的曝光。在图像处理中,我们通常使用遮罩来选择或指定需要修改的图像区域。遮罩 B 是一个与图像尺寸相同的数组(可以把它想象成一张覆盖在图像上、带有孔洞的纸张)。纸张上的"孔洞"用 255 表示(如果是在 0-1 范围内则用 1 表示),而不透明的区域则用 0 表示。

Photoshop 等现代图像编辑工具中,使用遮罩 B 对图像 A 进行颜色减淡是通过以下三元运算实现的,该运算对每个像素(索引 i )进行操作:

shell 复制代码
((B[i] == 255) ? B[i] :
    min(255, ((A[i] << 8) / (255 - B[i]))))

上述表达式本质上是将图像像素 A[i] 的值除以遮罩像素 B[i] 的逆值(像素值范围在 0-255 之间),同时确保结果像素值落在 (0, 255) 范围内,并且避免除以 0

我们可以将上述看似复杂的表达式转换为下面这个简单的 Python 函数。该函数接受两个 OpenCV 矩阵(图像和遮罩),并返回混合后的图像:

python 复制代码
def dodge_naive(image, mask):
    width, height = image.shape[:2]
    blend = np.zeros((width, height), np.uint8)

    for c in range(width):
        for r in range(height):
            result = (image[c, r] << 8) / (255 - mask[c, r])
            blend[c, r] = min(255, result)
    return blend

尽管以上代码在功能上是正确的,但它的速度无疑会极其缓慢。首先,该函数使用了 for 循环,其次,NumPy 数组( PythonOpenCV 图像的底层格式)是为数组计算而优化的,因此单独访问和修改每个 image[r, c] 像素会非常慢。

相反,我们应该认识到 << 8 运算等同于将像素值乘以 2 8 = 256 2^8=256 28=256,逐像素的除法可以通过 cv2.divide 函数来实现。因此,利用矩阵乘法(速度更快)改进后的减淡函数如下所示:

python 复制代码
def dodge(image, mask):
    print(image.dtype, mask.dtype)
    return cv2.divide(image, 255 - mask, scale=256)

在这里,我们将 dodge 函数精简到了一行!这个新的 dodge 函数产生的结果与 dodge_naive 相同,但速度比简单版本快了几个数量级。除此之外,cv2.divide 会自动处理除以零的情况:当 255 - mask 为零时,结果置为零。

下面是对示例图像应用减淡效果后的结果,其中我们对正方形区域(像素范围 100:300, 100:300 )进行了减淡处理:

可以看到,右侧照片中变亮的区域非常明显,但过渡非常生硬。有一些方法可以改善这一点,我们将在下一小节中探讨其中一种方法。接下来,我们学习如何通过二维卷积实现高斯模糊。

3.2 使用二维卷积实现高斯模糊

高斯模糊是通过将图像与一个高斯值的核进行卷积来实现的。二维卷积在图像处理中应用非常广泛。通常,我们有一张大图(以该图像的某个 5×5 子区域为例),以及一个核(或滤波器),后者是另一个更小尺寸的矩阵(在本节中为 3×3)。

为了得到卷积值,假设我们想要获取位置 (2, 3) 处的值。我们将核中心对准位置 (2, 3),然后计算重叠矩阵(下图中红色高亮区域)与核的逐元素乘积,并求总和。得到的数值(即 158.4)就是我们写入另一矩阵中位置 (2, 3) 处的值。

我们对所有元素重复此过程,得到的矩阵(右侧的矩阵)即为核与图像的卷积结果。在下图中,左侧可以看到原始图像,方格内是像素值(大于 100 的值)。我们还可以看到一个橙色滤波器,每个单元格右下角是其数值(一组 0.10.2 的值,总和为 1)。在右侧的矩阵中,可以看到将滤波器应用于左侧图像后得到的数值:

需要注意的是,对于边界上的点,核无法与矩阵完全对齐,因此我们需要制定一种策略来为这些点赋值。没有一种适用于所有情况的完美策略;常用的方法包括用零值扩展边界,或者用边界值进行扩展。

接下来,我们介绍如何将一张普通图片转换为铅笔画效果。

3.3 应用铅笔画滤镜

掌握了前几小节学到的技巧,我们现在可以来看整个实现流程了。最终代码在 tools.py 文件中的 convert_to_pencil_sketch 函数中实现。

以下步骤演示了如何将彩色图像转换为灰度图,然后将灰度图与其模糊后的负片进行混合。

(1) 首先,将 RGB 图像 (imgRGB) 转换为灰度图:

python 复制代码
def convert_to_pencil_sketch_ordered(rgb_image):
    gray_image = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)

使用 cv2.COLOR_RGB2GRAY 作为 cv2.cvtColor 函数的参数,该参数用于改变颜色空间。需要注意的是,无论输入图像是 RGB 还是 BGR (OpenCV 的默认格式),最终都能得到一张不错的灰度图像。

(2) 然后,反转图像,并用一个大小为 (21, 21) 的大尺寸高斯核对其进行模糊处理:

python 复制代码
    inv_gray = 255 - gray_image
    blurred_image = cv2.GaussianBlur(inv_gray, (21, 21), 0, 0)

(3) 使用减淡技术将原始灰度图与模糊后的负片进行混合:

python 复制代码
    gray_sketch = cv2.divide(gray_image, 255 - blurred_image, scale=256)
    return cv2.cvtColor(gray_sketch, cv2.COLOR_GRAY2RGB)

生成图像如下所示:

接下来,我们使用 OpenCV 进一步优化以上代码。

3.4 使用高斯模糊进行优化

高斯模糊本质上就是与高斯函数进行卷积,而卷积的一个重要特性是结合律。这意味着,无论我们是先反转图像再模糊,还是先模糊图像再反转,结果都是一样的。

如果我们从一张模糊的图像开始,并将其反转后传入减淡函数,那么在函数内部图像会被再次反转(即 255 - mask 这一部分),最终得到的结果本质上就是原始图像。如果去掉这些冗余操作,优化后的 convert_to_pencil_sketch 函数如下所示:

python 复制代码
def convert_to_pencil_sketch(rgb_image):
    gray_image = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
    blurred_image = cv2.GaussianBlur(gray_image, (21, 21), 0, 0)
    gray_sketch = cv2.divide(gray_image, blurred_image, scale=256)
    return cv2.cvtColor(gray_sketch, cv2.COLOR_GRAY2RGB)

为了增添趣味,我们还可以将变换后的图像 (img_sketch) 与背景图像 (canvas) 进行轻度混合,使其看起来像是在画布上绘制的一样。因此,在返回结果之前,如果存在画布,我们将其混合:

python 复制代码
    if canvas is not None:
        gray_sketch = cv2.multiply(gray_sketch, canvas, scale=1 / 256)

我们将最终的函数命名为 pencil_sketch_on_canvas,优化后的完整代码如下:

python 复制代码
def pencil_sketch_on_canvas(rgb_image, canvas=None):
    gray_image = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
    blurred_image = cv2.GaussianBlur(gray_image, (21, 21), 0, 0)
    gray_sketch = cv2.divide(gray_image, blurred_image, scale=256)
    if canvas is not None:
        gray_sketch = cv2.multiply(gray_sketch, canvas, scale=1 / 256)
    return cv2.cvtColor(gray_sketch, cv2.COLOR_GRAY2RGB)

我们为 convert_to_pencil_sketch 函数增加了可选的 canvas 参数,可以为铅笔画增添艺术效果。最终输出效果如下图所示:

接下来,我们继续学习如何生成冷暖色调滤镜,我们将在此过程中学习如何使用查找表进行图像处理。

4. 冷暖色调滤镜

当我们感知图像时,大脑会捕捉许多细微线索来推断场景的重要细节。例如,在明亮的日光下,高光区域可能略带黄色调,因为它们处于直射阳光下;而阴影区域由于蓝天的环境光可能呈现淡蓝色。当我们看到具有这种色彩特性的图像时,可能会立刻联想到晴朗的天气。

这种效果对摄影师来说非常常见,他们有时会刻意调整图像的白平衡来传达某种情绪。暖色调通常被认为更令人愉悦,而冷色调则与夜晚和沉闷相关联。

为了操控图像中可感知的色温,我们将实现一种曲线滤镜。这类滤镜控制着颜色在图像不同区域之间的过渡方式,使我们能够在不给图像添加不自然整体色调的情况下,巧妙地改变色彩谱系。

在下一小节中,我们将探讨如何通过曲线偏移来操控颜色。

4.1 使用曲线偏移进行颜色操控

曲线滤波器本质上是一个函数 y = f ( x ) y = f(x) y=f(x),它将输入像素值 x x x 映射到输出像素值 y y y。曲线由一组 n + 1 n+1 n+1 个锚点进行参数化,如下所示:
{ ( x 0 , y 0 ) , ( x 1 , y 1 ) , . . . , ( x n , y n ) } \{(x_0,y_0),(x_1,y_1),...,(x_n,y_n)\} {(x0,y0),(x1,y1),...,(xn,yn)}

其中每个锚点是一对数字,分别代表输入和输出像素值。例如,锚点 (30, 90) 表示输入像素值 30 被增加到输出值 90。锚点之间的值则沿着平滑曲线进行插值(因此得名"曲线滤波器")。

这种滤镜可以应用于任何图像通道,无论是单个灰度通道,还是 RGB 彩色图像的 R (红)、G (绿)、B (蓝)通道。因此,就我们的目的而言, x x x 和 y y y 的所有值必须保持在 0255 之间。

例如,如果我们想让一张灰度图像稍微亮一些,可以使用包含以下控制点集的曲线滤镜:
{ ( 0 , 0 ) , ( 128 , 192 ) , ( 255 , 255 ) } \{(0,0),(128,192),(255,255)\} {(0,0),(128,192),(255,255)}

这意味着除了 0255 之外的所有输入像素值都会略微增加,从而对图像产生整体提亮的效果。

如果希望这类滤镜生成自然逼真的图像,需要遵循以下两条重要规则:

每个锚点集都应包含 (0,0)(255,255)。这很重要,可以防止图像看起来带有整体色调,因为黑色保持为黑色,白色保持为白色。
f ( x ) f(x) f(x) 函数必须是单调递增的。换句话说,随着 x x x 的增加, f ( x ) f(x) f(x) 要么保持不变,要么增加(即永远不会减小)。这对于确保阴影仍然是阴影、高光仍然是高光至关重要。

下一小节将演示如何使用查找表实现曲线滤波器。

4.2 使用查找表实现曲线滤波器

曲线滤波器的计算开销很大,因为当 x x x 不与预设的锚点重合时,必须对 f ( x ) f(x) f(x) 的值进行插值。对每一帧图像的每一个像素都执行这种计算会严重影响性能。

相反,我们可以使用查找表。由于在我们的应用场景中只有 256 种可能的像素值,因此我们只需要为所有 256 个可能的 x x x 值计算 f ( x ) f(x) f(x) 即可。插值工作由 scipy.interpolate 模块中的 UnivariateSpline 函数完成,如以下代码片段所示:

python 复制代码
from scipy.interpolate import UnivariateSpline

def spline_to_lookup_table(spline_breaks: list, break_values: list, k=2):
    spl = UnivariateSpline(spline_breaks, break_values, k=k)
    return spl(range(256))

该函数的返回值是一个包含 256 个元素的列表,其中包含了每个可能的 x x x 值所对应的插值结果 f ( x ) f(x) f(x)。

现在我们所需要做的就是设计一组锚点 ( x i , y i ) (x_i,y_i) (xi,yi),然后就可以将滤镜应用于灰度输入图像 (img_gray) 了:

python 复制代码
import cv2
import numpy as np
x = [0, 128, 255]
y = [0, 192, 255]
myLUT = spline_to_lookup_table(x, y)
# Load image
image = cv2.imread('7.jpeg')
img_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
img_curved = cv2.LUT(img_gray, myLUT).astype(np.uint8)

结果如下图所示(左侧为原始图像,右侧为变换后的图像):

在下一小节中,我们将设计冷暖色调效果,还将学习如何将查找表应用于彩色图像,以及冷暖色调效果的工作原理。

4.3 设计冷暖色调滤镜

借助已有的机制------能够快速将通用曲线滤镜应用于任意图像通道,我们现在可以探讨如何操控图像的感知色温。

我们可以尝试不同的曲线设置,可以选择任意数量的锚点,并将曲线滤镜应用于任何图像通道(红、绿、蓝、色相、饱和度、亮度、明度等)。甚至可以组合多个通道,或者降低一个通道的值,同时将另一个通道的值调整到所需区域,观察实验结果。

(1) 首先,利用 spline_to_lookup_table 函数,定义两个通用的曲线滤镜:一个(总体趋势上)增加通道的所有像素值,另一个则减少它们:

python 复制代码
INCREASE_LOOKUP_TABLE = spline_to_lookup_table([0, 64, 128, 192, 256],
                                                [0, 70, 140, 210, 256])
DECREASE_LOOKUP_TABLE = spline_to_lookup_table([0, 64, 128, 192, 256],
                                                [0, 30, 80, 120, 192])

(2) 接下来,我们研究如何将查找表应用于 RGB 图像。OpenCV 包含了 cv2.LUT 函数,它接受一个查找表并将其应用于矩阵。因此,我们首先需要将图像分解为不同的通道:

python 复制代码
    c_r, c_g, c_b = cv2.split(rgb_image)

(3) 然后,根据需要将滤镜应用于每个通道:

python 复制代码
    if green_filter is not None:
        c_g = cv2.LUT(c_g, green_filter).astype(np.uint8)

(4)RGB 图像的所有三个通道执行此操作,我们得到以下辅助函数:

python 复制代码
def apply_rgb_filters(rgb_image, *,
                      red_filter=None, green_filter=None, blue_filter=None):
    c_r, c_g, c_b = cv2.split(rgb_image)
    if red_filter is not None:
        c_r = cv2.LUT(c_r, red_filter).astype(np.uint8)
    if green_filter is not None:
        c_g = cv2.LUT(c_g, green_filter).astype(np.uint8)
    if blue_filter is not None:
        c_b = cv2.LUT(c_b, blue_filter).astype(np.uint8)
    return cv2.merge((c_r, c_g, c_b))

(5) 让一幅图像看起来像是在炎热、阳光明媚的日子(或许接近日落时)拍摄的最简单方法是:增加图像中的红色,并通过提高色彩饱和度使颜色显得鲜艳。我们将分两步实现:

使用 INCREASE_LOOKUP_TABLE 增加 RGB 图像中 R 通道的像素值,并使用 DECREASE_LOOKUP_TABLE 减少 B 通道的像素值:

python 复制代码
interim_img = apply_rgb_filters(rgb_image,
                                red_filter=INCREASE_LOOKUP_TABLE,
                                blue_filter=DECREASE_LOOKUP_TABLE)

(6) 将图像转换到 HSV 颜色空间( H 表示色相,S 表示饱和度,V 表示明度),并使用 INCREASE_LOOKUP_TABLE 增加 S 通道。这可以通过以下函数实现,该函数接收 RGB 彩色图像和一个要应用的查找表(类似于 apply_rgb_filters 函数)作为输入:

python 复制代码
def apply_hue_filter(rgb_image, hue_filter):
    c_h, c_s, c_v = cv2.split(cv2.cvtColor(rgb_image, cv2.COLOR_RGB2HSV))
    c_s = cv2.LUT(c_s, hue_filter).astype(np.uint8)
    return cv2.cvtColor(cv2.merge((c_h, c_s, c_v)), cv2.COLOR_HSV2RGB)

结果如下所示:

(7) 类似地,我们可以定义一个冷却滤镜:增加 RGB 图像中 B 通道的像素值,减少 R 通道的像素值,将图像转换到 HSV 颜色空间,并通过 S 通道降低色彩饱和度:

python 复制代码
def _render_cool(rgb_image: np.ndarray) -> np.ndarray:
    interim_img = apply_rgb_filters(rgb_image,
                                    red_filter=DECREASE_LOOKUP_TABLE,
                                    blue_filter=INCREASE_LOOKUP_TABLE)
    return apply_hue_filter(interim_img, DECREASE_LOOKUP_TABLE)

结果如下所示:

下一小节中,我们将探索如何将图像卡通化,以了解什么是双边滤波器以及相关内容。

5. 图像卡通化

要实现基本的卡通效果,我们只需要一个双边滤波器和一些边缘检测。双边滤波器可以减少图像中的调色板规模,即使用的颜色数量。这模仿了卡通画的特点------漫画家通常只使用有限的几种颜色进行创作。接着,我们可以对生成的图像进行边缘检测,以产生粗体的轮廓。然而,真正的挑战在于双边滤波器的计算成本。因此,我们将使用一些技巧,实时地生成可接受的卡通效果。

我们将按照以下步骤将 RGB 彩色图像转换为卡通图像:

  1. 首先,应用双边滤波器,减少图像的调色板颜色数量
  2. 然后,将原始彩色图像转换为灰度图
  3. 之后,应用中值模糊来减少图像噪声
  4. 使用自适应阈值处理,在边缘掩膜中检测并突出边缘
  5. 最后,将步骤 1 得到的彩色图像与步骤 4 得到的边缘掩膜相结合

接下来,我们将详细了解上述步骤。首先,我们将学习如何使用双边滤波器进行边缘感知的平滑处理。

5.1 使用双边滤波器进行边缘感知平滑处理

一个强效的双边滤波器非常适合将 RGB 图像转换为彩色绘画或卡通效果,因为它能够平滑平坦区域,同时保持边缘清晰。这种滤波器的唯一缺点是其计算成本------它比其他平滑操作(如高斯模糊)要慢几个数量级。

当我们需要降低计算成本时,首先采取的措施是在低分辨率图像上执行操作。为了将 RGB 图像 (img_rgb) 缩小到其原始大小的四分之一(即宽度和高度各减半),我们可以使用 cv2.resize

python 复制代码
    img_small = cv2.resize(img_rgb, (0, 0), fx=0.5, fy=0.5)

缩放后图像中的每个像素值,对应原始图像中一个小邻域内的像素平均值。然而,这个过程可能会产生图像伪影,这也被称为混叠 (aliasing)。图像混叠本身就是一个大问题,同时其负面效应可能会被后续处理(例如边缘检测)放大。

一个更好的替代方案是使用高斯金字塔进行下采样(同样是缩小到原始大小的四分之一)。高斯金字塔在图像重采样之前先进行一次模糊操作,从而减少混叠效应:

python 复制代码
    downsampled_img = cv2.pyrDown(downsampled_img)

然而,即使在这个尺度下,双边滤波器可能仍然太慢,无法实时运行。

另一个技巧是:对图像重复应用(例如五次)小型双边滤波器,而不是一次性应用一个大型双边滤波器:

python 复制代码
    for _ in range(num_bilaterals):
        filterd_small_img = cv2.bilateralFilter(downsampled_img, 9, 9, 7)

cv2.bilateralFilter 中的三个参数分别控制像素邻域的直径 (d=9)、颜色空间中的滤波器标准差 (sigmaColor=9) 以及坐标空间中的标准差 (sigmaSpace=7)。

因此,我们运行双边滤波器的最终代码如下:

(1) 使用多次 pyrDown 调用对图像进行下采样:

python 复制代码
    downsampled_img = rgb_image
    for _ in range(num_pyr_downs):
        downsampled_img = cv2.pyrDown(downsampled_img)

(2) 然后,应用多个双边滤波器:

python 复制代码
    for _ in range(num_bilaterals):
        filterd_small_img = cv2.bilateralFilter(downsampled_img, 9, 9, 7)

(3) 最后,将其上采样到原始大小:

python 复制代码
    filtered_normal_img = filterd_small_img
    for _ in range(num_pyr_downs):
        filtered_normal_img = cv2.pyrUp(filtered_normal_img)

结果如下所示:

下一小节将学习如何检测并突出明显的边缘。

5.2 检测与突出明显边缘

同样,在边缘检测方面,挑战通常不在于底层算法的工作原理,而在于为当前任务选择哪种特定的算法。我们已经熟悉多种边缘检测器。例如,Canny 边缘检测 (cv2.Canny) 提供了一种相对简单且有效的方法来检测图像中的边缘,但它容易受到噪声的影响。
Sobel 算子 (cv2.Sobel) 可以减少此类伪影,但它不具备旋转对称性。Scharr 算子 (cv2.Scharr) 旨在纠正这一问题,但只考虑图像的一阶导数。我们也可以选择其他算子,例如拉普拉斯脊算子(它包含二阶导数),但它们要复杂得多。而且,最终就我们的特定目的而言,它们的效果可能并不会十分出色,这或许是因为它们和其他算法一样容易受到光照条件的影响。

出于本项目的目的,我们将选择一个可能与传统边缘检测无关的函数------ cv2.adaptiveThreshold。与 cv2.threshold 类似,该函数使用阈值像素值将灰度图像转换为二值图像。也就是说,如果原始图像中的像素值高于阈值,则最终图像中的像素值为 255;否则为 0

然而,自适应阈值的精妙之处在于,它并不考虑图像的整体属性。相反,它会独立地在每个小邻域内检测最显著的特征,而不受全局图像特征的影响。这使得该算法对光照条件具有极强的鲁棒性,这正是我们在卡通效果中要为物体和人物绘制粗体黑色轮廓时所需要的特性。

但这也使得该算法容易受到噪声的影响。为了解决这个问题,我们将使用中值滤波器对图像进行预处理。中值滤波器的作用正如其名:它将每个像素值替换为一个小像素邻域内所有像素的中值。因此,为了检测边缘,我们采用以下流程:

(1) 首先将 RGB 图像 (rgb_image) 转换为灰度图 (img_gray),然后应用一个七像素邻域的中值模糊:

python 复制代码
    img_gray = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
    img_blur = cv2.medianBlur(img_gray, 7)

(2) 在降低噪声之后,可以使用自适应阈值化来检测和增强边缘:

python 复制代码
    gray_edges = cv2.adaptiveThreshold(img_blur, 255,
                                       cv2.ADAPTIVE_THRESH_MEAN_C,
                                       cv2.THRESH_BINARY, 9, 2)

自适应阈值结果如下所示:

接下来,我们将介绍如何组合颜色和轮廓来生成卡通图像。

5.3 组合颜色和轮廓生成卡通图像

最后一步是将之前得到的两种效果结合起来。只需使用 cv2.bitwise_and 将这两种效果简单地融合到一张图像中即可。完整的函数如下:

python 复制代码
def cartoonize(rgb_image, *,
               num_pyr_downs=2, num_bilaterals=7):
    downsampled_img = rgb_image
    for _ in range(num_pyr_downs):
        downsampled_img = cv2.pyrDown(downsampled_img)
    for _ in range(num_bilaterals):
        filterd_small_img = cv2.bilateralFilter(downsampled_img, 9, 9, 7)
    filtered_normal_img = filterd_small_img
    for _ in range(num_pyr_downs):
        filtered_normal_img = cv2.pyrUp(filtered_normal_img)
    if filtered_normal_img.shape != rgb_image.shape:
        filtered_normal_img = cv2.resize(
            filtered_normal_img, rgb_image.shape[:2])
    img_gray = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
    img_blur = cv2.medianBlur(img_gray, 7)
    gray_edges = cv2.adaptiveThreshold(img_blur, 255,
                                       cv2.ADAPTIVE_THRESH_MEAN_C,
                                       cv2.THRESH_BINARY, 9, 2)
    rgb_edges = cv2.cvtColor(gray_edges, cv2.COLOR_GRAY2RGB)
    return cv2.bitwise_and(filtered_normal_img, rgb_edges)

结果如下所示:

在下一小节中,我们将设置主脚本并设计一个 GUI 应用程序。

7. 整合所有内容

在前面的部分中,我们实现了一些不错的滤镜,展示了如何利用 OpenCV 获得令人满意的效果。在本节中,我们将构建一个交互式应用程序,能够实时地将这些滤镜应用于笔记本电脑的摄像头。因此,我们需要编写一个用户界面 (User Interface, UI),以便捕获摄像头视频流,并提供一些按钮,可以选择想要应用的滤镜。我们将首先使用 OpenCV 设置摄像头捕获。然后,使用 wxPython 在其周围构建一个漂亮的界面。

7.1 运行应用程序

要运行该应用程序,我们将使用 filters.py 脚本。按照以下步骤操作:

(1) 首先,我们导入所有必要的模块:

python 复制代码
import numpy as np
import wx
import cv2

(2) 我们还需要导入通用的 GUI 布局(来自 wx_gui.py )以及设计好的图像特效(来自 tools.py):

python 复制代码
from wx_gui import BaseLayout
from tools import apply_hue_filter
from tools import apply_rgb_filters
from tools import load_img_resized
from tools import spline_to_lookup_table
from tools import cartoonize
from tools import pencil_sketch_on_canvas

(3) OpenCV 提供了一种直接的方法来访问计算机的摄像头设备。以下代码片段使用 cv2.VideoCapture 打开计算机的默认摄像头 ID (0):

python 复制代码
def main():
    capture = cv2.VideoCapture(0)

(4) 为了让我们的应用程序能够实时运行,我们将视频流的大小限制为 640×480 像素:

python 复制代码
    capture.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
    capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)

(5) 然后,可以将捕获的视频流传递给我们的 GUI 应用程序,该应用程序是 FilterLayout 类的一个实例:

python 复制代码
    app = wx.App()
    layout = FilterLayout(capture, title='Fun with Filters')
    layout.Center()
    layout.Show()
    app.MainLoop()

创建 FilterLayout 之后,我们将布局居中,使其显示在屏幕中央。然后调用 Show() 来实际显示布局。最后,调用 app.MainLoop(),以便该应用程序开始工作,接收和处理事件。

最后,我们需要做的就是设计上述 GUI

7.2 GUI 基类

FilterLayout GUI 将基于一个通用的、简单的布局类,名为 BaseLayout,我们将在后续学习中继续使用这个类。
BaseLayout 类被设计为一个抽象基类。可以把这个类看作一个蓝图,它适用于我们尚未设计的所有布局------也就是说,它是一个骨架类,将作为我们 GUI 代码的主干。

(1) 首先导入将要使用的库:

python 复制代码
import numpy as np
import wx
import cv2

(2) 该类被设计为从 wx.Frame 类派生而来:

python 复制代码
class BaseLayout(wx.Frame):

稍后,当我们编写自己的自定义布局 (FilterLayout) 时,将使用相同的表示法来指定该类基于 BaseLayout 蓝图(或骨架)类,例如 class FilterLayout(BaseLayout):。但眼下,我们先专注于 BaseLayout 类。

(3) 一个抽象类至少包含一个抽象方法。我们将通过确保如果该方法未被实现,应用程序将无法运行并抛出异常,来使该方法成为抽象方法:

python 复制代码
class BaseLayout(wx.Frame):
    # Abstract base class for all layouts
...
...
    def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
        # Process the frame of the camera (or other capture device)
        raise NotImplementedError()

那么,任何从该类派生的类(例如 FilterLayout )都必须提供该方法的完整实现,这将使我们能够创建自定义布局。

接下来,我们继续来看 GUI 构造函数。

7.2.1 GUI 构造函数

(1) BaseLayout 构造函数接受一个 ID (值为 -1)、一个标题字符串('Fun with Filters')、一个视频捕获对象,以及一个指定每秒帧数的可选参数。然后,构造函数中要做的第一件事是尝试从捕获对象中读取一帧,以确定图像尺寸:

python 复制代码
    def __init__(self,
                 capture: cv2.VideoCapture,
                 title: str = None,
                 parent=None,
                 window_id: int = -1,  # default value
                 fps: int = 10):
        """
        Initialize all necessary parameters and generate a basic GUI layout
        that can then be augmented using `self.augment_layout`.
        """
        self.capture = capture
        success, frame = self._acquire_frame()
        if not success:
            print("Could not acquire frame from camera.")
            raise SystemExit()
        self.imgHeight, self.imgWidth = frame.shape[:2]

(2) 我们将使用图像尺寸来准备一个缓冲区,用于将每一帧视频存储为位图,并设置 GUI 的大小。因为我们想要在当前视频帧下方显示一组控制按钮,所以将 GUI 的高度设置为 self.imgHeight + 20

python 复制代码
        super().__init__(parent, window_id, title,
                         size=(self.imgWidth, self.imgHeight + 20))
        self.fps = fps
        self.bmp = wx.Bitmap.FromBuffer(self.imgWidth, self.imgHeight, frame)

在下一小节中,我们将使用 wxPython 为我们的应用程序构建一个基本布局,其中包含一个视频流和一些按钮。

7.2.1.1 基本 GUI 布局

(1) 最基本的布局只包含一个大的黑色面板,该面板提供足够的空间来显示视频画面:

python 复制代码
        self.video_pnl = wx.Panel(self, size=(self.imgWidth, self.imgHeight))
        self.video_pnl.SetBackgroundColour(wx.BLACK)

(2) 为了使布局具有可扩展性,我们将其添加到一个垂直排列的 wx.BoxSizer 对象中:

python 复制代码
        self.panels_vertical = wx.BoxSizer(wx.VERTICAL)
        self.panels_vertical.Add(self.video_pnl, 1, flag=wx.EXPAND | wx.TOP,
                                 border=1)

(3) 接下来,我们指定一个抽象方法 augment_layout,我们不为此方法填写任何代码。相反,基类的任何用户都可以根据自己的需要对基本布局进行自定义修改:

python 复制代码
        self.augment_layout()

(4) 然后,我们只需要设置结果布局的最小尺寸并将其居中即可::

python 复制代码
        self.SetMinSize((self.imgWidth, self.imgHeight))
        self.SetSizer(self.panels_vertical)
        self.Centre()

下一小节将介绍如何处理视频流。

7.2.1.2 处理视频流

网络摄像头的视频流通过一系列步骤进行处理,这些步骤从 __init__ 方法开始。这些步骤乍看之下可能过于复杂,但目的是为了让视频即使在较高帧率下也能流畅运行(即消除闪烁)。
wxPython 模块使用事件和回调机制。当某个事件被触发时,它可以导致某个类方法被执行(换句话说,一个方法可以绑定到一个事件)。我们将利用这一机制,按照以下步骤定期显示新帧:

(1) 创建一个计时器,每当过去 1000./self.fps 毫秒时,该计时器就会生成一个 wx.EVT_TIMER 事件:

python 复制代码
        self.timer = wx.Timer(self)
        self.timer.Start(int(1000. / self.fps))

(2) 每当计时器启动时,我们希望调用 _on_next_frame 方法。该方法将尝试获取一个新的视频帧:

python 复制代码
        self.Bind(wx.EVT_TIMER, self._on_next_frame)

(3) _on_next_frame 方法将处理新的视频帧,并将处理后的帧存储到位图中。这将触发另一个事件 wx.EVT_PAINT。我们希望将此事件绑定到 _on_paint 方法,该方法将负责绘制新帧的显示。因此,我们创建一个视频占位符,并将 wx.EVT_PAINT 绑定到它上面:

python 复制代码
        self.video_pnl.Bind(wx.EVT_PAINT, self._on_paint)

(4) _on_next_frame 方法获取一个新帧,然后将该帧发送给另一个方法 process_frame 进行进一步处理 (process_frame 是一个抽象方法,应由子类实现):

python 复制代码
    def _on_next_frame(self, event):
        """
        Capture a new frame from the capture device,
        send an RGB version to `self.process_frame`, refresh.
        """
        success, frame = self._acquire_frame()
        if success:
                frame = self.process_frame(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
                

(5) 处理后的帧 (frame) 随后被存储到位图缓冲区 (self.bmp) 中。调用 Refresh 会触发前述的 wx.EVT_PAINT 事件,该事件绑定到 _on_paint 方法:

python 复制代码
            self.bmp.CopyFromBuffer(frame)
            self.Refresh(eraseBackground=False)

(6) 然后 paint 方法从从缓冲区获取帧并进行显示:

python 复制代码
    def _on_paint(self, event):
        wx.BufferedPaintDC(self.video_pnl).DrawBitmap(self.bmp, 0, 0)

下一小节将学习如何创建自定义滤镜布局。

7.3 设计自定义滤镜布局

如果想要使用 BaseLayout 类,我们需要为之前留空的两个方法提供代码,具体如下:

  • augment_layout:在这里可以对 GUI 布局进行特定于任务的修改
  • process_frame:在这里对摄像头输入的每一帧执行特定任务的处理

(1) 我们还需要修改构造函数,以初始化所需的参数------在本节中,是铅笔素描的画布背景:

python 复制代码
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        color_canvas = load_img_resized('pencilsketch_bg.jpg',
                                        (self.imgWidth, self.imgHeight))
        self.canvas = cv2.cvtColor(color_canvas, cv2.COLOR_RGB2GRAY)

(2) 为了自定义布局,我们水平排列多个单选按钮------每个图像特效模式对应一个按钮。此处,style=wx.RB_GROUP 选项确保一次只能选中其中一个单选按钮。为了使这些修改可见,需要将 pnl 添加到现有面板列表 self.panels_vertical 中:

python 复制代码
    def augment_layout(self):
        """ Add a row of radio buttons below the camera feed. """

        pnl = wx.Panel(self, -1)
        self.mode_warm = wx.RadioButton(pnl, -1, 'Warming Filter', (10, 10),
                                        style=wx.RB_GROUP)
        self.mode_cool = wx.RadioButton(pnl, -1, 'Cooling Filter', (10, 10))
        self.mode_sketch = wx.RadioButton(pnl, -1, 'Pencil Sketch', (10, 10))
        self.mode_cartoon = wx.RadioButton(pnl, -1, 'Cartoon', (10, 10))
        hbox = wx.BoxSizer(wx.HORIZONTAL)
        hbox.Add(self.mode_warm, 1)
        hbox.Add(self.mode_cool, 1)
        hbox.Add(self.mode_sketch, 1)
        hbox.Add(self.mode_cartoon, 1)
        pnl.SetSizer(hbox)

        self.panels_vertical.Add(pnl, flag=wx.EXPAND | wx.BOTTOM | wx.TOP,
                                 border=1)

(3) 最后一个需要指定的方法是 process_frame,每当接收到新的摄像头帧时,该方法就会被触发。我们只需要根据单选按钮的配置,选择要应用的正确图像特效即可。我们只需检查当前选中了哪个按钮,并调用相应的渲染方法:

python 复制代码
    def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
        if self.mode_warm.GetValue():
            return self._render_warm(frame_rgb)
        elif self.mode_cool.GetValue():
            return self._render_cool(frame_rgb)
        elif self.mode_sketch.GetValue():
            return pencil_sketch_on_canvas(frame_rgb, canvas=self.canvas)
        elif self.mode_cartoon.GetValue():
            return cartoonize(frame_rgb)
        else:
            raise NotImplementedError()

下图展示了应用不同滤镜后的输出图像:

小结

在本节中,我们探索了一系列有趣的图像处理特效。我们利用减淡和加深技术创建了黑白铅笔素描效果,探索了查找表以实现高效的曲线滤镜,并发挥创意制作了卡通效果。其中使用的一项技术是二维卷积,它接收一个滤波器和一张图像,然后生成一张新图像。

系列链接

OpenCV-Python实战(1)------OpenCV简介与图像处理基础
OpenCV-Python实战(2)------图像与视频文件的处理
OpenCV-Python实战(3)------OpenCV中绘制图形与文本
OpenCV-Python实战(4)------OpenCV常见图像处理技术
OpenCV-Python实战(5)------OpenCV图像运算
OpenCV-Python实战(6)------OpenCV中的色彩空间和色彩映射
OpenCV-Python实战(7)------直方图详解
OpenCV-Python实战(8)------直方图均衡化
OpenCV-Python实战(9)------OpenCV用于图像分割的阈值技术
OpenCV-Python实战(10)------OpenCV轮廓检测
OpenCV-Python实战(11)------OpenCV轮廓检测相关应用
OpenCV-Python实战(12)------一文详解AR增强现实
OpenCV-Python实战(13)------OpenCV与机器学习的碰撞
OpenCV-Python实战(14)------人脸检测详解
OpenCV-Python实战(15)------面部特征点检测详解
OpenCV-Python实战(16)------人脸追踪详解
OpenCV-Python实战(17)------人脸识别详解
OpenCV-Python实战(18)------深度学习简介与入门示例
OpenCV-Python实战(19)------OpenCV与深度学习的碰撞
OpenCV-Python实战(20)------OpenCV计算机视觉项目在Web端的部署
OpenCV-Python实战(21)------OpenCV人脸检测项目在Web端的部署
OpenCV-Python实战(22)------使用Keras和Flask在Web端部署图像识别应用
OpenCV-Python实战(23)------将OpenCV计算机视觉项目部署到云端

相关推荐
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(夜间-次晨)2026年5月26日
大数据·人工智能·python·信息可视化·自然语言处理·ai编程·灵砚智能
松果财经2 小时前
从“单点收付”到“跨国司库”,金融为何是出海深水区的关键变量?
人工智能·microsoft·金融
yangshuo12812 小时前
基于豆包AI实现抖音智能评论系统
人工智能
gis分享者2 小时前
告别手动解析,Python 加 AI 让网页抓取更稳定
python·大语言模型,网页抓取,数据提取
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月27日
人工智能·python·信息可视化·自然语言处理·ai编程·灵砚智能
小新同学^O^2 小时前
简单学习 --> llm是怎么训练出来的?
人工智能·深度学习·学习
Swift社区2 小时前
鸿蒙 PC 与 AI Runtime:下一代桌面交互
人工智能·交互·harmonyos
chengzi_beibei2 小时前
万字长文:如何用 harness 的理念设计一个 AI 驱动的 UI 自动化工程。
人工智能
北京软秦科技有限公司2 小时前
规避处罚!一单一库实施后,检测机构合规自查全指南(AI报告审核+IACheck赋能)
人工智能