Compare commits
8 commits
90b692da39
...
d0e1c476db
| Author | SHA1 | Date | |
|---|---|---|---|
| d0e1c476db | |||
| a7b477da08 | |||
| cbee345d93 | |||
| 5890237449 | |||
| a7cd888778 | |||
| c3dd5c28c7 | |||
| 89129177a3 | |||
| 8b09d612e1 |
|
|
@ -1,4 +0,0 @@
|
|||
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
|
||||
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
|
||||
102
README.md
|
|
@ -1,33 +1,83 @@
|
|||
## Installation
|
||||
# Frame
|
||||
|
||||
sudo raspi-config
|
||||
- Enable SPI
|
||||
A small e-ink photo frame for our home. It pulls from our self-hosted [Immich](https://immich.app/) library, checks the self-hosted [Home Assistant](https://www.home-assistant.io/) to see if anyone is home, and shows a photo on the [PhotoPainter](https://www.waveshare.com/wiki/PhotoPainter) (Waveshare 7.3" 6-colour panel hooked upto a Raspberry Pi Zero 2W) for everyone to enjoy..
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt upgrade
|
||||
sudo apt install -y python3-pip python3-pil python3-smbus python3-numba
|
||||
sudo swapoff -a
|
||||
sudo systemctl mask swap.target
|
||||
sudo systemctl disable --now bluetooth
|
||||
<p align="center"> <img src="photos/frame.jpg" alt="The frame showing a dithered landscape photo with a small overlay reading '2 years ago' and 'Palmeiras'" width="420"></p>
|
||||
|
||||
sudo nmcli c modify netplan-wlan0-HiddenPlace 802-11-wireless.powersave 2
|
||||
```
|
||||
## Why
|
||||
|
||||
Execute `sudo crontab -e` and add
|
||||
```
|
||||
@reboot /usr/sbin/iw wlan0 set power_save off
|
||||
*/5 * * * * /home/andras/frame/wifi-check.sh >> /home/andras/wifi.log 2>&1
|
||||
```
|
||||
Most digital frames either require you to pick and preprocess photos that you put on an SD card or they want to talk to a cloud service like Google Photos. Realistically, you're not going to update the photos more than once a year on the SD card. As for cloud providers, I'd rather not give them access to my cherised memories or let them be held hostage.
|
||||
|
||||
Execute `crontab -e` and add
|
||||
```
|
||||
*/15 * * * * cd ~/frame && python3 display.py -o 90 >> ~/frame.log 2>&1
|
||||
```
|
||||
It's magical to come home from a day out to immediatly see photos take from the day, or see memories from years ago pop-up within the living space, unconfined by an app on your phone.
|
||||
|
||||
|
||||
Reduce journald writes
|
||||
Edit /etc/systemd/journald.conf:
|
||||
```
|
||||
Storage=volatile
|
||||
```
|
||||
It was a fun afternoon project with Claude Code, a bit of experimenting with different ditherhing and post-processing, and then fine tuning the photo picking algorithm. Besides this repo powering my frame, I share it as a reference and to provide inspiration for self-hosting proving that the emergence of these services can produce something that wouldn't have been possible to just hack together in an afternoon with Claude Code otherwise.
|
||||
|
||||
## How it works
|
||||
|
||||
`src/display.py` runs every 15 minutes triggered by cron. Each run:
|
||||
|
||||
1. Quits if it's between midnight and 7am.
|
||||
2. Asks Home Assistant whether anyone in `HA_PRESENCE` is home. If not, quits to preserve power and not not strain the e-ink unnecessarily.
|
||||
3. Picks a random photo from Immich, weighted toward "on this day" memories, favourites, and recent uploads (see `src/lib/immich.py`).
|
||||
4. Crops around any detected faces, applies post-processing to boost contrast and saturation (which are both lacking in e-ink displays), dithers down to the 6-colour palette, and puches it to the panel.
|
||||
|
||||
## Image pipeline
|
||||
|
||||
The two choices that matter most are `face_aware_crop` and Atkinson dithering.
|
||||
`face_aware_crop` resize-crops to fill the frame, then nudges the crop toward
|
||||
Immich face boxes instead of blindly centering. Atkinson dithering maps the
|
||||
photo into the Waveshare 6-colour ACeP palette with a good quality/speed tradeoff
|
||||
on the Pi by relying on numa for compiling the array oprations.. The dither showcases below are nearest-neighbour scaled and quantized
|
||||
back to the display palette, so the preview does not invent blended colours.
|
||||
|
||||
<p align="center">
|
||||
<img src="photos/crop_compare_landscape.png" alt="Crop comparison showing original photos with face boxes, naive centre crops, and face-aware crops for a landscape frame target" width="760">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="photos/crop_compare_portrait.png" alt="Crop comparison showing original photos with face boxes, naive centre crops, and face-aware crops for a portrait frame target" width="760">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="photos/dither_compare_beach_crowd.png" alt="Palette-preserving dither comparison showing several 6-colour algorithms on a beach crowd photo" width="760">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="photos/dither_compare_hiker_in_mountains.png" alt="Palette-preserving dither comparison showing several 6-colour algorithms on a mountain hiker photo" width="760">
|
||||
</p>
|
||||
|
||||
## Setup
|
||||
|
||||
Copy `.env.example` to `.env` and fill it in:
|
||||
|
||||
| Variable | Purpose || ---------------- | ------------------------------------------------------------------ || `IMMICH_URL` | Base URL of your Immich server || `IMMICH_API_KEY` | Immich API key (Account Settings, API Keys) || `HA_URL` | Base URL of your Home Assistant instance || `HA_TOKEN` | Home Assistant long-lived access token || `HA_PRESENCE` | Comma-separated entity IDs. Any `home` state triggers a render || `IMMICH_PEOPLE` | Default people for `--people` (must match Immich person names) || `SYNC_TARGET` | rsync target for `./sync.sh`, e.g. `pi@192.168.0.81:~/frame/` |
|
||||
|
||||
`.env` is gitignored.
|
||||
|
||||
Follow [setup](./setup.md) for the full setup.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
```shpython3 src/display.py # uses IMMICH_PEOPLEpython3 src/display.py --album "Holiday 2025"python3 src/display.py --people "Alice,Bob"python3 src/display.py -o 90 # portraitpython3 src/display.py --saturation 1.5 --contrast 1.1 --gamma 0.85```
|
||||
|
||||
`display.py` only runs on the Pi (it needs SPI). For off-device experimentssee the notebooks below.
|
||||
|
||||
## Notebooks
|
||||
|
||||
Iterating on the crop and dither pipelines on the Pi is painfully slow (eachcycle is a 12 second refresh), so I do that work in Jupyter against a smalllocal photo pool. Run them with:
|
||||
|
||||
```shuv run jupyter lab notebooks/```
|
||||
|
||||
- [`notebooks/crop_compare.ipynb`](notebooks/crop_compare.ipynb): face-aware crop vs. plain centre crop, side-by-side on the photos where they disagree the most. This is what I used to tune `face_aware_crop` in `src/lib/crop.py`.- [`notebooks/dither_compare.ipynb`](notebooks/dither_compare.ipynb): a handful of error-diffusion and ordered-dithering algorithms against the 6-colour ACeP palette, with timing. Atkinson dithering won on a quality vs. speed trade-off, which is what `src/lib/waveshare_epd/epd7in3e.py` ships.
|
||||
|
||||
|
||||
## Learnings
|
||||
|
||||
|
||||
|
||||
Honestly, the Pi Zero 2W is overkill for this. It chews through battery if youtry to run untethered, and most of the time it's just sitting idle waiting forthe next cron tick. If I were doing this again for a battery-powered build I'dprobably reach for an ESP32 with deep sleep. Mine stays plugged in, so I haven'tbothered.
|
||||
|
||||
I would also give [Inky Impresion](https://shop.pimoroni.com/products/inky-impression?variant=55186435244411) a try with a custom made frame for a larger display and perhaps integrated lights as the e-ink looks a muddled in the in evening lights.
|
||||
|
||||
|
|
|
|||
|
|
@ -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/crop_compare_landscape.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
photos/crop_compare_portrait.png
Normal file
|
After Width: | Height: | Size: 3 MiB |
BIN
photos/dither_compare_hiker_in_mountains.png
Normal file
|
After Width: | Height: | Size: 489 KiB |
BIN
photos/dither_compare_leopard_on_road.png
Normal file
|
After Width: | Height: | Size: 680 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/frame.jpg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
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 |
46
setup.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Pi setup
|
||||
|
||||
Flash an image using the [Raspberry Pi imager](https://www.raspberrypi.com/software/), I picked the Raspberry Pi OS Lite (based on debian trixie) and set up WiFi & SSH from the Customisation settings.
|
||||
|
||||
## First commands
|
||||
|
||||
```sh
|
||||
sudo raspi-config # Then select: Interface Options -> SPI -> Enable
|
||||
|
||||
sudo apt update && sudo apt upgrade
|
||||
sudo apt install -y python3-pip python3-pil python3-smbus python3-numba
|
||||
|
||||
sudo systemctl mask swap.target
|
||||
sudo systemctl disable --now bluetooth
|
||||
sudo nmcli c modify <your-connection> 802-11-wireless.powersave 2
|
||||
```
|
||||
|
||||
Reduce SD card writes by setting `Storage=volatile` in `/etc/systemd/journald.conf`.
|
||||
|
||||
## Deploying
|
||||
|
||||
Ensure [.env](src/.env) has the correct paths and then run:
|
||||
|
||||
`./sync.sh`
|
||||
|
||||
> That rsyncs `src/` over.
|
||||
|
||||
## Setting up cron
|
||||
|
||||
In `crontab -e`, add:
|
||||
|
||||
```
|
||||
*/15 * * * * cd ~/frame && python3 display.py -o 90 >> ~/frame.log 2>&1
|
||||
```
|
||||
|
||||
### Optionally, to monitor the wifi connection:
|
||||
|
||||
In `sudo crontab -e`, add:
|
||||
|
||||
```
|
||||
@reboot /usr/sbin/iw wlan0 set power_save off*/5 * * * * /home/<user>/frame/wifi-check.sh >> /home/<user>/wifi.log 2>&1
|
||||
```
|
||||
|
||||
## Hardware notes
|
||||
|
||||
The driver in `src/lib/waveshare_epd/` is adapted from Waveshare's [PhotoPainter](https://www.waveshare.com/wiki/PhotoPainter) project. That wikipage has the wiring, init sequence, and 6-colour palette docs.
|
||||
15
src/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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/
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
|
@ -14,22 +15,28 @@ 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.
|
||||
# waveshare_epd is imported lazily only when a render will actually happen.
|
||||
# Its epdconfig claims GPIO pins at import time, so skip paths should not touch it.
|
||||
|
||||
load_env()
|
||||
IMMICH_URL = require("IMMICH_URL")
|
||||
IMMICH_API_KEY = require("IMMICH_API_KEY")
|
||||
HA_URL = require("HA_URL")
|
||||
HA_TOKEN = require("HA_TOKEN")
|
||||
HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"}
|
||||
|
||||
def _parse_presence(spec: str) -> list[str]:
|
||||
"""Parse comma-separated Home Assistant entity IDs."""
|
||||
return [entity_id.strip() for entity_id in spec.split(",") if entity_id.strip()]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_env()
|
||||
immich_url = require("IMMICH_URL")
|
||||
immich_api_key = require("IMMICH_API_KEY")
|
||||
ha_url = require("HA_URL")
|
||||
ha_token = require("HA_TOKEN")
|
||||
ha_presence = _parse_presence(require("HA_PRESENCE"))
|
||||
|
||||
parser = argparse.ArgumentParser(description="Display image on e-ink frame")
|
||||
parser.add_argument(
|
||||
"--people", default="Me,Ruby", help="Comma-separated names for Immich search"
|
||||
"--people",
|
||||
default=os.environ.get("IMMICH_PEOPLE", ""),
|
||||
help="Comma-separated names for Immich search (default from IMMICH_PEOPLE env var)",
|
||||
)
|
||||
parser.add_argument("--album", help="Fetch from album (overrides --people)")
|
||||
parser.add_argument(
|
||||
|
|
@ -52,36 +59,40 @@ def main() -> None:
|
|||
print("Another instance running, skipping")
|
||||
sys.exit(0)
|
||||
|
||||
from waveshare_epd import epd7in3e
|
||||
|
||||
now = datetime.now()
|
||||
print(f"Time: {now.strftime('%H:%M')}")
|
||||
if now.hour < 7:
|
||||
print("Night time, skipping")
|
||||
sys.exit(0)
|
||||
|
||||
ha = HomeAssistantClient(HA_URL, HA_TOKEN)
|
||||
home = [name for name, eid in HA_PRESENCE.items() if ha.is_person_home(eid)]
|
||||
ha = HomeAssistantClient(ha_url, ha_token)
|
||||
home = [entity_id for entity_id in ha_presence if ha.is_person_home(entity_id)]
|
||||
if not home:
|
||||
print("No one home, skipping")
|
||||
sys.exit(0)
|
||||
print(f"Home: {', '.join(home)}")
|
||||
|
||||
client = ImmichClient(IMMICH_URL, IMMICH_API_KEY)
|
||||
client = ImmichClient(immich_url, immich_api_key)
|
||||
if args.album:
|
||||
image_path, asset = get_random_photo_from_album(client, args.album, args.orientation)
|
||||
print(f"Album: {args.album}")
|
||||
else:
|
||||
names = [n.strip() for n in args.people.split(",")]
|
||||
names = [n.strip() for n in args.people.split(",") if n.strip()]
|
||||
if not names:
|
||||
sys.exit("Specify --people or --album, or set IMMICH_PEOPLE in .env")
|
||||
image_path, asset = get_random_photo_of_people(client, names, args.orientation)
|
||||
print(f"People: {', '.join(names)}")
|
||||
|
||||
asset_id = asset.get("id")
|
||||
print(f"Immich image: {client.base_url}/photos/{asset_id}")
|
||||
left_text = format_age(asset)
|
||||
right_text = format_location(asset)
|
||||
if left_text or right_text:
|
||||
print(f"Overlay: {left_text or '-'} | {right_text or '-'}")
|
||||
|
||||
try:
|
||||
from waveshare_epd import epd7in3e
|
||||
|
||||
epd = epd7in3e.EPD()
|
||||
try:
|
||||
epd.init()
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
"""Resize-to-cover then crop, biased toward Immich-detected face boxes."""
|
||||
"""Resize-to-cover with face-aware positioning.
|
||||
|
||||
When a portrait source is cropped onto a landscape target, the face joint-span
|
||||
centre lands on the top third of the crop window instead of the middle, so the
|
||||
eyes sit on the upper-third line where landscape composition naturally reads.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# Face boxes end at the hairline; extend each box upward by this fraction of
|
||||
# its own height so the fit-check considers the head, not just the face.
|
||||
# its own height so the joint-span midpoint lands closer to the eyes than the
|
||||
# bare face centre.
|
||||
HEAD_EXTENSION = 0.4
|
||||
|
||||
|
||||
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.
|
||||
"""Resize to cover (target_w, target_h), then crop biased toward face boxes.
|
||||
|
||||
Each face dict has imageWidth/imageHeight (the coord-space dims) and
|
||||
boundingBoxX1/Y1/X2/Y2. Per axis: if every (head-extended) face fits in
|
||||
the crop we centre on the joint span so all faces are included with hair
|
||||
clearance on top. If the span doesn't fit, we fall back to the
|
||||
area-weighted centroid of the unextended boxes — that biases toward the
|
||||
biggest, presumably foreground, face. Plain center crop when no faces.
|
||||
Joint-span midpoint of the head-extended boxes sets the crop centre. For
|
||||
portrait sources rendered on a landscape target, the centre is placed at
|
||||
the top third of the crop window (rule of thirds) instead of the middle.
|
||||
Plain centre crop when no faces.
|
||||
"""
|
||||
img_w, img_h = image.size
|
||||
img_aspect = img_w / img_h
|
||||
|
|
@ -43,22 +47,13 @@ def face_aware_crop(
|
|||
y1 = f["boundingBoxY1"] * sy
|
||||
x2 = f["boundingBoxX2"] * sx
|
||||
y2 = f["boundingBoxY2"] * sy
|
||||
area = max(0.0, (x2 - x1) * (y2 - y1))
|
||||
boxes.append((x1, y1, x2, y2, area))
|
||||
|
||||
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)
|
||||
|
||||
boxes.append((x1, y1, x2, y2))
|
||||
cx = (min(b[0] for b in boxes) + max(b[2] for b in boxes)) / 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)
|
||||
cy = (y_lo_ext + y_hi) / 2
|
||||
|
||||
y_anchor = target_h / 3 if img_h > img_w and target_w > target_h else target_h / 2
|
||||
x_off = max(0, min(int(cx - target_w / 2), new_w - target_w))
|
||||
y_off = max(0, min(int(cy - target_h / 2), new_h - target_h))
|
||||
y_off = max(0, min(int(cy - y_anchor), new_h - target_h))
|
||||
return resized.crop((x_off, y_off, x_off + target_w, y_off + target_h))
|
||||
|
||||
|
||||
def _weighted_center(boxes: list[tuple], lo: int, hi: int) -> float:
|
||||
total = sum(b[4] for b in boxes) or 1.0
|
||||
return sum((b[lo] + b[hi]) / 2 * b[4] for b in boxes) / total
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ from net import urlopen_with_retry
|
|||
HISTORY_FILE = Path(__file__).parent.parent / "photo_history.json"
|
||||
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_cache"
|
||||
|
||||
# Soft preference for picking photos whose orientation matches the frame.
|
||||
# Mismatched-orientation photos still appear, just less often, since
|
||||
# face_aware_crop handles them via the rule-of-thirds composition.
|
||||
ORIENTATION_MATCH_WEIGHT = 0.8
|
||||
ORIENTATION_DIFFER_WEIGHT = 0.2
|
||||
|
||||
_ROTATED_EXIF_ORIENTATIONS = {5, 6, 7, 8, "5", "6", "7", "8"}
|
||||
|
||||
|
||||
def _cache_get(key: str) -> list[dict] | None:
|
||||
path = CACHE_DIR / f"{key}.json"
|
||||
|
|
@ -144,23 +152,36 @@ class ImmichClient:
|
|||
return assets
|
||||
|
||||
|
||||
_ROTATED_ORIENTATIONS = {5, 6, 7, 8, "5", "6", "7", "8"}
|
||||
def _is_portrait(asset: dict) -> bool | None:
|
||||
"""True if the asset's pixel orientation is portrait, None if EXIF dims are missing."""
|
||||
exif = asset.get("exifInfo") or {}
|
||||
w, h = exif.get("exifImageWidth") or 0, exif.get("exifImageHeight") or 0
|
||||
if not (w and h):
|
||||
return None
|
||||
if exif.get("orientation") in _ROTATED_EXIF_ORIENTATIONS:
|
||||
w, h = h, w
|
||||
return h > w
|
||||
|
||||
|
||||
def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]:
|
||||
"""Keep assets matching the requested orientation. Skips assets without EXIF dimensions."""
|
||||
out = []
|
||||
for a in assets:
|
||||
exif = a.get("exifInfo") or {}
|
||||
w = exif.get("exifImageWidth") or 0
|
||||
h = exif.get("exifImageHeight") or 0
|
||||
if not (w and h):
|
||||
continue
|
||||
if exif.get("orientation") in _ROTATED_ORIENTATIONS:
|
||||
w, h = h, w
|
||||
if (h > w) == portrait:
|
||||
out.append(a)
|
||||
return out
|
||||
def _bias_by_orientation(candidates: list[dict], frame_portrait: bool) -> list[dict]:
|
||||
"""Pick the matching or differing-orientation pool per the configured weights."""
|
||||
matching, differing = [], []
|
||||
for a in candidates:
|
||||
is_p = _is_portrait(a)
|
||||
# Unknown orientation defaults to "matching" — better to include than to drop.
|
||||
if is_p is None or is_p == frame_portrait:
|
||||
matching.append(a)
|
||||
else:
|
||||
differing.append(a)
|
||||
if not differing:
|
||||
return matching
|
||||
if not matching:
|
||||
return differing
|
||||
pools = [(matching, ORIENTATION_MATCH_WEIGHT), (differing, ORIENTATION_DIFFER_WEIGHT)]
|
||||
pool, _ = random.choices(pools, weights=[w for _, w in pools])[0]
|
||||
chosen = "matching" if pool is matching else "differing"
|
||||
print(f"Orientation: {len(matching)} matching, {len(differing)} differing, picked {chosen}")
|
||||
return pool
|
||||
|
||||
|
||||
def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]:
|
||||
|
|
@ -223,21 +244,22 @@ def _pick_weighted_random(assets: list[dict]) -> 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:
|
||||
raise ValueError(f"No {'portrait' if portrait else 'landscape'} photos in {source_label}")
|
||||
if not assets:
|
||||
raise ValueError(f"No photos in {source_label}")
|
||||
|
||||
displayed, created_at = _load_history()
|
||||
candidates = [a for a in filtered if a.get("id") not in displayed]
|
||||
candidates = [a for a in assets if a.get("id") not in displayed]
|
||||
if not candidates:
|
||||
print(f"All {len(filtered)} photos shown, picking from full list")
|
||||
candidates = filtered
|
||||
print(f"All {len(assets)} photos shown, picking from full list")
|
||||
candidates = assets
|
||||
else:
|
||||
print(f"Photos: {len(candidates)} new / {len(filtered)} total")
|
||||
print(f"Photos: {len(candidates)} new / {len(assets)} total")
|
||||
|
||||
candidates = _bias_by_orientation(candidates, orientation in (90, 270))
|
||||
|
||||
asset = _pick_weighted_random(candidates)
|
||||
dest = Path(tempfile.gettempdir()) / "immich_photo.jpg"
|
||||
with tempfile.NamedTemporaryFile(prefix="immich_photo_", suffix=".jpg", delete=False) as tmp:
|
||||
dest = Path(tmp.name)
|
||||
path = client.download_asset(asset["id"], dest)
|
||||
displayed.add(asset["id"])
|
||||
_save_history(displayed, created_at)
|
||||
|
|
|
|||
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 && . "src/.env" && set +a
|
||||
: "${SYNC_TARGET:?SYNC_TARGET must be set in .env (e.g. pi@192.168.0.81:~/frame/)}"
|
||||
rsync -avz --progress src/ "$SYNC_TARGET"
|
||||
|
|
|
|||