Improvements and notebooks
This commit is contained in:
parent
84f8456fff
commit
f6b0ba5754
34 changed files with 2668 additions and 1373 deletions
563
notebooks/_dither.py
Normal file
563
notebooks/_dither.py
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
"""
|
||||
Dithering algorithms for 6-color e-ink display testing.
|
||||
|
||||
Includes multiple error-diffusion and ordered dithering algorithms
|
||||
for comparison testing.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from typing import Tuple, List
|
||||
|
||||
from numba import jit
|
||||
|
||||
# 6-color ACeP palette (RGB format)
|
||||
PALETTE_RGB = [
|
||||
(0, 0, 0), # BLACK
|
||||
(255, 255, 255), # WHITE
|
||||
(255, 255, 0), # YELLOW
|
||||
(255, 0, 0), # RED
|
||||
(0, 0, 255), # BLUE
|
||||
(0, 255, 0), # GREEN
|
||||
]
|
||||
|
||||
PALETTE_NAMES = ['Black', 'White', 'Yellow', 'Red', 'Blue', 'Green']
|
||||
|
||||
|
||||
def create_pil_palette_image() -> Image.Image:
|
||||
"""Create a PIL palette image for quantization."""
|
||||
pal_image = Image.new("P", (1, 1))
|
||||
flat_palette = []
|
||||
for color in PALETTE_RGB:
|
||||
flat_palette.extend(color)
|
||||
# Pad to 256 colors (768 values)
|
||||
flat_palette.extend([0] * (768 - len(flat_palette)))
|
||||
pal_image.putpalette(flat_palette)
|
||||
return pal_image
|
||||
|
||||
|
||||
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]:
|
||||
"""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))
|
||||
idx = np.argmin(distances)
|
||||
return idx, palette[idx]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Error Diffusion Dithering Algorithms
|
||||
# =============================================================================
|
||||
|
||||
def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.Image:
|
||||
"""
|
||||
Floyd-Steinberg dithering (1976).
|
||||
|
||||
Classic error diffusion algorithm with distribution:
|
||||
* 7/16
|
||||
3/16 5/16 1/16
|
||||
"""
|
||||
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
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
old_pixel = img[y, x].copy()
|
||||
_, new_pixel = find_fn(old_pixel, palette)
|
||||
img[y, x] = new_pixel
|
||||
error = old_pixel - new_pixel
|
||||
|
||||
if x + 1 < width:
|
||||
img[y, x + 1] += error * 7 / 16
|
||||
if y + 1 < height:
|
||||
if x > 0:
|
||||
img[y + 1, x - 1] += error * 3 / 16
|
||||
img[y + 1, x] += error * 5 / 16
|
||||
if x + 1 < width:
|
||||
img[y + 1, x + 1] += error * 1 / 16
|
||||
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(img, 'RGB')
|
||||
|
||||
|
||||
def dither_jarvis_judice_ninke(image: Image.Image, weighted: bool = False) -> Image.Image:
|
||||
"""
|
||||
Jarvis, Judice, and Ninke dithering (1976).
|
||||
|
||||
Spreads error over a larger area (12 pixels):
|
||||
* 7 5
|
||||
3 5 7 5 3
|
||||
1 3 5 3 1
|
||||
All divided by 48.
|
||||
"""
|
||||
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
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
old_pixel = img[y, x].copy()
|
||||
_, new_pixel = find_fn(old_pixel, palette)
|
||||
img[y, x] = new_pixel
|
||||
error = old_pixel - new_pixel
|
||||
|
||||
# Row 0 (current row)
|
||||
if x + 1 < width:
|
||||
img[y, x + 1] += error * 7 / 48
|
||||
if x + 2 < width:
|
||||
img[y, x + 2] += error * 5 / 48
|
||||
|
||||
# Row 1
|
||||
if y + 1 < height:
|
||||
for dx, w in [(-2, 3), (-1, 5), (0, 7), (1, 5), (2, 3)]:
|
||||
if 0 <= x + dx < width:
|
||||
img[y + 1, x + dx] += error * w / 48
|
||||
|
||||
# Row 2
|
||||
if y + 2 < height:
|
||||
for dx, w in [(-2, 1), (-1, 3), (0, 5), (1, 3), (2, 1)]:
|
||||
if 0 <= x + dx < width:
|
||||
img[y + 2, x + dx] += error * w / 48
|
||||
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(img, 'RGB')
|
||||
|
||||
|
||||
def dither_stucki(image: Image.Image, weighted: bool = False) -> Image.Image:
|
||||
"""
|
||||
Stucki dithering (1981).
|
||||
|
||||
Similar to JJN but with different weights:
|
||||
* 8 4
|
||||
2 4 8 4 2
|
||||
1 2 4 2 1
|
||||
All divided by 42.
|
||||
"""
|
||||
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
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
old_pixel = img[y, x].copy()
|
||||
_, new_pixel = find_fn(old_pixel, palette)
|
||||
img[y, x] = new_pixel
|
||||
error = old_pixel - new_pixel
|
||||
|
||||
if x + 1 < width:
|
||||
img[y, x + 1] += error * 8 / 42
|
||||
if x + 2 < width:
|
||||
img[y, x + 2] += error * 4 / 42
|
||||
|
||||
if y + 1 < height:
|
||||
for dx, w in [(-2, 2), (-1, 4), (0, 8), (1, 4), (2, 2)]:
|
||||
if 0 <= x + dx < width:
|
||||
img[y + 1, x + dx] += error * w / 42
|
||||
|
||||
if y + 2 < height:
|
||||
for dx, w in [(-2, 1), (-1, 2), (0, 4), (1, 2), (2, 1)]:
|
||||
if 0 <= x + dx < width:
|
||||
img[y + 2, x + dx] += error * w / 42
|
||||
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(img, 'RGB')
|
||||
|
||||
|
||||
def dither_atkinson(image: Image.Image, weighted: bool = False) -> Image.Image:
|
||||
"""
|
||||
Atkinson dithering (Bill Atkinson, Apple).
|
||||
|
||||
Only diffuses 6/8 of the error (loses some detail but reduces noise):
|
||||
* 1 1
|
||||
1 1 1
|
||||
1
|
||||
All divided by 8 (but only 6/8 total error diffused).
|
||||
"""
|
||||
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
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
old_pixel = img[y, x].copy()
|
||||
_, new_pixel = find_fn(old_pixel, palette)
|
||||
img[y, x] = new_pixel
|
||||
error = old_pixel - new_pixel
|
||||
|
||||
# Distribute 1/8 to each of 6 neighbors
|
||||
if x + 1 < width:
|
||||
img[y, x + 1] += error / 8
|
||||
if x + 2 < width:
|
||||
img[y, x + 2] += error / 8
|
||||
|
||||
if y + 1 < height:
|
||||
if x > 0:
|
||||
img[y + 1, x - 1] += error / 8
|
||||
img[y + 1, x] += error / 8
|
||||
if x + 1 < width:
|
||||
img[y + 1, x + 1] += error / 8
|
||||
|
||||
if y + 2 < height:
|
||||
img[y + 2, x] += error / 8
|
||||
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(img, 'RGB')
|
||||
|
||||
|
||||
def dither_sierra(image: Image.Image, weighted: bool = False) -> Image.Image:
|
||||
"""
|
||||
Sierra dithering (Frankie Sierra).
|
||||
|
||||
Full Sierra (Sierra-3):
|
||||
* 5 3
|
||||
2 4 5 4 2
|
||||
2 3 2
|
||||
All divided by 32.
|
||||
"""
|
||||
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
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
old_pixel = img[y, x].copy()
|
||||
_, new_pixel = find_fn(old_pixel, palette)
|
||||
img[y, x] = new_pixel
|
||||
error = old_pixel - new_pixel
|
||||
|
||||
if x + 1 < width:
|
||||
img[y, x + 1] += error * 5 / 32
|
||||
if x + 2 < width:
|
||||
img[y, x + 2] += error * 3 / 32
|
||||
|
||||
if y + 1 < height:
|
||||
for dx, w in [(-2, 2), (-1, 4), (0, 5), (1, 4), (2, 2)]:
|
||||
if 0 <= x + dx < width:
|
||||
img[y + 1, x + dx] += error * w / 32
|
||||
|
||||
if y + 2 < height:
|
||||
for dx, w in [(-1, 2), (0, 3), (1, 2)]:
|
||||
if 0 <= x + dx < width:
|
||||
img[y + 2, x + dx] += error * w / 32
|
||||
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(img, 'RGB')
|
||||
|
||||
|
||||
def dither_sierra_lite(image: Image.Image, weighted: bool = False) -> Image.Image:
|
||||
"""
|
||||
Sierra Lite (Sierra-2-4A) - faster variant.
|
||||
|
||||
* 2
|
||||
1 1
|
||||
All divided by 4.
|
||||
"""
|
||||
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
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
old_pixel = img[y, x].copy()
|
||||
_, new_pixel = find_fn(old_pixel, palette)
|
||||
img[y, x] = new_pixel
|
||||
error = old_pixel - new_pixel
|
||||
|
||||
if x + 1 < width:
|
||||
img[y, x + 1] += error * 2 / 4
|
||||
|
||||
if y + 1 < height:
|
||||
if x > 0:
|
||||
img[y + 1, x - 1] += error * 1 / 4
|
||||
img[y + 1, x] += error * 1 / 4
|
||||
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(img, 'RGB')
|
||||
|
||||
|
||||
def dither_burkes(image: Image.Image, weighted: bool = False) -> Image.Image:
|
||||
"""
|
||||
Burkes dithering.
|
||||
|
||||
* 8 4
|
||||
2 4 8 4 2
|
||||
All divided by 32.
|
||||
"""
|
||||
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
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
old_pixel = img[y, x].copy()
|
||||
_, new_pixel = find_fn(old_pixel, palette)
|
||||
img[y, x] = new_pixel
|
||||
error = old_pixel - new_pixel
|
||||
|
||||
if x + 1 < width:
|
||||
img[y, x + 1] += error * 8 / 32
|
||||
if x + 2 < width:
|
||||
img[y, x + 2] += error * 4 / 32
|
||||
|
||||
if y + 1 < height:
|
||||
for dx, w in [(-2, 2), (-1, 4), (0, 8), (1, 4), (2, 2)]:
|
||||
if 0 <= x + dx < width:
|
||||
img[y + 1, x + dx] += error * w / 32
|
||||
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
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[:size, :size] = 4 * smaller
|
||||
result[:size, size:] = 4 * smaller + 2
|
||||
result[size:, :size] = 4 * smaller + 3
|
||||
result[size:, size:] = 4 * smaller + 1
|
||||
return result
|
||||
|
||||
|
||||
def dither_ordered_bayer(image: Image.Image, matrix_size: int = 4, strength: float = 1.0) -> Image.Image:
|
||||
"""
|
||||
Ordered dithering using Bayer matrix.
|
||||
|
||||
Args:
|
||||
image: Input image
|
||||
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)
|
||||
height, width = img.shape[:2]
|
||||
palette = np.array(PALETTE_RGB, dtype=np.float64)
|
||||
|
||||
# Get appropriate Bayer matrix
|
||||
n = {2: 1, 4: 2, 8: 3, 16: 4}.get(matrix_size, 2)
|
||||
bayer = get_bayer_matrix(n)
|
||||
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
|
||||
|
||||
result = np.zeros_like(img)
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
threshold = bayer_normalized[y % bayer_size, x % bayer_size]
|
||||
adjusted_pixel = img[y, x] + threshold
|
||||
adjusted_pixel = np.clip(adjusted_pixel, 0, 255)
|
||||
_, new_pixel = find_nearest_color(adjusted_pixel, palette)
|
||||
result[y, x] = new_pixel
|
||||
|
||||
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_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
|
||||
best_dist = 1e10
|
||||
for i in range(palette.shape[0]):
|
||||
dr = (palette[i, 0] - r) * weights[0]
|
||||
dg = (palette[i, 1] - g) * weights[1]
|
||||
db = (palette[i, 2] - b) * weights[2]
|
||||
dist = dr * dr + dg * dg + db * db
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_idx = i
|
||||
return best_idx
|
||||
|
||||
@jit(nopython=True)
|
||||
def _numba_atkinson(img, palette, weights):
|
||||
height, width = img.shape[0], img.shape[1]
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
old_r, old_g, old_b = img[y, x, 0], img[y, x, 1], img[y, x, 2]
|
||||
idx = _numba_find_nearest(old_r, old_g, old_b, palette, weights)
|
||||
new_r, new_g, new_b = palette[idx, 0], palette[idx, 1], palette[idx, 2]
|
||||
img[y, x, 0], img[y, x, 1], img[y, x, 2] = new_r, new_g, new_b
|
||||
err_r = (old_r - new_r) / 8.0
|
||||
err_g = (old_g - new_g) / 8.0
|
||||
err_b = (old_b - new_b) / 8.0
|
||||
if x + 1 < width:
|
||||
img[y, x + 1, 0] += err_r
|
||||
img[y, x + 1, 1] += err_g
|
||||
img[y, x + 1, 2] += err_b
|
||||
if x + 2 < width:
|
||||
img[y, x + 2, 0] += err_r
|
||||
img[y, x + 2, 1] += err_g
|
||||
img[y, x + 2, 2] += err_b
|
||||
if y + 1 < height:
|
||||
if x > 0:
|
||||
img[y + 1, x - 1, 0] += err_r
|
||||
img[y + 1, x - 1, 1] += err_g
|
||||
img[y + 1, x - 1, 2] += err_b
|
||||
img[y + 1, x, 0] += err_r
|
||||
img[y + 1, x, 1] += err_g
|
||||
img[y + 1, x, 2] += err_b
|
||||
if x + 1 < width:
|
||||
img[y + 1, x + 1, 0] += err_r
|
||||
img[y + 1, x + 1, 1] += err_g
|
||||
img[y + 1, x + 1, 2] += err_b
|
||||
if y + 2 < height:
|
||||
img[y + 2, x, 0] += err_r
|
||||
img[y + 2, x, 1] += err_g
|
||||
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 = _numba_atkinson(img, _NUMBA_PALETTE, _NUMBA_WEIGHTS)
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
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')
|
||||
quantized = img.quantize(dither=Image.Dither.FLOYDSTEINBERG, palette=pal_image)
|
||||
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')
|
||||
quantized = img.quantize(dither=Image.Dither.NONE, palette=pal_image)
|
||||
return quantized.convert('RGB')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Algorithm Registry
|
||||
# =============================================================================
|
||||
|
||||
DITHER_ALGORITHMS = {
|
||||
'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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'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_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',
|
||||
},
|
||||
'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_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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_algorithm_names() -> List[str]:
|
||||
"""Return list of available algorithm names."""
|
||||
return list(DITHER_ALGORITHMS.keys())
|
||||
|
||||
|
||||
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)
|
||||
102
notebooks/_helpers.py
Normal file
102
notebooks/_helpers.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Shared helpers for the frame project notebooks.
|
||||
|
||||
Each notebook should call `bootstrap()` first — it puts `src/lib/` on the import
|
||||
path and stubs `waveshare_epd.epdconfig` so the production helpers can be
|
||||
imported without trying to claim GPIO pins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
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"
|
||||
|
||||
DEFAULT_PEOPLE = ("Me", "Ruby")
|
||||
DEFAULT_IMMICH_URL = "https://immich.example.com"
|
||||
DEFAULT_IMMICH_API_KEY = "REDACTED_IMMICH_API_KEY"
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
"""Make production lib + the migrated dither module importable, off-Pi safe."""
|
||||
for p in (REPO / "src" / "lib", REPO / "notebooks"):
|
||||
sp = str(p)
|
||||
if sp not in sys.path:
|
||||
sys.path.insert(0, sp)
|
||||
sys.modules.setdefault("waveshare_epd.epdconfig", ModuleType("waveshare_epd.epdconfig"))
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def is_landscape(asset: dict) -> bool:
|
||||
exif = asset.get("exifInfo") or {}
|
||||
w, h = exif.get("exifImageWidth") or 0, exif.get("exifImageHeight") or 0
|
||||
if exif.get("orientation") in (6, 8, "6", "8"):
|
||||
w, h = h, w
|
||||
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]:
|
||||
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)}")
|
||||
assets = client.search_assets_by_people(person_ids)
|
||||
filtered = [a for a in assets if filter_fn(a)]
|
||||
rng = random.Random(seed)
|
||||
return rng.sample(filtered, min(pool_size, len(filtered)))
|
||||
|
||||
|
||||
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():
|
||||
client.download_asset(asset["id"], dest)
|
||||
return Image.open(dest).convert("RGB")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def silenced():
|
||||
"""Suppress the production code's print() chatter during batch loops."""
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
yield
|
||||
|
||||
|
||||
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))
|
||||
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 j in range(n_cols):
|
||||
ax = axes[i][j]
|
||||
if j < len(row) and row[j] is not None:
|
||||
ax.imshow(row[j])
|
||||
ax.set_title(row_titles[j], fontsize=10)
|
||||
ax.axis("off")
|
||||
if suptitle:
|
||||
fig.suptitle(suptitle, fontsize=12)
|
||||
plt.tight_layout()
|
||||
return fig
|
||||
308
notebooks/crop_compare.ipynb
Normal file
308
notebooks/crop_compare.ipynb
Normal file
File diff suppressed because one or more lines are too long
182
notebooks/dither_compare.ipynb
Normal file
182
notebooks/dither_compare.ipynb
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue