Files
AvianVisitors/frame/display.py
Chris Sader 6884cce3c7 Add configurable title, matte dimensions, and interactive installer
- Configurable collage title via localStorage (bird:title) with admin settings UI
- Configurable matte opening dimensions in display.py (mat_opening_w/mat_opening_h)
- Interactive frame installer (install-interactive.sh) with guided setup prompts

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 15:53:30 -05:00

424 lines
17 KiB
Python

#!/usr/bin/env python3
"""Frame-Pi client: turn a collage screenshot into Inky panel pixels.
Runs on the frame Pi (a 3 A+ or Zero 2 W) on a systemd timer. Each run it decides whether a
refresh is worth it (the species set or call-count brackets changed, and it
is not quiet hours), then crops the title and collage from the screenshot,
centres and mats them, and pushes the result to the Inky Impression 13.3".
``--preview out.png`` writes an approximate 6-ink dither instead, so the
look can be checked on any machine without the panel.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import inspect
import io
import json
import os
import re
import statistics
import sys
import time
import urllib.request
from datetime import datetime
from PIL import Image, ImageChops, ImageDraw
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib
PANEL_W, PANEL_H = 1200, 1600 # portrait; the panel itself is 1600x1200
# Approximate Spectra-6 inks, used only for --preview. On hardware the Inky
# library maps to the panel's real palette.
SPECTRA6 = [(236, 234, 223), (26, 26, 28), (165, 60, 56),
(198, 176, 74), (49, 71, 130), (58, 110, 72)]
DEFAULTS = {
"base_url": "http://birdnet.local",
"species_source": "", # "" = the recent API at base_url; "birdweather" = BirdWeather near a ZIP
"zip": "", # BirdWeather ZIP / postal code (with species_source = "birdweather")
"bw_days": 7, # BirdWeather lookback window, in days
"bw_country": "us", # geocoder country for the ZIP
"hours": 24,
"image": "", # local PNG written by the shooter
"image_url": "", # or a published screenshot URL
"shoot": False, # or capture inline (needs a browser; the 3 A+ and Zero 2 W both handle it)
"shoot_title": None, "shoot_subtitle": None,
"shoot_headline_px": 42, "shoot_eyebrow_px": 18, "shoot_lowercase": False,
"shoot_mat": 0.04, "shoot_small_floor": 0.04, "shoot_count_exp": 0.65,
"mat": 0.0, # extra global shrink of the content inside the opening
"mat_opening_w": 0, # matte opening width in inches (0 = use A5 default)
"mat_opening_h": 0, # matte opening height in inches (0 = use A5 default)
"rotate": 90, # 90 or 270 if the frame hangs the other way up
"saturation": 0.6,
"panel": "", # "el133uf1" forces the 13.3" driver if auto() fails
"quiet_start": 0, "quiet_end": 0, # 0/0 = no quiet hours
"heal_hours": 24,
"state": "~/.birdframe/state.json",
"cache": "~/.birdframe",
"timeout": 45,
"basic_user": None, "basic_pass": None,
}
def _auth(cfg):
if not cfg.get("basic_user"):
return None
raw = f"{cfg['basic_user']}:{cfg.get('basic_pass') or ''}".encode()
return "Basic " + base64.b64encode(raw).decode()
# --- change detection -------------------------------------------------------
def slugify(sci):
return re.sub(r"[^a-z0-9]+", "-", sci.lower()).strip("-")
def _bucket(n):
for i, edge in enumerate((1, 2, 5, 15, 40, 100, 300, 1000)):
if n <= edge:
return i
return 8
def fetch_recent(base, hours, timeout, auth=None):
url = f"{base.rstrip('/')}/avian/api/birdnet-api.php?action=recent&hours={hours}"
req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"})
if auth:
req.add_header("Authorization", auth)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read(2_000_000)).get("species", [])
def signature(species):
items = sorted((slugify(s["sci"]), _bucket(int(s.get("n") or 1))) for s in species)
return hashlib.sha256(json.dumps(items).encode()).hexdigest()[:16]
def fetch_species(cfg, auth=None):
"""The species list the signature is built from: the BirdNET-Pi recent API
by default, or BirdWeather's recent detections near a ZIP when
species_source = "birdweather"."""
if cfg.get("species_source") == "birdweather":
import birdweather
return birdweather.species_for_zip(cfg["zip"], country=cfg["bw_country"], days=cfg["bw_days"])
return fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], auth)
# --- image ------------------------------------------------------------------
def get_image(src, timeout, auth=None):
if re.match(r"^https?://", src):
req = urllib.request.Request(src, headers={"User-Agent": "AvianVisitors-frame/1.0"})
if auth:
req.add_header("Authorization", auth)
with urllib.request.urlopen(req, timeout=timeout) as r:
return Image.open(io.BytesIO(r.read(20_000_000))).convert("RGB")
return Image.open(os.path.expanduser(src)).convert("RGB")
def fit_panel(img):
if img.size != (PANEL_W, PANEL_H):
img = img.resize((PANEL_W, PANEL_H), Image.LANCZOS)
return img
def _paper(img):
"""Median of the four corners, robust to a stray inked corner."""
w, h = img.size
px = (img.getpixel(p) for p in ((4, 4), (w - 5, 4), (4, h - 5), (w - 5, h - 5)))
return tuple(int(statistics.median(c)) for c in zip(*px))
# The mat opening defaults to A5 (1 : sqrt(2)) for the standard kit frame.
# Override with mat_opening_w / mat_opening_h (in inches) for custom frames.
_A5_H = PANEL_H * 0.7071
_A5_W = _A5_H / 1.41421
# Panel physical dimensions in inches (13.3" diagonal, 4:3 aspect in portrait)
_PANEL_W_IN = 8.4
_PANEL_H_IN = 11.2
def _opening_dims(cfg):
w = float(cfg.get("mat_opening_w") or 0)
h = float(cfg.get("mat_opening_h") or 0)
if w > 0 and h > 0:
return PANEL_W * (w / _PANEL_W_IN), PANEL_H * (h / _PANEL_H_IN)
return _A5_W, _A5_H
def _place(content, paper, mat, opening):
op_w, op_h = opening
s = min(op_w * (1 - mat) / content.width, op_h * (1 - mat) / content.height)
nw, nh = max(1, round(content.width * s)), max(1, round(content.height * s))
content = content.resize((nw, nh), Image.LANCZOS)
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
canvas.paste(content, ((PANEL_W - nw) // 2, (PANEL_H - nh) // 2))
return canvas
def _region_bbox(img, paper, y0, y1):
region = img.crop((0, y0, img.width, y1))
diff = ImageChops.difference(region, Image.new("RGB", region.size, paper))
bb = diff.convert("L").point(lambda p: 255 if p > 34 else 0).getbbox()
return None if not bb else (bb[0], y0 + bb[1], bb[2], y0 + bb[3])
def _scale_w(img, target_w):
s = target_w / img.width
return img.resize((max(1, round(img.width * s)), max(1, round(img.height * s))), Image.LANCZOS)
def _scale_h(img, target_h):
s = target_h / img.height
return img.resize((max(1, round(img.width * s)), max(1, round(img.height * s))), Image.LANCZOS)
def _centroid_x(img, paper):
"""Horizontal centre of ink weight (what the eye reads as centred)."""
m = ImageChops.difference(img, Image.new("RGB", img.size, paper)).convert("L")
cols = list(m.resize((img.width, 1), Image.BOX).tobytes())
total = sum(cols) or 1
return sum(x * v for x, v in enumerate(cols)) / total
# Content layout inside the A5 opening: the title and collage are sized
# independently (as fractions of the opening width), so tuning one leaves the
# other untouched. gap is a fraction of the opening height.
TITLE_H_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.065, 0.66, 0.1
def mat_and_center(img, mat, opening=None):
"""Crop the title and collage, size each to a fraction of the opening,
stack with a gap, and centre on the panel."""
if opening is None:
opening = (_A5_W, _A5_H)
op_w, op_h = opening
img = img.convert("RGB")
paper = _paper(img)
mask = ImageChops.difference(img, Image.new("RGB", img.size, paper))
mask = mask.convert("L").point(lambda p: 255 if p > 34 else 0)
full = mask.getbbox()
if not full:
return img
levels = list(mask.resize((1, img.height), Image.BOX).tobytes()) # per-row content
top, bot = full[1], full[3]
split, run = None, 0
for y in range(top, bot):
if levels[y] <= 2:
run += 1
if run >= 60:
cy = y
while cy < bot and levels[cy] <= 2:
cy += 1
split = (y - run + 1, cy)
break
else:
run = 0
tb = _region_bbox(img, paper, top, split[0]) if split else None
cb = _region_bbox(img, paper, split[1], bot + 1) if split else None
box_w, box_h = op_w * (1 - mat), op_h * (1 - mat)
if not (tb and cb):
return _place(img.crop(full), paper, mat, opening)
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
gap = round(box_h * GAP_FRAC)
# Size the collage to fill the room left under the fixed-size title,
# binding on whichever of width or remaining height runs out first, so the
# title stays a consistent size whether the collage is tall or compact
# instead of ballooning when the collage happens to be short.
coll = img.crop(cb)
cs = min(box_w * COLLAGE_FRAC / coll.width, (box_h - title.height - gap) / coll.height)
collage = coll.resize((max(1, round(coll.width * cs)), max(1, round(coll.height * cs))), Image.LANCZOS)
ccx = _centroid_x(collage, paper) # centre the collage by ink weight, not bbox
half = max(ccx, collage.width - ccx)
# A wildly off-centre collage can push the centroid-mirrored width (2*half)
# past the A5 opening; shrink only the collage, never the fixed-size title,
# so nothing spills under the physical mat.
if 2 * half > box_w:
s = box_w / (2 * half)
collage = collage.resize((max(1, round(collage.width * s)), max(1, round(collage.height * s))), Image.LANCZOS)
ccx = round(ccx * s)
half = max(ccx, collage.width - ccx)
cw = round(max(title.width, 2 * half))
comp = Image.new("RGB", (cw, title.height + gap + collage.height), paper)
comp.paste(title, ((cw - title.width) // 2, 0))
comp.paste(collage, (round(cw / 2 - ccx), title.height + gap))
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
return canvas
def quantize_spectra6(img):
pal = Image.new("P", (1, 1))
flat = [c for ink in SPECTRA6 for c in ink]
flat += list(SPECTRA6[0]) * ((768 - len(flat)) // 3) # pad the 256-entry palette with paper
pal.putpalette(flat[:768])
return img.convert("RGB").quantize(palette=pal, dither=Image.Dither.FLOYDSTEINBERG).convert("RGB")
def _draw_mat_box(img, opening=None):
"""Dev aid: outline the mat opening so the matte and centring show."""
op_w, op_h = opening if opening else (_A5_W, _A5_H)
x0, y0 = round((PANEL_W - op_w) / 2), round((PANEL_H - op_h) / 2)
ImageDraw.Draw(img).rectangle((x0, y0, PANEL_W - x0 - 1, PANEL_H - y0 - 1),
outline=(170, 60, 56), width=2)
# --- hardware ---------------------------------------------------------------
def push_panel(img, rotate, saturation, panel=""):
"""Rotate to the panel's landscape buffer and push. Lazy import so this
module still loads on a machine without the Inky library."""
if rotate not in (90, 270):
print(f"rotate must be 90 or 270, not {rotate}; using 90", file=sys.stderr)
rotate = 90
if panel == "el133uf1":
from inky.inky_el133uf1 import Inky
dev = Inky(resolution=(1600, 1200))
else:
from inky.auto import auto
dev = auto()
buf = img.rotate(rotate, expand=True)
if buf.size != (dev.width, dev.height):
buf = buf.resize((dev.width, dev.height), Image.LANCZOS)
kw = {"saturation": saturation} if "saturation" in inspect.signature(dev.set_image).parameters else {}
dev.set_image(buf, **kw)
dev.show()
# --- state ------------------------------------------------------------------
def load_state(path):
try:
with open(os.path.expanduser(path)) as f:
return json.load(f)
except Exception:
return {"signature": None, "last_refresh": 0}
def save_state(path, sig, when):
path = os.path.expanduser(path)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump({"signature": sig, "last_refresh": when}, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path) # atomic: a power cut can't leave a half-written file
def in_quiet_hours(cfg, hour):
s, e = cfg["quiet_start"], cfg["quiet_end"]
if s == e:
return False
return s <= hour < e if s < e else hour >= s or hour < e
# --- run --------------------------------------------------------------------
def obtain_image(cfg, species=None):
if cfg.get("species_source") == "birdweather":
from shoot import shoot_birdweather
if species is None: # gate skipped (--no-signature): fetch the list to render
species = fetch_species(cfg, _auth(cfg))
out = os.path.join(os.path.expanduser(cfg["cache"]), "frame.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
shoot_birdweather(out, species, title=cfg["shoot_title"], subtitle=cfg["shoot_subtitle"],
timeout_ms=cfg["timeout"] * 1000)
return Image.open(out).convert("RGB")
if cfg["shoot"]:
from shoot import shoot
out = os.path.join(os.path.expanduser(cfg["cache"]), "shot.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
shoot(cfg["base_url"], out, title=cfg["shoot_title"], subtitle=cfg["shoot_subtitle"],
headline_px=cfg["shoot_headline_px"], eyebrow_px=cfg["shoot_eyebrow_px"],
lowercase=cfg["shoot_lowercase"], mat=cfg["shoot_mat"],
small_floor=cfg["shoot_small_floor"], count_exp=cfg["shoot_count_exp"], timeout_ms=cfg["timeout"] * 1000,
user=cfg["basic_user"], password=cfg["basic_pass"])
return Image.open(out).convert("RGB")
src = cfg["image_url"] or cfg["image"]
if not src:
raise ValueError("set image, image_url, or shoot in config")
return get_image(src, cfg["timeout"], _auth(cfg))
def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
now = time.time()
state = load_state(cfg["state"])
sig = None
species = None
if use_signature:
try:
species = fetch_species(cfg, _auth(cfg))
sig = signature(species)
except Exception as e:
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change
heal_due = now - state.get("last_refresh", 0) >= cfg["heal_hours"] * 3600
changed = (not use_signature) or (sig is not None and sig != state.get("signature"))
if not force and not preview:
if in_quiet_hours(cfg, datetime.now().hour):
print("quiet hours; skip")
return
if not changed and not heal_due:
print("no change; skip")
return
print("refresh:", "changed" if changed else "heal")
try:
img = fit_panel(obtain_image(cfg, species))
except Exception as e:
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
return
opening = _opening_dims(cfg)
img = mat_and_center(img, cfg["mat"], opening)
if preview:
out = quantize_spectra6(img)
if mat_box:
_draw_mat_box(out, opening)
out.save(preview)
print(f"wrote preview {preview}")
return
try:
push_panel(img, cfg["rotate"], cfg["saturation"], cfg.get("panel", ""))
except Exception as e:
print(f"panel push failed: {e}", file=sys.stderr)
return
save_state(cfg["state"], sig if sig is not None else state.get("signature"), now)
print("panel updated")
def load_config(path):
cfg = dict(DEFAULTS)
if path:
with open(os.path.expanduser(path), "rb") as f:
cfg.update(tomllib.load(f))
return cfg
def main():
ap = argparse.ArgumentParser(description="Push the collage screenshot to the Inky panel.")
ap.add_argument("--config")
ap.add_argument("--base-url")
ap.add_argument("--image")
ap.add_argument("--image-url")
ap.add_argument("--preview", help="write a 6-ink preview PNG instead of pushing")
ap.add_argument("--rotate", type=int)
ap.add_argument("--force", action="store_true", help="refresh even if unchanged")
ap.add_argument("--no-signature", action="store_true", help="skip change detection")
ap.add_argument("--mat-box", action="store_true", help="dev: outline the mat window on the preview")
args = ap.parse_args()
cfg = load_config(args.config)
for key in ("base_url", "image", "image_url"):
val = getattr(args, key)
if val:
cfg[key] = val
if args.rotate is not None:
cfg["rotate"] = args.rotate
run(cfg, preview=args.preview, force=args.force, use_signature=not args.no_signature, mat_box=args.mat_box)
if __name__ == "__main__":
main()