Konwersja obrazu do grafiki ASCII

102

Prolog

Ten temat pojawia się od czasu do czasu w Stack Overflow, ale zwykle jest usuwany, ponieważ jest źle napisanym pytaniem. Widziałem wiele takich pytań, a następnie milczenie ze strony PO (zwykle niski przedstawiciel), gdy wymagane są dodatkowe informacje. Od czasu do czasu, jeśli dane wejściowe są dla mnie wystarczająco dobre, decyduję się odpowiedzieć i zwykle dostaje kilka głosów pozytywnych dziennie, gdy jest aktywny, ale potem po kilku tygodniach pytanie jest usuwane / usuwane i wszystko zaczyna się od początek. Postanowiłem więc napisać te pytania i odpowiedzi, aby móc bezpośrednio odnosić się do takich pytań, bez ponownego przepisywania odpowiedzi…

Innym powodem jest również ten metatątek skierowany do mnie, więc jeśli masz dodatkowe informacje, nie krępuj się komentować.

Pytanie

Jak przekonwertować obraz bitmapowy na grafikę ASCII przy użyciu C ++ ?

Niektóre ograniczenia:

  • obrazy w skali szarości
  • używając czcionek o mono-odstępach
  • zachowanie prostoty (nie używanie zbyt zaawansowanych rzeczy dla początkujących programistów)

Oto powiązana strona Wikipedii Sztuka ASCII (dzięki @RogerRowland).

Tutaj podobny labirynt do konwersji ASCII Art .

Spektre
źródło
Używając tej strony wiki jako odniesienia, czy możesz wyjaśnić, do jakiego rodzaju grafiki ASCII się odnosisz? Brzmi to dla mnie jak „Konwersja obrazu na tekst”, która jest „prostym” spojrzeniem z pikseli w skali szarości na odpowiedni znak tekstowy, więc zastanawiam się, czy masz na myśli coś innego. Wygląda na to, że i tak odpowiesz sobie sam .....
Roger Rowland,
Związane z: stackoverflow.com/q/26347985/2564301
usr2564301
@RogerRowland zarówno proste (bazujące tylko na intensywności skali szarości), jak i bardziej zaawansowane, biorąc pod uwagę również kształt postaci (ale wciąż dość proste)
Spektre
1
Chociaż twoja praca jest świetna, z pewnością doceniłbym wybór próbek, które są nieco bardziej SFW.
kmote
@TimCastelijns Jeśli przeczytasz prolog, to zobaczysz, że nie jest to pierwszy przypadek poproszenia o tego typu odpowiedź (a większość wyborców od początku znała kilka wcześniejszych pytań, więc reszta po prostu głosowała odpowiednio), ponieważ to nie tylko pytania i odpowiedzi P : Nie marnowałem zbyt wiele czasu z częścią Q (co jest winą z mojej strony, przyznaję) dodałem kilka ograniczeń do pytania, czy masz lepsze, możesz je edytować.
Spektre

Odpowiedzi:

152

Istnieje więcej podejść do konwersji obrazu do grafiki ASCII, które są w większości oparte na użyciu czcionek o pojedynczej szerokości . Dla uproszczenia trzymam się tylko podstaw:

Na podstawie intensywności pikseli / obszaru (cieniowanie)

To podejście traktuje każdy piksel obszaru pikseli jako pojedynczą kropkę. Chodzi o to, aby obliczyć średnią intensywność skali szarości tej kropki, a następnie zastąpić ją znakiem o intensywności zbliżonej do obliczonej. W tym celu potrzebujemy jakiejś listy użytecznych znaków, z których każda ma wstępnie obliczoną intensywność. Nazwijmy to postacią map. Aby szybciej wybrać, która postać jest najlepsza dla danej intensywności, istnieją dwa sposoby:

  1. Mapa znaków o rozkładzie liniowym intensywności

    Więc używamy tylko znaków, które mają różnicę intensywności w tym samym kroku. Innymi słowy, po posortowaniu rosnąco:

     intensity_of(map[i])=intensity_of(map[i-1])+constant;

    Również kiedy nasza postać mapjest posortowana, możemy obliczyć znak bezpośrednio z intensywności (bez potrzeby wyszukiwania)

     character = map[intensity_of(dot)/constant];
  2. Mapa znaków o arbitralnym rozkładzie intensywności

    Mamy więc szereg użytecznych postaci i ich intensywności. Musimy znaleźć intensywność najbliższą wartości, intensity_of(dot)więc ponownie, jeśli posortowaliśmy map[], możemy użyć wyszukiwania binarnego, w przeciwnym razie potrzebujemy O(n)pętli wyszukiwania minimalnej odległości lub O(1)słownika. Czasami dla uproszczenia postać map[]może być traktowana jako rozłożona liniowo, powodując niewielkie zniekształcenie gamma, zwykle niewidoczne w wyniku, chyba że wiesz, czego szukać.

Konwersja oparta na intensywności jest świetna również w przypadku obrazów w skali szarości (nie tylko czarno-białych). Jeśli wybierzesz kropkę jako pojedynczy piksel, wynik stanie się duży (jeden piksel -> pojedynczy znak), więc w przypadku większych obrazów zamiast tego wybierany jest obszar (wielokrotność rozmiaru czcionki), aby zachować proporcje i nie powiększać zbytnio.

Jak to zrobić:

  1. Równomiernego podziału obrazu w skali szarości (pikseli), lub (prostokątne) powierzchnie dot s
  2. Oblicz intensywność każdego piksela / obszaru
  3. Zastąp go znakiem z mapy znaków o najbliższej intensywności

Jako postać mapmożesz użyć dowolnych znaków, ale wynik jest lepszy, jeśli postać ma równomiernie rozmieszczone piksele wzdłuż obszaru znaku. Na początek możesz użyć:

  • char map[10]=" .,:;ox%#@";

posortowane malejąco i udawaj rozkład liniowy.

Więc jeśli intensywność piksela / obszaru jest równa, i = <0-255>to znak zastępczy będzie

  • map[(255-i)*10/256];

Jeśli i==0wtedy piksel / obszar jest czarny, jeśli i==127wtedy piksel / obszar jest szary, a jeśli i==255wtedy piksel / obszar jest biały. Możesz eksperymentować z różnymi postaciami w środku map[]...

Oto mój starożytny przykład w C ++ i VCL:

AnsiString m = " .,:;ox%#@";
Graphics::TBitmap *bmp = new Graphics::TBitmap;
bmp->LoadFromFile("pic.bmp");
bmp->HandleType = bmDIB;
bmp->PixelFormat = pf24bit;

int x, y, i, c, l;
BYTE *p;
AnsiString s, endl;
endl = char(13); endl += char(10);
l = m.Length();
s ="";
for (y=0; y<bmp->Height; y++)
{
    p = (BYTE*)bmp->ScanLine[y];
    for (x=0; x<bmp->Width; x++)
    {
        i  = p[x+x+x+0];
        i += p[x+x+x+1];
        i += p[x+x+x+2];
        i = (i*l)/768;
        s += m[l-i];
    }
    s += endl;
}
mm_log->Lines->Text = s;
mm_log->Lines->SaveToFile("pic.txt");
delete bmp;

Musisz wymienić / zignorować rzeczy VCL, chyba że używasz środowiska Borland / Embarcadero .

  • mm_log to notatka, w której wyprowadzany jest tekst
  • bmp jest wejściową mapą bitową
  • AnsiStringjest ciągiem typu VCL indeksowanym od 1, a nie od 0 jako char*!!!

Oto wynik: Przykładowy obraz o lekkiej intensywności NSFW

Po lewej stronie znajduje się obraz wyjściowy ASCII (rozmiar czcionki 5 pikseli), a po prawej obraz wejściowy powiększony kilka razy. Jak widać, wynik ma większy piksel -> znak. Jeśli używasz większych obszarów zamiast pikseli, powiększenie jest mniejsze, ale oczywiście wynik jest mniej przyjemny wizualnie. Takie podejście jest bardzo łatwe i szybkie w kodowaniu / przetwarzaniu.

Gdy dodasz bardziej zaawansowane rzeczy, takie jak:

  • automatyczne obliczenia map
  • automatyczny wybór rozmiaru piksela / obszaru
  • korekty współczynnika kształtu

Następnie możesz przetwarzać bardziej złożone obrazy z lepszymi wynikami:

Oto wynik w stosunku 1: 1 (powiększ, aby zobaczyć znaki):

Zaawansowany przykład intensywności

Oczywiście przy próbkowaniu obszaru tracisz drobne szczegóły. To jest obraz o takim samym rozmiarze jak pierwszy przykład z próbkowanymi obszarami:

Zaawansowany przykładowy obraz o nieznacznej intensywności NSFW

Jak widać, jest to bardziej odpowiednie dla większych obrazów.

Dopasowywanie znaków (hybryda między cieniowaniem a jednolitą grafiką ASCII)

To podejście próbuje zastąpić obszar (nie więcej pojedynczych punktów pikselowych) charakterem o podobnej intensywności i kształcie. Prowadzi to do lepszych wyników, nawet przy użyciu większych czcionek w porównaniu z poprzednim podejściem. Z drugiej strony to podejście jest oczywiście nieco wolniejsze. Jest na to więcej sposobów, ale główną ideą jest obliczenie różnicy (odległości) między obszarem obrazu ( dot) a renderowanym znakiem. Możesz zacząć od naiwnej sumy bezwzględnej różnicy między pikselami, ale to doprowadzi do niezbyt dobrych wyników, ponieważ nawet przesunięcie o jeden piksel spowoduje, że odległość będzie duża. Zamiast tego możesz użyć korelacji lub innych metryk. Ogólny algorytm jest prawie taki sam jak w poprzednim podejściu:

  1. Tak równomiernie podzielić obraz (skala szarości) obszary prostokątne dot „s

    najlepiej z tym samym współczynnikiem proporcji co renderowane znaki czcionki (zachowa współczynnik proporcji. Nie zapominaj, że znaki zwykle nakładają się trochę na osi x)

  2. Oblicz intensywność każdego obszaru ( dot)

  3. Zastąp go postacią z postaci mapo najbliższej intensywności / kształcie

Jak możemy obliczyć odległość między znakiem a kropką? To jest najtrudniejsza część tego podejścia. Eksperymentując, wypracowuję ten kompromis między szybkością, jakością i prostotą:

  1. Podziel obszar postaci na strefy

    Strefy

    • Oblicz osobną intensywność dla lewej, prawej, górnej, dolnej i środkowej strefy każdego znaku z alfabetu konwersji ( map).
    • Normalizuj wszystkie intensywności, aby były niezależne od wielkości obszaru i=(i*256)/(xs*ys).
  2. Przetwarzaj obraz źródłowy w obszarach prostokątnych

    • (z tym samym współczynnikiem proporcji co czcionka docelowa)
    • Dla każdego obszaru oblicz intensywność w taki sam sposób, jak w punkcie 1
    • Znajdź najbliższe dopasowanie spośród intensywności w alfabecie konwersji
    • Wpisz dopasowany znak

To jest wynik dla rozmiaru czcionki = 7 pikseli

Przykład dopasowania postaci

Jak widać, wynik jest przyjemny wizualnie, nawet przy użyciu większego rozmiaru czcionki (w poprzednim przykładzie podejście miało rozmiar czcionki 5 pikseli). Plik wyjściowy ma mniej więcej taki sam rozmiar jak obraz wejściowy (bez powiększenia). Lepsze wyniki osiąga się, ponieważ postacie są bliżej oryginalnego obrazu, nie tylko ze względu na intensywność, ale także ogólny kształt, dzięki czemu można używać większych czcionek i nadal zachować szczegóły (do pewnego momentu).

Oto pełny kod aplikacji do konwersji opartej na VCL:

//---------------------------------------------------------------------------
#include <vcl.h>
#pragma hdrstop

#include "win_main.h"
//---------------------------------------------------------------------------
#pragma package(smart_init)
#pragma resource "*.dfm"

TForm1 *Form1;
Graphics::TBitmap *bmp=new Graphics::TBitmap;
//---------------------------------------------------------------------------


class intensity
{
public:
    char c;                    // Character
    int il, ir, iu ,id, ic;    // Intensity of part: left,right,up,down,center
    intensity() { c=0; reset(); }
    void reset() { il=0; ir=0; iu=0; id=0; ic=0; }

    void compute(DWORD **p,int xs,int ys,int xx,int yy) // p source image, (xs,ys) area size, (xx,yy) area position
    {
        int x0 = xs>>2, y0 = ys>>2;
        int x1 = xs-x0, y1 = ys-y0;
        int x, y, i;
        reset();
        for (y=0; y<ys; y++)
            for (x=0; x<xs; x++)
            {
                i = (p[yy+y][xx+x] & 255);
                if (x<=x0) il+=i;
                if (x>=x1) ir+=i;
                if (y<=x0) iu+=i;
                if (y>=x1) id+=i;

                if ((x>=x0) && (x<=x1) &&
                    (y>=y0) && (y<=y1))

                    ic+=i;
        }

        // Normalize
        i = xs*ys;
        il = (il << 8)/i;
        ir = (ir << 8)/i;
        iu = (iu << 8)/i;
        id = (id << 8)/i;
        ic = (ic << 8)/i;
        }
    };


//---------------------------------------------------------------------------
AnsiString bmp2txt_big(Graphics::TBitmap *bmp,TFont *font) // Character  sized areas
{
    int i, i0, d, d0;
    int xs, ys, xf, yf, x, xx, y, yy;
    DWORD **p = NULL,**q = NULL;    // Bitmap direct pixel access
    Graphics::TBitmap *tmp;        // Temporary bitmap for single character
    AnsiString txt = "";            // Output ASCII art text
    AnsiString eol = "\r\n";        // End of line sequence
    intensity map[97];            // Character map
    intensity gfx;

    // Input image size
    xs = bmp->Width;
    ys = bmp->Height;

    // Output font size
    xf = font->Size;   if (xf<0) xf =- xf;
    yf = font->Height; if (yf<0) yf =- yf;

    for (;;) // Loop to simplify the dynamic allocation error handling
    {
        // Allocate and initialise buffers
        tmp = new Graphics::TBitmap;
        if (tmp==NULL)
            break;

        // Allow 32 bit pixel access as DWORD/int pointer
        tmp->HandleType = bmDIB;    bmp->HandleType = bmDIB;
        tmp->PixelFormat = pf32bit; bmp->PixelFormat = pf32bit;

        // Copy target font properties to tmp
        tmp->Canvas->Font->Assign(font);
        tmp->SetSize(xf, yf);
        tmp->Canvas->Font ->Color = clBlack;
        tmp->Canvas->Pen  ->Color = clWhite;
        tmp->Canvas->Brush->Color = clWhite;
        xf = tmp->Width;
        yf = tmp->Height;

        // Direct pixel access to bitmaps
        p  = new DWORD*[ys];
        if (p  == NULL) break;
        for (y=0; y<ys; y++)
            p[y] = (DWORD*)bmp->ScanLine[y];

        q  = new DWORD*[yf];
        if (q  == NULL) break;
        for (y=0; y<yf; y++)
            q[y] = (DWORD*)tmp->ScanLine[y];

        // Create character map
        for (x=0, d=32; d<128; d++, x++)
        {
            map[x].c = char(DWORD(d));
            // Clear tmp
            tmp->Canvas->FillRect(TRect(0, 0, xf, yf));
            // Render tested character to tmp
            tmp->Canvas->TextOutA(0, 0, map[x].c);

            // Compute intensity
            map[x].compute(q, xf, yf, 0, 0);
        }

        map[x].c = 0;

        // Loop through the image by zoomed character size step
        xf -= xf/3; // Characters are usually overlapping by 1/3
        xs -= xs % xf;
        ys -= ys % yf;
        for (y=0; y<ys; y+=yf, txt += eol)
            for (x=0; x<xs; x+=xf)
            {
                // Compute intensity
                gfx.compute(p, xf, yf, x, y);

                // Find the closest match in map[]
                i0 = 0; d0 = -1;
                for (i=0; map[i].c; i++)
                {
                    d = abs(map[i].il-gfx.il) +
                        abs(map[i].ir-gfx.ir) +
                        abs(map[i].iu-gfx.iu) +
                        abs(map[i].id-gfx.id) +
                        abs(map[i].ic-gfx.ic);

                    if ((d0<0)||(d0>d)) {
                        d0=d; i0=i;
                    }
                }
                // Add fitted character to output
                txt += map[i0].c;
            }
        break;
    }

    // Free buffers
    if (tmp) delete tmp;
    if (p  ) delete[] p;
    return txt;
}


//---------------------------------------------------------------------------
AnsiString bmp2txt_small(Graphics::TBitmap *bmp)    // pixel sized areas
{
    AnsiString m = " `'.,:;i+o*%&$#@"; // Constant character map
    int x, y, i, c, l;
    BYTE *p;
    AnsiString txt = "", eol = "\r\n";
    l = m.Length();
    bmp->HandleType = bmDIB;
    bmp->PixelFormat = pf32bit;
    for (y=0; y<bmp->Height; y++)
    {
        p = (BYTE*)bmp->ScanLine[y];
        for (x=0; x<bmp->Width; x++)
        {
            i  = p[(x<<2)+0];
            i += p[(x<<2)+1];
            i += p[(x<<2)+2];
            i  = (i*l)/768;
            txt += m[l-i];
        }
        txt += eol;
    }
    return txt;
}


//---------------------------------------------------------------------------
void update()
{
    int x0, x1, y0, y1, i, l;
    x0 = bmp->Width;
    y0 = bmp->Height;
    if ((x0<64)||(y0<64)) Form1->mm_txt->Text = bmp2txt_small(bmp);
     else                  Form1->mm_txt->Text = bmp2txt_big  (bmp, Form1->mm_txt->Font);
    Form1->mm_txt->Lines->SaveToFile("pic.txt");
    for (x1 = 0, i = 1, l = Form1->mm_txt->Text.Length();i<=l;i++) if (Form1->mm_txt->Text[i] == 13) { x1 = i-1; break; }
    for (y1=0, i=1, l=Form1->mm_txt->Text.Length();i <= l; i++) if (Form1->mm_txt->Text[i] == 13) y1++;
    x1 *= abs(Form1->mm_txt->Font->Size);
    y1 *= abs(Form1->mm_txt->Font->Height);
    if (y0<y1) y0 = y1; x0 += x1 + 48;
    Form1->ClientWidth = x0;
    Form1->ClientHeight = y0;
    Form1->Caption = AnsiString().sprintf("Picture -> Text (Font %ix%i)", abs(Form1->mm_txt->Font->Size), abs(Form1->mm_txt->Font->Height));
}


//---------------------------------------------------------------------------
void draw()
{
    Form1->ptb_gfx->Canvas->Draw(0, 0, bmp);
}


//---------------------------------------------------------------------------
void load(AnsiString name)
{
    bmp->LoadFromFile(name);
    bmp->HandleType = bmDIB;
    bmp->PixelFormat = pf32bit;
    Form1->ptb_gfx->Width = bmp->Width;
    Form1->ClientHeight = bmp->Height;
    Form1->ClientWidth = (bmp->Width << 1) + 32;
}


//---------------------------------------------------------------------------
__fastcall TForm1::TForm1(TComponent* Owner):TForm(Owner)
{
    load("pic.bmp");
    update();
}


//---------------------------------------------------------------------------
void __fastcall TForm1::FormDestroy(TObject *Sender)
{
    delete bmp;
}


//---------------------------------------------------------------------------
void __fastcall TForm1::FormPaint(TObject *Sender)
{
    draw();
}


//---------------------------------------------------------------------------
void __fastcall TForm1::FormMouseWheel(TObject *Sender, TShiftState Shift, int WheelDelta, TPoint &MousePos, bool &Handled)
{
    int s = abs(mm_txt->Font->Size);
    if (WheelDelta<0) s--;
    if (WheelDelta>0) s++;
    mm_txt->Font->Size = s;
    update();
}

//---------------------------------------------------------------------------

Jest to prosty formularz zgłoszeniowy ( Form1) z pojedynczym TMemo mm_txtw nim. Wczytuje obraz, "pic.bmp"a następnie zgodnie z rozdzielczością wybiera metodę konwersji na tekst, który jest zapisywany "pic.txt"i wysyłany do notatki w celu wizualizacji.

Dla tych bez VCL zignoruj ​​rzeczy VCL i zastąp AnsiStringdowolnym typem łańcucha, a także Graphics::TBitmapdowolną klasą bitmapową lub graficzną, którą masz do dyspozycji z możliwością dostępu do pikseli.

Bardzo ważną informacją jest to, że używa to ustawień mm_txt->Font, więc upewnij się, że ustawiłeś:

  • Font->Pitch = fpFixed
  • Font->Charset = OEM_CHARSET
  • Font->Name = "System"

aby to działało poprawnie, w przeciwnym razie czcionka nie będzie obsługiwana jako mono-spaced. Kółko myszy po prostu zmienia rozmiar czcionki w górę / w dół, aby zobaczyć wyniki dla różnych rozmiarów czcionek.

[Uwagi]

  • Zobacz wizualizację Portrety w programie Word
  • Użyj języka z dostępem do bitmap / plików i możliwościami wyjścia tekstu
  • Zdecydowanie polecam zacząć od pierwszego podejścia, ponieważ jest ono bardzo łatwe, proste i proste, a dopiero potem przejść do drugiego (co można zrobić jako modyfikację pierwszego, więc większość kodu pozostaje taka, jaka jest)
  • Dobrym pomysłem jest obliczanie z odwróconą intensywnością (maksymalna wartość to czarne piksele), ponieważ standardowy podgląd tekstu znajduje się na białym tle, co prowadzi do znacznie lepszych wyników.
  • możesz poeksperymentować z rozmiarem, liczbą i układem stref podziału lub 3x3zamiast tego użyć jakiejś siatki .

Porównanie

Na koniec jest porównanie między dwoma podejściami na tym samym wejściu:

Porównanie

Obrazy oznaczone zieloną kropką są wykonane z podejściem nr 2, a czerwone z numerem 1 , wszystkie z sześciopikselowym rozmiarem czcionki. Jak widać na obrazie żarówki, podejście wrażliwe na kształt jest znacznie lepsze (nawet jeśli # 1 jest zrobiony na obrazie źródłowym powiększonym 2x).

Fajna aplikacja

Czytając dzisiejsze nowe pytania, wpadłem na pomysł fajnej aplikacji, która przechwytuje wybrany obszar pulpitu i stale przesyła go do konwertera ASCIIart i przegląda wynik. Po godzinie kodowania gotowe i jestem tak zadowolony z wyniku, że po prostu muszę go tutaj dodać.

OK, aplikacja składa się tylko z dwóch okien. Pierwsze okno główne to w zasadzie moje stare okno konwertera bez wyboru obrazu i podglądu (wszystkie powyższe rzeczy są w nim). Ma tylko podgląd ASCII i ustawienia konwersji. Drugie okno to pusty formularz z przezroczystym wnętrzem do wyboru obszaru chwytania (brak jakiejkolwiek funkcjonalności).

Teraz na liczniku czasu po prostu chwytam wybrany obszar za pomocą formularza wyboru, przekazuję go do konwersji i przeglądam ASCIIart .

Więc otaczasz obszar, który chcesz przekonwertować, oknem wyboru i wyświetlasz wynik w oknie głównym. Może to być gra, przeglądarka itp. Wygląda to tak:

Przykład grabbera ASCIIart

Więc teraz mogę dla przyjemności oglądać nawet filmy w ASCIIart . Niektóre są naprawdę fajne :).

Ręce

Jeśli chcesz spróbować zaimplementować to w GLSL , spójrz na to:

Spektre
źródło
30
Zrobiłeś tu niesamowitą robotę! Dzięki! Uwielbiam cenzurowanie ASCII!
Ander Biguri
1
Propozycja poprawy: wypracuj pochodne kierunkowe, a nie tylko intensywność.
Yakk - Adam Nevraumont
1
@Yakk chcesz rozwinąć?
tariksbl
2
@tarik albo dopasowuje się nie tylko pod względem intensywności, ale i pochodnych: lub, pasmowo wzmocnione krawędzie. Ogólnie rzecz biorąc, ludzie widzą nie tylko intensywność: widzą gradienty i krawędzie.
Yakk - Adam Nevraumont
1
@Yakk podział na strefy robi coś takiego pośrednio. Może nawet lepiej byłoby zrobić obsługę postaci jako 3x3stref i porównać DCT, ale myślę, że to znacznie zmniejszyłoby wydajność.
Spektre