[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
+1 -1
View File
@@ -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.
---
+12
View File
@@ -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.
+206
View File
@@ -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"]})')
+67 -7
View File
@@ -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 <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 <ZIP code>, 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 <<SERVICE
[Unit]
Description=AvianVisitors frame, BirdWeather mode (ZIP $ZIP)
Documentation=https://github.com/Twarner491/AvianVisitors
Wants=network-online.target
After=network-online.target
cat <<DONE
[Service]
Type=oneshot
User=$USER
WorkingDirectory=$FRAME
ExecStart=/bin/sh -c '$PY $FRAME/shoot.py --bird-weather --zip "$ZIP" --out $PNG && $PY $FRAME/display.py --config $HOME/.birdframe/config.toml --image-url $PNG --no-signature --force'
Environment=PYTHONUNBUFFERED=1
Nice=10
TimeoutStartSec=300
SERVICE
# BirdWeather's recent-species list drifts slowly, so refresh a few times a day.
sed 's|OnUnitActiveSec=.*|OnUnitActiveSec=6h|' systemd/birdframe.timer \
| sudo tee /etc/systemd/system/birdframe.timer >/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 <<DONE
Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds
near you on the Pi and refreshes every 6 hours. Cutouts come from the repo on
GitHub, so add illustrations there for any local birds it is missing.
DONE
else
cat <<DONE
Installed. Set your image source in ~/.birdframe/config.toml (your /frame.png
key, or shoot = true); the panel fills itself in and refreshes every 15 min,
only when the birds change.
DONE
fi
# SPI only takes effect on a reboot, so do it for the user. Skip if SPI is
# already up (e.g. a re-run) so we don't bounce a working frame.
+93 -12
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,14 +70,21 @@ 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:
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:
@@ -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)