Compare commits

...

8 commits

Author SHA1 Message Date
d0e1c476db Add setup docs
All checks were successful
lint / ruff (push) Successful in 1m0s
2026-05-04 22:44:44 +01:00
a7b477da08 Update images 2026-05-04 22:36:42 +01:00
cbee345d93 Improve cropping with rule of thirds 2026-05-04 18:19:52 +01:00
5890237449 Print immich url 2026-05-04 18:18:57 +01:00
a7cd888778 Fix sync 2026-05-04 18:15:52 +01:00
c3dd5c28c7 Render notebooks 2026-05-04 18:14:36 +01:00
89129177a3 Add readme draft 2026-05-04 13:23:19 +01:00
8b09d612e1 Take photo 2026-05-04 10:23:26 +01:00
23 changed files with 680 additions and 607 deletions

View file

@ -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

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

102
README.md
View file

@ -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.

View file

@ -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

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: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 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
}
]
}

BIN
photos/frame.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

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

46
setup.md Normal file
View 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
View 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/

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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"