2013. február 9., szombat

Anaglif 3D kép előállítása Python és PIL segítségével

Az elmúlt napokban/hetekben háromdimenziós képek előállításával foglalkoztam. Hogy egész pontos legyek, csak azzal a részével, ahol a két kép egyé lesz. A mai 3D-s tartalom legnagyobb része SBS 3D módszerrel kerül kiadásra, ez az, amikor a bal- és a jobb szemnek szánt képek 1 db képre kerülnek rá, és ennek a képnek a jobb oldalán van a jobb szemnek szánt kép, és a bal oldalán a bal. Az ilyen képek megjelenítéséhez speciális eszközre van szükség, szóval egy 3D TV-re (vagy monitorra), de szerintem gondolni kell azokra is, akik nem szeretnének ilyen eszközköbe beruházni (pl.: én). Nekik az anaglif 3D módszer lehet egy (átmeneti) megoldás. Aki nem akarja elolvasni a wikipédiás cikket: ez az a módszer amikor piros-cián szemüveggel kell nézni a képet.

Tehát most ez utóbbival foglalkoztam. Egy olyan python scriptet raktam össze, ami benyel 2 képet, és kiadja magából az anaglif eredményt. Lássuk az elméletet mögötte:

Szemüveg


A lényeg az, hogy a piros-cián szemüveg lencséi a fénynek csak egyes komponenseit engedik tovább a szem felé. A bal szemnek a piros, a jobbnak a cián színű jut, azaz annyi a feladatunk, hogy a két forrás képből egy olyan képet állítsunk elő, amin a bal forrásképnek csak a piros csatornája szerepel, és a jobb forrásképnek pedig a zöld és kék csatornája (hisz e két szín keveréke adja ki a ciánt). Ezt az egyszerű módszert hívják color anaglyphnek.

Ezeket a színcsatorna műveleteket mátrixok írják le. Nagy segítség, hogy a Stereoscopic Player készítőinek weboldalán több módszert is felsorolnak. A bejegyzés további részében az optimized anaglyph eljárás implementálását mutatom be.

Naív kód

Természetesen először körbenéztem, hogy mások hogy valósítják meg a feladatot. Szinte általános, hogy mindenki a mátrixműveletekhez a NumPy matematikai libraryt használja. Használhatnám én is, viszont azt szerettem volna, hogy minél kevesebb függősége legyen a kész scriptnek.
A képfeldolgozáshoz elengedhetetlen a Python Image Library (PIL) beszerzése, ezt nem tudtam kiküszöbölni. Szerencsére találtam egy gyors megoldást, amivel a NumPy nélkül is gyorsan elkészül az eredmény.

Az eredeti ötletem az volt, mint sok más embernek is, hogy végig haladok egy ciklussal mindkét képen, és minden pixelre értelmezem a mátrix műveleteket. Ez egy 640*480-as felbontású képnél 307,200 mátrix szorzás, aminél a számítógép általában sokkal többet is lazán elvégez, de sajnos a Python igen hamar lassúnak mutatkozik ilyen igénybevétel mellett.

Forrás képeknek a fenti 3dtv.at weblap DeAnaglyph oldalán levő képeket használtam, így könnyen le tudtam ellenőrizni hogy ugyan olyan képet generál-e az én kódom mint amilyenek a weboldalon vannak.

Lássuk a kódot:
from PIL import Image

def transform(l, r):
    #http://www.3dtv.at/Knowhow/AnaglyphComparison_en.aspx
    ml = ((0, .7, .3),
          (0, 0, 0),
          (0, 0, 0))

    mr = ((0, 0, 0),
          (0, 1, 0),
          (0, 0, 1))

    return int(ml[0][0] * l[0] + ml[0][1] * l[1] + ml[0][2] * l[2] +
               mr[0][0] * r[0] + mr[0][1] * r[1] + mr[0][2] * r[2]), \
           int(ml[1][0] * l[0] + ml[1][1] * l[1] + ml[1][2] * l[2] +
               mr[1][0] * r[0] + mr[1][1] * r[1] + mr[1][2] * r[2]), \
           int(ml[2][0] * l[0] + ml[2][1] * l[1] + ml[2][2] * l[2] +
               mr[2][0] * r[0] + mr[2][1] * r[1] + mr[2][2] * r[2])


def PIL_naive(left, right, out):
    l = Image.open(left)
    r = Image.open(right)

    pixel_left = l.load()
    pixel_right = r.load()

    for x in xrange(l.size[0]):
        for y in xrange(l.size[1]):
            pixel_right[x, y] = transform(pixel_left[x, y], pixel_right[x, y])

    cR, cG, cB = r.split()
    #Gamma correction on red channel. Value = 1.5
    cR = Image.eval(cR, lambda px: ((float(px) / 255) ** (1/1.5)) * 255)

    Image.merge('RGB', (cR, cG, cB)).save(out)

PIL_naive('sample1left.jpg', 'sample1right.jpg', 'pil_naive.jpg')

A transform() függvényben megadtam a két mátrixot, ez az ml és mr. A szorzás ki van fejtve a return utasításban, mert nem akartam még ciklusokkal is lassítani a programot. Amúgy valószínűleg még itt is lehetne kicsit gyorsítani, de ez már így marad.

A PIL_naive() függvény végzi a piszkos munkát.
A 20 & 21. sorban betöltődik a paraméterül kapott kép kép 1-1 változóba.
A 23 & 24. sorban a .load() metódus visszaad egy-egy pixel access objektumot, amivel közvetlenül bele lehet nyúlni a betöltött kép pixeleinek információiba.
A 26 & 26. sorban elindulunk függőlegesen és vízszintesen a bal oldali kép felbontása szerint. Általában a két kép ugyan olyan méretű szokott lenni, így én most a bal képnek a méretei szerint haladok.

A függvényünk szíve a 28. sorban van, itt történik a transzformáció. Létrehozhattam volna egy 3. Image objektumot, de spórolni akartam a memóriával, így egyből visszaírom a kiszámított pixel színét a jobb oldali képbe. Ez lehetne a bal is.

A 30. sorban akár be is fejezhetnénk és kiírhatnánk a képet a lemezre, de ennek a bejegyzésnek az írása közben láttam csak meg, hogy gamma korrekciót végeztek a kész kép piros csatornáján. Ezért a 30. sorban felbontjuk a már anaglif képet színcsatornákra.
A 32. sorban a a cR nevű piros színcsatorna minden pixeljére alkalmazzuk az 1.5-ös gamma korrekciót. Ehhez a pixel színének értékét leosztjuk 255-el, hogy 0 és 1 közé essen, majd elvégezzük a hatványozást az 1.5 reciprokával (így lesz világosabb a kép), majd a végén felszorozzuk 255-el a pixel színét, hogy 0 .. 255 közé essen újra az értéke.
A 34. sorban összefűzzük a csatornákat, és elmentjük a képet az out változóban levő string névvel.

Futtatjuk a kódot, és az eredmény:
Összehasonlítva az eredeti képpel, szerintem tök egyformák.

Probléma: a sebesség

Ez a cikk azért született meg, mert a fenti kód, bár teljesen nyilvánvaló hogy mit csinál, de lassú. A mai napomat arra szántam, hogy átírom úgy a kódot, hogy több processzoron fusson. Az algoritmus felbontotta volna a képet annyi részre ahány processzormag van az aktuális számítógépben, majd mindegyik mag transzformálgatja a maga részét, a végén pedig összeragasztódnak a képek.

A fenti kód az én Core 2 Duo E8400-as gépemen 1.42 mp alatt fut le. Ez talán még elfogadható. Kipróbáltam a Raspberry Pi-men, ott 32.62 mp alatt készült el egy képpel, ami azért már eléggé sok.

A 2 magos gépen a várható gyorsulás legfeljebb kétszeres, tehát 700 ms, ami egyébként 1.4 FPS. Az RPi egymagos, azaz ott nem lesz gyorsabb.

Gyors konvertálás .convert() metódussal

Miközben olvasgattam a PIL kézikönyvet, a szemem a Image objektumok .convert() metódusánál erősen elkezdett csillogni. Ez a metódus képes átkonvertálni a kép palettáját egy mátrix segítségével. Gondoltam elég ha ide betápolom az anaglif mátrixokat, akkor mivel ez egy beépített metódus, biztos valahol a mélyben natív kód végzi majd a szorzást. Ez a gondolatom valószínűleg igaz is, mivel jelentős gyorsulást értem el így. De lássuk a kódot:
from PIL import Image, ImageChops

def PIL_only(left, right, out):
    #http://www.3dtv.at/Knowhow/AnaglyphComparison_en.aspx
    l = Image.open(left).convert('RGB', (0, .7, .3, 0,
                                         0, 0, 0, 0,
                                         0, 0, 0, 0))

    r = Image.open(right).convert('RGB', (0, 0, 0, 0,
                                          0, 1, 0, 0,
                                          0, 0, 1, 0))

    cR, cG, cB = ImageChops.add(l, r).split()
    cR = Image.eval(cR, lambda px: ((float(px) / 255) ** (1 / 1.5)) * 255)

    Image.merge('RGB', (cR, cG, cB)).save(out)

PIL_only('sample1left.jpg', 'sample1right.jpg', 'pil_only.jpg')

Ez a kód sokkal egyszerűbb, és lényegesen gyorsabb is.
Az érdemi munkát a PIL_only() függvény végzi.
Az 5. sorban betöltjük a left nevű argumentumban megkapott fájlnévhez tartozó fájlt, majd átkonvertáljuk a palettáját RGB-ből az adott mátrix szerint. A mátrixhoz hozzá kell csapni még egy oszlopot. Mondjuk az nem derült ki számomra konkrétan hogy ez az oszlop mit állít, csak feltételezem, hogy az alpha értéket. Mivel JPG képpel dolgozunk, és az nem támogatja az alphát, ezért ide szerintem amúgy akármit be lehetne írni.

A 13. sorban történik az izgalmas rész. Az ImageChops könyvtár .add() függvénye a két paraméterül kapott kép színcsatornáinak értékeit összeadja. A "Chops" amúgy a channel operations rövidítése, azaz csatorna műveletek. A naív implementációban is ez történt, csak ott kiszámolta a kód az egyik kép pixelének értékét, és azonnal hozzáadta a másik kép pixeléhez. Az összeadás ott is lehetett volna külön lépés.
Ugyan ebben a sorban, a .split() metódus felbontja a képet színcsatornákra. Erre ugye azért lesz szükség, hogy elvégezzük a gamma korrekciót a piros csatornán. Ez meg is történik a 14. sorban, hasonlóan a natív kódhoz.

Ezek után már csak a csatornák összefűzése következik, a függvényen kívül pedig a függvény hívása.

Eredmények


Gyorsabb? Mi az hogy! Az E8400-on 0.062 mp, ez 22.9x-es gyorsulás, ami már 16.12 FPS. A Raspberry Pi-n 0.8 mp, ami 40,7x-es gyorsulás. Ja és a kész kép:


Pontosan ugyan úgy néz ki

Nem próbáltam ki hogy NumPy-vel gyorsabb lenne-e a naív kódom mint ami a PIL beépített .convert() metódusát használja, majd egyszer kipróbálom azt is.
Azt viszont kipróbáltam, hogy gyorsabb-e ha a gamma korrekcióban az értékeket előre kiszámolom, és utána csak egy tömböt indexelgetek, de sajnos nem értem el gyorsulást. Előfordulhat viszont, hogy több pixellel idővel megjelenik valamilyen gyorsulás.