Lint
This commit is contained in:
parent
9a009f0b4c
commit
eed1567f7f
12 changed files with 463 additions and 243 deletions
24
.forgejo/workflows/lint.yml
Normal file
24
.forgejo/workflows/lint.yml
Normal 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
14
lint.sh
Executable 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
|
||||||
|
|
@ -6,22 +6,20 @@ 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 = [
|
||||||
(0, 0, 0), # BLACK
|
(0, 0, 0), # BLACK
|
||||||
(255, 255, 255), # WHITE
|
(255, 255, 255), # WHITE
|
||||||
(255, 255, 0), # YELLOW
|
(255, 255, 0), # YELLOW
|
||||||
(255, 0, 0), # RED
|
(255, 0, 0), # RED
|
||||||
(0, 0, 255), # BLUE
|
(0, 0, 255), # BLUE
|
||||||
(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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,29 +26,42 @@
|
||||||
{
|
{
|
||||||
"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",
|
||||||
"import numpy as np\n",
|
|
||||||
"from PIL import Image, ImageDraw\n",
|
|
||||||
"from waveshare_epd.epd7in3e import EPD_WIDTH, EPD_HEIGHT, _crop_center\n",
|
|
||||||
"from crop import face_aware_crop, HEAD_EXTENSION\n",
|
|
||||||
"\n",
|
"\n",
|
||||||
"POOL_SIZE = 80 # smaller than auto-tune notebook — each photo costs an extra API call for faces\n",
|
"import numpy as np\n",
|
||||||
|
"from crop import HEAD_EXTENSION, face_aware_crop\n",
|
||||||
|
"from PIL import Image, ImageDraw\n",
|
||||||
|
"from waveshare_epd.epd7in3e import EPD_HEIGHT, EPD_WIDTH, _crop_center\n",
|
||||||
|
"\n",
|
||||||
|
"POOL_SIZE = 80 # smaller than auto-tune notebook — each photo costs an extra API call for faces\n",
|
||||||
"SEED = 7\n",
|
"SEED = 7\n",
|
||||||
"N_PICKS = 4 # most-divergent picks (plus one no-faces baseline)"
|
"N_PICKS = 4 # most-divergent picks (plus one no-faces baseline)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
"created_at": created_at.isoformat(),
|
json.dumps(
|
||||||
"displayed": sorted(displayed),
|
{
|
||||||
}, indent=2))
|
"created_at": created_at.isoformat(),
|
||||||
|
"displayed": sorted(displayed),
|
||||||
|
},
|
||||||
|
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(
|
||||||
"personIds": person_ids,
|
"POST",
|
||||||
"size": 250,
|
"/search/metadata",
|
||||||
"page": page,
|
{
|
||||||
"type": "IMAGE",
|
"personIds": person_ids,
|
||||||
"withExif": True,
|
"size": 250,
|
||||||
}).get("assets", {})
|
"page": page,
|
||||||
|
"type": "IMAGE",
|
||||||
|
"withExif": True,
|
||||||
|
},
|
||||||
|
).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}")
|
||||||
|
|
|
||||||
|
|
@ -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
29
uv.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue