Artykuły

[Konkurs Adobers.org] – Program do tworzenia ascii-artow

28:12:07 18:50

Do czego sluży ten program?

Idea jest prosta:
wrzucamy odpowiednio skontrastowane zdjecie, a skrypt generuje nam obrazek ascii-art. Przykład możecie zobaczyć tutaj: pobierz plik

Jak to ma działać?

wizualizacja algorytmu Trzy kroki tworzenia ascii-artu

Przedstawiam uproszczony algorytm:

  • program analizuje obrazek i tworzy pole tekstowe o takim samym rozmiarze,
  • dzieli obrazek na prostokąty o wymiarach dokladnie takich, jakie zajmuje jedna litera i z każdego prostokąta wybiera średnią wartość koloru,
  • w miejsce każdego prostokąta wstawia znak o proporcjonalnym zaciemnieniu (czyli w ciemne miejsce wstawia np. #, a w jasne spacje).

Implementacja - generowanie tablicy znaków "rysujących"

tablica znakówznaki rysujące: od najjaśniejszego do najciemniejszego

Pierwsza rzecz, której potrzebujemy w algorytmie to odpowiednia tablica znaków - od najjaśniejszego do najciemniejszego. By ją wygenerować trzeba posortować wszystkie możliwe znaki wg ilości czarnych pikseli w każdym z nich. Wtedy będziemy wiedzieć, że np. znak # w czcionce Courier o rozmiarze 9 składa się z 15 px, spacja nie ma żadnego czarnego pixela, a dziwny francuski krzaczek ma ich aż 25 i jest najciemniejszy z wszystkich możliwych. By to zilustrować, załączam zdjęcie z przykładową tablicą posortowanych znaków. Napiszemy program tak, by za każdym razem generował tablicę znaków od nowa - ubiezpieczy nas to, przed ewentualnymi błędami wynikającymi z tego, że np. na Linuksie ktoś ma inną standardową czcionkę niż my.


Najpierw ustalamy formatowanie pola tekstowego.
Pole tekstowe musi mieć czcionkę nieproporcjonalną (o stałej szerokości) - dlatego wybierzemy "_typewriter'a" (mówi to flashowi, ze ma korzystać ze standardowej, zainstalowanej na komputerze klienta czcionki nieproporcjonalnej). Rozmiar ustawiamy na najmniejszy możliwy, dzięki temu ascii-art bedzie dokładniejszy.

var format:TextFormat = new TextFormat();
format.font = "_typewriter";
format.size = 9;

Teraz liczymy ile pikseli ma każdy znak.
Najistotniejszym fragmentem poniższego skryptu jest pętla for, w której wykonują się takie polecenia:

  • tf.text = String.fromCharCode( i );  wyświetla w polu tekstowym po kolei wszystkie znaki o kodach z przedziału od 0 do 600.
  • letterBmp.draw( tf ); Przekształca aktualną literkę na bitmapę letterBmp.
  • blackPixels = letterBmp.threshold( ... ) Wylicza ile czarnych pikseli ma dana literka i czyści je. Metoda threshold służy do wyszukiwania i zmieniania pikseli spelniajacych dany warunek. Czyli komputer przeczyta nasze polecenie mniej więcej tak: W instancji klasy BitmapData o nazwie "letterBmp" w przedziale bitmapy o polu zakreslonym przez kwadrat new Rectangle(0,0,100,100) ktorego lewy gorny róg jest w Point(0, 0) wyszukaj piksele ktore mają kolor == 0x000000. Jeśli mają taki kolor, to zmień go na 0xFFFFFF. Pod koniec powiedz ile pikseli zmieniłeś i zwróć to jako wynik operacji (return).
  • chars[ blackPixels ] = tf.text W tablicy chars zapamietaj każdy znak. Indeksem jest liczba czarnych pikseli zapisanego znaku.

// w tf bedziemy wyswietlac kazda litere z osobna
var tf:TextField = path.createTextField("box",1,100,100,25,25);
tf.setNewTextFormat( format );

// letterBmp bedzie przechowywal litery jako bitmapy
var letterBmp:BitmapData = new BitmapData(100,100,false,0xFFFFFF);

var blackPixels:Number;
var chars:Array = [];

for ( var i:Number = 0; i < 600; i++ )
{
    tf.text = String.fromCharCode( i );
    letterBmp.draw( tf );
    blackPixels = letterBmp.threshold( letterBmp, 
                                      new Rectangle(0,0,100,100),
                                      new Point(0, 0),
                                      "==",
                                      0,
                                      0xFFFFFF);
    chars[ blackPixels ] = tf.text;
}

Wybieramy posortowane znaki.
W pętli od 0 do 100 sprawdzamy czy w tablicy char istnieje znak - jeśli tak, to kopiujemy go do tablicy palette. Otrzymamy pięknie posegregowany ciąg znaków od jasnych do ciemnych (efekty można obejrzeć dzięki poleceniu trace). Oczywiście można by było jeden raz wygenerować tablice znaków, a potem wkleić ją do programu var palette:Array = ['.', '-', ... ]. Ale wystarczy, ze ktoś będzie używał innej czcionki nieproporcjonalnej niż my i program zadziała nie tak jak powinien.

var palette:Array = [];
for (var i:Number = 0; i <= 100; i++) 
{
    if ( chars[ i ] != undefined )
    {
        palette.push( chars[i] );
    }
}
trace( palette.join('') );
/*
    Ewentualnie zamiast powyzszego 
    kodu mozna wkleic po prostu:
    var str:String = "znaki od najjaśniejszego do najciemniejszego";
    var palette:Array = str.split('');
*/
//usuwamy pole tf, bo juz nie bedzie nam do niczego potrzebne
tf.removeTextField();

Implementacja - tworzenie obrazków ascii-art

Po krótce, pobieramy z biblioteki (flashowe Library) obrazek, który chcemy przekonwertować. Tworzymy pole tekstowe w którym wyświetlimy nasz ascii-art i zbieramy wszystkie potrzebne informacje, np.: wymiary jednej literki.

// identyfikator bitmapy  w Library
var linkageId:String = 'fota';
// MovieClip w ktorym bedziemy dzialac
var path:MovieClip = this;

// pobieramy z Library obrazek z linkageId
var image:BitmapData = BitmapData.loadBitmap( linkageId );

// tworzymy MovieClip w ktorym ten obrazek bedzie przechowywany
var imageMc:MovieClip = path.createEmptyMovieClip( 'image_clip', 1 );
// dodajemy obrazek "image" do MovieClipa "imageMc"
imageMc.attachBitmap( image, 1 );

// zapamietujemy wysokosc i szerokosc obrazka
var imageW:Number = image.width;
var imageH:Number = image.height;

// tworzymy pole tekstowe, w ktorym bedziemy zapisywac obrazek
var imageAscii:TextField = path.createTextField( 
                            "asciiArt",
                            path.getNextHighestDepth(),
                            0, 0, imageW, imageH );
// koniecznie musimy uzyc tego samego formatowania co przy 
// generowaniu tablicy znakow.
imageAscii.setNewTextFormat( format );
// type input - zeby sobie mozna samemu wprowadzac poprawki ;)
imageAscii.type = "input";
// ustalamy pozycje obrazka Ascii
imageAscii._x = imageMc._width;

// wrzucamy przykladowa litere (w czcionkach nieproporcjonalnych
//wszystkie litery maja takie same rozmiary) 
// i mierzymy wysokosc i szerokosc litery
imageAscii.text = "A";
var letterHeight:Number = imageAscii.textHeight;
var letterWidth:Number = imageAscii.textWidth;    

// tworzymy Stringa w którym będziemy przechowywać
// nasz obrazek Ascii Art. Dopiero na koniec przekopiujemy
// to do pola tekstowego.
var asciiArt:String = "";

Wyliczenie liczby rzędów i kolumn pola tekstowego.
By wyliczyć ile rzędów liter wykorzystamy do zamalowania obrazka musimy podzielić szerokość obrazka przez szerokość litery i wynik dzielenia zaokrąglić w dół. Analogicznie dla kolumn. Wiadomo że szerokość obrazka nie będzie się idealnie pokrywać z szerokością pola tekstowego. Marginesów obrazka wystających poza pole tekstowe nie będziemy uwzględniać (szerokość obrazka, bez marginesów zapisujemy w zmiennych pxHeight i pxWidth)

var columns:Number = Math.floor( imageH / letterHeight );
var rows:Number = Math.floor(imageW / letterWidth);
var pxHeight = columns * letterHeight;
var pxWidth = rows * letterWidth;

A teraz najważniejsza część programu - tworzenie ascii-rysunku.
Dzielimy obrazek na n prostokątów o wysokości i szerokości jednego znaku. W pętli analizujemy każdy prostokąt i wyliczamy jego średnią wartość koloru, tak by potem wstawić w to miejsce literkę o odpowiednim zaciemnieniu. Pytanie jak to zrobić? Wiemy, że każdy kolor jest zapisany jako 24 bitowa liczba, gdzie pierwsze 8 bitów reprezentuje składową koloru czerwonego, nastepne 8 składową koloru zielonego, a ostatnie 8 składową koloru niebieskiego (RedGreenBlue). Czyli np. kolor czerwony w systemie szesnastkowym ma taką postać 0x FF 00 00. By uzyskać średnią wartość koloru musimy wszystkie składowe dodać do siebie i podzielić przez trzy. Nie będę tutaj opisywał działania na operatorach bitowych, zainteresowanych odsyłam na binboy'a.

  • Linia kodu color = image.getPixel( k, l ); pobiera kolor z piksela o współrzędnych k i l obrazka image.
  • Kod rgb += ( color >> 16 ) + ( ( color >> 8 ) & 0xFF ) + ( color & 0xFF ); sumuje wszystkie 3 składowe i dodaje do zmiennej rgb.
  • Wartość zmiennej rgb musimy podzielić przez trzy (trzy składowe r, g, b) i przez ilość pikseli w prostokącie, by uzyskać średnią wartość koloru jednego piksela.
  • By znaleźć literkę najlepiej odwzorowywującą dany kolor, układamy proporcje
    0xFF (maksymalna wartość składowej) --- ilość znaków rysujących
    średni kolor danego prostokąta      --- szukany znak ascii
    i stąd wiemy, że znak odwzorowywujący dany obszar obrazka można wyliczyć z wzoru: indeks szukanego znaku = średni_kolor_prostokata * ilość_znaków_rysujacych / 0xFF .
  • Wszystkie te obliczenia można skompilować w jeden współczynnik ratio = charsLength / ( 0xFF * letterHeight * letterWidth * 3 ); i potem znajdować odpowiedni odpowiedni znak w tablicy poprzez palette[ Math.round( rgb * ratio) ]; Ale czegoœ tu jeszcze brakuje - z takim kodem program wstawiałby w jasne miejsca ciemne znaki. Wynika to z tego, że jasne kolory mają wysoką wartość, a ciemne znaki wysoki indeks w tablicy. Musimy troche zmodyfikować linijkę i wszystko będzie śmigać: palette [ charsLength - Math.round( rgb * ratio) ];

var color:Number, i:Number, j:Number, k:Number, l:Number, rgb:Number;
// -1, poniewaz szukamy indeksu tablicy, a one sa numerowane od 0
var charsLength:Number = palette.length - 1;
var ratio:Number = 
             charsLength / ( 0xFF * letterHeight * letterWidth * 3 );

for (j = 0; j < pxHeight; j += letterHeight)
{
    for (i = 0; i < pxWidth; i += letterWidth) 
    {
        // jestesmy w prostokacie, musimy teraz zebrac 
        // jego srednia wartosc koloru
        rgb = 0
        for ( k = i; k < i + letterWidth; k++ )
        {
            for ( l = j; l < j + letterHeight; l++ )
            {
                color = image.getPixel( k, l );
                rgb +=     ( color >> 16 )
                         + ( ( color >> 8 ) & 0xFF ) 
                         + ( color & 0xFF );
            }
        }
        // dodajemy znak do stringa przechowywującego obrazek ascii
        asciiArt += palette[ charsLength - Math.round( rgb*ratio) ];
    }
    // po ostatniej kolumnie trzeba dodac znak nowej linii
    asciiArt += '\n';
} 

To już prawie koniec! ;)
Zostało najprzyjemniejsze, czyli wyświetlamy:

imageAscii.text = asciiArt;

W razie wszelkich wątpliwości polecam w kolejności: help flasha, search na forum fz, google, pytanie w komentarzach do artykułu. Jeśli komuś się powyższy tutorial podoba, czegoś się nauczył - niech da znać, może skrobnę coś jeszcze :).

Na koniec podaję źródła do pliku fla (aha, obrazki tam użyte są wylosowane z googla, mam nadzieję, że żaden autor nie czuje się tym urażony ;) )

Info

Filip Zawada