240 lines
No EOL
14 KiB
Text
240 lines
No EOL
14 KiB
Text
{
|
|
"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": [
|
|
"<PIL.Image.Image image mode=RGB size=360x60>"
|
|
]
|
|
},
|
|
"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
|
|
} |