
A parallel image transformation script
A script used to transform images (AVIF, WebP) in parallel for the web.
Image on the Web
To optimize page loading, we'd like to serve images in different formats using:
- A server-side solution like Next.js Image Optimization or Cloudinary that automatically serves images in an optimal format depending on request payload.
- A client-side solution using
<Picture>
element with<source>
tags to serve static images in different formats based on browser support.
This gives us the option to use more advanced formats like AVIF
and WebP
that offer better compression and quality than JPG
and PNG
when the browser supports them.
On-demand image serving
However, one major flaw with existing server-side solutions is that they often serve images on-demand.
The server will generate the image and cache it for future requests when the first time a user visits a page. This can lead to a delay in loading images on the first visit of the corresponding image sizes and codec.
Next.js image optimization
The image optimization provided by Next.js is also a form of on-demand image serving. This means that it suffers from the same issue of loading delay. To properly address this, we need to consider pre-generating images in different formats and sizes during the build process.
This has become one of the most requested features in Next.js: Optimize images during next build
Solution
There are existing solutions to transform images during Next.js build like: next-export-optimize-images. However, I feel like they are too tied to the ecosystem and are too complex for a simple task. They also drastically increase the build time since most aren't optimized for performance.
I decided to make a simple parallel Python script that can be run independently of the Next.js build process:
import os
import sys
import io
import shutil
from PIL import Image, ImageOps
import pillow_avif
from concurrent.futures import ProcessPoolExecutor
Image.MAX_IMAGE_PIXELS = None
def process_single_image(params):
source_file = params['source_file']
target_root = params['target_root']
filename = params['filename']
max_width = params['max_width']
target_files = params['target_files']
target_dir = params['target_dir']
incremental = params['incremental']
ext = params['ext']
if source_file.startswith(target_dir):
return
os.makedirs(target_root, exist_ok=True)
if incremental and all(os.path.exists(tf[0]) for tf in target_files):
print(f"Skipping {filename} as transformed images already exist.")
return
print(f"Processing {filename} with max width {max_width}...")
try:
with Image.open(source_file) as img:
img = ImageOps.exif_transpose(img)
if 'exif' in img.info:
del img.info['exif']
width, height = img.size
if width > max_width:
new_width = max_width
new_height = int(max_width * height / width)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
for target_file, format in target_files:
save_params = {'quality': 75}
if format == 'JPEG':
save_params.update({'optimize': True, 'progressive': True, 'quality': 90})
elif format == 'PNG':
save_params.update({'optimize': True})
elif format == 'WEBP':
save_params.update({'method': 6}) # Highest quality method for WEBP
elif format == 'AVIF':
save_params.update({'speed': 0, 'quality': 70}) # Slowest speed for the best compression
if target_file.endswith(ext):
buffer = io.BytesIO()
try:
img.save(buffer, format=format, **save_params)
resized_size = buffer.tell()
original_size = os.path.getsize(source_file)
if resized_size >= original_size:
shutil.copy2(source_file, target_file)
else:
with open(target_file, 'wb') as f:
f.write(buffer.getvalue())
except Exception as e:
print(f"Error saving {target_file}: {e}")
else:
try:
if format == 'JPEG':
rgb_img = img
if img.mode == 'P':
print(f"Converting from palette to RGBA...")
rgb_img = img.convert('RGBA')
rgb_img.convert('RGB').save(target_file, format=format, **save_params)
else:
img.save(target_file, format=format, **save_params)
except Exception as e:
print(f"Error saving {target_file}: {e}")
except Exception as e:
print(f"Error processing {source_file}: {e}")
def process_images(source_dir, target_dir, incremental=False):
allowed_exts = ('.jpg', '.jpeg', '.png', '.avif', '.webp')
extension_to_format = {
'.jpg': 'JPEG',
'.jpeg': 'JPEG',
'.png': 'PNG',
'.webp': 'WEBP',
'.avif': 'AVIF',
}
if os.path.exists(target_dir) and not incremental:
shutil.rmtree(target_dir)
images_to_process = []
for root, dirs, files in os.walk(source_dir):
rel_path = os.path.relpath(root, source_dir)
target_root = os.path.join(target_dir, rel_path)
print(f"Processing directory: {rel_path}")
for filename in files:
if filename.lower().endswith(allowed_exts):
source_file = os.path.join(root, filename)
max_width = 2048
name_part, ext = os.path.splitext(filename)
if '_' in name_part:
possible_number = name_part.split('_')[-1]
if possible_number.isdigit():
max_width = int(possible_number)
name_without_ext = os.path.splitext(filename)[0]
original_format = extension_to_format.get(ext.lower(), 'JPEG')
target_files = [
(os.path.join(target_root, name_without_ext + ext), original_format),
(os.path.join(target_root, name_without_ext + '.webp'), 'WEBP'),
(os.path.join(target_root, name_without_ext + '.avif'), 'AVIF'),
]
if original_format != 'JPEG':
target_files.append((os.path.join(target_root, name_without_ext + '.jpg'), 'JPEG'))
params = {
'source_file': source_file,
'target_root': target_root,
'target_dir': target_dir,
'filename': filename,
'max_width': max_width,
'target_files': target_files,
'extension_to_format': extension_to_format,
'incremental': incremental,
'ext': ext,
'allowed_exts': allowed_exts,
}
images_to_process.append(params)
with ProcessPoolExecutor() as executor:
executor.map(process_single_image, images_to_process)
if __name__ == '__main__':
if len(sys.argv) < 3:
print("Usage: python script.py <source_dir> <target_dir> [--incremental]")
sys.exit(1)
source_dir = sys.argv[1]
target_dir = sys.argv[2]
incremental = '--incremental' in sys.argv
process_images(source_dir, target_dir, incremental=incremental)
Features
Resizing Images:
- Default maximum width:
2048
pixels. - Custom width: If an image filename includes a number after an underscore (e.g.,
image_1024.jpg
orimage_4000.png
), that number is used as the maximum width. - Maximum width won't exceed the original image width.
- Images are resized proportionally based on the maximum width.
Format Conversion:
- Saves images in their original format.
- WebP (
.webp
) - AVIF (
.avif
) - JPEG (
.jpg
) if the original is not JPEG.
The script keeps the original image as the original format if the transformed image is larger in file size.
Optimizations:
- Removes EXIF metadata.
- Applies format-specific compression settings for optimal quality and file size.
- Uses parallel processing to speed up image transformations.
- Skips images that have already been processed to save time.
Usage
Dependencies
Install the required Python packages:
pip install pillow pillow-avif-plugin
python script.py <source_dir> <target_dir> [--incremental]
<source_dir>
: Path to the directory containing source images.<target_dir>
: Path to the directory where transformed images will be saved.--incremental
(optional): Process only new images.
Supported Formats
- Input image extensions:
.jpg
,.jpeg
,.png
,.avif
,.webp
.
Target Directory
The script allows you to have target images in the source directory:
python script.py public public/transformed/
Frontend Integration
As far as I know, the only way to serve static images of different formats based on browser support is to use the <Picture>
element with <source>
tags.
Here's an example of how you can use this in your Next.js project:
export function Picture({src, alt, ...props} : {
src: string | undefined,
alt: string | undefined,
[key: string]: any }
) {
const srcWithoutExtension = useMemo(() => src?.split('.').slice(0, -1).join('.'), [src]);
const extension = useMemo(() => src?.split('.').pop(), [src]);
return <picture>
<source srcSet={`/transformed${srcWithoutExtension}.avif`}
type="image/avif"/>
<source srcSet={`/transformed${srcWithoutExtension}.webp`}
type="image/webp"/>
<motion.img src={`/transformed${srcWithoutExtension}.${extension}`}
alt={alt}
{...props}
/>
</picture>
}
The script doesn't transform images into different sizes. This is sometimes desired if you want to serve different sizes with responsive images and srcset
.
Tags:
Python,
Pillow,
Multiprocessing,
Next.js