Optimizar imágenes en Rust con procesamiento paralelo y Rayon
Usa Rayon para procesar imágenes separadas de forma concurrente, con una tarea de decodificación, redimensionamiento y codificación por hilo de trabajo. Este tutorial crea una CLI en Rust que convierte un directorio de imágenes PNG y JPEG en archivos JPEG más pequeños. Tú eliges el número de hilos de trabajo, y el comando informa cada fallo de archivo antes de devolver su estado final.
Elige las políticas de imagen y salida
El ejemplo está pensado para lotes locales de imágenes estáticas, con estas decisiones:
- Selecciona archivos regulares que terminen en
.png,.jpgo.jpeg, sin distinguir mayúsculas de minúsculas en la extensión. Omite subdirectorios, enlaces simbólicos y otras extensiones. No modifiques el directorio de entrada durante una ejecución. - Acepta imágenes decodificadas a RGB de 8 bits o escala de grises, con o sin canal alfa. Usa entradas sRGB: el programa no convierte los perfiles de color incrustados. La animación y los flujos de trabajo de impresión con gestión del color quedan fuera de este ejemplo.
- Aplica la orientación EXIF del decodificador, aplana la transparencia sobre blanco y luego ajusta la imagen a un máximo de 800 × 600 píxeles sin recortarla ni ampliar las imágenes más pequeñas. Codifica el JPEG con calidad 85.
- Añade
.jpgal nombre completo del archivo de entrada:photo.pngse convierte enphoto.png.jpg, yphoto.jpgse convierte enphoto.jpg.jpg. Así se mantienen diferenciadas las entradas con el mismo nombre base. - Exige un directorio de salida nuevo. Un directorio existente, aunque esté vacío, es un error. Los archivos procesados correctamente se conservan si otro archivo falla; una nueva ejecución necesita un directorio de salida diferente.
El codificador solo recibe píxeles, por lo que no se copian los metadatos EXIF, GPS, ICC ni de texto originales. La orientación se aplica a los píxeles antes de descartar esos metadatos. Aquí, la composición alfa y el redimensionamiento operan sobre valores de canal codificados, no sobre valores de luz lineal. Se trata de una simplificación deliberada para miniaturas, no de un pipeline de gestión del color.
Crea el proyecto de Cargo
Los comandos siguientes usan Bash en Linux y se probaron con Rust y Cargo 1.98.1. Instala la
cadena de herramientas estable actual siguiendo la
guía oficial de instalación de Rust.
Usa un directorio de trabajo fuera de un proyecto de Cargo existente y de su configuración
.cargo. Todos los bloques de shell siguientes se pegan desde ese mismo
directorio de trabajo.
rustc --version && cargo --version
Crea un directorio nuevo; este comando rechaza reutilizar un image-batch existente:
(mkdir image-batch && mkdir image-batch/src)
Guarda este manifiesto 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]
Solo se habilitan los códecs PNG y JPEG. Rayon proporciona el paralelismo del lote; la funcionalidad
opcional de Rayon del crate image está deshabilitada. Las
notas de versión de image
describen las API actuales de decodificación y orientación que se usan aquí.
Implementa el comando completo de procesamiento por lotes
Guarda lo siguiente como image-batch/src/main.rs. La exploración del directorio termina antes
de crear el directorio de salida. La función
read_dir de Rust puede fallar tanto al abrir
un directorio como al avanzar su iterador, por lo que ambos errores se propagan en lugar de reducir
el lote silenciosamente.
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 tarea codifica antes de escribir un archivo .part y luego cambia su
nombre al nombre JPEG final. Solo un cambio de nombre completado cuenta como éxito. Un fallo de
escritura o una interrupción puede dejar archivos .part; son trabajo
incompleto, no imágenes publicadas. Mantén este directorio de salida exclusivo para la ejecución.
El ejemplo no sincroniza los archivos con el almacenamiento duradero ni revierte los archivos
procesados correctamente.
El iterador paralelo suma los fallos en lugar de detenerse en el primer error. Así se intenta procesar cada imagen encontrada ante un error ordinario de archivo, y se mantiene un estado de proceso distinto de cero para un lote incompleto. Los mensajes de error pueden aparecer en cualquier orden.
Compila, ejecuta e inspecciona un archivo de salida
Genera image-batch/Cargo.lock una vez y consérvalo con el proyecto. Fijar versiones exactas
de las dependencias directas no basta para congelar las dependencias transitivas; las compilaciones
posteriores usan el archivo de bloqueo conservado.
(cd image-batch && cargo generate-lockfile)
Para obtener una entrada reproducible, instala ImageMagick, probado aquí con la versión 7.1.2-31. Esto crea un PNG de 1.600 × 800 con una mitad izquierda roja opaca y una mitad derecha transparente. Usa nombres de directorio nuevos para el ejemplo:
(
mkdir input-images &&
magick -size 1600x800 xc:none -fill red -draw 'rectangle 0,0 799,799' \
PNG32:input-images/sample.png
)
Compila en modo de lanzamiento y ejecuta con dos hilos de trabajo. El comando ejecuta el programa solo después de que la compilación se complete correctamente:
(
cd image-batch &&
cargo build --release --locked &&
./target/release/image-batch ../input-images ../output-images 2
)
Para esta única entrada, el programa informa 1 succeeded; 0 failed. Inspecciona el archivo
que se generó con un decodificador independiente:
magick ./output-images/sample.png.jpg -format '%m %wx%h\n' info:
JPEG 800x400
Abre también ese JPEG: la mitad izquierda debería ser roja y la derecha blanca, incluido el borde
inferior. resize conserva la relación de
aspecto dentro del recuadro; no fuerza esta imagen a 800 × 600. La comprobación explícita del tamaño
mantiene las dimensiones originales de una entrada más pequeña. Aplanar antes de redimensionar
evita que los valores RGB ocultos en los píxeles transparentes se filtren al fondo blanco.
Para procesar tus propias imágenes, sustituye los dos argumentos de directorio y usa un directorio de salida nuevo. Pon entre comillas las rutas que contengan espacios. Se informa de un archivo seleccionado corrupto si el decodificador lo rechaza; las extensiones no compatibles se omiten. Una decodificación correcta no es una comprobación de integridad: con este decodificador, un JPEG truncado puede devolver un resultado exitoso con bloques grises que sustituyen los píxeles faltantes. Inspecciona el contenido de la imagen en todo el lienzo y conserva tus originales.
Evalúa el número de hilos según la memoria y el tiempo transcurrido
num_threads
limita los hilos de trabajo de este grupo. La CLI acepta desde uno hasta 16; empieza con uno o dos
para entradas grandes. Almacena las rutas del directorio y luego mantiene los píxeles decodificados
solo para las tareas activas. Cada tarea puede necesitar varios búferes de píxeles, por lo que
duplicar los hilos de trabajo puede aumentar considerablemente el uso de memoria.
Los límites de 4.096 píxeles de ancho y alto se aplican a la imagen original antes de orientarla y redimensionarla. El límite de asignación de memoria de 128 MiB del decodificador se aplica en la medida de lo posible y no cubre los búferes RGBA/RGB posteriores, el redimensionamiento, la codificación JPEG, la lista de rutas ni la memoria total del proceso. Estos controles no convierten el programa en un entorno aislado para subidas no confiables.
Tras una compilación correcta, compara la misma entrada representativa de varias imágenes con nuevos destinos:
time ./image-batch/target/release/image-batch ./input-images ./timed-one 1 &&
time ./image-batch/target/release/image-batch ./input-images ./timed-two 2
Compara el tiempo transcurrido real de Bash, repite con nombres de directorio
de salida nuevos y ten en cuenta las cachés del sistema de archivos ya cargadas. La prueba básica
con una sola imagen no permite demostrar una aceleración del lote. Más hilos de trabajo pueden
ayudar cuando la decodificación y el redimensionamiento independientes mantienen ocupados los
núcleos de la CPU; el almacenamiento, la presión sobre la memoria y la sobrecarga de planificación
pueden anular la mejora. Mantén Lanczos3 si su resultado es adecuado para tus imágenes; compara
otro filtro con las mismas entradas antes de sacrificar calidad de imagen para reducir el tiempo
transcurrido.
