Optimizing Images with Shell Scripts

Published on
Reading time
8 min read

Contents

Doing it the slow way

Image optimization can be unrewardingly time-consuming. Getting it wrong can negatively impact performance, but doing it correctly with complex layouts of images and backgrounds can become unmanageable. When starting out, my workflow probably went like this:

  1. Source and download images
  2. Organize relevant folders, files, and their naming conventions
  3. Crop and Resize with a GUI photo editor
  4. Convert to webp or avif by uploading to some random tool online
  5. Download the new, compressed file and replace it in the folders
  6. Repeat the process
  7. Clean up the mess of leftovers and originals (hopefully one day 😂)

I didn't have many images to work with, so I didn't care how slow this approach was. Ultimately, having more images necessitates a faster way to process them for the web. Let's look at how to use a few packages to crop, resize, and convert folders full of images with a short script!

Setup your environment

Follow along by making a directory with a test image! I'm using Zsh on Ubuntu running in WSL2. The examples should work the same on Mac since Zsh is the default shell. I will explain the few differences between Zsh and Bash and provide the final script for both at the end.

imagemagick is the only necessary dependency as it can handle everything we'll need, including compression to Webp and Avif. Avif and JPEG XL offer better compression than Webp but may have browser compatibility issues (there definitely will be with JPEG XL). For a small test, we will look at a comparison between JPG, Webp, and Avif.

To install what we need, run:

sudo apt update
sudo apt install imagemagick
sudo apt install webp

Three steps to process images

Cropping

Beforehand, consider how you plan to crop your images while sourcing them. When I look for thumbnails, I factor in what I'll need to crop to within the image. Relative to the frame, the subject could be to the right, bottom, center, etc. Picking a reference like this allows for a shortcut around doing unrepeatable, image-specific pixel math. Additionally, you can loop and apply the same crop to images where the subject is in a similar position.

Now, pick an aspect ratio. I'll use 2/3, the aspect ratio of my site's thumbnails. Again, for repeatability, it is easier to group images that need the same aspect ratio. Now to get croppin'! Assuming the subject of your image is relatively centered, we can crop with the following line using imagemagick:

convert -gravity center -crop 2:3 -- example.jpg cropped-example.jpg

To break this down, we are using imagemagick's tool magick convert on example.jpg. We use the argument -gravity to crop the least amount necessary to achieve the specified aspect ratio, 2:3. The gravity setting center crops to the center of the image for an even cut on each side, which is why I mentioned the reference point earlier! -gravity can also crop to other parts of the image. Take some time to try the different settings as a test! You can get a list of accepted gravity settings if you run this:

convert -list gravity

Resizing

To be candid, I find this step to be quite arduous 😅. For performance, it's ideal for the size to be as low as possible without the image looking like crap (pixelated or squashed). Aim for a maximum of about 2500 pixels wide for anything you want to be full-width on a desktop. Otherwise, the max should match the largest display dimensions the image will take on the website.

This script is also straightforward, but we'll look at two examples:

  1. Resizing by percentage:
convert -resize 25% -- example.jpg exampleResized.jpg;
  1. Resizing by pixel width:
convert -resize 1800x -- example.jpg exampleResized.jpg

Each of these will maintain the aspect ratio. The second example has a specified width and adjusts the height proportionally. You can specify both as width x height such as 600x400. Excluding width as in x400 or heigth sets the specified argument and adjusts the excluded one to keep the aspect ratio the same.

Compressing / Converting

The last step to optimize your images is to use a modern format for compression. Note that the quality of the result will be better if you resize before converting. Specify the -quality and the file ending you will compress to, like so:

convert -quality 25 -- example.jpg example.webp

I typically use Webp, but I'm keen to try Avif so here is another example that does just that!

convert -quality 25 -- example.jpg example.avif

Putting it all together

Now, we can string these three steps together into a single script. It will take one or more files in a folder and process the images through each step. Let's start simple by looking at loop in the shell:

for file in *;
echo $file;
done;

This logs each child in the current directory. The asterisk (*), or wildcard, matches and includes every folder and file. echo then logs the names, and done closes the loop. Target a specific file type by adding its ending after the asterisk as in *.jpg; or include multiple file types by listing them: *.{jpg,webp,png}.

With this, we can target each image file and run the three image processing tasks covered earlier. Keep in mind the order can affect the output quality. The way I tend to do it is how I've introduced each - crop, resize, compress. All together, we have:

for file in *.{jpg,png}(N);
do convert -gravity center -crop 2:3 -resize 25% -quality 25 -- $file "${file%.*}.avif";
done;

Since each step uses convert, we can write all three on one line where the option/argument pairs execute from left to right. The only thing new here is the string interpolation, "${file%.*}.avif", which strips off the original file ending to add the new one. Here it's Avif, but you could switch to webp as shown before. The benefit of doing everything on a single line is to avoid spitting out unnecessary files that result from each step of the process. The only output we get is example.avif, so we don't have to clean up the cropped and resized versions that a line-by-line implementation would create.

Results

Here is a size and quality comparison between an original JPG, Webp, and Avif. I sourced an image from Unsplash and generated the Webp and Avif versions using the previous script with the same ratio, resize, and quality settings. A 4th result that resizes the image to 400x600 demonstrates resizing to the intended display size. The source image already has a ratio close to 2/3 so the crop will be almost unnoticeable.

Note that the apparent quality of each image is affected by the size presented on the screen. If you zoom in, the quality drop-off is more noticeable. Always tweak your output for the size and quality you need by adjusting the arguments of our script.

From the original JPG to the resized Avif, there is a 100x size decrease (2100 KB to 20 KB). Typically Avif files are 10x smaller than JPGs, independent of any resizing. When setting the image to the dimensions of its display, we cut the Avif size in half again. Quality-wise, I would try turning up the -quality on the compression as there is more pixelation than I prefer in the foreground, though it isn't too bad.

Changing -quality 25 to -quality 50, the new Avif result (at 35KB) reduces this pixelation with the tradeoff of tripling the size:

Trolley with height quality compression

A few final tips

  1. For responsive images that will shrink with your website's page, it's important to serve the correct size. No sense in serving a 2500px image to an Apple Watch. By generating several images with different, intentional pixel width arguments for -resize (keep the aspect ratio constant), the srcset attribute can handle switching out the images.

  2. The script we've written here could be saved as a file for reusability, although it isn't that big. I usually just paste it in or up arrow back to it in my console's history, but it's worth mentioning that you can refactor the arguments to be input variables and save it as a bash file for future use.

  3. Lastly, if you're curious about doing this in JS instead of Bash, check out the Sharp package for Node.