182 lines
7.3 KiB
Text
182 lines
7.3 KiB
Text
{
|
||
"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
|
||
}
|