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 個硬體核心之間的差異。

在本教程中,我們使用了水平梯度濾波器(如上動畫所示),它會生成一個突出垂直邊緣的影像。

結果影像