Compare commits
2 commits
d0e1c476db
...
0d23348c65
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d23348c65 | |||
| 9d6b0b5931 |
2 changed files with 41 additions and 41 deletions
73
README.md
73
README.md
|
|
@ -1,83 +1,82 @@
|
|||
# Frame
|
||||
|
||||
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..
|
||||
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 up to a Raspberry Pi Zero 2W) for everyone to enjoy.
|
||||
|
||||
<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>
|
||||
|
||||
## Why
|
||||
|
||||
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.
|
||||
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 cherished memories or let them be held hostage.
|
||||
|
||||
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.
|
||||
It's magical to come home from a day out to immediately see photos taken from the day, or see memories from years ago pop up within the living space, unconfined by an app on your phone.
|
||||
|
||||
|
||||
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.
|
||||
It was a fun afternoon project with Claude Code, a bit of experimenting with different dithering and post-processing, and then fine tuning the photo picking algorithm. Besides powering my frame, I share this repo as a reference and as inspiration for self-hosting, to show that the combination of these services can produce something that otherwise wouldn't have been possible to hack together so quickly.
|
||||
|
||||
## 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.
|
||||
2. Asks Home Assistant whether anyone in `HA_PRESENCE` is home. If not, quits to preserve power and not strain the e-ink unnecessarily.
|
||||
3. Picks a random photo from Immich, weighted toward "on this day" memories, favourites, and recent uploads (see [immich.py](./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 pushes 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>
|
||||
### Cropping
|
||||
|
||||
The frame has one orientation but I didn't want to limit it to only show portrait or landscape photos. So the `face_aware_crop` function resize-crops to fill the frame while keeping all faces within the frame. This can nicely turn a landscape with extra background into a crop that works well on the portrait frame. For finding faces, it relies on the bounding boxes returned by Immich.
|
||||
|
||||
See the following example from [crop_compare.ipynb](./notebooks/crop_compare.ipynb) that shows how the head bounding boxes affect the final crop.
|
||||
|
||||
<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>
|
||||
The second issue is the limited 6-colour palette. The intensity of these colours can't be changed like on an LCD panel, they're either shown or not. So to get any legible results, we have to turn to dithering. Turns out, there are many dithering algorithms with wildly different running times. [dither_compare.ipynb](./notebooks/dither_compare.ipynb) shows a comparison between a few.
|
||||
|
||||
<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>
|
||||
|
||||
Ultimately, I chose Atkinson dithering which seems to keep the highest contrast without too much artifacting. Of course, its performance on the Raspberry Pi was abysmal so the final version relies on numba for compiling the array operations, resulting in a 100x speed improvement.
|
||||
|
||||
## Setup
|
||||
|
||||
Copy `.env.example` to `.env` and fill it in:
|
||||
To run the project, copy [src/.env.example](./src/.env.example) to `src/.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/` |
|
||||
| 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.
|
||||
|
||||
Follow [setup](./setup.md) for the additional setup steps.
|
||||
|
||||
## 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```
|
||||
The script takes the following options:
|
||||
|
||||
```sh
|
||||
python3 src/display.py # uses IMMICH_PEOPLE from the .env file
|
||||
python3 src/display.py --album "Holiday 2025"
|
||||
python3 src/display.py --people "Alice,Bob"
|
||||
python3 src/display.py -o 90 # portrait orientation
|
||||
python3 src/display.py --saturation 1.5 --contrast 1.1 --gamma 0.85 # change preprocessing settings
|
||||
```
|
||||
|
||||
`display.py` only runs on the Pi (it needs SPI). For off-device experiments see 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 you try to run untethered, and most of the time it's just sitting idle waiting for the next cron tick. If I were doing this again for a battery-powered build I'd probably reach for an ESP32 with deep sleep. Mine stays plugged in, so I haven't bothered.
|
||||
|
||||
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.
|
||||
|
||||
I would also give [Inky Impression](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 bit muddled in the evening lights. I think a separate light source would be the greatest improvement by far.
|
||||
|
|
|
|||
5
setup.md
5
setup.md
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
Flash an image using the [Raspberry Pi imager](https://www.raspberrypi.com/software/). I picked Raspberry Pi OS Lite (based on Debian Trixie) and set up WiFi & SSH from the Customisation settings.
|
||||
|
||||
## First commands
|
||||
|
||||
|
|
@ -38,7 +38,8 @@ In `crontab -e`, add:
|
|||
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
|
||||
@reboot /usr/sbin/iw wlan0 set power_save off
|
||||
*/5 * * * * /home/<user>/frame/wifi-check.sh >> /home/<user>/wifi.log 2>&1
|
||||
```
|
||||
|
||||
## Hardware notes
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue