diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml deleted file mode 100644 index 0a4cfcc..0000000 --- a/.forgejo/workflows/lint.yml +++ /dev/null @@ -1,24 +0,0 @@ -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 diff --git a/.gitignore b/.gitignore index d55f838..0d20b64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1 @@ *.pyc -__pycache__/ -.venv/ -.ruff_cache/ -*.egg-info/ -.ipynb_checkpoints/ -photo_history.json diff --git a/CLAUDE.md b/CLAUDE.md index 561ca62..bb5d6d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: - `_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()` 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. +- `_pick_weighted_random()` biases selection: 20% favorites, 50% recently-added (last 30 days, by Immich `createdAt`), otherwise uniform random - 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 - 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/waveshare_epd/epd7in3e.py`** — Modified Waveshare driver. The `getbuffer()` method handles the full image pipeline: -- Falls back to a face-less center crop via `face_aware_crop` if the input isn't already at target size +- Center-crops to 800x480 (or 480x800) - 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) -- Packs into 4-bit-per-pixel buffer (two pixels per byte) and returns `bytes` +- Packs into 4-bit-per-pixel buffer (two pixels per byte) via numpy **`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 - **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 -- **Dependencies on Pi**: `python3-pil python3-numba python3-smbus spidev gpiozero` +- **Dependencies on Pi**: `python3-pil python3-opencv 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) - **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 diff --git a/README.md b/README.md index fd899a8..c60f9f4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ ## Installation -sudo raspi-config +sudo raspi-confi - Enable SPI ```sh sudo apt update sudo apt upgrade -sudo apt install -y python3-pip python3-pil python3-smbus python3-numba +sudo apt install -y python3-pip python3-pil python3-opencv python3-smbus python3-numba sudo swapoff -a sudo systemctl mask swap.target 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 ``` -Execute `sudo crontab -e` and add +Execute `sudo crontab -e` reland add ``` @reboot /usr/sbin/iw wlan0 set power_save off */5 * * * * /home/andras/frame/wifi-check.sh >> /home/andras/wifi.log 2>&1 diff --git a/lint.sh b/lint.sh deleted file mode 100755 index f88511a..0000000 --- a/lint.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/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 diff --git a/notebooks/_dither.py b/notebooks/_dither.py index 20a1c9a..8424673 100644 --- a/notebooks/_dither.py +++ b/notebooks/_dither.py @@ -6,20 +6,22 @@ for comparison testing. """ import numpy as np -from numba import jit from PIL import Image +from typing import Tuple, List + +from numba import jit # 6-color ACeP palette (RGB format) PALETTE_RGB = [ - (0, 0, 0), # BLACK + (0, 0, 0), # BLACK (255, 255, 255), # WHITE - (255, 255, 0), # YELLOW - (255, 0, 0), # RED - (0, 0, 255), # BLUE - (0, 255, 0), # GREEN + (255, 255, 0), # YELLOW + (255, 0, 0), # RED + (0, 0, 255), # BLUE + (0, 255, 0), # GREEN ] -PALETTE_NAMES = ["Black", "White", "Yellow", "Red", "Blue", "Green"] +PALETTE_NAMES = ['Black', 'White', 'Yellow', 'Red', 'Blue', 'Green'] def create_pil_palette_image() -> Image.Image: @@ -34,20 +36,20 @@ def create_pil_palette_image() -> Image.Image: return pal_image -def find_nearest_color(pixel: np.ndarray, palette: np.ndarray) -> tuple[int, np.ndarray]: +def find_nearest_color(pixel: np.ndarray, palette: np.ndarray) -> Tuple[int, np.ndarray]: """Find the nearest palette color using Euclidean distance.""" distances = np.sqrt(np.sum((palette - pixel) ** 2, axis=1)) idx = np.argmin(distances) return idx, palette[idx] -def find_nearest_color_weighted(pixel: np.ndarray, palette: np.ndarray) -> tuple[int, np.ndarray]: +def find_nearest_color_weighted(pixel: np.ndarray, palette: np.ndarray) -> Tuple[int, np.ndarray]: """Find nearest color using perceptually-weighted distance (human eye sensitivity).""" # Weights based on human perception: Green > Red > Blue weights = np.array([0.299, 0.587, 0.114]) diff = palette - pixel weighted_diff = diff * weights - distances = np.sqrt(np.sum(weighted_diff**2, axis=1)) + distances = np.sqrt(np.sum(weighted_diff ** 2, axis=1)) idx = np.argmin(distances) return idx, palette[idx] @@ -56,7 +58,6 @@ def find_nearest_color_weighted(pixel: np.ndarray, palette: np.ndarray) -> tuple # Error Diffusion Dithering Algorithms # ============================================================================= - def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.Image: """ Floyd-Steinberg dithering (1976). @@ -65,7 +66,7 @@ def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image. * 7/16 3/16 5/16 1/16 """ - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) height, width = img.shape[:2] palette = np.array(PALETTE_RGB, dtype=np.float64) find_fn = find_nearest_color_weighted if weighted else find_nearest_color @@ -87,7 +88,7 @@ def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image. img[y + 1, x + 1] += error * 1 / 16 img = np.clip(img, 0, 255).astype(np.uint8) - return Image.fromarray(img, "RGB") + return Image.fromarray(img, 'RGB') def dither_jarvis_judice_ninke(image: Image.Image, weighted: bool = False) -> Image.Image: @@ -100,7 +101,7 @@ def dither_jarvis_judice_ninke(image: Image.Image, weighted: bool = False) -> Im 1 3 5 3 1 All divided by 48. """ - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) height, width = img.shape[:2] palette = np.array(PALETTE_RGB, dtype=np.float64) find_fn = find_nearest_color_weighted if weighted else find_nearest_color @@ -131,7 +132,7 @@ def dither_jarvis_judice_ninke(image: Image.Image, weighted: bool = False) -> Im img[y + 2, x + dx] += error * w / 48 img = np.clip(img, 0, 255).astype(np.uint8) - return Image.fromarray(img, "RGB") + return Image.fromarray(img, 'RGB') def dither_stucki(image: Image.Image, weighted: bool = False) -> Image.Image: @@ -144,7 +145,7 @@ def dither_stucki(image: Image.Image, weighted: bool = False) -> Image.Image: 1 2 4 2 1 All divided by 42. """ - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) height, width = img.shape[:2] palette = np.array(PALETTE_RGB, dtype=np.float64) find_fn = find_nearest_color_weighted if weighted else find_nearest_color @@ -172,7 +173,7 @@ def dither_stucki(image: Image.Image, weighted: bool = False) -> Image.Image: img[y + 2, x + dx] += error * w / 42 img = np.clip(img, 0, 255).astype(np.uint8) - return Image.fromarray(img, "RGB") + return Image.fromarray(img, 'RGB') def dither_atkinson(image: Image.Image, weighted: bool = False) -> Image.Image: @@ -185,7 +186,7 @@ def dither_atkinson(image: Image.Image, weighted: bool = False) -> Image.Image: 1 All divided by 8 (but only 6/8 total error diffused). """ - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) height, width = img.shape[:2] palette = np.array(PALETTE_RGB, dtype=np.float64) find_fn = find_nearest_color_weighted if weighted else find_nearest_color @@ -214,7 +215,7 @@ def dither_atkinson(image: Image.Image, weighted: bool = False) -> Image.Image: img[y + 2, x] += error / 8 img = np.clip(img, 0, 255).astype(np.uint8) - return Image.fromarray(img, "RGB") + return Image.fromarray(img, 'RGB') def dither_sierra(image: Image.Image, weighted: bool = False) -> Image.Image: @@ -227,7 +228,7 @@ def dither_sierra(image: Image.Image, weighted: bool = False) -> Image.Image: 2 3 2 All divided by 32. """ - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) height, width = img.shape[:2] palette = np.array(PALETTE_RGB, dtype=np.float64) find_fn = find_nearest_color_weighted if weighted else find_nearest_color @@ -255,7 +256,7 @@ def dither_sierra(image: Image.Image, weighted: bool = False) -> Image.Image: img[y + 2, x + dx] += error * w / 32 img = np.clip(img, 0, 255).astype(np.uint8) - return Image.fromarray(img, "RGB") + return Image.fromarray(img, 'RGB') def dither_sierra_lite(image: Image.Image, weighted: bool = False) -> Image.Image: @@ -266,7 +267,7 @@ def dither_sierra_lite(image: Image.Image, weighted: bool = False) -> Image.Imag 1 1 All divided by 4. """ - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) height, width = img.shape[:2] palette = np.array(PALETTE_RGB, dtype=np.float64) find_fn = find_nearest_color_weighted if weighted else find_nearest_color @@ -287,7 +288,7 @@ def dither_sierra_lite(image: Image.Image, weighted: bool = False) -> Image.Imag img[y + 1, x] += error * 1 / 4 img = np.clip(img, 0, 255).astype(np.uint8) - return Image.fromarray(img, "RGB") + return Image.fromarray(img, 'RGB') def dither_burkes(image: Image.Image, weighted: bool = False) -> Image.Image: @@ -298,7 +299,7 @@ def dither_burkes(image: Image.Image, weighted: bool = False) -> Image.Image: 2 4 8 4 2 All divided by 32. """ - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) height, width = img.shape[:2] palette = np.array(PALETTE_RGB, dtype=np.float64) find_fn = find_nearest_color_weighted if weighted else find_nearest_color @@ -321,21 +322,20 @@ def dither_burkes(image: Image.Image, weighted: bool = False) -> Image.Image: img[y + 1, x + dx] += error * w / 32 img = np.clip(img, 0, 255).astype(np.uint8) - return Image.fromarray(img, "RGB") + return Image.fromarray(img, 'RGB') # ============================================================================= # Ordered Dithering Algorithms # ============================================================================= - def get_bayer_matrix(n: int) -> np.ndarray: """Generate a Bayer matrix of size 2^n x 2^n.""" if n == 0: return np.array([[0]]) smaller = get_bayer_matrix(n - 1) size = 2 ** (n - 1) - result = np.zeros((2**n, 2**n)) + result = np.zeros((2 ** n, 2 ** n)) result[:size, :size] = 4 * smaller result[:size, size:] = 4 * smaller + 2 result[size:, :size] = 4 * smaller + 3 @@ -343,9 +343,7 @@ def get_bayer_matrix(n: int) -> np.ndarray: return result -def dither_ordered_bayer( - image: Image.Image, matrix_size: int = 4, strength: float = 1.0 -) -> Image.Image: +def dither_ordered_bayer(image: Image.Image, matrix_size: int = 4, strength: float = 1.0) -> Image.Image: """ Ordered dithering using Bayer matrix. @@ -354,7 +352,7 @@ def dither_ordered_bayer( matrix_size: Size of Bayer matrix (2, 4, 8, or 16) strength: Dithering strength multiplier (0.0-2.0) """ - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) height, width = img.shape[:2] palette = np.array(PALETTE_RGB, dtype=np.float64) @@ -364,7 +362,7 @@ def dither_ordered_bayer( bayer_size = bayer.shape[0] # Normalize Bayer matrix to -0.5 to 0.5 range, then scale - bayer_normalized = (bayer / (bayer_size**2) - 0.5) * strength * 128 + bayer_normalized = (bayer / (bayer_size ** 2) - 0.5) * strength * 128 result = np.zeros_like(img) @@ -376,23 +374,15 @@ def dither_ordered_bayer( _, new_pixel = find_nearest_color(adjusted_pixel, palette) result[y, x] = new_pixel - return Image.fromarray(result.astype(np.uint8), "RGB") + return Image.fromarray(result.astype(np.uint8), 'RGB') -_NUMBA_PALETTE = np.array( - [ - [0, 0, 0], - [255, 255, 255], - [255, 255, 0], - [255, 0, 0], - [0, 0, 255], - [0, 255, 0], - ], - dtype=np.float64, -) +_NUMBA_PALETTE = np.array([ + [0, 0, 0], [255, 255, 255], [255, 255, 0], + [255, 0, 0], [0, 0, 255], [0, 255, 0], +], dtype=np.float64) _NUMBA_WEIGHTS = np.array([0.299, 0.587, 0.114], dtype=np.float64) - @jit(nopython=True) def _numba_find_nearest(r, g, b, palette, weights): best_idx = 0 @@ -407,7 +397,6 @@ def _numba_find_nearest(r, g, b, palette, weights): best_idx = i return best_idx - @jit(nopython=True) def _numba_atkinson(img, palette, weights): height, width = img.shape[0], img.shape[1] @@ -446,34 +435,32 @@ def _numba_atkinson(img, palette, weights): img[y + 2, x, 2] += err_b return img - def dither_atkinson_numba(image: Image.Image) -> Image.Image: """Numba-accelerated Atkinson dithering with perceptual weighting (~150x faster).""" - img = np.array(image.convert("RGB"), dtype=np.float64) + img = np.array(image.convert('RGB'), dtype=np.float64) img = _numba_atkinson(img, _NUMBA_PALETTE, _NUMBA_WEIGHTS) img = np.clip(img, 0, 255).astype(np.uint8) - return Image.fromarray(img, "RGB") + return Image.fromarray(img, 'RGB') # ============================================================================= # PIL Built-in (for comparison) # ============================================================================= - def dither_pil_floyd_steinberg(image: Image.Image) -> Image.Image: """PIL's built-in Floyd-Steinberg dithering for comparison.""" pal_image = create_pil_palette_image() - img = image.convert("RGB") + img = image.convert('RGB') quantized = img.quantize(dither=Image.Dither.FLOYDSTEINBERG, palette=pal_image) - return quantized.convert("RGB") + return quantized.convert('RGB') def dither_pil_none(image: Image.Image) -> Image.Image: """PIL quantization with no dithering (nearest color only).""" pal_image = create_pil_palette_image() - img = image.convert("RGB") + img = image.convert('RGB') quantized = img.quantize(dither=Image.Dither.NONE, palette=pal_image) - return quantized.convert("RGB") + return quantized.convert('RGB') # ============================================================================= @@ -481,90 +468,90 @@ def dither_pil_none(image: Image.Image) -> Image.Image: # ============================================================================= DITHER_ALGORITHMS = { - "none": { - "name": "No Dithering (PIL)", - "func": dither_pil_none, - "description": "Simple nearest-color quantization without error diffusion", + 'none': { + 'name': 'No Dithering (PIL)', + 'func': dither_pil_none, + 'description': 'Simple nearest-color quantization without error diffusion', }, - "pil_fs": { - "name": "Floyd-Steinberg (PIL)", - "func": dither_pil_floyd_steinberg, - "description": "PIL built-in Floyd-Steinberg implementation", + 'pil_fs': { + 'name': 'Floyd-Steinberg (PIL)', + 'func': dither_pil_floyd_steinberg, + 'description': 'PIL built-in Floyd-Steinberg implementation', }, - "floyd_steinberg": { - "name": "Floyd-Steinberg", - "func": dither_floyd_steinberg, - "description": "Classic error diffusion (1976), good balance of speed and quality", + 'floyd_steinberg': { + 'name': 'Floyd-Steinberg', + 'func': dither_floyd_steinberg, + 'description': 'Classic error diffusion (1976), good balance of speed and quality', }, - "floyd_steinberg_weighted": { - "name": "Floyd-Steinberg (Weighted)", - "func": lambda img: dither_floyd_steinberg(img, weighted=True), - "description": "Floyd-Steinberg with perceptual color weighting", + 'floyd_steinberg_weighted': { + 'name': 'Floyd-Steinberg (Weighted)', + 'func': lambda img: dither_floyd_steinberg(img, weighted=True), + 'description': 'Floyd-Steinberg with perceptual color weighting', }, - "atkinson": { - "name": "Atkinson", - "func": dither_atkinson, - "description": "Bill Atkinson (Apple), diffuses only 75% of error for cleaner results", + 'atkinson': { + 'name': 'Atkinson', + 'func': dither_atkinson, + 'description': 'Bill Atkinson (Apple), diffuses only 75% of error for cleaner results', }, - "atkinson_weighted": { - "name": "Atkinson (Weighted)", - "func": lambda img: dither_atkinson(img, weighted=True), - "description": "Atkinson with perceptual color weighting", + 'atkinson_weighted': { + 'name': 'Atkinson (Weighted)', + 'func': lambda img: dither_atkinson(img, weighted=True), + 'description': 'Atkinson with perceptual color weighting', }, - "atkinson_fast": { - "name": "Atkinson (Numba Fast)", - "func": dither_atkinson_numba, - "description": "Numba-accelerated Atkinson (~150x faster, requires numba)", + 'atkinson_fast': { + 'name': 'Atkinson (Numba Fast)', + 'func': dither_atkinson_numba, + 'description': 'Numba-accelerated Atkinson (~150x faster, requires numba)', }, - "jarvis": { - "name": "Jarvis-Judice-Ninke", - "func": dither_jarvis_judice_ninke, - "description": "Larger diffusion kernel (1976), smoother gradients but slower", + 'jarvis': { + 'name': 'Jarvis-Judice-Ninke', + 'func': dither_jarvis_judice_ninke, + 'description': 'Larger diffusion kernel (1976), smoother gradients but slower', }, - "stucki": { - "name": "Stucki", - "func": dither_stucki, - "description": "Similar to JJN with modified weights (1981)", + 'stucki': { + 'name': 'Stucki', + 'func': dither_stucki, + 'description': 'Similar to JJN with modified weights (1981)', }, - "sierra": { - "name": "Sierra", - "func": dither_sierra, - "description": "Full Sierra dithering, balanced results", + 'sierra': { + 'name': 'Sierra', + 'func': dither_sierra, + 'description': 'Full Sierra dithering, balanced results', }, - "sierra_lite": { - "name": "Sierra Lite", - "func": dither_sierra_lite, - "description": "Faster Sierra variant with smaller kernel", + 'sierra_lite': { + 'name': 'Sierra Lite', + 'func': dither_sierra_lite, + 'description': 'Faster Sierra variant with smaller kernel', }, - "burkes": { - "name": "Burkes", - "func": dither_burkes, - "description": "Simplified two-row error diffusion", + 'burkes': { + 'name': 'Burkes', + 'func': dither_burkes, + 'description': 'Simplified two-row error diffusion', }, - "bayer2": { - "name": "Ordered (Bayer 2x2)", - "func": lambda img: dither_ordered_bayer(img, matrix_size=2), - "description": "Ordered dithering with 2x2 Bayer matrix", + 'bayer2': { + 'name': 'Ordered (Bayer 2x2)', + 'func': lambda img: dither_ordered_bayer(img, matrix_size=2), + 'description': 'Ordered dithering with 2x2 Bayer matrix', }, - "bayer4": { - "name": "Ordered (Bayer 4x4)", - "func": lambda img: dither_ordered_bayer(img, matrix_size=4), - "description": "Ordered dithering with 4x4 Bayer matrix", + 'bayer4': { + 'name': 'Ordered (Bayer 4x4)', + 'func': lambda img: dither_ordered_bayer(img, matrix_size=4), + 'description': 'Ordered dithering with 4x4 Bayer matrix', }, - "bayer8": { - "name": "Ordered (Bayer 8x8)", - "func": lambda img: dither_ordered_bayer(img, matrix_size=8), - "description": "Ordered dithering with 8x8 Bayer matrix", + 'bayer8': { + 'name': 'Ordered (Bayer 8x8)', + 'func': lambda img: dither_ordered_bayer(img, matrix_size=8), + 'description': 'Ordered dithering with 8x8 Bayer matrix', }, - "bayer4_strong": { - "name": "Ordered (Bayer 4x4 Strong)", - "func": lambda img: dither_ordered_bayer(img, matrix_size=4, strength=1.5), - "description": "Bayer 4x4 with increased dithering strength", + 'bayer4_strong': { + 'name': 'Ordered (Bayer 4x4 Strong)', + 'func': lambda img: dither_ordered_bayer(img, matrix_size=4, strength=1.5), + 'description': 'Bayer 4x4 with increased dithering strength', }, } -def get_algorithm_names() -> list[str]: +def get_algorithm_names() -> List[str]: """Return list of available algorithm names.""" return list(DITHER_ALGORITHMS.keys()) @@ -573,4 +560,4 @@ def apply_dithering(image: Image.Image, algorithm: str) -> Image.Image: """Apply the specified dithering algorithm to an image.""" if algorithm not in DITHER_ALGORITHMS: raise ValueError(f"Unknown algorithm: {algorithm}. Available: {get_algorithm_names()}") - return DITHER_ALGORITHMS[algorithm]["func"](image) + return DITHER_ALGORITHMS[algorithm]['func'](image) diff --git a/notebooks/_helpers.py b/notebooks/_helpers.py index eb85467..08458b0 100644 --- a/notebooks/_helpers.py +++ b/notebooks/_helpers.py @@ -13,9 +13,9 @@ import os import random import sys import tempfile -from collections.abc import Callable, Iterable from pathlib import Path from types import ModuleType +from typing import Callable, Iterable REPO = Path(__file__).resolve().parent.parent CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook" @@ -36,7 +36,6 @@ def bootstrap() -> None: def immich_client(): from immich import ImmichClient - return ImmichClient( os.environ.get("IMMICH_URL", DEFAULT_IMMICH_URL), os.environ.get("IMMICH_API_KEY", DEFAULT_IMMICH_API_KEY), @@ -51,13 +50,8 @@ def is_landscape(asset: dict) -> bool: return w > h > 0 -def fetch_pool( - client, - names: Iterable[str] = DEFAULT_PEOPLE, - pool_size: int = 500, - seed: int = 7, - filter_fn: Callable[[dict], bool] = is_landscape, -) -> list[dict]: +def fetch_pool(client, names: Iterable[str] = DEFAULT_PEOPLE, pool_size: int = 500, + seed: int = 7, filter_fn: Callable[[dict], bool] = is_landscape) -> list[dict]: person_ids = [pid for n in names if (pid := client.get_person_id(n))] if not person_ids: raise ValueError(f"no people found: {list(names)}") @@ -70,7 +64,6 @@ def fetch_pool( def download_image(client, asset: dict): """Download (cached) and open as PIL RGB Image.""" from PIL import Image - CACHE_DIR.mkdir(exist_ok=True) dest = CACHE_DIR / f"{asset['id']}.jpg" if not dest.exists(): @@ -85,21 +78,18 @@ def silenced(): yield -def show_grid( - rows: list[list], titles: list[list[str]], figsize_scale=(4.4, 3.0), suptitle: str | None = None -): +def show_grid(rows: list[list], titles: list[list[str]], figsize_scale=(4.4, 3.0), + suptitle: str | None = None): """Render a 2-D image grid with matplotlib. `rows` is list-of-lists of PIL/np images.""" import matplotlib.pyplot as plt - n_rows, n_cols = len(rows), max(len(r) for r in rows) - fig, axes = plt.subplots( - n_rows, n_cols, figsize=(figsize_scale[0] * n_cols, figsize_scale[1] * n_rows) - ) + fig, axes = plt.subplots(n_rows, n_cols, + figsize=(figsize_scale[0] * n_cols, figsize_scale[1] * n_rows)) if n_rows == 1: axes = [axes] if n_cols == 1 else [list(axes)] elif n_cols == 1: axes = [[ax] for ax in axes] - for i, (row, row_titles) in enumerate(zip(rows, titles, strict=True)): + for i, (row, row_titles) in enumerate(zip(rows, titles)): for j in range(n_cols): ax = axes[i][j] if j < len(row) and row[j] is not None: diff --git a/notebooks/crop_compare.ipynb b/notebooks/crop_compare.ipynb index 7b2e4c7..f1e7108 100644 --- a/notebooks/crop_compare.ipynb +++ b/notebooks/crop_compare.ipynb @@ -2,7 +2,6 @@ "cells": [ { "cell_type": "markdown", - "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "source": [ "# Face-aware crop vs. centre crop\n", @@ -26,42 +25,29 @@ { "cell_type": "code", "execution_count": 1, - "id": "acae54e37e7d407bbb7b55eff062a284", "metadata": {}, "outputs": [], "source": [ "import sys\n", - "\n", - "sys.path.insert(0, \".\")\n", - "from _helpers import (\n", - " CACHE_DIR,\n", - " bootstrap,\n", - " download_image,\n", - " fetch_pool,\n", - " immich_client,\n", - " show_grid,\n", - " silenced,\n", - ")\n", - "\n", + "sys.path.insert(0, '.')\n", + "from _helpers import bootstrap, immich_client, fetch_pool, download_image, silenced, show_grid, CACHE_DIR\n", "bootstrap()\n", "\n", "import json\n", "import math\n", - "\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", + "from waveshare_epd.epd7in3e import EPD_WIDTH, EPD_HEIGHT, _crop_center\n", + "from crop import face_aware_crop, HEAD_EXTENSION\n", "\n", - "POOL_SIZE = 80 # smaller than auto-tune notebook — each photo costs an extra API call for faces\n", + "POOL_SIZE = 80 # smaller than auto-tune notebook — each photo costs an extra API call for faces\n", "SEED = 7\n", - "N_PICKS = 4 # most-divergent picks (plus one no-faces baseline)" + "N_PICKS = 4 # most-divergent picks (plus one no-faces baseline)" ] }, { "cell_type": "code", "execution_count": 2, - "id": "9a63283cbaf04dbcab1f6479b197f3a8", "metadata": {}, "outputs": [ { @@ -76,28 +62,27 @@ "source": [ "client = immich_client()\n", "pool_assets = fetch_pool(client, pool_size=POOL_SIZE, seed=SEED)\n", - "print(f\"pool size: {len(pool_assets)} landscape photos\")\n", + "print(f'pool size: {len(pool_assets)} landscape photos')\n", "\n", "# Cache the face lookups — each is a separate /assets/{id} API call.\n", - "face_cache_path = CACHE_DIR / \"faces.json\"\n", + "face_cache_path = CACHE_DIR / 'faces.json'\n", "CACHE_DIR.mkdir(exist_ok=True)\n", "face_cache = json.loads(face_cache_path.read_text()) if face_cache_path.exists() else {}\n", "\n", "fetched_now = 0\n", "for asset in pool_assets:\n", - " if asset[\"id\"] in face_cache:\n", + " if asset['id'] in face_cache:\n", " continue\n", - " face_cache[asset[\"id\"]] = client.get_asset_faces(asset[\"id\"])\n", + " face_cache[asset['id']] = client.get_asset_faces(asset['id'])\n", " fetched_now += 1\n", "if fetched_now:\n", " face_cache_path.write_text(json.dumps(face_cache))\n", - "print(f\"face data: {fetched_now} fetched, {len(pool_assets) - fetched_now} cached\")" + "print(f'face data: {fetched_now} fetched, {len(pool_assets) - fetched_now} cached')" ] }, { "cell_type": "code", "execution_count": 3, - "id": "8dd0d8092fe74a7c96281538738b07e2", "metadata": {}, "outputs": [ { @@ -126,12 +111,10 @@ " if faces:\n", " boxes = []\n", " for f in faces:\n", - " sx = new_w / (f.get(\"imageWidth\") or img_w)\n", - " sy = new_h / (f.get(\"imageHeight\") or img_h)\n", - " x1 = f[\"boundingBoxX1\"] * sx\n", - " y1 = f[\"boundingBoxY1\"] * sy\n", - " x2 = f[\"boundingBoxX2\"] * sx\n", - " y2 = f[\"boundingBoxY2\"] * sy\n", + " sx = new_w / (f.get('imageWidth') or img_w)\n", + " sy = new_h / (f.get('imageHeight') or img_h)\n", + " x1 = f['boundingBoxX1'] * sx; y1 = f['boundingBoxY1'] * sy\n", + " x2 = f['boundingBoxX2'] * sx; y2 = f['boundingBoxY2'] * sy\n", " boxes.append((x1, y1, x2, y2, max(0.0, (x2 - x1) * (y2 - y1))))\n", " x_lo, x_hi = min(b[0] for b in boxes), max(b[2] for b in boxes)\n", " if x_hi - x_lo <= target_w:\n", @@ -146,53 +129,39 @@ " else:\n", " total = sum(b[4] for b in boxes) or 1.0\n", " cy = sum((b[1] + b[3]) / 2 * b[4] for b in boxes) / total\n", - " face_off = (\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", + " face_off = (max(0, min(int(cx - target_w / 2), new_w - target_w)),\n", + " max(0, min(int(cy - target_h / 2), new_h - target_h)))\n", " return centre_off, face_off, (new_w, new_h), boxes if faces else []\n", "\n", - "\n", "stats = []\n", "for asset in pool_assets:\n", - " exif = asset.get(\"exifInfo\") or {}\n", - " iw, ih = exif.get(\"exifImageWidth\") or 0, exif.get(\"exifImageHeight\") or 0\n", - " if exif.get(\"orientation\") in (6, 8, \"6\", \"8\"):\n", + " exif = asset.get('exifInfo') or {}\n", + " iw, ih = exif.get('exifImageWidth') or 0, exif.get('exifImageHeight') or 0\n", + " if exif.get('orientation') in (6, 8, '6', '8'):\n", " iw, ih = ih, iw\n", " if not (iw and ih):\n", " continue\n", - " faces = face_cache.get(asset[\"id\"], [])\n", + " faces = face_cache.get(asset['id'], [])\n", " centre_off, face_off, canvas, boxes = crop_offsets(iw, ih, EPD_WIDTH, EPD_HEIGHT, faces)\n", " dx = face_off[0] - centre_off[0]\n", " dy = face_off[1] - centre_off[1]\n", - " stats.append(\n", - " {\n", - " \"asset\": asset,\n", - " \"faces\": faces,\n", - " \"boxes\": boxes,\n", - " \"centre_off\": centre_off,\n", - " \"face_off\": face_off,\n", - " \"canvas\": canvas,\n", - " \"shift\": math.hypot(dx, dy),\n", - " \"dx\": dx,\n", - " \"dy\": dy,\n", - " }\n", - " )\n", + " stats.append({\n", + " 'asset': asset, 'faces': faces, 'boxes': boxes,\n", + " 'centre_off': centre_off, 'face_off': face_off, 'canvas': canvas,\n", + " 'shift': math.hypot(dx, dy), 'dx': dx, 'dy': dy,\n", + " })\n", "\n", - "with_faces = [s for s in stats if s[\"faces\"]]\n", - "no_faces = [s for s in stats if not s[\"faces\"]]\n", - "print(f\"{len(with_faces)} with faces, {len(no_faces)} without\")\n", - "shifts = np.array([s[\"shift\"] for s in with_faces])\n", + "with_faces = [s for s in stats if s['faces']]\n", + "no_faces = [s for s in stats if not s['faces']]\n", + "print(f'{len(with_faces)} with faces, {len(no_faces)} without')\n", + "shifts = np.array([s['shift'] for s in with_faces])\n", "if len(shifts):\n", - " print(\n", - " f\"crop shift (px): min={shifts.min():.0f} median={np.median(shifts):.0f} max={shifts.max():.0f}\"\n", - " )" + " print(f'crop shift (px): min={shifts.min():.0f} median={np.median(shifts):.0f} max={shifts.max():.0f}')" ] }, { "cell_type": "code", "execution_count": 4, - "id": "72eea5119410473aa328ad9291626812", "metadata": {}, "outputs": [ { @@ -208,23 +177,20 @@ } ], "source": [ - "with_faces.sort(key=lambda s: s[\"shift\"], reverse=True)\n", + "with_faces.sort(key=lambda s: s['shift'], reverse=True)\n", "picks = with_faces[:N_PICKS]\n", "if no_faces:\n", " picks.append(no_faces[0])\n", "\n", "print(f\"{'name':36s} {'faces':>5s} {'dx':>5s} {'dy':>5s} {'|shift|':>7s}\")\n", "for s in picks:\n", - " name = (s[\"asset\"].get(\"originalFileName\") or s[\"asset\"][\"id\"])[:36]\n", - " print(\n", - " f\"{name:36s} {len(s['faces']):>5d} {s['dx']:>+5.0f} {s['dy']:>+5.0f} {s['shift']:>7.0f}\"\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}\")" ] }, { "cell_type": "code", "execution_count": 5, - "id": "8edb47106e1a46a883d545849b8ab81b", "metadata": {}, "outputs": [ { @@ -261,16 +227,15 @@ " canvas = image.resize((new_w, new_h), Image.LANCZOS).copy()\n", " draw = ImageDraw.Draw(canvas)\n", " for f in faces:\n", - " sx = new_w / (f.get(\"imageWidth\") or iw)\n", - " sy = new_h / (f.get(\"imageHeight\") or ih)\n", - " x1, y1 = f[\"boundingBoxX1\"] * sx, f[\"boundingBoxY1\"] * sy\n", - " x2, y2 = f[\"boundingBoxX2\"] * sx, f[\"boundingBoxY2\"] * sy\n", + " sx = new_w / (f.get('imageWidth') or iw)\n", + " sy = new_h / (f.get('imageHeight') or ih)\n", + " x1, y1 = f['boundingBoxX1'] * sx, f['boundingBoxY1'] * sy\n", + " x2, y2 = f['boundingBoxX2'] * sx, f['boundingBoxY2'] * sy\n", " head_y1 = y1 - (y2 - y1) * HEAD_EXTENSION\n", " draw.rectangle([x1, head_y1, x2, y2], outline=(0, 220, 255), width=4)\n", " draw.rectangle([x1, y1, x2, y2], outline=(255, 220, 0), width=2)\n", " return canvas, (new_w, new_h)\n", "\n", - "\n", "def draw_crop_windows(canvas, centre_off, face_off, target=(EPD_WIDTH, EPD_HEIGHT)):\n", " out = canvas.copy()\n", " draw = ImageDraw.Draw(out)\n", @@ -280,32 +245,28 @@ " draw.rectangle([fx, fy, fx + target[0], fy + target[1]], outline=(0, 200, 0), width=4)\n", " return out\n", "\n", - "\n", "rows, titles = [], []\n", "for s in picks:\n", - " img = download_image(client, s[\"asset\"])\n", - " canvas, _ = draw_boxes_on_canvas(img, s[\"faces\"])\n", - " windowed = draw_crop_windows(canvas, s[\"centre_off\"], s[\"face_off\"])\n", + " img = download_image(client, s['asset'])\n", + " canvas, _ = draw_boxes_on_canvas(img, s['faces'])\n", + " windowed = draw_crop_windows(canvas, s['centre_off'], s['face_off'])\n", " centre = _crop_center(img, EPD_WIDTH, EPD_HEIGHT)\n", - " smart = face_aware_crop(img, EPD_WIDTH, EPD_HEIGHT, s[\"faces\"])\n", - " name = (s[\"asset\"].get(\"originalFileName\") or s[\"asset\"][\"id\"])[:24]\n", + " smart = face_aware_crop(img, EPD_WIDTH, EPD_HEIGHT, s['faces'])\n", + " name = (s['asset'].get('originalFileName') or s['asset']['id'])[:24]\n", " rows.append([canvas, centre, smart, windowed])\n", - " titles.append(\n", - " [\n", - " f\"{name}\\n{len(s['faces'])} face{'' if len(s['faces']) == 1 else 's'}\",\n", - " \"centre crop\",\n", - " f\"face-aware crop\\nshift: ({s['dx']:+.0f}, {s['dy']:+.0f}) px\",\n", - " \"crop windows on canvas\\norange = centre, green = face-aware\",\n", - " ]\n", - " )\n", + " titles.append([\n", + " f'{name}\\n{len(s[\"faces\"])} face{\"\" if len(s[\"faces\"]) == 1 else \"s\"}',\n", + " 'centre crop',\n", + " f'face-aware crop\\nshift: ({s[\"dx\"]:+.0f}, {s[\"dy\"]:+.0f}) px',\n", + " 'crop windows on canvas\\norange = centre, green = face-aware',\n", + " ])\n", "\n", "with silenced():\n", - " show_grid(rows, titles, figsize_scale=(5.0, 3.2))" + " show_grid(rows, titles, figsize_scale=(5.0, 3.2));" ] }, { "cell_type": "markdown", - "id": "10185d26023b46108eb7d9f57d49d2b3", "metadata": {}, "source": [ "## Reading the comparison\n", diff --git a/notebooks/dither_compare.ipynb b/notebooks/dither_compare.ipynb index 3849c52..69ffd3c 100644 --- a/notebooks/dither_compare.ipynb +++ b/notebooks/dither_compare.ipynb @@ -2,7 +2,6 @@ "cells": [ { "cell_type": "markdown", - "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "source": [ "# Dithering algorithm comparison\n", @@ -28,40 +27,26 @@ { "cell_type": "code", "execution_count": 1, - "id": "acae54e37e7d407bbb7b55eff062a284", "metadata": {}, "outputs": [], "source": [ "import sys\n", - "\n", - "sys.path.insert(0, \".\")\n", - "from _helpers import bootstrap, download_image, fetch_pool, immich_client, show_grid, silenced\n", - "\n", + "sys.path.insert(0, '.')\n", + "from _helpers import bootstrap, immich_client, fetch_pool, download_image, silenced, show_grid\n", "bootstrap()\n", "\n", "import time\n", - "\n", "import numpy as np\n", - "from _dither import DITHER_ALGORITHMS, PALETTE_NAMES, PALETTE_RGB, apply_dithering\n", "from PIL import Image\n", - "from waveshare_epd.epd7in3e import EPD_HEIGHT, EPD_WIDTH, _crop_center\n", + "from waveshare_epd.epd7in3e import EPD_WIDTH, EPD_HEIGHT, _crop_center\n", + "from _dither import DITHER_ALGORITHMS, apply_dithering, PALETTE_RGB, PALETTE_NAMES\n", "\n", "# Pure-Python algorithms run at ~30s per 800x480 image; keep a curated subset by default.\n", "# Toggle SHOW_ALL = True to run everything (will take several minutes).\n", - "DEFAULT_ALGOS = [\n", - " \"atkinson_fast\",\n", - " \"atkinson\",\n", - " \"atkinson_weighted\",\n", - " \"floyd_steinberg\",\n", - " \"floyd_steinberg_weighted\",\n", - " \"jarvis\",\n", - " \"sierra_lite\",\n", - " \"burkes\",\n", - " \"bayer4\",\n", - " \"bayer8\",\n", - " \"pil_fs\",\n", - " \"none\",\n", - "]\n", + "DEFAULT_ALGOS = ['atkinson_fast', 'atkinson', 'atkinson_weighted',\n", + " 'floyd_steinberg', 'floyd_steinberg_weighted',\n", + " 'jarvis', 'sierra_lite', 'burkes',\n", + " 'bayer4', 'bayer8', 'pil_fs', 'none']\n", "SHOW_ALL = False\n", "ALGOS = list(DITHER_ALGORITHMS.keys()) if SHOW_ALL else DEFAULT_ALGOS\n", "\n", @@ -72,7 +57,6 @@ { "cell_type": "code", "execution_count": 2, - "id": "9a63283cbaf04dbcab1f6479b197f3a8", "metadata": {}, "outputs": [ { @@ -107,21 +91,20 @@ " sources.append((asset, _crop_center(img, EPD_WIDTH, EPD_HEIGHT)))\n", "\n", "for asset, _ in sources:\n", - " print(asset.get(\"originalFileName\") or asset[\"id\"])\n", + " print(asset.get('originalFileName') or asset['id'])\n", "\n", "# Render the 6-colour palette as a tiny banner so the colour budget is visible.\n", "swatch_h = 60\n", "swatch_w = 60\n", "palette_strip = np.zeros((swatch_h, swatch_w * len(PALETTE_RGB), 3), dtype=np.uint8)\n", "for i, rgb in enumerate(PALETTE_RGB):\n", - " palette_strip[:, i * swatch_w : (i + 1) * swatch_w] = rgb\n", + " palette_strip[:, i * swatch_w:(i + 1) * swatch_w] = rgb\n", "Image.fromarray(palette_strip)" ] }, { "cell_type": "code", "execution_count": 3, - "id": "8dd0d8092fe74a7c96281538738b07e2", "metadata": {}, "outputs": [ { @@ -160,23 +143,22 @@ "source": [ "results = [] # list of dict per (photo, algo)\n", "for asset, source in sources:\n", - " name = asset.get(\"originalFileName\") or asset[\"id\"]\n", - " print(f\"[{name}]\")\n", + " name = asset.get('originalFileName') or asset['id']\n", + " print(f'[{name}]')\n", " photo_results = []\n", " for algo in ALGOS:\n", " info = DITHER_ALGORITHMS[algo]\n", " t0 = time.perf_counter()\n", " out = apply_dithering(source, algo)\n", " dt = time.perf_counter() - t0\n", - " photo_results.append({\"algo\": algo, \"name\": info[\"name\"], \"image\": out, \"duration\": dt})\n", - " print(f\" {info['name']:32s} {dt:6.2f}s\")\n", - " results.append({\"asset\": asset, \"source\": source, \"algos\": photo_results})" + " photo_results.append({'algo': algo, 'name': info['name'], 'image': out, 'duration': dt})\n", + " print(f' {info[\"name\"]:32s} {dt:6.2f}s')\n", + " results.append({'asset': asset, 'source': source, 'algos': photo_results})" ] }, { "cell_type": "code", "execution_count": 4, - "id": "72eea5119410473aa328ad9291626812", "metadata": {}, "outputs": [ { @@ -203,22 +185,21 @@ "source": [ "# One grid per photo: original + every algorithm.\n", "import matplotlib.pyplot as plt\n", - "\n", "for entry in results:\n", - " panels = [entry[\"source\"]] + [r[\"image\"] for r in entry[\"algos\"]]\n", - " titles = [\"original (cropped)\"] + [f\"{r['name']}\\n{r['duration']:.2f}s\" for r in entry[\"algos\"]]\n", + " panels = [entry['source']] + [r['image'] for r in entry['algos']]\n", + " titles = ['original (cropped)'] + [f\"{r['name']}\\n{r['duration']:.2f}s\" for r in entry['algos']]\n", " cols = 4\n", " rows = (len(panels) + cols - 1) // cols\n", " fig, axes = plt.subplots(rows, cols, figsize=(5.0 * cols, 3.2 * rows))\n", " axes = np.atleast_2d(axes)\n", - " name = entry[\"asset\"].get(\"originalFileName\") or entry[\"asset\"][\"id\"]\n", + " name = entry['asset'].get('originalFileName') or entry['asset']['id']\n", " fig.suptitle(name, fontsize=12)\n", " for k in range(rows * cols):\n", " ax = axes[k // cols][k % cols]\n", " if k < len(panels):\n", " ax.imshow(panels[k])\n", " ax.set_title(titles[k], fontsize=10)\n", - " ax.axis(\"off\")\n", + " ax.axis('off')\n", " plt.tight_layout()\n", " plt.show()" ] @@ -226,7 +207,6 @@ { "cell_type": "code", "execution_count": 5, - "id": "8edb47106e1a46a883d545849b8ab81b", "metadata": {}, "outputs": [ { @@ -252,13 +232,12 @@ "source": [ "# Per-algorithm summary across both photos: mean runtime + a single representative panel.\n", "from collections import defaultdict\n", - "\n", "import matplotlib.pyplot as plt\n", "\n", "agg = defaultdict(list)\n", "for entry in results:\n", - " for r in entry[\"algos\"]:\n", - " agg[r[\"algo\"]].append(r[\"duration\"])\n", + " for r in entry['algos']:\n", + " agg[r['algo']].append(r['duration'])\n", "\n", "print(f\"{'algorithm':32s} {'avg time':>9s} description\")\n", "for algo in ALGOS:\n", @@ -269,7 +248,6 @@ }, { "cell_type": "markdown", - "id": "10185d26023b46108eb7d9f57d49d2b3", "metadata": {}, "source": [ "## Picking an algorithm\n", diff --git a/pyproject.toml b/pyproject.toml index 7ecf868..3ab3ad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.11,<3.14" dependencies = [ "numpy>=1.26", "pillow>=10", + "opencv-python>=4.8", "numba>=0.60", ] @@ -15,36 +16,6 @@ notebook = [ "jupyterlab>=4.2", "ipykernel>=6.29", ] -dev = [ - "ruff>=0.8", -] [tool.uv] -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 +default-groups = ["notebook"] diff --git a/src/display.py b/src/display.py index 22ae920..98eedf2 100644 --- a/src/display.py +++ b/src/display.py @@ -9,11 +9,10 @@ from pathlib import Path from PIL import Image sys.path.append(str(Path(__file__).parent / "lib")) -from crop import face_aware_crop +from immich import ImmichClient, get_random_photo_of_people, get_random_photo_from_album 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 - +from crop import face_aware_crop # waveshare_epd is imported lazily after the lock — its epdconfig claims # GPIO pins at import time, so two overlapping invocations would both crash # on "GPIO busy" before reaching the flock below. @@ -22,33 +21,23 @@ IMMICH_URL = os.environ.get("IMMICH_URL", "https://immich.schmelczer.dev") IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "6crxVS1JLTJxsfGlzVhN2kefdL4EP7HPkkoMk9L6ZOE") 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"} def main() -> None: parser = argparse.ArgumentParser(description="Display image on e-ink frame") - parser.add_argument( - "--people", default="Me,Ruby", help="Comma-separated names for Immich search" - ) + parser.add_argument("--people", default="Me,Ruby", + help="Comma-separated names for Immich search") parser.add_argument("--album", help="Fetch from album (overrides --people)") - parser.add_argument( - "-o", - "--orientation", - type=int, - choices=[0, 90, 180, 270], - default=0, - help="Rotation in degrees", - ) + parser.add_argument("-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("--contrast", type=float, default=1.05) parser.add_argument("--gamma", type=float, default=0.90) args = parser.parse_args() - lock_fd = open("/tmp/frame.lock", "w") # noqa: SIM115 — held for process lifetime + lock_fd = open("/tmp/frame.lock", "w") try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: @@ -95,15 +84,9 @@ def main() -> None: img = face_aware_crop(img, target_w, target_h, faces) if args.orientation: img = img.rotate(args.orientation, expand=True) - buf = epd.getbuffer( - img, - saturation=args.saturation, - contrast=args.contrast, - gamma=args.gamma, - left_text=left_text, - right_text=right_text, - orientation=args.orientation, - ) + buf = epd.getbuffer(img, saturation=args.saturation, contrast=args.contrast, + gamma=args.gamma, left_text=left_text, right_text=right_text, + orientation=args.orientation) epd.display(buf) finally: epd.sleep() diff --git a/src/lib/crop.py b/src/lib/crop.py index 3515381..b954598 100644 --- a/src/lib/crop.py +++ b/src/lib/crop.py @@ -9,9 +9,8 @@ from PIL import Image HEAD_EXTENSION = 0.4 -def face_aware_crop( - image: Image.Image, target_w: int, target_h: int, faces: list[dict] -) -> Image.Image: +def face_aware_crop(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. Each face dict has imageWidth/imageHeight (the coord-space dims) and @@ -48,11 +47,17 @@ def face_aware_crop( x_lo = min(b[0] for b in boxes) x_hi = max(b[2] for b in boxes) - cx = (x_lo + x_hi) / 2 if x_hi - x_lo <= target_w else _weighted_center(boxes, 0, 2) + if x_hi - x_lo <= target_w: + 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_hi = max(b[3] for b in boxes) - cy = (y_lo_ext + y_hi) / 2 if y_hi - y_lo_ext <= target_h else _weighted_center(boxes, 1, 3) + if y_hi - y_lo_ext <= target_h: + 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)) y_off = max(0, min(int(cy - target_h / 2), new_h - target_h)) diff --git a/src/lib/immich.py b/src/lib/immich.py index f090801..2589463 100644 --- a/src/lib/immich.py +++ b/src/lib/immich.py @@ -4,7 +4,7 @@ import random import tempfile import time from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.request import Request @@ -35,25 +35,20 @@ def _load_history() -> tuple[set[str], datetime]: data = json.loads(HISTORY_FILE.read_text()) created_at = datetime.fromisoformat(data["created_at"]) if created_at.tzinfo is None: - created_at = created_at.replace(tzinfo=UTC) - if datetime.now(UTC) - created_at <= timedelta(days=7): + created_at = created_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) - created_at <= timedelta(days=7): return set(data.get("displayed", [])), created_at print("Photo history expired (>7 days), clearing...") except (FileNotFoundError, json.JSONDecodeError, ValueError, KeyError): pass - return set(), datetime.now(UTC) + return set(), datetime.now(timezone.utc) def _save_history(displayed: set[str], created_at: datetime) -> None: - HISTORY_FILE.write_text( - json.dumps( - { - "created_at": created_at.isoformat(), - "displayed": sorted(displayed), - }, - indent=2, - ) - ) + HISTORY_FILE.write_text(json.dumps({ + "created_at": created_at.isoformat(), + "displayed": sorted(displayed), + }, indent=2)) @dataclass @@ -90,17 +85,13 @@ class ImmichClient: items = [] page = 1 while True: - assets = self._request( - "POST", - "/search/metadata", - { - "personIds": person_ids, - "size": 250, - "page": page, - "type": "IMAGE", - "withExif": True, - }, - ).get("assets", {}) + assets = self._request("POST", "/search/metadata", { + "personIds": person_ids, + "size": 250, + "page": page, + "type": "IMAGE", + "withExif": True, + }).get("assets", {}) items.extend(assets.get("items", [])) if not assets.get("nextPage"): break @@ -144,9 +135,6 @@ class ImmichClient: return assets -_ROTATED_ORIENTATIONS = {5, 6, 7, 8, "5", "6", "7", "8"} - - def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]: """Keep assets matching the requested orientation. Skips assets without EXIF dimensions.""" out = [] @@ -156,20 +144,16 @@ def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]: h = exif.get("exifImageHeight") or 0 if not (w and h): continue - if exif.get("orientation") in _ROTATED_ORIENTATIONS: + if exif.get("orientation") in (6, 8, "6", "8"): w, h = h, w if (h > w) == portrait: out.append(a) return out -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. - - 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() +def _on_this_day_candidates(assets: list[dict]) -> list[dict]: + """Photos taken on today's month-day in past years, with a ±3-day fallback.""" + today = datetime.now(timezone.utc).date() dated = [] for a in assets: exif = a.get("exifInfo") or {} @@ -185,18 +169,18 @@ def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]: exact = [a for a, dt in dated if (dt.month, dt.day) == (today.month, today.day)] if exact: - return exact, True + return exact nearby_md = set() for offset in range(-3, 4): d = today + timedelta(days=offset) nearby_md.add((d.month, d.day)) - return [a for a, dt in dated if (dt.month, dt.day) in nearby_md], False + return [a for a, dt in dated if (dt.month, dt.day) in nearby_md] def _pick_weighted_random(assets: list[dict]) -> dict: """Pick random asset, biased towards on-this-day memories, favorites, and recents.""" - cutoff = datetime.now(UTC) - timedelta(days=30) + cutoff = datetime.now(timezone.utc) - timedelta(days=30) favorites = [a for a in assets if a.get("isFavorite")] recent = [] for a in assets: @@ -205,10 +189,10 @@ def _pick_weighted_random(assets: list[dict]) -> dict: recent.append(a) except (ValueError, AttributeError): pass - on_this_day, on_this_day_exact = _on_this_day_candidates(assets) + on_this_day = _on_this_day_candidates(assets) candidates = [ - ("on this day", on_this_day, 0.30 if on_this_day_exact else 0.10), + ("on this day", on_this_day, 0.10), ("favorites", favorites, 0.18), ("recent", recent, 0.36), ("all", assets, 0.36), @@ -220,9 +204,8 @@ def _pick_weighted_random(assets: list[dict]) -> dict: return random.choice(pool) -def _pick_and_download( - client: ImmichClient, assets: list[dict], orientation: int, source_label: str -) -> tuple[Path, dict]: +def _pick_and_download(client: ImmichClient, assets: list[dict], + orientation: int, source_label: str) -> tuple[Path, dict]: portrait = orientation in (90, 270) filtered = _filter_by_orientation(assets, portrait) if not filtered: @@ -244,9 +227,7 @@ def _pick_and_download( 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))] if not person_ids: raise ValueError(f"No people found: {names}") @@ -258,9 +239,7 @@ def get_random_photo_of_people( 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) if not album_id: raise ValueError(f"Album not found: {album_name}") diff --git a/src/lib/net.py b/src/lib/net.py index 080edb4..f067d88 100644 --- a/src/lib/net.py +++ b/src/lib/net.py @@ -9,8 +9,7 @@ def urlopen_with_retry(req: Request, timeout: int = 30): for delay in (3, 10, None): try: return urlopen(req, timeout=timeout) - except (URLError, TimeoutError) as e: + except (URLError, TimeoutError): if delay is None: raise - print(f"urlopen {req.full_url}: {e}; retry in {delay}s") time.sleep(delay) diff --git a/src/lib/overlay.py b/src/lib/overlay.py index 6062d04..3230230 100644 --- a/src/lib/overlay.py +++ b/src/lib/overlay.py @@ -5,7 +5,7 @@ array; black/white survive Atkinson dithering so edges stay crisp on e-ink. """ import os -from datetime import UTC, datetime +from datetime import datetime, timezone import numpy as np from PIL import Image, ImageDraw, ImageFont @@ -39,8 +39,8 @@ def format_age(asset: dict) -> str | None: except (ValueError, AttributeError): return None if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) - days = (datetime.now(UTC) - dt).days + dt = dt.replace(tzinfo=timezone.utc) + days = (datetime.now(timezone.utc) - dt).days if days < 0: return None if days == 0: @@ -49,13 +49,12 @@ def format_age(asset: dict) -> str | None: return "Yesterday" if days < 7: return f"{days} days ago" - for n, unit in ((12 * 30, "year"), (30, "month"), (7, "week")): + for n, unit in ((365, "year"), (30, "month"), (7, "week")): if days >= n: count = max(1, days // n) if count == 1: - return f"A {unit} ago" + return f"Last {unit}" return f"{count} {unit}s ago" - return None def format_location(asset: dict) -> str | None: @@ -64,9 +63,10 @@ def format_location(asset: dict) -> str | None: return exif.get("city") or exif.get("state") or exif.get("country") or None -def render_text_into_indices( - indices: np.ndarray, left_text: str | None, right_text: str | None, orientation: int = 0 -) -> None: +def render_text_into_indices(indices: np.ndarray, + left_text: str | None, + right_text: str | None, + orientation: int = 0) -> None: """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` @@ -89,28 +89,14 @@ def render_text_into_indices( if left_text: pos = (margin, baseline) fill_draw.text(pos, left_text, font=font, fill=255, anchor="lb") - full_draw.text( - pos, - left_text, - font=font, - fill=255, - anchor="lb", - stroke_width=stroke_width, - stroke_fill=255, - ) + full_draw.text(pos, left_text, font=font, fill=255, anchor="lb", + stroke_width=stroke_width, stroke_fill=255) if right_text: pos = (view_w - margin, baseline) fill_draw.text(pos, right_text, font=font, fill=255, anchor="rb") - full_draw.text( - pos, - right_text, - font=font, - fill=255, - anchor="rb", - stroke_width=stroke_width, - stroke_fill=255, - ) + full_draw.text(pos, right_text, font=font, fill=255, anchor="rb", + stroke_width=stroke_width, stroke_fill=255) if orientation: fill_layer = fill_layer.rotate(orientation, expand=True) diff --git a/src/lib/progress.py b/src/lib/progress.py index bf18988..98f7002 100644 --- a/src/lib/progress.py +++ b/src/lib/progress.py @@ -1,14 +1,11 @@ """Simple terminal progress bar for e-ink frame.""" -import sys - class ProgressBar: def __init__(self, total: int, desc: str = ""): self.total = total self.desc = desc self._last_percent = -1 - self._is_tty = sys.stdout.isatty() def set(self, value: int) -> None: if self.total == 0: @@ -17,16 +14,10 @@ class ProgressBar: percent = int(100 * value / self.total) if percent == self._last_percent: 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 + 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 "" - 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) + print(f"\r{prefix}|{bar}| {percent:3d}%", end=end, flush=True) diff --git a/src/lib/waveshare_epd/epd7in3e.py b/src/lib/waveshare_epd/epd7in3e.py index 0d3ae69..85d61a1 100644 --- a/src/lib/waveshare_epd/epd7in3e.py +++ b/src/lib/waveshare_epd/epd7in3e.py @@ -2,9 +2,9 @@ # Waveshare 7.3" 6-color e-Paper driver (modified) import numpy as np +import cv2 from PIL import Image, ImageEnhance from numba import jit -from crop import face_aware_crop from progress import ProgressBar from overlay import render_text_into_indices from . import epdconfig @@ -13,13 +13,13 @@ EPD_WIDTH = 800 EPD_HEIGHT = 480 # 6-color e-ink encoding: indices 0,1,2,3,5,6 are wire-format colors; -# 4 is reserved/unused — _find_nearest_color skips it explicitly. +# 4 is reserved/unused (filled with BLACK so nearest-color never picks it). PALETTE_RGB = np.array([ [0, 0, 0], # 0: BLACK [255, 255, 255], # 1: WHITE [255, 255, 0], # 2: YELLOW [255, 0, 0], # 3: RED - [0, 0, 0], # 4: unused (skipped) + [0, 0, 0], # 4: unused [0, 0, 255], # 5: BLUE [0, 255, 0], # 6: GREEN ], dtype=np.float64) @@ -56,12 +56,28 @@ def _enhance_for_eink(image: Image.Image, saturation: float, 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) def _find_nearest_color(r, g, b, palette, weights): best_idx, best_dist = 0, 1e10 for i in range(palette.shape[0]): - if i == 4: # reserved palette slot - continue dr = (palette[i, 0] - r) * weights[0] dg = (palette[i, 1] - g) * weights[1] db = (palette[i, 2] - b) * weights[2] @@ -117,8 +133,6 @@ def _dither_atkinson(image: Image.Image) -> np.ndarray: print("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 for i in range((height + chunk_size - 1) // chunk_size): start, end = i * chunk_size, min((i + 1) * chunk_size, height) @@ -194,7 +208,7 @@ class EPD: image = image.convert('RGB') if image.size != (self.width, self.height): print(f"Input: {image.size[0]}x{image.size[1]} → {self.width}x{self.height}") - image = face_aware_crop(image, self.width, self.height, []) + image = _crop_center(image, self.width, self.height) print("Enhancing...") image = _enhance_for_eink(image, saturation, contrast, gamma) @@ -207,7 +221,7 @@ class EPD: print("Packing buffer...") flat = indices.reshape(-1) - return bytes((flat[0::2] << 4) | flat[1::2]) + return ((flat[0::2].astype(np.uint8) << 4) | flat[1::2].astype(np.uint8)).tolist() def display(self, image): self.send_command(0x10) diff --git a/src/lib/waveshare_epd/epdconfig.py b/src/lib/waveshare_epd/epdconfig.py index 7e6a8bc..d3f5465 100644 --- a/src/lib/waveshare_epd/epdconfig.py +++ b/src/lib/waveshare_epd/epdconfig.py @@ -87,13 +87,13 @@ class RaspberryPi: if pin == self.BUSY_PIN: return self.GPIO_BUSY_PIN.value elif pin == self.RST_PIN: - return self.GPIO_RST_PIN.value + return self.RST_PIN.value elif pin == self.DC_PIN: - return self.GPIO_DC_PIN.value + return self.DC_PIN.value # elif pin == self.CS_PIN: - # return self.GPIO_CS_PIN.value + # return self.CS_PIN.value elif pin == self.PWR_PIN: - return self.GPIO_PWR_PIN.value + return self.PWR_PIN.value def delay_ms(self, delaytime): time.sleep(delaytime / 1000.0) @@ -134,7 +134,7 @@ class RaspberryPi: self.DEV_SPI = CDLL(so_filename) break if self.DEV_SPI is None: - raise RuntimeError('Cannot find DEV_Config.so') + RuntimeError('Cannot find DEV_Config.so') self.DEV_SPI.DEV_Module_Init() diff --git a/uv.lock b/uv.lock index 0946d69..cc6bbbb 100644 --- a/uv.lock +++ b/uv.lock @@ -443,13 +443,11 @@ source = { virtual = "." } dependencies = [ { name = "numba" }, { name = "numpy" }, + { name = "opencv-python" }, { name = "pillow" }, ] [package.dev-dependencies] -dev = [ - { name = "ruff" }, -] notebook = [ { name = "ipykernel" }, { name = "jupyterlab" }, @@ -460,11 +458,11 @@ notebook = [ requires-dist = [ { name = "numba", specifier = ">=0.60" }, { name = "numpy", specifier = ">=1.26" }, + { name = "opencv-python", specifier = ">=4.8" }, { name = "pillow", specifier = ">=10" }, ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.8" }] notebook = [ { name = "ipykernel", specifier = ">=6.29" }, { name = "jupyterlab", specifier = ">=4.2" }, @@ -1210,6 +1208,24 @@ 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" }, ] +[[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]] name = "overrides" version = "7.7.0" @@ -1680,31 +1696,6 @@ 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" }, ] -[[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]] name = "send2trash" version = "2.1.0"