目标

本教程旨在演示如何使用 OpenCV parallel_for_ 框架轻松实现代码并行化。为了说明这一概念,我们将编写一个对图像进行卷积运算的程序。完整的教程代码在这里。

前提条件

并行框架

第一个先决条件是 OpenCV 采用并行框架。在 OpenCV 4.5 中,以下并行框架依次可用:

  • 英特尔线程构件(第三方库,应明确启用)
  • OpenMP(集成到编译器中,应明确启用)
  • APPLE GCD(系统范围,自动使用(仅限 APPLE)
  • Windows RT 并发(系统范围,自动使用(仅限 Windows RT)
  • Windows 并发(运行时的一部分,自动使用(仅限 Windows - MSVC++ >= 10)
  • Pthreads

如您所见,OpenCV 库中可使用多个并行框架。有些并行库是第三方库,必须在构建前在 CMake 中明确启用,而有些并行库则是平台自动提供的(如 APPLE GCD)。

竞赛条件

当多个线程同时尝试写入或读写特定内存位置时,就会出现竞赛条件。因此,我们可以将算法大致分为两类

  1. 只有一个线程向特定内存位置写入数据的算法。
  • 例如,在卷积中,即使多个线程在特定时间从一个像素读取数据,但只有一个线程向特定像素写入数据。
  1. 多个线程可写入单个内存位置的算法。
  • 查找轮廓、特征等。此类算法可能需要每个线程同时向全局变量添加数据。例如,在检测特征时,每个线程都会将各自图像部分的特征添加到一个公共向量中,从而产生竞争条件。

卷积

我们将以执行卷积为例,演示如何使用 parallel_for_ 来并行计算。这是一个不会导致竞赛条件的算法示例。

理论

卷积是一种广泛应用于图像处理的简单数学运算。在这里,我们在图像上滑动一个较小的矩阵(称为内核),像素值与内核中相应值的乘积之和就会得出输出中特定像素的值(称为内核的锚点)。根据内核中的值,我们可以得到不同的结果。在下面的示例中,我们使用了一个 3x3 内核(锚定在其中心),并对 5x5 矩阵进行卷积,生成一个 3x3 矩阵。输出的大小可以通过在输入中填充合适的值来改变。

使用opencv合并2个多边形_卷积


卷积动画

在本教程中,我们将实现该函数的最简单形式,即接收灰度图像(1 个通道)和奇数长度的方形核并生成输出图像。该操作不会就地执行。

注意事项
我们可以临时存储一些相关像素,以确保在卷积过程中使用原始值,然后就地执行。不过,本教程的目的是介绍 parallel_for_函数,就地执行可能过于复杂。

伪代码

InputImage src, OutputImage dst, kernel(size n)
makeborder(src, n/2)
for each pixel (i, j) strictly inside borders, do:
{
    value := 0
    for k := -n/2 to n/2, do:
        for l := -n/2 to n/2, do:
            value += kernel[n/2 + k][n/2 + l]*src[i + k][j + l]

    dst[i][j] := value
}

对于 n 大小的内核,我们将添加一个 n/2 大小的边界来处理边缘情况。然后,我们运行两个循环,沿着内核移动,并将乘积相加求和。

实现

顺序执行

void conv_seq(Mat src, Mat &dst, Mat kernel)
{
 int rows = src.rows, cols = src.cols;
 dst = Mat(rows, cols, src.type());
 // 处理边缘值
 // 使 border = kernel.rows / 2;
 int sz = kernel.rows / 2;
 copyMakeBorder(src, src, sz, sz, sz, sz, BORDER_REPLICATE);
 for (int i = 0; i < rows; i++)
 {
 uchar *dptr = dst.ptr(i);
 for (int j = 0; j < cols; j++)
 {
 double value = 0;
 for (int k = -sz; k <= sz; k++)
 {
 // 当我们创建一个 ptr 时,由于内存访问效率更高,结果会稍快一些。
 uchar *sptr = src.ptr(i + sz + k);
 for (int l = -sz; l <= sz; l++)
 {
 value += kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
 }
 }
 dptr[j] = saturate_cast<uchar>(value);
 }
 }
}

我们首先制作一个与 src 大小相同的输出矩阵(dst),并为 src 图像添加边框(以处理边缘情况)。

int rows = src.rows, cols = src.cols;
 dst = Mat(rows, cols, src.type());
 // 处理边缘值
 // 使 border = kernel.rows / 2;
 int sz = kernel.rows / 2;
 copyMakeBorder(src, src, sz, sz, sz, sz, BORDER_REPLICATE);

然后,我们依次遍历源图像中的像素,计算内核值和邻近像素值。然后将值填入 dst 图像中的相应像素。

for (int i = 0; i < rows; i++)
 {
 uchar *dptr = dst.ptr(i);
 for (int j = 0; j < cols; j++)
 {
 double value = 0;
 for (int k = -sz; k <= sz; k++)
 {
 // 当我们创建一个 ptr 时,由于内存访问效率更高,结果会稍快一些。
 uchar *sptr = src.ptr(i + sz + k);
 for (int l = -sz; l <= sz; l++)
 {
 value += kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
 }
 }
 dptr[j] = saturate_cast<uchar>(value);
 }
 }

并行执行

在查看顺序执行时,我们可以注意到每个像素都取决于多个相邻像素,但每次只编辑一个像素。因此,为了优化计算,我们可以利用现代处理器的多核架构,将图像分割成条状,并在每个条状上并行执行卷积。OpenCV cv::parallel_for_

注意事项 虽然特定条纹中的像素值可能取决于条纹外的像素值,但这些操作只是只读操作,因此不会导致未定义的行为。

我们首先声明一个继承自 cv::ParallelLoopBody 的自定义类,并覆盖虚拟 void operator ()(const cv::Range& range) const

class parallelConvolution : public ParallelLoopBody
{
private:
 Mat m_src, &m_dst;
 Mat m_kernel;
 int sz;
public:
 parallelConvolution(Mat src, Mat &dst, Mat kernel)
 :m_src(src), m_dst(dst), m_kernel(kernel)
 {
 sz = kernel.rows / 2;
 }
 virtual void operator()(const Range &range) const CV_OVERRIDE
 {
 for (int r = range.start; r < range.end; r++)
 {
 int i = r / m_src.cols, j = r % m_src.cols;
 double value = 0;
 for (int k = -sz; k <= sz; k++)
 {
 uchar *sptr = m_src.ptr(i + sz + k);
 for (int l = -sz; l <= sz; l++)
 {
 value += m_kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
 }
 }
 m_dst.ptr(i)[j] = saturate_cast<uchar>(value);
 }
 }
};

operator()中的范围表示单个线程将处理的值的子集。根据需要,可能有不同的方法来分割范围,进而改变计算。

例如,我们可以

  1. 分割图像的整个遍历,并按以下方式获取 [row, col] 坐标(如上代码所示):
virtual void operator()(const Range &range) const CV_OVERRIDE
 {
 for (int r = range.start; r < range.end; r++)
 {
 int i = r / m_src.cols, j = r % m_src.cols;
 double value = 0;
 for (int k = -sz; k <= sz; k++)
 {
 uchar *sptr = m_src.ptr(i + sz + k);
 for (int l = -sz; l <= sz; l++)
 {
 value += m_kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
 }
 }
 m_dst.ptr(i)[j] = saturate_cast<uchar>(value);
 }
 }

然后,我们将以下面的方式调用 parallel_for_ 函数:

parallelConvolution obj(src, dst, kernel);
 parallel_for_(Range(0, rows * cols), obj);
  1. 分割行并计算每一行:
virtual void operator()(const Range &range) const CV_OVERRIDE
 {
 for (int i = range.start; i < range.end; i++)
 {
 uchar *dptr = dst.ptr(i);
 for (int j = 0; j < cols; j++)
 {
 double value = 0;
 for (int k = -sz; k <= sz; k++)
 {
 uchar *sptr = src.ptr(i+sz+k);
 for (int l = -sz; l <= sz; l++)
 {
 value += kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
 }
 }
 dptr[j] = saturate_cast<uchar>(value);
 }
 }
 }

在这种情况下,我们使用不同的范围调用 parallel_for_ 函数:

parallelConvolutionRowSplit obj(src, dst, kernel);
 parallel_for_(Range(0, rows), obj);

注释
在我们的案例中,两种实现方式的性能类似。在某些情况下,可能会有更好的内存访问模式或其他性能优势。

要设置线程数,可以使用:cv::setNumThreads。也可以使用 cv::parallel_for_ 中的 nstripes 参数指定分割的数量。例如,如果您的处理器有 4 个线程,设置 cv::setNumThreads(2) 或设置 nstripes=2 应该是一样的,因为默认情况下它将使用所有可用的处理器线程,但只在两个线程上分割工作负载。

注意事项
C++ 11 标准允许通过删除 parallelConvolution 类并用 lambda 表达式取而代之来简化并行执行:

parallel_for_(Range(0, rows * cols), [&](const Range &range)
 {
 for (int r = range.start; r < range.end; r++)
 {
 int i = r / cols, j = r % cols;
 double value = 0;
 for (int k = -sz; k <= sz; k++)
 {
 uchar *sptr = src.ptr(i+sz+k);
 for (int l = -sz; l <= sz; l++)
 {
 value += kernel.ptr<double>(k + sz)[l + sz] * sptr[j + sz + l];
 }
 }
 dst.ptr(i)[j] = saturate_cast<uchar>(value);
 }
 });

结果

  • 在 5x5 内核下, 512x512 输入
5x5 内核的 512x512 输入上执行这两种实现所需的时间:
  该程序展示了如何使用 OpenCV parallel_for_ 函数,并比较了在一个 5x5 内核的 512x512 输入上顺序执行和并行执行的性能。
  的性能进行比较。
  卷积操作的性能
  使用方法
  ./a.out [image_path -- 默认为 lena.jpg]

  顺序执行: 0.0953564s
  并行执行: 0.0246762s 0.0246762s
  并行执行(行拆分): 0.0248722s 0.0248722s
  • 使用 3x3 内核的 512x512 输入
该程序展示了如何使用 OpenCV parallel_for_ 函数,并比较了顺序执行和并行执行的性能。
  的性能进行了比较。
  卷积操作的性能
  使用方法
  ./a.out [image_path -- 默认为 lena.jpg]

  顺序执行: 0.0301325s
  并行执行: 0.0117053s 0.0117053s
  并行执行(行拆分): 0.0117894s 0.0117894s

并行执行的性能取决于 CPU 的类型。例如,在 4 核 8 线程 CPU 上,运行时间可能比顺序执行快 6 到 7 倍。有很多因素可以解释为什么我们没有达到 8 倍的速度:

  • 创建和管理线程的开销、
  • 并行运行的后台进程
  • 4 个硬件内核(每个内核 2 个逻辑线程)与 8 个硬件内核之间的差异。

在教程中,我们使用了水平渐变滤镜(如上面的动画所示),它能生成突出垂直边缘的图像。

使用opencv合并2个多边形_opencv_02