Image Optimization for Web Servers

When it comes to image optimization, my go to WordPress plugin is WP Smush Pro. That said there are times I want to do more than what a plugin can handle. I recently had a customer who had over 50GBs of images. That’s even after WP Smush Pro reduced image usage by over 5GBs. 😵

What I really wanted was greater control over the file selection.

WordPress plugins like WP Smush are deeply coupled with the WordPress media library. That means it will miss any image in the upload directory which are not assigned to a media ID. The solution? To the command line!

Proving my orphaned image theory with a bash script.

Luckily this site is hosted with Kinsta which has fantastic SSH access. The following SSH script will hunt for any images larger than 2048×2048, the maximum size I’ve optimized with WP Smush Pro.

max_width=2048
max_height=2048

cd ~/public

# Loop through all JPGs + PNGs and report large images
for image in $( find wp-content/uploads/ -type f \( -name "*.jpg" -or -name "*.jpeg" -or -name "*.png" \) ); do

    dimensions=($(identify -format "%w %h" $image))

    if [ ${dimensions[0]} -gt $max_width ] || [ ${dimensions[1]} -gt $max_height ]; then
        echo "$image - width ${dimensions[0]} height ${dimensions[1]}"
    fi

done

It didn’t take long to confirm my hunch. This site was loaded with images larger then what WP Smush Pro should have downsized.

Optimizing these images directly on the server.

My first thought was to sync the 50GBs to my local computer and having it handle all of the optimization then syncing back the changes. That seemed tedious. Wouldn’t it be amazing if the web server could handle the downsize and optimizations directly? That lead me to try out ImageOptim HTTP API.

With ImageOptim HTTP API, curl is the only prerequisite.

Attempting to install all of the necessary command line tools for image optimization is really, really hard. It’s also not something you can do directly on a managed web server. That is why I decided to use a paid API service like ImageOptim. With ImageOptim HTTP API it makes optimizing images as easy as a curl request.

Pulling it all together with some fancy scripts.

The following is how I was able to pull this off with Kinsta. You’ll need to adapt these scripts if you use another host provider. Over SSH I created files named image-collect.php, image-scan.sh and image-optimize.sh and stored them under ~/private/ folder (see scripts at the bottom of this post). Make sure both bash scripts are executable.

chmod +x image-scan.sh
chmod +x image-optimize.sh

Running image-scan.sh will find all images larger than the defined max width and height and send to image-collect.php which then stores the images in a images.json data file. Running image-optimize.sh will optimize and update the image status images.json via image-collect.php.

Optimizing images with ImageOptim HTTP API costs money. All of these scripts make sure that I’m only going to downsize and optimize the really large images. It will also only attempt to optimize each image once.

Keeping the scripts separate allows the server load to be spread out.

This is important when dealing with large file sets that require lots CPU time. For my site it took 64 minutes to scan through 219,835 images which resulted in 528 found images matching my search parameters. I definitely recommend that this be run in a background process with screen to avoid an early timeout. Once the scan finished, running image-optimize.sh took around an hour to actually optimize those images. According to my ImageOptim dashboard I saw a savings of around 700MBs. Not bad for only 528 images. 🗜

WP Smush PRO is good enough for most WordPress sites.

That said, you might consider optimization images on the command line as described here if:

  • You have too many images for WP Smush PRO.
  • Want to optimize images not in the media library yet inside the uploads directory.
  • Greater control over cropping and resizing options.

Below are the 3 scripts I mentioned in this post. Enjoy saving space!

image-collect.php

#! /usr/bin/env php
<?php

#
#   Collect image and store in images.json for optimization tracking
#
#   `php image-collect.php add "<image>" <width> <height> <size>`
#   `php image-collect.php done "<image>" <new-width> <new-height> <new-size>`
#   `php image-collect.php todo`
#

$json_file = dirname(__FILE__) . "/images.json";
$images    = json_decode( file_get_contents( $json_file ) );
$command   = $argv[1]; 

if ( $command == "add" ) {

    if ( ! is_array( $images) ) {
        $images = []; 
    }

    $files = array_column( $images, "file" ); 

    if ( ! in_array( $argv[2], $files ) ) {
        $images[] = [
            "file"   => $argv[2],
            "width"  => $argv[3],
            "height" => $argv[4],
            "size"   => $argv[5],
            "status" => "pending",
        ];
    }

    file_put_contents( $json_file, json_encode( $images ) ); 

}

if ( $command == "done" ) {

    $key = array_search( $argv[2], array_column( $images, 'file' ) );

    $images[$key]->status     = "complete";
    $images[$key]->new_width  = $argv[3];
    $images[$key]->new_height = $argv[4];
    $images[$key]->new_size   = $argv[5];

    file_put_contents( $json_file, json_encode( $images ) ); 

}

if ( $command == "todo" ) {

    $pending = array_filter( $images, function ($var) {
        return ( $var->status == 'pending' );
    } );

    echo '"' . implode('" "', array_column( $pending, 'file') ) . '"';

}

image-scan.sh

max_width=2048
max_height=2048

cd ~/public

# Loop through image formats (.jpg .jpeg .png) and collect stats
for image in $( find wp-content/uploads/ -type f \( -name "*.jpg" -or -name "*.jpeg" -or -name "*.png" \) ); do
	dimensions=( $(identify -format "%w %h %b" $image) )

	# If width and height aren't returned skip the image
	if [[ ${#dimensions[@]} != "3" ]]; then
		continue
    fi

	# Pass to PHP collector script
	if [ ${dimensions[0]} -gt $max_width ] || [ ${dimensions[1]} -gt $max_height ]; then
		php ../private/image-collect.php add "${image}" ${dimensions[0]} ${dimensions[1]} ${dimensions[2]}
		echo "Checking ${image} ${dimensions[0]} ${dimensions[1]} ${dimensions[2]}"		
	fi

done

image-optimize.sh

imageoptim_api=change_me_api_key
images=( $( php ~/private/image-collect.php todo ) )
max_width=2048
max_height=2048

cd ~/public

# WordPress home url
wp_home=$( wp option get home --skip-themes --skip-plugins )

# Remove tailing slash if found
wp_home=${wp_home%/}

# Loop through image formats (.jpg .jpeg .png) and collect stats
for image in "${images[@]}"; do

   image="${image%\"}"
   image="${image#\"}"

   # Grab dimensions of original image
   old_dimensions=($(identify -format "%w %h %b" ${image}))

   curl --silent -XPOST -o ~/private/image.out -L "https://im2.io/${imageoptim_api}/${max_width}x${max_height},quality=lossless/${wp_home}/${image}"

   # Grab dimensions of optimized image
   dimensions=($(identify -format "%w %h %b" ~/private/image.out))

   # Verify downloaded image has at least a pixel width and height
   if [ ${dimensions[0]} -gt 1 ] && [ ${dimensions[1]} -gt 1 ]; then

        # remove original image
        rm ${image} 

        # move new image in place
        mv ~/private/image.out ${image} 
        php ~/private/image-collect.php done "${image}" ${dimensions[0]} ${dimensions[1]} ${dimensions[2]}
        echo "Optimized ${image} - Before: ${old_dimensions[0]} ${old_dimensions[1]} ${old_dimensions[2]} After ${dimensions[0]} ${dimensions[1]} ${dimensions[2]}"
        continue

   fi

   # Report error if made it this far
   echo "FAILED: optimizing ${image}"

done

Bonus tip, downsizing images without optimization can result in massive savings.

If your just hunting for space savings and don’t care about image optimizations then run the following image-resize.sh script instead of image-optimize.sh. This will downsize the previous found large images directly from the command line using ImageMagick.

images=( $( php ~/private/image-collect.php todo ) )
max_width=2048
max_height=2048

cd ~/public

# Loop through image formats (.jpg .jpeg .png) and collect stats
for image in "${images[@]}"; do

   image="${image%\"}"
   image="${image#\"}"

   # Grab dimensions of original image
   old_dimensions=( $( identify -format "%w %h %b" ${image} ) )

   # Resize with ImageMagick
   convert ${image} -resize ${max_width}x${max_height} ~/private/image.out

   # Grab dimensions of optimized image
   dimensions=( $( identify -format "%w %h %b" ~/private/image.out ) )

   # Verify downloaded image has at least a pixel width and height
   if [ ${dimensions[0]} -gt 1 ] && [ ${dimensions[1]} -gt 1 ]; then

        # remove original image
        rm ${image}

        # move new image in place
        mv ~/private/image.out ${image}
        php ~/private/image-collect.php done "${image}" ${dimensions[0]} ${dimensions[1]} ${dimensions[2]}
        echo "Optimized ${image} - Before: ${old_dimensions[0]} ${old_dimensions[1]} ${old_dimensions[2]} After ${dimensions[0]} ${dimensions[1]} ${dimensions[2]}"
        continue

   fi

   # Report error if made it this far
   echo "FAILED: optimizing ${image}"

done