Procesamiento dinámico de imágenes en Scala con ImageMagick
Ejecuta ImageMagick desde Scala para redimensionar y añadir marcas de agua a las imágenes que indiques en la línea de comandos. El programa siguiente escribe un PNG por entrada en un directorio nuevo, conserva los originales y devuelve un estado distinto de cero si alguna imagen falla. Las imágenes procesadas correctamente siguen disponibles cuando hay fallos en el lote.
Esta guía se centra en la integración de procesos de Scala y la gestión de errores por lotes. Para generar varios anchos y un formato alternativo para un sitio web, sigue la guía de imágenes adaptables independiente.
Configura un pequeño proyecto de Scala
Usa Linux, Bash, cURL, una instalación de JDK 21 e
ImageMagick 7.1.2-31 con soporte para JPEG, PNG y FreeType.
Este ejemplo se probó con OpenJDK 21.0.12.1, Scala 2.13.18 e ImageMagick 7.1.2-31 Q16-HDRI.
Scala 2.13.18 es la versión actual de Scala 2.13 y
es compatible con JDK 21.
El punto de entrada convert de ImageMagick 6 y los intérpretes de comandos
nativos de Windows quedan fuera del alcance de esta guía.
También necesitas una fuente TrueType que se pueda leer. El comando de ejecución usa
/usr/share/fonts/liberation/LiberationSans-Regular.ttf; sustitúyelo por la ruta completa de tu fuente si tu
distribución la instala en otro lugar. Pega los bloques de comandos con la opción
errexit de Bash desactivada para que un fallo de un comando te devuelva al
indicador de comandos. Cada bloque deja intactos el directorio y las opciones del intérprete desde
el que se ejecuta.
java -version && magick -version && magick -list format
Comprueba que JPEG y PNG admitan lectura y
escritura en la lista de formatos. Empieza en un directorio de trabajo con permisos de escritura.
Este bloque crea un proyecto nuevo scala-images y descarga de Maven Central los
tres archivos jar de Scala con versiones fijadas. Rechaza un directorio de proyecto existente;
elige otro directorio de trabajo si ese nombre ya está en uso.
(
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
)
Estos son el compilador, la biblioteca de ejecución y la biblioteca de reflexión de Scala 2.13.18. El proyecto no usa una compilación con sbt ni dependencias de Scala de terceros. Sus rutas de clases explícitas también funcionan dentro de un proyecto sbt existente sin leer la configuración de compilación del proyecto contenedor ni añadirle dependencias.
Guarda el programa completo de procesamiento por lotes
Guarda esto como scala-images/ImageBatch.scala. Los argumentos son un directorio de salida nuevo,
la ruta de una fuente, el texto de una marca de agua y una o más rutas locales de imágenes estáticas
JPEG o PNG. El texto admite hasta 40 letras ASCII, dígitos, espacios, puntos, guiones bajos y guiones,
y debe empezar por una letra o un dígito. Esto excluye del texto las secuencias de escape de texto y
la sintaxis de lectura de archivos de ImageMagick.
El nombre de salida incluye el nombre completo del archivo de entrada:
photo.jpg se convierte en photo.jpg.png.
Dos entradas con el mismo nombre base se rechazan antes de crear el directorio de salida. Las entradas
se procesan en el orden de los argumentos, incluidas las extensiones .jpg,
.jpeg y .png con mayúsculas y minúsculas combinadas.
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)
pasa argumentos separados al ejecutable. Los espacios en las rutas no requieren construir un comando
del intérprete. Sin embargo, ImageMagick tiene su propia
interpretación de nombres de archivo, incluidos selectores de
fotogramas y patrones de salida. Las operaciones de archivos de Java copian cada archivo de origen a
source, ejecutan ImageMagick en ese directorio privado y mueven
result.png al nombre de salida literal. Esto también mantiene como literales los
corchetes, los signos de porcentaje y los guiones iniciales en tus nombres de archivo.
La orientación se aplica antes de redimensionar y eliminar los metadatos. El redimensionamiento
ajusta la imagen a un área de 800×600 sin recortarla, estirarla ni ampliar las entradas más pequeñas.
Los píxeles transparentes se componen sobre blanco y PNG24: escribe un PNG RGB
opaco de 8 bits. El texto blanco con contorno negro se coloca en la esquina inferior derecha;
usa un texto corto para que quepa en las imágenes pequeñas.
La conversión a sRGB es genérica. Las imágenes sensibles al perfil o de gama de colores amplia, especialmente las fotografías CMYK, necesitan un flujo de trabajo con perfiles ICC adecuado para lograr una correspondencia de color precisa. Conserva los originales. Usa PNG estáticos: el decodificador de PNG solo lee el primer fotograma de APNG, por lo que la comprobación de formato no puede detectar todas las animaciones. Este programa no conserva la animación.
Compila y ejecuta un ejemplo real
Crea dos imágenes de muestra. Estos comandos se ejecutan desde tu directorio de trabajo original y se niegan a reemplazar cualquiera de las muestras si ya existe.
(
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'
)
Compila el programa guardado y luego invoca su clase principal con Java.
&& evita que una compilación fallida ejecute archivos de clase antiguos.
El directorio padre del directorio de salida debe existir; el propio
output no debe existir.
(
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'
)
Una ejecución correcta imprime una línea OK por entrada y
Batch: 2 succeeded, 0 failed, con estado cero.
Inspecciona los archivos de salida con sus nombres literales:
(
cd scala-images &&
magick identify -format '%f: %m %wx%h\n' \
output/landscape.jpg.png 'output/portrait photo.png.png'
)
La imagen horizontal es un PNG de 800×450; la vertical es un PNG de 300×600. Abre ambos archivos en un
visor de imágenes y comprueba el texto Scala batch de la esquina inferior derecha.
Para tu propio lote, reemplaza los dos argumentos de entrada por rutas entre comillas y elige un
directorio de salida nuevo. Java pasa al programa todos los argumentos que siguen a
ImageBatch, incluidos los nombres de archivo que empiezan por
-.
Mantén acotado el procesamiento del lote
El bucle de Scala procesa una imagen a la vez. Cada invocación de ImageMagick incluye
-limit thread 1; esto limita los hilos de procesamiento de ImageMagick, no la memoria
total ni los delegados externos. La decodificación de un archivo de origen grande aún puede superar
la política de recursos de tu instalación antes de que el redimensionamiento lo reduzca.
No cambies la política global solo para ejecutar estos ejemplos. La
documentación de la política de recursos explica esos límites.
Cada tarea prepara una entrada, una fuente y una salida, y luego elimina su directorio temporal.
Los PNG procesados correctamente se acumulan en el directorio del lote. Este ejemplo no tiene un
límite de tiempo por tarea ni limpieza tras una interrupción; detener la JVM puede dejar un directorio
.work-*, que puedes inspeccionar y eliminar después de que los procesos se
detengan. Usa esto como una utilidad local de procesamiento por lotes para archivos de confianza,
no como un servicio de subida.
Diagnostica un lote fallido
El estado de salida 1 significa que al menos una imagen seleccionada falló. La línea
FAILED identifica esa entrada y los recuentos finales incluyen todos los
elementos, incluso cuando una imagen posterior se procesa correctamente. Los archivos vacíos,
no encontrados, ilegibles y no reconocidos fallan sin detener las tareas restantes. La salida temporal
de una conversión fallida se elimina; las imágenes procesadas correctamente permanecen en el
directorio de salida.
El estado de salida 2 significa que el lote no pudo iniciarse, por ejemplo, porque la fuente es ilegible, el texto de la marca de agua no es válido, hay nombres base de entrada duplicados o el directorio de salida ya existe. Una nueva ejecución rechaza el directorio existente y conserva sus archivos. Elige un destino nuevo después de resolver el problema. Mantén las entradas y la fuente sin cambios durante el procesamiento y no permitas que otro proceso edite un directorio de lote mientras esté bajo el control de este programa.
-regard-warnings convierte algunas
advertencias del decodificador en errores; el programa también rechaza un comando que emite mensajes
de diagnóstico aunque devuelva cero. Esto no es una comprobación de integridad de la imagen.
Un decodificador puede recuperar datos dañados sin avisar. Inspecciona por completo el archivo de
origen y el de salida cuando pueda haber datos dañados; por sí solos, un encabezado válido, las
dimensiones esperadas o un estado de proceso correcto no permiten determinar que los píxeles estén
intactos.
Para una aplicación que necesite procesamiento gestionado en lugar de una instalación local de ImageMagick, consulta la API de procesamiento de imágenes de Transloadit.
