Gra sterowana kamerą - bieg po farbę

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

Nie wylewaj Pan farby!

Zaprogramujmy ostatnią "atrakcję" etapu biegu czyli wylewanie się farby.

Gotowy kod z tej części można znaleźć w paczce ze źródłami w katalogu: Wersja_5_Wylewanie_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.

Wylewanie farby i wyświetlanie wiadra

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 {
($ \dots $)
public:
    double nachylenieWiadra;
    double wyznaczSzybkoscUtratyFarby(Mat obraz);
Stany.h
    double StanBiegu::wyznaczSzybkoscUtratyFarby(Mat obraz) {
        return 0;
    }
StanBiegu.cpp

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);
}
StanBiegu.cpp

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 $ \alpha \in [0,180] $ liczony od lewej strony zgodnie z ruchem wskazówek zegara. Kąt odchylenia wiadra od poziomu będzie wtedy wynosił:

$ K_{odch} = \min (\alpha , 180 – \alpha) $

Gracz powinien mieć szansę doniesienia pełnego wiadra, więc będziemy go karać dopiero wtedy, gdy kąt odchylenia przekroczy $ 10^{\circ} $. 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;
}
StanBiegu.cpp

Aby umożliwić kompilację tego kodu, dodajmy deklarację oraz pseudo implementację: wyznaczNachylenie:

double wyznaczNachylenie(Mat obraz);
PrzetwarzanieObrazowBiegu.h
double PrzetwarzanieObrazowBiegu::wyznaczNachylenie(Mat obraz) {
    return 90;
}
PrzetwarzanieObrazowBiegu.cpp

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);
Uzytki.h

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); 
}
Uzytki.cpp

Z takim narzędziem przejdźmy do metody wyświetlającej . Wiadro możemy przedstawić za pomocą dwóch prostokątów. Dodajmy do 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);
    }
}
StanBiegu::wyswietlStan

Utworzone w ten sposób wiadro podczas rozgrywki będzie wyglądało tak:

Kalibracja wiadra

Podobnie jak w przypadku wykrywania pędzla (patrz Gra sterowana kamerą - pomaluj płot), 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;
        }
    }
}
main.cpp

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;
main.cpp

Następnie dodajmy metodę wyznaczającą i ustawiającą dobre parametry wiadra na podstawie podanego obrazu:

void ustawParametryWiadra(Mat wiadro);
Stany.h
void StanGracza::ustawParametryWiadra(Mat wiadro) {
    Vec3b kolor;
    int wartoscGraniczna;
    PrzetwarzanieObrazow::wyliczDobreParametry(wiadro,kolor,wartoscGraniczna);
    stanBiegu.przetwarzanieObrazow.kolor = kolor;
    stanBiegu.przetwarzanieObrazow.wartoscGraniczna = wartoscGraniczna;
}
StanGracza.cpp

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]);
}
main.cpp

Wyznaczanie przechylenia wiadra

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);
PrzetwarzanieObrazowBiegu.h
double PrzetwarzanieObrazowBiegu::wyznaczNachylenie(Mat obraz) {
    Mat piksele = wybierzPiksele(obraz);
    Kontur wybranyKontur = filtruj(piksele);
    return wyznaczNachylenieKonturu(wybranyKontur);
}
PrzetwarzanieObrazowBiegu.cpp

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 (oznaczanym jako $ I $). 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:

$ I = \sum_p w(p) \cdot r^2 $ gdzie $ r $ to odległość piksela od osi, a $ w(p) $ to wartość piksela

Mamy więc metodę pozwalającą wybrać z paru potencjalnych osi tę najlepszą (o najmniejszym $ I $). Ale skąd wiadomo, że nie ma jeszcze lepszej? Pierwszą wskazówką jest twierdzenie Steinera, które mówi, że $ I $ dla każdej osi wynosi:

$ I = I_0 + K $, gdzie $ I_0 $ to oś równoległa do osi $ I $, ale przechodząca przez środek masy, $ K $ – pewna nieujemna liczba

Można więc wywnioskować, że oś obrotu o najmniejszym $ I $ 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 dla przypadku ciągłego.

Ostateczne rozwiązanie okazuje się wykorzystywać momenty, które poznaliśmy już w poprzednim artykule:

$ \sin 2x = \frac{b}{\sqrt{b^2 + (a-c)^2}} $ $ \cos 2x = \frac{a-c}{\sqrt{b^2 + (a-c)^2}} $ gdzie $ a $ - $ mc_{20} $, $ b $$ 2mc_{11} $, $ c $$ mc_{02} $
$ mc_{xy} $ oznaczają momenty centralne, czyli liczone w układzie, którego środek pokrywa się ze środkiem masy obiektu.

Załóżmy, że mamy już $ \sin 2x $ i $ \cos 2x $. Jak teraz, na ich podstawie, wyznaczyć kąt $ x $? Funkcja $ \sin $ jest różnowartościowa tylko w przedziale 180 stopni, natomiast gdy kąt to $ 2x $, 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 $ x \in [0^{\circ} , 90^{\circ}] $ stopni, w przeciwnym wypadku weźmiemy $ x \in [90^{\circ}, 180^{\circ}] $, 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;
    }
 
PrzetwarzanieObrazowBiegu.cpp

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;
PrzetwarzanieObrazowBiegu.cpp

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;
PrzetwarzanieObrazowBiegu.cpp

Na koniec sprawdzamy wartość sinusa, by ustalić właściwy kąt:

        if(sinus > 0) return abs(phi);
        else return 180 - abs(phi);
    }
}
PrzetwarzanieObrazowBiegu.cpp

W zasadzie to już wszystko, ale sprawdźmy na koniec, czy wykrywanie nachylenia wiadra działa jak należy:

Test wykrywania nachylenie wiadra (zielony ręcznik)

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!

0
Twoja ocena: Brak

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com