給定0-1矩陣,求連通域

圖像處理題目:

注意,一下所有需要寫代碼的題目,不允許使用OpenCV的Mat類。如果圖片內容需要用指針讀取。

 

1 . 給定0-1矩陣,求連通域。(遇到過N次,筆試面試都有,最好做到能徒手hack代碼或者僞代碼。)

    二值圖像分析最重要的方法就是連通區域標記,它是所有二值圖像分析的基礎,它通過對二值圖像中白色像素(目標)的標記,讓每個單獨的連通區域形成一個被標識的塊,進一步的我們就可以獲取這些塊的輪廓、外接矩形、質心、不變矩等幾何參數。

   連通域

在圖像中,最小的單位是像素,每個像素周圍有8個鄰接像素,常見的鄰接關係有2種:4鄰接與8鄰接。

4鄰接一共4個點,即上下左右,如下左圖所示。8鄰接的點一共有8個,包括了對角線位置的點,如下右圖所示。

image        image

如果像素點A與B鄰接,我們稱A與B連通,於是我們不加證明的有如下的結論:

如果A與B連通,B與C連通,則A與C連通。

在視覺上看來,彼此連通的點形成了一個區域,而不連通的點形成了不同的區域。

這樣的一個所有的點彼此連通點構成的集合,我們稱爲一個連通區域。

下面這符圖中,如果考慮4鄰接,則有3個連通區域;如果考慮8鄰接,則有2個連通區域。

(注:圖像是被放大的效果,圖像正方形實際只有4個像素)。

image

連通區域的標記

連通區域標記算法有很多種,有的算法可以一次遍歷圖像完成標記,有的則需要2次或更多次遍歷圖像。

這也就造成了不同的算法時間效率的差別,在這裏我們介紹2種算法。

第一種算法是:

現在matlab中連通區域標記函數bwlabel中使的算法,

 L = bwlabel(BW,n)
    返回一個和BW大小相同的L矩陣,包含了標記了BW中每個連通區域的類別標籤,這些標籤的值爲1、2、num(連通區域的個數)。

n的值爲4或8,表示是按4連通尋找區域,還是8連通尋找,默認爲8。

    4連通或8連通是圖像處理裏的基本感念:而8連通,是說一個像素,如果和其他像素在上、下、左、右、左上角、左下角、右上角或右下角連接着,則認爲他們是聯通的;4連通是指,如果像素的位置在其他像素相鄰的上、下、左或右,則認爲他們是連接着的,連通的,在左上角、左下角、右上角或右下角連接,則不認爲他們連通。請注意「或」字的含義,就是滿足其中一個條件就認爲是連通的。

    [L,num] = bwlabel(BW,n)這裏num返回的就是BW中連通區域的個數。

    通俗的說,這個函數的作用是用來找這個二值圖像中的連通區域的,對於不同的符合條件的連通區域(4連通,8連通)分別用不同的標號加以區別,結果保存在L這個矩陣裏,而num裏保存的是輸入圖像中連通區域的總數。

    舉例說明:

BW =
    1     1     1     0     0     0     0     0
    1     1     1     0     1     1     0     0
    1     1     1     0     1     1     0     0
    1     1     1     0     0     0     1     0
    1     1     1     0     0     0     1     0
    1     1     1     0     0     0     1     0
    1     1     1     0     0     1     1     0
    1     1     1     0     0     0     0     0
    按4連通計算,方形的區域,和翻轉的L形區域,有用是對角連接,不屬於連通,所以分開標記,連通區域個數爲3,就是有3個不同的連接區域。

 L = bwlabel(BW,4)
    結果如下:
    L =
    1     1     1     0     0     0     0     0
    1     1     1     0     2     2     0     0
    1     1     1     0     2     2     0     0
    1     1     1     0     0     0     3     0
    1     1     1     0     0     0     3     0
    1     1     1     0     0     0     3     0
    1     1     1     0     0     3     3     0
    1     1     1     0     0     0     0     0
而8連通標記,它們是連通的:
    [L, num] = bwlabel(BW,8)
    L =
    1     1     1     0     0     0     0     0
    1     1     1     0     2     2     0     0
    1     1     1     0     2     2     0     0
    1     1     1     0     0     0     2     0
    1     1     1     0     0     0     2     0
    1     1     1     0     0     0     2     0
    1     1     1     0     0     2     2     0
    1     1     1     0     0     0     0     0
   這裏
   num =2

第二種算法是:

現在開源庫cvBlob中使用的標記算法,它通過定位連通區域的內外輪廓來標記整個圖像,

這個算法的核心是輪廓的搜索算法,這個算法相比與第一種方法效率上要低一些,但是在連通區域個數在100以內時,兩者幾乎無差別,

當連通區域個數到了103數量級時,上面的算法會比該算法快10倍以上。

基於行程的標記

我們首先給出算法的描述,然後再結合實際圖像來說明算法的步驟。

1,逐行掃描圖像,我們把每一行中連續的白色像素組成一個序列稱爲一個團(run),並記下它的起點start、它的終點end以及它所在的行號。

2,對於除了第一行外的所有行裏的團,如果它與前一行中的所有團都沒有重合區域,則給它一個新的標號;

如果它僅與上一行中一個團有重合區域,則將上一行的那個團的標號賦給它;

如果它與上一行的2個以上的團有重疊區域,則給當前團賦一個相連團的最小標號,並將上一行的這幾個團的標記寫入等價對,說明它們屬於一類。

3,將等價對轉換爲等價序列,每一個序列需要給一相同的標號,因爲它們都是等價的。從1開始,給每個等價序列一個標號。

4,遍歷開始團的標記,查找等價序列,給予它們新的標記。

5,將每個團的標號填入標記圖像中。

6,結束。

我們來結合一個三行的圖像說明,上面的這些操作。

image

第一行,我們得到兩個團:[2,6]和[10,13],同時給它們標記1和2。

第二行,我們又得到兩個團:[6,7]和[9,10],但是它們都和上一行的團有重疊區域,所以用上一行的團標記,即1和2。

第三行,兩個:[2,4]和[7,8]。[2,4]這個團與上一行沒有重疊的團,所以給它一個新的記號爲3;而[2,4]這個團與上一行的兩個團都有重疊,所以給它一個兩者中最小的標號,即1,然後將(1,2)寫入等價對。

全部圖像遍歷結束,我們得到了很多個團的起始座標,終止座標,它們所在的行以及它們的標號。同時我們還得到了一個等價對的列表。

下面我們用C++實現上面的過程,即步驟2,分兩個進行:

1)fillRunVectors函數完成所有團的查找與記錄:

void fillRunVectors(const Mat& bwImage, int& NumberOfRuns, vector<int>& stRun, vector<int>& enRun, vector<int>& rowRun)
{
    for (int i = 0; i < bwImage.rows; i++)
    {
        const uchar* rowData = bwImage.ptr<uchar>(i);

        if (rowData[0] == 255)
        {
            NumberOfRuns++;
            stRun.push_back(0);
            rowRun.push_back(i);
        }
        for (int j = 1; j < bwImage.cols; j++)
        {
            if (rowData[j - 1] == 0 && rowData[j] == 255)
            {
                NumberOfRuns++;
                stRun.push_back(j);
                rowRun.push_back(i);
            }
            else if (rowData[j - 1] == 255 && rowData[j] == 0)
            {
                enRun.push_back(j - 1);
            }
        }
        if (rowData[bwImage.cols - 1])
        {
            enRun.push_back(bwImage.cols - 1);
        }
    }
}

2)firstPass函數完成團的標記與等價對列表的生成。相比之下第二個函數要稍微難理解一些。

void firstPass(vector<int>& stRun, vector<int>& enRun, vector<int>& rowRun, int NumberOfRuns,
    vector<int>& runLabels, vector<pair<int, int>>& equivalences, int offset)
{
    runLabels.assign(NumberOfRuns, 0);
    int idxLabel = 1;
    int curRowIdx = 0;
    int firstRunOnCur = 0;
    int firstRunOnPre = 0;
    int lastRunOnPre = -1;
    for (int i = 0; i < NumberOfRuns; i++)
    {
        if (rowRun[i] != curRowIdx)
        {
            curRowIdx = rowRun[i];
            firstRunOnPre = firstRunOnCur;
            lastRunOnPre = i - 1;
            firstRunOnCur = i;

        }
        for (int j = firstRunOnPre; j <= lastRunOnPre; j++)
        {
            if (stRun[i] <= enRun[j] + offset && enRun[i] >= stRun[j] - offset && rowRun[i] == rowRun[j] + 1)
            {
                if (runLabels[i] == 0) // 沒有被標號過
                    runLabels[i] = runLabels[j];
                else if (runLabels[i] != runLabels[j])// 已經被標號             
                    equivalences.push_back(make_pair(runLabels[i], runLabels[j])); // 保存等價對
            }
        }
        if (runLabels[i] == 0) // 沒有與前一列的任何run重合
        {
            runLabels[i] = idxLabel++;
        }

    }
}

接下來是我們的重點,即等價對的處理,我們需要將它轉化爲若干個等價序列。比如有如下等價對:

(1,2),(1,6),(3,7),(9-3),(8,1),(8,10),(11,5),(11,8),(11,12),(11,13),(11,14),(15,11)

我們需要得到最終序列是:

1-2-5-6-8-10-11-12-13-14-15

3-7-9

4

一個思路是將1-15個點都看成圖的結點,而等價對(1,2)說明結點1與結點2之間有通路,而且形成的圖是無向圖,即(1,2)其實包含了(2,1)。我們需要遍歷圖,找出其中的所有連通圖。所以我們採用了圖像深入優先遍歷的原理,進行等價序列的查找。

從結點1開始,它有3個路徑1-2,1-6,1-8。2和6後面都沒有路徑,8有2條路徑通往10和11,而10沒有後續路徑,11則有5條路徑通往5,12,13,14,15。等價表1查找完畢。

第2條等價表從3開始,則只有2條路徑通向7和9,7和9後面無路徑,等價表2查找完畢。

最後只剩下4,它沒有在等價對裏出現過,所以單兒形成一個序列(這裏假設步驟2中團的最大標號爲15)。

image    image    image

下面是這個過程的C++實現,每個等價表用一個vector<int>來保存,等價對列表保存在map<pair<int,int>>裏。

void replaceSameLabel(vector<int>& runLabels, vector<pair<int, int>>&
    equivalence)
{
    int maxLabel = *max_element(runLabels.begin(), runLabels.end());
    vector<vector<bool>> eqTab(maxLabel, vector<bool>(maxLabel, false));
    vector<pair<int, int>>::iterator vecPairIt = equivalence.begin();
    while (vecPairIt != equivalence.end())
    {
        eqTab[vecPairIt->first - 1][vecPairIt->second - 1] = true;
        eqTab[vecPairIt->second - 1][vecPairIt->first - 1] = true;
        vecPairIt++;
    }
    vector<int> labelFlag(maxLabel, 0);
    vector<vector<int>> equaList;
    vector<int> tempList;
    cout << maxLabel << endl;
    for (int i = 1; i <= maxLabel; i++)
    {
        if (labelFlag[i - 1])
        {
            continue;
        }
        labelFlag[i - 1] = equaList.size() + 1;
        tempList.push_back(i);
        for (vector<int>::size_type j = 0; j < tempList.size(); j++)
        {
            for (vector<bool>::size_type k = 0; k != eqTab[tempList[j] - 1].size(); k++)
            {
                if (eqTab[tempList[j] - 1][k] && !labelFlag[k])
                {
                    tempList.push_back(k + 1);
                    labelFlag[k] = equaList.size() + 1;
                }
            }
        }
        equaList.push_back(tempList);
        tempList.clear();
    }
    cout << equaList.size() << endl;
    for (vector<int>::size_type i = 0; i != runLabels.size(); i++)
    {
        runLabels[i] = labelFlag[runLabels[i] - 1];
    }
}

基於輪廓的標記

在這裏我還是先給出算法描述:

1,從上至下,從左至右依次遍歷圖像。

2,如下圖A所示,A爲遇到一個外輪廓點(其實上遍歷過程中第一個遇到的白點即爲外輪廓點),且沒有被標記過,則給A一個新的標記號。我們從A點出發,按照一定的規則(這個規則後面詳細介紹)將A所在的外輪廓點全部跟蹤到,然後回到A點,並將路徑上的點全部標記爲A的標號。

3,如下圖B所示,如果遇到已經標記過的外輪廓點,則從向右,將它右邊的點都標記爲的標號,直到遇到黑色像素爲止。

4,如下圖C所示,如果遇到了一個已經被標記的點B,且是內輪廓的點(它的正下方像素爲黑色像素且不在外輪廓上),則從B點開始,跟蹤內輪廓,路徑上的點都設置爲B的標號,因爲B已經被標記過與A相同,所以內輪廓與外輪廓將標記相同的標號。

5,如下圖D所示,如果遍歷到內輪廓上的點,則也是用輪廓的標號去標記它右側的點,直到遇到黑色像素爲止。

6,結束。

 

image

整個算法步驟,我們只掃描了一次圖像,同時我們對圖像中的像素進行標記,要麼賦予一個新的標號,要麼用它同行的左邊的標號去標記它,下面是算法更詳細的描述:

對於一個需要標記的圖像,我們定義一個與它對應的標記圖像,用來保存標記信息,開始我們把L上的所有值設置爲0,同時我們有一個標籤變量,初始化爲1。然後我們開始掃描圖像I,遇到白色像素時,設這個點爲點,我們需要按下面不同情況進行不同的處理:

情況1:如果點是一個白色像素,在圖像上這個位置沒有被標記過,而且點的上方爲黑色,則P是一個新的外輪廓的點,這時候我們將C的標籤值標記給L圖像上P點的位置,即,接着我們沿着P點開始做輪廓跟蹤,並把把輪廓上的點對應的L上都標記爲C,完成整個輪廓的搜索與標記後,回到了P點。最後不要忘了把C的值加1。這個過程如下面圖像S1中所示。

image

 

情況2:如果P點的下方的點是unmarked點(什麼是unmark點,情況3介紹完就會給出定義),則P點一定是內輪廓上的點,這時候有兩種情況,一種是P點在L上已經被標記過了,說明這個點同時也是外輪廓上的點;另一種情況是P點在L上還沒有被標記過,那如果是按上面步驟來的,P點左邊的點一定被標記了(這一處剛開始理解可能不容易,不妨畫一個簡單的圖,自己試着一個點一個點標記試試,就容易理解了),所以這時候我們採用P點左邊點的標記值來標記P,接着從P點開始跟蹤內輪廓把內輪廓上的點都標記爲P的標號。

下面圖像顯示了上面分析的兩種P的情況,左圖的P點既是外輪廓上的點也是內輪廓上的點。

image    image

情況3:如果一個點P,不是上面兩種情況之一,那麼P點的左邊一定被標記過(不理解,就手動去標記上面兩幅圖像),我們只需要用它左邊的標號去標記L上的P點。

現在我們只剩下一個問題了,就是什麼是unmarked點,我們知道內輪廓點開始點P的下方一定是一個黑色像素,是不是黑色像素就是unmarked點呢,顯然不是,如下圖像的Q點,它的下面也是黑色像素,然而它卻不是內輪廓上的點。

實際上在我們在輪廓跟蹤時,我們我輪廓點的周圍做了標記,在輪廓點周圍被查找過的點(查找方式見下面的輪廓跟蹤算法)在L上被標記了一個負值(如下面右圖所示),所以Q點的下方被標記爲了負值,這樣Q的下方就不是一個unmarked點,unmarked點,即在L上的標號沒有被修改過,即爲0。

image      image

顯然,這個算法的重點在於輪廓的查找與標記,而對於輪廓的查找,就是確定搜索策略的問題,我們下面給內輪廓與外輪廓定義tracker規則。

我們對一點像素點周圍的8個點分析作一個標號0-7,因爲我們在遍歷圖像中第一個遇到的點肯定是外輪廓點,所以我們先來確定外輪廓的搜索策略,對於外輪廓的點P,有兩種情況:

image

1)如果P是外輪廓的起點,也就是說我們是從P點開始跟蹤的,那麼我們從7號(右上角)位置開始,看7號是不是白色點,如果是,則把這個點加入外輪廓點中,並將它標記與P點相同,如果7號點是黑色點,則按順時針7-0-1-2-3-4-5-6這個順序搜索直到遇到白點爲止,把那個點確定爲,加入外輪廓,並把這個點的標號設置與P點相同。這裏很重要一步就是,假設我們2號點纔是白點,那麼7,0,1這三個位置我們都搜索過,所以我們要把這些點在L上標記爲一個負值。如下圖所示,其中右圖像標記的結果。

image    image

2)那麼如果P是不是外輪廓的起點,即P是外輪廓路徑上的一個點,那麼它肯定是由一個點進入的,我們設置爲點,點的位置爲,那麼P點從這個位置開始尋找下一步的路徑,是加2取模的意思,它反映在圖像就是從P-1點按順時針數2個格子的位置。確定搜索起點後,按照上面一種情況進行下面的步驟。

外輪廓點的跟蹤方式確定了後,內輪廓點的跟蹤方式大同小異,只是如果P是內輪廓的第一個點,則它的開始搜索位置不是7號點而是3號點。其他的與外輪廓完全一致。

如要上面搜索方式,你不是很直觀的理解,不妨嘗試着去搜索下面這幅圖像,你應該有能有明確的瞭解了。一個路徑搜索結束的條件是,回到原始點S,則S周圍不存在unmarked點。

如下邊中間圖像所示,從S點開始形成的路徑是STUTSVWV。

   image 

在OpenCV中查找輪廓的函數已經存在了,而且可以得到輪廓之間的層次關係。這個函數按上面的算法實現起來並不困難,所以這裏就不再實現這個函數,有興趣的可以看OpenCV的源碼(contours.cpp)。

void bwLabel(const Mat& imgBw, Mat & imgLabeled)
{
    // 對圖像周圍擴充一格
    Mat imgClone = Mat(imgBw.rows + 1, imgBw.cols + 1, imgBw.type(), Scalar(0));
    imgBw.copyTo(imgClone(Rect(1, 1, imgBw.cols, imgBw.rows)));

    imgLabeled.create(imgClone.size(), imgClone.type());
    imgLabeled.setTo(Scalar::all(0));

    vector<vector<Point>> contours;
    vector<Vec4i> hierarchy;
    findContours(imgClone, contours, hierarchy, CV_RETR_CCOMP, CV_CHAIN_APPROX_NONE);

    vector<int> contoursLabel(contours.size(), 0);
    int numlab = 1;
    // 標記外圍輪廓
    for (vector<vector<Point>>::size_type i = 0; i < contours.size(); i++)
    {
        if (hierarchy[i][3] >= 0) // 有父輪廓
        {
            continue;
        }
        for (vector<Point>::size_type k = 0; k != contours[i].size(); k++)
        {
            imgLabeled.at<uchar>(contours[i][k].y, contours[i][k].x) = numlab;
        }
        contoursLabel[i] = numlab++;
    }
    // 標記內輪廓
    for (vector<vector<Point>>::size_type i = 0; i < contours.size(); i++)
    {
        if (hierarchy[i][3] < 0)
        {
            continue;
        }
        for (vector<Point>::size_type k = 0; k != contours[i].size(); k++)
        {
            imgLabeled.at<uchar>(contours[i][k].y, contours[i][k].x) = contoursLabel[hierarchy[i][3]];
        }
    }
    // 非輪廓像素的標記
    for (int i = 0; i < imgLabeled.rows; i++)
    {
        for (int j = 0; j < imgLabeled.cols; j++)
        {
            if (imgClone.at<uchar>(i, j) != 0 && imgLabeled.at<uchar>(i, j) == 0)
            {
                imgLabeled.at<uchar>(i, j) = imgLabeled.at<uchar>(i, j - 1);
            }
        }
    }
    imgLabeled = imgLabeled(Rect(1, 1, imgBw.cols, imgBw.rows)).clone(); // 將邊界裁剪掉1像素
}