OpenCV 4.12.0
开源计算机视觉
加载中...
搜索中...
无匹配项
如何使用 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());
// Taking care of edge values
// 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++)
{
// slightly faster results when we create a ptr due to more efficient memory access.
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),并为源图像添加边界(以处理边缘情况)。

int rows = src.rows, cols = src.cols;
dst = Mat(rows, cols, src.type());
// Taking care of edge values
// Make 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++)
{
// slightly faster results when we create a ptr due to more efficient memory access.
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 () 中的 range 代表将由单个线程处理的值子集。根据要求,可能存在不同的拆分 range 的方式,这反过来会改变计算。

例如,我们可以选择

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

    在这种情况下,我们使用不同的 range 调用 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 个硬件核心之间的差异。

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

结果图像