Otimizar processamento de imagem em Rust com paralelismo e Rayon
Use o Rayon para processar imagens separadas simultaneamente, com uma tarefa de decodificação–redimensionamento–codificação por thread de trabalho. Este passo a passo cria uma CLI em Rust que converte um diretório de imagens PNG e JPEG em JPEGs menores. Você escolhe o número de threads, e o comando relata cada falha de arquivo antes de retornar seu status final.
Escolha as políticas de imagem e de saída
O exemplo se destina a lotes locais de imagens estáticas, com estas escolhas:
- Selecione arquivos regulares terminados em
.png,.jpgou.jpeg, sem diferenciar maiúsculas e minúsculas na extensão. Ignore subdiretórios, links simbólicos e outras extensões. Não modifique o diretório de entrada durante uma execução. - Aceite imagens decodificadas como RGB de 8 bits, escala de cinza ou qualquer um dos dois com alfa. Use entradas sRGB: o programa não converte perfis de cor incorporados. Animações e fluxos de trabalho de impressão com gerenciamento de cor estão fora do escopo deste exemplo.
- Aplique a orientação EXIF do decodificador, achate a transparência sobre branco e, em seguida, ajuste a imagem para caber em 800 × 600 pixels sem recortar nem ampliar imagens menores. Codifique o JPEG com qualidade 85.
- Acrescente
.jpgao nome completo do arquivo de entrada:photo.pngse tornaphoto.png.jpg, ephoto.jpgse tornaphoto.jpg.jpg. Isso mantém distintas as entradas com o mesmo nome base. - Exija um novo diretório de saída. Um diretório existente, mesmo vazio, é um erro. Os arquivos processados com sucesso permanecem quando outro arquivo falha; uma nova execução precisa de outro diretório de saída.
O codificador recebe apenas pixels, então os metadados EXIF, GPS, ICC e de texto da origem não são copiados. A orientação é aplicada aos pixels antes que esses metadados sejam descartados. Aqui, a composição alfa e o redimensionamento operam sobre valores de canal codificados, não sobre valores em luz linear. Essa é uma simplificação deliberada para miniaturas, não um pipeline de gerenciamento de cor.
Crie o projeto Cargo
Os comandos abaixo usam Bash no Linux e foram testados com Rust e Cargo 1.98.1. Instale a toolchain
estável atual seguindo o guia oficial de instalação do Rust.
Use um diretório de trabalho fora de um projeto Cargo existente e da configuração .cargo dele. Todos
os blocos de shell abaixo são colados a partir desse mesmo diretório de trabalho.
rustc --version && cargo --version
Crie um novo diretório; este comando se recusa a reutilizar um image-batch existente:
(mkdir image-batch && mkdir image-batch/src)
Salve este manifesto completo como image-batch/Cargo.toml:
[package]
name = "image-batch"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "=1.0.104"
image = { version = "=0.25.10", default-features = false, features = ["jpeg", "png"] }
rayon = "=1.12.0"
[workspace]
Apenas os codecs PNG e JPEG estão habilitados. O Rayon fornece o paralelismo do lote; o recurso
opcional de Rayon do crate image está desabilitado. As notas de versão do crate image
descrevem as APIs atuais de decodificação e orientação usadas aqui.
Implemente o comando completo de lote
Salve o seguinte como image-batch/src/main.rs. A descoberta de arquivos no diretório termina antes de o
diretório de saída ser criado. O read_dir do Rust pode
falhar tanto ao abrir um diretório quanto ao avançar seu iterador, então ambos os erros são
propagados em vez de reduzir silenciosamente o lote.
use std::{env, fs, path::Path, process::ExitCode};
use anyhow::{Context, Result, ensure};
use image::{ColorType, DynamicImage, ImageDecoder, ImageReader, Limits, Rgb, RgbImage};
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use rayon::prelude::*;
fn convert(input: &Path, output_dir: &Path) -> Result<()> {
let mut reader = ImageReader::open(input)?.with_guessed_format()?;
let mut limits = Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(128 * 1024 * 1024);
reader.limits(limits);
let mut decoder = reader.into_decoder().context("Read image header")?;
ensure!(
matches!(decoder.color_type(), ColorType::L8 | ColorType::La8 | ColorType::Rgb8 | ColorType::Rgba8),
"Expected an 8-bit image"
);
let orientation = decoder.orientation()?;
let mut decoded = DynamicImage::from_decoder(decoder).context("Decode pixels")?;
decoded.apply_orientation(orientation);
let rgba = decoded.into_rgba8();
let rgb = RgbImage::from_fn(rgba.width(), rgba.height(), |x, y| {
let pixel = rgba.get_pixel(x, y);
let alpha = u16::from(pixel[3]);
let blend = |channel: u8| {
((u16::from(channel) * alpha + 255 * (255 - alpha) + 127) / 255) as u8
};
Rgb([blend(pixel[0]), blend(pixel[1]), blend(pixel[2])])
});
drop(rgba);
let image = DynamicImage::ImageRgb8(rgb);
let resized = if image.width() > 800 || image.height() > 600 {
image.resize(800, 600, FilterType::Lanczos3)
} else {
image
};
let mut bytes = Vec::new();
JpegEncoder::new_with_quality(&mut bytes, 85).encode_image(&resized)?;
let mut name = input.file_name().context("Missing filename")?.to_os_string();
name.push(".jpg");
let output = output_dir.join(&name);
name.push(".part");
let staging = output_dir.join(name);
fs::write(&staging, bytes).context("Write staged JPEG")?;
fs::rename(&staging, &output).context("Publish JPEG")?;
Ok(())
}
fn run() -> Result<()> {
let args: Vec<_> = env::args_os().skip(1).collect();
ensure!(args.len() == 3, "Usage: image-batch INPUT_DIR NEW_OUTPUT_DIR THREADS");
let threads: usize = args[2].to_str().context("Invalid thread count")?.parse()?;
ensure!((1..=16).contains(&threads), "THREADS must be between 1 and 16");
let input_dir = fs::canonicalize(&args[0]).context("Resolve input directory")?;
let mut inputs = Vec::new();
for entry in fs::read_dir(&input_dir).context("Open input directory")? {
let entry = entry.context("Read directory entry")?;
if !entry.file_type().context("Read entry type")?.is_file() {
continue;
}
let path = entry.path();
let supported = path.extension().is_some_and(|ext| {
ext.eq_ignore_ascii_case("png")
|| ext.eq_ignore_ascii_case("jpg")
|| ext.eq_ignore_ascii_case("jpeg")
});
if supported {
inputs.push(path);
}
}
ensure!(!inputs.is_empty(), "No PNG or JPEG files found");
inputs.sort();
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build()?;
let output_dir = Path::new(&args[1]);
fs::create_dir(output_dir).context("Create a new output directory; parent must exist")?;
let failed: usize = pool.install(|| {
inputs.par_iter().map(|input| {
match convert(input, output_dir) {
Ok(()) => 0,
Err(error) => {
eprintln!("FAILED {}: {error:#}", input.display());
1
}
}
}).sum()
});
println!("{} succeeded; {failed} failed", inputs.len() - failed);
ensure!(failed == 0, "Batch incomplete; successful outputs were kept");
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error:#}");
ExitCode::FAILURE
}
}
}
Cada tarefa codifica a imagem antes de gravar um arquivo .part e depois renomeia esse arquivo para
o nome JPEG final. Somente uma renomeação concluída conta como sucesso. Uma falha de gravação ou uma
interrupção pode deixar arquivos .part; eles são trabalho incompleto, não imagens publicadas.
Mantenha este diretório de saída exclusivo para a execução. O exemplo não sincroniza os arquivos com
o armazenamento durável nem reverte os arquivos processados com sucesso.
O iterador paralelo soma as falhas em vez de parar no primeiro erro. Assim, diante de um erro comum em um arquivo, o programa tenta processar cada imagem descoberta e mantém um status de processo diferente de zero para um lote incompleto. As mensagens de erro podem aparecer em qualquer ordem.
Compile, execute e inspecione uma saída
Gere o image-batch/Cargo.lock uma vez e mantenha-o junto com o projeto. Fixar versões exatas apenas das
dependências diretas não congela as dependências transitivas; os builds seguintes usam o arquivo de
lock mantido.
(cd image-batch && cargo generate-lockfile)
Para ter uma entrada reproduzível, instale o ImageMagick, testado aqui com a versão 7.1.2-31. Isto cria um PNG de 1.600 × 800 com a metade esquerda vermelha e opaca e a metade direita transparente. Use novos nomes de diretório para o exemplo:
(
mkdir input-images &&
magick -size 1600x800 xc:none -fill red -draw 'rectangle 0,0 799,799' \
PNG32:input-images/sample.png
)
Compile no modo release e execute com duas threads. O comando executa o binário somente depois que o build for bem-sucedido:
(
cd image-batch &&
cargo build --release --locked &&
./target/release/image-batch ../input-images ../output-images 2
)
Para essa única entrada, o programa relata 1 succeeded; 0 failed. Inspecione o arquivo efetivamente
produzido com um decodificador independente:
magick ./output-images/sample.png.jpg -format '%m %wx%h\n' info:
JPEG 800x400
Abra também esse JPEG: a metade esquerda deve estar vermelha e a direita branca, incluindo a borda
inferior. resize preserva a
proporção dentro da caixa; ele não força esta imagem a ficar com 800 × 600. A verificação explícita
de tamanho mantém uma entrada menor em suas dimensões originais. Achatar antes de redimensionar
evita que valores RGB ocultos em pixels transparentes vazem para o fundo branco.
Para processar suas próprias imagens, substitua os dois argumentos de diretório e mantenha o diretório de saída novo. Coloque entre aspas os caminhos que contêm espaços. Um arquivo selecionado corrompido é relatado se o decodificador o rejeitar; extensões não suportadas são ignoradas. Uma decodificação bem-sucedida não é uma verificação de integridade: com este decodificador, um JPEG truncado pode retornar sucesso com blocos cinza no lugar dos pixels ausentes. Inspecione o conteúdo da imagem em toda a área e guarde seus originais.
Meça o número de threads em relação à memória e ao tempo decorrido
num_threads
limita as threads deste pool. A CLI aceita de uma a 16; comece com uma ou duas para entradas
grandes. Ela armazena os caminhos do diretório e mantém pixels decodificados apenas para as tarefas
ativas. Cada tarefa pode precisar de vários buffers de pixels, então dobrar o número de threads pode
aumentar substancialmente o uso de memória.
Os limites de 4.096 pixels de largura e altura se aplicam à origem antes da orientação e do redimensionamento. O limite de alocação de 128 MiB do decodificador é de melhor esforço e não cobre os buffers RGBA/RGB posteriores, o redimensionamento, a codificação JPEG, a lista de caminhos nem a memória total do processo. Esses controles não transformam o programa em uma sandbox para uploads não confiáveis.
Após um build bem-sucedido, compare a mesma entrada representativa com várias imagens usando destinos novos:
time ./image-batch/target/release/image-batch ./input-images ./timed-one 1 &&
time ./image-batch/target/release/image-batch ./input-images ./timed-two 2
Compare o tempo decorrido informado pelo real do Bash, repita com novos nomes de diretório de saída
e leve em conta os caches do sistema de arquivos já aquecidos. O teste rápido com uma imagem não
consegue demonstrar ganho de velocidade no lote. Mais threads podem ajudar quando decodificações e
redimensionamentos independentes mantêm os núcleos da CPU ocupados; o armazenamento, a pressão de
memória e a sobrecarga de escalonamento podem anular o ganho. Mantenha o Lanczos3 se o resultado
dele for adequado para suas imagens; compare um filtro diferente com as mesmas entradas antes de
trocar qualidade de imagem por tempo decorrido.
