Compare commits

..

3 commits

Author SHA1 Message Date
88cda50037 Claude clean up
All checks were successful
lint / ruff (push) Successful in 2m1s
2026-05-03 11:06:19 +01:00
7ba0c0376c Lint 2026-05-03 10:39:31 +01:00
a713c3eb61 More memories 2026-05-03 10:32:00 +01:00
19 changed files with 519 additions and 310 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

6
.gitignore vendored
View file

@ -1 +1,7 @@
*.pyc *.pyc
__pycache__/
.venv/
.ruff_cache/
*.egg-info/
.ipynb_checkpoints/
photo_history.json

View file

@ -31,7 +31,7 @@ python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85
**`src/lib/immich.py`** — Immich API client. Key behaviors: **`src/lib/immich.py`** — Immich API client. Key behaviors:
- `_load_history()` / `_save_history()` track displayed photos in `photo_history.json` to avoid repeats (resets after 7 days). Asset is only marked displayed after a successful download. - `_load_history()` / `_save_history()` track displayed photos in `photo_history.json` to avoid repeats (resets after 7 days). Asset is only marked displayed after a successful download.
- `_pick_weighted_random()` biases selection: 20% favorites, 50% recently-added (last 30 days, by Immich `createdAt`), otherwise uniform random - `_pick_weighted_random()` picks a pool first, then a uniform random asset from it. Weights: on-this-day 0.30 (or 0.10 for the ±3-day fallback), favorites 0.18, recent-30-days 0.36, all 0.36. Empty pools are dropped before sampling.
- Filters photos by orientation (portrait/landscape) based on EXIF data including rotation tags. Raises if nothing matches the requested orientation. - Filters photos by orientation (portrait/landscape) based on EXIF data including rotation tags. Raises if nothing matches the requested orientation.
- Downloads preview-size thumbnails, not originals - Downloads preview-size thumbnails, not originals
- Asset lists (people-search and album) are cached on disk in `/tmp/frame_cache/` for 1 hour - Asset lists (people-search and album) are cached on disk in `/tmp/frame_cache/` for 1 hour
@ -40,10 +40,10 @@ python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85
**`src/lib/homeassistant.py`** — Simple Home Assistant REST client for presence detection. **`src/lib/homeassistant.py`** — Simple Home Assistant REST client for presence detection.
**`src/lib/waveshare_epd/epd7in3e.py`** — Modified Waveshare driver. The `getbuffer()` method handles the full image pipeline: **`src/lib/waveshare_epd/epd7in3e.py`** — Modified Waveshare driver. The `getbuffer()` method handles the full image pipeline:
- Center-crops to 800x480 (or 480x800) - Falls back to a face-less center crop via `face_aware_crop` if the input isn't already at target size
- Enhances saturation/contrast/gamma for e-ink (caller passes values; CLI defaults live in `display.py`: saturation=1.3, contrast=1.05, gamma=0.90) - Enhances saturation/contrast/gamma for e-ink (caller passes values; CLI defaults live in `display.py`: saturation=1.3, contrast=1.05, gamma=0.90)
- Atkinson dithering to 6-color palette using numba JIT; produces palette indices directly (no Pillow quantize round-trip) - Atkinson dithering to 6-color palette using numba JIT; produces palette indices directly (no Pillow quantize round-trip)
- Packs into 4-bit-per-pixel buffer (two pixels per byte) via numpy - Packs into 4-bit-per-pixel buffer (two pixels per byte) and returns `bytes`
**`src/lib/waveshare_epd/epdconfig.py`** — GPIO/SPI hardware config. **Critical: PWR pin is BCM 27** (not default 18). **`src/lib/waveshare_epd/epdconfig.py`** — GPIO/SPI hardware config. **Critical: PWR pin is BCM 27** (not default 18).
@ -61,7 +61,7 @@ former `dither_test/`):
- **Always call `epd.sleep()` after display** — the driver uses a try/finally pattern for this - **Always call `epd.sleep()` after display** — the driver uses a try/finally pattern for this
- **Display refresh takes 12-15 seconds** — the BUSY pin polling handles this - **Display refresh takes 12-15 seconds** — the BUSY pin polling handles this
- **No test suite** — this is a hardware project; test by deploying to the Pi - **No test suite** — this is a hardware project; test by deploying to the Pi
- **Dependencies on Pi**: `python3-pil python3-opencv python3-numba python3-smbus spidev gpiozero` - **Dependencies on Pi**: `python3-pil python3-numba python3-smbus spidev gpiozero`
- **Config via environment variables**: `IMMICH_URL`, `IMMICH_API_KEY`, `HA_URL`, `HA_TOKEN` (with hardcoded defaults in display.py) - **Config via environment variables**: `IMMICH_URL`, `IMMICH_API_KEY`, `HA_URL`, `HA_TOKEN` (with hardcoded defaults in display.py)
- **Uses only stdlib `urllib`** — no requests library; the Immich client uses `urllib.request` directly - **Uses only stdlib `urllib`** — no requests library; the Immich client uses `urllib.request` directly
- **Single-instance lock** at `/tmp/frame.lock` (fcntl) — overlapping cron runs exit cleanly - **Single-instance lock** at `/tmp/frame.lock` (fcntl) — overlapping cron runs exit cleanly

View file

@ -1,12 +1,12 @@
## Installation ## Installation
sudo raspi-confi sudo raspi-config
- Enable SPI - Enable SPI
```sh ```sh
sudo apt update sudo apt update
sudo apt upgrade sudo apt upgrade
sudo apt install -y python3-pip python3-pil python3-opencv python3-smbus python3-numba sudo apt install -y python3-pip python3-pil python3-smbus python3-numba
sudo swapoff -a sudo swapoff -a
sudo systemctl mask swap.target sudo systemctl mask swap.target
sudo systemctl disable --now bluetooth sudo systemctl disable --now bluetooth
@ -14,7 +14,7 @@ sudo systemctl disable --now bluetooth
sudo nmcli c modify netplan-wlan0-HiddenPlace 802-11-wireless.powersave 2 sudo nmcli c modify netplan-wlan0-HiddenPlace 802-11-wireless.powersave 2
``` ```
Execute `sudo crontab -e` reland add Execute `sudo crontab -e` and add
``` ```
@reboot /usr/sbin/iw wlan0 set power_save off @reboot /usr/sbin/iw wlan0 set power_save off
*/5 * * * * /home/andras/frame/wifi-check.sh >> /home/andras/wifi.log 2>&1 */5 * * * * /home/andras/frame/wifi-check.sh >> /home/andras/wifi.log 2>&1

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,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)

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,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",

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

@ -6,7 +6,6 @@ requires-python = ">=3.11,<3.14"
dependencies = [ dependencies = [
"numpy>=1.26", "numpy>=1.26",
"pillow>=10", "pillow>=10",
"opencv-python>=4.8",
"numba>=0.60", "numba>=0.60",
] ]
@ -16,6 +15,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.schmelczer.dev")
IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "6crxVS1JLTJxsfGlzVhN2kefdL4EP7HPkkoMk9L6ZOE") IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "6crxVS1JLTJxsfGlzVhN2kefdL4EP7HPkkoMk9L6ZOE")
HA_URL = os.environ.get("HA_URL", "https://homeassistant.schmelczer.dev") HA_URL = os.environ.get("HA_URL", "https://homeassistant.schmelczer.dev")
HA_TOKEN = os.environ.get("HA_TOKEN", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmZjk3OTNmOWMzOWU0YjdmYmRjYTc5YmJkMTUyODcyNSIsImlhdCI6MTc2OTIwMjg1NCwiZXhwIjoyMDg0NTYyODU0fQ.IiL_1vTrGMlOoPMksN6lAopE0aInlY_wRnL4Jc-CeBs") HA_TOKEN = os.environ.get(
"HA_TOKEN",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmZjk3OTNmOWMzOWU0YjdmYmRjYTc5YmJkMTUyODcyNSIsImlhdCI6MTc2OTIwMjg1NCwiZXhwIjoyMDg0NTYyODU0fQ.IiL_1vTrGMlOoPMksN6lAopE0aInlY_wRnL4Jc-CeBs",
)
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(
"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
@ -135,6 +144,9 @@ class ImmichClient:
return assets return assets
_ROTATED_ORIENTATIONS = {5, 6, 7, 8, "5", "6", "7", "8"}
def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]: def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]:
"""Keep assets matching the requested orientation. Skips assets without EXIF dimensions.""" """Keep assets matching the requested orientation. Skips assets without EXIF dimensions."""
out = [] out = []
@ -144,16 +156,20 @@ def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]:
h = exif.get("exifImageHeight") or 0 h = exif.get("exifImageHeight") or 0
if not (w and h): if not (w and h):
continue continue
if exif.get("orientation") in (6, 8, "6", "8"): if exif.get("orientation") in _ROTATED_ORIENTATIONS:
w, h = h, w w, h = h, w
if (h > w) == portrait: if (h > w) == portrait:
out.append(a) out.append(a)
return out return out
def _on_this_day_candidates(assets: list[dict]) -> list[dict]: def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]:
"""Photos taken on today's month-day in past years, with a ±3-day fallback.""" """Photos taken on today's month-day in past years, with a ±3-day fallback.
today = datetime.now(timezone.utc).date()
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.
"""
today = datetime.now().date()
dated = [] dated = []
for a in assets: for a in assets:
exif = a.get("exifInfo") or {} exif = a.get("exifInfo") or {}
@ -169,18 +185,18 @@ def _on_this_day_candidates(assets: list[dict]) -> list[dict]:
exact = [a for a, dt in dated if (dt.month, dt.day) == (today.month, today.day)] exact = [a for a, dt in dated if (dt.month, dt.day) == (today.month, today.day)]
if exact: if exact:
return exact return exact, True
nearby_md = set() nearby_md = set()
for offset in range(-3, 4): for offset in range(-3, 4):
d = today + timedelta(days=offset) d = today + timedelta(days=offset)
nearby_md.add((d.month, d.day)) nearby_md.add((d.month, d.day))
return [a for a, dt in dated if (dt.month, dt.day) in nearby_md] return [a for a, dt in dated if (dt.month, dt.day) in nearby_md], False
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:
@ -189,10 +205,10 @@ def _pick_weighted_random(assets: list[dict]) -> dict:
recent.append(a) recent.append(a)
except (ValueError, AttributeError): except (ValueError, AttributeError):
pass pass
on_this_day = _on_this_day_candidates(assets) on_this_day, on_this_day_exact = _on_this_day_candidates(assets)
candidates = [ candidates = [
("on this day", on_this_day, 0.10), ("on this day", on_this_day, 0.30 if on_this_day_exact else 0.10),
("favorites", favorites, 0.18), ("favorites", favorites, 0.18),
("recent", recent, 0.36), ("recent", recent, 0.36),
("all", assets, 0.36), ("all", assets, 0.36),
@ -204,8 +220,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:
@ -227,7 +244,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}")
@ -239,7 +258,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

@ -9,7 +9,8 @@ def urlopen_with_retry(req: Request, timeout: int = 30):
for delay in (3, 10, None): for delay in (3, 10, None):
try: try:
return urlopen(req, timeout=timeout) return urlopen(req, timeout=timeout)
except (URLError, TimeoutError): except (URLError, TimeoutError) as e:
if delay is None: if delay is None:
raise raise
print(f"urlopen {req.full_url}: {e}; retry in {delay}s")
time.sleep(delay) time.sleep(delay)

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:
@ -49,12 +49,13 @@ def format_age(asset: dict) -> str | None:
return "Yesterday" return "Yesterday"
if days < 7: if days < 7:
return f"{days} days ago" return f"{days} days ago"
for n, unit in ((365, "year"), (30, "month"), (7, "week")): for n, unit in ((12 * 30, "year"), (30, "month"), (7, "week")):
if days >= n: if days >= n:
count = max(1, days // n) count = max(1, days // n)
if count == 1: if count == 1:
return f"Last {unit}" return f"A {unit} ago"
return f"{count} {unit}s ago" return f"{count} {unit}s ago"
return None
def format_location(asset: dict) -> str | None: def format_location(asset: dict) -> str | None:
@ -63,10 +64,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 +89,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)

View file

@ -1,11 +1,14 @@
"""Simple terminal progress bar for e-ink frame.""" """Simple terminal progress bar for e-ink frame."""
import sys
class ProgressBar: class ProgressBar:
def __init__(self, total: int, desc: str = ""): def __init__(self, total: int, desc: str = ""):
self.total = total self.total = total
self.desc = desc self.desc = desc
self._last_percent = -1 self._last_percent = -1
self._is_tty = sys.stdout.isatty()
def set(self, value: int) -> None: def set(self, value: int) -> None:
if self.total == 0: if self.total == 0:
@ -14,10 +17,16 @@ class ProgressBar:
percent = int(100 * value / self.total) percent = int(100 * value / self.total)
if percent == self._last_percent: if percent == self._last_percent:
return return
# In non-tty (cron log) mode, only emit a few milestones — no \r spam.
if not self._is_tty and percent not in (25, 50, 75, 100):
return
self._last_percent = percent self._last_percent = percent
filled = int(30 * value / self.total)
bar = "" * filled + "" * (30 - filled)
end = "\n" if value >= self.total else ""
prefix = f"{self.desc}: " if self.desc else "" prefix = f"{self.desc}: " if self.desc else ""
print(f"\r{prefix}|{bar}| {percent:3d}%", end=end, flush=True) if self._is_tty:
filled = int(30 * value / self.total)
bar = "" * filled + "" * (30 - filled)
end = "\n" if value >= self.total else ""
print(f"\r{prefix}|{bar}| {percent:3d}%", end=end, flush=True)
else:
print(f"{prefix}{percent}%", flush=True)

View file

@ -2,9 +2,9 @@
# Waveshare 7.3" 6-color e-Paper driver (modified) # Waveshare 7.3" 6-color e-Paper driver (modified)
import numpy as np import numpy as np
import cv2
from PIL import Image, ImageEnhance from PIL import Image, ImageEnhance
from numba import jit from numba import jit
from crop import face_aware_crop
from progress import ProgressBar from progress import ProgressBar
from overlay import render_text_into_indices from overlay import render_text_into_indices
from . import epdconfig from . import epdconfig
@ -13,13 +13,13 @@ EPD_WIDTH = 800
EPD_HEIGHT = 480 EPD_HEIGHT = 480
# 6-color e-ink encoding: indices 0,1,2,3,5,6 are wire-format colors; # 6-color e-ink encoding: indices 0,1,2,3,5,6 are wire-format colors;
# 4 is reserved/unused (filled with BLACK so nearest-color never picks it). # 4 is reserved/unused — _find_nearest_color skips it explicitly.
PALETTE_RGB = np.array([ PALETTE_RGB = np.array([
[0, 0, 0], # 0: BLACK [0, 0, 0], # 0: BLACK
[255, 255, 255], # 1: WHITE [255, 255, 255], # 1: WHITE
[255, 255, 0], # 2: YELLOW [255, 255, 0], # 2: YELLOW
[255, 0, 0], # 3: RED [255, 0, 0], # 3: RED
[0, 0, 0], # 4: unused [0, 0, 0], # 4: unused (skipped)
[0, 0, 255], # 5: BLUE [0, 0, 255], # 5: BLUE
[0, 255, 0], # 6: GREEN [0, 255, 0], # 6: GREEN
], dtype=np.float64) ], dtype=np.float64)
@ -56,28 +56,12 @@ def _enhance_for_eink(image: Image.Image, saturation: float,
return img return img
def _crop_center(image: Image.Image, target_w: int, target_h: int) -> Image.Image:
print("Center cropping...")
img_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
img_h, img_w = img_cv.shape[:2]
img_aspect, target_aspect = img_w / img_h, target_w / target_h
if img_aspect < target_aspect:
new_w, new_h = target_w, int(target_w / img_aspect)
else:
new_w, new_h = int(target_h * img_aspect), target_h
img_cv = cv2.resize(img_cv, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
x_off = (new_w - target_w) // 2
y_off = (new_h - target_h) // 2
cropped = img_cv[y_off:y_off + target_h, x_off:x_off + target_w]
return Image.fromarray(cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB))
@jit(nopython=True, cache=True) @jit(nopython=True, cache=True)
def _find_nearest_color(r, g, b, palette, weights): def _find_nearest_color(r, g, b, palette, weights):
best_idx, best_dist = 0, 1e10 best_idx, best_dist = 0, 1e10
for i in range(palette.shape[0]): for i in range(palette.shape[0]):
if i == 4: # reserved palette slot
continue
dr = (palette[i, 0] - r) * weights[0] dr = (palette[i, 0] - r) * weights[0]
dg = (palette[i, 1] - g) * weights[1] dg = (palette[i, 1] - g) * weights[1]
db = (palette[i, 2] - b) * weights[2] db = (palette[i, 2] - b) * weights[2]
@ -133,6 +117,8 @@ def _dither_atkinson(image: Image.Image) -> np.ndarray:
print("Dithering...") print("Dithering...")
progress = ProgressBar(height, desc="Dithering") progress = ProgressBar(height, desc="Dithering")
# Chunking is for progress reporting only; error diffusion still
# spans chunks because `img` is the same buffer between calls.
chunk_size = 48 chunk_size = 48
for i in range((height + chunk_size - 1) // chunk_size): for i in range((height + chunk_size - 1) // chunk_size):
start, end = i * chunk_size, min((i + 1) * chunk_size, height) start, end = i * chunk_size, min((i + 1) * chunk_size, height)
@ -208,7 +194,7 @@ class EPD:
image = image.convert('RGB') image = image.convert('RGB')
if image.size != (self.width, self.height): if image.size != (self.width, self.height):
print(f"Input: {image.size[0]}x{image.size[1]}{self.width}x{self.height}") print(f"Input: {image.size[0]}x{image.size[1]}{self.width}x{self.height}")
image = _crop_center(image, self.width, self.height) image = face_aware_crop(image, self.width, self.height, [])
print("Enhancing...") print("Enhancing...")
image = _enhance_for_eink(image, saturation, contrast, gamma) image = _enhance_for_eink(image, saturation, contrast, gamma)
@ -221,7 +207,7 @@ class EPD:
print("Packing buffer...") print("Packing buffer...")
flat = indices.reshape(-1) flat = indices.reshape(-1)
return ((flat[0::2].astype(np.uint8) << 4) | flat[1::2].astype(np.uint8)).tolist() return bytes((flat[0::2] << 4) | flat[1::2])
def display(self, image): def display(self, image):
self.send_command(0x10) self.send_command(0x10)

View file

@ -87,13 +87,13 @@ class RaspberryPi:
if pin == self.BUSY_PIN: if pin == self.BUSY_PIN:
return self.GPIO_BUSY_PIN.value return self.GPIO_BUSY_PIN.value
elif pin == self.RST_PIN: elif pin == self.RST_PIN:
return self.RST_PIN.value return self.GPIO_RST_PIN.value
elif pin == self.DC_PIN: elif pin == self.DC_PIN:
return self.DC_PIN.value return self.GPIO_DC_PIN.value
# elif pin == self.CS_PIN: # elif pin == self.CS_PIN:
# return self.CS_PIN.value # return self.GPIO_CS_PIN.value
elif pin == self.PWR_PIN: elif pin == self.PWR_PIN:
return self.PWR_PIN.value return self.GPIO_PWR_PIN.value
def delay_ms(self, delaytime): def delay_ms(self, delaytime):
time.sleep(delaytime / 1000.0) time.sleep(delaytime / 1000.0)
@ -134,7 +134,7 @@ class RaspberryPi:
self.DEV_SPI = CDLL(so_filename) self.DEV_SPI = CDLL(so_filename)
break break
if self.DEV_SPI is None: if self.DEV_SPI is None:
RuntimeError('Cannot find DEV_Config.so') raise RuntimeError('Cannot find DEV_Config.so')
self.DEV_SPI.DEV_Module_Init() self.DEV_SPI.DEV_Module_Init()

49
uv.lock generated
View file

@ -443,11 +443,13 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "numba" }, { name = "numba" },
{ name = "numpy" }, { name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" }, { name = "pillow" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [
{ name = "ruff" },
]
notebook = [ notebook = [
{ name = "ipykernel" }, { name = "ipykernel" },
{ name = "jupyterlab" }, { name = "jupyterlab" },
@ -458,11 +460,11 @@ notebook = [
requires-dist = [ requires-dist = [
{ name = "numba", specifier = ">=0.60" }, { name = "numba", specifier = ">=0.60" },
{ name = "numpy", specifier = ">=1.26" }, { name = "numpy", specifier = ">=1.26" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pillow", specifier = ">=10" }, { name = "pillow", specifier = ">=10" },
] ]
[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" },
@ -1208,24 +1210,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
] ]
[[package]]
name = "opencv-python"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
{ url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
{ url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
{ url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
{ url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
]
[[package]] [[package]]
name = "overrides" name = "overrides"
version = "7.7.0" version = "7.7.0"
@ -1696,6 +1680,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"