2013. március 3., vasárnap

Gamma korrekció Javascript canvason

TL;DR Live demo elérhető itt: http://jsfiddle.net/slapec/m8RN9/6/
Jó, rögtön a 7. bejegyzésem (ezen a blogon) egyből nem a pythonnal foglalkozik.

A korábbi anaglif kép előállító scriptem írása közben vettem csak észre, hogy a weboldalon ahol különböző módszereket felsorolják, az optimized anaglyph eljárásnál a kész kép piros színcsatornájára gamma korrekciót alkalmaznak. Ekkor derült csak ki, hogy a PIL-ben nincs erre dedikált metódus, szóval magamnak kellett leprogramozni. Ezzel véget is érhetett volna a móka, de valójából a pythonos kódom egy javascriptes projektből származik, amit akkor mondjuk nem mondtam, de mivel kiderült hogy a javascript canvas se ismeri ezt a funkciót, ezért most felfedhettem az eredeti szándékomat.

Jó pár órával nappal ezelőtt ültem le megírni a kódot amit itt most be szeretnék mutatni. Ez a sok idő elég volt arra, hogy kicsit elkanyarodjak a probléma "magjától", szóval 2 tonna csicsa került a gyakorlatilag 3 sornyi hasznos kód köré, de nem baj, imádom a csicsát :3. Lássuk hogy mit akartam:
Képszerkesztő programokban szokott lenni külön ablak, ahol gamma korrekciót lehet állítani, csinos kis csúszkákkal, és még a függvény képe is látszik 0 ≤ x ≤ 1 között, illetve azonnal lehet látni a végeredményt is.

Arról hogy mi a gamma korrekció az első bekezdésben levő linkben lehet olvasni. Az ottani képleten egy kicsit kell kalapálni, és ezt kapjuk a végén:


Arról van szó, hogy a kapott értéket (Vin) le kell osztani 255-el, hogy 0 ... 1 közé essen. A kapott értéket hatványozzuk a gammával, ami nálam valójából a gamma reciproka, de csak azért, hogy a gamma < 1 legyen általában (ez a gamma compression) majd a hatványozott értéket vissza szorozzuk 255-el, hogy újra 0 ... 255 közé essen, hogy aztán vissza lehessen rakni a helyére.

A HTML

Annyira nem túl izgalmas, de mivel van 1-2 HTML5-ös element a kódomban, ezért akkor kicsit részletezném azt is. Ide nem másoltam be a teljes kódot, azt meg lehet nézni a bejegyzés legelső sorában levő linkjén, meg majd alul is!
<canvas id='canvas' width='512' height='512'></canvas>
<div id='controlls' style='display: inline-block'>
    <form id='gammaForm'>
        <label for='red'>Piros</label>
        <input type='range' value='1.0' min='0.2' max='5.0' step='0.1'
        id='red' name='red'></input>
        <output for='red' name='red_out'>1</output>
        <br>
        <label for='green'>Zöld</label>
        <input type='range' value='1.0' min='0.2' max='5.0' step='0.1'
        id='green' name='green'></input>
        <output for='green' name='green_out'>1</output>
        <br>
        <label for='blue'>Kék</label>
        <input type='range' value='1.0' min='0.2' max='5.0' step='0.1'
        id='blue' name='blue'></input>
        <output for='blue' name='blue_out'>1</output>
    </form>
    <br>
    <canvas id='plot' width='150' height='150'></canvas>
</div>
<img style='display: none;' id='lena' src='data:image/png;base64,'>

Az 1. sorban létrehozunk egy 512x512-es canvast.
A 2. sorban egy nagy div lesz, ebbe lesznek a kezelő szervek, pontosabban a divben levő formban. Itt a csúszkák melletti szöveg label elemek közé vannak téve, ahogy illik.
Az 5. sorban egy range típusú inputot definiáltam, amit amúgy a Firefox még nem támogat, szóval ott nem fog látszódni.
Ez egy vízszintes csúszka lesz. Az alap értéke 1.0, 0.2-ig mehet le, 5.0-ig mehet fel 0.1-es lépésekkel. Az id-je és a name-je megegyezik, mert a label-ben a for-hoz name-et kell írni... legalábbis azt hiszem ezért csináltam így.
A 7. sorban egy output element van, ebbe lesz majd beleírni a csúszka aktuális értéke. A label és az output element amúgy a weboldalon 1-1 sima szövegként fog megjelenni igazából, a különbség majd a JS oldalán lesz, illetve a böngésző pontosan tudja majd hogy mik a szándékaink.
A 23. sorig bezárólag copypasta van, de ott megjelenik még egy 150x150-es canvas, amibe majd a gamma függvény képét rajzoljuk csatornánként.
A 25. sorba egy img van, ide kerül a kép, az id-je 'lena'.
Én a src attribútumba a képet inline módon, data URI-val bemásoltam. Így a HTML fájl vagy 30kb lett, de így nem akadok bele a JS same origin policy-jába, meg a példának megteszi ez is.
Ez a kép CSS-el el lett rejtve, mert igazából nincs rá szükség, szerintem. Összehasonlítás miatt mondjuk egymás mellé lehetne tenni, de csak ennyi ötletem van.

A Javascript

Végre elérkeztem az izgalmas részhez. Először szedjük szét a feladatot részekre:

  1. Kell egy függvény ami ami benyeli a csatornánkénti (R, G, B) gammát, és módosítja a képet
  2. Kell egy függvény ami megrajzolja a gamma függvény képét
  3. Kell egy függvény ami frissíti az output elemet
(Amúgy ami a Javascriptes programozási stílusomat illeti, ez így a C-s és Pythonos élményeim egyvelege, valódi JS szakértelmet csak nyomokban tartalmaz. Na ettől még annyira nem katasztrófa, csak nem olyan elegáns)

Kép módosítása

El se hiszem, végre itt a bejegyzésem magja. Ne is várakozzunk tovább, itt a kód:
function gammaApply(gR, gG, gB) {
    var canvas, ctx, lena;
    var i, len, px, imgArray, imgData;

    var lut_r = [],
        lut_g = [],
        lut_b = [];

    lena = document.getElementById('lena');

    canvas = document.getElementById('canvas');
    ctx = canvas.getContext('2d');
    ctx.drawImage(lena, 0, 0);

    imgData = ctx.getImageData(0, 0, 512, 512);
    imgArray = imgData.data;
    len = imgData.width * imgData.height * 4;

    for (i = 0; i < 256; i++) {
        lut_r[i] = gammaCalc(i, gR);
        lut_g[i] = gammaCalc(i, gG);
        lut_b[i] = gammaCalc(i, gB);
    }

    for (i = 0; i < len; i += 4) {
        imgArray[i] = lut_r[imgArray[i]];
        imgArray[i + 1] = lut_g[imgArray[i + 1]];
        imgArray[i + 2] = lut_b[imgArray[i + 2]];
    }

    ctx.putImageData(imgData, 0, 0);
}

Hívjuk ezt a függvényt a továbbiakban gammaApply()-nak.
A 2-7. sorban deklarálok pár változót.

  • A canvas nevű mutat majd a weboldal 'canvas' nevű canvasára (ej, de szar neveket adtam, most jövök rá). 
  • A ctx-ben a contextünk lesz ezen a canvason. 
  • A lena-ba a kép lesz majd, ami a HTML-be kézzel bele lett írva.
  • Az i majd a ciklusváltozó lesz
  • A len-be a kapott kép mérete lesz
  • Az imgArray mutat majd a canvasról kihalászott kép tömbjére
  • Az imgData-ba lesz majd a canvasról kihalászott kép maga
Az 5. sorban 3 db lookup table-t is deklaráltam, minden csatornának egyet-egyet. Erre azért lesz szükség, mert nagyon sokat tud gyorsítani majd a korrekció alkalmazásánál.

A 9. sorban a lena változót ráállítjuk a képre.
A 11. sorban a canvas változót ráállítjuk a canvasra amire majd a korrigált képet rajzoljuk.
A 12. és 13. sorban először kérünk egy 2D contextet, majd rárajzoljuk a lena változóban levő képet a canvasra.
Erre a lépésre azért volt szükség, mert JS-el csak így tudjuk majd kikérni a kép bináris adatát. Arra, hogy az adatot kikérjük egy átmeneti canvast is felhasználhatnánk, de az csak plusz idő és memória. Most tehát rárajzoljuk a képet a canvasra, kikérjük az adatot, azt átírjuk majd visszaírjuk az adatot a canvasra és BUM kész is.
A 15. sorban kikérjük tehát a kép adatát, ez az imgData változóban lesz.
A 16. sorban ráállítjuk az imgArray változót az imgData .data propertijére, így egy kicsivel gyorsabb lesz az adat elérése.
A 17. sorban kiszámoljuk hogy mekkora lesz az a tömb amiben az RGB értékek benne vannak, tehát az imgArray mérete. Ez amúgy benne van az imgArray-ben, viszont így hogy ki van írva el tudom magyarázni, hogy hogy jön ki a méret.
A szélességet szorozzuk a magassággal, ez idáig rendbe van, megkapjuk hány pixel a kép összesen. Egy pixelt viszont JS-ben 4 érték ír le, a pixel R G B és A csatornájának értékei (az A az alpha), ezért kerül a 4-el szorzás a sor végére.
A 19. sorban jön az első ciklus, ez viszont még csak a LUT-ot számolja ki. A gondolat a következő:
Az 512x512-es kép mind a három színcsatornájára alkalmazunk a korrekciót, az 786,432 függvényhívás minimum, ami sok, de minden 0 ... 255 értékhez ugyan az a korrigált érték fog tartozni, tehát sokkal gyorsabb kiszámolni a 3 csatornára előre az összes korrigált értéket, hisz az csak 3 * 255 = 765 függvényhívás, és utána kikeresni a tömbből hogy majd melyik pixelhez milyen korrigált érték társul. A tömbből a kikeresés se lesz nehéz: A 0-hoz tartozó érték a tömb 0. indexén van, az 1 az 1-en ... a 255 a 255-ön.
A ciklusban elszámolunk 0-tól 255-ig, meglesz minden lehetséges érték amit egy színcsatorna felvehet. A gammaCalc() függvény megkapja a pixel értékét és a gammát és visszaadja a korrigált értéket. Ebben a ciklusban most az i változó reprezentálja a pixelt magát. Aki nem értené:
Van 1 pixelünk, ami mondjuk RGB(76, 149, 29) színű. A gammaCalc() megkapja a 76-ot és a gammát, az legyen most 1.5, kihányja hogy 113, azaz ennyi a korrigált értéke. A LUT-ban a lut_r 76. indexén a 113-as érték fog majd lenni, azaz elég lesz majd a pixel R csatornájának értékével megindexelni a lut_r-t, és készen is vagyunk.
A 25. sorban végigugrálunk négyesével a kép tömbjén, az imgArray-en. 
Így az imgArray[i] az R lesz, az i+1 a G, i+2 a B i+3 az A. A cikluson belül, ahogy feljebb írtam, megindexeljük a csatornák LUTjait a csatornák értékeivel, és amit kapunk azt állítjuk be a csatorna új értékének, szóval ez a korrigált érték kikeresése.
A 31. sorban visszarakjuk a canvas-ra az imageData objektumot, amit az előbb módosítottunk az imgArray-en keresztül, hiszen az imgArray az imageData.data-jára mutatott.

Ezzel készen is van a függvény, a lényeg készen van.

Korrekció

function gammaCalc(px, g) {
    return parseInt(Math.pow(px / 255, 1 / g) * 255, 10);
}

Ez az egyszerű kis függvény számolja ki a korrigált értékeket. A bejegyzés elején levő képlet javascriptes verziója. Annyi megjegyzés csak, hogy az egész kifejezés egy parseInt() függvénybe van téve, ami int-é castolja a korrekció eredményét. A biztonság kedvéért kapott ez a függvény egy második paramétert, ami azt jelzi, hogy milyen számrendszerű az első paramétere. Most így belegondolva, ennek csak akkor lenne haszna, ha 0-val kezdődő számokat kéne feldolgozni (az jelzi a 8-as számrendszert), de ilyen itt nem fordulhat elő. Sebaj, ez már így marad.

Függvény rajzolás

A függvény kirajzolásának nagy izgalommal estem neki, aztán végül is egy kis buta egyszerű algoritmus lett a vége. gammaPlot()-nak neveztem el, és a rajzolás maga eléggé egyszerű benne:
Vesz egy canvast, elindul vízszintesen pixelenként, és az épp aktuális pozíciójának az x koordinátáját betolja a korrekciót számoló képletbe, megkapjuk az y koordinátát, szóval mint egy sima kis függvény az általános iskolából.
function gammaPlot(r, g, b) {
    var canvas, ctx;
    var w, h, i;

    canvas = document.getElementById('plot');
    w = canvas.width;
    h = canvas.height;

    ctx = canvas.getContext('2d');

    ctx.fillStyle = '#DBDBDB';
    ctx.lineWidth = 2;
    ctx.fillRect(0, 0, w, h);

    //red
    ctx.beginPath();
    ctx.strokeStyle = '#F00';
    ctx.moveTo(0, h);
    for (i = 0; i < w; i++) {
        ctx.lineTo(i, w - (Math.pow(i / w, 1 / r) * w));
    }
    ctx.stroke();
    ctx.closePath();

    //green
    ctx.beginPath();
    ctx.strokeStyle = '#0F0';
    ctx.moveTo(0, h);
    for (i = 0; i < w; i++) {
        ctx.lineTo(i, w - (Math.pow(i / w, 1 / g) * w));
    }
    ctx.stroke();
    ctx.closePath();

    //blue
    ctx.beginPath();
    ctx.strokeStyle = '#00F';
    ctx.moveTo(0, h);
    for (i = 0; i < w; i++) {
        ctx.lineTo(i, w - (Math.pow(i / w, 1 / b) * w));
    }
    ctx.stroke();
    ctx.closePath();
}
A 2. és 3. sorban deklarálok megint pár változót, a canvasnak, a contextnek, szélességnek/magasságnak meg ciklusváltozónak.
5. sorban rámutat a kód a 'plot' canvasra
A 6. & 7. sorban lekérjük a canvas méreteit, szóval így dinamikus lesz kicsit a kód. A 9. sorban beállítjuk még a contextet.
A 11. sorban beállítom az aktuális kitöltő színt és a vonalvastagságot, majd az egész canvasra húzok egy négyzetet, így lesz egy kis háttérszín is.
A 16. sorban kezdődik az érdemi munka. A beginPath()-al kezdi rögzíteni a JS hogy majd milyen útvonalon húzza végig a tollat.
A 17. sorban beállítom a toll színét, ez most #F00
A 18. sorban lekerül a toll a bal-alsó sarokba
A 19. sorban történik a kirajzolás.
Elindul egy ciklus, ami végigszámol egyesével a vízszintes tengely mentén. A cikluson belül a lineTo()-ba állítjuk be hogy hova menjen majd a toll. Az első paramétere maga a ciklusváltozó, azaz vízszintesen mindig 1-el megy majd odébb a toll, ez az X tengely. A függőleges irányt a bejegyzés elején levő képlet alapján számoljuk, annyi a különbség, hogy nem 255-el van leosztva az i, hanem a szélességgel, hisz a lényeg az, hogy a tört 0 ... 1 közé essen. Jó, 255-el osztásnál is oda esne ha a canvas szélessége <256, de így éppen pontosan keresztbe teljesen végig ér a vonal.
A 22. sorban kirajzolódik a vonal, és a 23. sorban lezárjuk a toll mozgásának rögzítését.
Ezek után pontosan ugyan ez a kód van még kétszer lemásolva, a különbség egyedül az, hogy a különböző csatornák számolásánál más-más gammákkal fut a kirajzolás. Ezeket a gammákat a függvény paraméterül kapta, de ez látszik.

Most hogy elkészült a képet módosító függvény, és a függvényt kirajzoló függvény, már csak össze kell fogni a dolgokat.

Csúszkák kezelése

Az alábbi függvény mindig meghívódik, amikor valami változik a weboldal formjában:
function updateOutputs(form) {
    var gR = form.red.valueAsNumber,
        gG = form.green.valueAsNumber,
        gB = form.blue.valueAsNumber;

    form.red_out.value = gR;
    form.green_out.value = gG;
    form.blue_out.value = gB;

    return {
        'red': gR,
        'green': gG,
        'blue': gB
    };
}
A függvény megkapja a formot, így ebbe nem lesz getElementById..

A 2-4. sorban kiszedegetjük a csúszkák értékeit. Szerencsére van olyan properyjük, amivel azonnal számként kapjuk az értékeiket, szóval nem kell nekem castolni.
A 6-8. sorban az előbb kiszedett értékeket beállítom az outputoknak, így látszódik majd hogy melyik csúszka hogy áll
A függvény a végén visszatér a beállított értékekkel, azaz a gammákkal. Arra gondoltam hogy ha már ez a függvény úgyis mindig meghívódik, és tudja hogy ki és mit állított be, akkor vissza is adhatná, és nem kell más függvénynek magának összeszednie.

window.onload

Már csak annyi van hátra, hogy megírjuk hogy mi kerül a window.onload-ba, azaz mi fusson ha betöltődött teljesen a weboldal.
gammaApply(1, 1, 1);
gammaPlot(1, 1, 1);

form = document.getElementById('gammaForm');
form.oninput = function () {
    var g = updateOutputs(this);
    gammaApply(g.red, g.green, g.blue);
    gammaPlot(g.red, g.green, g.blue);
};

Kézzel 1,1,1-es gammákkal meghíjuk a gammaAply-t és plot-ot, így megjeleni a kép, és a függvény is kirajzolódik.
A 4. sorban kikérjük a DOM-ból a gammaForm nevű elementet, szóval a formot, na.
Az 5. sorban a form oninput eventjére beállítunk egy függvényt. Az oninput mindig lefut majd, amikor valami változik a form területén.
A 6. sorban meghívjuk az updateOutputs()-ot, ami ugye átírja majd az output elementeket és vissza is adja hogy miket állított be.
A 7. sorban a kapott gammákkal meghívjuk a gammaApply-t ami kirajzolja a képet, és a 8. sorban a gammaPlot pedig kirajzolja a függvény képét

Végeredmény

Itt látható mindenestől: http://jsfiddle.net/slapec/m8RN9/6/
Akit nem érdekel a kód: http://jsfiddle.net/slapec/m8RN9/embedded/result/

Zárás

Te jó ég! Szerintem kicsit túl sokat írok, az biztos. Ennyi elég is volt egy időre :)

-slp

Nincsenek megjegyzések:

Megjegyzés küldése