Render notebooks

This commit is contained in:
Andras Schmelczer 2026-05-04 18:14:36 +01:00
parent 89129177a3
commit c3dd5c28c7
14 changed files with 439 additions and 524 deletions

View file

@ -2,3 +2,14 @@ IMMICH_URL=https://immich.example.com
IMMICH_API_KEY=your-immich-api-key IMMICH_API_KEY=your-immich-api-key
HA_URL=https://homeassistant.example.com HA_URL=https://homeassistant.example.com
HA_TOKEN=your-home-assistant-long-lived-token HA_TOKEN=your-home-assistant-long-lived-token
# Comma-separated Home Assistant entity IDs. The frame only displays when at
# least one of these entities is "home".
HA_PRESENCE=person.alice,person.bob
# Default people for Immich search when --people / --album are not passed.
# Names must match person names in your Immich library.
IMMICH_PEOPLE=Alice,Bob
# rsync target for sync.sh (user@host:path)
SYNC_TARGET=pi@192.168.0.81:/home/pi/frame/

View file

@ -1,68 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Is
An e-ink photo frame that runs on a Raspberry Pi Zero 2W. It fetches photos from an Immich server, checks Home Assistant for presence (only displays when someone is home), and renders them on a Waveshare 7.3" 6-color e-Paper display (800x480, ACeP technology with Black/White/Yellow/Red/Blue/Green).
## Deployment
```bash
./sync.sh # rsync src/ to andras@192.168.0.81:~/frame/
```
On the Pi:
```bash
cd ~/frame
python3 display.py # default: photos of Me,Ruby
python3 display.py --album "Album Name" # from specific album
python3 display.py -o 90 # portrait mode (90° or 270°)
python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85
```
## Architecture
**`src/display.py`** — Entry point. Orchestrates the pipeline:
1. Checks time (skips between midnight7am)
2. Checks Home Assistant presence (skips if nobody home)
3. Fetches a random photo from Immich (by people or album)
4. Sends to e-ink display driver
**`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.
- 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
- `urlopen` calls retry transient failures twice (3s, 10s backoff)
**`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
- 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`
**`src/lib/waveshare_epd/epdconfig.py`** — GPIO/SPI hardware config. **Critical: PWR pin is BCM 27** (not default 18).
**`src/lib/progress.py`** — Simple terminal progress bar.
**`notebooks/`** — Off-Pi observable comparisons covering pipeline stages. Run via the
uv-managed env (`uv run jupyter lab notebooks/...`). The notebooks share `_helpers.py`
(bootstrap, Immich client, pool fetch, image cache) and `_dither.py` (migrated from the
former `dither_test/`):
- `crop_compare.ipynb` — face-aware crop vs. centre crop on the most-divergent picks
- `dither_compare.ipynb` — error-diffusion + ordered dithering algorithms with timing
## Key Constraints
- **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`
- **Config via `.env`** (gitignored): `IMMICH_URL`, `IMMICH_API_KEY`, `HA_URL`, `HA_TOKEN`. Loaded by `src/lib/env.py` (stdlib-only); `require(key)` raises if a value is missing. Copy `.env.example` to `.env` and fill in. The Pi keeps its own `~/frame/.env``sync.sh` excludes it.
- **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
- `sys.path.append` is used to add `lib/` to the path from display.py

View file

@ -1,6 +1,6 @@
"""Shared helpers for the frame project notebooks. """Shared helpers for the frame project notebooks.
Each notebook should call `bootstrap()` first it puts `src/lib/` on the import Each notebook should call `bootstrap()` first. It puts `src/lib/` on the import
path and stubs `waveshare_epd.epdconfig` so the production helpers can be path and stubs `waveshare_epd.epdconfig` so the production helpers can be
imported without trying to claim GPIO pins. imported without trying to claim GPIO pins.
""" """
@ -9,17 +9,15 @@ from __future__ import annotations
import contextlib import contextlib
import io import io
import random import json
import sys import sys
import tempfile from collections.abc import Callable
from collections.abc import Callable, Iterable
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook" PHOTOS_DIR = REPO / "photos"
FACES_FILE = PHOTOS_DIR / "faces.json"
DEFAULT_PEOPLE = ("Me", "Ruby")
def bootstrap() -> None: def bootstrap() -> None:
@ -29,51 +27,69 @@ def bootstrap() -> None:
if sp not in sys.path: if sp not in sys.path:
sys.path.insert(0, sp) sys.path.insert(0, sp)
sys.modules.setdefault("waveshare_epd.epdconfig", ModuleType("waveshare_epd.epdconfig")) sys.modules.setdefault("waveshare_epd.epdconfig", ModuleType("waveshare_epd.epdconfig"))
from env import load_env
load_env()
def immich_client(): def center_crop(image, target_w: int, target_h: int):
from env import require """Resize-to-cover then centre-crop. Used as the baseline in crop_compare."""
from immich import ImmichClient import math
return ImmichClient(require("IMMICH_URL"), require("IMMICH_API_KEY"))
def is_landscape(asset: dict) -> bool:
exif = asset.get("exifInfo") or {}
w, h = exif.get("exifImageWidth") or 0, exif.get("exifImageHeight") or 0
if exif.get("orientation") in (6, 8, "6", "8"):
w, h = h, w
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]:
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)}")
assets = client.search_assets_by_people(person_ids)
filtered = [a for a in assets if filter_fn(a)]
rng = random.Random(seed)
return rng.sample(filtered, min(pool_size, len(filtered)))
def download_image(client, asset: dict):
"""Download (cached) and open as PIL RGB Image."""
from PIL import Image from PIL import Image
CACHE_DIR.mkdir(exist_ok=True) iw, ih = image.size
dest = CACHE_DIR / f"{asset['id']}.jpg" if iw / ih < target_w / target_h:
if not dest.exists(): new_w, new_h = target_w, math.ceil(target_w * ih / iw)
client.download_asset(asset["id"], dest) else:
return Image.open(dest).convert("RGB") new_w, new_h = math.ceil(target_h * iw / ih), target_h
resized = image.resize((new_w, new_h), Image.LANCZOS)
x = max(0, (new_w - target_w) // 2)
y = max(0, (new_h - target_h) // 2)
return resized.crop((x, y, x + target_w, y + target_h))
def local_pool(
filter_fn: Callable[[dict], bool] | None = None,
) -> list[dict]:
"""Pool of bundled CC-licensed photos under photos/.
Face boxes from photos/faces.json are attached as `_faces` when present.
"""
from PIL import Image
faces_by_filename = {}
if FACES_FILE.exists():
raw = json.loads(FACES_FILE.read_text())
faces_by_filename = {k: v for k, v in raw.items() if not k.startswith("_")}
out = []
for path in sorted(PHOTOS_DIR.glob("*.jpg")):
if path.name == "frame.jpg":
continue
with Image.open(path) as im:
w, h = im.size
out.append(
{
"id": path.stem,
"originalFileName": path.name,
"_local_path": str(path),
"_faces": faces_by_filename.get(path.name, []),
"exifInfo": {"exifImageWidth": w, "exifImageHeight": h},
}
)
if filter_fn is not None:
out = [a for a in out if filter_fn(a)]
return out
def image_faces(asset: dict) -> list[dict]:
"""Face boxes attached to a bundled photo asset."""
return asset.get("_faces", [])
def open_image(asset: dict):
"""Open a bundled photo asset as PIL RGB."""
from PIL import Image
return Image.open(asset["_local_path"]).convert("RGB")
@contextlib.contextmanager @contextlib.contextmanager
@ -81,30 +97,3 @@ def silenced():
"""Suppress the production code's print() chatter during batch loops.""" """Suppress the production code's print() chatter during batch loops."""
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
yield yield
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)
)
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 j in range(n_cols):
ax = axes[i][j]
if j < len(row) and row[j] is not None:
ax.imshow(row[j])
ax.set_title(row_titles[j], fontsize=10)
ax.axis("off")
if suptitle:
fig.suptitle(suptitle, fontsize=12)
plt.tight_layout()
return fig

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

41
photos/faces.json Normal file
View file

@ -0,0 +1,41 @@
{
"_comment": "Hand-eyeballed face boxes for crop_compare.ipynb. Coordinates are in the original image's pixel space; imageWidth/imageHeight match the on-disk file dimensions. Same shape as Immich's per-asset face data.",
"man_on_cliff.jpg": [
{
"imageWidth": 1600,
"imageHeight": 1067,
"boundingBoxX1": 380,
"boundingBoxY1": 410,
"boundingBoxX2": 510,
"boundingBoxY2": 510
}
],
"man_with_dog.jpg": [
{
"imageWidth": 1600,
"imageHeight": 1067,
"boundingBoxX1": 1080,
"boundingBoxY1": 200,
"boundingBoxX2": 1320,
"boundingBoxY2": 450
}
],
"mother_and_daughter.jpg": [
{
"imageWidth": 800,
"imageHeight": 1200,
"boundingBoxX1": 160,
"boundingBoxY1": 240,
"boundingBoxX2": 380,
"boundingBoxY2": 510
},
{
"imageWidth": 800,
"imageHeight": 1200,
"boundingBoxX1": 380,
"boundingBoxY1": 320,
"boundingBoxX2": 580,
"boundingBoxY2": 510
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

BIN
photos/leopard_on_road.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

BIN
photos/man_on_cliff.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
photos/man_with_dog.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View file

@ -1,2 +1,5 @@
#!/bin/bash #!/bin/bash
rsync -avz --progress --exclude=.env src/ andras@192.168.0.81:~/frame/ set -euo pipefail
set -a && . ".env" && set +a
: "${SYNC_TARGET:?SYNC_TARGET must be set in .env (e.g. pi@192.168.0.81:~/frame/)}"
rsync -avz --progress --exclude=.env src/ "$SYNC_TARGET"