2026
An E-Ink Photo Frame That Sleeps When the House Is Empty
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.
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 panel, pulling from my self-hosted Immich library, part of the same self-hosting setup I back up with btrfs and 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
That’s the point. Albert Borgmann once 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 is a focal thing. I curated the weights. I hung it where the light was right. I 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.
The point isn’t power saving. John Berger drew a line between photographs kept inside a context of lived meaning (private), and ones severed and circulated (public). Google Photos hands you the public mode dressed as the private. A wall in the hallway, lit only when your people are home, restores 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.
- 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.shevery 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 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 doesn’t tell anyone about it. The smallness is the point. There should be more of this kind of thing.