From eed1567f7f9b3526763e44b96e2cf1227545a695 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 3 May 2026 10:39:31 +0100 Subject: [PATCH] Lint --- .forgejo/workflows/lint.yml | 24 ++++ lint.sh | 14 ++ notebooks/_dither.py | 227 +++++++++++++++++---------------- notebooks/_helpers.py | 26 ++-- notebooks/crop_compare.ipynb | 143 +++++++++++++-------- notebooks/dither_compare.ipynb | 64 +++++++--- pyproject.toml | 32 ++++- src/display.py | 41 ++++-- src/lib/crop.py | 15 +-- src/lib/immich.py | 56 +++++--- src/lib/overlay.py | 35 +++-- uv.lock | 29 +++++ 12 files changed, 463 insertions(+), 243 deletions(-) create mode 100644 .forgejo/workflows/lint.yml create mode 100755 lint.sh diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml new file mode 100644 index 0000000..0a4cfcc --- /dev/null +++ b/.forgejo/workflows/lint.yml @@ -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 diff --git a/lint.sh b/lint.sh new file mode 100755 index 0000000..f88511a --- /dev/null +++ b/lint.sh @@ -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 diff --git a/notebooks/_dither.py b/notebooks/_dither.py index 8424673..20a1c9a 100644 --- a/notebooks/_dither.py +++ b/notebooks/_dither.py @@ -6,22 +6,20 @@ for comparison testing. """ import numpy as np -from PIL import Image -from typing import Tuple, List - from numba import jit +from PIL import Image # 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: @@ -36,20 +34,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] @@ -58,6 +56,7 @@ 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). @@ -66,7 +65,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 @@ -88,7 +87,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: @@ -101,7 +100,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 @@ -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 = 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: @@ -145,7 +144,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 @@ -173,7 +172,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: @@ -186,7 +185,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 @@ -215,7 +214,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: @@ -228,7 +227,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 @@ -256,7 +255,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: @@ -267,7 +266,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 @@ -288,7 +287,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: @@ -299,7 +298,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 @@ -322,20 +321,21 @@ 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,7 +343,9 @@ 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. @@ -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) 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) @@ -362,7 +364,7 @@ def dither_ordered_bayer(image: Image.Image, matrix_size: int = 4, strength: flo 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) @@ -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) 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 @@ -397,6 +407,7 @@ 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] @@ -435,32 +446,34 @@ 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") # ============================================================================= @@ -468,90 +481,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()) @@ -560,4 +573,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 fb32df4..5b5cf2a 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,6 +36,7 @@ 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), @@ -50,8 +51,13 @@ 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)}") @@ -64,6 +70,7 @@ def fetch_pool(client, names: Iterable[str] = DEFAULT_PEOPLE, pool_size: int = 5 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(): @@ -78,18 +85,21 @@ 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)): + for i, (row, row_titles) in enumerate(zip(rows, titles, strict=True)): 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 f1e7108..7b2e4c7 100644 --- a/notebooks/crop_compare.ipynb +++ b/notebooks/crop_compare.ipynb @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "source": [ "# Face-aware crop vs. centre crop\n", @@ -25,29 +26,42 @@ { "cell_type": "code", "execution_count": 1, + "id": "acae54e37e7d407bbb7b55eff062a284", "metadata": {}, "outputs": [], "source": [ "import sys\n", - "sys.path.insert(0, '.')\n", - "from _helpers import bootstrap, immich_client, fetch_pool, download_image, silenced, show_grid, CACHE_DIR\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", "bootstrap()\n", "\n", "import json\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", - "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", - "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": [ { @@ -62,27 +76,28 @@ "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": [ { @@ -111,10 +126,12 @@ " 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; y1 = f['boundingBoxY1'] * sy\n", - " x2 = f['boundingBoxX2'] * sx; 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\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", " 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", @@ -129,39 +146,53 @@ " 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 = (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", + " 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", " 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", - " '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", + " 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", "\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(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", "execution_count": 4, + "id": "72eea5119410473aa328ad9291626812", "metadata": {}, "outputs": [ { @@ -177,20 +208,23 @@ } ], "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(f\"{name:36s} {len(s['faces']):>5d} {s['dx']:>+5.0f} {s['dy']:>+5.0f} {s['shift']:>7.0f}\")" + " 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", + " )" ] }, { "cell_type": "code", "execution_count": 5, + "id": "8edb47106e1a46a883d545849b8ab81b", "metadata": {}, "outputs": [ { @@ -227,15 +261,16 @@ " 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", @@ -245,28 +280,32 @@ " 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", - " 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", + " 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", "\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 69ffd3c..3849c52 100644 --- a/notebooks/dither_compare.ipynb +++ b/notebooks/dither_compare.ipynb @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "source": [ "# Dithering algorithm comparison\n", @@ -27,26 +28,40 @@ { "cell_type": "code", "execution_count": 1, + "id": "acae54e37e7d407bbb7b55eff062a284", "metadata": {}, "outputs": [], "source": [ "import sys\n", - "sys.path.insert(0, '.')\n", - "from _helpers import bootstrap, immich_client, fetch_pool, download_image, silenced, show_grid\n", + "\n", + "sys.path.insert(0, \".\")\n", + "from _helpers import bootstrap, download_image, fetch_pool, immich_client, show_grid, silenced\n", + "\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_WIDTH, EPD_HEIGHT, _crop_center\n", - "from _dither import DITHER_ALGORITHMS, apply_dithering, PALETTE_RGB, PALETTE_NAMES\n", + "from waveshare_epd.epd7in3e import EPD_HEIGHT, EPD_WIDTH, _crop_center\n", "\n", "# Pure-Python algorithms run at ~30s per 800x480 image; keep a curated subset by default.\n", "# Toggle SHOW_ALL = True to run everything (will take several minutes).\n", - "DEFAULT_ALGOS = ['atkinson_fast', 'atkinson', 'atkinson_weighted',\n", - " 'floyd_steinberg', 'floyd_steinberg_weighted',\n", - " 'jarvis', 'sierra_lite', 'burkes',\n", - " 'bayer4', 'bayer8', 'pil_fs', 'none']\n", + "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", "SHOW_ALL = False\n", "ALGOS = list(DITHER_ALGORITHMS.keys()) if SHOW_ALL else DEFAULT_ALGOS\n", "\n", @@ -57,6 +72,7 @@ { "cell_type": "code", "execution_count": 2, + "id": "9a63283cbaf04dbcab1f6479b197f3a8", "metadata": {}, "outputs": [ { @@ -91,20 +107,21 @@ " 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": [ { @@ -143,22 +160,23 @@ "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": [ { @@ -185,21 +203,22 @@ "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()" ] @@ -207,6 +226,7 @@ { "cell_type": "code", "execution_count": 5, + "id": "8edb47106e1a46a883d545849b8ab81b", "metadata": {}, "outputs": [ { @@ -232,12 +252,13 @@ "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", @@ -248,6 +269,7 @@ }, { "cell_type": "markdown", + "id": "10185d26023b46108eb7d9f57d49d2b3", "metadata": {}, "source": [ "## Picking an algorithm\n", diff --git a/pyproject.toml b/pyproject.toml index 3ab3ad2..d2bd06c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,36 @@ notebook = [ "jupyterlab>=4.2", "ipykernel>=6.29", ] +dev = [ + "ruff>=0.8", +] [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 diff --git a/src/display.py b/src/display.py index 1975b0f..5bdc8ce 100644 --- a/src/display.py +++ b/src/display.py @@ -9,10 +9,11 @@ from pathlib import Path from PIL import Image 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 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 # GPIO pins at import time, so two overlapping invocations would both crash # on "GPIO busy" before reaching the flock below. @@ -21,23 +22,33 @@ IMMICH_URL = os.environ.get("IMMICH_URL", "https://immich.example.com") IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "REDACTED_IMMICH_API_KEY") HA_URL = os.environ.get("HA_URL", "https://homeassistant.example.com") -HA_TOKEN = os.environ.get("HA_TOKEN", "REDACTED_HA_TOKEN") +HA_TOKEN = os.environ.get( + "HA_TOKEN", + "REDACTED_HA_TOKEN", +) HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"} 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") + lock_fd = open("/tmp/frame.lock", "w") # noqa: SIM115 — held for process lifetime try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: @@ -84,9 +95,15 @@ 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 b954598..3515381 100644 --- a/src/lib/crop.py +++ b/src/lib/crop.py @@ -9,8 +9,9 @@ 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 @@ -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_hi = max(b[2] for b in boxes) - if x_hi - x_lo <= target_w: - cx = (x_lo + x_hi) / 2 - else: - cx = _weighted_center(boxes, 0, 2) + cx = (x_lo + x_hi) / 2 if x_hi - x_lo <= target_w else _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) - if y_hi - y_lo_ext <= target_h: - cy = (y_lo_ext + y_hi) / 2 - else: - cy = _weighted_center(boxes, 1, 3) + cy = (y_lo_ext + y_hi) / 2 if y_hi - y_lo_ext <= target_h else _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 e7954b9..b9e2669 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 datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from urllib.request import Request @@ -35,20 +35,25 @@ 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=timezone.utc) - if datetime.now(timezone.utc) - created_at <= timedelta(days=7): + created_at = created_at.replace(tzinfo=UTC) + if datetime.now(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(timezone.utc) + return set(), datetime.now(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 @@ -85,13 +90,17 @@ 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 @@ -157,7 +166,7 @@ def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]: Returns (candidates, is_exact). `is_exact` is True when same-month-day matches exist; callers use it to weight the pool higher than the looser ±3-day fallback. """ - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() dated = [] for a in assets: exif = a.get("exifInfo") or {} @@ -184,7 +193,7 @@ def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]: def _pick_weighted_random(assets: list[dict]) -> dict: """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")] recent = [] for a in assets: @@ -208,8 +217,9 @@ 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: @@ -231,7 +241,9 @@ def _pick_and_download(client: ImmichClient, assets: list[dict], 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}") @@ -243,7 +255,9 @@ def get_random_photo_of_people(client: ImmichClient, names: list[str], orientati return _pick_and_download(client, assets, orientation, f"photos for {', '.join(names)}") -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/overlay.py b/src/lib/overlay.py index c678da3..9800f95 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 datetime, timezone +from datetime import UTC, datetime 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=timezone.utc) - days = (datetime.now(timezone.utc) - dt).days + dt = dt.replace(tzinfo=UTC) + days = (datetime.now(UTC) - dt).days if days < 0: return None if days == 0: @@ -63,10 +63,9 @@ def format_location(asset: dict) -> str | None: return exif.get("city") or exif.get("state") or exif.get("country") or None -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,14 +88,28 @@ def render_text_into_indices(indices: np.ndarray, 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/uv.lock b/uv.lock index cc6bbbb..9684f8e 100644 --- a/uv.lock +++ b/uv.lock @@ -448,6 +448,9 @@ dependencies = [ ] [package.dev-dependencies] +dev = [ + { name = "ruff" }, +] notebook = [ { name = "ipykernel" }, { name = "jupyterlab" }, @@ -463,6 +466,7 @@ requires-dist = [ ] [package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.8" }] notebook = [ { name = "ipykernel", specifier = ">=6.29" }, { name = "jupyterlab", specifier = ">=4.2" }, @@ -1696,6 +1700,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[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"