From 10548dbca7353c7a6adbb434400f8c79c031294f Mon Sep 17 00:00:00 2001 From: Teddy Warner Date: Fri, 19 Jun 2026 19:52:21 -0700 Subject: [PATCH] [FEAT] frame: --bird-weather draws the collage from a ZIP code, no mic (#12) --- README.md | 2 +- frame/README.md | 12 +++ frame/birdweather.py | 206 +++++++++++++++++++++++++++++++++++++++++++ frame/install.sh | 74 ++++++++++++++-- frame/shoot.py | 115 ++++++++++++++++++++---- 5 files changed, 384 insertions(+), 25 deletions(-) create mode 100644 frame/birdweather.py diff --git a/README.md b/README.md index 95c264d..c1bdfe4 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Everything outside `avian/` and `frame/` is upstream BirdNET-Pi. ## Wall frame -An optional e-ink frame mirrors the last 24h of birds onto a panel by your window. Build it from [`frame/`](frame/README.md). +An optional e-ink frame mirrors the last 24h of birds onto a panel by your window. Build it from [`frame/`](frame/README.md). It can run off your own BirdNET mic, or standalone from BirdWeather data for any ZIP code with no mic at all. --- diff --git a/frame/README.md b/frame/README.md index 8a18a7e..f8170fa 100644 --- a/frame/README.md +++ b/frame/README.md @@ -56,3 +56,15 @@ image_url = "https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY" ``` No Worker? Set `shoot = true` to screenshot on any capable host instead. Full options are in [`config.example.toml`](config.example.toml). + +--- + +## No bird mic? BirdWeather mode + +To run the frame on its own, with no mic and no website, install with `--bird-weather` and your ZIP code instead of step 3: + +```bash +cd AvianVisitors/frame && ./install.sh --bird-weather --zip 94107 +``` + +It pulls the top recently-heard birds near that ZIP code from [BirdWeather](https://app.birdweather.com) and renders the collage on the Pi itself, refreshing every few hours. Cutouts load from this repo's illustrations on GitHub, so there is no image set to copy over; add illustrations for any local birds it is missing the same way you would for the site. ZIP codes with no station nearby fall back to the closest ones, and an optional `EBIRD_API_KEY` covers the most remote spots. diff --git a/frame/birdweather.py b/frame/birdweather.py new file mode 100644 index 0000000..5f149ea --- /dev/null +++ b/frame/birdweather.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Top recently-heard birds near a ZIP, from BirdWeather, for the frame's +--bird-weather mode. + +Returns the shape the collage already expects, [{"sci","com","n"}], so the +existing renderer draws it unchanged. Standalone (Python stdlib only). Filtered +to species the collage can actually draw, read from the slug list bundled in +apt.js, so no network call and it tracks whatever illustrations the repo ships. +BirdWeather's public GraphQL needs no key. +""" +from __future__ import annotations + +import json +import math +import os +import re +import urllib.parse +import urllib.request + +BIRDWEATHER = "https://app.birdweather.com/graphql" +GEOCODER = "https://api.zippopotam.us" +EBIRD = "https://api.ebird.org/v2/data/obs/geo/recent" +APT_JS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend", "apt.js") + +_drawable = None + + +def _graphql(query, timeout): + req = urllib.request.Request( + BIRDWEATHER, + data=json.dumps({"query": query}).encode(), + headers={"Content-Type": "application/json", "User-Agent": "AvianVisitors-frame/1.0"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read(2_000_000)) + except Exception: + return {} # a transient upstream failure degrades to the next radius or fallback + + +def geocode(zip_code, country="us", timeout=20): + """ZIP / postal code to (lat, lon) via the keyless zippopotam.us gazetteer.""" + url = f"{GEOCODER}/{country}/{urllib.parse.quote(zip_code.strip())}" + req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + place = json.loads(r.read(200_000))["places"][0] + return float(place["latitude"]), float(place["longitude"]) + + +def bbox(lat, lon, miles): + """Square box `miles` to each side of the point. Returns (ne, sw) corners.""" + dlat = miles / 69.0 + dlon = miles / (69.0 * max(0.2, math.cos(math.radians(lat)))) + return (lat + dlat, lon + dlon), (lat - dlat, lon - dlon) + + +def top_species(lat, lon, miles, days=7, limit=60, timeout=20): + """BirdWeather's most-detected species in the box, as [{sci,com,n}].""" + (ne_lat, ne_lon), (sw_lat, sw_lon) = bbox(lat, lon, miles) + query = ( + '{ topSpecies(period: {count: %d, unit: "day"}, ' + 'ne: {lat: %f, lon: %f}, sw: {lat: %f, lon: %f}, limit: %d) ' + '{ count species { commonName scientificName } } }' + % (days, ne_lat, ne_lon, sw_lat, sw_lon, limit) + ) + rows = (_graphql(query, timeout).get("data") or {}).get("topSpecies") or [] + out = [] + for row in rows: + sp = row.get("species") or {} + sci = sp.get("scientificName") + if sci: + out.append({"sci": sci, "com": sp.get("commonName") or sci, "n": row.get("count") or 1}) + return out + + +def _haversine(lat1, lon1, lat2, lon2): + """Great-circle distance between two points, in miles.""" + r = 3958.8 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp, dl = math.radians(lat2 - lat1), math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +def nearest_stations(lat, lon, n=3, boxes=(50, 150, 400), timeout=20): + """The n closest BirdWeather stations to the point, as [(miles, id)], found + by growing the search box until at least n are in view.""" + nodes = [] + for miles in boxes: + (ne_lat, ne_lon), (sw_lat, sw_lon) = bbox(lat, lon, miles) + query = ('{ stations(ne: {lat: %f, lon: %f}, sw: {lat: %f, lon: %f}, first: 200) ' + '{ nodes { id coords { lat lon } } } }' % (ne_lat, ne_lon, sw_lat, sw_lon)) + nodes = (((_graphql(query, timeout).get("data") or {}).get("stations") or {}).get("nodes")) or [] + if len(nodes) >= n: + break + out = [] + for node in nodes: + c = node.get("coords") or {} + if c.get("lat") is not None and c.get("lon") is not None: + out.append((_haversine(lat, lon, c["lat"], c["lon"]), node["id"])) + out.sort() + return out[:n] + + +def triangulate(lat, lon, n=3, days=7, timeout=20): + """Estimate the birds near a point from the n closest stations, inverse- + distance weighted so the nearest station counts most. Used where the local + box has too few stations to fill a collage, so remote ZIPs still get birds.""" + weighted = {} + for miles, sid in nearest_stations(lat, lon, n, timeout=timeout): + weight = 1.0 / max(miles, 1.0) + query = ('{ topSpecies(stationIds: [%s], period: {count: %d, unit: "day"}, limit: 40) ' + '{ count species { commonName scientificName } } }' % (sid, days)) + for row in (((_graphql(query, timeout).get("data") or {}).get("topSpecies")) or []): + sp = row.get("species") or {} + sci = sp.get("scientificName") + if not sci: + continue + entry = weighted.setdefault(sci, {"com": sp.get("commonName") or sci, "score": 0.0}) + entry["score"] += (row.get("count") or 0) * weight + out = [{"sci": sci, "com": e["com"], "n": max(1, round(e["score"]))} for sci, e in weighted.items()] + out.sort(key=lambda s: -s["n"]) + return out + + +def ebird_nearby(lat, lon, days=14, key=None, timeout=20): + """Recent eBird observations near the point, as [{sci,com,n}]. The deepest + fallback, for spots with no station in range. Needs a free eBird API key in + EBIRD_API_KEY and returns [] without one, so keyless installs just skip it.""" + key = key or os.environ.get("EBIRD_API_KEY") + if not key: + return [] + url = f"{EBIRD}?lat={lat:.4f}&lng={lon:.4f}&dist=50&back={min(days, 30)}" + req = urllib.request.Request(url, headers={"X-eBirdApiToken": key, "User-Agent": "AvianVisitors-frame/1.0"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + observations = json.loads(r.read(5_000_000)) + except Exception: + return [] + tally = {} + for o in observations: + sci = o.get("sciName") + if not sci: + continue + entry = tally.setdefault(sci, {"com": o.get("comName") or sci, "n": 0}) + entry["n"] += o.get("howMany") or 1 + out = [{"sci": sci, "com": e["com"], "n": e["n"]} for sci, e in tally.items()] + out.sort(key=lambda s: -s["n"]) + return out + + +def slugify(sci): + return re.sub(r"[^a-z0-9]+", "-", sci.lower()).strip("-") + + +def drawable_slugs(apt_js=APT_JS): + """Base slugs we have a cutout for, parsed once from the collage's bundled + DIMS table (perched and flight entries collapse to the base slug).""" + global _drawable + if _drawable is None: + with open(apt_js, encoding="utf-8") as f: + block = re.search(r"var DIMS = (\{.*?\});", f.read(), re.S) + keys = re.findall(r'"([a-z0-9-]+)"\s*:', block.group(1)) if block else [] + _drawable = {re.sub(r"-2$", "", k) for k in keys} + return _drawable + + +def species_for_zip(zip_code, country="us", target=10, days=7, radii=(15, 30, 50), + apt_js=APT_JS, timeout=20): + """Geocode the ZIP, pull BirdWeather top species, and grow the search radius + only until `target` drawable species are found, so it stays as local as the + data allows. Returns the top `target` by detection count, or fewer where + birds or stations are sparse. + """ + lat, lon = geocode(zip_code, country, timeout) + have = drawable_slugs(apt_js) + found = [] + for miles in radii: + found = [s for s in top_species(lat, lon, miles, days, 60, timeout) + if slugify(s["sci"]) in have] + if len(found) >= target: + break + if len(found) < target: + # box too sparse: estimate from the nearest stations so remote ZIPs still fill + tri = [s for s in triangulate(lat, lon, 3, days, timeout) if slugify(s["sci"]) in have] + if len(tri) > len(found): + found = tri + if len(found) < target: + # no station in range at all: fall back to eBird sightings, if a key is set + eb = [s for s in ebird_nearby(lat, lon, days * 2, timeout=timeout) if slugify(s["sci"]) in have] + if len(eb) > len(found): + found = eb + found.sort(key=lambda s: -(s["n"] or 0)) + return found[:target] + + +if __name__ == "__main__": + import argparse + ap = argparse.ArgumentParser(description="Print BirdWeather top drawable species for a ZIP.") + ap.add_argument("zip") + ap.add_argument("--country", default="us") + ap.add_argument("--target", type=int, default=10) + ap.add_argument("--days", type=int, default=7) + a = ap.parse_args() + for s in species_for_zip(a.zip, a.country, a.target, a.days): + print(f'{s["n"]:>8} {s["com"]} ({s["sci"]})') diff --git a/frame/install.sh b/frame/install.sh index 073a168..577b18d 100755 --- a/frame/install.sh +++ b/frame/install.sh @@ -1,10 +1,31 @@ #!/usr/bin/env bash # Install the AvianVisitors e-ink frame (display side) on a Raspberry Pi. # Enables SPI + I2C, installs deps, makes a venv, installs the systemd timer. +# +# Default mode mirrors a local bird mic (set the image source in the config). +# Pass --bird-weather --zip to instead run the frame standalone from +# BirdWeather data for that ZIP: it renders on the Pi and pulls cutouts from the +# repo's raw GitHub URLs, so no mic and no local illustration folder are needed. set -euo pipefail cd "$(dirname "$0")" FRAME="$(pwd)" +BIRD_WEATHER=0 +ZIP="" +while [ $# -gt 0 ]; do + case "$1" in + --bird-weather) BIRD_WEATHER=1 ;; + --zip) ZIP="${2:-}"; shift ;; + --zip=*) ZIP="${1#*=}" ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac + shift +done +if [ "$BIRD_WEATHER" = 1 ] && [ -z "$ZIP" ]; then + echo "--bird-weather needs --zip , e.g. install.sh --bird-weather --zip 94107" >&2 + exit 1 +fi + CONFIG_TXT=/boot/firmware/config.txt [ -f "$CONFIG_TXT" ] || CONFIG_TXT=/boot/config.txt @@ -21,24 +42,63 @@ echo "3/5 Creating venv and installing Python deps..." python3 -m venv .venv .venv/bin/pip install -q --upgrade pip .venv/bin/pip install -q -r requirements-frame.txt +if [ "$BIRD_WEATHER" = 1 ]; then + echo " BirdWeather mode: installing Playwright + Chromium for on-Pi rendering (a few minutes)..." + .venv/bin/pip install -q playwright + sudo .venv/bin/playwright install-deps chromium + .venv/bin/playwright install chromium +fi echo "4/5 Setting up config..." mkdir -p "$HOME/.birdframe" [ -f "$HOME/.birdframe/config.toml" ] || cp config.example.toml "$HOME/.birdframe/config.toml" -echo "5/5 Installing systemd timer..." -sed "s|/home/monalisa/AvianVisitors/frame|$FRAME|g; s|/home/monalisa|$HOME|g; s|User=monalisa|User=$USER|" \ - systemd/birdframe.service | sudo tee /etc/systemd/system/birdframe.service >/dev/null -sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer -sudo systemctl daemon-reload -sudo systemctl enable birdframe.timer +echo "5/5 Installing systemd service + timer..." +if [ "$BIRD_WEATHER" = 1 ]; then + PY="$FRAME/.venv/bin/python" + PNG="$HOME/.birdframe/frame.png" + sudo tee /etc/systemd/system/birdframe.service >/dev/null </dev/null +else + sed "s|/home/monalisa/AvianVisitors/frame|$FRAME|g; s|/home/monalisa|$HOME|g; s|User=monalisa|User=$USER|" \ + systemd/birdframe.service | sudo tee /etc/systemd/system/birdframe.service >/dev/null + sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer +fi +sudo systemctl daemon-reload +sudo systemctl enable --now birdframe.timer # --now starts it immediately, not only on the next boot + +if [ "$BIRD_WEATHER" = 1 ]; then + cat < 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)