5.11. Różnicowanie ramek

Różnicowanie ramek porównuje każdą nową ramkę z zapisaną ramką referencyjną, aby znaleźć te fragmenty sceny, które uległy zmianie. Jest to podstawowe narzędzie aplikacji kamerowych, które wypatrują, czy coś się dzieje – przechwytywanie wyzwalane ruchem, alerty o intruzach, „zapisz wideo, gdy coś się poruszy” – a zbudowane jest w całości z operacji wykonywanych na poszczególnych pikselach, omówionych wcześniej: różnicy bezwzględnej, progowania i wyszukiwania obszarów, uruchamianych na każdej ramce.

5.11.1. Podstawowy potok przetwarzania

Pierwszym etapem jest pozyskanie referencji. W pewnym momencie zaraz po uruchomieniu – najlepiej wtedy, gdy scena jest w stanie oznaczającym „brak zmiany” – aplikacja przechwytuje ramkę i ją zachowuje. Ramka ta staje się punktem odniesienia, z którym porównywane będzie każde kolejne przechwycenie.

reference = csi0.snapshot().copy()

Wywołanie .copy() ma znaczenie. csi0.snapshot() samo w sobie zwraca obiekt Image, którego bufor znajduje się w buforze ramki, gdzie kolejne wywołanie snapshot go nadpisze. .copy() przydziela osobny bufor dla referencji i pozwala, by piksele tej ramki przetrwały następne przechwycenie.

Drugi etap uruchamiany jest na każdej ramce: przechwyć nowy obraz, a następnie oblicz różnicę bezwzględną między nim a referencją. Dokładnie to robi difference():

current = csi0.snapshot()
current.difference(reference)

Po tym wywołaniu current przechowuje obraz, którego niezerowe piksele oznaczają każde położenie, w którym scena zmieniła się od momentu zarejestrowania referencji, przy czym wielkość każdego piksela jest proporcjonalna do tego, jak bardzo zmieniło się to położenie.

Trzeci etap progowania obrazu różnicowego. Surowa różnica zawsze zawiera pewien szum: niewielkie wahania jasności pochodzące z szumu fotonowego sensora, zmiany gradientu wynikające z dryfu oświetlenia, drgania subpikselowe z lekkiego ruchu kamery. Przebieg progujący – binary() z progiem ustawionym powyżej tego poziomu szumu – zachowuje tylko te zmiany, które są na tyle duże, by uznać je za rzeczywisty ruch, a resztę odrzuca, tworząc obraz binarny, którego niezerowe piksele to faktycznie zmienione położenia.

Czwarty etap wyodrębnia połączone obszary tej maski binarnej – grupy sąsiadujących niezerowych pikseli tworzące zwarte plamy. find_blobs() robi to w jednym wywołaniu, zwracając listę obszarów ruchu, z których każdy ma ramkę ograniczającą i liczbę pikseli, na podstawie których reszta aplikacji może podjąć działanie.

Poziomy diagram potoku przetwarzania. Dwa skrajne lewe panele to ramka referencyjna i bieżąca ramka obok siebie, ze znakiem plus między nimi. Strzałka prowadzi od pary do trzeciego panelu oznaczonego jako różnica, w którym kilka plam jest jasnych na ciemnym tle. Strzałka prowadzi stamtąd do czwartego panelu przedstawiającego binarną, progowaną wersję różnicy, w której te same plamy są teraz jednolicie białe. Ostatnia strzałka prowadzi do piątego panelu przedstawiającego maskę binarną z naniesionymi prostokątnymi ramkami ograniczającymi narysowanymi wokół każdej plamy.

Potok różnicowania ramek: ramka referencyjna i bieżąca ramka stają się obrazem różnicowym; progowanie zamienia różnicę w maskę binarną zmienionych położeń; etap połączonych obszarów zamienia maskę w listę obszarów ruchu.

5.11.2. Referencje w pamięci i na dysku

Podstawowy potok przechowuje ramkę referencyjną w pamięci RAM. Jest to właściwe rozwiązanie, gdy referencja jest przechwytywana w tym uruchomieniu skryptu i musi przetrwać tylko tak długo, jak długo skrypt działa.

W przypadku aplikacji działającej długotrwale – kamery, która powinna wznowić wykrywanie zmian po wyłączeniu i ponownym włączeniu zasilania, lub przerywanego skryptu, który musi wykrywać jakąkolwiek zmianę od pewnego wcześniejszego momentu – ramka referencyjna musi przetrwać dłużej niż działający skrypt. Wzorzec polega na zapisaniu referencji na dysku:

csi0.snapshot().save("/sdcard/reference.bmp")

i wczytaniu jej z powrotem na początku każdego uruchomienia:

reference = image.Image("/sdcard/reference.bmp")

Logika różnicowania nie zmienia się; zmienia się tylko to, gdzie referencja przebywa między przechwyceniami. Kilka udoskonaleń w naturalny sposób rozszerza ten wariant dyskowy – automatyczne ponowne przechwytywanie referencji według licznika czasu (timer), opcjonalne średnie kroczące śledzące powolny dryf oświetlenia – ale podstawienie w centrum pozostaje takie samo.

5.11.3. Izolacja źródła światła

Ten sam wzorzec odejmowania pojawia się w nieco innym kontekście: izolowania źródła światła na tle reszty sceny. Sztuczka polega na przechwyceniu referencji „przy zgaszonym świetle” – ramki zarejestrowanej wtedy, gdy to, co ma być wykrywane (beacon IR, piksel ekranu, wskaźnik stanu), nie świeci – i odjęciu tej referencji od każdej kolejnej ramki. W wyniku jasność jest zerowa wszędzie tam, gdzie scena była taka sama na obu przechwyceniach, a niezerowa tylko tam, gdzie źródło światła faktycznie się zaświeciło.

5.11.4. Wybór między difference a sub

Praktyczna uwaga dotycząca tego, którą operację arytmetyczną wybrać. difference() zwraca wartość bezwzględną zmiany – bez znaku – co czyni ją czułą na zmianę w dowolnym kierunku (rozjaśnienie lub przyciemnienie) kosztem niemówienia aplikacji, w którym kierunku poszła zmiana. Dla czystego wykrywania ruchu jest to właściwe rozwiązanie: wszystko, co się poruszyło, jest interesujące, niezależnie od tego, w którą stronę przesunęła się jasność.

W przypadku wykrywania źródła światła oświetlony piksel jest zawsze jaśniejszy niż referencja przy zgaszonym świetle, więc sub() (z przycinaniem do zera) jest uczciwszym wyborem. Wszędzie tam, gdzie bieżąca ramka jest ciemniejsza niż referencja (co byłoby szumem sensora wokół nieoświetlonej wartości), wynik przycinany jest do zera zamiast zgłaszać fałszywy sygnał „światło było włączone”.