#!/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()