OpenCV 4.12.0
開源計算機視覺
載入中...
搜尋中...
無匹配項
如何使用OpenCV掃描影像、查詢表和進行時間測量

上一個教程: Mat - 基本影像容器
下一個教程: 矩陣上的掩膜操作

原始作者Bernát Gábor
相容性OpenCV >= 3.0

目標

我們將尋找以下問題的答案

  • 如何遍歷影像的每個畫素?
  • OpenCV矩陣值是如何儲存的?
  • 如何衡量演算法的效能?
  • 什麼是查詢表,為什麼要使用它們?

我們的測試用例

讓我們考慮一種簡單的顏色縮減方法。透過使用 C 和 C++ 中的無符號字元(unsigned char)型別來儲存矩陣項,一個畫素通道可以有多達 256 個不同的值。對於一個三通道影像來說,這可以形成過多的顏色(精確地說,是 1600 萬種)。處理如此多的顏色色調可能會嚴重影響演算法效能。然而,有時只需處理少得多的顏色就能獲得相同的最終結果。

在這種情況下,通常我們會進行顏色空間縮減。這意味著我們將顏色空間的當前值除以一個新的輸入值,以得到更少的顏色。例如,所有介於零到九之間的值都取新值零,所有介於十到十九之間的值都取新值十,依此類推。

當你將一個 uchar(無符號字元 - 即介於零到 255 之間的值)值除以一個 int 值時,結果也將是 char。這些值只能是字元值。因此,任何小數部分都將被向下取整。利用這一事實,在 uchar 域中的上述操作可以表示為

\[I_{new} = (\frac{I_{old}}{10}) * 10\]

一個簡單的顏色空間縮減演算法將只包括遍歷影像矩陣的每個畫素並應用此公式。值得注意的是,我們執行了除法和乘法運算。這些操作對系統來說代價非常高。如果可能,最好透過使用更便宜的操作來避免它們,例如幾次減法、加法,或者在最好的情況下,一個簡單的賦值。此外,請注意,上述操作的輸入值數量有限。對於 uchar 系統來說,精確地說是 256 個。

因此,對於更大的影像,明智的做法是預先計算所有可能的值,並在賦值時直接使用查詢表進行賦值。查詢表是簡單的陣列(具有一個或多個維度),對於給定的輸入值變化,它儲存最終的輸出值。它的優點是我們不需要進行計算,只需讀取結果即可。

我們的測試用例程式(以及下面的程式碼示例)將執行以下操作:讀取作為命令列引數傳遞的影像(可以是彩色或灰度影像),並使用給定的命令列引數整數值應用縮減。在 OpenCV 中,目前有三種主要的方式逐畫素遍歷影像。為了使事情更有趣,我們將使用這些方法中的每一種來掃描影像,並打印出所花費的時間。

你可以在此處下載完整的原始碼,或在 OpenCV 的 samples 目錄中核心部分的 cpp 教程程式碼中查詢。其基本用法是

how_to_scan_images imageName.jpg intValueToReduce [G]

最後一個引數是可選的。如果提供,影像將以灰度格式載入,否則使用 BGR 顏色空間。第一件事是計算查詢表。

int divideWith = 0; // convert our input string to number - C++ style
stringstream s;
s << argv[2];
s >> divideWith;
if (!s || !divideWith)
{
cout << "Invalid number entered for dividing. " << endl;
return -1;
}
uchar table[256];
for (int i = 0; i < 256; ++i)
table[i] = (uchar)(divideWith * (i/divideWith));

在這裡,我們首先使用 C++ stringstream 類將第三個命令列引數從文字轉換為整數格式。然後我們使用一個簡單的查詢和上述公式來計算查詢表。這裡沒有 OpenCV 特定的內容。

另一個問題是如何測量時間?OpenCV 提供了兩個簡單的函式來實現這一點:cv::getTickCount()cv::getTickFrequency()。前者返回自某個事件(例如系統啟動)以來系統 CPU 的時鐘週期數。後者返回你的 CPU 每秒發出多少個時鐘週期。因此,測量兩個操作之間經過的時間就像這樣簡單:

double t = (double)getTickCount();
// 執行某些操作 ...
t = ((double)getTickCount() - t)/getTickFrequency();
cout << "Times passed in seconds: " << t << endl;

影像矩陣是如何儲存在記憶體中的?

正如你可以在我的Mat - 基本影像容器教程中讀到,矩陣的大小取決於所使用的顏色系統。更準確地說,它取決於使用的通道數量。在灰度影像的情況下,我們有類似如下結構:

對於多通道影像,列中包含的子列數量與通道數量相同。例如,在 BGR 顏色系統中

請注意,通道的順序是反向的:BGR 而不是 RGB。由於在許多情況下記憶體足夠大,可以連續儲存行,所以行可以一個接一個地排列,形成一個單一的長行。因為所有內容都連續地儲存在一個地方,這有助於加速掃描過程。我們可以使用 cv::Mat::isContinuous() 函式來查詢矩陣是否是這種情況。繼續閱讀下一節以找到示例。

高效方式

談到效能,你無法超越經典的 C 風格 `operator[]`(指標)訪問。因此,我們推薦的最有效的賦值方法是

Mat& ScanImageAndReduceC(Mat& I, const uchar* const table)
{
// 只接受 char 型別矩陣
int channels = I.channels();
int nRows = I.rows;
int nCols = I.cols * channels;
if (I.isContinuous())
{
nCols *= nRows;
nRows = 1;
}
int i,j;
uchar* p;
for( i = 0; i < nRows; ++i)
{
p = I.ptr<uchar>(i);
for ( j = 0; j < nCols; ++j)
{
p[j] = table[p[j]];
}
}
return I;
}

這裡我們基本上只是獲取指向每行開頭的指標,然後遍歷直到行結束。在矩陣以連續方式儲存的特殊情況下,我們只需請求一次指標並一直遍歷到末尾。我們需要注意彩色影像:我們有三個通道,因此每行需要遍歷三倍的專案。

還有另一種方法。Mat 物件的 data 資料成員返回指向第一行、第一列的指標。如果此指標為空,則該物件中沒有有效輸入。檢查此項是檢查影像載入是否成功的簡單方法。如果儲存是連續的,我們可以使用它來遍歷整個資料指標。在灰度影像的情況下,它看起來像這樣:

uchar* p = I.data;
for( unsigned int i = 0; i < ncol*nrows; ++i)
*p++ = table[*p];
I.at<uchar>(y, x) = saturate_cast<uchar>(r);
uchar
unsigned char uchar

你會得到相同的結果。然而,這段程式碼在後續閱讀時會困難得多。如果你在那裡有一些更高階的技術,它會變得更難。此外,在實踐中我觀察到你會獲得相同的效能結果(因為大多數現代編譯器可能會自動為你進行這種小的最佳化技巧)。

迭代器(安全)方法

在高效方法中,確保你遍歷了正確數量的 uchar 欄位並跳過行之間可能出現的間隙是你的責任。迭代器方法被認為是一種更安全的方式,因為它從使用者那裡接管了這些任務。你所需要做的就是詢問影像矩陣的開始和結束位置,然後只需增加開始迭代器直到達到結束。要獲取迭代器指向的值,請使用 `*` 運算子(將其加在迭代器前面)。

Mat& ScanImageAndReduceIterator(Mat& I, const uchar* const table)
{
// 只接受 char 型別矩陣
const int channels = I.channels();
switch(channels)
{
case 1:
{
for( it = I.begin<uchar>(), end = I.end<uchar>(); it != end; ++it)
*it = table[*it];
break;
}
case 3:
{
for( it = I.begin<Vec3b>(), end = I.end<Vec3b>(); it != end; ++it)
{
(*it)[0] = table[(*it)[0]];
(*it)[1] = table[(*it)[1]];
(*it)[2] = table[(*it)[2]];
}
}
}
return I;
}

對於彩色影像,每列有三個 uchar 項。這可以被視為一個短的 uchar 項向量,在 OpenCV 中被命名為 Vec3b。要訪問第 n 個子列,我們使用簡單的 `operator[]` 訪問。重要的是要記住 OpenCV 迭代器會遍歷列並自動跳到下一行。因此,對於彩色影像,如果你使用一個簡單的 uchar 迭代器,你將只能訪問藍色通道的值。

即時地址計算與引用返回

最後一種方法不建議用於掃描。它旨在獲取或修改影像中的隨機元素。它的基本用法是指定要訪問項的行號和列號。在我們之前的掃描方法中,你可能已經注意到透過什麼型別檢視影像很重要。在這裡也沒有什麼不同,因為你需要手動指定在自動查詢時使用什麼型別。你可以在灰度影像的以下原始碼中觀察到這一點(使用 + cv::Mat::at() 函式的用法)

Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* const table)
{
// 只接受 char 型別矩陣
const int channels = I.channels();
switch(channels)
{
case 1:
{
for( int i = 0; i < I.rows; ++i)
for( int j = 0; j < I.cols; ++j )
I.at<uchar>(i,j) = table[I.at<uchar>(i,j)];
break;
}
case 3:
{
Mat_<Vec3b> _I = I;
for( int i = 0; i < I.rows; ++i)
for( int j = 0; j < I.cols; ++j )
{
_I(i,j)[0] = table[_I(i,j)[0]];
_I(i,j)[1] = table[_I(i,j)[1]];
_I(i,j)[2] = table[_I(i,j)[2]];
}
I = _I;
break;
}
}
return I;
}
MatIterator_< _Tp > end()
返回矩陣迭代器並將其設定為矩陣的最後一個元素之後的位置。
MatIterator_< _Tp > begin()
返回矩陣迭代器並將其設定為矩陣的第一個元素。

該函式接收你的輸入型別和座標,然後計算查詢項的地址。接著返回對其的引用。當你獲取值時,這可能是一個常量引用;當你設定值時,它可能是一個非常量引用。作為安全措施,僅在除錯模式下*會進行檢查,以確保你的輸入座標有效且存在。如果不是這種情況,你會在標準錯誤輸出流上收到友好的提示訊息。與釋出模式下的高效方法相比,使用此方法的唯一區別在於,對於影像的每個元素,你都會獲得一個新的行指標,我們用它來透過 C 語言的 `operator[]` 獲取列元素。

如果你需要對影像使用此方法進行多次查詢,那麼每次訪問都輸入型別和 `at` 關鍵字可能會很麻煩且耗時。為了解決這個問題,OpenCV 提供了一種 cv::Mat_ 資料型別。它與 Mat 相同,但定義時需要額外指定檢視資料矩陣的資料型別,作為回報,你可以使用 `operator()` 快速訪問元素。更棒的是,它可以輕鬆地與常規的 cv::Mat 資料型別互相轉換。你可以在上述函式的彩色影像示例中看到它的用法。然而,重要的是要注意,相同的操作(具有相同的執行時速度)也可以透過 cv::Mat::at 函式完成。這只是一個為懶惰程式設計師準備的減少編寫程式碼的小技巧。

核心函式

這是在影像中實現查詢表修改的一種額外方法。在影像處理中,你經常需要將給定影像的所有值修改為其他值。OpenCV 提供了一個用於修改影像值的函式,無需編寫影像的掃描邏輯。我們使用核心模組的 cv::LUT() 函式。首先我們構建一個查詢表的 Mat 型別

Mat lookUpTable(1, 256, CV_8U);
uchar* p = lookUpTable.ptr();
for( int i = 0; i < 256; ++i)
p[i] = table[i];

最後呼叫函式(I 是我們的輸入影像,J 是輸出影像)

LUT(I, lookUpTable, J);

效能差異

為了獲得最佳結果,請自行編譯並執行該程式。為了使差異更明顯,我使用了一張相當大(2560 X 1600)的影像。這裡顯示的效能是針對彩色影像的。為了獲得更準確的值,我對函式呼叫一百次得到的值進行了平均。

方法時間
高效方式79.4717 毫秒
迭代器83.7201 毫秒
即時隨機訪問93.7878 毫秒
LUT 函式32.5759 毫秒

我們可以得出幾點結論。如果可能,請使用 OpenCV 已有的函式(而不是重新發明輪子)。最快的方法是 LUT 函式。這是因為 OpenCV 庫透過 Intel Threaded Building Blocks 啟用了多執行緒。然而,如果你需要編寫簡單的影像掃描,請優先選擇指標方法。迭代器是一種更安全的選擇,但速度相當慢。在除錯模式下,使用即時引用訪問方法進行完整影像掃描的成本最高。在釋出模式下,它可能擊敗或未能擊敗迭代器方法,但它肯定為此犧牲了迭代器的安全性特性。

最後,你可以在我們 YouTube 頻道上釋出的影片中觀看該程式的示例執行。