Conversão de imagem para arte ASCII

102

Prólogo

Este assunto aparece aqui no Stack Overflow de vez em quando, mas geralmente é removido por ser uma pergunta mal escrita. Eu vi muitas dessas perguntas e, em seguida, silêncio do OP (baixa repetição usual) quando informações adicionais são solicitadas. De vez em quando, se a entrada for boa o suficiente para mim, decido responder com uma resposta e geralmente obtém alguns votos positivos por dia enquanto ativo, mas depois de algumas semanas a pergunta é removida / excluída e tudo começa no começando. Então decidi escrever este Q&A para que possa fazer referência a essas perguntas diretamente, sem reescrever a resposta repetidamente ...

Outra razão é também esta meta discussão direcionado a mim, então, se você tiver informações adicionais, à vontade para comentar.

Questão

Como posso converter uma imagem bitmap em arte ASCII usando C ++ ?

Algumas restrições:

  • imagens em escala de cinza
  • usando fontes mono-espaçadas
  • mantendo-o simples (não usando coisas muito avançadas para programadores de nível iniciante)

Aqui está uma página da Wikipedia relacionada com arte ASCII (graças a @RogerRowland).

Aqui, labirinto semelhante para perguntas e respostas de conversão de arte ASCII .

Spektre
fonte
Usando esta página wiki como referência, você pode esclarecer a que tipo de arte ASCII você está se referindo? Parece-me "conversão de imagem em texto", que é uma pesquisa "simples" de pixels em tons de cinza para o caractere de texto correspondente, então estou me perguntando se você quer dizer algo diferente. Parece que você vai responder mesmo de qualquer maneira ...
Roger Rowland,
Relacionado: stackoverflow.com/q/26347985/2564301
usr2564301
@RogerRowland simples (apenas com base na intensidade da escala de cinza) e mais avançado, levando em consideração também a forma dos caracteres (mas ainda simples o suficiente)
Spektre,
1
Embora seu trabalho seja ótimo, eu certamente apreciaria uma seleção de amostras um pouco mais SFW.
kmote
@TimCastelijns Se você leu o prólogo, pode ver que esta não é a primeira vez que esse tipo de resposta foi solicitado (e a maioria dos eleitores desde o início estavam familiarizados com algumas questões anteriores relacionadas, então o resto apenas votou de acordo), como esta não é apenas uma pergunta e resposta Q Eu não perdi muito tempo com a parte Q (que é uma falha da minha parte, eu admito), adicionei algumas restrições à questão se você tiver uma melhor, sinta-se à vontade para editar.
Spektre,

Respostas:

152

Existem mais abordagens para a conversão de imagem para arte ASCII que são baseadas principalmente no uso de fontes mono-espaçadas . Para simplificar, eu me concentro apenas no básico:

Com base na intensidade de pixel / área (sombreamento)

Essa abordagem trata cada pixel de uma área de pixels como um único ponto. A ideia é calcular a intensidade média da escala de cinza desse ponto e, em seguida, substituí-lo por um caractere com intensidade próxima o suficiente da calculada. Para isso, precisamos de uma lista de caracteres utilizáveis, cada um com uma intensidade pré-calculada. Vamos chamá-lo de personagem map. Para escolher mais rapidamente qual personagem é o melhor para qual intensidade, existem duas maneiras:

  1. Mapa de caráter de intensidade linearmente distribuído

    Portanto, usamos apenas personagens que têm uma diferença de intensidade com o mesmo passo. Em outras palavras, quando classificado em ordem crescente, então:

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

    Além disso, quando nosso personagem mapé classificado, podemos computar o personagem diretamente a partir da intensidade (sem necessidade de pesquisa)

     character = map[intensity_of(dot)/constant];
  2. Mapa de caráter de intensidade distribuída arbitrariamente

    Portanto, temos uma variedade de caracteres utilizáveis ​​e suas intensidades. Precisamos encontrar a intensidade mais próxima do intensity_of(dot)Então, novamente, se classificarmos o map[], podemos usar a pesquisa binária, caso contrário, precisamos de um O(n)loop ou O(1)dicionário de distância mínima de pesquisa. Às vezes, para simplificar, o caractere map[]pode ser tratado como uma distribuição linear, causando uma leve distorção gama, geralmente invisível no resultado, a menos que você saiba o que procurar.

A conversão baseada em intensidade também é ótima para imagens em escala de cinza (não apenas em preto e branco). Se você selecionar o ponto como um único pixel, o resultado ficará grande (um pixel -> caractere único); portanto, para imagens maiores, uma área (multiplicação do tamanho da fonte) é selecionada para preservar a proporção do aspecto e não aumentar muito.

Como fazer isso:

  1. Divida uniformemente a imagem em pixels (escala de cinza) ou áreas (retangulares) ponto s
  2. Calcule a intensidade de cada pixel / área
  3. Substitua-o por caractere do mapa de caracteres com a intensidade mais próxima

Como personagem, mapvocê pode usar qualquer caractere, mas o resultado fica melhor se o personagem tiver pixels dispersos uniformemente ao longo da área do personagem. Para começar, você pode usar:

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

classificados em ordem decrescente e fingem ser linearmente distribuídos.

Portanto, se a intensidade do pixel / área for i = <0-255>, o caractere de substituição será

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

Se i==0o pixel / área for preto, se i==127o pixel / área for cinza e se i==255o pixel / área for branco. Você pode experimentar diferentes personagens dentro de map[]...

Aqui está um exemplo antigo meu em C ++ e 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;

Você precisa substituir / ignorar as coisas da VCL, a menos que use o ambiente Borland / Embarcadero .

  • mm_log é o memorando onde o texto é enviado
  • bmp é o bitmap de entrada
  • AnsiStringé uma string do tipo VCL indexada de 1, não de 0 como char*!!!

Este é o resultado: Exemplo de imagem de intensidade ligeiramente NSFW

À esquerda está a saída de arte ASCII (tamanho da fonte 5 pixels) e à direita a imagem de entrada ampliada algumas vezes. Como você pode ver, a saída é pixel -> caractere maior. Se você usar áreas maiores em vez de pixels, o zoom será menor, mas é claro que a saída será menos agradável visualmente. Essa abordagem é muito fácil e rápida de codificar / processar.

Quando você adiciona coisas mais avançadas como:

  • cálculos de mapa automatizados
  • seleção automática de tamanho de pixel / área
  • correções de proporção de aspecto

Então você pode processar imagens mais complexas com melhores resultados:

Aqui está o resultado em uma proporção de 1: 1 (zoom para ver os caracteres):

Exemplo avançado de intensidade

Claro, para amostragem de área você perde os pequenos detalhes. Esta é uma imagem do mesmo tamanho que o primeiro exemplo com amostra de áreas:

Imagem de exemplo avançado de intensidade ligeiramente NSFW

Como você pode ver, isso é mais adequado para imagens maiores.

Ajuste de caracteres (híbrido entre sombreado e arte ASCII sólida)

Essa abordagem tenta substituir a área (não há mais pontos de pixel único) por caracteres com intensidade e forma semelhantes. Isso leva a melhores resultados, mesmo com fontes maiores usadas em comparação com a abordagem anterior. Por outro lado, essa abordagem é um pouco mais lenta, é claro. Existem mais maneiras de fazer isso, mas a ideia principal é calcular a diferença (distância) entre a área da imagem ( dot) e o caractere renderizado. Você pode começar com uma soma ingênua da diferença absoluta entre os pixels, mas isso levará a resultados não muito bons, porque mesmo uma mudança de um pixel tornará a distância grande. Em vez disso, você pode usar correlação ou métricas diferentes. O algoritmo geral é quase o mesmo da abordagem anterior:

  1. Portanto, divida uniformemente a imagem em áreas retangulares (em escala de cinza) ponto 's

    de preferência com a mesma proporção dos caracteres de fonte renderizados (isso preservará a proporção. Não se esqueça de que os caracteres geralmente se sobrepõem um pouco no eixo x)

  2. Calcule a intensidade de cada área ( dot)

  3. Substitua-o por um personagem do personagem mapcom a intensidade / forma mais próxima

Como podemos calcular a distância entre um caractere e um ponto? Essa é a parte mais difícil dessa abordagem. Enquanto experimento, desenvolvo este meio-termo entre velocidade, qualidade e simplicidade:

  1. Divida a área do personagem em zonas

    Zonas

    • Calcule uma intensidade separada para a zona esquerda, direita, para cima, para baixo e central de cada caractere de seu alfabeto de conversão (map ).
    • Normalize todas as intensidades, de forma que sejam independentes do tamanho da área i=(i*256)/(xs*ys).
  2. Processa a imagem de origem em áreas retangulares

    • (com a mesma proporção da fonte de destino)
    • Para cada área, calcule a intensidade da mesma maneira que no item 1
    • Encontre a correspondência mais próxima de intensidades no alfabeto de conversão
    • Produza o caractere ajustado

Este é o resultado para o tamanho da fonte = 7 pixels

Exemplo de adaptação de personagem

Como você pode ver, a saída é visualmente agradável, mesmo com um tamanho de fonte maior usado (o exemplo de abordagem anterior era com um tamanho de fonte de 5 pixels). A saída tem aproximadamente o mesmo tamanho da imagem de entrada (sem zoom). Os melhores resultados são obtidos porque os caracteres estão mais próximos da imagem original, não apenas pela intensidade, mas também pela forma geral e, portanto, você pode usar fontes maiores e ainda preservar os detalhes (até certo ponto, é claro).

Aqui está o código completo para o aplicativo de conversão baseado em 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();
}

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

É um aplicativo de formulário simples ( Form1) com um único TMemo mm_txtnele. Ele carrega uma imagem, "pic.bmp"e, de acordo com a resolução, escolhe qual abordagem usar para converter para o texto que será salvo no"pic.txt" e enviado para memo para visualização.

Para aqueles sem VCL, ignore as coisas do VCL e substitua AnsiStringpor qualquer tipo de string que você tenha, e também Graphics::TBitmappor qualquer bitmap ou classe de imagem que você tenha à disposição com capacidade de acesso a pixels.

Uma observação muito importante é que ele usa as configurações de mm_txt->Font, portanto, certifique-se de definir:

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

para fazer isso funcionar corretamente, caso contrário, a fonte não será tratada como mono-espaçada. A roda do mouse apenas muda o tamanho da fonte para cima / baixo para ver os resultados em diferentes tamanhos de fonte.

[Notas]

  • Vejo visualização de Word Portraits
  • Use uma linguagem com acesso a bitmap / arquivo e recursos de saída de texto
  • Eu recomendo fortemente começar com a primeira abordagem, pois é muito fácil, direto e simples, e só então passar para a segunda (que pode ser feita como modificação da primeira, então a maior parte do código permanece como está)
  • É uma boa ideia calcular com intensidade invertida (pixels pretos é o valor máximo) porque a visualização do texto padrão está em um fundo branco, levando a resultados muito melhores.
  • você pode experimentar o tamanho, a contagem e o layout das zonas de subdivisão ou usar uma grade semelhante 3x3.

Comparação

Finalmente, aqui está uma comparação entre as duas abordagens na mesma entrada:

Comparação

As imagens marcadas com pontos verdes são feitas com a abordagem # 2 e as vermelhas com # 1 , todas em um tamanho de fonte de seis pixels. Como você pode ver na imagem da lâmpada, a abordagem sensível à forma é muito melhor (mesmo se o nº 1 for feito em uma imagem de origem com zoom 2x).

Aplicativo legal

Ao ler as novas perguntas de hoje, tive uma ideia de um aplicativo legal que pega uma região selecionada da área de trabalho e a alimenta continuamente no ASCIIart conversor e visualiza o resultado. Depois de uma hora de codificação, está pronto e estou tão satisfeito com o resultado que simplesmente preciso adicioná-lo aqui.

OK, o aplicativo consiste em apenas duas janelas. A primeira janela mestre é basicamente minha janela do conversor antigo, sem a seleção e visualização da imagem (todo o material acima está nela). Ele tem apenas as configurações de visualização e conversão ASCII. A segunda janela é um formulário vazio com o interior transparente para a seleção da área de captura (sem qualquer funcionalidade).

Agora, em um cronômetro, eu apenas pego a área selecionada pelo formulário de seleção, passo para a conversão e visualizo o ASCIIart .

Assim, você inclui uma área que deseja converter pela janela de seleção e visualiza o resultado na janela principal. Pode ser um jogo, visualizador, etc. Tem a seguinte aparência:

Exemplo ASCIIart grabber

Agora posso assistir até vídeos em ASCIIart para me divertir. Alguns são muito legais :).

Mãos

Se você quiser tentar implementar isso em GLSL , dê uma olhada nisso:

Spektre
fonte
30
Você fez um trabalho incrível aqui! Obrigado! E eu amo a censura ASCII!
Ander Biguri,
1
Uma sugestão de melhoria: calcule as derivadas direcionais, não apenas a intensidade.
Yakk - Adam Nevraumont
1
@Yakk se preocupa em elaborar?
tariksbl
2
@tarik não combina apenas com a intensidade, mas também com as derivadas: ou, band pass aumenta as bordas. Basicamente, a intensidade não é a única coisa que as pessoas veem: elas veem gradientes e bordas.
Yakk - Adam Nevraumont
1
@Yakk, a subdivisão de zonas faz esse tipo de coisa indiretamente. Pode ser ainda melhor seria lidar com personagens como 3x3zonas e comparar os DCTs, mas isso diminuiria muito o desempenho, eu acho.
Spektre,