W poprzednim artykule stworzyliśmy grę sterowaną za pomocą ruchów ciała (a la KINECT). Teraz rozwiniemy ją o mechanizm zużywania się farby. Konsekwencją tego jest "bieg" gracza po kolejne wiadro farby. Wprowadzimy więc nowy etap, w którym gracz biegnie po farbę. Dodatkowo musi uważać, aby wracając z pełnym wiadrem, nie wylać farby po drodze!
W tym artykule zajmiemy się poszerzeniem gry Gra sterowana kamerą - pomaluj płot [4] o nowy element – bieg po farbę. Abstrahując od wizualizacji tego efektu oraz samej mechaniki gry, będziemy musieli rozwinąć nasze metody przetwarzania obrazu o nowe elementy, które pozwolą reagować na nowe ruchy graczy. Mamy dwie nowe sytuacje: bieg gracza oraz wylewanie się farby.
Do wykrycia ruchu gracza wykorzystamy różnicę pomiędzy kolejnymi obrazami z kamery. Dzięki temu wyznaczymy miejsca obrazu, w których jest ruch. Analizując tylko położenie tych miejsc postaramy się ocenić "prędkość" biegu.
Farba będzie się wylewać, gdy wirtualne wiadro będzie przechylone. Wiadro będzie reprezentowane przez podłużny przedmiot o ustalonym kolorze, który będziemy wykrywać podobnie jak pędzel w pierwszym artykule. Na podstawie układu pikseli należących do wiadra wyznaczymy jego nachylenie. W zależności od nachylenia ustalimy prędkość utraty farby z wiadra.
Podobnie jak w pierwszym artykule, wykorzystywane metody są dość proste, ale mimo to wystarczają do uzyskania ciekawych efektów oraz stworzenia wciągającej gry.
Zanim przejdziemy do głównego tematu artykułu, poprawimy pewną niedogodność poprzedniej wersji gry. Chodzi o to, że kolor farby, którą malujemy płot, może być nie dość wyraźny, co utrudnia zorientowanie się, które obszary zostały już pomalowane.
Problemem jest to, że używany kolor jest w naszej wymyślonej przestrzeni RGB*. Oznacza to, że suma jego składowych jest równa tylko 255, przez co sam kolor może być mało wyrazisty:
Taki kolor może w dużym stopniu zlewać się z otoczeniem, dlatego postaramy się go wzmocnić.
Dopiszmy deklarację metodywzmocnijKolor
w PrzetwarzanieObrazow.h:
static Vec3b wzmocnijKolor(Vec3b piksel);
Chcemy zwiększyć sumę składowych, równocześnie zachowując między nimi proporcje. Zrobimy to znajdując taką liczbę, że gdy przemnożymy przez nią składowe, co najmniej jedna z nich będzie bliska 255:
Vec3b PrzetwarzanieObrazow::wzmocnijKolor(Vec3b piksel) { double mnoznik = min(255.0/piksel[0],min(255.0/piksel[1],255.0/piksel[2])); piksel[0] = piksel[0] * mnoznik; piksel[1] = piksel[1] * mnoznik; piksel[2] = piksel[2] * mnoznik; return piksel; }
Sprawdźmy jak po takim "wzmocnieniu" będzie wyglądał kolor, z którym mieliśmy problem:
Efekt jest zadowalający, więc wykorzystamy ten sposób podczas ustawianiu koloru farby w metodzie StanGracza
ustawParametryPedzla
:
PrzetwarzanieObrazow::wyliczDobreParametry(pedzel,kolor,wartoscGraniczna); stanMalowania.kolor = PrzetwarzanieObrazow::wzmocnijKolor(kolor); stanMalowania.przetwarzanieObrazow.kolor = kolor;
Po tej krótkiej dygresji powróćmy do głównego tematu, czyli stworzenia nowego etapu gry.
Rozszerzenie gry o nowy etap jest dość dużą modyfikacją, dlatego warto przed rzuceniem się w wir programowania ustalić, jak będzie on wyglądał i jakie zmiany w konstrukcji gry wymusza. Na początek zastanówmy się, jak chcielibyśmy widzieć rozgrywkę od strony gracza, a następnie co my, jako programiści, musimy w tym celu zrobić.
Naszym zadaniem jest pomalowanie płotu i chcemy to zrobić właśnie teraz. Zaczynamy malowanie, ale dość szybko kończy nam się farba. Ponieważ Castorama, gdzie farby jest ocean, znajduje się na końcu ulicy, musimy się tam przebiec. Wracając z zakupem musimy z kolei uważać, by nie wylać farby z otwartego wiadra (pokrywek brak!), gdyż tracąc ją w ten sposób będziemy zmuszeni wrócić się po nowe wiadro. Po powrocie do płotu malujemy go do momentu, gdy przyniesiona farba się skończy. Wtedy powtarzamy procedurę. Tę mechanikę gry można podsumować schematem:
Od strony programistycznej będziemy mieli kilka poważnych zmian:
StanuGracza
o StanBiegu
, który będzie sterował rozgrywką w czasie biegu gracza. Zauważmy, że nasz nowy stan będzie musiał mieć dostęp do klasy podobnej do PrzetwarzanieObrazow
, ponieważ tak jak StanMalowania
będzie musiał analizować obraz z kamery. Klasa ta będzie musiała zostać poszerzona o nowe elementy pozwalające m.in. na wykrycie biegu gracza.
Chcemy, aby oba stany malowania mogły mieć inne ustawienia (np. żeby kolor pędzla i wiadra mógł się różnić), więc StanGracza
i StanBiegu
będą miały osobne obiekty przetwarzające obrazy. Jeśli chodzi o związek między PrzetwarzanieObrazowBiegu
i PrzetwarzanieObrazow
, to pierwszy będzie dziedziczył po drugim. Dzięki temu ładnie wydzielimy nowe metody, zachowując możliwość korzystania z wcześniejszych funkcji:
StanMalowania
wprowadzimy zużywanie się farby w miarę malowania. Będziemy musieli odpowiednio wizualizować pozostałą ilość farby.
StanGracza
tak, by zarządzał rozgrywką oraz podległymi mu stanami. Chodzi między innymi o:
StanBiegu
, który będzie musiał:
PrzetwarzanieObrazow
do PrzetwarzaniaObrazowBiegu
, dodając takie metody, aby StanBiegu
mógł się na nich oprzeć, czyli:
Lista wydaje się dość długa, jednak zajmując się jedną rzeczą na raz bez problemu sobie ze wszystkim poradzimy.
Całą implementację podzielimy na trzy „skoki”:StanMalowania
, mechanika zmieniania się etapów oraz stworzenie szkieletu StanBiegu
na jej potrzeby.
StanBiegu
.
Mając plan tego, co należy zrobić, weźmy się do roboty.
Wykonajmy pierwszy skok, w którym dodamy mechanizm kończenia się farby i przeplatania się dwóch różnych etapów rozgrywki.
Zajmijmy się zużywaniem się farby podczas malowania. Do implementacji tego efektu w StanMalowania
będziemy potrzebowali informacji o tym, ile farby jeszcze pozostało. Do wizualizacji jej ilości przyda się nam stała określająca szerokość paska farby:
protected: static const int SZEROKOSC_PASKA_FARBY = 40; public: double pozostalaFarba;
Następnie zmienimy metodę uaktualnijStan
tak, by przed pomalowaniem kawałka płotu sprawdzała ona, czy jeszcze została farba:
if(pozostalaFarba>0) { Point polozenie = przetwarzanieObrazow.polozeniePedzla(obraz); if(polozenie.x>0) { double zuzytaFarba;
Chcemy, aby farba zużywała się proporcjonalnie do pomalowanego obszaru. Ustalimy więc ilość zużytej farby w jednym kroku jako ilość "pomalowanych" pikseli:
// Ile już pomalowaliśmy. int ilePomalowanoDotad = cv::countNonZero(_aktualnyStan); // Malujemy. circle(_aktualnyStan,polozenie,ROZMIAR_PEDZLA,255,-1); bitwise_and(_aktualnyStan, _zarysPlotu, _aktualnyStan); // Ile pomalowaliśmy w tym kroku. zuzytaFarba = cv::countNonZero(_aktualnyStan) - ilePomalowanoDotad; pozostalaFarba -= zuzytaFarba; pozostalaFarba = max(0.0,pozostalaFarba); } }
Pozostaje nam poinformowanie gracza o posiadanej ilości farby, co zrobimy w wyswietlStan
. Pokażemy na dole ekranu pasek o takiej powierzchni, jaką gracz może jeszcze pomalować. By zwiększyć czytelność kodu malującego prostokąty dodamy do Uzytki
metodę rysujProstokat
, która namaluje nam prostokąt o podanych wymiarach, umieszczając jego dolny lewy róg w podanym punkcie:
void rysujProstokat(Mat gdzie, Point DLRog, int wysokosc, int dlugosc, Vec3b kolor);
void Uzytki::rysujProstokat(Mat gdzie, Point DLRog, int wysokosc, int dlugosc, Vec3b kolor) { rectangle(gdzie,DLRog,Point(DLRog.x+dlugosc,DLRog.y-wysokosc),(CvScalar)kolor,-1); }
Teraz wykorzystamy tę metodę do wizualizacji ilości pozostałej farby:
gdzie.setTo(kolor,_aktualnyStan); // Niech będzie mały odstęp od lewej strony. int przesuniecie = 10; // Zapewnijmy, że pasek nie wyjedzie poza plansze. int dlugoscPaska = min(gdzie.cols - przesuniecie - 10,(int)(pozostalaFarba / SZEROKOSC_PASKA_FARBY)); Uzytki::rysujProstokat(gdzie,Point(przesuniecie,gdzie.rows - 10),SZEROKOSC_PASKA_FARBY, dlugoscPaska, kolor); } }
StanGry
w lewym dolnym rogu obrazu wyświetla pozostały czas rozgrywki, więc musimy go przemieścić trochę wyżej, aby zrobić miejsce na pasek:
char pozostalyCzas[20]; sprintf(pozostalyCzas,"%.2lf s.", _pozostalyCzasGry); Uzytki::wypiszTekst(gdzie,pozostalyCzas,Point(10,gdzie.rows-55)); }
Na koniec ustawmy początkową ilość farby w konstruktorze StanMalowania
.
pozostalaFarba = 30000;
Część dotycząca zużywania się farby podczas malowania jest już gotowa. Możemy uruchomić program i sprawdzić, czy rzeczywiście działa:
Zmienimy teraz StanGracza
tak, by poprawnie wywoływał metody aktywnego stanu i w razie potrzeby zmieniał aktualny etap z biegu na malowanie i odwrotnie. Zacznijmy od dodania do deklaracji klasy StanBiegu
, znacznika aktualnej akcji oraz konstruktora:
class StanGracza { protected: enum Akcja {MALOWANIE, BIEG}; Akcja aktualnaAkcja; public: string nazwa; StanMalowania stanMalowania; StanBiegu stanBiegu; StanGracza();
Dodajmy jeszcze wstępną deklarację StanBiegu
. Będzie on musiał mieć dostępne pola z aktualnym położeniem gracza, ilością niesionej farby oraz metody do uaktualniania i wyświetlania jego stanu:
class StanBiegu { public: double niesionaFarba; double polozenie; void uaktualnijStan(double czas, Mat obraz); void wyswietlStan(Mat gdzie); };
Wracając do implementacji StanGracza
, ustawimy w jego konstruktorze, że początkowy etap to malowanie (trochę farby ze sobą przynieśliśmy!):
StanGracza::StanGracza() { aktualnaAkcja = MALOWANIE; }
Poprawmy teraz funkcję wyswietlStanGracza
tak, aby wyświetlała stan aktualnej akcji:
void StanGracza::wyswietlStanGracza(Mat gdzie) { if(aktualnaAkcja == BIEG) { stanBiegu.wyswietlStan(gdzie); } else { stanMalowania.wyswietlStan(gdzie); }
Zajmijmy się teraz główną metodą sterującą rozgrywką, czyli uaktualnijStanGracza
. Wykonajmy najpierw aktualną akcję, a następnie sprawdźmy, czy nie jest konieczna jej zmiana. Bieg po farbę kończy się w momencie doniesienia farby do płotu (dodajemy tę farbę do już posiadanej), a malowanie – gdy zabraknie farby:
void StanGracza::uaktualnijStanGracza(double czas, Mat obraz) { if(aktualnaAkcja == BIEG) { stanBiegu.uaktualnijStan(czas,obraz); if(stanBiegu.niesionaFarba != 0 && stanBiegu.polozenie == 0) { stanMalowania.pozostalaFarba += stanBiegu.niesionaFarba; stanBiegu.niesionaFarba = 0; aktualnaAkcja = MALOWANIE; } } else { stanMalowania.uaktualnijStan(czas,obraz); if(stanMalowania.pozostalaFarba == 0) { aktualnaAkcja = BIEG; } } }
Mamy już mechanikę przejść między etapami, pozostaje nam uzupełnić ostatni element, czyli StanBiegu
.
Na razie zaimplementujemy tylko pewien szkielet klasy StanBiegu
bez głównej części, jaką jest interakcja z graczem. Dzięki temu będziemy mogli przetestować kod napisany w tej części. Uzupełnijmy deklarację klasy o kolor farby (używany podczas wyświetlania) oraz kilka stałych opisujących pojemność wiadra, odległość między płotem a farbą oraz maksymalną prędkość gracza:
class StanBiegu { protected: static const int ODLEGLOSC = 100; static const int POJEMNOSC_WIADRA = 30000; static const int MAKSYMALNA_PREDKOSC = 30; public: Vec3b kolorFarby;
Implementację klasy umieścimy w nowym pliku StanBiegu.cpp
i zaczniemy od zmian stanu, czyli uaktualnijStan
. Ponieważ jest to tylko szkielet tej klasy i gracz nie ma żadnego wpływu na przebieg tego etapu, jedyną czynnością będzie przesuwanie gracza w stronę farby, a następnie z powrotem (z pełnym wiadrem):
void StanBiegu::uaktualnijStan(double czas, Mat obraz) { double predkosc = MAKSYMALNA_PREDKOSC; if(niesionaFarba == 0) { polozenie += czas * predkosc; } else { polozenie -= czas * predkosc; } if(polozenie>=ODLEGLOSC) { polozenie=ODLEGLOSC; niesionaFarba = POJEMNOSC_WIADRA; } else if(polozenie <= 0) { polozenie = 0; } }
Przejdźmy do wyświetlenia aktualnego stanu biegu. Na chwilę obecną chcemy tylko pokazać, gdzie jest gracz oraz ile farby niesie. Położenie zaznaczymy na poziomej osi, gdzie lewy kraniec to płot, a prawy – Castorama:
void StanBiegu::wyswietlStan(Mat gdzie) { int lewyKraniec = 10; int prawyKraniec = gdzie.cols - 20; int dlugosc = prawyKraniec - lewyKraniec; // Czarna oś. Uzytki::rysujProstokat(gdzie,Point(lewyKraniec,gdzie.rows - 20),5,dlugosc,0); // Lewy koniec osi w kolorze szarym. Uzytki::rysujProstokat(gdzie,Point(lewyKraniec,gdzie.rows - 15),15,10,Vec3b(120,120,120)); // Prawy koniec osi w kolorze farby. Uzytki::rysujProstokat(gdzie,Point(prawyKraniec,gdzie.rows - 15),15,10,kolorFarby); // Zaznaczenie gracza na osi kolorem niebieskim. int polozenieGracza = lewyKraniec + polozenie * dlugosc / ODLEGLOSC; Uzytki::rysujProstokat(gdzie,Point(polozenieGracza,gdzie.rows - 15),15,10,Vec3b(255,0,0));
Ilość farby w niesionym wiadrze oznaczymy paskiem w kolorze farby. Długość paska będzie proporcjonalna do wypełnienia wiadra i będzie zajmować cały ekran, gdy wiadro będzie pełne.
// Proporcjonalna długość paska (+10 by dorównać paskowi położenia). int dlugoscPaskaFarby = niesionaFarba * (dlugosc + 10) / POJEMNOSC_WIADRA; Uzytki::rysujProstokat(gdzie,Point(lewyKraniec,gdzie.rows - 35),10,dlugoscPaskaFarby,kolorFarby); }
Gra nam się trochę rozrosła, więc musimy zwiększyć długość rozgrywki w main.cpp tak, aby można było poznać dobrze jej wszystkie elementy:
stanGry.rozpocznijGre(50);
StanGracza
:
stanBiegu.kolorFarby = stanMalowania.kolor; }
Możemy uruchomić grę i sprawdzić, jak aktualnie wygląda rozgrywka:
W tej części dodamy element interaktywności do etapu biegu tak, żeby nazwa tego etapu rzeczywiście odzwierciedlała to, co ma się w nim dziać. Jeśli gracz chce, żeby jego postać się poruszała, on sam musi się poruszać.
Zastanówmy się, w jaki sposób możemy wykryć, że gracz biegnie czy w ogóle się rusza. Możemy porównać aktualny obraz z kamery z poprzednim. Następnie na podstawie różnicy tych obrazów możemy z grubsza wyznaczyć, które miejsca zostały "ruszone". Oczywiście, jeśli gracz będzie nosił ubranie koloru tła, metoda ta nie zda egzaminu, ale załóżmy, że tak nie jest. Pomysł w teorii brzmi sensownie, ale sprawdźmy jaki efekt otrzymamy w praktyce. Wyliczając różnicę dwóch kolejnych klatek, a następnie dodając do siebie składowe poszczególnych pikseli, otrzymując czarno-biały obraz:
Pierwszym spostrzeżeniem jest to, że losowe szumy sprawiają, że tło nie jest czarne, a pulsuje małymi, losowymi zmianami. Standardową metodą walki z takimi błędami jest użycie filtru medianowego (o rozmiarze 5x5) (median filter [5]) na wejściowych obrazach. Zobaczmy, jak ta operacja wpływa na wynikowy obraz różnicy:
Szumy zostały zmniejszone oraz, co ważne, wygładzone (brak pojedynczych jasnych pikseli), dzięki czemu łatwiej będzie je później odfiltrować.
Kolejnym spostrzeżeniem związanym z naszą metodą wykrywania ruchu jest to, że jego ślad widać w dwóch miejscach. Dzieje się tak, ponieważ wyliczamy wartość bezwzględną z różnicy. Nie ma więc znaczenia, czy ręka jest w pewnym miejscu w pierwszym obrazie czy w drugim. Dla pewnych zastosowań (np. śledzenia obiektów) taka informacja byłaby niewystarczająca, ponieważ nie znamy kierunku ruchu. W naszym przypadku to nie przeszkadza.
Przyjrzyjmy się śladowi gracza podczas podskoków (które udają bieg):
Na podstawie wysokości powyżej wyliczonego śladu ruchu wyliczymy chwilową prędkość gracza. Dzięki temu zmusimy gracza, by ruszał całym ciałem, od stóp po głowę, a nie tylko rękami.
Mamy już ogólny zamysł, więc możemy przejść do jego implementacji. Na początek stworzymy plik PrzetwarzanieObrazowBiegu.h z deklaracją klasy PrzetwarzanieObrazowBiegu, na którą zrzucimy całą pracę związaną z wyznaczeniem śladu ruchu:
#pragma once #include "PrzetwarzanieObrazow.h" class PrzetwarzanieObrazowBiegu : public PrzetwarzanieObrazow { };
Dla porządku część wyznaczającą prędkość na podstawie podanego obrazu umieścimy w nowej funkcji należącej do StanBiegu
:
#include "PrzetwarzanieObrazowBiegu.h" () class StanBiegu { () PrzetwarzanieObrazowBiegu przetwarzanieObrazow; double wyznaczPredkosc(Mat obraz);
Użyjmy tej funkcji na początku uaktualnijStan
:
void StanBiegu::uaktualnijStan(double czas, Mat obraz) { double predkosc = wyznaczPredkosc(obraz);
Przejdźmy teraz do implementacji metody wyznaczPredkosc
. Zaczniemy od skorzystania z PrzetwarzanieObrazowBiegu
do wyliczenia najmniejszego prostokąta otaczającego ślad ruchu:
double StanBiegu::wyznaczPredkosc(Mat obraz) { Rect prostokatOtaczajacy = przetwarzanieObrazow.wykryjRuch(obraz);
Interesuje nas tylko to, jak gracz przemieszcza się po osi Y, więc wykorzystamy tylko pionową składową prostokąta . Chcemy wymusić na graczu, aby ruszał jednocześnie całym ciałem, dlatego jego prędkość chwilowa będzie proporcjonalna do . Wykorzystamy zwykłą funkcję liniową wyskalowaną tak, aby przy maksymalnym , równym wysokości obrazu , gracz poruszał się z maksymalną możliwą prędkością :
Implementacja powyższych rozważań jest bardzo krótka:
double wysokoscSladu = prostokatOtaczajacy.height; return wysokoscSladu * MAKSYMALNA_PREDKOSC / obraz.rows; }
Czas zająć się najciekawszą częścią implementacji etapu biegu, czyli metodą wykrywającą ruch. Chcemy za jej pomocą wyznaczyć prostokąt otaczający wszystkie "ruszone" piksele. Schemat algorytmu będzie następujący:
Zauważmy, że tak jak w metodzie polozeniePedzla
klasy PrzetwarzanieObrazow
mamy podział na trzy etapy: wyznacz interesujące nas piksele, oczyść je z błędów, interpretuj wyniki.
Dodajmy deklaracje wszystkich metod:
class PrzetwarzanieObrazowBiegu : public PrzetwarzanieObrazow { public: Rect wykryjRuch(Mat obraz); Mat wyznaczSlad(Mat obraz); Mat usunPrzypadkowePiksele(Mat obraz); Rect otoczProstokatem(Mat obraz); };
Implementację tej klasy umieścimy w pliku PrzetwarzanieObrazowBiegu.cpp. Zacznijmy od rozłożenia wykryjRuch
na poszczególne etapy:
#include "PrzetwarzanieObrazowBiegu.h" Rect PrzetwarzanieObrazowBiegu::wykryjRuch(Mat obraz) { Mat slady = wyznaczSlad(obraz); Mat czystySlad = usunPrzypadkowePiksele(slady); return otoczProstokatem(czystySlad); } Mat PrzetwarzanieObrazowBiegu::wyznaczSlad(Mat obraz) { return obraz; } Mat PrzetwarzanieObrazowBiegu::usunPrzypadkowePiksele(Mat obraz) { return obraz; } Rect PrzetwarzanieObrazowBiegu::otoczProstokatem(Mat obraz) { return Rect(); }
Teraz zajmiemy się kolejnymi etapami.
Pierwszy etap to wyznaczenie w obrazie pikseli, które się zmieniły. Zacznijmy od użycia wcześniej wspomnianego filtru medianowego do zmniejszenia szumów w obrazie:
Mat PrzetwarzanieObrazowBiegu::wyznaczSlad(Mat obraz) { obraz.copyTo(_wyznaczSladObraz); medianBlur(_wyznaczSladObraz,_wyznaczSladObraz,5);
Chcemy wyliczyć różnicę między aktualnym a poprzednim obrazem. Najpierw sprawdźmy, czy go mamy zapisany poprzedni obraz:
if(_wyznaczSladPoprzedniObraz.data == NULL) { _wyznaczSladWynik = Mat(obraz.size(),CV_8U,Scalar(0)); } else {
Jeśli nie mamy poprzedniej klatki, to funkcja zwróci pusty obraz, w przeciwnym przypadku wyliczy dla każdego piksela różnicę między jego składowymi, a następnie sumę tych różnić. Jeśli czytałeś poprzedni artykuł, zabrzmi to znajomo. Okazuje się, że napisaliśmy już takie sumowanie składowych w PrzetwarzanieObrazow::wybierzPiksele
, więc możemy zrobić małą refaktoryzację wydzielając metodę, która wyliczy sumę składowych pikseli:
class PrzetwarzanieObrazow { static void sumujSkladowe(Mat &obraz, Mat &wynik); }
void PrzetwarzanieObrazow::sumujSkladowe(Mat &obraz, Mat &wynik) { // Wynik będzie zawierał czarno-biały obraz. wynik = Mat(obraz.size(),CV_8U); for(int i=0;i<wynik.rows;i++) for(int i2=0;i2<wynik.cols;i2++) { Vec3b piksel = obraz.at<Vec3b>(i,i2); // Dodajemy składowe pamiętając o zakresie uchar. wynik.at<uchar>(i,i2) = saturate_cast<uchar> ((int)piksel[0]+piksel[1]+piksel[2]); } }
Zastąpmy kod z PrzetwarzanieObrazow::wybierzPiksele
, który przenieśliśmy do sumujSkladowe
wywołaniem tej funkcji:
absdiff(_wybierzPikseleBarwa,szukanaBarwa,_wybierzPikseleRoznica); sumujSkladowe(_wybierzPikseleRoznica,_wybierzPikseleWynik); threshold(_wybierzPikseleWynik,_wybierzPikseleWynik,wartoscGraniczna,255,THRESH_BINARY_INV);
Wróćmy do implementacji wyznaczSlad
i wykorzystajmy naszą refaktoryzację:
else { absdiff(_wyznaczSladObraz,_wyznaczSladPoprzedniObraz,_wyznaczSladRoznica); sumujSkladowe(_wyznaczSladRoznica,_wyznaczSladWynik);
Otrzymujemy obraz przedstawiający różnice między odpowiadającymi sobie pikselami w dwóch kolejnych obrazach z kamery. Teraz musimy zdecydować o tym, jaka różnica wystarczy, by uznać piksel za "ruszony". Zrobimy to prosto za pomocą stałej wartości granicznej:
// Pikselom o wartości większej niż MINIMALNA_ZMIANA ustawimy wartość 255 (bedą białe) // Całej reszcie - 0 (będą czarne) threshold(_wyznaczSladWynik,_wyznaczSladWynik,MINIMALNA_ZMIANA,255,THRESH_BINARY); }
Na koniec nie zapomnijmy zapisać przefiltrowanej wersji aktualnego obrazu, do wykorzystania w wywołaniu wyznaczSlad
:
_wyznaczSladObraz.copyTo(_wyznaczSladPoprzedniObraz); return _wyznaczSladWynik; }
Pozostaje nam jeszcze dopisać deklaracje wszystkich roboczych macierzy oraz stałej:
protected: static const int MINIMALNA_ZMIANA = 60; Mat _wyznaczSladPoprzedniObraz; Mat _wyznaczSladObraz; Mat _wyznaczSladRoznica; Mat _wyznaczSladWynik;
Zobaczmy teraz, jak sprawuje się wyznaczSlad
na próbnej parze kolejnych obrazów:
Udało nam się dość dobrze wyznaczyć piksele, które się zmieniły między kolejnymi obrazami. Niestety, widać też trochę błędnych pikseli. Z nimi powinniśmy sobie poradzić w kolejnym etapie.
Otrzymaliśmy obraz, który przedstawiał z błędami piksele które się zmieniły. Chcemy się pozbyć błędów zachowując większość dobrych pikseli. Zauważmy, że błędy to zazwyczaj małe grupy pikseli lub wręcz pojedyncze piksele. Do ich usunięcia wykorzystamy operację erozji (patrz erosion [6] oraz Gra sterowana kamerą - pomaluj płot [4]), polegającą na usunięciu pikseli z obrazu, jeśli w jego okolicy jest jakiś czarny piksel. Ponieważ OpenCV dostarcza metodę wykonującą taką operację, implementacja tego etapu jest łatwa. Okolicę piksela wyznaczać będzie koło wpisane w kwadrat 5 x 5:
Mat PrzetwarzanieObrazowBiegu::usunPrzypadkowePiksele(Mat obraz) { erode(obraz,_usunPrzypadkowePikseleWynik,getStructuringElement(MORPH_ELLIPSE,Size(5,5))); return _usunPrzypadkowePikseleWynik; }
Dodajmy deklarację macierzy, do której zapiszemy przefiltrowany obraz:
protected: () Mat _usunPrzypadkowePikseleWynik; public:
Zobaczmy, czy uda nam się usunąć wszystkie błędy z poprzedniego etapu:
Jak widać udało się usunąć wszystkie błędne piksele za cenę niewielkiej ilości dobrych pikseli. Pamiętajmy, że od gracza mamy prawo oczekiwać znacznego ruchu, a do następnego etapu nie powinien przejść ani jeden błędny piksel. Dlatego, jeśli zajdzie taka potrzeba, można wzmocnić ten etap zmieniając parametry metody erode
, czyli zwiększyć rozmiar okolicy piksela lub liczbę iteracji.
Ostatni etap algorytmu znajdzie najmniejszy prostokąt otaczający białe piksele obrazu. Ponieważ wszystkie piksele mają zawierać się w jego wnętrzu, musimy być pewni, że poprzednie etapy skutecznie wyeliminują wszystkie niepoprawne piksele. Algorytm wyznaczający prostokąt jest prosty i polega na znalezienia położenia pierwszego piksela od lewej i prawej strony oraz od góry i od dołu. Napiszemy algorytm może nie optymalnie, ale bardzo zwięźle:
Rect PrzetwarzanieObrazowBiegu::otoczProstokatem(Mat obraz) { // Inicjalizujemy położenie rogów prostokąta tak, by // zostały zastąpione przez pierwszy biały piksel. int lx=obraz.cols,gy=obraz.rows; int px=-1,dy=-1; for(int y=0;y<obraz.rows;y++) for(int x=0;x<obraz.cols;x++) { if(obraz.at<uchar>(y,x)!=0) { lx = min(lx,x); px = max(px,x); gy = min(gy,y); dy = max(dy,y); } }
Musimy pamiętać, że jeśli wszystkie piksele będą czarne, wyliczone wartości nie będą miały sensu. W takim przypadku zwrócimy „zerowy” prostokąt:
if(px == -1) return Rect(); else return Rect(Point(lx,gy),Point(px,dy)); }
Powyższa metoda poprawnie znajduje otaczający prostokąt w obrazie otrzymanym z poprzedniego etapu:
Cała metoda wykryjRuch
działa zaskakująco dobrze:
W ten sposób zakończyliśmy część dotyczącą wykrywania biegu gracza. Oczywiście opiera się ona na bardzo prostych technikach, ale wydaje się spełniać swoją rolę. Co więcej, podobną technikę wykrywania ruchu można zastosować w prostym systemie monitoringu – gdy wykryjemy ruch, zaczyna się nagrywanie oraz wysyłanie informacji o intruzie.
Zaprogramujmy ostatnią "atrakcję" etapu biegu czyli wylewanie się farby.
Element ten sprawi, że gracz, poza biegiem, będzie musiał się koncentrować na trzymaniu wiadra w pozycji poziomej. W przeciwnym wypadku może przynieść do płotu prawie puste wiadro. Może nawet uznać, że bardziej opłaca mu się wylać resztę farby i wrócić po nowe, pełne wiadro.
Nasuwają się pytania: O co chodzi? Jakie wiadro? Jak sprawdzić czy jest przechylone?
Wiadro w naszej grze będzie reprezentowane przez podłużny przedmiot i właśnie zależnie od kąta jego nachylenia, farba będzie znikać albo spokojnie czekać na czas malowania. Do wykrycia wiadra wykorzystamy metody utworzone w poprzednim artykule.
Alternatywą byłoby użycie dwóch przedmiotów i wyznaczenie kąta nachylenia wiadra z kąta nachylenia linii łączącej ich środki. Byłoby to jednak dość nienaturalne i bardziej podatne na błędy (pewniej jest wykryć jeden duży obiekt niż dwa małe). Dlatego zaimplementujemy pierwsze rozwiązanie.
Całość wykonamy podobnie jak w przypadku StanBiegu
. Zaczniemy od wierzchniej warstwy, czyli od wyświetlania stanu oraz usuwania farby z wiadra na podstawie kąta nachylenia. Na końcu zajmiemy się metodami PrzetwarzanieObrazowBiegu
pozwalającymi na wyznaczenie tego kąta.
Zaczniemy implementację od dodania funkcji wyznaczSzybkoscUtratyFarby
do StanBiegu
, która wyznaczy, na podstawie podanego obrazu, ile farby zostanie wylane w jednej sekundzie. Do wyświetlania będziemy potrzebowali aktualny kąt nachylenia wiadra. Zadeklarujemy w tym celu zmienną:
class StanBiegu { () public: double nachylenieWiadra; double wyznaczSzybkoscUtratyFarby(Mat obraz);
double StanBiegu::wyznaczSzybkoscUtratyFarby(Mat obraz) { return 0; }
Niech w uaktualnijStan
farba wylewa się z wiadra z prędkością wyliczoną przez wyznaczSzybkoscUtratyFarby
:
else { polozenie -= czas * predkosc; niesionaFarba -= czas * wyznaczSzybkoscUtratyFarby(obraz); niesionaFarba = max(niesionaFarba,0.0); }
Prędkość wypływu farby będzie zależeć tylko od przechylenia wiadra, którego wyznaczenie zrzucimy na PrzetwarzanieObrazowBiegu
. Załóżmy, że zostanie zwrócony kąt liczony od lewej strony zgodnie z ruchem wskazówek zegara. Kąt odchylenia wiadra od poziomu będzie wtedy wynosił:
Gracz powinien mieć szansę doniesienia pełnego wiadra, więc będziemy go karać dopiero wtedy, gdy kąt odchylenia przekroczy . Dodatkowo, przy maksymalnym kącie (prostym), pełne wiadro opróżni się w sekundę. Powstała funkcja prezentuje się następująco:
double StanBiegu::wyznaczSzybkoscUtratyFarby(Mat obraz) { nachylenieWiadra = przetwarzanieObrazow.wyznaczNachylenie(obraz); double katOdPoziomu = min(nachylenieWiadra,180-nachylenieWiadra); if(katOdPoziomu < 10) return 0; else return POJEMNOSC_WIADRA * katOdPoziomu / 90; }
Aby umożliwić kompilację tego kodu, dodajmy deklarację oraz pseudo implementację: wyznaczNachylenie
:
double wyznaczNachylenie(Mat obraz);
double PrzetwarzanieObrazowBiegu::wyznaczNachylenie(Mat obraz) { return 90; }
Część odpowiedzialna za wylewanie farby jest gotowa, ale gracz będzie mógł to zauważyć tylko po malejącym pasku posiadanej farby. Aby to zmienić, dodamy w wyswietlStan
wizualizację wiadra i jego przechylenia. Dla uproszczenia, wiadro przedstawimy jako prostokąt z paskiem w kolorze farby. Zacznijmy od metody rysującej obrócony prostokąt w Uzytki
:
void rysujObroconyProstokat(Mat gdzie, RotatedRect &prostokat, Vec3b kolor);
Do implementacji wykorzystamy strukturę RotatedRect
reprezentującą obrócony prostokąt. W OpenCV nie ma metody, która mogłaby go namalować, ale możemy pobrać punkty do niego należące i wykorzystać w metodzie malującej wielokąty:
void Uzytki::rysujObroconyProstokat(Mat gdzie, RotatedRect &prostokat, Vec3b kolor) { Point2f tmprogi[4]; Point rogi[4]; // Musimy przerobić współrzędne punktów na całkowite. prostokat.points(tmprogi); for(int i=0;i<4;i++) rogi[i] = (Point)tmprogi[i]; fillConvexPoly(gdzie,rogi,4,(Scalar)kolor); }
Z takim narzędziem przejdźmy do metody wyświetlającej wyswietlStan
następujący kod:
// Tylko jeśli wracamy z farbą. if(niesionaFarba != 0) { Point srodek = Point(gdzie.cols/2, gdzie.rows/2); RotatedRect obrocony = RotatedRect(srodek,Size(150,70),nachylenieWiadra); RotatedRect obroconyPasek = RotatedRect(srodek,Size(150,30),nachylenieWiadra); // Rysuj szare wiadro. Uzytki::rysujObroconyProstokat(gdzie, obrocony, Vec3b(100,100,100)); // Rysuj kolorowy pasek pośrodku wiadra. Uzytki::rysujObroconyProstokat(gdzie, obroconyPasek, kolorFarby); } }
Utworzone w ten sposób wiadro podczas rozgrywki będzie wyglądało tak:
Podobnie jak w przypadku wykrywania pędzla (patrz Gra sterowana kamerą - pomaluj płot [4]), aby wykryć wiadro w obrazie, trzeba wcześniej wyznaczyć jego parametry (m.in. barwę). Ponieważ PrzetwarzanieObrazowBiegu
dziedziczy po PrzetwarzanieObrazow
, kalibracja będzie bardzo podobna. Główną różnicą będzie wykorzystanie prostokątnego okna w miejsce kwadratowego tak, aby wymusić podłużny kształt przedmiotu.
Dodajmy zmodyfikowaną kopię metody kalibrujPedzelGracza
jako nową metodę kalibrujWiadroGracza
:
void kalibrujWiadroGracza(StanGracza &gracz) { kamera >> aktualnyObraz; flip(aktualnyObraz,aktualnyObraz,1); aktualnyObraz.copyTo(wyswietlanyObraz); // Wypisz informację dla gracza. Uzytki::wypiszTekst(wyswietlanyObraz, gracz.nazwa + "! Wybierz wiadro!",Point(0,25)); wyswietlZachowujacProporcje(wyswietlanyObraz); waitKey(3000); while(waitKey(100)!='k') { kamera >> aktualnyObraz; flip(aktualnyObraz,aktualnyObraz,1); aktualnyObraz.copyTo(wyswietlanyObraz); // Niech okno będzie prostokątne. Rect polozenieOkna = Rect(aktualnyObraz.cols/2-ROZMIAR_OKNA_KALIBRACJI_WIADRA/2,aktualnyObraz.rows/2-40,ROZMIAR_OKNA_KALIBRACJI_WIADRA,40); Mat okno = Mat(aktualnyObraz,polozenieOkna); // Ustaw parametry wiadra na podstawie zawartości okna. gracz.ustawParametryWiadra(okno); Mat pikseleWiadra = gracz.stanBiegu.przetwarzanieObrazow.wybierzPiksele(aktualnyObraz); wyswietlanyObraz.setTo(Scalar(255,0,0),pikseleWiadra); rectangle(wyswietlanyObraz,polozenieOkna,Scalar(0,255,0),3); wyswietlZachowujacProporcje(wyswietlanyObraz); int liczbaPikseliWiadraOkno = countNonZero(Mat(pikseleWiadra,polozenieOkna)); int liczbaPikseliWiadra = countNonZero(pikseleWiadra); if(2*liczbaPikseliWiadraOkno > liczbaPikseliWiadra) { break; } } }
Ze względu na podobieństwo obu metod można się pokusić o odpowiednią refaktoryzację. Niech to będzie zadanie dla ambitnych czytelników.
Musimy jeszcze dodać stałą oznaczającą długość dłuższego boku okna kalibracji wiadra:
const int ROZMIAR_OKNA_KALIBRACJI = 50; const int ROZMIAR_OKNA_KALIBRACJI_WIADRA = 150;
Następnie dodajmy metodę wyznaczającą i ustawiającą dobre parametry wiadra na podstawie podanego obrazu:
void ustawParametryWiadra(Mat wiadro);
void StanGracza::ustawParametryWiadra(Mat wiadro) { Vec3b kolor; int wartoscGraniczna; PrzetwarzanieObrazow::wyliczDobreParametry(wiadro,kolor,wartoscGraniczna); stanBiegu.przetwarzanieObrazow.kolor = kolor; stanBiegu.przetwarzanieObrazow.wartoscGraniczna = wartoscGraniczna; }
Na koniec użyjmy naszej metody kalibrującej przed rozpoczęciem rozgrywki, a zaraz po kalibracji pędzla:
kalibrujPedzelGracza(stanGry.gracze[0]); kalibrujWiadroGracza(stanGry.gracze[0]); if(liczbaGraczy==2) { kalibrujPedzelGracza(stanGry.gracze[1]); kalibrujWiadroGracza(stanGry.gracze[1]); }
Pozostała nam ostatnia, a zarazem najciekawsza część pracy, czyli implementacja metod znajdujących wiadro oraz kąt jego nachylenia. Algorytm tradycyjnie już będzie składał się z trzech etapów:
Zauważmy, że pierwsze dwa etapy mamy już zrealizowane, ponieważ takie same etapy (oczywiście z innymi parametrami) są już wykonywane podczas szukania pędzla. Dodajmy deklarację ostatniego etapu:
double wyznaczNachylenieKonturu(Kontur wybranyKontur);
double PrzetwarzanieObrazowBiegu::wyznaczNachylenie(Mat obraz) { Mat piksele = wybierzPiksele(obraz); Kontur wybranyKontur = filtruj(piksele); return wyznaczNachylenieKonturu(wybranyKontur); }
Do napisania pozostaje nam tylko wyznaczenie nachylenia obiektu opisanego przez kontur. Zastanówmy się, jak można zdefiniować takie nachylenie. Załóżmy, że nasz przedmiot wygląda tak:
Która oś lepiej oddaje ukierunkowanie przedmiotu? Oczywiście jest to oś A. Po czym można to poznać? Wyobraźmy sobie, że mamy płaski przedmiot o kształcie pokazanym powyżej. Wokół której osi łatwiej nam będzie go obracać? Właśnie wokół osi A, ponieważ masa jest bardziej skupiona wokół niej. W fizyce tę własność nazywa się momentem bezwładności [7] (oznaczanym jako ). Im jego wartość jest większa, tym trudniej zmieniać prędkość obrotową przedmiotu. W przypadku dyskretnym (takim jak nasz obraz), można go wyliczyć następująco:
gdzie to odległość piksela od osi, a to wartość pikselaMamy więc metodę pozwalającą wybrać z paru potencjalnych osi tę najlepszą (o najmniejszym ). Ale skąd wiadomo, że nie ma jeszcze lepszej? Pierwszą wskazówką jest twierdzenie Steinera [8], które mówi, że dla każdej osi wynosi:
, gdzie to oś równoległa do osi , ale przechodząca przez środek masy, – pewna nieujemna liczbaMożna więc wywnioskować, że oś obrotu o najmniejszym musi przechodzić przez środek masy. Dzięki temu możemy skoncentrować się na optymalizacji tylko jednej wartości – kąta nachylenia osi.
Pozostaje pytanie, czy można dokładnie (analitycznie) wyznaczyć tę najlepszą oś? Okazuje się, że tak. Do rozwiązania potrzeba trochę trygonometrii oraz umiejętności wykorzystywania pochodnych do znajdowania ekstremów funkcji. Zainteresowanych odsyłam do całkiem ładnego wyprowadzenia [9] dla przypadku ciągłego.
Ostateczne rozwiązanie okazuje się wykorzystywać momenty [10], które poznaliśmy już w poprzednim artykule:
gdzie - , – , –Załóżmy, że mamy już i . Jak teraz, na ich podstawie, wyznaczyć kąt ? Funkcja jest różnowartościowa tylko w przedziale 180 stopni, natomiast gdy kąt to , przedział maleje do 90 stopni. Chcemy wyznaczyć nachylenie w przedziale od 0 do 180 stopni, dlatego sam sinus nie wystarczy i będziemy musieli wykorzystać też wartość cosinusa. Przyjrzyjmy się wykresom obu funkcji:
Gdy wyliczymy arccosinusa z pewnej wartości cosinusa, to otrzymamy jeden z dwóch kątów (jak przedstawiono na wykresie):
Skąd wiadomo, który z nich jest właściwy? Zauważmy, że możemy wykorzystać do tego wartość sinusa. Jeśli sinus jest dodatni, to wybierzemy stopni, w przeciwnym wypadku weźmiemy , który, jak łatwo zauważyć, ma wartość 180 – x.
Udało nam się uporać z trygonometrią, więc można się zabrać za implementację pomysłu. Zaczniemy od sprawdzenia, czy wcześniejszym krokom udało się wykryć wiadro:
double PrzetwarzanieObrazowBiegu::wyznaczNachylenieKonturu(Kontur kontur) { if(kontur.size()==0 || abs(contourArea(kontur)) < MINIMALNY_ROZMIAR_KONTURU){ return 90; }
Jeśli się nie udało to uznajemy, że nachylenie jest maksymalne, gdyż gracz mógł ukryć gdzieś wiadro próbując uniknąć całej zabawy z utratą farby.
Gdy mamy odpowiednio duży kontur wiadra, to wyliczamy dla niego momenty oraz momenty centralne. Udowodnienie wzorów wyliczających momenty centralne na podstawie zwykłych momentów i położenia środka masy zostawiam jako zadanie:
else { Moments momenty = moments(kontur); Point2f srodekMasy(momenty.m10/momenty.m00,momenty.m01/momenty.m00); // Wyliczmy wartość momentów centralnych. double c11 = momenty.m11 - srodekMasy.x * momenty.m01; double c20 = momenty.m20 - srodekMasy.x * momenty.m10; double c02 = momenty.m02 - srodekMasy.y * momenty.m01;
Następnie, zgodnie ze wcześniejszymi ustaleniami, wyliczamy wartości sinusa i cosinusa, a następnie kąt nachylenia wynikający tylko z wartości cosinusa:
double sinus = 2*c11/sqrt(4*c11*c11 + (c20-c02)*(c20-c02)); double cosinus = (c20-c02)/sqrt(4*c11*c11 + (c20-c02)*(c20-c02)); // Otrzymamy kąt w radianach, które musimy przeliczyć na stopnie. double phi = acos(cosinus)/2*360/2/3.14;
Na koniec sprawdzamy wartość sinusa, by ustalić właściwy kąt:
if(sinus > 0) return abs(phi); else return 180 - abs(phi); } }
W zasadzie to już wszystko, ale sprawdźmy na koniec, czy wykrywanie nachylenia wiadra działa jak należy:
Mechanizm wydaje się działać bez zarzutu i jest dość dokładny. Potencjalnie można wykorzystać tak wyliczone nachylenie do sterowania motorem czy samochodem w jakiejś grze. Na pewno będzie to dużo wygodniejsze niż używanie do tego celu klawiatury.
Prace nad grą można uznać za zakończone. Gratulacje! W ramach nagrody możesz oddać się rozrywce!
Poniżej prezentujemy naszą rozgrywkę w "Malowanie płotu" w wersji z dodatkiem "Bieg po farbę":
Projekt można uznać za zakończony, więc dokonajmy pewnego podsumowania:
Pozostaje mi życzyć wam dużo zabawy z grą oraz zapału w tworzeniu własnych programów oraz gier.
Odnośniki:
[1] https://www.researchgate.net/profile/Filip-Mroz
[2] http://informatyka.wroc.pl/upload/mroz/Gry_BF_VS2008srcs.zip
[3] http://informatyka.wroc.pl/upload/mroz/Gry_BF_exe.zip
[4] http://informatyka.wroc.pl/node/1380
[5] http://en.wikipedia.org/wiki/Median_filter
[6] http://en.wikipedia.org/wiki/Erosion_(morphology)
[7] http://pl.wikipedia.org/wiki/Moment_bezw%C5%82adno%C5%9Bci
[8] http://pl.wikipedia.org/wiki/Twierdzenie_Steinera_(mechanika)
[9] http://homepages.inf.ed.ac.uk/rbf/CVonline/LOCAL_COPIES/OWENS/LECT2/node3.html
[10] http://en.wikipedia.org/wiki/Image_moment