ASCII Art - Convertendo imagens para ASCII

Saída gerada pelo conversor
Um tópico um pouco esquecido, mas ainda interessante, é a criação de ASCII art a partir de imagens e fotos.

Na época em que os consoles de texto eram a mais comum, se não a única opção de interface disponível para os usuários de computador, as pessoas se valiam de muita criatividade (e um bocado de trabalho) para criar imagens usando somente os caracteres disponíveis na tabela ASCII. Muita coisa boa foi e ainda é criada com essa idéia, incluindo jogos e animações.

Depois de assistir a este vídeo, decidi implementar um pequeno conversor ASCII em pascal. O resultado da empreitada pode ser visto na imagem do rei do rock ao lado, que foi criada usando a versão do conversor criada para este artigo.



O Algoritmo

Para este conversor vamos usar uma abordagem de conversão pixel a pixel onde cada pixel na imagem vai ser representado por um caractere ASCII. Para tanto, vamos criar uma "paleta" ou uma tabela de equivalência para cada tom RGB presente na imagem de origem. Além disto, para manter as coisas simples, vamos gerar somente representações em tons de cinza.

Assim temos:
  1. Converter a imagem de origem para tons de cinza (0..255)
  2. Criar uma tabela de equivalência de cor RGB -> ASCII
  3. Representar cada pixel com o caractere de tom mais próximo
Simples e rápido. Vamos ao código!


Convertendo Imagens para Tons de Cinza

Esta é a parte mais fácil.
Para converter um pixel colorido para um tom de cinza, basta calcular a média dos canais RGB:
c = (r + g + b) / 3
Assumindo que nossa imagem utiliza 32bit por pixel, vamos armazenar o tom calculado no quarto byte, que normalmente é utilizado para guardar o canal alpha. Assim temos:

procedure TForm1.ConvertSourceImage;
var
  x, y   : integer;
  pColor : PRGBQuad;
begin
  FreeAndNil(fGrayAlphaImage);
  fGrayAlphaImage := TBitmap.Create;
  fGrayAlphaImage.PixelFormat := pf32bit;
  fGrayAlphaImage.Width   := imgOrigem.Picture.Width;
  fGrayAlphaImage.Height  := imgOrigem.Picture.Height;
  fGrayAlphaImage.Canvas.Draw(0,0, imgOrigem.Picture.Graphic);
  for y := 0 to fGrayAlphaImage.Height-1 do
  begin
    pColor := fGrayAlphaImage.ScanLine[y];
    for x:=0 to fGrayAlphaImage.Width-1 do
    begin
      pColor^.rgbReserved := ( pColor^.rgbRed + pColor^.rgbGreen + pColor^.rgbBlue) div 3;
      inc(pColor);
    end;
  end;
end;

Tons de cinza calculados, vamos ao próximo passo,  nossos tons (ou shades) ASCII.


Equivalências de intensidade de luz

Tabela de shades
De um lado, temos um pixel em um tom de cinza que representa a quantidade de luz existente naquele ponto da imagem (0 = 0% de luz, 255= 100% de luz) do outro, temos um caractere ASCII.

Para criar uma equivalência entre os dois, vamos tratar o caractere como uma imagem em preto e branco e calcular o percentual de pixels pretos em relação à quantidade de pixels da imagem. Depois de fazer isto para todos os caracteres escolhidos, teremos uma tabela de equivalência que pode ser visualmente representada pela imagem ao lado.
O código para esta função ficou assim:

procedure TForm1.InitShades;
var
  bmp : TBitmap;
  i, x, y, pixelCount : integer;
  k: Double;
  p : PRGBQuad;
  rct : TRect;
begin
  FillChar(fIntensityByAnsiChar, length(fIntensityByAnsiChar), $FF);
  FillChar(fAnsiCharByIntensity, length(fAnsiCharByIntensity), AnsiChar(' '));

  bmp := TBitmap.Create;
  bmp.Canvas.Font.Size  := 10; 
  bmp.Canvas.Font.Color := clBlack;
  bmp.Canvas.Font.Name  := cbxFont.Text;
  bmp.PixelFormat       := pf32bit;
  charW                 := bmp.Canvas.TextWidth('A');
  charH                 := bmp.Canvas.TextHeight('A');
  bmp.Height            := charH;
  bmp.Width             := charW;
  pixelCount            := charW * charH;

  bmp.Canvas.Brush.Color := clWhite;
  for i := 0 to high(fIntensityByAnsiChar) do
  begin
    k := 0;
    bmp.Canvas.FillRect(rect(0,0, charW, charH));
    bmp.Canvas.TextOut(0,0, WideChar(i));
    for y := 0 to bmp.Height-1 do
    begin
      p := PRGBQuad(bmp.ScanLine[y]);
      for x := 0 to bmp.Width-1 do
      begin
         if ((p^.rgbRed + p^.rgbGreen + p^.rgbBlue) div 3) = 255 then
            k := k + 1;
        Inc(p);
      end;
    end;
    fIntensityByAnsiChar[i] := trunc(( k / pixelCount ) * 255);
    fAnsiCharByIntensity[fIntensityByAnsiChar[i]] := AnsiChar(i);
  end;

  for i:=1 to High(fAnsiCharByIntensity) do
    if (fAnsiCharByIntensity[i] = AnsiChar(' ')) and (i <> 32) then
       fAnsiCharByIntensity[i] := fAnsiCharByIntensity[i-1];

  FreeAndNil(bmp);
end;

Nossos shades estão prontos!
Temos agora um array que contém a intensidade de cor para todos os nossos caracteres (fIntensityByAnsiChar) e um outro que contém o caractere equivalente a uma determinada intensidade (fAnsiCharByIntensity).
Só nos falta agora gerar a nossa imagem e conferir o resultado.


Gerando nosso ASCII 

Com nossa tabela de shades devidamente calculada, gerar o ASCII a partir de uma imagem é simplesmente uma questão de varrer os pixels da imagem de origem e pesquisar o caractere equivalente:

procedure TForm1.CreateASCIIImage;
var
  x, y   : integer;
  pColor : PRGBQuad;
begin
  memOut.Font.Name := cbxFont.Text;
  memOut.Font.Size := 8;
  SetLength(fASCIIImage, fGrayAlphaImage.Width, fGrayAlphaImage.Height);
  for x:=0 to fGrayAlphaImage.Width-1 do
    for y := 0 to fGrayAlphaImage.Height-1 do
      fASCIIImage[x,y]:= AnsiChar(32);

  for y := 0 to fGrayAlphaImage.Height - 1 do
  begin
    pColor := fGrayAlphaImage.ScanLine[y];
    for x := 0 to fGrayAlphaImage.Width - 1 do
    begin
       if pColor^.rgbReserved <> 0 then
         if ckbInvert.Checked then
           fASCIIImage[x,y]:= fAnsiCharByIntensity[255-pColor^.rgbReserved]
         else
           fASCIIImage[x,y]:= fAnsiCharByIntensity[pColor^.rgbReserved];
      Inc(pColor);
    end;
  end;
end;

Bingo! Convertemos a imagem.
Para visualizá-la, vamos utilizar um TMemo:

procedure TForm1.ShowASCIIImage;
var
  s : AnsiString;
  x, y : integer;
begin
  memOut.Lines.BeginUpdate;
  memOut.Clear;
  for y := 0 to fGrayAlphaImage.Height - 1 do
  begin
    s := '';
    for x :=0 to fGrayAlphaImage.Width-1 do
       s := s + AnsiChar(fASCIIImage[x,y]);
    memOut.Lines.Add(s);
  end;
  memOut.Lines.EndUpdate;
end;

Considerações

O algoritmo que implementamos neste artigo é muito simples e muito rápido, mas tem suas limitações.
A mais importante delas é o fato de que ele não leva em consideração o aspecto (relação entre altura e largura) do pixel nem da fonte utilizada e como consequência a imagem final pode apresentar distorções em suas dimensões.

Downloads