Gra sterowana kamerą - bieg po farbę

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

Biegnij graczu, biegnij!

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ć.

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

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:

Obraz zmian sceny. Im jaśniejszy piksel, tym bardziej zmienił się jego kolor

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) na wejściowych obrazach. Zobaczmy, jak ta operacja wpływa na wynikowy obraz różnicy:

Obraz zmian sceny po zastosowaniu filtru medianowego przed wyliczeniem 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):

Aktualny obraz z kamery oraz wyliczony obraz różnicy podczas "biegu"

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.

Implementacja wyznaczania prędkości biegu

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 {
 
};
PrzetwarzanieObrazowBiegu.h

Dla porządku część wyznaczającą prędkość na podstawie podanego obrazu umieścimy w nowej funkcji należącej do StanBiegu:

#include "PrzetwarzanieObrazowBiegu.h"
($ \dots $)
 
class StanBiegu {
    ($ \dots $)
    PrzetwarzanieObrazowBiegu przetwarzanieObrazow;
 
    double wyznaczPredkosc(Mat obraz);
Stany.h

Użyjmy tej funkcji na początku uaktualnijStan:

void StanBiegu::uaktualnijStan(double czas, Mat obraz) {
    double predkosc = wyznaczPredkosc(obraz);
    $ \dots $
StanBiegu.cpp

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

Interesuje nas tylko to, jak gracz przemieszcza się po osi Y, więc wykorzystamy tylko pionową składową prostokąta $ P_y $. Chcemy wymusić na graczu, aby ruszał jednocześnie całym ciałem, dlatego jego prędkość chwilowa $ V_g $ będzie proporcjonalna do $ P_y $. Wykorzystamy zwykłą funkcję liniową wyskalowaną tak, aby przy maksymalnym $ P_y $, równym wysokości obrazu $ O_y $, gracz poruszał się z maksymalną możliwą prędkością $ V_{max} $:

$ V_g(P_y) = \frac{V_{max}}{O_y} \cdot P_y $

Implementacja powyższych rozważań jest bardzo krótka:

    double wysokoscSladu = prostokatOtaczajacy.height;
    return wysokoscSladu * MAKSYMALNA_PREDKOSC / obraz.rows;
}
StanBiegu.cpp

Wykrycie ruchu w obrazie

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

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

Teraz zajmiemy się kolejnymi etapami.

Wyznaczenie śladu

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

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

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 {
    $ \dots $
    static void sumujSkladowe(Mat &obraz, Mat &wynik);
}
Stany.h
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]);
    }
}
PrzetwarzanieObrazow.cpp

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

Wróćmy do implementacji wyznaczSlad i wykorzystajmy naszą refaktoryzację:

    else {
        absdiff(_wyznaczSladObraz,_wyznaczSladPoprzedniObraz,_wyznaczSladRoznica);
        sumujSkladowe(_wyznaczSladRoznica,_wyznaczSladWynik);
PrzetwarzanieObrazowBiegu.cpp

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

Na koniec nie zapomnijmy zapisać przefiltrowanej wersji aktualnego obrazu, do wykorzystania w wywołaniu wyznaczSlad:

    _wyznaczSladObraz.copyTo(_wyznaczSladPoprzedniObraz);
    return _wyznaczSladWynik;
}
PrzetwarzanieObrazowBiegu.cpp

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

Zobaczmy teraz, jak sprawuje się wyznaczSlad na próbnej parze kolejnych obrazów:

Test metody wyznaczającej ślad. Od lewej widzimy: obraz wejściowy, wyliczona różnica, maska pikseli "ruszonych"

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.

Usuwanie błędnych pikseli

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 oraz Gra sterowana kamerą - pomaluj płot), 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;
}
PrzetwarzanieObrazowBiegu.cpp

Dodajmy deklarację macierzy, do której zapiszemy przefiltrowany obraz:

protected:
    ($ \dots $)
    Mat _usunPrzypadkowePikseleWynik;
public:
PrzetwarzanieObrazowBiegu.cpp

Zobaczmy, czy uda nam się usunąć wszystkie błędy z poprzedniego etapu:

Test usuwania błędnych pikseli. Po lewej obraz z błędami, po prawej obraz po operacji.

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.

Wyznaczanie prostokąta otaczającego

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

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

Powyższa metoda poprawnie znajduje otaczający prostokąt w obrazie otrzymanym z poprzedniego etapu:

Obraz z zaznaczoną ramką otaczającą

Cała metoda wykryjRuch działa zaskakująco dobrze:

Obraz z kamery z zaznaczoną ramką otaczającą wykryty ruch

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.

0
Twoja ocena: Brak

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com