# Frame A small e-ink photo frame for our home. It pulls photos from a self-hosted [Immich](https://immich.app/) library, checks a self-hosted [Home Assistant](https://www.home-assistant.io/) instance to see whether anyone is home, and shows a photo on the [PhotoPainter](https://www.waveshare.com/wiki/PhotoPainter) (a Waveshare 7.3" 6-colour panel driven by a Raspberry Pi Zero 2W) for everyone to enjoy.

The bottom corners show the photo's capture age and EXIF location.
## Why Most digital frames either make you hand-pick and preprocess photos onto an SD card, or they insist on talking to a cloud service like Google Photos. Realistically, that SD card gets refreshed once a year at best. As for cloud providers, I'd rather not hand them my most cherished memories or risk having those held hostage. Coming home to a photo from earlier that day, or one from five years ago, hits differently when it's hanging on the wall instead of buried in your phone. This was a fun afternoon project with Claude Code, followed by a bit of experimenting with different dithering and post-processing options, and then fine-tuning the photo-picking algorithm. Besides powering my frame, I'm sharing this repo as a reference and as self-hosting inspiration: combining these services made it possible to hack together something that would otherwise have been far harder to build. ## How it works `src/display.py` runs every 15 minutes, triggered by cron. Each run: 1. Exits if the time is between midnight and 7 am. 2. Asks Home Assistant whether anyone in `HA_PRESENCE` is home. If nobody is, it exits to save power and to spare the e-ink panel unnecessary refreshes. 3. Picks a random photo from Immich. The pool is weighted: ~25% "on this day" memories, ~15% favourites, ~30% from the last 30 days, and ~30% everything else (if only the ±3-day fallback fires, the memories' share drops to 10% and the rest scale up accordingly). A 7-day rolling history prevents repeats, and photos matching the frame's orientation get 4x the weight of those that don't. Before accepting a candidate, the picker verifies that every detected head fits inside the crop with a small safety margin; candidates that fail are skipped. See [immich.py](./src/lib/immich.py). 4. Crops around any detected faces, boosts contrast and saturation (both of which e-ink lacks), dithers the image down to the 6-colour palette, and pushes it to the panel. The capture age and EXIF location are painted into the bottom corners as white text with a black stroke, so dithering can't smear the edges. ## Image pipeline The two choices that matter most are `face_aware_crop` and Atkinson dithering. ### Cropping The frame can only hang in one orientation at a time, but I didn't want to limit it to showing only portrait or only landscape photos. So `face_aware_crop` resizes and crops each photo to fill the frame, biasing the crop towards the faces returned by Immich. A landscape shot with some room around the subject usually crops cleanly to portrait this way. The important guardrail is `heads_fit_in_crop`: before the picker accepts a downloaded candidate, it checks the exact crop window against each face box, extended upward to cover the head and padded by `HEAD_SAFETY_MARGIN`. If the crop would cut into any visible padded head area, the photo is rejected and another candidate is tried. The examples below, from [crop_compare.ipynb](./notebooks/crop_compare.ipynb), show how the head bounding boxes affect the final crop and which candidates would be accepted or rejected.