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
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.Modo vídeo – Use a crate
opencvou processe frames de um GIF animado, convertendo cada frame em ASCII e exibindo como animação no terminal.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.
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.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
- Manipulação de Strings – Operações com String e &str usadas extensivamente neste projeto
- Iteradores – Transformação funcional de dados pixel a pixel
- Vec e Coleções – Armazenamento dinâmico dos dados da imagem
- Lendo Arquivos – Padrões de leitura usados ao carregar a imagem
- Escrevendo Arquivos – Como salvar a arte ASCII em disco