---
title: An E-Ink Photo Frame That Sleeps When the House Is Empty
description: A Pi, a 6-colour e-ink panel, and a self-hosted Immich library. Photos picked by date and favourites, gated on Home Assistant presence, Atkinson-dithered.
date: 2026-05-27
period: '2026'
thumbnail:
  src: ./_assets/frame.jpg
  alt: The e-ink frame on the wall showing a dithered landscape scene with the capture age and EXIF location painted into the bottom corners.
links:
  - label: Source
    url: https://home.schmelczer.dev/git/andras/frame
article:
  tags: ['embedded', 'systems', 'tools']
  stack:
    [
      'Python',
      'Raspberry Pi Zero 2W',
      'Waveshare 7.3" 6-colour panel',
      'Immich',
      'Home Assistant',
      'numba',
      'Atkinson dither',
    ]
  scale: One panel, one household, ~64 refreshes a day at peak
  outcome: A wall-mounted photo frame that pulls from self-hosted Immich, gated on home presence, with no cloud dependencies
  media:
    - type: image
      src: ./_assets/frame.jpg
      alt: The frame on the wall showing a 6-colour Atkinson-dithered landscape scene, with "2 years ago" and a location label painted into the bottom corners.
      caption: The bottom corners carry the photo's age and EXIF location. Painted as text on top, so the dither can't smear them.
project:
  title: Frame
  selected: true
  technologies:
    [
      'Python',
      'Raspberry Pi Zero 2W',
      'Waveshare PhotoPainter',
      'Immich',
      'Home Assistant',
      'numba',
      'Atkinson dither',
    ]
---

In 2024, researchers found family-blog photos of Brazilian children inside the LAION training set. Self-hosting your photos used to be a preference; it's a safeguarding decision now. Nixplay's cloud-tied frames have bricked. Funimation deleted libraries people had paid for. I wanted a photo frame on the hallway wall, and I wasn't going to hand the family album to a vendor who could close the doors on it.

So it's a Raspberry Pi Zero 2W driving Waveshare's [PhotoPainter](https://www.waveshare.com/wiki/PhotoPainter) panel, pulling from my self-hosted [Immich](https://immich.app/) library, part of the same [self-hosting setup I back up with btrfs and borg](/articles/backup-container-btrfs-borg/). A few hundred lines of stdlib Python on top of the reference driver.

## Why a stupid amount of engineering for a picture on a wall

Because the engineering is how you end up caring about the object. Albert Borgmann distinguished _devices_, which efficiently deliver a commodity and disappear into the wall, from _focal things_, which gather a practice around them. A Nest Hub is a device; it shows you photos the way a microwave delivers heat. The frame became a focal thing somewhere along the way: I curated the weights, hung it where the light is right, and still tweak it when something feels off. It doesn't sell my attention back to me; it asks me to pay some.

The medium helps. E-ink doesn't glow and doesn't beep. From across the room it reads as _image_, not as _screen_, and that one perceptual difference changes how often I actually look at it.

## The presence gate

The cron line does most of the work. Every 15 minutes, the script checks the time of day, then asks Home Assistant whether anyone in `HA_PRESENCE` is home. If not, it quits. The panel keeps showing the last photo, because e-ink, so you walk in to whatever was there when the house emptied.

This was never about power saving. John Berger drew a line between photographs kept inside a context of lived meaning (private) and ones severed from it and circulated (public), and Google Photos hands you the public mode dressed as the private one. A wall in the hallway, refreshed only while your people are home, restores some of the context. The same photograph means something different surfacing while you're cooking dinner than it does in a feed at 11pm.

## How a photo gets picked

The pool is biased the way memory is biased: four buckets, weighted ~30% "on this day" (dropping to ~10% if only the ±3-day fallback fires), ~18% favourites, ~36% the last 30 days, ~36% everything else. Within those buckets, orientation-match against the current frame gets 4x the weight of mismatch, because cropping landscape to portrait works less often than the reverse.

A 7-day rolling history filters repeats. Before accepting a candidate, the picker runs `heads_fit_in_crop` against Immich's detected face boxes, extended upward to cover the skull and padded by `HEAD_SAFETY_MARGIN`: if the planned crop would slice into any visible head, that candidate is rejected and another is drawn. A wall photo with half a face in it is worse than the same photo not on the wall at all.

`face_aware_crop` does the actual cropping: resize-cropping to fill the frame while biasing the window around detected faces. A landscape shot with room around the subject usually crops cleanly to portrait this way; the guardrail above catches the ones that don't.

## Tuning the pipeline somewhere else

Iterating on the Pi means waiting 12+ seconds per refresh. Both the face-aware crop and the dither were tuned in Jupyter against a local pool of a few hundred photos, then frozen and shipped.

The dither is where the choice visibly matters. The panel can only show black, white, red, yellow, blue, green; no intensity control, every pixel is one of those six. I compared Floyd-Steinberg, Stucki, and a couple of ordered variants. Atkinson kept the highest perceived contrast on the 6-colour palette without smearing skin tones into the nearest yellow. Pure-Python Atkinson on the Pi Zero was unusably slow, so the inner loop runs through `numba` with perceptual-weighted nearest-colour matching (0.299/0.587/0.114). Roughly 100x faster after the JIT cache warms.

## The weekend-reimplementable rule

Hundred Rabbits, a couple who live offshore on a sailboat doing permacomputing in practice, hold themselves to a rule: any system they depend on should be reimplementable in a weekend. The frame meets the bar. A few hundred lines of stdlib Python on a documented panel, reading from an HTTP endpoint that returns JPEGs. It came together over an afternoon with Claude Code plus a couple of weekends tuning the picker and the dither; the repo is public partly as a reference for anyone wanting to do something similar. If Immich disappears tomorrow the selection logic is eighty lines I can repoint at whatever replaces it.

## Smaller calls

- **Capture age and EXIF location painted as text.** White on a black stroke, written _after_ dithering, so the labels stay sharp on the 6-colour palette.
- **A lock file, because refreshes take a while.** A render can run past 12 seconds; `flock` on a lock file makes an overlapping cron tick exit quietly instead of fighting the panel.
- **When every candidate fails the head check,** the picker quietly falls back to photos it has shown before. A repeat on the wall beats a sliced face.
- **Swap masked, journald volatile.** The SD card is the most likely thing to die on this build. Don't write to it unless you have to.
- **Wifi power-save reconnect job.** The Pi Zero 2W's wifi drops if power-save kicks in. A separate `wifi-check.sh` every five minutes brings it back.

## What I'd change

- **Lower-power hardware.** The Pi Zero 2W is overkill and idles 14 minutes out of every 15. The Waveshare board didn't have an RTC interrupt pin soldered, and rather than hack one in, I'd reach for an ESP32 next time. Deep sleep has plenty of time to do the image work inside a 15-minute window.
- **A bigger panel and a small light.** The [Inky Impression](https://shop.pimoroni.com/products/inky-impression) 13" with a custom frame and integrated lighting would help most in the evenings, when the e-ink reads muddled under warm lamps.
- **A daytime cadence curve.** 15 minutes is constant. It should slow at night and speed up around the times we're actually in the hallway.

The frame is small, slow, and almost entirely silent. It does one thing for one household and reports to no one. I'd like more of my software to be like that.
