OpenCV
开源计算机视觉
加载中...
搜索中...
没有匹配项
如何使用 OpenCV parallel_for_ 来并行化代码

上一个教程: 使用 XML 和 YAML 文件进行文件输入输出
下一个教程: 使用通用内涵量矢量化代码

兼容性OpenCV >= 3.0

目标

本教程的目标是演示如何使用 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. 仅单个线程向特定内存写入数据的算法。
    • 例如,即使多个线程可能同时从特定时间的像素读取,在卷积中,也仅有单个线程特定像素写入。
  2. 多个线程可能向特定内存写入数据的算法。
    • 查找轮廓、特征等。此类算法可能要求各线程同时将数据添加到一个全局变量中。例如在检测特征时,各线程会将各自图像部分中的特征添加到一个通用向量中,从而导致竞争条件。

卷积运算

我们将使用执行卷积运算的示例来演示如何使用 parallel_for_ 实现计算并行化。这是一个不会导致竞争条件的算法示例。

原理

卷积运算是一种在图像处理中广泛使用的数学运算。在此过程中,我们将一个小矩阵(称为“核”)在图像上滑动,然后将像素值与其在核中对应值的乘积相加,从而得到输出中特定像素的值(称为核的锚点)。根据核中的值,我们将得到不同的结果。在以下示例中,我们使用一个 3x3 核(中心作为锚点)并在 5x5 矩阵上进行卷积运算,以生成 3x3 矩阵。通过用合适的值对输入进行填充,可以调整输出大小。

如需详细了解不同的核及其作用,请参见此处

在本教程中,我们将实现函数的最简单形式,该函数采用灰度图像(单通道)和奇长方型核,并生成输出图像。该运算不会就地执行。

注意
我们可以暂时存储几个相关像素,以确保在卷积过程中使用原始值,然后就地执行。然而本教程的目的是介绍 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());
// 处理边缘值
// Make 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++)
{
//由于更有效的内存访问导致我们创建一个指针时稍快一些结果。
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);
}
}
}
unsigned char uchar
Definition interface.h:51

首先,我们使输出矩阵(dst)具有与 src 相同的大小,并在 src 图像中添加边框(以处理边缘情况)。

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

然后,我们依次迭代 src 图像中的像素并计算内核和相邻像素值上的值。然后,我们将值填充到 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++)
{
//由于更有效的内存访问导致我们创建一个指针时稍快一些结果。
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 的自定义类,并覆盖virtual 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);
}
}
};
#define CV_OVERRIDE
Definition cvdef.h:792

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);


  2. 分割行并针对每一行进行计算

    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 输入
      This program shows how to use the OpenCV parallel_for_ function and
      compares the performance of the sequential and parallel implementations for a
      convolution operation
      Usage:
      ./a.out [image_path -- default lena.jpg]
    
      Sequential Implementation: 0.0953564s
      Parallel Implementation: 0.0246762s
      Parallel Implementation(Row Split): 0.0248722s
    

  • 带有 3x3 内核的 512x512 输入
      This program shows how to use the OpenCV parallel_for_ function and
      compares the performance of the sequential and parallel implementations for a
      convolution operation
      Usage:
      ./a.out [image_path -- default lena.jpg]
    
      Sequential Implementation: 0.0301325s
      Parallel Implementation: 0.0117053s
      Parallel Implementation(Row Split): 0.0117894s
    
    并行实现的性能取决于你的 CPU 类型。例如,在 4 个核 - 8 个线程 CPU 上,运行时间可能比顺序实现快 6 到 7 倍。有许多因素可以解释我们为何没有实现 8 倍加速
    • 创建和管理线程的开销,
    • 并行运行的后台进程,
    • 每个核有 4 个硬件核和 2 个逻辑线程与有 8 个硬件核之间的差异。

在教程中,我们使用了一个水平梯度滤波器(如上方的动画所示),它生成了一张突出显示垂直边缘的图像。

结果图像