Dynamic image processing in Scala with ImageMagick
Run ImageMagick from Scala to resize and watermark the images you name on the command line. The program below writes one PNG per input into a new directory, keeps your originals, and returns a nonzero status if any image fails. Successful neighbors remain available when a batch has failures.
This walkthrough focuses on Scala’s process integration and batch error handling. To generate several widths and a format fallback for a website, follow the separate responsive-image recipe.
Set up a small Scala project
Use Linux, Bash, cURL, a JDK 21 installation, and
ImageMagick 7.1.2-31 with JPEG, PNG, and FreeType support.
This example was tested with OpenJDK 21.0.12.1, Scala 2.13.18, and ImageMagick 7.1.2-31 Q16-HDRI.
Scala 2.13.18 is the current Scala 2.13 release and
supports JDK 21.
ImageMagick 6’s convert entry point and native Windows shells are outside this walkthrough.
You also need a readable TrueType font. The run command uses
/usr/share/fonts/liberation/LiberationSans-Regular.ttf; substitute your font’s full path if your
distribution installs it elsewhere. Paste the shell blocks with Bash’s errexit disabled so a
command failure returns to your prompt. Each block leaves the caller’s directory and shell options
unchanged.
java -version && magick -version && magick -list format
Check that JPEG and PNG have read/write support in the format list. Start in a writable working
directory. This block creates a fresh scala-images project and downloads the three pinned Scala
jars from Maven Central. It refuses an existing project directory; choose another working directory
if that name is already in use.
(
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
)
These are the compiler, runtime library, and reflection library for Scala 2.13.18. The project uses no sbt build or third-party Scala dependencies. Its explicit classpaths also work inside an existing sbt project without reading the parent’s build settings or adding dependencies to it.
Save the complete batch program
Save this as scala-images/ImageBatch.scala. Arguments are a new output directory, a font
path, a watermark label, and one or more local still JPEG or PNG paths. The label accepts up to 40
ASCII letters, digits, spaces, periods, underscores, and hyphens, starting with a letter or digit.
This keeps ImageMagick’s text escapes and file-reading syntax out of the label.
The output name includes the complete input filename: photo.jpg becomes photo.jpg.png.
Two inputs with the same basename are rejected before creating the output directory. Inputs are
processed in argument order, including mixed-case .jpg, .jpeg, and .png extensions.
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)
passes separate arguments to the executable. Spaces in paths do not require constructing a shell
command. However, ImageMagick has its own
filename interpretation, including frame
selectors and output patterns. The Java file operations copy each source to source, run
ImageMagick in that private directory, and move result.png to the literal output name. This also
keeps brackets, percent signs, and leading hyphens in your filenames literal.
Orientation is applied before resizing and stripping metadata. The resize fits within 800×600
without cropping, stretching, or enlarging smaller inputs. Transparent pixels are composited onto
white, and PNG24: writes an opaque, 8-bit RGB PNG. The white label with a black outline is placed
at the lower right; use a short label for small images so it fits.
The sRGB conversion is generic. Profile-sensitive or wide-gamut assets, especially CMYK photographs, need an appropriate ICC profile workflow for accurate color matching. Keep your originals. Use still PNGs: the PNG decoder reads only the first frame of APNG, so the format check cannot detect every animation. This program does not preserve animation.
Build and run a real example
Create two sample images. These commands run from your original working directory and refuse to replace either sample if it already exists.
(
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'
)
Compile the saved program, then invoke its main class with Java. The && prevents a failed build
from launching old class files. The output directory’s parent must exist; output itself must not.
(
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'
)
A successful run prints one OK line per input and Batch: 2 succeeded, 0 failed, with status zero.
Inspect the literal output files:
(
cd scala-images &&
magick identify -format '%f: %m %wx%h\n' \
output/landscape.jpg.png 'output/portrait photo.png.png'
)
The landscape is a PNG at 800×450; the portrait is a PNG at 300×600. Open both files in an image
viewer and check the lower-right Scala batch label. For your own batch, replace the two input
arguments with quoted paths and choose a fresh output directory. Java passes every argument after
ImageBatch to the program, including filenames beginning with -.
Keep the batch bounded
The Scala loop runs one image at a time. Each ImageMagick invocation has -limit thread 1; this
limits ImageMagick’s processing threads, not total memory or external delegates. Decoding a large
source can still exceed your installation’s resource policy before resizing makes it smaller.
Do not change the global policy just to run these samples. The
resource policy documentation explains those limits.
Each job stages one input, one font, and one output, then removes its temporary directory. Successful
PNGs accumulate in the batch directory. This example has no per-job deadline or interruption
cleanup; stopping the JVM may leave a .work-* directory, which you can inspect and remove after
the processes stop. Use this as a local batch utility for trusted files, rather than an upload
service.
Diagnose a failed batch
Exit status 1 means at least one selected image failed. The FAILED line names that input,
and the final counts include all members, even when a later image succeeds. Empty, missing,
unreadable, and unrecognized files fail without stopping the remaining jobs. A failed conversion’s
staged output is removed; successful neighbors stay in the output directory.
Exit status 2 means the batch could not start, for example because the font is unreadable, the watermark label is invalid, input basenames collide, or the output directory already exists. A rerun refuses the existing directory, preserving its files. Choose a new destination after fixing the problem. Keep the inputs and font unchanged during processing, and do not let another process edit a batch directory while this program owns it.
-regard-warnings promotes some
decoder warnings to errors; the program also rejects a command that emits diagnostics despite
returning zero. This is not an image-integrity check. A decoder can silently recover a damaged
payload. Inspect the entire source and output when corruption is possible; a valid header, expected
dimensions, or successful process status alone cannot establish intact pixels.
For an application that needs managed processing instead of a local ImageMagick installation, see Transloadit’s Image Processing API.
