This commit is contained in:
Andras Schmelczer 2026-05-03 10:39:31 +01:00
parent 9a009f0b4c
commit eed1567f7f
12 changed files with 463 additions and 243 deletions

View file

@ -6,22 +6,20 @@ for comparison testing.
"""
import numpy as np
from PIL import Image
from typing import Tuple, List
from numba import jit
from PIL import Image
# 6-color ACeP palette (RGB format)
PALETTE_RGB = [
(0, 0, 0), # BLACK
(0, 0, 0), # BLACK
(255, 255, 255), # WHITE
(255, 255, 0), # YELLOW
(255, 0, 0), # RED
(0, 0, 255), # BLUE
(0, 255, 0), # GREEN
(255, 255, 0), # YELLOW
(255, 0, 0), # RED
(0, 0, 255), # BLUE
(0, 255, 0), # GREEN
]
PALETTE_NAMES = ['Black', 'White', 'Yellow', 'Red', 'Blue', 'Green']
PALETTE_NAMES = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
def create_pil_palette_image() -> Image.Image:
@ -36,20 +34,20 @@ def create_pil_palette_image() -> Image.Image:
return pal_image
def find_nearest_color(pixel: np.ndarray, palette: np.ndarray) -> Tuple[int, np.ndarray]:
def find_nearest_color(pixel: np.ndarray, palette: np.ndarray) -> tuple[int, np.ndarray]:
"""Find the nearest palette color using Euclidean distance."""
distances = np.sqrt(np.sum((palette - pixel) ** 2, axis=1))
idx = np.argmin(distances)
return idx, palette[idx]
def find_nearest_color_weighted(pixel: np.ndarray, palette: np.ndarray) -> Tuple[int, np.ndarray]:
def find_nearest_color_weighted(pixel: np.ndarray, palette: np.ndarray) -> tuple[int, np.ndarray]:
"""Find nearest color using perceptually-weighted distance (human eye sensitivity)."""
# Weights based on human perception: Green > Red > Blue
weights = np.array([0.299, 0.587, 0.114])
diff = palette - pixel
weighted_diff = diff * weights
distances = np.sqrt(np.sum(weighted_diff ** 2, axis=1))
distances = np.sqrt(np.sum(weighted_diff**2, axis=1))
idx = np.argmin(distances)
return idx, palette[idx]
@ -58,6 +56,7 @@ def find_nearest_color_weighted(pixel: np.ndarray, palette: np.ndarray) -> Tuple
# Error Diffusion Dithering Algorithms
# =============================================================================
def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.Image:
"""
Floyd-Steinberg dithering (1976).
@ -66,7 +65,7 @@ def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.
* 7/16
3/16 5/16 1/16
"""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color
@ -88,7 +87,7 @@ def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.
img[y + 1, x + 1] += error * 1 / 16
img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB')
return Image.fromarray(img, "RGB")
def dither_jarvis_judice_ninke(image: Image.Image, weighted: bool = False) -> Image.Image:
@ -101,7 +100,7 @@ def dither_jarvis_judice_ninke(image: Image.Image, weighted: bool = False) -> Im
1 3 5 3 1
All divided by 48.
"""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color
@ -132,7 +131,7 @@ def dither_jarvis_judice_ninke(image: Image.Image, weighted: bool = False) -> Im
img[y + 2, x + dx] += error * w / 48
img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB')
return Image.fromarray(img, "RGB")
def dither_stucki(image: Image.Image, weighted: bool = False) -> Image.Image:
@ -145,7 +144,7 @@ def dither_stucki(image: Image.Image, weighted: bool = False) -> Image.Image:
1 2 4 2 1
All divided by 42.
"""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color
@ -173,7 +172,7 @@ def dither_stucki(image: Image.Image, weighted: bool = False) -> Image.Image:
img[y + 2, x + dx] += error * w / 42
img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB')
return Image.fromarray(img, "RGB")
def dither_atkinson(image: Image.Image, weighted: bool = False) -> Image.Image:
@ -186,7 +185,7 @@ def dither_atkinson(image: Image.Image, weighted: bool = False) -> Image.Image:
1
All divided by 8 (but only 6/8 total error diffused).
"""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color
@ -215,7 +214,7 @@ def dither_atkinson(image: Image.Image, weighted: bool = False) -> Image.Image:
img[y + 2, x] += error / 8
img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB')
return Image.fromarray(img, "RGB")
def dither_sierra(image: Image.Image, weighted: bool = False) -> Image.Image:
@ -228,7 +227,7 @@ def dither_sierra(image: Image.Image, weighted: bool = False) -> Image.Image:
2 3 2
All divided by 32.
"""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color
@ -256,7 +255,7 @@ def dither_sierra(image: Image.Image, weighted: bool = False) -> Image.Image:
img[y + 2, x + dx] += error * w / 32
img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB')
return Image.fromarray(img, "RGB")
def dither_sierra_lite(image: Image.Image, weighted: bool = False) -> Image.Image:
@ -267,7 +266,7 @@ def dither_sierra_lite(image: Image.Image, weighted: bool = False) -> Image.Imag
1 1
All divided by 4.
"""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color
@ -288,7 +287,7 @@ def dither_sierra_lite(image: Image.Image, weighted: bool = False) -> Image.Imag
img[y + 1, x] += error * 1 / 4
img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB')
return Image.fromarray(img, "RGB")
def dither_burkes(image: Image.Image, weighted: bool = False) -> Image.Image:
@ -299,7 +298,7 @@ def dither_burkes(image: Image.Image, weighted: bool = False) -> Image.Image:
2 4 8 4 2
All divided by 32.
"""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color
@ -322,20 +321,21 @@ def dither_burkes(image: Image.Image, weighted: bool = False) -> Image.Image:
img[y + 1, x + dx] += error * w / 32
img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB')
return Image.fromarray(img, "RGB")
# =============================================================================
# Ordered Dithering Algorithms
# =============================================================================
def get_bayer_matrix(n: int) -> np.ndarray:
"""Generate a Bayer matrix of size 2^n x 2^n."""
if n == 0:
return np.array([[0]])
smaller = get_bayer_matrix(n - 1)
size = 2 ** (n - 1)
result = np.zeros((2 ** n, 2 ** n))
result = np.zeros((2**n, 2**n))
result[:size, :size] = 4 * smaller
result[:size, size:] = 4 * smaller + 2
result[size:, :size] = 4 * smaller + 3
@ -343,7 +343,9 @@ def get_bayer_matrix(n: int) -> np.ndarray:
return result
def dither_ordered_bayer(image: Image.Image, matrix_size: int = 4, strength: float = 1.0) -> Image.Image:
def dither_ordered_bayer(
image: Image.Image, matrix_size: int = 4, strength: float = 1.0
) -> Image.Image:
"""
Ordered dithering using Bayer matrix.
@ -352,7 +354,7 @@ def dither_ordered_bayer(image: Image.Image, matrix_size: int = 4, strength: flo
matrix_size: Size of Bayer matrix (2, 4, 8, or 16)
strength: Dithering strength multiplier (0.0-2.0)
"""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64)
@ -362,7 +364,7 @@ def dither_ordered_bayer(image: Image.Image, matrix_size: int = 4, strength: flo
bayer_size = bayer.shape[0]
# Normalize Bayer matrix to -0.5 to 0.5 range, then scale
bayer_normalized = (bayer / (bayer_size ** 2) - 0.5) * strength * 128
bayer_normalized = (bayer / (bayer_size**2) - 0.5) * strength * 128
result = np.zeros_like(img)
@ -374,15 +376,23 @@ def dither_ordered_bayer(image: Image.Image, matrix_size: int = 4, strength: flo
_, new_pixel = find_nearest_color(adjusted_pixel, palette)
result[y, x] = new_pixel
return Image.fromarray(result.astype(np.uint8), 'RGB')
return Image.fromarray(result.astype(np.uint8), "RGB")
_NUMBA_PALETTE = np.array([
[0, 0, 0], [255, 255, 255], [255, 255, 0],
[255, 0, 0], [0, 0, 255], [0, 255, 0],
], dtype=np.float64)
_NUMBA_PALETTE = np.array(
[
[0, 0, 0],
[255, 255, 255],
[255, 255, 0],
[255, 0, 0],
[0, 0, 255],
[0, 255, 0],
],
dtype=np.float64,
)
_NUMBA_WEIGHTS = np.array([0.299, 0.587, 0.114], dtype=np.float64)
@jit(nopython=True)
def _numba_find_nearest(r, g, b, palette, weights):
best_idx = 0
@ -397,6 +407,7 @@ def _numba_find_nearest(r, g, b, palette, weights):
best_idx = i
return best_idx
@jit(nopython=True)
def _numba_atkinson(img, palette, weights):
height, width = img.shape[0], img.shape[1]
@ -435,32 +446,34 @@ def _numba_atkinson(img, palette, weights):
img[y + 2, x, 2] += err_b
return img
def dither_atkinson_numba(image: Image.Image) -> Image.Image:
"""Numba-accelerated Atkinson dithering with perceptual weighting (~150x faster)."""
img = np.array(image.convert('RGB'), dtype=np.float64)
img = np.array(image.convert("RGB"), dtype=np.float64)
img = _numba_atkinson(img, _NUMBA_PALETTE, _NUMBA_WEIGHTS)
img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB')
return Image.fromarray(img, "RGB")
# =============================================================================
# PIL Built-in (for comparison)
# =============================================================================
def dither_pil_floyd_steinberg(image: Image.Image) -> Image.Image:
"""PIL's built-in Floyd-Steinberg dithering for comparison."""
pal_image = create_pil_palette_image()
img = image.convert('RGB')
img = image.convert("RGB")
quantized = img.quantize(dither=Image.Dither.FLOYDSTEINBERG, palette=pal_image)
return quantized.convert('RGB')
return quantized.convert("RGB")
def dither_pil_none(image: Image.Image) -> Image.Image:
"""PIL quantization with no dithering (nearest color only)."""
pal_image = create_pil_palette_image()
img = image.convert('RGB')
img = image.convert("RGB")
quantized = img.quantize(dither=Image.Dither.NONE, palette=pal_image)
return quantized.convert('RGB')
return quantized.convert("RGB")
# =============================================================================
@ -468,90 +481,90 @@ def dither_pil_none(image: Image.Image) -> Image.Image:
# =============================================================================
DITHER_ALGORITHMS = {
'none': {
'name': 'No Dithering (PIL)',
'func': dither_pil_none,
'description': 'Simple nearest-color quantization without error diffusion',
"none": {
"name": "No Dithering (PIL)",
"func": dither_pil_none,
"description": "Simple nearest-color quantization without error diffusion",
},
'pil_fs': {
'name': 'Floyd-Steinberg (PIL)',
'func': dither_pil_floyd_steinberg,
'description': 'PIL built-in Floyd-Steinberg implementation',
"pil_fs": {
"name": "Floyd-Steinberg (PIL)",
"func": dither_pil_floyd_steinberg,
"description": "PIL built-in Floyd-Steinberg implementation",
},
'floyd_steinberg': {
'name': 'Floyd-Steinberg',
'func': dither_floyd_steinberg,
'description': 'Classic error diffusion (1976), good balance of speed and quality',
"floyd_steinberg": {
"name": "Floyd-Steinberg",
"func": dither_floyd_steinberg,
"description": "Classic error diffusion (1976), good balance of speed and quality",
},
'floyd_steinberg_weighted': {
'name': 'Floyd-Steinberg (Weighted)',
'func': lambda img: dither_floyd_steinberg(img, weighted=True),
'description': 'Floyd-Steinberg with perceptual color weighting',
"floyd_steinberg_weighted": {
"name": "Floyd-Steinberg (Weighted)",
"func": lambda img: dither_floyd_steinberg(img, weighted=True),
"description": "Floyd-Steinberg with perceptual color weighting",
},
'atkinson': {
'name': 'Atkinson',
'func': dither_atkinson,
'description': 'Bill Atkinson (Apple), diffuses only 75% of error for cleaner results',
"atkinson": {
"name": "Atkinson",
"func": dither_atkinson,
"description": "Bill Atkinson (Apple), diffuses only 75% of error for cleaner results",
},
'atkinson_weighted': {
'name': 'Atkinson (Weighted)',
'func': lambda img: dither_atkinson(img, weighted=True),
'description': 'Atkinson with perceptual color weighting',
"atkinson_weighted": {
"name": "Atkinson (Weighted)",
"func": lambda img: dither_atkinson(img, weighted=True),
"description": "Atkinson with perceptual color weighting",
},
'atkinson_fast': {
'name': 'Atkinson (Numba Fast)',
'func': dither_atkinson_numba,
'description': 'Numba-accelerated Atkinson (~150x faster, requires numba)',
"atkinson_fast": {
"name": "Atkinson (Numba Fast)",
"func": dither_atkinson_numba,
"description": "Numba-accelerated Atkinson (~150x faster, requires numba)",
},
'jarvis': {
'name': 'Jarvis-Judice-Ninke',
'func': dither_jarvis_judice_ninke,
'description': 'Larger diffusion kernel (1976), smoother gradients but slower',
"jarvis": {
"name": "Jarvis-Judice-Ninke",
"func": dither_jarvis_judice_ninke,
"description": "Larger diffusion kernel (1976), smoother gradients but slower",
},
'stucki': {
'name': 'Stucki',
'func': dither_stucki,
'description': 'Similar to JJN with modified weights (1981)',
"stucki": {
"name": "Stucki",
"func": dither_stucki,
"description": "Similar to JJN with modified weights (1981)",
},
'sierra': {
'name': 'Sierra',
'func': dither_sierra,
'description': 'Full Sierra dithering, balanced results',
"sierra": {
"name": "Sierra",
"func": dither_sierra,
"description": "Full Sierra dithering, balanced results",
},
'sierra_lite': {
'name': 'Sierra Lite',
'func': dither_sierra_lite,
'description': 'Faster Sierra variant with smaller kernel',
"sierra_lite": {
"name": "Sierra Lite",
"func": dither_sierra_lite,
"description": "Faster Sierra variant with smaller kernel",
},
'burkes': {
'name': 'Burkes',
'func': dither_burkes,
'description': 'Simplified two-row error diffusion',
"burkes": {
"name": "Burkes",
"func": dither_burkes,
"description": "Simplified two-row error diffusion",
},
'bayer2': {
'name': 'Ordered (Bayer 2x2)',
'func': lambda img: dither_ordered_bayer(img, matrix_size=2),
'description': 'Ordered dithering with 2x2 Bayer matrix',
"bayer2": {
"name": "Ordered (Bayer 2x2)",
"func": lambda img: dither_ordered_bayer(img, matrix_size=2),
"description": "Ordered dithering with 2x2 Bayer matrix",
},
'bayer4': {
'name': 'Ordered (Bayer 4x4)',
'func': lambda img: dither_ordered_bayer(img, matrix_size=4),
'description': 'Ordered dithering with 4x4 Bayer matrix',
"bayer4": {
"name": "Ordered (Bayer 4x4)",
"func": lambda img: dither_ordered_bayer(img, matrix_size=4),
"description": "Ordered dithering with 4x4 Bayer matrix",
},
'bayer8': {
'name': 'Ordered (Bayer 8x8)',
'func': lambda img: dither_ordered_bayer(img, matrix_size=8),
'description': 'Ordered dithering with 8x8 Bayer matrix',
"bayer8": {
"name": "Ordered (Bayer 8x8)",
"func": lambda img: dither_ordered_bayer(img, matrix_size=8),
"description": "Ordered dithering with 8x8 Bayer matrix",
},
'bayer4_strong': {
'name': 'Ordered (Bayer 4x4 Strong)',
'func': lambda img: dither_ordered_bayer(img, matrix_size=4, strength=1.5),
'description': 'Bayer 4x4 with increased dithering strength',
"bayer4_strong": {
"name": "Ordered (Bayer 4x4 Strong)",
"func": lambda img: dither_ordered_bayer(img, matrix_size=4, strength=1.5),
"description": "Bayer 4x4 with increased dithering strength",
},
}
def get_algorithm_names() -> List[str]:
def get_algorithm_names() -> list[str]:
"""Return list of available algorithm names."""
return list(DITHER_ALGORITHMS.keys())
@ -560,4 +573,4 @@ def apply_dithering(image: Image.Image, algorithm: str) -> Image.Image:
"""Apply the specified dithering algorithm to an image."""
if algorithm not in DITHER_ALGORITHMS:
raise ValueError(f"Unknown algorithm: {algorithm}. Available: {get_algorithm_names()}")
return DITHER_ALGORITHMS[algorithm]['func'](image)
return DITHER_ALGORITHMS[algorithm]["func"](image)

View file

@ -13,9 +13,9 @@ import os
import random
import sys
import tempfile
from collections.abc import Callable, Iterable
from pathlib import Path
from types import ModuleType
from typing import Callable, Iterable
REPO = Path(__file__).resolve().parent.parent
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook"
@ -36,6 +36,7 @@ def bootstrap() -> None:
def immich_client():
from immich import ImmichClient
return ImmichClient(
os.environ.get("IMMICH_URL", DEFAULT_IMMICH_URL),
os.environ.get("IMMICH_API_KEY", DEFAULT_IMMICH_API_KEY),
@ -50,8 +51,13 @@ def is_landscape(asset: dict) -> bool:
return w > h > 0
def fetch_pool(client, names: Iterable[str] = DEFAULT_PEOPLE, pool_size: int = 500,
seed: int = 7, filter_fn: Callable[[dict], bool] = is_landscape) -> list[dict]:
def fetch_pool(
client,
names: Iterable[str] = DEFAULT_PEOPLE,
pool_size: int = 500,
seed: int = 7,
filter_fn: Callable[[dict], bool] = is_landscape,
) -> list[dict]:
person_ids = [pid for n in names if (pid := client.get_person_id(n))]
if not person_ids:
raise ValueError(f"no people found: {list(names)}")
@ -64,6 +70,7 @@ def fetch_pool(client, names: Iterable[str] = DEFAULT_PEOPLE, pool_size: int = 5
def download_image(client, asset: dict):
"""Download (cached) and open as PIL RGB Image."""
from PIL import Image
CACHE_DIR.mkdir(exist_ok=True)
dest = CACHE_DIR / f"{asset['id']}.jpg"
if not dest.exists():
@ -78,18 +85,21 @@ def silenced():
yield
def show_grid(rows: list[list], titles: list[list[str]], figsize_scale=(4.4, 3.0),
suptitle: str | None = None):
def show_grid(
rows: list[list], titles: list[list[str]], figsize_scale=(4.4, 3.0), suptitle: str | None = None
):
"""Render a 2-D image grid with matplotlib. `rows` is list-of-lists of PIL/np images."""
import matplotlib.pyplot as plt
n_rows, n_cols = len(rows), max(len(r) for r in rows)
fig, axes = plt.subplots(n_rows, n_cols,
figsize=(figsize_scale[0] * n_cols, figsize_scale[1] * n_rows))
fig, axes = plt.subplots(
n_rows, n_cols, figsize=(figsize_scale[0] * n_cols, figsize_scale[1] * n_rows)
)
if n_rows == 1:
axes = [axes] if n_cols == 1 else [list(axes)]
elif n_cols == 1:
axes = [[ax] for ax in axes]
for i, (row, row_titles) in enumerate(zip(rows, titles)):
for i, (row, row_titles) in enumerate(zip(rows, titles, strict=True)):
for j in range(n_cols):
ax = axes[i][j]
if j < len(row) and row[j] is not None:

View file

@ -2,6 +2,7 @@
"cells": [
{
"cell_type": "markdown",
"id": "7fb27b941602401d91542211134fc71a",
"metadata": {},
"source": [
"# Face-aware crop vs. centre crop\n",
@ -25,29 +26,42 @@
{
"cell_type": "code",
"execution_count": 1,
"id": "acae54e37e7d407bbb7b55eff062a284",
"metadata": {},
"outputs": [],
"source": [
"import sys\n",
"sys.path.insert(0, '.')\n",
"from _helpers import bootstrap, immich_client, fetch_pool, download_image, silenced, show_grid, CACHE_DIR\n",
"\n",
"sys.path.insert(0, \".\")\n",
"from _helpers import (\n",
" CACHE_DIR,\n",
" bootstrap,\n",
" download_image,\n",
" fetch_pool,\n",
" immich_client,\n",
" show_grid,\n",
" silenced,\n",
")\n",
"\n",
"bootstrap()\n",
"\n",
"import json\n",
"import math\n",
"import numpy as np\n",
"from PIL import Image, ImageDraw\n",
"from waveshare_epd.epd7in3e import EPD_WIDTH, EPD_HEIGHT, _crop_center\n",
"from crop import face_aware_crop, HEAD_EXTENSION\n",
"\n",
"POOL_SIZE = 80 # smaller than auto-tune notebook — each photo costs an extra API call for faces\n",
"import numpy as np\n",
"from crop import HEAD_EXTENSION, face_aware_crop\n",
"from PIL import Image, ImageDraw\n",
"from waveshare_epd.epd7in3e import EPD_HEIGHT, EPD_WIDTH, _crop_center\n",
"\n",
"POOL_SIZE = 80 # smaller than auto-tune notebook — each photo costs an extra API call for faces\n",
"SEED = 7\n",
"N_PICKS = 4 # most-divergent picks (plus one no-faces baseline)"
"N_PICKS = 4 # most-divergent picks (plus one no-faces baseline)"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
"metadata": {},
"outputs": [
{
@ -62,27 +76,28 @@
"source": [
"client = immich_client()\n",
"pool_assets = fetch_pool(client, pool_size=POOL_SIZE, seed=SEED)\n",
"print(f'pool size: {len(pool_assets)} landscape photos')\n",
"print(f\"pool size: {len(pool_assets)} landscape photos\")\n",
"\n",
"# Cache the face lookups — each is a separate /assets/{id} API call.\n",
"face_cache_path = CACHE_DIR / 'faces.json'\n",
"face_cache_path = CACHE_DIR / \"faces.json\"\n",
"CACHE_DIR.mkdir(exist_ok=True)\n",
"face_cache = json.loads(face_cache_path.read_text()) if face_cache_path.exists() else {}\n",
"\n",
"fetched_now = 0\n",
"for asset in pool_assets:\n",
" if asset['id'] in face_cache:\n",
" if asset[\"id\"] in face_cache:\n",
" continue\n",
" face_cache[asset['id']] = client.get_asset_faces(asset['id'])\n",
" face_cache[asset[\"id\"]] = client.get_asset_faces(asset[\"id\"])\n",
" fetched_now += 1\n",
"if fetched_now:\n",
" face_cache_path.write_text(json.dumps(face_cache))\n",
"print(f'face data: {fetched_now} fetched, {len(pool_assets) - fetched_now} cached')"
"print(f\"face data: {fetched_now} fetched, {len(pool_assets) - fetched_now} cached\")"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "8dd0d8092fe74a7c96281538738b07e2",
"metadata": {},
"outputs": [
{
@ -111,10 +126,12 @@
" if faces:\n",
" boxes = []\n",
" for f in faces:\n",
" sx = new_w / (f.get('imageWidth') or img_w)\n",
" sy = new_h / (f.get('imageHeight') or img_h)\n",
" x1 = f['boundingBoxX1'] * sx; y1 = f['boundingBoxY1'] * sy\n",
" x2 = f['boundingBoxX2'] * sx; y2 = f['boundingBoxY2'] * sy\n",
" sx = new_w / (f.get(\"imageWidth\") or img_w)\n",
" sy = new_h / (f.get(\"imageHeight\") or img_h)\n",
" x1 = f[\"boundingBoxX1\"] * sx\n",
" y1 = f[\"boundingBoxY1\"] * sy\n",
" x2 = f[\"boundingBoxX2\"] * sx\n",
" y2 = f[\"boundingBoxY2\"] * sy\n",
" boxes.append((x1, y1, x2, y2, max(0.0, (x2 - x1) * (y2 - y1))))\n",
" x_lo, x_hi = min(b[0] for b in boxes), max(b[2] for b in boxes)\n",
" if x_hi - x_lo <= target_w:\n",
@ -129,39 +146,53 @@
" else:\n",
" total = sum(b[4] for b in boxes) or 1.0\n",
" cy = sum((b[1] + b[3]) / 2 * b[4] for b in boxes) / total\n",
" face_off = (max(0, min(int(cx - target_w / 2), new_w - target_w)),\n",
" max(0, min(int(cy - target_h / 2), new_h - target_h)))\n",
" face_off = (\n",
" max(0, min(int(cx - target_w / 2), new_w - target_w)),\n",
" max(0, min(int(cy - target_h / 2), new_h - target_h)),\n",
" )\n",
" return centre_off, face_off, (new_w, new_h), boxes if faces else []\n",
"\n",
"\n",
"stats = []\n",
"for asset in pool_assets:\n",
" exif = asset.get('exifInfo') or {}\n",
" iw, ih = exif.get('exifImageWidth') or 0, exif.get('exifImageHeight') or 0\n",
" if exif.get('orientation') in (6, 8, '6', '8'):\n",
" exif = asset.get(\"exifInfo\") or {}\n",
" iw, ih = exif.get(\"exifImageWidth\") or 0, exif.get(\"exifImageHeight\") or 0\n",
" if exif.get(\"orientation\") in (6, 8, \"6\", \"8\"):\n",
" iw, ih = ih, iw\n",
" if not (iw and ih):\n",
" continue\n",
" faces = face_cache.get(asset['id'], [])\n",
" faces = face_cache.get(asset[\"id\"], [])\n",
" centre_off, face_off, canvas, boxes = crop_offsets(iw, ih, EPD_WIDTH, EPD_HEIGHT, faces)\n",
" dx = face_off[0] - centre_off[0]\n",
" dy = face_off[1] - centre_off[1]\n",
" stats.append({\n",
" 'asset': asset, 'faces': faces, 'boxes': boxes,\n",
" 'centre_off': centre_off, 'face_off': face_off, 'canvas': canvas,\n",
" 'shift': math.hypot(dx, dy), 'dx': dx, 'dy': dy,\n",
" })\n",
" stats.append(\n",
" {\n",
" \"asset\": asset,\n",
" \"faces\": faces,\n",
" \"boxes\": boxes,\n",
" \"centre_off\": centre_off,\n",
" \"face_off\": face_off,\n",
" \"canvas\": canvas,\n",
" \"shift\": math.hypot(dx, dy),\n",
" \"dx\": dx,\n",
" \"dy\": dy,\n",
" }\n",
" )\n",
"\n",
"with_faces = [s for s in stats if s['faces']]\n",
"no_faces = [s for s in stats if not s['faces']]\n",
"print(f'{len(with_faces)} with faces, {len(no_faces)} without')\n",
"shifts = np.array([s['shift'] for s in with_faces])\n",
"with_faces = [s for s in stats if s[\"faces\"]]\n",
"no_faces = [s for s in stats if not s[\"faces\"]]\n",
"print(f\"{len(with_faces)} with faces, {len(no_faces)} without\")\n",
"shifts = np.array([s[\"shift\"] for s in with_faces])\n",
"if len(shifts):\n",
" print(f'crop shift (px): min={shifts.min():.0f} median={np.median(shifts):.0f} max={shifts.max():.0f}')"
" print(\n",
" f\"crop shift (px): min={shifts.min():.0f} median={np.median(shifts):.0f} max={shifts.max():.0f}\"\n",
" )"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "72eea5119410473aa328ad9291626812",
"metadata": {},
"outputs": [
{
@ -177,20 +208,23 @@
}
],
"source": [
"with_faces.sort(key=lambda s: s['shift'], reverse=True)\n",
"with_faces.sort(key=lambda s: s[\"shift\"], reverse=True)\n",
"picks = with_faces[:N_PICKS]\n",
"if no_faces:\n",
" picks.append(no_faces[0])\n",
"\n",
"print(f\"{'name':36s} {'faces':>5s} {'dx':>5s} {'dy':>5s} {'|shift|':>7s}\")\n",
"for s in picks:\n",
" name = (s['asset'].get('originalFileName') or s['asset']['id'])[:36]\n",
" print(f\"{name:36s} {len(s['faces']):>5d} {s['dx']:>+5.0f} {s['dy']:>+5.0f} {s['shift']:>7.0f}\")"
" name = (s[\"asset\"].get(\"originalFileName\") or s[\"asset\"][\"id\"])[:36]\n",
" print(\n",
" f\"{name:36s} {len(s['faces']):>5d} {s['dx']:>+5.0f} {s['dy']:>+5.0f} {s['shift']:>7.0f}\"\n",
" )"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "8edb47106e1a46a883d545849b8ab81b",
"metadata": {},
"outputs": [
{
@ -227,15 +261,16 @@
" canvas = image.resize((new_w, new_h), Image.LANCZOS).copy()\n",
" draw = ImageDraw.Draw(canvas)\n",
" for f in faces:\n",
" sx = new_w / (f.get('imageWidth') or iw)\n",
" sy = new_h / (f.get('imageHeight') or ih)\n",
" x1, y1 = f['boundingBoxX1'] * sx, f['boundingBoxY1'] * sy\n",
" x2, y2 = f['boundingBoxX2'] * sx, f['boundingBoxY2'] * sy\n",
" sx = new_w / (f.get(\"imageWidth\") or iw)\n",
" sy = new_h / (f.get(\"imageHeight\") or ih)\n",
" x1, y1 = f[\"boundingBoxX1\"] * sx, f[\"boundingBoxY1\"] * sy\n",
" x2, y2 = f[\"boundingBoxX2\"] * sx, f[\"boundingBoxY2\"] * sy\n",
" head_y1 = y1 - (y2 - y1) * HEAD_EXTENSION\n",
" draw.rectangle([x1, head_y1, x2, y2], outline=(0, 220, 255), width=4)\n",
" draw.rectangle([x1, y1, x2, y2], outline=(255, 220, 0), width=2)\n",
" return canvas, (new_w, new_h)\n",
"\n",
"\n",
"def draw_crop_windows(canvas, centre_off, face_off, target=(EPD_WIDTH, EPD_HEIGHT)):\n",
" out = canvas.copy()\n",
" draw = ImageDraw.Draw(out)\n",
@ -245,28 +280,32 @@
" draw.rectangle([fx, fy, fx + target[0], fy + target[1]], outline=(0, 200, 0), width=4)\n",
" return out\n",
"\n",
"\n",
"rows, titles = [], []\n",
"for s in picks:\n",
" img = download_image(client, s['asset'])\n",
" canvas, _ = draw_boxes_on_canvas(img, s['faces'])\n",
" windowed = draw_crop_windows(canvas, s['centre_off'], s['face_off'])\n",
" img = download_image(client, s[\"asset\"])\n",
" canvas, _ = draw_boxes_on_canvas(img, s[\"faces\"])\n",
" windowed = draw_crop_windows(canvas, s[\"centre_off\"], s[\"face_off\"])\n",
" centre = _crop_center(img, EPD_WIDTH, EPD_HEIGHT)\n",
" smart = face_aware_crop(img, EPD_WIDTH, EPD_HEIGHT, s['faces'])\n",
" name = (s['asset'].get('originalFileName') or s['asset']['id'])[:24]\n",
" smart = face_aware_crop(img, EPD_WIDTH, EPD_HEIGHT, s[\"faces\"])\n",
" name = (s[\"asset\"].get(\"originalFileName\") or s[\"asset\"][\"id\"])[:24]\n",
" rows.append([canvas, centre, smart, windowed])\n",
" titles.append([\n",
" f'{name}\\n{len(s[\"faces\"])} face{\"\" if len(s[\"faces\"]) == 1 else \"s\"}',\n",
" 'centre crop',\n",
" f'face-aware crop\\nshift: ({s[\"dx\"]:+.0f}, {s[\"dy\"]:+.0f}) px',\n",
" 'crop windows on canvas\\norange = centre, green = face-aware',\n",
" ])\n",
" titles.append(\n",
" [\n",
" f\"{name}\\n{len(s['faces'])} face{'' if len(s['faces']) == 1 else 's'}\",\n",
" \"centre crop\",\n",
" f\"face-aware crop\\nshift: ({s['dx']:+.0f}, {s['dy']:+.0f}) px\",\n",
" \"crop windows on canvas\\norange = centre, green = face-aware\",\n",
" ]\n",
" )\n",
"\n",
"with silenced():\n",
" show_grid(rows, titles, figsize_scale=(5.0, 3.2));"
" show_grid(rows, titles, figsize_scale=(5.0, 3.2))"
]
},
{
"cell_type": "markdown",
"id": "10185d26023b46108eb7d9f57d49d2b3",
"metadata": {},
"source": [
"## Reading the comparison\n",

View file

@ -2,6 +2,7 @@
"cells": [
{
"cell_type": "markdown",
"id": "7fb27b941602401d91542211134fc71a",
"metadata": {},
"source": [
"# Dithering algorithm comparison\n",
@ -27,26 +28,40 @@
{
"cell_type": "code",
"execution_count": 1,
"id": "acae54e37e7d407bbb7b55eff062a284",
"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",
"\n",
"sys.path.insert(0, \".\")\n",
"from _helpers import bootstrap, download_image, fetch_pool, immich_client, show_grid, 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_WIDTH, EPD_HEIGHT, _crop_center\n",
"from _dither import DITHER_ALGORITHMS, apply_dithering, PALETTE_RGB, PALETTE_NAMES\n",
"from waveshare_epd.epd7in3e import EPD_HEIGHT, EPD_WIDTH, _crop_center\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",
"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",
@ -57,6 +72,7 @@
{
"cell_type": "code",
"execution_count": 2,
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
"metadata": {},
"outputs": [
{
@ -91,20 +107,21 @@
" 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",
" 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",
" palette_strip[:, i * swatch_w : (i + 1) * swatch_w] = rgb\n",
"Image.fromarray(palette_strip)"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "8dd0d8092fe74a7c96281538738b07e2",
"metadata": {},
"outputs": [
{
@ -143,22 +160,23 @@
"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",
" 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})"
" 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": 4,
"id": "72eea5119410473aa328ad9291626812",
"metadata": {},
"outputs": [
{
@ -185,21 +203,22 @@
"source": [
"# One grid per photo: original + every algorithm.\n",
"import matplotlib.pyplot as plt\n",
"\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",
" 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",
" 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",
" ax.axis(\"off\")\n",
" plt.tight_layout()\n",
" plt.show()"
]
@ -207,6 +226,7 @@
{
"cell_type": "code",
"execution_count": 5,
"id": "8edb47106e1a46a883d545849b8ab81b",
"metadata": {},
"outputs": [
{
@ -232,12 +252,13 @@
"source": [
"# Per-algorithm summary across both photos: mean runtime + a single representative panel.\n",
"from collections import defaultdict\n",
"\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",
" 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",
@ -248,6 +269,7 @@
},
{
"cell_type": "markdown",
"id": "10185d26023b46108eb7d9f57d49d2b3",
"metadata": {},
"source": [
"## Picking an algorithm\n",