{ "cells": [ { "cell_type": "markdown", "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "source": [ "# Dithering algorithm comparison\n", "\n", "The 6-colour ACeP palette can only show Black, White, Yellow, Red, Blue, and\n", "Green, so the dithering algorithm choice has a big effect on perceived image\n", "quality. This notebook applies a curated set of error-diffusion and\n", "ordered-dithering algorithms to a few photos and shows them side-by-side, with\n", "timing.\n", "\n", "It uses the small CC-licensed set bundled under `photos/` (a mix of\n", "saturated colour, fine detail, and smooth gradients to stress the palette in\n", "different ways).\n", "\n", "Production uses Atkinson with perceptual weighting. `atkinson_weighted` is the\n", "closest pure-Python match; the version that actually ships is numba-JIT'd and\n", "equivalent to `atkinson_fast`.\n", "\n", "Algorithm taxonomy:\n", "\n", "- **Error diffusion** (`floyd_steinberg`, `atkinson`, `jarvis`, `stucki`,\n", " `sierra`, `sierra_lite`, `burkes`): quantise pixels left-to-right, push the\n", " rounding error onto unprocessed neighbours.\n", "- **Ordered** (`bayer4`, `bayer8`, `bayer4_strong`): add a deterministic\n", " threshold pattern before quantising. No error spreading; pattern is\n", " independent of content.\n", "- **PIL built-ins** (`pil_fs`, `pil_none`): reference." ] }, { "cell_type": "code", "execution_count": 1, "id": "acae54e37e7d407bbb7b55eff062a284", "metadata": { "execution": { "iopub.execute_input": "2026-05-04T09:39:19.008641Z", "iopub.status.busy": "2026-05-04T09:39:19.008549Z", "iopub.status.idle": "2026-05-04T09:39:20.236632Z", "shell.execute_reply": "2026-05-04T09:39:20.236242Z" } }, "outputs": [], "source": [ "import sys\n", "\n", "sys.path.insert(0, \".\")\n", "from _helpers import PHOTOS_DIR, bootstrap, center_crop, local_pool, open_image, silenced\n", "\n", "bootstrap()\n", "\n", "import time\n", "\n", "import numpy as np\n", "from _dither import DITHER_ALGORITHMS, PALETTE_NAMES, PALETTE_RGB, apply_dithering\n", "from PIL import Image\n", "from waveshare_epd.epd7in3e import EPD_HEIGHT, EPD_WIDTH\n", "\n", "# Pure-Python algorithms run at ~30s per 800x480 image; keep a curated subset by default.\n", "# Toggle SHOW_ALL = True to run everything (will take several minutes).\n", "DEFAULT_ALGOS = [\n", " \"atkinson_fast\",\n", " \"atkinson\",\n", " \"atkinson_weighted\",\n", " \"floyd_steinberg\",\n", " \"floyd_steinberg_weighted\",\n", " \"jarvis\",\n", " \"sierra_lite\",\n", " \"burkes\",\n", " \"bayer4\",\n", " \"bayer8\",\n", " \"pil_fs\",\n", " \"none\",\n", "]\n", "SHOW_ALL = False\n", "ALGOS = list(DITHER_ALGORITHMS.keys()) if SHOW_ALL else DEFAULT_ALGOS\n", "\n", "N_PHOTOS = 2 # one image cycle per photo\n", "SEED = 11" ] }, { "cell_type": "code", "execution_count": 2, "id": "9a63283cbaf04dbcab1f6479b197f3a8", "metadata": { "execution": { "iopub.execute_input": "2026-05-04T09:39:20.238105Z", "iopub.status.busy": "2026-05-04T09:39:20.237988Z", "iopub.status.idle": "2026-05-04T09:39:20.303038Z", "shell.execute_reply": "2026-05-04T09:39:20.302717Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "hiker_in_mountains.jpg\n", "leopard_on_road.jpg\n" ] }, { "data": { "image/jpeg": "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", "image/png": "iVBORw0KGgoAAAANSUhEUgAAAWgAAAA8CAIAAADE77fXAAACBUlEQVR4Ae3UgQnAQAwDsbb775yOYQ70C8TIj98n+O4umPrtZU5GDv6N60F/vd8sMQECawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrAcOxbsB9AkEBwxEsTWQCawHDsW7AfQJBAcMRLE1kAmsBw7FuwH0CQQHDESxNZAJrgR/+VQV3J+6g7AAAAABJRU5ErkJggg==", "text/plain": [ "" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "pool_assets = local_pool()[:20]\n", "\n", "with silenced():\n", " sources = []\n", " for asset in pool_assets[:N_PHOTOS]:\n", " img = open_image(asset)\n", " sources.append((asset, center_crop(img, EPD_WIDTH, EPD_HEIGHT)))\n", "\n", "for asset, _ in sources:\n", " print(asset.get(\"originalFileName\") or asset[\"id\"])\n", "\n", "# Render the 6-colour palette as a tiny banner so the colour budget is visible.\n", "swatch_h = 60\n", "swatch_w = 60\n", "palette_strip = np.zeros((swatch_h, swatch_w * len(PALETTE_RGB), 3), dtype=np.uint8)\n", "for i, rgb in enumerate(PALETTE_RGB):\n", " palette_strip[:, i * swatch_w : (i + 1) * swatch_w] = rgb\n", "Image.fromarray(palette_strip)" ] }, { "cell_type": "code", "execution_count": 3, "id": "8dd0d8092fe74a7c96281538738b07e2", "metadata": { "execution": { "iopub.execute_input": "2026-05-04T09:39:20.304061Z", "iopub.status.busy": "2026-05-04T09:39:20.303983Z", "iopub.status.idle": "2026-05-04T09:40:38.405946Z", "shell.execute_reply": "2026-05-04T09:40:38.405598Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[hiker_in_mountains.jpg]\n", " Atkinson (Numba Fast) 0.41s\n", " Atkinson 4.02s\n", " Atkinson (Weighted) 4.42s\n", " Floyd-Steinberg 3.92s\n", " Floyd-Steinberg (Weighted) 4.52s\n", " Jarvis-Judice-Ninke 8.61s\n", " Sierra Lite 3.39s\n", " Burkes 5.82s\n", " Ordered (Bayer 4x4) 2.23s\n", " Ordered (Bayer 8x8) 2.23s\n", " Floyd-Steinberg (PIL) 0.01s\n", " No Dithering (PIL) 0.00s\n", "[leopard_on_road.jpg]\n", " Atkinson (Numba Fast) 0.01s\n", " Atkinson 3.97s\n", " Atkinson (Weighted) 4.32s\n", " Floyd-Steinberg 3.93s\n", " Floyd-Steinberg (Weighted) 4.41s\n", " Jarvis-Judice-Ninke 9.15s\n", " Sierra Lite 4.47s\n", " Burkes 6.30s\n", " Ordered (Bayer 4x4) 2.29s\n", " Ordered (Bayer 8x8) 2.61s\n", " Floyd-Steinberg (PIL) 0.02s\n", " No Dithering (PIL) 0.00s\n" ] } ], "source": [ "results = [] # list of dict per (photo, algo)\n", "for asset, source in sources:\n", " name = asset.get(\"originalFileName\") or asset[\"id\"]\n", " print(f\"[{name}]\")\n", " photo_results = []\n", " for algo in ALGOS:\n", " info = DITHER_ALGORITHMS[algo]\n", " t0 = time.perf_counter()\n", " out = apply_dithering(source, algo)\n", " dt = time.perf_counter() - t0\n", " photo_results.append({\"algo\": algo, \"name\": info[\"name\"], \"image\": out, \"duration\": dt})\n", " print(f\" {info['name']:32s} {dt:6.2f}s\")\n", " results.append({\"asset\": asset, \"source\": source, \"algos\": photo_results})" ] }, { "cell_type": "code", "execution_count": null, "id": "72eea5119410473aa328ad9291626812", "metadata": { "execution": { "iopub.execute_input": "2026-05-04T09:40:38.407080Z", "iopub.status.busy": "2026-05-04T09:40:38.407001Z", "iopub.status.idle": "2026-05-04T09:40:39.861571Z", "shell.execute_reply": "2026-05-04T09:40:39.861154Z" } }, "outputs": [], "source": "# One palette-preserving contact sheet per photo. The cell above dithers at the\n# full EPD resolution to time each algorithm; for the contact sheet we downscale\n# the source first and then dither, so the dither pattern is sized for the tile\n# rather than aliased away by a NEAREST resize of a high-frequency dither.\nfrom math import ceil\nfrom pathlib import Path\n\nfrom IPython.display import display\nfrom PIL import ImageDraw, ImageFont\n\nSHORT_NAMES = {\n \"atkinson_fast\": \"Atkinson fast\",\n \"atkinson\": \"Atkinson\",\n \"atkinson_weighted\": \"Atkinson weighted\",\n \"floyd_steinberg\": \"Floyd-Steinberg\",\n \"floyd_steinberg_weighted\": \"FS weighted\",\n \"jarvis\": \"Jarvis\",\n \"sierra_lite\": \"Sierra lite\",\n \"burkes\": \"Burkes\",\n \"bayer4\": \"Bayer 4x4\",\n \"bayer8\": \"Bayer 8x8\",\n \"pil_fs\": \"PIL FS\",\n \"none\": \"No dither\",\n}\nSHOWCASE_WIDTH = 1920\nCOLS = 4\nGUTTER = 8\nLABEL_H = 40\nTITLE_H = 40\nTILE_W = (SHOWCASE_WIDTH - GUTTER * (COLS + 1)) // COLS\nTILE_H = EPD_HEIGHT * TILE_W // EPD_WIDTH\n\n\ndef _load_font(size: int):\n font_paths = (\n \"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf\",\n \"/usr/share/fonts/truetype/freefont/FreeSans.ttf\",\n \"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf\",\n )\n for path in font_paths:\n try:\n return ImageFont.truetype(path, size=size)\n except OSError:\n continue\n return ImageFont.load_default()\n\n\nFONT = _load_font(32)\nWHITE = (255, 255, 255)\nBLACK = (0, 0, 0)\n\n\ndef _display_palette_image():\n pal = Image.new(\"P\", (1, 1))\n flat = []\n for rgb in PALETTE_RGB:\n flat.extend(rgb)\n flat.extend([0] * (768 - len(flat)))\n pal.putpalette(flat)\n return pal\n\n\nDISPLAY_PALETTE = _display_palette_image()\n\n\ndef _centered_text(draw, box, text):\n bbox = draw.textbbox((0, 0), text, font=FONT)\n tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]\n x0, y0, x1, y1 = box\n draw.text((x0 + (x1 - x0 - tw) // 2, y0 + (y1 - y0 - th) // 2), text, fill=BLACK, font=FONT)\n\n\ndef make_dither_showcase(entry):\n rows = ceil(len(entry[\"algos\"]) / COLS)\n height = GUTTER + TITLE_H + GUTTER + rows * (LABEL_H + TILE_H + GUTTER)\n sheet = Image.new(\"RGB\", (SHOWCASE_WIDTH, height), WHITE)\n draw = ImageDraw.Draw(sheet)\n name = entry[\"asset\"].get(\"originalFileName\") or entry[\"asset\"][\"id\"]\n _centered_text(draw, (0, GUTTER, SHOWCASE_WIDTH, GUTTER + TITLE_H), name)\n\n tile_source = entry[\"source\"].resize((TILE_W, TILE_H), Image.Resampling.LANCZOS)\n\n top = GUTTER + TITLE_H + GUTTER\n for i, result in enumerate(entry[\"algos\"]):\n col = i % COLS\n row = i // COLS\n x = GUTTER + col * (TILE_W + GUTTER)\n y = top + row * (LABEL_H + TILE_H + GUTTER)\n label = f\"{SHORT_NAMES[result['algo']]} {result['duration']:.2f}s\"\n _centered_text(draw, (x, y, x + TILE_W, y + LABEL_H), label)\n tile = apply_dithering(tile_source, result[\"algo\"])\n sheet.paste(tile, (x, y + LABEL_H))\n draw.rectangle((x, y + LABEL_H, x + TILE_W - 1, y + LABEL_H + TILE_H - 1), outline=BLACK)\n\n return sheet.quantize(palette=DISPLAY_PALETTE, dither=Image.Dither.NONE).convert(\"RGB\")\n\n\nfor entry in results:\n sheet = make_dither_showcase(entry)\n name = entry[\"asset\"].get(\"originalFileName\") or entry[\"asset\"][\"id\"]\n out_path = PHOTOS_DIR / f\"dither_compare_{Path(name).stem}.png\"\n sheet.save(out_path, optimize=True)\n display(sheet)" } ], "metadata": { "kernelspec": { "display_name": "frame (3.12.13)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.13" } }, "nbformat": 4, "nbformat_minor": 5 }