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

@ -0,0 +1,24 @@
name: lint
on:
push:
pull_request:
jobs:
ruff:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
run: uv python install 3.11
- name: Run lint.sh in check mode
run: ./lint.sh --check

14
lint.sh Executable file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Format and lint Python sources with Ruff.
# Pass --check to fail (instead of fix) — used by CI.
set -euo pipefail
cd "$(dirname "$0")"
if [[ "${1:-}" == "--check" ]]; then
uv run --group dev ruff format --check .
uv run --group dev ruff check .
else
uv run --group dev ruff format .
uv run --group dev ruff check --fix .
fi

View file

@ -6,10 +6,8 @@ for comparison testing.
""" """
import numpy as np import numpy as np
from PIL import Image
from typing import Tuple, List
from numba import jit from numba import jit
from PIL import Image
# 6-color ACeP palette (RGB format) # 6-color ACeP palette (RGB format)
PALETTE_RGB = [ PALETTE_RGB = [
@ -21,7 +19,7 @@ PALETTE_RGB = [
(0, 255, 0), # GREEN (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: def create_pil_palette_image() -> Image.Image:
@ -36,20 +34,20 @@ def create_pil_palette_image() -> Image.Image:
return pal_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.""" """Find the nearest palette color using Euclidean distance."""
distances = np.sqrt(np.sum((palette - pixel) ** 2, axis=1)) distances = np.sqrt(np.sum((palette - pixel) ** 2, axis=1))
idx = np.argmin(distances) idx = np.argmin(distances)
return idx, palette[idx] 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).""" """Find nearest color using perceptually-weighted distance (human eye sensitivity)."""
# Weights based on human perception: Green > Red > Blue # Weights based on human perception: Green > Red > Blue
weights = np.array([0.299, 0.587, 0.114]) weights = np.array([0.299, 0.587, 0.114])
diff = palette - pixel diff = palette - pixel
weighted_diff = diff * weights 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) idx = np.argmin(distances)
return idx, palette[idx] return idx, palette[idx]
@ -58,6 +56,7 @@ def find_nearest_color_weighted(pixel: np.ndarray, palette: np.ndarray) -> Tuple
# Error Diffusion Dithering Algorithms # Error Diffusion Dithering Algorithms
# ============================================================================= # =============================================================================
def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.Image: def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.Image:
""" """
Floyd-Steinberg dithering (1976). Floyd-Steinberg dithering (1976).
@ -66,7 +65,7 @@ def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.
* 7/16 * 7/16
3/16 5/16 1/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] height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64) palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color 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[y + 1, x + 1] += error * 1 / 16
img = np.clip(img, 0, 255).astype(np.uint8) 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: 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 1 3 5 3 1
All divided by 48. 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] height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64) palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color 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[y + 2, x + dx] += error * w / 48
img = np.clip(img, 0, 255).astype(np.uint8) 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: 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 1 2 4 2 1
All divided by 42. 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] height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64) palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color 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[y + 2, x + dx] += error * w / 42
img = np.clip(img, 0, 255).astype(np.uint8) 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: 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 1
All divided by 8 (but only 6/8 total error diffused). 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] height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64) palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color 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[y + 2, x] += error / 8
img = np.clip(img, 0, 255).astype(np.uint8) 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: 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 2 3 2
All divided by 32. 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] height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64) palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color 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[y + 2, x + dx] += error * w / 32
img = np.clip(img, 0, 255).astype(np.uint8) 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: 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 1 1
All divided by 4. 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] height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64) palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color 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[y + 1, x] += error * 1 / 4
img = np.clip(img, 0, 255).astype(np.uint8) 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: 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 2 4 8 4 2
All divided by 32. 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] height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64) palette = np.array(PALETTE_RGB, dtype=np.float64)
find_fn = find_nearest_color_weighted if weighted else find_nearest_color 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[y + 1, x + dx] += error * w / 32
img = np.clip(img, 0, 255).astype(np.uint8) img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB') return Image.fromarray(img, "RGB")
# ============================================================================= # =============================================================================
# Ordered Dithering Algorithms # Ordered Dithering Algorithms
# ============================================================================= # =============================================================================
def get_bayer_matrix(n: int) -> np.ndarray: def get_bayer_matrix(n: int) -> np.ndarray:
"""Generate a Bayer matrix of size 2^n x 2^n.""" """Generate a Bayer matrix of size 2^n x 2^n."""
if n == 0: if n == 0:
return np.array([[0]]) return np.array([[0]])
smaller = get_bayer_matrix(n - 1) smaller = get_bayer_matrix(n - 1)
size = 2 ** (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
result[:size, size:] = 4 * smaller + 2 result[:size, size:] = 4 * smaller + 2
result[size:, :size] = 4 * smaller + 3 result[size:, :size] = 4 * smaller + 3
@ -343,7 +343,9 @@ def get_bayer_matrix(n: int) -> np.ndarray:
return result 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. 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) matrix_size: Size of Bayer matrix (2, 4, 8, or 16)
strength: Dithering strength multiplier (0.0-2.0) 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] height, width = img.shape[:2]
palette = np.array(PALETTE_RGB, dtype=np.float64) 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] bayer_size = bayer.shape[0]
# Normalize Bayer matrix to -0.5 to 0.5 range, then scale # 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) 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) _, new_pixel = find_nearest_color(adjusted_pixel, palette)
result[y, x] = new_pixel 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([ _NUMBA_PALETTE = np.array(
[0, 0, 0], [255, 255, 255], [255, 255, 0], [
[255, 0, 0], [0, 0, 255], [0, 255, 0], [0, 0, 0],
], dtype=np.float64) [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) _NUMBA_WEIGHTS = np.array([0.299, 0.587, 0.114], dtype=np.float64)
@jit(nopython=True) @jit(nopython=True)
def _numba_find_nearest(r, g, b, palette, weights): def _numba_find_nearest(r, g, b, palette, weights):
best_idx = 0 best_idx = 0
@ -397,6 +407,7 @@ def _numba_find_nearest(r, g, b, palette, weights):
best_idx = i best_idx = i
return best_idx return best_idx
@jit(nopython=True) @jit(nopython=True)
def _numba_atkinson(img, palette, weights): def _numba_atkinson(img, palette, weights):
height, width = img.shape[0], img.shape[1] 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 img[y + 2, x, 2] += err_b
return img return img
def dither_atkinson_numba(image: Image.Image) -> Image.Image: def dither_atkinson_numba(image: Image.Image) -> Image.Image:
"""Numba-accelerated Atkinson dithering with perceptual weighting (~150x faster).""" """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 = _numba_atkinson(img, _NUMBA_PALETTE, _NUMBA_WEIGHTS)
img = np.clip(img, 0, 255).astype(np.uint8) img = np.clip(img, 0, 255).astype(np.uint8)
return Image.fromarray(img, 'RGB') return Image.fromarray(img, "RGB")
# ============================================================================= # =============================================================================
# PIL Built-in (for comparison) # PIL Built-in (for comparison)
# ============================================================================= # =============================================================================
def dither_pil_floyd_steinberg(image: Image.Image) -> Image.Image: def dither_pil_floyd_steinberg(image: Image.Image) -> Image.Image:
"""PIL's built-in Floyd-Steinberg dithering for comparison.""" """PIL's built-in Floyd-Steinberg dithering for comparison."""
pal_image = create_pil_palette_image() pal_image = create_pil_palette_image()
img = image.convert('RGB') img = image.convert("RGB")
quantized = img.quantize(dither=Image.Dither.FLOYDSTEINBERG, palette=pal_image) 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: def dither_pil_none(image: Image.Image) -> Image.Image:
"""PIL quantization with no dithering (nearest color only).""" """PIL quantization with no dithering (nearest color only)."""
pal_image = create_pil_palette_image() pal_image = create_pil_palette_image()
img = image.convert('RGB') img = image.convert("RGB")
quantized = img.quantize(dither=Image.Dither.NONE, palette=pal_image) 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 = { DITHER_ALGORITHMS = {
'none': { "none": {
'name': 'No Dithering (PIL)', "name": "No Dithering (PIL)",
'func': dither_pil_none, "func": dither_pil_none,
'description': 'Simple nearest-color quantization without error diffusion', "description": "Simple nearest-color quantization without error diffusion",
}, },
'pil_fs': { "pil_fs": {
'name': 'Floyd-Steinberg (PIL)', "name": "Floyd-Steinberg (PIL)",
'func': dither_pil_floyd_steinberg, "func": dither_pil_floyd_steinberg,
'description': 'PIL built-in Floyd-Steinberg implementation', "description": "PIL built-in Floyd-Steinberg implementation",
}, },
'floyd_steinberg': { "floyd_steinberg": {
'name': 'Floyd-Steinberg', "name": "Floyd-Steinberg",
'func': dither_floyd_steinberg, "func": dither_floyd_steinberg,
'description': 'Classic error diffusion (1976), good balance of speed and quality', "description": "Classic error diffusion (1976), good balance of speed and quality",
}, },
'floyd_steinberg_weighted': { "floyd_steinberg_weighted": {
'name': 'Floyd-Steinberg (Weighted)', "name": "Floyd-Steinberg (Weighted)",
'func': lambda img: dither_floyd_steinberg(img, weighted=True), "func": lambda img: dither_floyd_steinberg(img, weighted=True),
'description': 'Floyd-Steinberg with perceptual color weighting', "description": "Floyd-Steinberg with perceptual color weighting",
}, },
'atkinson': { "atkinson": {
'name': 'Atkinson', "name": "Atkinson",
'func': dither_atkinson, "func": dither_atkinson,
'description': 'Bill Atkinson (Apple), diffuses only 75% of error for cleaner results', "description": "Bill Atkinson (Apple), diffuses only 75% of error for cleaner results",
}, },
'atkinson_weighted': { "atkinson_weighted": {
'name': 'Atkinson (Weighted)', "name": "Atkinson (Weighted)",
'func': lambda img: dither_atkinson(img, weighted=True), "func": lambda img: dither_atkinson(img, weighted=True),
'description': 'Atkinson with perceptual color weighting', "description": "Atkinson with perceptual color weighting",
}, },
'atkinson_fast': { "atkinson_fast": {
'name': 'Atkinson (Numba Fast)', "name": "Atkinson (Numba Fast)",
'func': dither_atkinson_numba, "func": dither_atkinson_numba,
'description': 'Numba-accelerated Atkinson (~150x faster, requires numba)', "description": "Numba-accelerated Atkinson (~150x faster, requires numba)",
}, },
'jarvis': { "jarvis": {
'name': 'Jarvis-Judice-Ninke', "name": "Jarvis-Judice-Ninke",
'func': dither_jarvis_judice_ninke, "func": dither_jarvis_judice_ninke,
'description': 'Larger diffusion kernel (1976), smoother gradients but slower', "description": "Larger diffusion kernel (1976), smoother gradients but slower",
}, },
'stucki': { "stucki": {
'name': 'Stucki', "name": "Stucki",
'func': dither_stucki, "func": dither_stucki,
'description': 'Similar to JJN with modified weights (1981)', "description": "Similar to JJN with modified weights (1981)",
}, },
'sierra': { "sierra": {
'name': 'Sierra', "name": "Sierra",
'func': dither_sierra, "func": dither_sierra,
'description': 'Full Sierra dithering, balanced results', "description": "Full Sierra dithering, balanced results",
}, },
'sierra_lite': { "sierra_lite": {
'name': 'Sierra Lite', "name": "Sierra Lite",
'func': dither_sierra_lite, "func": dither_sierra_lite,
'description': 'Faster Sierra variant with smaller kernel', "description": "Faster Sierra variant with smaller kernel",
}, },
'burkes': { "burkes": {
'name': 'Burkes', "name": "Burkes",
'func': dither_burkes, "func": dither_burkes,
'description': 'Simplified two-row error diffusion', "description": "Simplified two-row error diffusion",
}, },
'bayer2': { "bayer2": {
'name': 'Ordered (Bayer 2x2)', "name": "Ordered (Bayer 2x2)",
'func': lambda img: dither_ordered_bayer(img, matrix_size=2), "func": lambda img: dither_ordered_bayer(img, matrix_size=2),
'description': 'Ordered dithering with 2x2 Bayer matrix', "description": "Ordered dithering with 2x2 Bayer matrix",
}, },
'bayer4': { "bayer4": {
'name': 'Ordered (Bayer 4x4)', "name": "Ordered (Bayer 4x4)",
'func': lambda img: dither_ordered_bayer(img, matrix_size=4), "func": lambda img: dither_ordered_bayer(img, matrix_size=4),
'description': 'Ordered dithering with 4x4 Bayer matrix', "description": "Ordered dithering with 4x4 Bayer matrix",
}, },
'bayer8': { "bayer8": {
'name': 'Ordered (Bayer 8x8)', "name": "Ordered (Bayer 8x8)",
'func': lambda img: dither_ordered_bayer(img, matrix_size=8), "func": lambda img: dither_ordered_bayer(img, matrix_size=8),
'description': 'Ordered dithering with 8x8 Bayer matrix', "description": "Ordered dithering with 8x8 Bayer matrix",
}, },
'bayer4_strong': { "bayer4_strong": {
'name': 'Ordered (Bayer 4x4 Strong)', "name": "Ordered (Bayer 4x4 Strong)",
'func': lambda img: dither_ordered_bayer(img, matrix_size=4, strength=1.5), "func": lambda img: dither_ordered_bayer(img, matrix_size=4, strength=1.5),
'description': 'Bayer 4x4 with increased dithering strength', "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 of available algorithm names."""
return list(DITHER_ALGORITHMS.keys()) 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.""" """Apply the specified dithering algorithm to an image."""
if algorithm not in DITHER_ALGORITHMS: if algorithm not in DITHER_ALGORITHMS:
raise ValueError(f"Unknown algorithm: {algorithm}. Available: {get_algorithm_names()}") 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 random
import sys import sys
import tempfile import tempfile
from collections.abc import Callable, Iterable
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Callable, Iterable
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook" CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook"
@ -36,6 +36,7 @@ def bootstrap() -> None:
def immich_client(): def immich_client():
from immich import ImmichClient from immich import ImmichClient
return ImmichClient( return ImmichClient(
os.environ.get("IMMICH_URL", DEFAULT_IMMICH_URL), os.environ.get("IMMICH_URL", DEFAULT_IMMICH_URL),
os.environ.get("IMMICH_API_KEY", DEFAULT_IMMICH_API_KEY), os.environ.get("IMMICH_API_KEY", DEFAULT_IMMICH_API_KEY),
@ -50,8 +51,13 @@ def is_landscape(asset: dict) -> bool:
return w > h > 0 return w > h > 0
def fetch_pool(client, names: Iterable[str] = DEFAULT_PEOPLE, pool_size: int = 500, def fetch_pool(
seed: int = 7, filter_fn: Callable[[dict], bool] = is_landscape) -> list[dict]: 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))] person_ids = [pid for n in names if (pid := client.get_person_id(n))]
if not person_ids: if not person_ids:
raise ValueError(f"no people found: {list(names)}") 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): def download_image(client, asset: dict):
"""Download (cached) and open as PIL RGB Image.""" """Download (cached) and open as PIL RGB Image."""
from PIL import Image from PIL import Image
CACHE_DIR.mkdir(exist_ok=True) CACHE_DIR.mkdir(exist_ok=True)
dest = CACHE_DIR / f"{asset['id']}.jpg" dest = CACHE_DIR / f"{asset['id']}.jpg"
if not dest.exists(): if not dest.exists():
@ -78,18 +85,21 @@ def silenced():
yield yield
def show_grid(rows: list[list], titles: list[list[str]], figsize_scale=(4.4, 3.0), def show_grid(
suptitle: str | None = None): 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.""" """Render a 2-D image grid with matplotlib. `rows` is list-of-lists of PIL/np images."""
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
n_rows, n_cols = len(rows), max(len(r) for r in rows) n_rows, n_cols = len(rows), max(len(r) for r in rows)
fig, axes = plt.subplots(n_rows, n_cols, fig, axes = plt.subplots(
figsize=(figsize_scale[0] * n_cols, figsize_scale[1] * n_rows)) n_rows, n_cols, figsize=(figsize_scale[0] * n_cols, figsize_scale[1] * n_rows)
)
if n_rows == 1: if n_rows == 1:
axes = [axes] if n_cols == 1 else [list(axes)] axes = [axes] if n_cols == 1 else [list(axes)]
elif n_cols == 1: elif n_cols == 1:
axes = [[ax] for ax in axes] 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): for j in range(n_cols):
ax = axes[i][j] ax = axes[i][j]
if j < len(row) and row[j] is not None: if j < len(row) and row[j] is not None:

View file

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

View file

@ -2,6 +2,7 @@
"cells": [ "cells": [
{ {
"cell_type": "markdown", "cell_type": "markdown",
"id": "7fb27b941602401d91542211134fc71a",
"metadata": {}, "metadata": {},
"source": [ "source": [
"# Dithering algorithm comparison\n", "# Dithering algorithm comparison\n",
@ -27,26 +28,40 @@
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 1, "execution_count": 1,
"id": "acae54e37e7d407bbb7b55eff062a284",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"import sys\n", "import sys\n",
"sys.path.insert(0, '.')\n", "\n",
"from _helpers import bootstrap, immich_client, fetch_pool, download_image, silenced, show_grid\n", "sys.path.insert(0, \".\")\n",
"from _helpers import bootstrap, download_image, fetch_pool, immich_client, show_grid, silenced\n",
"\n",
"bootstrap()\n", "bootstrap()\n",
"\n", "\n",
"import time\n", "import time\n",
"\n",
"import numpy as np\n", "import numpy as np\n",
"from _dither import DITHER_ALGORITHMS, PALETTE_NAMES, PALETTE_RGB, apply_dithering\n",
"from PIL import Image\n", "from PIL import Image\n",
"from waveshare_epd.epd7in3e import EPD_WIDTH, EPD_HEIGHT, _crop_center\n", "from waveshare_epd.epd7in3e import EPD_HEIGHT, EPD_WIDTH, _crop_center\n",
"from _dither import DITHER_ALGORITHMS, apply_dithering, PALETTE_RGB, PALETTE_NAMES\n",
"\n", "\n",
"# Pure-Python algorithms run at ~30s per 800x480 image; keep a curated subset by default.\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", "# Toggle SHOW_ALL = True to run everything (will take several minutes).\n",
"DEFAULT_ALGOS = ['atkinson_fast', 'atkinson', 'atkinson_weighted',\n", "DEFAULT_ALGOS = [\n",
" 'floyd_steinberg', 'floyd_steinberg_weighted',\n", " \"atkinson_fast\",\n",
" 'jarvis', 'sierra_lite', 'burkes',\n", " \"atkinson\",\n",
" 'bayer4', 'bayer8', 'pil_fs', 'none']\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", "SHOW_ALL = False\n",
"ALGOS = list(DITHER_ALGORITHMS.keys()) if SHOW_ALL else DEFAULT_ALGOS\n", "ALGOS = list(DITHER_ALGORITHMS.keys()) if SHOW_ALL else DEFAULT_ALGOS\n",
"\n", "\n",
@ -57,6 +72,7 @@
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 2, "execution_count": 2,
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
{ {
@ -91,20 +107,21 @@
" sources.append((asset, _crop_center(img, EPD_WIDTH, EPD_HEIGHT)))\n", " sources.append((asset, _crop_center(img, EPD_WIDTH, EPD_HEIGHT)))\n",
"\n", "\n",
"for asset, _ in sources:\n", "for asset, _ in sources:\n",
" print(asset.get('originalFileName') or asset['id'])\n", " print(asset.get(\"originalFileName\") or asset[\"id\"])\n",
"\n", "\n",
"# Render the 6-colour palette as a tiny banner so the colour budget is visible.\n", "# Render the 6-colour palette as a tiny banner so the colour budget is visible.\n",
"swatch_h = 60\n", "swatch_h = 60\n",
"swatch_w = 60\n", "swatch_w = 60\n",
"palette_strip = np.zeros((swatch_h, swatch_w * len(PALETTE_RGB), 3), dtype=np.uint8)\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", "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)" "Image.fromarray(palette_strip)"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 3, "execution_count": 3,
"id": "8dd0d8092fe74a7c96281538738b07e2",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
{ {
@ -143,22 +160,23 @@
"source": [ "source": [
"results = [] # list of dict per (photo, algo)\n", "results = [] # list of dict per (photo, algo)\n",
"for asset, source in sources:\n", "for asset, source in sources:\n",
" name = asset.get('originalFileName') or asset['id']\n", " name = asset.get(\"originalFileName\") or asset[\"id\"]\n",
" print(f'[{name}]')\n", " print(f\"[{name}]\")\n",
" photo_results = []\n", " photo_results = []\n",
" for algo in ALGOS:\n", " for algo in ALGOS:\n",
" info = DITHER_ALGORITHMS[algo]\n", " info = DITHER_ALGORITHMS[algo]\n",
" t0 = time.perf_counter()\n", " t0 = time.perf_counter()\n",
" out = apply_dithering(source, algo)\n", " out = apply_dithering(source, algo)\n",
" dt = time.perf_counter() - t0\n", " dt = time.perf_counter() - t0\n",
" photo_results.append({'algo': algo, 'name': info['name'], 'image': out, 'duration': dt})\n", " photo_results.append({\"algo\": algo, \"name\": info[\"name\"], \"image\": out, \"duration\": dt})\n",
" print(f' {info[\"name\"]:32s} {dt:6.2f}s')\n", " print(f\" {info['name']:32s} {dt:6.2f}s\")\n",
" results.append({'asset': asset, 'source': source, 'algos': photo_results})" " results.append({\"asset\": asset, \"source\": source, \"algos\": photo_results})"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 4, "execution_count": 4,
"id": "72eea5119410473aa328ad9291626812",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
{ {
@ -185,21 +203,22 @@
"source": [ "source": [
"# One grid per photo: original + every algorithm.\n", "# One grid per photo: original + every algorithm.\n",
"import matplotlib.pyplot as plt\n", "import matplotlib.pyplot as plt\n",
"\n",
"for entry in results:\n", "for entry in results:\n",
" panels = [entry['source']] + [r['image'] 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", " titles = [\"original (cropped)\"] + [f\"{r['name']}\\n{r['duration']:.2f}s\" for r in entry[\"algos\"]]\n",
" cols = 4\n", " cols = 4\n",
" rows = (len(panels) + cols - 1) // cols\n", " rows = (len(panels) + cols - 1) // cols\n",
" fig, axes = plt.subplots(rows, cols, figsize=(5.0 * cols, 3.2 * rows))\n", " fig, axes = plt.subplots(rows, cols, figsize=(5.0 * cols, 3.2 * rows))\n",
" axes = np.atleast_2d(axes)\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", " fig.suptitle(name, fontsize=12)\n",
" for k in range(rows * cols):\n", " for k in range(rows * cols):\n",
" ax = axes[k // cols][k % cols]\n", " ax = axes[k // cols][k % cols]\n",
" if k < len(panels):\n", " if k < len(panels):\n",
" ax.imshow(panels[k])\n", " ax.imshow(panels[k])\n",
" ax.set_title(titles[k], fontsize=10)\n", " ax.set_title(titles[k], fontsize=10)\n",
" ax.axis('off')\n", " ax.axis(\"off\")\n",
" plt.tight_layout()\n", " plt.tight_layout()\n",
" plt.show()" " plt.show()"
] ]
@ -207,6 +226,7 @@
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 5, "execution_count": 5,
"id": "8edb47106e1a46a883d545849b8ab81b",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
{ {
@ -232,12 +252,13 @@
"source": [ "source": [
"# Per-algorithm summary across both photos: mean runtime + a single representative panel.\n", "# Per-algorithm summary across both photos: mean runtime + a single representative panel.\n",
"from collections import defaultdict\n", "from collections import defaultdict\n",
"\n",
"import matplotlib.pyplot as plt\n", "import matplotlib.pyplot as plt\n",
"\n", "\n",
"agg = defaultdict(list)\n", "agg = defaultdict(list)\n",
"for entry in results:\n", "for entry in results:\n",
" for r in entry['algos']:\n", " for r in entry[\"algos\"]:\n",
" agg[r['algo']].append(r['duration'])\n", " agg[r[\"algo\"]].append(r[\"duration\"])\n",
"\n", "\n",
"print(f\"{'algorithm':32s} {'avg time':>9s} description\")\n", "print(f\"{'algorithm':32s} {'avg time':>9s} description\")\n",
"for algo in ALGOS:\n", "for algo in ALGOS:\n",
@ -248,6 +269,7 @@
}, },
{ {
"cell_type": "markdown", "cell_type": "markdown",
"id": "10185d26023b46108eb7d9f57d49d2b3",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## Picking an algorithm\n", "## Picking an algorithm\n",

View file

@ -16,6 +16,36 @@ notebook = [
"jupyterlab>=4.2", "jupyterlab>=4.2",
"ipykernel>=6.29", "ipykernel>=6.29",
] ]
dev = [
"ruff>=0.8",
]
[tool.uv] [tool.uv]
default-groups = ["notebook"] default-groups = ["notebook", "dev"]
[tool.ruff]
target-version = "py311"
line-length = 100
extend-exclude = ["src/lib/waveshare_epd", "notebooks/__pycache__"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"RUF", # ruff-specific
]
ignore = [
"E501", # line-too-long — formatter handles it
]
[tool.ruff.lint.per-file-ignores]
"notebooks/*.ipynb" = ["E402", "F401"]
[tool.ruff.format]
docstring-code-format = true

View file

@ -9,10 +9,11 @@ from pathlib import Path
from PIL import Image from PIL import Image
sys.path.append(str(Path(__file__).parent / "lib")) sys.path.append(str(Path(__file__).parent / "lib"))
from immich import ImmichClient, get_random_photo_of_people, get_random_photo_from_album
from homeassistant import HomeAssistantClient
from overlay import format_age, format_location
from crop import face_aware_crop from crop import face_aware_crop
from homeassistant import HomeAssistantClient
from immich import ImmichClient, get_random_photo_from_album, get_random_photo_of_people
from overlay import format_age, format_location
# waveshare_epd is imported lazily after the lock — its epdconfig claims # waveshare_epd is imported lazily after the lock — its epdconfig claims
# GPIO pins at import time, so two overlapping invocations would both crash # GPIO pins at import time, so two overlapping invocations would both crash
# on "GPIO busy" before reaching the flock below. # on "GPIO busy" before reaching the flock below.
@ -21,23 +22,33 @@ IMMICH_URL = os.environ.get("IMMICH_URL", "https://immich.example.com")
IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "REDACTED_IMMICH_API_KEY") IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "REDACTED_IMMICH_API_KEY")
HA_URL = os.environ.get("HA_URL", "https://homeassistant.example.com") HA_URL = os.environ.get("HA_URL", "https://homeassistant.example.com")
HA_TOKEN = os.environ.get("HA_TOKEN", "REDACTED_HA_TOKEN") HA_TOKEN = os.environ.get(
"HA_TOKEN",
"REDACTED_HA_TOKEN",
)
HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"} HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"}
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Display image on e-ink frame") parser = argparse.ArgumentParser(description="Display image on e-ink frame")
parser.add_argument("--people", default="Me,Ruby", parser.add_argument(
help="Comma-separated names for Immich search") "--people", default="Me,Ruby", help="Comma-separated names for Immich search"
)
parser.add_argument("--album", help="Fetch from album (overrides --people)") parser.add_argument("--album", help="Fetch from album (overrides --people)")
parser.add_argument("-o", "--orientation", type=int, choices=[0, 90, 180, 270], parser.add_argument(
default=0, help="Rotation in degrees") "-o",
"--orientation",
type=int,
choices=[0, 90, 180, 270],
default=0,
help="Rotation in degrees",
)
parser.add_argument("--saturation", type=float, default=1.3) parser.add_argument("--saturation", type=float, default=1.3)
parser.add_argument("--contrast", type=float, default=1.05) parser.add_argument("--contrast", type=float, default=1.05)
parser.add_argument("--gamma", type=float, default=0.90) parser.add_argument("--gamma", type=float, default=0.90)
args = parser.parse_args() args = parser.parse_args()
lock_fd = open("/tmp/frame.lock", "w") lock_fd = open("/tmp/frame.lock", "w") # noqa: SIM115 — held for process lifetime
try: try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: except BlockingIOError:
@ -84,9 +95,15 @@ def main() -> None:
img = face_aware_crop(img, target_w, target_h, faces) img = face_aware_crop(img, target_w, target_h, faces)
if args.orientation: if args.orientation:
img = img.rotate(args.orientation, expand=True) img = img.rotate(args.orientation, expand=True)
buf = epd.getbuffer(img, saturation=args.saturation, contrast=args.contrast, buf = epd.getbuffer(
gamma=args.gamma, left_text=left_text, right_text=right_text, img,
orientation=args.orientation) saturation=args.saturation,
contrast=args.contrast,
gamma=args.gamma,
left_text=left_text,
right_text=right_text,
orientation=args.orientation,
)
epd.display(buf) epd.display(buf)
finally: finally:
epd.sleep() epd.sleep()

View file

@ -9,8 +9,9 @@ from PIL import Image
HEAD_EXTENSION = 0.4 HEAD_EXTENSION = 0.4
def face_aware_crop(image: Image.Image, target_w: int, target_h: int, def face_aware_crop(
faces: list[dict]) -> Image.Image: image: Image.Image, target_w: int, target_h: int, faces: list[dict]
) -> Image.Image:
"""Resize to cover (target_w, target_h), then crop to keep faces in frame. """Resize to cover (target_w, target_h), then crop to keep faces in frame.
Each face dict has imageWidth/imageHeight (the coord-space dims) and Each face dict has imageWidth/imageHeight (the coord-space dims) and
@ -47,17 +48,11 @@ def face_aware_crop(image: Image.Image, target_w: int, target_h: int,
x_lo = min(b[0] for b in boxes) x_lo = min(b[0] for b in boxes)
x_hi = max(b[2] for b in boxes) x_hi = max(b[2] for b in boxes)
if x_hi - x_lo <= target_w: cx = (x_lo + x_hi) / 2 if x_hi - x_lo <= target_w else _weighted_center(boxes, 0, 2)
cx = (x_lo + x_hi) / 2
else:
cx = _weighted_center(boxes, 0, 2)
y_lo_ext = min(b[1] - (b[3] - b[1]) * HEAD_EXTENSION for b in boxes) y_lo_ext = min(b[1] - (b[3] - b[1]) * HEAD_EXTENSION for b in boxes)
y_hi = max(b[3] for b in boxes) y_hi = max(b[3] for b in boxes)
if y_hi - y_lo_ext <= target_h: cy = (y_lo_ext + y_hi) / 2 if y_hi - y_lo_ext <= target_h else _weighted_center(boxes, 1, 3)
cy = (y_lo_ext + y_hi) / 2
else:
cy = _weighted_center(boxes, 1, 3)
x_off = max(0, min(int(cx - target_w / 2), new_w - target_w)) x_off = max(0, min(int(cx - target_w / 2), new_w - target_w))
y_off = max(0, min(int(cy - target_h / 2), new_h - target_h)) y_off = max(0, min(int(cy - target_h / 2), new_h - target_h))

View file

@ -4,7 +4,7 @@ import random
import tempfile import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from urllib.request import Request from urllib.request import Request
@ -35,20 +35,25 @@ def _load_history() -> tuple[set[str], datetime]:
data = json.loads(HISTORY_FILE.read_text()) data = json.loads(HISTORY_FILE.read_text())
created_at = datetime.fromisoformat(data["created_at"]) created_at = datetime.fromisoformat(data["created_at"])
if created_at.tzinfo is None: if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc) created_at = created_at.replace(tzinfo=UTC)
if datetime.now(timezone.utc) - created_at <= timedelta(days=7): if datetime.now(UTC) - created_at <= timedelta(days=7):
return set(data.get("displayed", [])), created_at return set(data.get("displayed", [])), created_at
print("Photo history expired (>7 days), clearing...") print("Photo history expired (>7 days), clearing...")
except (FileNotFoundError, json.JSONDecodeError, ValueError, KeyError): except (FileNotFoundError, json.JSONDecodeError, ValueError, KeyError):
pass pass
return set(), datetime.now(timezone.utc) return set(), datetime.now(UTC)
def _save_history(displayed: set[str], created_at: datetime) -> None: def _save_history(displayed: set[str], created_at: datetime) -> None:
HISTORY_FILE.write_text(json.dumps({ HISTORY_FILE.write_text(
json.dumps(
{
"created_at": created_at.isoformat(), "created_at": created_at.isoformat(),
"displayed": sorted(displayed), "displayed": sorted(displayed),
}, indent=2)) },
indent=2,
)
)
@dataclass @dataclass
@ -85,13 +90,17 @@ class ImmichClient:
items = [] items = []
page = 1 page = 1
while True: while True:
assets = self._request("POST", "/search/metadata", { assets = self._request(
"POST",
"/search/metadata",
{
"personIds": person_ids, "personIds": person_ids,
"size": 250, "size": 250,
"page": page, "page": page,
"type": "IMAGE", "type": "IMAGE",
"withExif": True, "withExif": True,
}).get("assets", {}) },
).get("assets", {})
items.extend(assets.get("items", [])) items.extend(assets.get("items", []))
if not assets.get("nextPage"): if not assets.get("nextPage"):
break break
@ -157,7 +166,7 @@ def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]:
Returns (candidates, is_exact). `is_exact` is True when same-month-day matches Returns (candidates, is_exact). `is_exact` is True when same-month-day matches
exist; callers use it to weight the pool higher than the looser ±3-day fallback. exist; callers use it to weight the pool higher than the looser ±3-day fallback.
""" """
today = datetime.now(timezone.utc).date() today = datetime.now(UTC).date()
dated = [] dated = []
for a in assets: for a in assets:
exif = a.get("exifInfo") or {} exif = a.get("exifInfo") or {}
@ -184,7 +193,7 @@ def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]:
def _pick_weighted_random(assets: list[dict]) -> dict: def _pick_weighted_random(assets: list[dict]) -> dict:
"""Pick random asset, biased towards on-this-day memories, favorites, and recents.""" """Pick random asset, biased towards on-this-day memories, favorites, and recents."""
cutoff = datetime.now(timezone.utc) - timedelta(days=30) cutoff = datetime.now(UTC) - timedelta(days=30)
favorites = [a for a in assets if a.get("isFavorite")] favorites = [a for a in assets if a.get("isFavorite")]
recent = [] recent = []
for a in assets: for a in assets:
@ -208,8 +217,9 @@ def _pick_weighted_random(assets: list[dict]) -> dict:
return random.choice(pool) return random.choice(pool)
def _pick_and_download(client: ImmichClient, assets: list[dict], def _pick_and_download(
orientation: int, source_label: str) -> tuple[Path, dict]: client: ImmichClient, assets: list[dict], orientation: int, source_label: str
) -> tuple[Path, dict]:
portrait = orientation in (90, 270) portrait = orientation in (90, 270)
filtered = _filter_by_orientation(assets, portrait) filtered = _filter_by_orientation(assets, portrait)
if not filtered: if not filtered:
@ -231,7 +241,9 @@ def _pick_and_download(client: ImmichClient, assets: list[dict],
return path, asset return path, asset
def get_random_photo_of_people(client: ImmichClient, names: list[str], orientation: int = 0) -> tuple[Path, dict]: def get_random_photo_of_people(
client: ImmichClient, names: list[str], orientation: int = 0
) -> tuple[Path, dict]:
person_ids = [pid for name in names if (pid := client.get_person_id(name))] person_ids = [pid for name in names if (pid := client.get_person_id(name))]
if not person_ids: if not person_ids:
raise ValueError(f"No people found: {names}") raise ValueError(f"No people found: {names}")
@ -243,7 +255,9 @@ def get_random_photo_of_people(client: ImmichClient, names: list[str], orientati
return _pick_and_download(client, assets, orientation, f"photos for {', '.join(names)}") return _pick_and_download(client, assets, orientation, f"photos for {', '.join(names)}")
def get_random_photo_from_album(client: ImmichClient, album_name: str, orientation: int = 0) -> tuple[Path, dict]: def get_random_photo_from_album(
client: ImmichClient, album_name: str, orientation: int = 0
) -> tuple[Path, dict]:
album_id = client.get_album_id(album_name) album_id = client.get_album_id(album_name)
if not album_id: if not album_id:
raise ValueError(f"Album not found: {album_name}") raise ValueError(f"Album not found: {album_name}")

View file

@ -5,7 +5,7 @@ array; black/white survive Atkinson dithering so edges stay crisp on e-ink.
""" """
import os import os
from datetime import datetime, timezone from datetime import UTC, datetime
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@ -39,8 +39,8 @@ def format_age(asset: dict) -> str | None:
except (ValueError, AttributeError): except (ValueError, AttributeError):
return None return None
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=UTC)
days = (datetime.now(timezone.utc) - dt).days days = (datetime.now(UTC) - dt).days
if days < 0: if days < 0:
return None return None
if days == 0: if days == 0:
@ -63,10 +63,9 @@ def format_location(asset: dict) -> str | None:
return exif.get("city") or exif.get("state") or exif.get("country") or None return exif.get("city") or exif.get("state") or exif.get("country") or None
def render_text_into_indices(indices: np.ndarray, def render_text_into_indices(
left_text: str | None, indices: np.ndarray, left_text: str | None, right_text: str | None, orientation: int = 0
right_text: str | None, ) -> None:
orientation: int = 0) -> None:
"""Paint white-on-black-stroke text into a (height, width) palette-index array. """Paint white-on-black-stroke text into a (height, width) palette-index array.
Text is laid out viewer-bottom-left/right, then rotated by `orientation` Text is laid out viewer-bottom-left/right, then rotated by `orientation`
@ -89,14 +88,28 @@ def render_text_into_indices(indices: np.ndarray,
if left_text: if left_text:
pos = (margin, baseline) pos = (margin, baseline)
fill_draw.text(pos, left_text, font=font, fill=255, anchor="lb") fill_draw.text(pos, left_text, font=font, fill=255, anchor="lb")
full_draw.text(pos, left_text, font=font, fill=255, anchor="lb", full_draw.text(
stroke_width=stroke_width, stroke_fill=255) pos,
left_text,
font=font,
fill=255,
anchor="lb",
stroke_width=stroke_width,
stroke_fill=255,
)
if right_text: if right_text:
pos = (view_w - margin, baseline) pos = (view_w - margin, baseline)
fill_draw.text(pos, right_text, font=font, fill=255, anchor="rb") fill_draw.text(pos, right_text, font=font, fill=255, anchor="rb")
full_draw.text(pos, right_text, font=font, fill=255, anchor="rb", full_draw.text(
stroke_width=stroke_width, stroke_fill=255) pos,
right_text,
font=font,
fill=255,
anchor="rb",
stroke_width=stroke_width,
stroke_fill=255,
)
if orientation: if orientation:
fill_layer = fill_layer.rotate(orientation, expand=True) fill_layer = fill_layer.rotate(orientation, expand=True)

29
uv.lock generated
View file

@ -448,6 +448,9 @@ dependencies = [
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [
{ name = "ruff" },
]
notebook = [ notebook = [
{ name = "ipykernel" }, { name = "ipykernel" },
{ name = "jupyterlab" }, { name = "jupyterlab" },
@ -463,6 +466,7 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.8" }]
notebook = [ notebook = [
{ name = "ipykernel", specifier = ">=6.29" }, { name = "ipykernel", specifier = ">=6.29" },
{ name = "jupyterlab", specifier = ">=4.2" }, { name = "jupyterlab", specifier = ">=4.2" },
@ -1696,6 +1700,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
]
[[package]] [[package]]
name = "send2trash" name = "send2trash"
version = "2.1.0" version = "2.1.0"