Gra sterowana kamerą - pomaluj płot

19.05.2013 - Filip Mróz
TrudnośćTrudność

Przetwarzanie Obrazów

Nareszcie dotarliśmy do sedna gry, czyli znajdowania pędzla w obrazie.

Gotowy kod z tej części można znaleźć w paczce ze źródłami w katalogu: Wersja_2_Pedzel

Ustaliliśmy już, że będziemy szukać pędzla na podstawie wcześniej wybranego koloru, więc należy zadbać o dobre oświetlenie i wyróżniający się kolor. Cały proces ustalania położenia pędzla możemy podzielić na trzy etapy:

Dodajmy potrzebne deklaracje do PrzetwarzanieObrazow:

using namespace cv;
typedef vector<Point> Kontur;
 
class PrzetwarzanieObrazow {
protected:
    static const int WARTOSC_GRANICZNA = 150;
public:
    int wartoscGraniczna;
    Vec3b kolor;
 
    PrzetwarzanieObrazow();
    Point polozeniePedzla(Mat obraz);
 
    Mat wybierzPiksele(Mat obraz);
    Kontur filtruj(Mat wybrane);
    Point wyznaczPolozenie(Kontur kontur);
};
PrzetwarzanieObrazow.h

Widzimy tu deklaracje naszych trzech kroków, koloru pędzla oraz wartości granicznej, określającej wymagane podobieństwo pikseli do pędzla. Do trzymania wyznaczonych konturów pędzla wykorzystamy listę punktów.

Zaimplementujmy teraz konstruktor i zmodyfikujmy polozeniePedzla:

PrzetwarzanieObrazow::PrzetwarzanieObrazow() {
    wartoscGraniczna = WARTOSC_GRANICZNA;
    kolor = Vec3b(0,0,255);
}
 
Point PrzetwarzanieObrazow::polozeniePedzla (Mat obraz) {
    Mat piksele = wybierzPiksele(obraz);
    Kontur wybranyKontur = filtruj(piksele);
    return wyznaczPolozenie(wybranyKontur);
}
PrzetwarzanieObrazow.cpp

Dodajmy jeszcze sztuczne implementacje kroków, aby nie trzeba było czekać z kompilacją i uruchomieniem aż do ostatniego kroku:

Mat PrzetwarzanieObrazow::wybierzPiksele(Mat obraz) {
    return Mat();
}
Kontur PrzetwarzanieObrazow::filtruj(Mat wybrane) {
    return Kontur();
}
Point PrzetwarzanieObrazow::wyznaczPolozenie(Kontur kontur) {
    return Point(100,100);
}
PrzetwarzanieObrazow.cpp

Przejdźmy do implementacji poszczególnych kroków.

Znajdź w obrazie podobne piksele

Będziemy chcieli w prosty sposób zdecydować, niezależnie dla każdego piksela, czy należy on do pędzla, czy nie. Zrobimy to porównując kolor piksela i pędzla. Do tego celu można wykorzystać różne metryki (np. sumę różnicy na kanałach koloru, maksimum) w kombinacji ze zmianą reprezentacji koloru (np. na HSV czy HSL).

Wykorzystamy prostą sumę różnic na kanałach kolorów, ale w niestandardowej reprezentacji. RGB (i oczywiście BGR) jest bardzo wrażliwy na zmiany jasności. Jest to coś, co będziemy chcieli ograniczyć, ponieważ dla nas ważna jest tylko barwa pędzla, a nie jego chwilowa jasność. Dobrą alternatywą jest przestrzeń HSV, w której jasność jest skupiona w składowej V, więc porównywanie tylko składowej H czy HS może pozwolić na efektywnie zmniejszenie wpływu jasności na nasz test ‘bycia pędzlem’. My pójdziemy bardziej intuicyjną ścieżką, wyliczając ‘barwę’ piksela (nazwijmy ją RGB*) poprzez odpowiednią normalizację wartości składowych. Idea jest bardzo prosta - przeskalujemy wektor RGB tak, by suma składowych wyniosła 255:

$ RGB^*((r, g, b)) = (\frac{r \cdot 255}{s}, \frac{g \cdot 255}{s}, \frac{b \cdot 255}{s}) $ gdzie $ s = r + g + b $

W RGB* nie ma znaczenia jasność, czyli suma składowych, tylko ich proporcje. Mając obraz i kolor w takiej postaci wyliczymy prostą sumę różnicy ich składowych, a następnie porównamy ją z wcześniej ustaloną wartością graniczną.

Implementację zacznijmy od konwersji RGB na RGB*. Zadeklarujmy w tym celu dwie metody: wyliczBarwe wykonującą konwersję jednego piksela oraz konwertujNaBarwe przetwarzającą cały obraz:

class PrzetwarzanieObrazow {
protected:
    static Vec3b wyliczBarwe(Vec3b piksel);
    static void konwertujNaBarwe(Mat &obraz);
$ \dots $
PrzetwarzanieObrazow.h

W implementacji musimy się zabezpieczyć przez dzieleniem przez zero, co może się zdarzyć, gdy piksel jest całkiem czarny:

Vec3b PrzetwarzanieObrazow::wyliczBarwe(Vec3b piksel) {
    int suma = (int)piksel.val[0]+piksel.val[1]+piksel.val[2];
    if(suma!=0) { 
        piksel.val[0] = piksel.val[0] * 255 / suma;
        piksel.val[1] = piksel.val[1] * 255 / suma;
        piksel.val[2] = 255 - piksel.val[0] - piksel.val[1];
    }
    return piksel;
}
 
void PrzetwarzanieObrazow::konwertujNaBarwe(Mat &obraz) {
    for(int i=0;i<obraz.rows;i++) for(int i2=0;i2<obraz.cols;i2++) {
        obraz.at<Vec3b>(i,i2) = wyliczBarwe(obraz.at<Vec3b>(i,i2));
    }
}
PrzetwarzanieObrazow.cpp

Pora zaimplementować wybierzPiksele, zaczniemy od konwersji na RGB*:

Mat PrzetwarzanieObrazow::wybierzPiksele(Mat obraz) {
    Vec3b szukanaBarwa = wyliczBarwe(kolor);
    obraz.copyTo(_wybierzPikseleBarwa);
    konwertujNaBarwe(_wybierzPikseleBarwa);
    imshow("Barwa", _wybierzPikseleBarwa);
PrzetwarzanieObrazow.cpp

Na początku kopiujemy obraz do pomocniczej macierzy _wybierzPikseleBarwa, a następnie przekształcamy wszystkie piksele przy pomocy naszej funkcji wyliczBarwe. Dla celów poglądowych wyświetlimy obraz świata RGB* w osobnym oknie.

W trybie Debug taka konwersja może być dość powolna, jednak włączenie optymalizacji w trybie Release powoduje olbrzymie przyśpieszenie.

Kolejnym krokiem jest wyliczenie dla każdego piksela różnicy między jego wartością a wartością dla pędzla.

    absdiff(_wybierzPikseleBarwa,szukanaBarwa,_wybierzPikseleRoznica);
    _wybierzPikseleWynik = Mat(_wybierzPikseleRoznica.size(),CV_8U);
    for(int i=0;i<_wybierzPikseleWynik.rows;i++)
    for(int i2=0;i2<_wybierzPikseleWynik.cols;i2++) {
        Vec3b piksel = _wybierzPikseleRoznica.at<Vec3b>(i,i2);
        _wybierzPikseleWynik.at<uchar>(i,i2) = saturate_cast<uchar>((int)piksel.val[0]+piksel.val[1]+piksel.val[2]);
    }
PrzetwarzanieObrazow.cpp

Zaczynamy od wyliczenia macierzy wartości bezwzględnych różnic, korzystając z funkcji absdiff oraz pomocniczej macierzy _wybierzPikseleRoznica. Potem sumujemy wartości ze wszystkich składowych, zapisując wyniki w nowej macierzy _wybierzPikseleWynik. Zauważmy, że podczas sumowania musimy pamiętać o potencjalnym przekroczeniu zakresu uchar, zarówno podczas dodawania (rzutując na int), jak i przy przypisaniu poprzez użycie funkcji saturate_cast, który automatycznie przycina wartość.

Ostatni etap to podjęcie decyzji, jaką różnicę między pikselami a pędzlem uznamy za dopuszczalną:

    threshold(_wybierzPikseleWynik,_wybierzPikseleWynik,wartoscGraniczna,255,THRESH_BINARY_INV);
    imshow("Wybrane",_wybierzPikseleWynik);
    return _wybierzPikseleWynik;
}
PrzetwarzanieObrazow.cpp

Metoda threshold porównuje wszystkie piksele do wartoscGraniczna. Dla opcji THRESH_BINARY_INV, jeśli wartości pikseli są większe od wartosciGranicznej, to metoda wpisuje 0, a w przeciwnym przypadku wpisuje 255. Podobnie jak dla obrazu barwy, tu też dobrze będzie zobaczyć jak wygląda wynik, więc wyświetlimy go w oknie "Wybrane".

Używamy paru różnych macierzy pomocniczych, których jeszcze nie zadeklarowaliśmy. Robimy to w ten sposób:

protected:
    $ \dots $
    Mat _wybierzPikseleBarwa;
    Mat _wybierzPikseleRoznica;
    Mat _wybierzPikseleWynik;
public:
PrzetwarzanieObrazow.h

Czemu nie deklarujemy macierzy lokalnie? Ponieważ wtedy w każdym cyklu pamięć byłaby alokowana i zwalniana (prawdopodobnie – bo nigdy nie wiadomo, co zrobi optymalizacja), co mogłoby negatywnie wpłynąć na wydajność gry.

Zobaczmy teraz, jak wygląda świat w barwach RGB*:

Zauważmy, że wpływ jasności (patrz cienie) został zauważalnie zredukowany przy jednoczesnym zachowaniu odcieni kolorów. Wciąż nie jest jednak idealnie (patrz cień dłoni) z paru powodów. Jednym z nich jest to, że domowe źródła światła są zazwyczaj żółte, przez co wpływają nie tylko na jasność obiektów, ale także na ich odcień. Dlatego dobrze jest unikać sytuacji, gdy pędzel wchodzi lub wychodzi z cienia.

Sprawdźmy też jak sprawuje się wybór pikseli:

Wynik w tym przypadku jest zadowalający i dzięki kolejnym etapom uda się wyznaczyć położenie kwiatu (tzn. pędzla).

Trzeba pamiętać, że aktualnie obie wartości: koloru oraz maksymalnej różnicy są ustawione "z palca". Może to sprawić, że wyniki nie będą wyglądać zachwycająco. Wkrótce dodamy możliwość automatycznego ustawiania tych wartości. Do tego czasu trzeba zapewnić dobre warunki (wyrazisty pędzel, oświetlenie), by zrekompensować błędne parametry.

Z binarnym obrazem wyznaczającym piksele potencjalnie należące do pędzla przechodzimy do kolejnego etapu.

Wyznacz największy z potencjalnych pędzli

Nawet w przypadku dobrego ustawienia parametrów, pewna część pikseli będzie błędnie uznana za część pędzla. Aby odfiltrować te wszystkie "śmieci", skorzystamy z założenia, że pędzel jest jednym ciałem, czyli piksele do niego należące tworzą spójny obszar. Możemy także uznać, że będzie to największy z takich obszarów. Dzięki tej operacji uzyskamy pojedynczy obszar, którego środek łatwo będzie wyznaczyć.

Na szczęście nie musimy samodzielnie szukać spójnych kawałków w obrazie – odpowiednie metody dostarcza OpenCV. Dzięki temu znalezienie konturów jest bardzo proste:

Kontur PrzetwarzanieObrazow::filtruj(Mat wybrane) { 
    vector<Kontur> filtrujKontury;
    findContours(wybrane,filtrujKontury,CV_RETR_EXTERNAL,CV_CHAIN_APPROX_SIMPLE);
PrzetwarzanieObrazow.cpp

Metoda zapisze znalezione kontury w filtrujKontury. Kolejny parametr, którym się zajmiemy, to organizacja znalezionych konturów, w tym wypadku chodzi nam tylko o zewnętrzne kontury. Ostatnia wartość mówi poleceniu findContours, żeby zastosował prostą, ośmiokierunkową kompresję (kontur kwadratu składać się będzie z 4 punków).

Otrzymujemy listę konturów, pozostaje wybrać kontur o największej powierzchni. Do wyliczenia rozmiaru konturu możemy skorzystać z contourArea:

    double najwiekszyRozmiar = 0;
    int nrNajwiekszego;
    for(int i=0;i<filtrujKontury.size();i++) {
        double rozmiar = abs(contourArea(filtrujKontury[i]));
        if(rozmiar>najwiekszyRozmiar) {
            najwiekszyRozmiar = rozmiar;
            nrNajwiekszego = i;
        }
    }
    return najwiekszyRozmiar > 0 ? filtrujKontury[nrNajwiekszego] : Kontur();
}
PrzetwarzanieObrazow.cpp

Pozostaje nam ostatni krok, czyli wyliczenie środka tego konturu.

Wyznacz jego środek oraz zweryfikuj

Na początku sprawdźmy, czy wyznaczony kontur może być pędzlem. Sensownym ograniczeniem wydaje się być rozmiar. Można założyć, że pędzel jest zawsze dobrze widoczny, aby nie znajdować go w pojedynczych pikselach (gdy prawdziwy pędzel jest zasłonięty). Implementacja jest krótka:

Point PrzetwarzanieObrazow::wyznaczPolozenie(Kontur kontur) {
    if(kontur.size()==0 || abs(contourArea(kontur)) < MINIMALNY_ROZMIAR_KONTURU) {
        return Point();
    }
    else {
        // Wyznaczenie położenia środka
    }
}
PrzetwarzanieObrazow.cpp

Musimy jeszcze dodać deklarację użytej stałej:

class PrzetwarzanieObrazow {
protected:
    static const int MINIMALNY_ROZMIAR_KONTURU = 100;
    $ \dots $
PrzetwarzanieObrazow.h

Położenie pędzla można rozumieć jako średnią między jego skrajnymi brzegami, jednak lepszą metodą wydaje się wyliczenie i wykorzystanie jego "środka masy". Dzięki temu przypadkowa linia pikseli w jakimś kierunku nie zdoła zaburzyć nam wyniku. W kwestii środka masy na pomoc przychodzi nam OpenCV, dostarczając metodę wyliczającą tak zwane Momenty.

Momenty

Funkcja w skrócie wylicza nam wartości paru pierwszych sum w postaci:

$ M(r_x,r_y) = \sum_x \sum_y I(x,y) \cdot x^{r_x} y^{r_y} $ gdzie $ I(x,y) $ to wartość piksela $ (x,y) $

Przyjrzyjmy się $ M(0,0) $, $ M(1,0) $ i $ M(0,1) $:

$$M(0,0) = \sum_x \sum_y I(x,y) $$
$$M(1,0) = \sum_x \sum_y I(x,y) \cdot x $$
$$M(0,1) = \sum_x \sum_y I(x,y) \cdot y $$

Wynika z tego, że położenie środka masy p można zapisać jako:

$ P=\left( \frac{M(1,0)}{M(0,0)} , \frac{M(0,1)}{M(0,0)} \right) $

Można się zastanowić, w jaki sposób funkcja wylicza te wartości posiadając tylko ich kontury. Wykorzystuje ona do tego Twierdzenie Greena, przez co może dać wynik nieznacznie różniący się od momentów wyliczonych "ręcznie" na uprzednio wypisanym konturze.

Jako że znowu OpenCV robi wszystko za nas, do implementacji pozostaje niewiele:

    $ \dots $
    else {
        Moments momenty = moments(kontur);
        return Point(momenty.m10/momenty.m00,momenty.m01/momenty.m00);
    }
}
PrzetwarzanieObrazow.cpp

Pierwsza wersja programu jest gotowa. Pora ją wypróbować:

Główny mechanizm wydaje się działać, zajmiemy się teraz poprawieniem grywalności.

Problemy z wykrywaniem pędzla

Jeśli pędzla nie udaje się poprawnie znaleźć to można zmienić WARTOSC_GRANICZNA. Najlepiej sprawdzić co widać na pomocniczych oknach ("Barwa", "Wybrane") i tak ustawić WARTOSC_GRANICZNA aby tylko piksele z pędzla znalazły się w oknie "Wybrane". Może się też zdażyć, że otoczenie na to nie pozwala, bo mamy lekko czerwone światło lub ściany. W takiej sytuacji należy sie nie przejmować i przejść do momentu gdzie wprowadzamy automatyczne dopasowywanie koloru pędzla i WARTOSC_GRANICZNA.

5
Twoja ocena: Brak Ocena: 5 (2 ocen)

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com