Files
AvianVisitors/frame/display.py
T

363 lines
14 KiB
Python

#!/usr/bin/env python3
"""Frame-Pi client: turn a collage screenshot into Inky panel pixels.
Runs on the Pi Zero 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",
"hours": 24,
"image": "", # local PNG written by the shooter
"image_url": "", # or a published screenshot URL
"shoot": False, # or capture inline (needs a browser; not a Zero W)
"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,
"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]
# --- 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 _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_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.55, 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 >= 30: # first empty band of 30px splits title from collage
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_w(img.crop(tb), box_w * TITLE_FRAC)
collage = _scale_w(img.crop(cb), box_w * COLLAGE_FRAC)
gap = round(box_h * GAP_FRAC)
ccx = _centroid_x(collage, paper) # centre the collage by ink weight, not bbox
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))
fit = min(box_w / comp.width, box_h / comp.height, 1.0) # shrink to the opening, never enlarge
if fit < 1.0:
comp = comp.resize((max(1, round(comp.width * fit)), max(1, round(comp.height * fit))), Image.LANCZOS)
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):
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"], 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
if use_signature:
try:
sig = signature(fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg)))
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))
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()