{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Dithering algorithm comparison\n", "\n", "Migrated from `dither_test/`. The 6-colour ACeP palette can only show Black, White, Yellow,\n", "Red, Blue, 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 ordered-dithering\n", "algorithms to a few real photos from Immich and shows them side-by-side, with timing.\n", "\n", "Production uses Atkinson with perceptual weighting (`atkinson_weighted` is the closest\n", "match — the actual production version is numba-JIT'd, equivalent to `atkinson_fast`). This\n", "notebook is the place to evaluate alternatives if you want to switch.\n", "\n", "Algorithm taxonomy:\n", "- **Error diffusion** (`floyd_steinberg`, `atkinson`, `jarvis`, `stucki`, `sierra`,\n", " `sierra_lite`, `burkes`) — quantise pixels left-to-right, push the rounding error onto\n", " unprocessed neighbours.\n", "- **Ordered** (`bayer4`, `bayer8`, `bayer4_strong`) — add a deterministic threshold pattern\n", " before quantising. No error spreading; pattern is independent of content.\n", "- **PIL built-ins** (`pil_fs`, `pil_none`) — for reference." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import sys\n", "sys.path.insert(0, '.')\n", "from _helpers import bootstrap, immich_client, fetch_pool, download_image, silenced, show_grid\n", "bootstrap()\n", "\n", "import time\n", "import numpy as np\n", "from PIL import Image\n", "from waveshare_epd.epd7in3e import EPD_WIDTH, EPD_HEIGHT, _crop_center\n", "from _dither import DITHER_ALGORITHMS, apply_dithering, PALETTE_RGB, PALETTE_NAMES\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 = ['atkinson_fast', 'atkinson', 'atkinson_weighted',\n", " 'floyd_steinberg', 'floyd_steinberg_weighted',\n", " 'jarvis', 'sierra_lite', 'burkes',\n", " 'bayer4', 'bayer8', 'pil_fs', 'none']\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": null, "metadata": {}, "outputs": [], "source": [ "client = immich_client()\n", "pool_assets = fetch_pool(client, pool_size=20, seed=SEED)\n", "\n", "with silenced():\n", " sources = []\n", " for asset in pool_assets[:N_PHOTOS]:\n", " img = download_image(client, asset)\n", " sources.append((asset, _crop_center(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": null, "metadata": {}, "outputs": [], "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, "metadata": {}, "outputs": [], "source": [ "# One grid per photo: original + every algorithm.\n", "import matplotlib.pyplot as plt\n", "for entry in results:\n", " panels = [entry['source']] + [r['image'] for r in entry['algos']]\n", " titles = ['original (cropped)'] + [f\"{r['name']}\\n{r['duration']:.2f}s\" for r in entry['algos']]\n", " cols = 4\n", " rows = (len(panels) + cols - 1) // cols\n", " fig, axes = plt.subplots(rows, cols, figsize=(5.0 * cols, 3.2 * rows))\n", " axes = np.atleast_2d(axes)\n", " name = entry['asset'].get('originalFileName') or entry['asset']['id']\n", " fig.suptitle(name, fontsize=12)\n", " for k in range(rows * cols):\n", " ax = axes[k // cols][k % cols]\n", " if k < len(panels):\n", " ax.imshow(panels[k])\n", " ax.set_title(titles[k], fontsize=10)\n", " ax.axis('off')\n", " plt.tight_layout()\n", " plt.show()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Per-algorithm summary across both photos: mean runtime + a single representative panel.\n", "from collections import defaultdict\n", "import matplotlib.pyplot as plt\n", "\n", "agg = defaultdict(list)\n", "for entry in results:\n", " for r in entry['algos']:\n", " agg[r['algo']].append(r['duration'])\n", "\n", "print(f\"{'algorithm':32s} {'avg time':>9s} description\")\n", "for algo in ALGOS:\n", " info = DITHER_ALGORITHMS[algo]\n", " avg = np.mean(agg[algo])\n", " print(f\"{info['name']:32s} {avg:>8.2f}s {info['description']}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Picking an algorithm\n", "\n", "- **Photographs** — `atkinson_fast` (production), `atkinson_weighted`, or\n", " `floyd_steinberg_weighted`. Atkinson loses some detail (only diffuses 6/8 of the error)\n", " but gives cleaner edges; FS preserves detail at the cost of more visible noise.\n", "- **Graphics / illustrations / posters** — `bayer4` or `bayer8`. The pattern is regular\n", " (no \"wormy\" artifacts) and well-suited to large flat regions.\n", "- **Speed-critical paths** — `atkinson_fast` (numba-JIT). Pure-Python error-diffusion\n", " algorithms are ~150× slower than the JIT'd version on this resolution.\n", "\n", "The `none` row (PIL nearest-colour) shows what happens with no dithering at all — useful as\n", "a baseline to confirm the dithering is buying you something.\n", "\n", "**To compare more algorithms** set `SHOW_ALL = True` in the setup cell. Expect several\n", "minutes of CPU time per photo for the slow pure-Python implementations." ] } ], "metadata": { "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, "language_info": {"name": "python"} }, "nbformat": 4, "nbformat_minor": 5 }