Gerador de Arte ASCII em Rust

Construa um conversor de imagens para arte ASCII em Rust: leitura de imagens, mapeamento de brilho, largura ajustável e saída em arquivo.

Neste projeto vamos construir um gerador de arte ASCII que converte qualquer imagem (PNG, JPEG, etc.) em uma representação textual usando caracteres ASCII. O programa lê a imagem, converte cada pixel em um nível de brilho e mapeia esse brilho para um caractere correspondente – de espaços em branco (claro) a caracteres densos como @ e # (escuro). O resultado pode ser exibido no terminal ou salvo em arquivo de texto.

Esse tipo de projeto é fascinante porque combina processamento de imagens com manipulação de strings, e o resultado visual é imediatamente gratificante. Ao longo do walkthrough, você vai aprender sobre a crate image para carregar e redimensionar imagens, iteradores para transformação de dados e escrita de arquivos.

O Que Vamos Construir

Um conversor de imagens para arte ASCII com as seguintes funcionalidades:

  • Leitura de imagens nos formatos PNG, JPEG, BMP, GIF e outros
  • Conversão de pixels para escala de cinza
  • Mapeamento de brilho para caracteres ASCII configurável
  • Largura de saída ajustável (número de colunas)
  • Exibição no terminal com saída colorida
  • Opção de salvar o resultado em arquivo .txt
  • Inversão de brilho para fundos claros ou escuros

Estrutura do Projeto

gerador-arte-ascii/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

Crie o projeto com o Cargo:

cargo new gerador-arte-ascii
cd gerador-arte-ascii

Edite o Cargo.toml com as dependências:

[package]
name = "gerador-arte-ascii"
version = "0.1.0"
edition = "2021"

[dependencies]
image = "0.25"
clap = { version = "4", features = ["derive"] }

Usamos image para carregar e processar imagens e clap para parsing de argumentos de linha de comando.

Passo 1: Definindo a Interface CLI e Configurações

Vamos começar criando a interface de linha de comando que aceita o caminho da imagem e as opções de configuração.

use clap::Parser;
use image::{DynamicImage, GenericImageView, imageops::FilterType};
use std::fs;
use std::io::{self, Write};
use std::path::Path;

/// Gerador de arte ASCII a partir de imagens
#[derive(Parser)]
#[command(name = "ascii-art")]
#[command(about = "Converte imagens em arte ASCII")]
struct Argumentos {
    /// Caminho da imagem de entrada
    imagem: String,

    /// Largura da saída em caracteres (padrão: 80)
    #[arg(short = 'l', long = "largura", default_value_t = 80)]
    largura: u32,

    /// Arquivo de saída (se omitido, exibe no terminal)
    #[arg(short = 'o', long = "saida")]
    arquivo_saida: Option<String>,

    /// Inverte o mapeamento de brilho (para terminais de fundo claro)
    #[arg(short = 'i', long = "inverter")]
    inverter: bool,

    /// Conjunto de caracteres: "padrao", "simples", "blocos"
    #[arg(short = 'c', long = "charset", default_value = "padrao")]
    charset: String,
}

/// Retorna o conjunto de caracteres ordenado do mais escuro ao mais claro
fn obter_charset(nome: &str) -> Vec<char> {
    match nome {
        "simples" => " .:-=+*#%@".chars().collect(),
        "blocos" => " ░▒▓█".chars().collect(),
        _ => " .,:;i1tfLCG08@".chars().collect(), // padrão
    }
}

O programa aceita a imagem como argumento posicional e oferece opções para largura, arquivo de saída, inversão e conjunto de caracteres. Os três charsets disponíveis oferecem diferentes níveis de detalhe visual.

Passo 2: Processamento da Imagem

Agora implementamos as funções que carregam a imagem, a redimensionam e convertem para escala de cinza.

/// Carrega e redimensiona a imagem mantendo a proporção
fn carregar_imagem(caminho: &str, largura: u32) -> Result<DynamicImage, String> {
    let img = image::open(caminho)
        .map_err(|e| format!("Erro ao abrir '{}': {}", caminho, e))?;

    let (largura_original, altura_original) = img.dimensions();

    // Caracteres no terminal são ~2x mais altos que largos,
    // então dividimos a altura por 2 para compensar
    let proporcao = largura_original as f64 / altura_original as f64;
    let nova_altura = (largura as f64 / proporcao / 2.0) as u32;

    let img_redimensionada = img.resize_exact(largura, nova_altura, FilterType::Lanczos3);

    Ok(img_redimensionada)
}

/// Converte um pixel em escala de cinza para um caractere ASCII
fn pixel_para_caractere(brilho: u8, charset: &[char], inverter: bool) -> char {
    let brilho_ajustado = if inverter { 255 - brilho } else { brilho };

    // Mapeia o brilho (0-255) para um índice no charset
    let indice = (brilho_ajustado as f64 / 255.0 * (charset.len() - 1) as f64) as usize;
    charset[indice]
}

/// Converte a imagem inteira em uma string de arte ASCII
fn imagem_para_ascii(img: &DynamicImage, charset: &[char], inverter: bool) -> String {
    let (largura, altura) = img.dimensions();
    let escala_cinza = img.to_luma8();

    let mut resultado = String::with_capacity((largura as usize + 1) * altura as usize);

    for y in 0..altura {
        for x in 0..largura {
            let pixel = escala_cinza.get_pixel(x, y);
            let brilho = pixel[0]; // Valor de luminosidade (0 = preto, 255 = branco)
            let caractere = pixel_para_caractere(brilho, charset, inverter);
            resultado.push(caractere);
        }
        resultado.push('\n');
    }

    resultado
}

A chave do algoritmo é simples: cada pixel da imagem redimensionada é convertido em escala de cinza e mapeado para um caractere ASCII baseado na sua luminosidade. Pixels escuros recebem caracteres densos (@, #) e pixels claros recebem caracteres leves (., ). A correção de aspecto (dividir altura por 2) é fundamental porque caracteres de terminal são aproximadamente duas vezes mais altos do que largos.

Passo 3: Saída Colorida e Escrita em Arquivo

Agora adicionamos a capacidade de exibir a arte com cores no terminal e salvar em arquivo.

/// Gera a arte ASCII com cores ANSI (cada caractere colorido conforme o pixel original)
fn imagem_para_ascii_colorido(img: &DynamicImage, charset: &[char], inverter: bool) -> String {
    let (largura, altura) = img.dimensions();
    let escala_cinza = img.to_luma8();

    let mut resultado = String::new();

    for y in 0..altura {
        for x in 0..largura {
            let pixel_cinza = escala_cinza.get_pixel(x, y);
            let brilho = pixel_cinza[0];
            let caractere = pixel_para_caractere(brilho, charset, inverter);

            // Obtém a cor original do pixel
            let pixel_cor = img.get_pixel(x, y);
            let r = pixel_cor[0];
            let g = pixel_cor[1];
            let b = pixel_cor[2];

            // Aplica cor ANSI 24-bit (truecolor) ao caractere
            resultado.push_str(&format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, caractere));
        }
        resultado.push('\n');
    }

    resultado
}

/// Salva a arte ASCII em um arquivo de texto
fn salvar_em_arquivo(arte: &str, caminho: &str) -> Result<(), String> {
    fs::write(caminho, arte)
        .map_err(|e| format!("Erro ao salvar em '{}': {}", caminho, e))?;
    println!("Arte ASCII salva em '{}'", caminho);
    Ok(())
}

/// Exibe informações sobre a imagem processada
fn exibir_info(caminho: &str, img: &DynamicImage, largura_original: u32, altura_original: u32) {
    let (larg, alt) = img.dimensions();
    eprintln!("Imagem: {}", caminho);
    eprintln!("Tamanho original: {}x{}", largura_original, altura_original);
    eprintln!("Tamanho ASCII: {}x{} caracteres", larg, alt);
    eprintln!();
}

A versão colorida usa sequências de escape ANSI 24-bit (truecolor) para colorir cada caractere com a cor original do pixel correspondente. Isso produz uma arte ASCII impressionante em terminais modernos que suportam truecolor. A versão em arquivo de texto salva apenas os caracteres sem cores.

Passo 4: Montando o main.rs Completo

Aqui está o código completo do src/main.rs:

use clap::Parser;
use image::{DynamicImage, GenericImageView, imageops::FilterType};
use std::fs;
use std::io::{self, Write};

/// Gerador de arte ASCII a partir de imagens
#[derive(Parser)]
#[command(name = "ascii-art")]
#[command(about = "Converte imagens em arte ASCII")]
struct Argumentos {
    /// Caminho da imagem de entrada
    imagem: String,

    /// Largura da saída em caracteres (padrão: 80)
    #[arg(short = 'l', long = "largura", default_value_t = 80)]
    largura: u32,

    /// Arquivo de saída (se omitido, exibe no terminal)
    #[arg(short = 'o', long = "saida")]
    arquivo_saida: Option<String>,

    /// Inverte o mapeamento de brilho
    #[arg(short = 'i', long = "inverter")]
    inverter: bool,

    /// Conjunto de caracteres: "padrao", "simples", "blocos"
    #[arg(short = 'c', long = "charset", default_value = "padrao")]
    charset: String,

    /// Exibe com cores no terminal
    #[arg(long = "colorido")]
    colorido: bool,
}

fn obter_charset(nome: &str) -> Vec<char> {
    match nome {
        "simples" => " .:-=+*#%@".chars().collect(),
        "blocos" => " ░▒▓█".chars().collect(),
        _ => " .,:;i1tfLCG08@".chars().collect(),
    }
}

fn carregar_imagem(caminho: &str, largura: u32) -> Result<(DynamicImage, u32, u32), String> {
    let img = image::open(caminho)
        .map_err(|e| format!("Erro ao abrir '{}': {}", caminho, e))?;

    let (largura_original, altura_original) = img.dimensions();
    let proporcao = largura_original as f64 / altura_original as f64;
    let nova_altura = (largura as f64 / proporcao / 2.0) as u32;
    let nova_altura = nova_altura.max(1);

    let img_redimensionada = img.resize_exact(largura, nova_altura, FilterType::Lanczos3);

    Ok((img_redimensionada, largura_original, altura_original))
}

fn pixel_para_caractere(brilho: u8, charset: &[char], inverter: bool) -> char {
    let brilho_ajustado = if inverter { 255 - brilho } else { brilho };
    let indice = (brilho_ajustado as f64 / 255.0 * (charset.len() - 1) as f64) as usize;
    charset[indice]
}

fn imagem_para_ascii(img: &DynamicImage, charset: &[char], inverter: bool) -> String {
    let (largura, altura) = img.dimensions();
    let escala_cinza = img.to_luma8();
    let mut resultado = String::with_capacity((largura as usize + 1) * altura as usize);

    for y in 0..altura {
        for x in 0..largura {
            let pixel = escala_cinza.get_pixel(x, y);
            let caractere = pixel_para_caractere(pixel[0], charset, inverter);
            resultado.push(caractere);
        }
        resultado.push('\n');
    }
    resultado
}

fn imagem_para_ascii_colorido(img: &DynamicImage, charset: &[char], inverter: bool) -> String {
    let (largura, altura) = img.dimensions();
    let escala_cinza = img.to_luma8();
    let mut resultado = String::new();

    for y in 0..altura {
        for x in 0..largura {
            let brilho = escala_cinza.get_pixel(x, y)[0];
            let caractere = pixel_para_caractere(brilho, charset, inverter);
            let pixel_cor = img.get_pixel(x, y);
            let (r, g, b) = (pixel_cor[0], pixel_cor[1], pixel_cor[2]);
            resultado.push_str(&format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, caractere));
        }
        resultado.push('\n');
    }
    resultado
}

fn main() {
    let args = Argumentos::parse();
    let charset = obter_charset(&args.charset);

    // Carrega e redimensiona a imagem
    let (img, larg_original, alt_original) = match carregar_imagem(&args.imagem, args.largura) {
        Ok(dados) => dados,
        Err(e) => {
            eprintln!("{}", e);
            std::process::exit(1);
        }
    };

    let (larg, alt) = img.dimensions();
    eprintln!("Imagem: {}", args.imagem);
    eprintln!("Tamanho original: {}x{}", larg_original, alt_original);
    eprintln!("Tamanho ASCII: {}x{} caracteres", larg, alt);
    eprintln!("Charset: {}", args.charset);
    eprintln!();

    // Gera a arte ASCII
    let arte = if args.colorido && args.arquivo_saida.is_none() {
        imagem_para_ascii_colorido(&img, &charset, args.inverter)
    } else {
        imagem_para_ascii(&img, &charset, args.inverter)
    };

    // Exibe ou salva
    match args.arquivo_saida {
        Some(ref caminho) => {
            if let Err(e) = fs::write(caminho, &arte) {
                eprintln!("Erro ao salvar: {}", e);
                std::process::exit(1);
            }
            println!("Arte ASCII salva em '{}'", caminho);
        }
        None => {
            let stdout = io::stdout();
            let mut handle = stdout.lock();
            handle.write_all(arte.as_bytes()).unwrap();
        }
    }
}

O fluxo principal carrega a imagem, exibe informações de diagnóstico via stderr, gera a arte ASCII no formato escolhido e direciona o resultado para o terminal ou para um arquivo.

Como Executar

Compile e execute o projeto:

cargo build --release

Exemplos de uso:

# Converter imagem para ASCII com largura padrão (80 colunas)
./target/release/gerador-arte-ascii foto.jpg

# Converter com largura de 120 colunas
./target/release/gerador-arte-ascii foto.png -l 120

# Salvar resultado em arquivo
./target/release/gerador-arte-ascii foto.jpg -o arte.txt

# Usar charset de blocos Unicode
./target/release/gerador-arte-ascii foto.jpg -c blocos

# Exibir colorido no terminal
./target/release/gerador-arte-ascii foto.jpg --colorido

# Inverter para terminal de fundo claro
./target/release/gerador-arte-ascii foto.jpg -i

Saída esperada no terminal (exemplo simplificado):

Imagem: foto.jpg
Tamanho original: 1920x1080
Tamanho ASCII: 80x23 caracteres
Charset: padrao

          ..,,,:::;;iiiii;;;::,,..
      .,:;i1tttfffLLLLLLLfffttt1i;:,.
    ,:i1tfLCCGG0000000000GGCCLft1i:,
   ,;1tLCG0088888888888880GCLt1;,
  .:i1fLCG0088@@@@@@@@8800GCLf1i:.

Desafios para Expandir

  1. Arte ASCII em HTML – Gere um arquivo HTML onde cada caractere é um <span> colorido, permitindo visualizar a arte no navegador com cores e fonte monoespaçada.

  2. Modo vídeo – Use a crate opencv ou processe frames de um GIF animado, convertendo cada frame em ASCII e exibindo como animação no terminal.

  3. Dithering – Implemente o algoritmo de dithering Floyd-Steinberg para distribuir o erro de quantização entre pixels vizinhos, produzindo arte ASCII com muito mais detalhe em baixas resoluções.

  4. Detecção de bordas – Adicione um modo que aplica o filtro Sobel para detectar bordas na imagem e usa caracteres como |, -, /, \ para representar as bordas, criando um estilo artístico diferente.

  5. Suporte a webcam – Integre captura de webcam em tempo real, convertendo cada frame capturado em arte ASCII e exibindo no terminal como um espelho ASCII ao vivo.

Veja Também