Compounding Self-Hosting

Combining Immich, Home Assistant, an e-ink screen, and an overpowered Raspberry Pi for truly personal tech.

The frame showing a dithered landscape with a small overlay reading '2 years ago' and 'Palmeiras'.

A small e-ink photo frame for our home. It pulls from a self-hosted Immich1 library, checks a self-hosted Home Assistant2 to see if anyone is home, and puts a photo on the PhotoPainter3 for everyone to enjoy.

Why

Most digital frames offer a choice between two bad options: preprocess photos onto an SD card yourself, or hand your library to a cloud service like Google Photos. Realistically, the SD card gets updated once a year. And I’d rather not give a cloud provider access to my most cherished memories, or let it hold them hostage.

Yet, coming home to a photo from earlier that day, or one from five years ago, hits differently when it’s inside a real picture frame instead of buried in your phone. So I decided to spend a weekend building with Claude Code. After some experimenting with dithering and post-processing, and some fine-tuning of the photo-picking algorithm, I ended up with this project.

A weekend was enough only because of how much came for free: Immich had already run face detection on every photo, already reverse-geocoded the place names shown on the overlay, and already knew our favourites; Home Assistant already knew who was home. That’s the compounding return of self-hosting: every service exposes an easy-to-integrate API, and each new project gets to stand on all of them. I’m now looking for more ways to leverage this.

How it works

The frame is driven by a Raspberry Pi Zero 2W, which is fast enough that I could write plain Python and mostly ignore performance, a luxury after counting individual bytes on an ATtiny854. Cron triggers a script every 15 minutes. Each run:

  1. Quits if it’s between midnight and 7 am. The e-ink display holds its image at zero power, so the last photo of the evening simply stays up overnight.
  2. Asks Home Assistant whether anyone in HA_PRESENCE is home. If not, it quits; no point wasting power and straining the display.
  3. Picks a random photo from Immich, drawn from four overlapping pools: “on this day” memories, favourites, the last 30 days, and the whole library. The weights work out to roughly a 25% chance for a memory (10% if only the ±3-day fallback has matches), 15% for favourites, and 30% for each of the other two. A 7-day rolling history prevents repeats, photos matching the frame’s orientation get 4× the weight, and any candidate whose crop would clip a head is rejected (more on that below).
  4. Crops around any detected faces, boosts contrast and saturation (e-ink is short on both), dithers down to the six-colour palette, and pushes the result to the panel. The photo’s age and EXIF location are painted into the bottom corners as white text with a black stroke, so the dither can’t smear the edges.

Image pipeline

Two choices matter most for how the result looks: the face-aware crop and dithering.

Cropping

The frame stands in one orientation, but I didn’t want to limit it to photos that happen to match. So a face-aware algorithm resize-crops the image to fill the panel, biasing the crop window towards the faces Immich has already detected. A landscape shot with some room around the subject usually crops cleanly to portrait this way. If the crop would cut into any heads, the photo is instead rejected and the picker moves on to the next candidate.

The examples below show how the head bounding boxes steer the final crop, and which candidates get rejected.

Crop comparison showing original photos with face boxes, naive centre crops, and face-aware crops for a portrait frame target, with one candidate rejected for cutting into a head

Dithering

The panel shows exactly six colours: black, white, red, yellow, blue, and green. There’s no intensity control like on an LCD, so every pixel is one of the six. Anything photo-like has to be dithered, and the candidate algorithms differ wildly in both output quality and running time. Here’s how a few of them compare:

Palette-preserving dither comparison showing several 6-colour algorithms applied to a hiker in the mountains

I settled on Atkinson dithering5, which keeps the most contrast without too many visible artefacts. Unlike its alternatives, it only diffuses 6/8 of each pixel’s quantisation error, so highlights stay bright instead of getting dragged grey by their neighbours. Dithering was also the first place pure Python became unusably slow on the Pi Zero. So the inner loop, including the perceptually weighted nearest-colour matching, runs through numba which makes it roughly two orders of magnitude faster once the JIT cache is warm.

Learnings

The Pi Zero 2W is overkill. It drains the battery way too quickly if you try to run it cordless, and it spends most of its life idle, waiting for the next cron tick. An RTC wired to an interrupt pin plus deep sleep would have fixed that, but the Waveshare board doesn’t come with one soldered on and I couldn’t be bothered to hack it in. For a battery-powered build I’d reach for an ESP32 with deep sleep instead; with 15 minutes between refreshes, even a slow chip has all the time it needs for the image processing and dithering.

A few reliability quirks worth knowing: the Zero 2W’s Wi-Fi drops when power-save kicks in, so a separate cron job runs wifi-check.sh every 5 minutes to reconnect. I also ensured that swap is masked and journald is set to volatile, because SD-card wear is the only thing likely to slowly kill this build.

I’d also like to try an Inky Impression6 in a custom-made frame: a larger display, and perhaps integrated lights, because the e-ink looks a bit muddled in evening light. A dedicated light source would be the greatest improvement by far.

But all in all, the frame already does its job. It stands in the living room as a pleasant speck of curiosity, subtle enough that most visitors never notice it (until I inevitably point it out). But once you know it’s there, catching the panel mid-refresh comes with a small rush of excitement: you’re about to be pulled into a memory, distant or very recent.

  1. Immich: https://immich.app/
  2. Home Assistant: https://www.home-assistant.io/
  3. PhotoPainter: https://www.waveshare.com/wiki/PhotoPainter
  4. counting individual bytes on an ATtiny85: https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/
  5. Atkinson dithering: https://en.wikipedia.org/wiki/Atkinson_dithering
  6. Inky Impression: https://shop.pimoroni.com/products/inky-impression?variant=55186435244411