Dithering images with Pelican

I'm still using Pelican to generate these notes. That's a tool that I created more than a decade ago, and it's still serving me well. Thanks to the team maintaining it nowadays!

One thing that I like with it is that it's highly extensible, with Python. As a result, there is a large amount of plugins for it.

Amongst others, Low Tech magazine did some work to lower the footprint of images, by using a technique named dithering. They provide the source of the plugin they did for doing that.

I've taken it and updated it to do things a little differently:

It results in images like these ones (with a file size reduced by ~4):

Photo of a view of the sea from the coast Photo of a view of a tree under the snow, with red berries

Here's the code, provided under GPLv3. Thanks to Roel Roscam Abbing for the original code.

Run it with uvx like this (yeah, I know, and it sucks):

uvx --with git+https://www.github.com/hbldh/hitherdither --with Pillow --with beautifulsoup4 pelican 
from pathlib import Path
import json
import logging
from pelican import signals

logger = logging.getLogger(__name__)

try:
    from PIL import Image
    import hitherdither

    enabled = True
except:
    logging.warning("Unable to load PIL or hitherdither, disabling dither plugin")
    enabled = False

DEFAULT_THRESHOLD = [9, 9, 9]
DEFAULT_DITHER_PALETTE = [
    (0, 0, 0),  # black
    (29, 43, 83),  # dark blue
    (126, 37, 83),  # dark purple
    (0, 135, 81),  # dark green
    (171, 82, 54),  # brown
    (95, 87, 79),  # dark grey
    (194, 195, 199),  # light grey
    (255, 241, 232),  # white
    (255, 0, 77),  # red
    (255, 163, 0),  # orange
    (255, 236, 39),  # yellow
    (0, 228, 54),  # green
    (41, 173, 255),  # blue
    (131, 118, 156),  # lavender
    (255, 119, 168),  # pink
    (255, 204, 170),  # peach
]  # Pico-8 16-color palette
DEFAULT_RESIZE_OUTPUT = True
DEFAULT_MAX_SIZE = (800, 800)

IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
CACHE_FILE = ".dither_cache.json"


def dither(pelican):
    global enabled
    if not enabled:
        return

    output_path = Path(pelican.settings["OUTPUT_PATH"])
    cache_path = output_path / CACHE_FILE

    # Load cache: {rel_path: mtime_after_dither}
    cache = {}
    if cache_path.exists():
        try:
            cache = json.loads(cache_path.read_text())
        except Exception:
            cache = {}

    resize = pelican.settings.get("RESIZE", DEFAULT_RESIZE_OUTPUT)
    image_size = pelican.settings.get("SIZE", DEFAULT_MAX_SIZE)
    palette = hitherdither.palette.Palette(
        pelican.settings.get("DITHER_PALETTE", DEFAULT_DITHER_PALETTE)
    )
    threshold = pelican.settings.get("THRESHOLD", DEFAULT_THRESHOLD)

    for output_file in output_path.rglob("*"):
        if not output_file.is_file():
            continue
        if output_file.suffix.lower() not in IMAGE_EXTENSIONS:
            continue

        rel_path = str(output_file.relative_to(output_path))
        current_mtime = output_file.stat().st_mtime

        # Skip if already dithered (file hasn't been replaced since last dither)
        if rel_path in cache and current_mtime <= cache[rel_path]:
            logger.debug(f"Dither plugin: skipping already dithered {rel_path}")
            continue

        logger.debug(f"Dither plugin: dithering {rel_path}")
        try:
            img = Image.open(output_file).convert("RGB")

            if resize:
                img.thumbnail(image_size, Image.LANCZOS)

            img_dithered = hitherdither.ordered.bayer.bayer_dithering(
                img, palette, threshold, order=8
            )

            # JPEG doesn't support palette mode, convert back to RGB
            if output_file.suffix.lower() in (".jpg", ".jpeg"):
                img_dithered = img_dithered.convert("RGB")

            img_dithered.save(output_file, optimize=True)
            cache[rel_path] = output_file.stat().st_mtime

        except Exception as e:
            logger.warning(f"Dither plugin: failed to dither {rel_path}: {e}")

    cache_path.write_text(json.dumps(cache))


def register():
    signals.finalized.connect(dither)

Published on 2026-03-20 - In code