Render notebooks
11
.env.example
|
|
@ -2,3 +2,14 @@ IMMICH_URL=https://immich.example.com
|
|||
IMMICH_API_KEY=your-immich-api-key
|
||||
HA_URL=https://homeassistant.example.com
|
||||
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/
|
||||
|
|
|
|||
68
CLAUDE.md
|
|
@ -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 midnight–7am)
|
||||
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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""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
|
||||
imported without trying to claim GPIO pins.
|
||||
"""
|
||||
|
|
@ -9,17 +9,15 @@ from __future__ import annotations
|
|||
|
||||
import contextlib
|
||||
import io
|
||||
import random
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook"
|
||||
|
||||
DEFAULT_PEOPLE = ("Me", "Ruby")
|
||||
PHOTOS_DIR = REPO / "photos"
|
||||
FACES_FILE = PHOTOS_DIR / "faces.json"
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
|
|
@ -29,51 +27,69 @@ def bootstrap() -> None:
|
|||
if sp not in sys.path:
|
||||
sys.path.insert(0, sp)
|
||||
sys.modules.setdefault("waveshare_epd.epdconfig", ModuleType("waveshare_epd.epdconfig"))
|
||||
from env import load_env
|
||||
|
||||
load_env()
|
||||
|
||||
|
||||
def immich_client():
|
||||
from env import require
|
||||
from immich import ImmichClient
|
||||
def center_crop(image, target_w: int, target_h: int):
|
||||
"""Resize-to-cover then centre-crop. Used as the baseline in crop_compare."""
|
||||
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
|
||||
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
dest = CACHE_DIR / f"{asset['id']}.jpg"
|
||||
if not dest.exists():
|
||||
client.download_asset(asset["id"], dest)
|
||||
return Image.open(dest).convert("RGB")
|
||||
iw, ih = image.size
|
||||
if iw / ih < target_w / target_h:
|
||||
new_w, new_h = target_w, math.ceil(target_w * ih / iw)
|
||||
else:
|
||||
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
|
||||
|
|
@ -81,30 +97,3 @@ def silenced():
|
|||
"""Suppress the production code's print() chatter during batch loops."""
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
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
|
||||
|
|
|
|||
BIN
photos/dither_compare_hiker_in_mountains.png
Normal file
|
After Width: | Height: | Size: 485 KiB |
BIN
photos/dither_compare_leopard_on_road.png
Normal file
|
After Width: | Height: | Size: 674 KiB |
41
photos/faces.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
photos/hiker_in_mountains.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
photos/leopard_on_road.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
photos/man_on_cliff.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
photos/man_with_dog.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
photos/mother_and_daughter.jpg
Normal file
|
After Width: | Height: | Size: 162 KiB |
5
sync.sh
|
|
@ -1,2 +1,5 @@
|
|||
#!/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"
|
||||
|
|
|
|||