[FEAT] frame: --bird-weather draws the collage from a ZIP code, no mic (#12)

This commit is contained in:
Teddy Warner
2026-06-19 19:52:21 -07:00
committed by GitHub
parent 3cf7ac191b
commit 10548dbca7
5 changed files with 384 additions and 25 deletions
+98 -17
View File
@@ -19,13 +19,23 @@ from __future__ import annotations
import argparse
import base64
import http.server
import json
import os
import re
import socketserver
import sys
import threading
import urllib.parse
from playwright.sync_api import TimeoutError as PWTimeout
from playwright.sync_api import sync_playwright
# --bird-weather pulls cutouts straight from the repo's raw GitHub URLs, so the
# Pi never bundles the illustration set and picks up new birds with no redeploy.
RAW_ILLUSTRATIONS = ("https://raw.githubusercontent.com/Twarner491/AvianVisitors/"
"avian-visitors/avian/assets/illustrations/")
# Hide the controls and the other views, freeze animations. Titles + collage
# stay. Injected before first paint.
HIDE_CSS = """
@@ -60,19 +70,26 @@ def _safe_continue(route):
pass
def _make_api_handler(floor_frac, window_hours, auth):
def _make_api_handler(floor_frac, window_hours, auth, species=None):
"""Re-window action=recent (to preview busy days) and floor the rarest
counts so the packer draws them a little larger."""
counts so the packer draws them a little larger. With `species` set
(--bird-weather), serve that list for recent and an empty body for the
other views, which have no backend in that mode."""
def handler(route):
req = route.request
if "action=recent" not in req.url:
if species is not None:
return route.fulfill(status=200, content_type="application/json", body="{}")
return route.continue_()
try:
url = re.sub(r"hours=\d+", f"hours={int(window_hours)}", req.url) if window_hours else req.url
kw = {"url": url}
if auth:
kw["headers"] = {**req.headers, "authorization": auth}
data = route.fetch(**kw).json()
if species is not None:
data = {"hours": int(window_hours or 24), "species": species, "as_of": ""}
else:
url = re.sub(r"hours=\d+", f"hours={int(window_hours)}", req.url) if window_hours else req.url
kw = {"url": url}
if auth:
kw["headers"] = {**req.headers, "authorization": auth}
data = route.fetch(**kw).json()
sp = data.get("species", [])
if sp and floor_frac > 0:
floor = max((s.get("n") or 1) for s in sp) * floor_frac
@@ -86,6 +103,37 @@ def _make_api_handler(floor_frac, window_hours, auth):
return handler
def _serve_frontend(directory):
"""Serve the static collage frontend on a free localhost port (daemon thread)."""
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, *a):
pass
def make(*args, **kwargs):
return Quiet(*args, directory=directory, **kwargs)
httpd = socketserver.TCPServer(("127.0.0.1", 0), make)
httpd.daemon_threads = True
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd, httpd.server_address[1]
def _make_cutout_handler(base):
"""Redirect each cutout.php lookup to the bird's raw illustration on GitHub.
Trusts species_for_zip to pre-filter to drawable slugs, so a redirect only
lands on a missing file if the repo is mid-update."""
def handler(route):
try:
params = urllib.parse.parse_qs(urllib.parse.urlparse(route.request.url).query)
slug = re.sub(r"[^a-z0-9]+", "-", (params.get("sci") or [""])[0].lower()).strip("-")
if (params.get("pose") or ["1"])[0] == "2":
slug += "-2"
route.fulfill(status=302, headers={"location": base + slug + ".png"})
except Exception:
_safe_continue(route)
return handler
def _make_js_handler(xbias, ybias, count_exp, pad, auth, misses):
"""Rewrite the collage tunables inside the page's apt.js at capture time."""
def handler(route):
@@ -110,20 +158,22 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
headline_px=42, eyebrow_px=18, lowercase=False,
mat=0.04, collage_vh=52, cluster_xbias=1.0, cluster_ybias=1.2,
count_exp=0.4, cluster_pad=1, small_floor=0.04, window_hours=None,
timeout_ms=45000, user=None, password=None):
timeout_ms=45000, user=None, password=None, species=None, cutout_base=None):
pad_side, pad_top, pad_bottom = int(vw * mat), int(vh * mat * 0.92), int(vh * mat)
auth = "Basic " + base64.b64encode(f"{user}:{password or ''}".encode()).decode() if user else None
with sync_playwright() as p:
browser = p.chromium.launch(args=["--force-color-profile=srgb"])
browser = p.chromium.launch(args=["--force-color-profile=srgb", "--disable-dev-shm-usage"])
try:
ctx_kw = {"viewport": {"width": vw, "height": vh}, "device_scale_factor": dsf}
if user:
ctx_kw["http_credentials"] = {"username": user, "password": password or ""}
page = browser.new_context(**ctx_kw).new_page()
misses = []
page.route("**/birdnet-api.php**", _make_api_handler(small_floor, window_hours, auth))
page.route("**/birdnet-api.php**", _make_api_handler(small_floor, window_hours, auth, species))
page.route("**/apt.js*", _make_js_handler(cluster_xbias, cluster_ybias, count_exp, cluster_pad, auth, misses))
if cutout_base:
page.route("**/cutout.php*", _make_cutout_handler(cutout_base))
css = HIDE_CSS + _frame_css(headline_px, eyebrow_px, lowercase, pad_top, pad_side, pad_bottom, collage_vh)
page.add_init_script(
@@ -164,16 +214,24 @@ def main():
ap.add_argument("--title")
ap.add_argument("--subtitle")
ap.add_argument("--lowercase", action="store_true")
ap.add_argument("--headline-px", type=int, default=42)
ap.add_argument("--eyebrow-px", type=int, default=18)
ap.add_argument("--headline-px", type=int, default=None,
help="headline font px; default 42 for the mic, 39 for --bird-weather")
ap.add_argument("--eyebrow-px", type=int, default=None,
help="eyebrow font px; default 18 for the mic, 17 for --bird-weather")
ap.add_argument("--mat", type=float, default=0.04)
ap.add_argument("--collage-vh", type=float, default=52)
ap.add_argument("--cluster-xbias", type=float, default=1.0)
ap.add_argument("--cluster-ybias", type=float, default=1.2)
ap.add_argument("--count-exp", type=float, default=0.4)
ap.add_argument("--count-exp", type=float, default=None,
help="count-to-size exponent; default 0.4 for the mic, 1.0 for --bird-weather")
ap.add_argument("--cluster-pad", type=int, default=1)
ap.add_argument("--small-floor", type=float, default=0.04)
ap.add_argument("--window-hours", type=int)
ap.add_argument("--bird-weather", action="store_true",
help="render from BirdWeather data for --zip instead of a local mic")
ap.add_argument("--zip", help="ZIP / postal code, required with --bird-weather")
ap.add_argument("--bw-days", type=int, default=7, help="--bird-weather lookback window in days")
ap.add_argument("--bw-country", default="us", help="--bird-weather geocoder country code")
ap.add_argument("--width", type=int, default=600)
ap.add_argument("--height", type=int, default=800)
ap.add_argument("--dsf", type=int, default=2)
@@ -181,13 +239,36 @@ def main():
ap.add_argument("--password")
ap.add_argument("--timeout", type=int, default=45000)
a = ap.parse_args()
url, title, subtitle, species, cutout_base = a.url, a.title, a.subtitle, None, None
if a.bird_weather:
if not a.zip:
print("--bird-weather needs --zip", file=sys.stderr)
sys.exit(2)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import birdweather
species = birdweather.species_for_zip(a.zip, country=a.bw_country, days=a.bw_days)
if not species:
print(f"no drawable birds near {a.zip}; nothing to render", file=sys.stderr)
sys.exit(3)
front = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend")
_httpd, port = _serve_frontend(front)
url = f"http://127.0.0.1:{port}/"
cutout_base = RAW_ILLUSTRATIONS
title = title or "Avian Visitors"
subtitle = subtitle or "Heard Today"
# BirdWeather's 7-day counts are flatter than a mic's, so they need a steeper
# exponent to get the same hero-bird hierarchy and collage shape.
count_exp = a.count_exp if a.count_exp is not None else (1.0 if a.bird_weather else 0.4)
headline_px = a.headline_px if a.headline_px is not None else (39 if a.bird_weather else 42)
eyebrow_px = a.eyebrow_px if a.eyebrow_px is not None else (17 if a.bird_weather else 18)
try:
shoot(a.url, a.out, title=a.title, subtitle=a.subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
headline_px=a.headline_px, eyebrow_px=a.eyebrow_px, lowercase=a.lowercase,
shoot(url, a.out, title=title, subtitle=subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
headline_px=headline_px, eyebrow_px=eyebrow_px, lowercase=a.lowercase,
mat=a.mat, collage_vh=a.collage_vh, cluster_xbias=a.cluster_xbias,
cluster_ybias=a.cluster_ybias, count_exp=a.count_exp, cluster_pad=a.cluster_pad,
cluster_ybias=a.cluster_ybias, count_exp=count_exp, cluster_pad=a.cluster_pad,
small_floor=a.small_floor,
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password)
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password,
species=species, cutout_base=cutout_base)
except Exception as e:
print(f"shoot failed: {e}", file=sys.stderr)
sys.exit(1)