OpenCV
开源计算机视觉
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Properties Friends Macros Modules Pages
如何使用OpenCV parallel_for_ 并行化你的代码

上一篇教程: 使用XML/YAML/JSON文件进行文件输入和输出
下一篇教程: 使用通用内联函数向量化你的代码

兼容性OpenCV >= 3.0

目标

本教程的目标是演示如何使用OpenCV parallel_for_ 框架轻松地并行化你的代码。为了说明这个概念,我们将编写一个程序来对图像执行卷积运算。完整的教程代码 在此

前提条件

并行框架

第一个前提条件是OpenCV需要使用并行框架构建。在OpenCV 4.5中,按以下顺序提供以下并行框架:

  • Intel Threading Building Blocks (第三方库,应显式启用)
  • OpenMP (集成到编译器中,应显式启用)
  • APPLE GCD (系统范围,自动使用(仅限APPLE))
  • Windows RT 并发 (系统范围,自动使用(仅限Windows RT))
  • Windows 并发 (运行时的一部分,自动使用(仅限Windows - MSVC++ >= 10))
  • Pthreads

如你所见,OpenCV库可以使用多种并行框架。一些并行库是第三方库,必须在构建之前在CMake中显式启用,而其他库则随平台自动提供(例如APPLE GCD)。

竞争条件

当多个线程尝试同时写入读取和写入特定内存位置时,就会发生竞争条件。基于此,我们可以将算法大致分为两类:

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

卷积

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

理论

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

有关不同内核及其功能的更多信息,请查看 此处

在本教程中,我们将实现该函数的最简单形式,该函数采用灰度图像(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());
// 处理边缘值
// 设置边界 = 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);
}
}
}

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

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

然后,我们顺序迭代src图像中的像素,并计算内核和相邻像素值的value。然后,我们将value填充到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);
}
}
};

operator ()中的范围表示将由单个线程处理的值的子集。根据需求,可能存在不同的范围分割方式,这反过来会改变计算。

例如,我们可以:

  1. 分割图像的整个遍历,并通过以下方式获取[行,列]坐标(如上代码所示)

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

结果

两种实现在一台

  • 512x512 输入5x5 卷积核
    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


  • 512x512 输入和 3x3 卷积核
    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 个硬件核心之间的区别。

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

结果图像