Files
AvianVisitors/frame/display.py
T

404 lines
16 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 A5 opening
"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 is an A5 rectangle (1 : sqrt(2)) centred in the panel; the
# content floats inside it with `mat` of inner whitespace.
A5_H = PANEL_H * 0.7071 # A5 is 1/sqrt(2) of the panel height
A5_W = A5_H / 1.41421 # A5 aspect 1 : sqrt(2)
def _place(content, paper, mat):
s = min(A5_W * (1 - mat) / content.width, A5_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):
"""Crop the title and collage, size each to a fraction of the A5 opening,
stack with a gap, and centre on the panel."""
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: # split below the headline; a 60px band clears the ~30px eyebrow/headline gap so the title stays whole
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 = A5_W * (1 - mat), A5_H * (1 - mat)
if not (tb and cb):
return _place(img.crop(full), paper, mat)
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):
"""Dev aid: outline the A5 mat opening so the matte and centring show."""
x0, y0 = round((PANEL_W - A5_W) / 2), round((PANEL_H - A5_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
img = mat_and_center(img, cfg["mat"])
if preview:
out = quantize_spectra6(img)
if mat_box:
_draw_mat_box(out)
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()