Viděli jste ducha?

... Petr Blahoš, 1. 12. 2017 ComputerVision Python

Já jsem vždycky tak nějak podvědomě cítil, že zpracování videa je moc složité a moc pomalé. A že tím pádem nemá smysl snažit se dělat jej v Pythonu. Jenže když použijeme numpy a OpenCV, tak vlastně používáme C-čkové knihovny, a Python zde funguje jenom jako jakési lepidlo. Tak směle do toho.

Abych Vás nalákal, podívejte se na tohle video:

Kde vzít OpenCV

Instalace OpenCV (a numpy) není vždy úplně jednoduchá. Radím Vám postupovat podle oficiálního návodu pro Váš operační systém. Pro Linuxy bude pravděpodobně v repozitářích, a stačí nainstalovat. Např. pro debian je to balíček python-opencv. Pro Windows je návod na stránkách OpenCV. Hodně štěstí.

Kde vzít video

Kde vzít video na hraní? Jedna možnost (a tu Vám rozhodně pro začátek doporučuji) je nahrát si vlastní. Postavte si foťák na stativ, spusťte nahrávání, a projděte se po místnosti. Víc není potřeba.

Jestli ale hledáte nepřebernou studnici nápadů, zkuste YouTube spolu s balíčkem pafy. Pafy Vám umožní stáhnout video, které pak OpenCV použije. Např. takto:

import os.path

import cv2
import pafy


def main(url):
    video = pafy.new(url)
    best = video.getbest()
    fn = best.generate_filename()
    if not os.path.exists(fn):
        print("Downloading %s" % fn)
        best.download(filepath=fn)
    else:
        print("Using %s" % fn)

    camera = cv2.VideoCapture(fn)
    while True:
        (grabbed, frame) = camera.read()
        if not grabbed:
            break
        cv2.imshow("Frame", frame)
        key = cv2.waitKey(1) & 0xFF
        if key == ord("q"):  # quit
            break
        if key == ord("p"):  # pause
            cv2.waitKey(0)
    camera.release()

if "__main__" == __name__:
    main("https://www.youtube.com/watch?v=rBDiIYaFEF0")

Teoreticky by měl OpenCV umět přehrát video rovnou z URL, takto:

    url = best.url
    camera = cv2.VideoCapture(url)

ale to mi pro YouTube videa nefunguje.

Laciné triky

Představme si situaci: Máme video natáčené statickou kamerou s poměrně stabilními světelnými podmínkami. Chceme zjistit, co se na videu pohybuje. Vezmeme tedy 2 po sobě jdoucí snímky, a odečteme. Místa, na kterých je nula, jsou na obou obrázcích stejná, tedy nehybná. Tedy možná pozadí. Místa, na kterých není nula, nejsou stejná, tudíž se možná pohybovala, takže to není pozadí. Zkusíme si to takhle zobrazit:

    camera = cv2.VideoCapture(fn)
    prev = None
    while True:
        (grabbed, frame) = camera.read()
        if not grabbed:
            break
        if prev is None:
            prev = frame
            continue

        gray0 = cv2.cvtColor(prev, cv2.COLOR_BGR2GRAY)
        gray1 = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

        diff = gray0 - gray1

        cv2.imshow("Frame", diff)
        if ord("q") == cv2.waitKey(1) & 0xFF:
            break

        prev = frame
    camera.release()

A výsledek:

Špatné odčítání

Což samozřejmně není ono z jednoho prostého důvodu. gray0 a gray1 jsou numpy array a jejich hodnoty jsou neznaménkové byte. Budou se normálně odčítat s přetečením, takže 127 - 128 je 255. Nejlepší je použít místo takového odečtení funkci cv2.absdiff, které provede odečtení se znaménkem, a do výsledku uloží absolutní hodnotu.

        # ...
        gray0 = cv2.cvtColor(prev, cv2.COLOR_BGR2GRAY)
        gray1 = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

        diff = cv2.absdiff(gray1, gray0)

        cv2.imshow("Frame", diff)
        # ...

Tohle už by šlo:

Správné odčítání

Až na to, že tam vidíme bílé plošky, na kterých byla absolutní hodnota rozdílu nízká. Zbavíme se jich takto:

        # ...
        diff = cv2.absdiff(gray1, gray0)
        diff = cv2.threshold(diff, 35, 0, cv2.THRESH_TOZERO)[1]

        cv2.imshow("Frame", diff)
        # ...

Správné odčítání s prahováním

A teď konečně duch

To, co vidíme na předchozím obrázku je pouhý obrys pohybující se části. Aby to aspoň vzdáleně připomínalo ducha, musíme to přidat do původního obrázku. Bude to ale pořád jen obrys. Takže si schováme několik těch obrysů, a do původního obrázku přidáme ne ten nejnovější, ale několik starších. Navíc, ty starší trošku budeme postupně víc a víc ztmavovat. Asi takto:

import os.path
import time

import cv2
import pafy


class AddressableQueue(object):
    """
    Creates a data structure that will hold some number of records.
    A newly added record is always put on the position 0, the previusly
    first record will become second, second will become third, and so
    on. The structure only holds some defined number of records.
    """
    def __init__(self, max_records=20):
        self.max_records = max_records
        self.data = []

    def add(self, i):
        self.data.append(i)
        if len(self.data) > 2*self.max_records:
            del self.data[0:self.max_records]

    def get_at_or_first(self, idx):
        """
        Returns the idx-th record, or the first record if there is
        nothing on that index.
        """
        if idx >= len(self.data):
            idx = 0
        return self.data[-idx-1]


def main(url):
    video = pafy.new(url)
    best = video.getbest()
    fn = best.generate_filename()
    if not os.path.exists(fn):
        print("Downloading %s" % fn)
        best.download(filepath=fn)
    else:
        print("Using %s" % fn)

    camera = cv2.VideoCapture(fn)
    prev = None

    BASE = 10
    FRAME_COUNT = 10
    pic_queue = AddressableQueue(max_records=FRAME_COUNT+BASE)

    (grabbed, frame) = camera.read()
    prev = frame
    while grabbed:
        gray0 = cv2.cvtColor(prev, cv2.COLOR_BGR2GRAY)
        gray1 = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

        diff = cv2.absdiff(gray1, gray0)
        diff = cv2.threshold(diff, 35, 0, cv2.THRESH_TOZERO)[1]
        pic_queue.add(diff)

        out = frame
        for i in range(0, FRAME_COUNT):
            white = pic_queue.get_at_or_first(BASE + i)
            white = cv2.cvtColor(white, cv2.COLOR_GRAY2RGB)
            out = cv2.addWeighted(out, 1.0, white, 1.0/(1 + i), 0)

        cv2.imshow("Frame", out)
        if ord("q") == cv2.waitKey(1) & 0xFF:
            break

        prev = frame
        (grabbed, frame) = camera.read()
        time.sleep(0.01)

    camera.release()

if "__main__" == __name__:
    main("https://www.youtube.com/watch?v=rBDiIYaFEF0")

AddressableQueue je taková jednoduchá fronta, která uchovává maximálně předem určený počet prvků. V případě, že nově přidaný by překročil ten maximální počet, tak zahodí nejstarší. Do ní (do instance pic_queue) si budeme ukládat spočtené diffy.

Začátek cyklu pak probíhá opět stejně. Načteme obrázek z videa, převedeme na odstíny šedi, odečteme od něj předchozí, uděláme threshold. Výsledek si přidáme do fronty pic_queue. Pak vezmeme snímky 10-20 snímků zpět, a z nich uděláme ducha. Pomocí addWeighted smícháme 2 obrázky. Druhý a čtvrtý parametr je váha jednotlivých obrázků, pátý parametr je číslo, které se ještě přičte k výsledku.

Výsledek

Co dál? Zkuste si pohrát. Třeba můžete změnit BASE = 30, nebo měnit váhu obrázků v addWeighted.

Závěr

Všiměte si, že tady vlastně provedeme 24 operací s rastrem obrázku. Jednoduchých operací, ale stejně, jsou to operace, které zpracují celou matici, v tomto případě o velikosti 310x240px, a video je rychlejší, než v reálu.

Zde je skript, který vygeneruje přesně to video ze začátku.

Jako zdroj jsem použil video ITF Taekwon Do Patterns | Do San od Riverina Traditional Taekwondo zveřejněné pod licencí Creative Commons.