Gra sterowana kamerą - pomaluj płot

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

Rozwinięcie aspektu gry

Nasze dzieło na razie nie do końca zasługuje na miano gry. Dlaczego?

  • nie możemy wybrać swojego imienia
  • nie da się wybrać pędzla
  • brak jakiegokolwiek systemu zapisywania najlepszych wyników
  • przecież mamy malować płot, a nie cały ekran!

W tej części zajmiemy się uzupełnieniem tych braków, co z pewnością uatrakcyjni rozgrywkę.

Kod po wszystkich tych zmianach można znaleźć w paczce ze źródłami w katalogu: Wersja_6_Plot

Wybór imienia

Z tym nie będzie zbyt wiele problemu. Imię gracza będzie wpisywane w konsoli przed uruchomieniem gry. Wystarczy wczytać je i ustawić, zastępując wcześniejszy kod (wstawiający imię "Filip") poniższym:

printf("Kamera podlaczona!\n");
char imieGracza[100];
printf("Podaj imie gracza: ");
scanf("%s",imieGracza);
stanGry.gracz.nazwa = imieGracza;
main.cpp

Aby rozgrywka nie zaczynała się od razu (gdy jesteśmy przy klawiaturze), przez trzy sekundy będziemy wyświetlać odpowiedni komunikat:

$ \dots $
cvResizeWindow("Maluj plot!", screenX, screenY);
 
kamera >> aktualnyObraz;
flip(aktualnyObraz,aktualnyObraz,1);
aktualnyObraz.copyTo(wyswietlanyObraz);
Uzytki::wypiszTekst(wyswietlanyObraz,"Przygotuj sie do gry!",Point(0,25));
wyswietlZachowujacProporcje(wyswietlanyObraz);
waitKey(3000);
$ \dots $
main.cpp

Wybór pędzla

Na początku rozgrywki chcielibyśmy ustawić parametry pędzla w taki sposób, żeby rozpoznawał wybrany przez nas przedmiot. Ważnym jest, aby proces ten wymagał tylko odpowiedniego pokazania pędzla kamerze, bez suwaków czy ręcznego wypróbowania wartości. Dobrze byłoby, gdyby efekty parametryzowania były widoczne od razu i można było na nie odpowiednio zareagować (np. zmienić używany przedmiot).

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

Rozwiązaniem spełniającym te wszystkie warunki jest dodanie przed rozpoczęciem gry dodatkowego etapu, w którym wybór przedmiotu odbywa się poprzez umieszczenie go w oznaczonym na ekranie kwadracie. Program będzie automatycznie analizował obraz i wyliczał najlepsze ustawienie pędzla, czyli jego kolor i akceptowalne od niego odchylenie. Na ekranie na bieżąco będą wyświetlane wszystkie piksele, które na podstawie aktualnych ustawień zostaną uznane za pędzel, dzięki czemu od razu będzie można ocenić przydatność wybranego przedmiotu.

Zacznijmy od metody wyliczającej parametry pędzla na podstawie podanego obrazu. Dodajmy deklarację do PrzetwarzanieObrazow.h:

static void wyliczDobreParametry(Mat probka, Vec3b& kolor, int& toleracja);
PrzetwarzanieObrazow.h

probka zawiera tylko piksele z kwadratu z pędzlem, które wykorzystamy w naszej kalibracji.

Implementację rozpoczniemy od konwersji próbki do RGB*, ponieważ w tej przestrzeni, szukamy potem pędzla. Najpierw wyznaczymy kolor pędzla. Dobrym kandydatem jest kolor powstały ze średnich arytmetycznych poszczególnych składowych:

void PrzetwarzanieObrazow::wyliczDobreParametry(Mat probka, Vec3b& kolor, int& tolerancja) {
    Mat probkaBarwa;
    probka.copyTo(probkaBarwa);
    konwertujNaBarwe(probkaBarwa);
    Scalar suma = sum(probkaBarwa);
    kolor = Vec3b(suma.val[0] / probkaBarwa.total(), suma.val[1] / probkaBarwa.total(), suma.val[2] / probkaBarwa.total());
    kolor = wyliczBarwe(kolor);
PrzetwarzanieObrazow.cpp

Można się zastanowić czy ostatnia linia jest potrzebna, może kolor ma już postać RGB*. Zgodnie z definicją wymagane jest by:

r+g+b = 255

Sprawdźmy czy kolor spełnia ten warunek:

r+g+b = suma r / n + suma g / n + suma b / n = (suma r + suma g + suma b)/n r+g+b = suma (r+g+b)/n = 255 * n / n = 255

Pokazaliśmy, że kolor powinien należeć do RGB*. Dlaczego więc przeliczamy go jeszcze raz? Robimy tak, ponieważ podczas liczenia średniej używamy dzielenia całkowitego co sprawia, że pokazana przez nas równość nie zachodzi.

Następnym krokiem jest wyznaczenie takiej tolerancji, by około 80% pikseli z obrazu zostało uznanych za pędzel. Czemu robimy to w taki sposób? Trzeba pamiętać, że obraz z kamery zawiera dużo losowego szumu, więc znajdą się w nim pojedyncze piksele, których barwa może być znacząco zniekształcona. Na szczęście losowość oznacza, że takich felernych pikseli będzie niewiele. Najpierw dla każdego piksela wyliczymy różnicę między nim a wcześniej wyznaczonym kolorem pędzla:

    for(int i=0;i<probkaBarwa.rows;i++) 
    for(int i2=0;i2<probkaBarwa.cols;i2++) {
        Vec3b wartosc = probkaBarwa.at<Vec3b>(i,i2);
        listaRoznic.push_back(abs(wartosc[0] - kolor[0]) + abs(wartosc[1] - kolor[1]) + abs(wartosc[2] - kolor[2]));
    }
PrzetwarzanieObrazow.cpp

Po posortowaniu rosnąco tych różnic wybierzemy wartość będącą w 8/10 listy. Wszystkie różnice na lewo od tej wartości będą od niej mniejsze, więc odpowiadające im piksele zostaną zaakceptowane jako część pędzla. By uniknąć problemów, gdy w kwadracie znajduje się też przedmiot o zupełnie innej barwie, przytniemy tolerancję do 70. Zarówno 8/10 jak i 70 zostały wybrane doświadczalnie, więc zawsze można je dopasować do swoich warunków.

    sort(listaRoznic.begin(),listaRoznic.end());
    tolerancja = min(listaRoznic[8*listaRoznic.size()/10],70);
}
PrzetwarzanieObrazow.cpp

Dodamy teraz nową funkcję do StanGracza, która dla podanego wycinka obrazu wyliczy i ustawi parametry szukania pędzla. Zacznijmy od deklaracji w Stany.h

void ustawParametryPedzla(Mat pedzel);
Stany.h
W implementacji tej metody skorzystamy z naszej nowej funkcji wyliczDobreParametry:
void StanGracza::ustawParametryPedzla(Mat pedzel) {
    Vec3b kolor;
    int wartoscGraniczna;
    PrzetwarzanieObrazow::wyliczDobreParametry(pedzel,kolor,wartoscGraniczna);
    stanMalowania.kolor = kolor;
    stanMalowania.przetwarzanieObrazow.kolor = kolor;
    stanMalowania.przetwarzanieObrazow.wartoscGraniczna = wartoscGraniczna;
}
StanGracza.cpp

Przejdźmy teraz do implementacji etapu kalibracji w pliku main.cpp. Bezpośrednio po ustawieniu okna gry damy graczowi trzy sekundy na przygotowanie, identycznie jak w przypadku przygotowania do gry:

$ \dots $
cvResizeWindow("Maluj plot!", screenX, screenY);
 
kamera >> aktualnyObraz;
flip(aktualnyObraz,aktualnyObraz,1);
aktualnyObraz.copyTo(wyswietlanyObraz);
Uzytki::wypiszTekst(wyswietlanyObraz,"Przygotuj sie do wyboru pedzla!", Point(0,25));
wyswietlZachowujacProporcje(wyswietlanyObraz);
waitKey(3000);
main.cpp

Następnie rozpoczniemy właściwy cykl kalibracji:

while(waitKey(100)!=' ') {
    kamera >> aktualnyObraz;
    flip(aktualnyObraz,aktualnyObraz,1);
    aktualnyObraz.copyTo(wyswietlanyObraz); 
main.cpp

Pierwszą rzeczą do zrobienia jest wyznaczenie okna, w którym będziemy umieszczać pędzel. Będzie to kwadrat o boku ROZMIAR_OKNA_KALIBRACJI umieszczony na środku ekranu. Dodajmy deklaracje tej stałej na początku pliku:

const int ROZMIAR_OKNA_KALIBRACJI = 50;
main.cpp

Teraz stwórzmy prostokąt opisujący nasze okno i wybierzmy odpowiadający mu kawałek obrazu z kamery:

    Rect polozenieOkna = Rect(aktualnyObraz.cols/2-ROZMIAR_OKNA_KALIBRACJI,aktualnyObraz.rows/2-ROZMIAR_OKNA_KALIBRACJI,ROZMIAR_OKNA_KALIBRACJI,ROZMIAR_OKNA_KALIBRACJI);
    Mat okno = Mat(aktualnyObraz,polozenieOkna);
main.cpp

Za pomocą wcześniej napisanej metody ustawParametryPedzla ustawimy parametry pędzla na podstawie zawartości okna:

    stanGry.gracz.ustawParametryPedzla(okno);
main.cpp

Zmieniliśmy ustawienia pędzla gracza. Czas pokazać, czy wybór ten był dobry. Pobierzemy wszystkie piksele uznane za pędzel przy pomocy tych ustawień i wyświetlimy je na ekranie jako niebieskie:

    Mat pikselePedzla = stanGry.gracz.stanMalowania.przetwarzanieObrazow.wybierzPiksele(aktualnyObraz);
    wyswietlanyObraz.setTo(Scalar(255,0,0),pikselePedzla);
main.cpp

Nie zapomnijmy o oznaczeniu położenia naszego okna na wynikowym obrazie i naszej pełnoekranowej metodzie wyświetlania:

    rectangle(wyswietlanyObraz,polozenieOkna,Scalar(0,255,0),3);
    wyswietlZachowujacProporcje(wyswietlanyObraz);
main.cpp

Ekran podczas wyboru pędzla prezentuje się następująco:

Ostatnią ważną rzeczą jest decyzja, kiedy kalibrację uznać za zakończoną. Poza ręcznym przerwaniem za pomocą spacji, powinna być dostępna również metoda automatycznej oceny kalibracji. Uznamy, że parametry pędzla nas satysfakcjonują gdy:

$ P_o > P_p $ gdzie $ P_o $ to liczba pikseli pędzla wewnątrz okna, a $ P_p $ to liczba pikseli pędzla poza oknem.

Ponieważ $ P_p = P – P_o $ możemy ten warunek przekształcić do: $ P_o > P – P_o \equiv 2P_o > P $

Za pomocą już poznanych funkcji łatwo sprawdzić ten warunek:

    int liczbaPikseliPedzlaOkno = countNonZero(Mat(pikselePedzla,polozenieOkna));
    int liczbaPikseliPedzla = countNonZero(pikselePedzla);
    if(2*liczbaPikseliPedzlaOkno > liczbaPikseliPedzla) {
        break;
    }
}
main.cpp

Ze względu na wybraną metodę oceny, podczas kalibracji pędzel powinien wypełniać całą ramkę, ale nie wolno mu przy tym znacznie poza nią wykraczać.

Jeśli jeszcze tego nie zrobiliśmy, to dobrze jest wyrzucić wyświetlanie półproduktów z wybierzPiksele, ponieważ będą one tylko przeszkadzać w grze.

Lista najlepszych wyników

Możliwość odnotowania swoich osiągnięć zawsze pozytywnie wpływa na grywalność, zwiększa determinację i wytrwałość graczy (może tym razem się uda!). Dodajmy więc tabelę najlepszych wyników.

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

Wyniki będziemy trzymać w jawnej postaci w pliku historia.dat. Żeby uniemożliwić ich modyfikację, na końcu pliku dodamy prostą sumę kontrolną. Najpierw w Uzytki napiszmy funkcję sumaKontrolna, która przerobi nam tekst na liczbę:

long long wyliczSumeKontrolna (string tekst);
Uzytki.h
long long Uzytki:: wyliczSumeKontrolna (string tekst) {
    long long suma = 1;
    for(int i=0;i<tekst.size();i++) suma = (suma * tekst[i]) % 10000019;
    return suma;
}
Uzytki.cpp

Suma kontrolna będzie iloczynem wartości ASCII wszystkich liter należących do tekstu, wykonana modulo pewna liczba pierwsza.

Skoro potrafimy już sprawdzić, czy wyniki nie zostały nieuczciwie zmienione poprzez edycję pliku, możemy napisać funkcję wczytajWyniki, która będzie czytać dane z pliku, sprawdzać ich sumę kontrolną i zwracać posortowaną według wyników listę graczy:

vector< pair<double,string> > wczytajWyniki(string sciezka = "historia.dat");
Uzytki.cpp

Implementację zaczniemy od wczytania liczby zapisanych wyników i wyzerowania sumy kontrolnej:

vector<pair<double,string> > Uzytki::wczytajWyniki(string sciezka) {
    vector< pair<double,string> > wyniki;
    FILE * plik = fopen(sciezka.c_str(),"r");
    if(plik != NULL) {
        int ileWynikow;
        fscanf(plik,"%d\n",&ileWynikow);
        long long sumaKontrolna = 0;
        char bufor[1000]; 
Uzytki.cpp

Będziemy teraz czytać plik linia po linii, odczytując wyniki i obliczając sumę sum kontrolnych wyliczonych na podstawie poszczególnych linii:

        for(int i=0;i<ileWynikow;i++) {
            fgets(bufor,1000,plik);
            sumaKontrolna += wyliczSumeKontrolna(bufor);
 
            char nazwaGracza[100];
            double wynikGracza;
            sscanf(bufor,"%s %lf",nazwaGracza,&wynikGracza);
            wyniki.push_back(make_pair(wynikGracza,nazwaGracza));
        }
        sumaKontrolna %= 10000019;
Uzytki.cpp

Wyniki zostały wczytane, mamy też wyliczoną sumę kontrolną, którą teraz porównamy z wartością zapisaną w pliku. Następnie posortujemy wyniki bądź, w przypadku wykrycia matactwa, usuniemy je wszystkie.

        int zapisanaSumaKontrolna;
        fscanf(plik,"%d",&zapisanaSumaKontrolna);
        if(sumaKontrolna == zapisanaSumaKontrolna) {
            sort(wyniki.begin(),wyniki.end(), greater< pair<double,string> >());
        }
        else {
            wyniki.clear();
        }
        fclose(plik);
    }
    return wyniki;
}    
Uzytki.cpp

Dzięki użyciu greater lista zostanie posortowana nierosnąco względem wyników.

Za pomocą powyższej metody możemy już bez problemów wypisać najlepszych graczy do konsoli, jednak byłoby to bardzo niewygodne dla graczy, którzy oczekują tych wyników na ekranie. Dopiszmy więc funkcję wyswietlNajlepszych do StanGry:
void wyswietlNajlepszych(Mat gdzie); 
Stany.h

Na ekran wypiszemy pięć pierwszych wyników z dokładnością do setnych procenta:

void StanGry::wyswietlNajlepszych(Mat gdzie) {
    vector< pair<double,string> > wyniki = Uzytki::wczytajWyniki();
    Uzytki::wypiszTekst(gdzie,"Najlepsi malarze:",Point(100, 50));
    for(int i=0;i<min(5,(int)wyniki.size());i++) {
        char tekst[200];
        sprintf(tekst,"%d. %s - %s%%",i+1,wyniki[i].second.c_str(),Uzytki::ftos(wyniki[i].first*100,2).c_str());
        Uzytki::wypiszTekst(gdzie,tekst,Point(100, 100 + 50*i ));
    }
}
StanGry.cpp

Pozostaje nam wyświetlić te wyniki po zakończeniu gry. Dodajmy najpierw krótką informacje o końcu rozgrywki (wyświetlaną przez 3 sekundy):

    $ \dots $
    if(stanGry.graTrwa() == false) break;
}
 
aktualnyObraz.copyTo(wyswietlanyObraz);
Uzytki::wypiszTekst(wyswietlanyObraz,"Koniec gry!",Point(0,25));
wyswietlZachowujacProporcje(wyswietlanyObraz);
waitKey(3000);
main.cpp

Potem pokażemy czarny obraz z wyświetlonymi wynikami:

wyswietlanyObraz.setTo(Vec3b(0,0,0));
stanGry.wyswietlNajlepszych(wyswietlanyObraz);
wyswietlZachowujacProporcje(wyswietlanyObraz);
waitKey(0);
main.cpp

Czy to już wszystko? Nie, ponieważ nie zapisujemy osiągniętych wyników! Dodajmy do Uzytki funkcje dodajWpisDoWynikow:

void dodajWpisDoWynikow(string nazwaGracza, double nowyWynikGracza, string sciezka = "historia.dat");
Uzytki.h

Implementację zaczniemy od uaktualnienia listy wyników o nową pozycję:

void Uzytki::dodajWpisDoWynikow(string nazwaGracza, double nowyWynikGracza, string sciezka) {
    vector< pair<double,string> > wyniki = wczytajWyniki(sciezka);
    wyniki.push_back(make_pair(nowyWynikGracza,nazwaGracza));
Uzytki.cpp

Symetrycznie względem wczytywania, zapisujemy nową listę wyników, przy okazji wyliczając ich sumę kontrolną. Musimy uważać, żeby proces liczenia sumy był w tym przypadku taki sam jak podczas odczytu, inaczej dane nie zostaną wczytane podczas następnej gry:

    FILE * plik = fopen(sciezka.c_str(),"w");
    fprintf(plik,"%d\n",wyniki.size());
    long long sumaKontrolna = 0;
    for(int i=0;i<wyniki.size();i++) {
        char bufor[1000];
        sprintf(bufor,"%s %lf\n",wyniki[i].second.c_str(),wyniki[i].first);
        sumaKontrolna += wyliczSumeKontrolna(bufor);
        fputs(bufor,plik);
    }
    sumaKontrolna %= 10000019;
    fprintf(plik,"%d\n",sumaKontrolna);
    fclose(plik);
}
Uzytki.cpp

Wykorzystajmy powyższą funkcję dodającą wynik gracza w metodzie zakonczGre kończącej rozgrywkę.

    $ \dots $
    _graTrwa = false;
    Uzytki::dodajWpisDoWynikow(gracz.nazwa,gracz.stanMalowania.wyznaczPomalowanaCzesc());
}
StanGry.cpp

To już koniec implementacji tabeli wyników i po paru rozgrywkach możemy ujrzeć taki widok:

Malowanie płotu

Jak dotąd naszym zadaniem było zamalowanie całego ekranu, jednak celem gry miało być pomalowanie płotu. Pora powołać taki płot do istnienia!

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

Obszar płotu będzie reprezentowany przez czarno-biały obraz, w którym płot będzie wyznaczony przez białe piksele. Do stworzenia takiego zarysu można wykorzystać program Paint, pamiętając przy tym, że zarys powinien mieć takie same wymiary jak obraz pobrany z kamery (zazwyczaj 640x480). Płot może wyglądać na przykład tak:

Zapiszmy ten plik w katalogu z projektem pod nazwą plot.png i przy uruchomieniu naszego programu sprawdźmy, czy jest dostępny:

int main(int argc, char * argv[]) {
 
    if(imread("plot.png").data == NULL) {
        printf("Brak pliku plot.png z zarysem plotu!\n"); 
        getchar();
        return -1;
    }
main.cpp

Zarys płotu będzie używany w StanMalowania, więc tam właśnie go wczytamy. Dodajmy macierz _zarysPlotu:

class StanMalowania {
protected:
    Mat _zarysPlotu;
    $ \dots $
Stany.h

Wczytajmy zarys w konstruktorze StanMalowania:

    $ \dots $
    kolor = Vec3b(0,0,255);
    _zarysPlotu = imread("plot.png",0); 
}
StanMalowania.cpp

Flaga 0 w imread oznacza, że wczytany obraz będzie miał tylko jedną składową (odcień szarości).

By zapewnić pewną elastyczność wobec różnych rozdzielczości kamer, przy pierwszym wywołaniu metody uaktualnijStan przeskalujemy zarys płotu tak, by miał zawsze takie same wymiary jak obraz z kamery:

if(_aktualnyStan.data==NULL) {
    _aktualnyStan = Mat(obraz.size(),CV_8U,Scalar(0));
    resize(_zarysPlotu,_zarysPlotu,_aktualnyStan.size());
}
StanMalowania.cpp

Zmieńmy sposób liczenia pomalowanej części tak, aby korzystał z zarysu płotu:

double StanMalowania::wyznaczPomalowanaCzesc() {
    return (double)cv::countNonZero(_aktualnyStan)/cv::countNonZero(_zarysPlotu);
}
StanMalowania.cpp

Aby powyższa metoda działała prawidłowo, musimy pilnować, by pomalowane piksele w _aktualnyStan stanowiły podzbiór pikseli _zarysPlotu. Trzeba zmodyfikować metodę uaktualnijStan w taki sposób, aby po każdym udanym malowaniu zamalowane były tylko piksele należące do zarysu. Żeby to zrobić wystarczy użyć operacji bitowej AND między pikselami płotu a pikselami pomalowanymi:

if(polozenie.x>0) {
    circle(_aktualnyStan,polozenie,ROZMIAR_PEDZLA,255,-1);
    bitwise_and(_aktualnyStan, _zarysPlotu, _aktualnyStan);
}
StanMalowania.cpp

Pozostaje nam dodać wyświetlanie zarysu płotu w wyswietlStan. Oczywiście najpierw wyświetlimy szary zarys, a następnie na nim pomalowaną część:

void StanMalowania::wyswietlStan(Mat gdzie) {
    if(_aktualnyStan.data!=NULL) {
        gdzie.setTo(Vec3b(100,100,100),_zarysPlotu);
gdzie.setTo(kolor,_aktualnyStan);
    }
}
StanMalowania.cpp

Program zasługuje już na miano gry i nawet się jakoś prezentuje!

Problemy z wyborem i działaniem pędzla

Cały algorytm jest dość prosty, więc wymaga wybrania dobrego pędzla oraz otoczenia. Poniżej znajdują się wskazówki pomagające w ich wyborze:

Dobry pędzel powinien mieć żywe kolory, wyróżniające się z tła oraz być matowy (refleksy mogą uniemożliwić jego wykrycie).

W przypadku wyboru otoczenia należy pamiętać, że:

  • Sztuczne światło ma często określony odcień (np. żółty) co sprawia, że większość przedmiotów nabiera tego odcienia.
  • Jeśli światło pada z konkretnego kierunku to pojawiają się cienie, które mogą zmieniać barwę. Jest tak, gdy barwa światła padającego bezpośrednio na przedmiot jest różna od barwy światła odbitego od ścian. Wtedy sumaryczna barwa zależy od tego czy przedmiot jest w cieniu czy nie.
  • Otoczenie musi być dobrze oświetlone i najlepsze do tego wydaje się światło dzienne.

Warto też pamiętać, że w przypadku większości kamerek można zmienić ich podstawowe ustawienia. Polecamy wyłączyć automatyczne dopasowanie jasności oraz zwiększyć rozdzielczość do 640x480. Jeśli obraz jest ciemny można ręcznie nastawić większy czas ekspozycji kosztem płynności rozgrywki.

Jeśli wciąż nie udaje się uzyskać dobrych efektów i nie wiadomo czemu tak się dzieje, to można wyświetlić pomocnicze okna, takie jak "Barwa" czy "Wybrane" i zobaczyć jaką barwę przedmiotu i otoczenia widzi nasz program. Czasem jest to dość zaskakujące...

Gra ma już prawie wszystko, co sobie założyliśmy. Brakuje tylko....

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

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com