Optimiser le traitement d’images en Rust : parallélisme et Rayon
Utilisez Rayon pour traiter des images distinctes simultanément, avec une tâche décodage–redimensionnement–encodage par fil d’exécution. Ce tutoriel construit une CLI Rust qui convertit un répertoire d’images PNG et JPEG en JPEG plus légers. Vous choisissez le nombre de fils d’exécution, et la commande signale chaque échec de fichier avant de renvoyer son statut final.
Choisir les règles pour les images et les sorties
L’exemple concerne des lots locaux d’images fixes, avec les choix suivants :
- Sélectionnez les fichiers ordinaires se terminant par
.png,.jpgou.jpeg, sans tenir compte de la casse de l’extension. Ignorez les sous-répertoires, les liens symboliques et les autres extensions. Ne modifiez pas le répertoire d’entrée pendant une exécution. - Acceptez les images décodées en RGB 8 bits, en niveaux de gris, ou l’un des deux avec canal alpha. Utilisez des entrées sRGB : le programme ne convertit pas les profils colorimétriques intégrés. L’animation et les flux d’impression avec gestion des couleurs sortent du cadre de cet exemple.
- Appliquez l’orientation EXIF du décodeur, aplatissez la transparence sur un fond blanc, puis ajustez l’image dans 800 × 600 pixels sans recadrer ni agrandir les images plus petites. Encodez le JPEG avec une qualité de 85.
- Ajoutez
.jpgà la fin du nom complet du fichier d’entrée :photo.pngdevientphoto.png.jpg, etphoto.jpgdevientphoto.jpg.jpg. Ainsi, les entrées ayant le même radical restent distinctes. - Exigez un nouveau répertoire de sortie. Un répertoire existant, même vide, constitue une erreur. Les fichiers réussis sont conservés lorsqu’un autre fichier échoue ; une nouvelle exécution nécessite un autre répertoire de sortie.
L’encodeur ne reçoit que les pixels : les métadonnées EXIF, GPS, ICC et textuelles de la source ne sont donc pas copiées. L’orientation est appliquée aux pixels avant que ces métadonnées ne soient supprimées. Ici, la composition alpha et le redimensionnement opèrent sur les valeurs de canal encodées, et non sur des valeurs en lumière linéaire. C’est une simplification délibérée pour des miniatures, pas un pipeline de gestion des couleurs.
Créer le projet Cargo
Les commandes ci-dessous utilisent Bash sous Linux et ont été testées avec Rust et Cargo 1.98.1.
Installez la chaîne d’outils stable actuelle à l’aide du guide d’installation officiel de Rust.
Utilisez un répertoire de travail situé en dehors de tout projet Cargo existant et de sa
configuration .cargo. Tous les blocs shell ci-dessous sont collés depuis ce même répertoire de travail.
rustc --version && cargo --version
Créez un nouveau répertoire ; cette commande refuse de réutiliser un image-batch existant :
(mkdir image-batch && mkdir image-batch/src)
Enregistrez ce manifeste complet sous 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]
Seuls les codecs PNG et JPEG sont activés. Rayon fournit le parallélisme du traitement par lots ;
la fonctionnalité Rayon optionnelle de la crate image est désactivée. Les notes de version de la crate image
décrivent les API actuelles de décodage et d’orientation utilisées ici.
Implémenter la commande complète de traitement par lots
Enregistrez le code suivant sous image-batch/src/main.rs. L’exploration du répertoire se termine avant la création
du répertoire de sortie. read_dir de Rust peut échouer
à l’ouverture d’un répertoire comme pendant l’avancement de son itérateur ; les deux erreurs sont
donc propagées au lieu de réduire silencieusement le lot.
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
}
}
}
Chaque tâche encode l’image avant d’écrire un fichier .part, puis renomme ce fichier avec son nom
JPEG final. Seul un renommage terminé compte comme un succès. Un échec d’écriture ou une
interruption peut laisser des fichiers .part ; il s’agit de travail incomplet, pas d’images
publiées. Réservez ce répertoire de sortie à cette seule exécution. L’exemple ne synchronise pas
les fichiers vers un stockage durable et n’annule pas les fichiers réussis.
L’itérateur parallèle additionne les échecs au lieu de s’arrêter à la première erreur. Ainsi, en cas d’erreur ordinaire propre à un fichier, chaque image découverte est tentée, tandis qu’un statut de processus non nul est conservé pour un lot incomplet. Les messages d’erreur peuvent apparaître dans n’importe quel ordre.
Compiler, exécuter et inspecter une sortie
Générez image-batch/Cargo.lock une seule fois et conservez-le avec le projet. Épingler exactement les dépendances
directes ne suffit pas à figer les dépendances transitives ; les compilations suivantes utilisent
le fichier de verrouillage conservé.
(cd image-batch && cargo generate-lockfile)
Pour obtenir une entrée reproductible, installez ImageMagick, testé ici avec la version 7.1.2-31. Cette commande crée un PNG de 1 600 × 800 dont la moitié gauche est rouge opaque et la moitié droite transparente. Utilisez de nouveaux noms de répertoires pour l’exemple :
(
mkdir input-images &&
magick -size 1600x800 xc:none -fill red -draw 'rectangle 0,0 799,799' \
PNG32:input-images/sample.png
)
Compilez en mode release et exécutez avec deux fils d’exécution. La commande n’exécute le binaire qu’après la réussite de la compilation :
(
cd image-batch &&
cargo build --release --locked &&
./target/release/image-batch ../input-images ../output-images 2
)
Pour cette unique entrée, le programme affiche 1 succeeded; 0 failed. Inspectez le fichier réellement produit
avec un décodeur indépendant :
magick ./output-images/sample.png.jpg -format '%m %wx%h\n' info:
JPEG 800x400
Ouvrez également ce JPEG : la moitié gauche doit être rouge et la moitié droite blanche, y compris
le bord inférieur. resize préserve
le rapport d’aspect à l’intérieur du cadre ; il ne force pas cette image à 800 × 600. La
vérification explicite de la taille conserve les dimensions d’origine d’une entrée plus petite.
Aplatir avant de redimensionner empêche les valeurs RGB masquées des pixels transparents de
déborder sur le fond blanc.
Pour traiter vos propres images, remplacez les deux arguments de répertoire et utilisez toujours un nouveau répertoire de sortie. Mettez entre guillemets les chemins contenant des espaces. Un fichier sélectionné corrompu est signalé si le décodeur le rejette ; les extensions non prises en charge sont ignorées. Un décodage réussi n’est pas une vérification d’intégrité : avec ce décodeur, un JPEG tronqué peut renvoyer un succès, des blocs gris remplaçant alors les pixels manquants. Inspectez le contenu de l’image sur toute sa surface et conservez vos originaux.
Mesurer le nombre de fils d’exécution par rapport à la mémoire et au temps écoulé
num_threads
limite le nombre de fils d’exécution de ce pool. La CLI accepte une valeur de un à 16 ; commencez
par un ou deux pour les entrées volumineuses. Elle stocke les chemins du répertoire, puis ne
conserve les pixels décodés que pour les tâches actives. Chaque tâche peut nécessiter plusieurs
tampons de pixels : doubler le nombre de fils d’exécution peut donc augmenter considérablement
l’utilisation de la mémoire.
Les limites de 4 096 pixels en largeur et en hauteur s’appliquent à la source avant l’orientation et le redimensionnement. La limite d’allocation de 128 MiB du décodeur est appliquée au mieux, et ne couvre ni les tampons RGBA/RGB ultérieurs, ni le redimensionnement, ni l’encodage JPEG, ni la liste des chemins, ni la mémoire totale du processus. Ces contrôles ne font pas du programme un bac à sable pour des fichiers téléversés non fiables.
Après une compilation réussie, comparez la même entrée représentative de plusieurs images avec de nouvelles destinations :
time ./image-batch/target/release/image-batch ./input-images ./timed-one 1 &&
time ./image-batch/target/release/image-batch ./input-images ./timed-two 2
Comparez le temps écoulé indiqué par real de Bash, répétez avec de nouveaux noms de répertoires de
sortie et tenez compte des caches du système de fichiers déjà chauds. Le test rapide sur une seule
image ne peut pas démontrer l’accélération d’un lot. Davantage de fils d’exécution peuvent aider
lorsque le décodage et le redimensionnement indépendants occupent les cœurs du processeur ; le
stockage, la pression mémoire et le coût d’ordonnancement peuvent annuler ce gain. Conservez
Lanczos3 si son résultat convient à vos images ; comparez un autre filtre avec les mêmes entrées
avant de sacrifier la qualité d’image au profit du temps écoulé.
