Bulk Optimizing Images with ImageMagick

By | November 20, 2023

Summary

The article discusses bulk optimizing images using ImageMagick, a powerful open-source image manipulation tool. The author provides a step-by-step guide on installing and using the software to resize, compress and enhance multiple images simultaneously, reducing their size without compromising quality. The article also includes various command-line examples for different scenarios, such as batch resizing, compression, and format conversion. It is a valuable resource for optimising many images for their website or digital project.

The Problem We Face

At the College where I do my regular day-time job, we must upload student images to the School Management System. In this case, the software is called The Alpha School System or TASS. Our admin staff faced a problem: when not taken by the school photographer, the images can be of all sorts of quality. My problem is that the school photographer wasn’t supplying the photographs already optimized for the database.

Optimizing the images one at a time is very time-consuming, and the readily available tools to bulk process the photos don’t provide the output I need. I needed a tool that would:

  • Resize the images to the dimensions of 500px x 625px.
  • The image should then be cropped so that the picture data fills the whole frame (no black or white borders on any side of the image). The crop should be centred on the image so the student remains in the middle of the frame.
  • Resample the image to reduce the DPI to 72
  • Save the picture as a JPEG with a size no larger than 50KB.

Example

The easiest way to explain what was needed is with an example. The image on the left is the original file from https://pxhere.com/en/photo/108386. After processing, the image’s size was reduced by almost 98% and is the perfect dimension to be uploaded to the database. The face of the person is correctly positioned in the frame.

Required Workflow

The desired workflow for the end user is to:

  1. Upload one or more images of any standard format, such as JPG or PNG, to the software.
    • A single image can be provided as an image file.
    • Multiple images will be uploaded as a single ZIP file.
  2. The software processes all of the provided images and
    • It provides a secure method for the user to retrieve the processed images.
    • The images should be provided to the user as a single ZIP file suitable for upload into TASS.
  3. The images should be removed when they are no longer required to remain on the server to preserve privacy.

If You Can’t Be Bothered

If you can’t be bothered to create your instance of this tool, you can use mine at https://www.street.id.au/tass/. This is the production version, and it does have some extra features.

Components

ImageMagick

ImageMagick is a powerful open-source software for displaying, converting, and editing images. It can read and write over 200 image formats, making it a versatile tool for working with images in various formats. With ImageMagick, users can resize, crop, flip, and rotate images, apply various effects and filters, and create animations. It can be used through a command-line interface or with various programming languages such as Python, Perl, and Ruby. Overall, ImageMagick is a popular and essential tool for anyone who works with digital images.

Installing ImageMagick

You can install ImageMagick on an Ubuntu 22.04 LTS Server without a GUI by following these steps:

  1. Open a terminal window on your server.
  2. Update the package list by running the following command:
sudo apt update
  1. Install ImageMagick by running the following command:
sudo apt install imagemagick
  1. Verify the installation by checking the version of ImageMagick using the following command:
convert -version

Using ImageMagick to Convert an Image

convert -resize 500x625^ -gravity center -extent 500x625 -density 72 -strip -define jpeg:extent=50K input_image.jpg output_image.jpg

This command uses the ImageMagick command-line tool ‘convert’ to modify an input image file named ‘input_image.jpg’ and save the modified image as ‘output_image.jpg’. Here’s what each option does:

  • ‘/usr/bin/convert’: specifies the location of the ‘convert’ tool on the system.
  • ‘-resize 500×625^’: resizes the image to fit within a 500×625 box while maintaining the aspect ratio. The ‘^’ indicates that the image should be resized to the smallest dimension (height or width) greater than or equal to the specified size.
  • ‘-gravity center’: positions the resized image in the centre of the 500×625 box.
  • ‘-extent 500×625’: sets the canvas size to 500×625 and centres the resized image within it. This ensures that the output image has a consistent size and aspect ratio.
  • ‘-density 72’: sets the image density to 72 DPI (dots per inch), a standard resolution for web images.
  • ‘-strip’: removes any metadata from the image file, such as comments or EXIF data.
  • ‘-define jpeg:extent=50K’: sets the maximum file size for the JPEG output to 50 kilobytes. This will reduce the image quality to meet the file size constraint if necessary.

Using ImageMagick this way is handy if you are technical. A technical person could also write a script to bulk-process images with this. Most of our office staff wouldn’t be able to do that, so it’s time to put this into a web application.

The TASS Bulk Photo Optimizer

The final application that satisfies all workflow requirements will allow non-technical staff to upload batches of photographs. The ZIP file that the user receives back can be opened to access individual photos or directly uploaded into TASS, provided all of the image files were named correctly in the first place.

The files below need to be installed on to a Linux server with Apache, PHP and the command line utilities ImageMagick and Zip.

The required folder structure looks like this:

[drwxr-xr-x www-data www-data]  .
├── [drwxr-xr-x www-data www-data]  download
├── [drwxr-xr-x root     root    ]  images
│   ├── [-rw-r--r-- root     root    ]  logo.png
├── [-rw-r--r-- root     root    ]  index.html
├── [-rw-r--r-- root     root    ]  uploader.php
└── [drwxr-xr-x www-data www-data]  uploads

index.html

The HTML file defines a web page with a form that allows users to upload a file and submit it to a server-side script for processing. The JavaScript code adds an event listener to the form to prevent the default form submission behaviour and instead uses an AJAX request to submit the form data to the server. It also includes custom code to handle progress updates for the upload.

The CSS styling sets the web page’s background colour, font, and layout. It also defines a progress bar to display upload progress.

<!DOCTYPE html>
<html>
<head>
  <title>TASS Photo Bulk Photo Optimizer</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <script>
    $(document).ready(function() { // wait for document to be ready
      $('#fileUploadForm').on('submit', function(e) { // attach a submit event handler to the form with ID 'fileUploadForm'
        e.preventDefault(); // prevent the default form submission behavior
        var formData = new FormData(this); // create a new FormData object from the form
        $('#progressBarContainer').css('display', 'block'); // show the container for the progress bar
        $.ajax({ // make an AJAX request
          xhr: function() { // set up a custom XHR object to handle the progress bar
            var xhr = new window.XMLHttpRequest();
            xhr.upload.addEventListener('progress', function(e) { // add a progress event listener
              if (e.lengthComputable) {
                var percent = Math.round((e.loaded / e.total) * 100); // calculate upload progress percentage
                $('#progressBar').css('width', percent + '%').text(percent + '%'); // update the progress bar width and text
              }
            });
            return xhr;
          },
          type: 'POST', // set the HTTP method to POST
          url: 'uploader.php', // set the URL for the server-side script that handles the file upload
          data: formData, // set the data to be sent to the server (the file(s) and any other form fields)
          processData: false, // prevent jQuery from automatically processing the data
          contentType: false, // prevent jQuery from automatically setting the content type header
          success: function(data) { // callback function to be executed if the request is successful
            $('#progressBar').text('Upload Complete'); // update the progress bar text
          }
        });
      });
    });
  </script>
  <style>
    html {
      background-color: #002800;
    }
    body {
      font-family: Arial, sans-serif;
      max-width: 900px;
      margin: 0 auto;
      padding: 20px;
      background-color: white;
    }
    h1 {
      text-align: center;
      margin: 20px 0;
      font-size: 1.5rem;
    }
    hr {
      border: none;
      border-top: 1px solid #ccc;
      margin: 20px 0;
    }
    h2 {
      font-size: 1.2rem;
      margin: 20px 0 10px;
    }
    h3 {
      font-size: 1.0rem;
      margin: 20px 0 10px;
    }
    p {
      font-size: 1.0rem;
      line-height: 1.5;
      margin-bottom: 20px;
    }
    ul {
      font-size: 1.0rem;
      line-height: 1.5;
      margin-bottom: 20px;
      padding-left: 20px;
    }
    ol {
      font-size: 1.0rem;
      line-height: 1.5;
      margin-bottom: 20px;
      padding-left: 20px;
    }
    li {
      margin-bottom: 10px;
    }
    #progressBarContainer {
      display: block;
      max-width: 775px;
      margin: 0 auto;
      height: 30px;
      background-color: #f1f1f1;
      margin-bottom: 20px;
    }
    #progressBar {
      height: 100%;
      background-color: #4CAF50;
      text-align: center;
      line-height: 30px;
      color: white;
      width: 0%;
      transition: width 0.5s ease-in-out;
    }
    form {
      max-width: 500px;
      margin: 0 auto;
      padding: 0 20px;
      box-sizing: border-box;
    }
    label {
      display: block;
      font-size: 1.0rem;
      margin-bottom: 5px;
    }
    input[type="email"],
    input[type="file"],
    button[type="submit"] {
      display: block;
      width: 100%;
      font-size: 1.2rem;
      padding: 10px;
      margin-bottom: 20px;
      box-sizing: border-box;
    }
  </style>
</head>

<body> <img src="images/logo_sml.png" alt="Streetwise Logo" width="65px" height="65px" style="float: right; margin-left: 10px;">
  <h1>TASS Bulk Photo Optimizer</h1>
  <hr />
  <div id="progressBarContainer">
    <div id="progressBar">Upload Progress</div>
  </div>
  <form method="post" action="uploader.php" enctype="multipart/form-data" id="fileUploadForm">
    <label for="email">Email Address:</label>
    <input type="email" name="email" id="email" required>
    <br>
    <br>
    <label for="file">File Upload:</label>
    <input type="file" name="file" id="file" accept=".jpeg,.jpg,.zip" required>
    <br>
    <br>
    <button type="submit">Upload</button>
  </form>
</body>
</html>

uploader.php

The main PHP script for this application is called uploader.php, and the form in index.html calls it. It contains four functions that perform different tasks:

  1. email() sends an email to a given address containing a link to download a specified zip file. The email is sent using the built-in PHP mail() function and includes default headers for the sender and reply-to address. The function returns true if the email was sent successfully, false otherwise.
  2. delete() recursively deletes the specified directory and all its contents or the given file. If the path is a directory, all its subdirectories and files will also be deleted. If the path is a file, only that file will be deleted. The function throws an exception if the path is invalid or inaccessible.
  3. generateRandomString() generates a random string of a specified length. The default length is 16 characters.
  4. resize() resizes all image files in the given directory to a fixed width and height, crops them to maintain the aspect ratio, and reduces their file size to a target size while preserving quality. The resized images are stored in a temporary subdirectory, then zipped and returned as a file for download. The function returns the name of the zip file containing the resized images.

Each function is well-commented, and its purpose is clear. The first two functions are utility functions that may be useful in various applications. The third function can be useful whenever a random string of characters is required, such as when generating a temporary file name. The fourth function is specific to image processing and may be helpful in web applications that allow users to upload and resize images.

<?php
/**
 * Sends an email to the given address containing a link to download the specified
 * zip file. The email is sent using the built-in PHP mail() function and includes
 * default headers for the sender and reply-to address. Returns true if the email
 * was sent successfully, false otherwise.
 * @param string $address The email address to send the email to.
 * @param string $file The URL or hyperlink to the zip file to be downloaded.
 * @return bool True if the email was sent successfully, false otherwise.
 */
function email($address, $file)
{
    // Set the email parameters
    $to = $address;
    $subject = "Download the zip file";
    $message =
        "Please click on the following link to download the zip file: " . $file;

    // Set the email headers
    $headers =
        "From: sender@example.com" .
        "\r\n" .
        "Reply-To: sender@example.com" .
        "\r\n" .
        "X-Mailer: PHP/" .
        phpversion();

    // Send the email using the parameters and headers
    // Return true if the email was sent successfully, false otherwise
    if (mail($to, $subject, $message, $headers)) {
        return true;
    } else {
        return false;
    }
}

/**
Recursively deletes the given directory and all its contents, or the given file.
If the path is a directory, all its subdirectories and files will also be deleted.
If the path is a file, only that file will be deleted.
@param string $path The path to the directory or file to delete.
@throws Exception If the path is invalid or inaccessible.
*/
function delete($path)
{
    // Check if the path is a directory
    if (is_dir($path)) {
        // Create a new iterator object to iterate over all files in the directory
        $files = new RecursiveIteratorIterator(
            // Specify the path to the directory and exclude dot files and directories
            new RecursiveDirectoryIterator(
                $path,
                RecursiveDirectoryIterator::SKIP_DOTS
            ),
            // Iterate over child directories before files
            RecursiveIteratorIterator::CHILD_FIRST
        );

        // Loop through each file and directory found in the specified path
        foreach ($files as $file) {
            // If the current file or directory is a directory, delete it
            if ($file->isDir()) {
                rmdir($file->getRealPath());
            } else {
                // If the current file is not a directory, delete it
                unlink($file->getRealPath());
            }
        }

        // Delete the original directory
        rmdir($path);
    } else {
        // If the specified path is not a directory, throw an exception
        throw new Exception("Invalid path");
    }
}

/**
 * Generate a random string of the specified length.
 * @param int $length The length of the random string to generate. Defaults to 16.
 * @return string The random string.
 */
function generateRandomString($length = 16)
{
    // Define the characters that can appear in the random string.
    $characters =
        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

    // Generate a random string of bytes twice the length of the requested string,
    // since each character will be represented by two bytes (in hex).
    $random_bytes = random_bytes($length * 2);

    // Convert the random bytes to hex, shuffle the characters, and take the first
    // $length characters to create the final random string.
    $random_string = substr(str_shuffle(bin2hex($random_bytes)), 0, $length);

    // Return the random string.
    return $random_string;
}

/**
 * Resizes all image files in the given directory to a fixed width and height,
 * crops them to maintain aspect ratio, and reduces their file size to a target
 * size while preserving quality. The resized images are stored in a temporary
 * subdirectory, which is then zipped and returned as a file for download.
 * @param string $path The path to the directory containing the image files.
 * @return string The name of the zip file containing the resized images.
 */
function resize($path): string
{
    // Set the desired width and height in pixels, DPI, target file size, and temporary zip file name
    $width = 500;
    $height = 625;
    $dpi = 72;
    $targetFileSize = "50K";
    $tempZip = generateRandomString(30) . ".zip";

    // Scan the directory and create a new temporary directory
    $files = scandir($path);
    mkdir($path . "/temp", 0700, true);

    // Create an array to store the image processing subprocesses
    $processes = [];

    // Loop through all files in the directory
    foreach ($files as $file) {
        // Get the MIME type of the file
        $mimeType = mime_content_type("$path/$file");
        // If the MIME type indicates an image
        if (strpos($mimeType, "image/") === 0) {
            // Get the file path information and set the input and output file paths
            $pathInfo = pathinfo("$path/$file");
            $inputFile = "$path/$file";
            $outputFile = $path . "/temp/" . $pathInfo["filename"] . ".jpg";

            // Set the image processing command options
            $command = [
                "/usr/bin/convert",
                "-resize",
                "{$width}x{$height}^",
                "-gravity",
                "center",
                "-extent",
                "{$width}x{$height}",
                "-density",
                $dpi,
                "-strip",
                "-define",
                "jpeg:extent={$targetFileSize}",
                $inputFile,
                $outputFile,
            ];

            // Execute the image processing command as a subprocess and store the process handle in the processes array
            $process = proc_open(
                implode(" ", $command),
                [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]],
                $pipes
            );

            if (is_resource($process)) {
                $processes[] = $process;
            }
        }
    }

    // Wait for all image processing subprocesses to complete
    foreach ($processes as $process) {
        proc_close($process);
    }

    // Create a new zip file containing all the processed images and delete the original directory
    $zipCommand = "/usr/bin/zip -jum0 download/$tempZip {$path}/temp/*.jpg";
    system($zipCommand);
    delete($path);

    // Return the name of the temporary zip file
    return $tempZip;
}

/**
 * Handles the form submission to upload and process a file, and sends an email
 * with the output of the processing to the specified email address. The function
 * checks if the email is valid, if a file was uploaded, if the file type is
 * allowed, creates a secure directory for the uploaded files, moves the uploaded
 * file to the directory, processes the uploaded file based on its MIME type,
 * and sends an email with the output of the processing.
 */
function main()
{
    // Define allowed file types
    $allowed_types = ["image/jpeg", "application/zip"];

    // Check if the form has been submitted
    if ($_SERVER["REQUEST_METHOD"] === "POST") {
        // Check if email is valid
        $email = $_POST["email"];
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            echo "Invalid email address.";
            exit();
        }

        // Check if a file was uploaded
        if (
            !isset($_FILES["file"]) || // if file not set
            !is_uploaded_file($_FILES["file"]["tmp_name"]) // if file not uploaded
        ) {
            echo "No file uploaded.";
            exit();
        }

        // Check if file type is allowed
        $file_type = mime_content_type($_FILES["file"]["tmp_name"]); // get mime type of uploaded file
        if (!in_array($file_type, $allowed_types)) {
            // check if mime type is in allowed types
            echo "Invalid file type.";
            exit();
        }

        // Create a directory for uploaded files with secure permissions
        $path = "uploads/" . generateRandomString(30); // create a unique path for uploaded file
        mkdir($path, 0700, true); // create a directory with secure permissions

        // Sanitize the file name by replacing spaces with underscores
        $file_name = str_replace(" ", "_", $_FILES["file"]["name"]); // replace spaces with underscores in file name

        // Get the temporary location of the uploaded file
        $file_temp_location = $_FILES["file"]["tmp_name"]; // get temporary location of uploaded file

        // Move the uploaded file to the new directory
        if (!move_uploaded_file($file_temp_location, "$path/$file_name")) {
            // move uploaded file to new directory
            echo "Error uploading file.";
            exit();
        }

        // Determine the MIME type of the uploaded file
        $mime_type = mime_content_type("$path/$file_name"); // get mime type of uploaded file

        // Process the uploaded file based on its MIME type
        switch ($mime_type) {
            case "image/jpeg":
                $output = resize($path); // if image, resize the image and store output
                break;
            case "application/zip":
                // Unzip the file with secure arguments
                $zip = escapeshellarg("$path/$file_name"); // escape the file name for shell execution
                $cmd = "/usr/bin/unzip -jd " . escapeshellarg($path) . " $zip"; // command to unzip file with secure arguments
                system($cmd); // execute command to unzip file

                $output = resize($path); // if zip, resize the images in the zip file and store output
                break;
            default:
                break;
        }

        // Send an email with the output of the processing
        email($email, $output); // send email with output of processing
    }
}

main();
?>

Legal Disclaimer

Please note that any code presented on this blog site is provided in good faith but without any warranty or guarantee regarding its accuracy or fitness for any particular purpose. The information and code presented here are for educational and informational purposes only, and the reader must use the code at their own risk and discretion. I make no representations or warranties of any kind, express or implied, about the completeness, accuracy, reliability, suitability or availability with respect to the code presented on this site. Therefore, I cannot be held responsible for any loss or damage from using this code.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.