Traitement d’images dynamique en Scala avec ImageMagick
Exécutez ImageMagick depuis Scala pour redimensionner les images que vous désignez sur la ligne de commande et leur ajouter un filigrane. Le programme ci-dessous écrit un PNG par entrée dans un nouveau répertoire, conserve vos originaux et renvoie un statut non nul si le traitement d’une image échoue. Les images traitées avec succès restent disponibles même si le lot comporte des échecs.
Ce guide se concentre sur l’intégration des processus en Scala et la gestion des erreurs par lot. Pour générer plusieurs largeurs et un format de repli pour un site web, suivez le guide des images adaptatives, présenté séparément.
Configurer un petit projet Scala
Utilisez Linux, Bash, cURL, une installation du JDK 21 et
ImageMagick 7.1.2-31 avec la prise en charge de JPEG, PNG et FreeType.
Cet exemple a été testé avec OpenJDK 21.0.12.1, Scala 2.13.18 et ImageMagick 7.1.2-31 Q16-HDRI.
Scala 2.13.18 est la version actuelle de Scala 2.13 et
prend en charge le JDK 21.
Le point d’entrée convert d’ImageMagick 6 et les interpréteurs de commandes
natifs de Windows ne sont pas abordés dans ce guide.
Vous avez également besoin d’une police TrueType accessible en lecture. La commande d’exécution
utilise /usr/share/fonts/liberation/LiberationSans-Regular.ttf ; remplacez ce chemin par le chemin complet de votre police si
votre distribution l’installe ailleurs. Collez les blocs de commandes shell avec l’option
errexit de Bash désactivée afin qu’un échec de commande vous ramène à l’invite.
Chaque bloc laisse inchangés le répertoire courant et les options du shell appelant.
java -version && magick -version && magick -list format
Vérifiez que JPEG et PNG sont pris en charge en
lecture et en écriture dans la liste des formats. Commencez dans un répertoire de travail accessible
en écriture. Ce bloc crée un nouveau projet scala-images et télécharge depuis Maven
Central les trois fichiers JAR de Scala aux versions figées. Il refuse un répertoire de projet
existant ; choisissez un autre répertoire de travail si ce nom est déjà utilisé.
(
mkdir scala-images || exit 1
cd scala-images || exit 1
mkdir lib classes inputs || exit 1
for artifact in scala-library scala-reflect scala-compiler; do
curl -fsSLo "lib/$artifact-2.13.18.jar" \
"https://repo.maven.apache.org/maven2/org/scala-lang/$artifact/2.13.18/$artifact-2.13.18.jar" || exit 1
done
java -cp 'lib/*' scala.tools.nsc.Main -version
)
Il s’agit du compilateur, de la bibliothèque d’exécution et de la bibliothèque de réflexion de Scala 2.13.18. Le projet n’utilise ni compilation via sbt ni dépendances Scala tierces. Ses chemins de classes explicites fonctionnent aussi dans un projet sbt existant, sans lire la configuration de compilation du projet parent ni y ajouter de dépendances.
Enregistrer le programme complet de traitement par lot
Enregistrez ce code dans scala-images/ImageBatch.scala. Les arguments sont un nouveau répertoire
de sortie, un chemin de police, un texte de filigrane et un ou plusieurs chemins locaux d’images
fixes JPEG ou PNG. Le texte accepte jusqu’à 40 caractères ASCII parmi les lettres, les chiffres,
les espaces, les points, les tirets bas et les traits d’union, et doit commencer par une lettre ou
un chiffre. Cela exclut du texte les séquences d’échappement et la syntaxe de lecture de fichiers
d’ImageMagick.
Le nom de sortie inclut le nom complet du fichier d’entrée : photo.jpg devient
photo.jpg.png. Deux entrées ayant le même nom de base sont rejetées avant la
création du répertoire de sortie. Les entrées sont traitées dans l’ordre des arguments, y compris
celles portant les extensions .jpg, .jpeg et
.png avec différentes combinaisons de majuscules et de minuscules.
import java.io.IOException
import java.nio.file.{Files, Path, Paths}
import java.util.Locale
import scala.sys.process.{Process, ProcessLogger}
import scala.util.control.NonFatal
object ImageBatch {
private def command(arguments: Seq[String], directory: Path): String = {
val output = new StringBuilder
val errors = new StringBuilder
val status = Process(arguments, directory.toFile).!(ProcessLogger(
line => { output.append(line).append('\n'); () },
line => { errors.append(line).append('\n'); () }
))
if (status != 0 || errors.nonEmpty) {
throw new IOException(s"ImageMagick status $status: ${errors.toString.trim}")
}
output.toString
}
private def processImage(input: Path, output: Path, font: Path, label: String): Boolean = {
try {
val name = input.getFileName.toString.toLowerCase(Locale.ROOT)
if (!Seq(".jpg", ".jpeg", ".png").exists(name.endsWith)) {
throw new IOException("Expected a .jpg, .jpeg, or .png filename")
}
if (!Files.isRegularFile(input) || !Files.isReadable(input)) {
throw new IOException("Not a readable regular file")
}
val stage = Files.createTempDirectory(output, ".work-")
try {
// Ordinary private names prevent ImageMagick from interpreting the reader's paths.
Files.copy(input, stage.resolve("source"))
Files.copy(font, stage.resolve("font.ttf"))
val formats = command(Seq("magick", "identify", "-regard-warnings",
"-limit", "thread", "1", "-format", "%m\n", "source"), stage)
.linesIterator.toVector
if (formats != Vector("JPEG") && formats != Vector("PNG")) {
throw new IOException("Expected one still JPEG or PNG image")
}
command(Seq(
"magick", "-regard-warnings", "-limit", "thread", "1", "source",
"-auto-orient", "-colorspace", "sRGB", "-resize", "800x600>",
"-background", "white", "-alpha", "remove", "-alpha", "off",
"-font", "font.ttf", "-pointsize", "24", "-gravity", "southeast",
"-fill", "white", "-stroke", "black", "-strokewidth", "1",
"-annotate", "+12+12", label, "-strip", "PNG24:result.png"
), stage)
Files.move(stage.resolve("result.png"), output.resolve(input.getFileName.toString + ".png"))
} finally {
val children = Files.list(stage)
try children.forEach(path => { Files.delete(path); () })
finally children.close()
Files.delete(stage)
}
println(s"OK $input")
true
} catch {
case NonFatal(error) =>
Console.err.println(s"FAILED $input: ${error.getMessage}")
false
}
}
private def run(arguments: Array[String]): Int = {
if (arguments.length < 4) {
Console.err.println("Usage: ImageBatch NEW_OUTPUT_DIR FONT.ttf LABEL INPUT [INPUT ...]")
return 2
}
val output = Paths.get(arguments(0)).toAbsolutePath
val font = Paths.get(arguments(1)).toAbsolutePath
val label = arguments(2)
val inputs = arguments.drop(3).map(value => Paths.get(value).toAbsolutePath).toVector
require(Files.isRegularFile(font) && Files.isReadable(font), "Font must be readable")
require(label.matches("[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}"), "Invalid watermark label")
require(inputs.map(_.getFileName.toString).distinct.size == inputs.size,
"Input basenames must be distinct")
// Creating a new directory reserves this batch; an existing directory is never reused.
Files.createDirectory(output)
var succeeded = 0
var failed = 0
for (input <- inputs) {
if (processImage(input, output, font, label)) succeeded += 1
else failed += 1
}
println(s"Batch: $succeeded succeeded, $failed failed")
if (failed == 0) 0 else 1
}
def main(arguments: Array[String]): Unit = {
val status = try run(arguments) catch {
case NonFatal(error) =>
Console.err.println(s"Cannot start batch: ${error.getMessage}")
2
}
sys.exit(status)
}
}
Process(Seq(...), directory)
transmet des arguments séparés à l’exécutable. Les espaces dans les chemins ne nécessitent pas de
construire une commande shell. Toutefois, ImageMagick possède sa propre
interprétation des noms de fichiers, qui inclut des sélecteurs
d’images et des motifs de sortie. Le programme utilise les opérations sur les fichiers de Java pour
copier chaque source vers source, exécute ImageMagick dans ce répertoire
privé, puis déplace result.png vers le nom de sortie littéral. Ainsi, les
crochets, les signes pour cent et les traits d’union initiaux de vos noms de fichiers sont aussi
interprétés littéralement.
L’orientation est appliquée avant le redimensionnement et la suppression des métadonnées. Le
redimensionnement fait tenir l’image dans 800×600 sans recadrage, étirement ni agrandissement des
images plus petites. Les pixels transparents sont composités sur un fond blanc, et
PNG24: écrit un PNG RGB opaque sur 8 bits. Le texte blanc à contour noir est
placé en bas à droite ; utilisez un texte court pour qu’il tienne sur les petites images.
La conversion sRGB est générique. Les ressources sensibles aux profils ou à large gamut, notamment les photographies CMYK, nécessitent un flux de travail avec des profils ICC adapté pour une correspondance fidèle des couleurs. Conservez vos originaux. Utilisez des PNG fixes : le décodeur PNG ne lit que la première image d’un APNG, la vérification du format ne peut donc pas détecter toutes les animations. Ce programme ne préserve pas l’animation.
Compiler et exécuter un exemple concret
Créez deux images d’exemple. Ces commandes s’exécutent depuis votre répertoire de travail initial et refusent de remplacer l’une ou l’autre des images si elle existe déjà.
(
cd scala-images &&
test ! -e inputs/landscape.jpg && test ! -L inputs/landscape.jpg &&
test ! -e 'inputs/portrait photo.png' && test ! -L 'inputs/portrait photo.png' &&
magick -size 1600x900 xc:steelblue inputs/landscape.jpg &&
magick -size 600x1200 xc:seagreen 'inputs/portrait photo.png'
)
Compilez le programme enregistré, puis invoquez sa classe principale avec Java.
&& empêche le lancement d’anciens fichiers de classes si la compilation
échoue. Le répertoire parent du répertoire de sortie doit exister ; output
lui-même ne doit pas exister.
(
cd scala-images &&
java -cp 'lib/*' scala.tools.nsc.Main -usejavacp -d classes ImageBatch.scala &&
java -cp 'classes:lib/scala-library-2.13.18.jar' ImageBatch \
output /usr/share/fonts/liberation/LiberationSans-Regular.ttf 'Scala batch' \
inputs/landscape.jpg 'inputs/portrait photo.png'
)
Une exécution réussie affiche une ligne OK par entrée ainsi que
Batch: 2 succeeded, 0 failed, avec un statut nul. Inspectez les fichiers de sortie aux noms
littéraux :
(
cd scala-images &&
magick identify -format '%f: %m %wx%h\n' \
output/landscape.jpg.png 'output/portrait photo.png.png'
)
Le paysage est un PNG de 800×450 ; le portrait est un PNG de 300×600. Ouvrez les deux fichiers dans
une visionneuse d’images et vérifiez le texte Scala batch en bas à droite. Pour
votre propre lot, remplacez les deux arguments d’entrée par des chemins entre guillemets et
choisissez un nouveau répertoire de sortie. Java transmet au programme tous les arguments placés
après ImageBatch, y compris les noms de fichiers commençant par
-.
Limiter les ressources du traitement par lot
La boucle Scala traite une image à la fois. Chaque invocation d’ImageMagick utilise
-limit thread 1 ; cela limite les threads de traitement d’ImageMagick, mais pas la
mémoire totale ni les programmes délégués externes. Le décodage d’une source volumineuse peut encore
dépasser les limites de la politique de ressources de votre installation avant que le
redimensionnement ne réduise l’image. Ne modifiez pas la politique globale simplement pour exécuter
ces exemples. La documentation sur la politique de ressources
explique ces limites.
Chaque tâche utilise un espace temporaire pour une entrée, une police et une sortie, puis supprime
son répertoire temporaire. Les PNG créés avec succès s’accumulent dans le répertoire du lot. Cet
exemple ne prévoit ni délai maximal par tâche ni nettoyage en cas d’interruption ; l’arrêt de la
JVM peut laisser un répertoire .work-*, que vous pouvez inspecter et supprimer
après l’arrêt des processus. Utilisez ce programme comme un utilitaire local de traitement par lot
pour des fichiers de confiance, plutôt que comme un service de téléversement.
Diagnostiquer l’échec d’un traitement par lot
Le code de sortie 1 signifie que le traitement d’au moins une image sélectionnée a échoué. La
ligne FAILED indique l’entrée concernée, et les décomptes finaux incluent
toutes les images, même si une image ultérieure est traitée avec succès. Les fichiers vides,
manquants, illisibles ou non reconnus provoquent un échec sans arrêter les tâches restantes. La
sortie temporaire d’une conversion échouée est supprimée ; les images traitées avec succès restent
dans le répertoire de sortie.
Le code de sortie 2 signifie que le traitement du lot n’a pas pu démarrer, par exemple parce que la police est illisible, que le texte du filigrane est invalide, que les noms de base des entrées sont en conflit ou que le répertoire de sortie existe déjà. Une nouvelle exécution refuse le répertoire existant et préserve ainsi ses fichiers. Choisissez une nouvelle destination après avoir corrigé le problème. Gardez les entrées et la police inchangées pendant le traitement, et ne laissez pas un autre processus modifier le répertoire d’un lot tant que ce programme en a le contrôle.
-regard-warnings transforme certains avertissements du
décodeur en erreurs ; le programme rejette aussi une commande qui émet des messages de diagnostic
malgré un code de retour nul. Il ne s’agit pas d’un contrôle d’intégrité des images. Un décodeur peut
récupérer silencieusement des données endommagées. Inspectez l’intégralité de la source et de la
sortie lorsqu’une corruption est possible ; un en-tête valide, les dimensions attendues ou un code
de sortie indiquant une réussite ne suffisent pas, à eux seuls, à établir que les pixels sont intacts.
Pour une application qui nécessite un traitement géré plutôt qu’une installation locale d’ImageMagick, consultez l’API de traitement d’images de Transloadit.
