frame/notebooks/dither_compare.ipynb

182 lines
7.3 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"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
}