Merge pull request #18 from Twarner491/installerillustrations

[FEAT] frame: backfill missing BirdWeather illustrations + change-detection refresh
This commit is contained in:
Teddy Warner
2026-06-30 17:16:26 -07:00
committed by GitHub
11 changed files with 12614 additions and 22107 deletions
+8
View File
@@ -66,3 +66,11 @@ Pick how the frame gets its birds:
Each one enables SPI + I2C, installs the deps and a systemd timer, writes `~/.birdframe/config.toml`, and reboots once to bring SPI up. Full options live in [`config.example.toml`](config.example.toml). Each one enables SPI + I2C, installs the deps and a systemd timer, writes `~/.birdframe/config.toml`, and reboots once to bring SPI up. Full options live in [`config.example.toml`](config.example.toml).
BirdWeather mode renders on the Pi from this repo's illustrations on GitHub, so there is no image set to copy over. ZIP codes with no station nearby fall back to the closest ones. If you are far from any BirdWeather station, add `--ebird-key <key>` (a free key from [ebird.org/api/keygen](https://ebird.org/api/keygen)) and the frame fills from eBird sightings instead. BirdWeather mode renders on the Pi from this repo's illustrations on GitHub, so there is no image set to copy over. ZIP codes with no station nearby fall back to the closest ones. If you are far from any BirdWeather station, add `--ebird-key <key>` (a free key from [ebird.org/api/keygen](https://ebird.org/api/keygen)) and the frame fills from eBird sightings instead.
The bundled illustrations center on the western U.S. If birds near your ZIP aren't in the set you cloned, the installer flags them and the frame skips them until they exist. To generate them, run [`generate_illustrations.py`](generate_illustrations.py) on a laptop or workstation (it uses the same rembg cutout as the rest of the pipeline, which the Pi can't fit in memory), passing your ZIP and a paid Google Gemini key, then commit the new cutouts or copy them to the Pi:
```bash
python3 generate_illustrations.py --zip 10001 --gemini-key YOUR_GEMINI_KEY
```
It generates only the species you're missing; `--country` and `--sample` carry through for non-US postcodes or a wider region.
+84 -4
View File
@@ -23,6 +23,7 @@ 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") APT_JS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend", "apt.js")
_drawable = None _drawable = None
_GEO_CACHE = os.path.expanduser("~/.birdframe/geocode.json")
def _graphql(query, timeout): def _graphql(query, timeout):
@@ -39,12 +40,37 @@ def _graphql(query, timeout):
def geocode(zip_code, country="us", timeout=20): def geocode(zip_code, country="us", timeout=20):
"""ZIP / postal code to (lat, lon) via the keyless zippopotam.us gazetteer.""" """ZIP / postal code to (lat, lon) via the keyless zippopotam.us gazetteer.
url = f"{GEOCODER}/{country}/{urllib.parse.quote(zip_code.strip())}" A ZIP's coordinates never move, so the result is cached on disk and the
frame geocodes once rather than on every 15-minute refresh."""
zip_code = zip_code.strip()
key = f"{country}/{zip_code}".lower()
store = {}
try:
with open(_GEO_CACHE) as f:
loaded = json.load(f)
if isinstance(loaded, dict):
store = loaded
hit = store.get(key)
if isinstance(hit, list) and len(hit) == 2: # shape-guard a hand-edited / stale entry
return float(hit[0]), float(hit[1])
except Exception:
store = {}
url = f"{GEOCODER}/{country}/{urllib.parse.quote(zip_code)}"
req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"}) req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as r: with urllib.request.urlopen(req, timeout=timeout) as r:
place = json.loads(r.read(200_000))["places"][0] place = json.loads(r.read(200_000))["places"][0]
return float(place["latitude"]), float(place["longitude"]) lat, lon = float(place["latitude"]), float(place["longitude"])
store[key] = [lat, lon]
try:
os.makedirs(os.path.dirname(_GEO_CACHE), exist_ok=True)
tmp = _GEO_CACHE + ".tmp"
with open(tmp, "w") as f:
json.dump(store, f)
os.replace(tmp, _GEO_CACHE) # atomic, like display.py's state write
except Exception:
pass
return lat, lon
def bbox(lat, lon, miles): def bbox(lat, lon, miles):
@@ -194,13 +220,67 @@ def species_for_zip(zip_code, country="us", target=10, days=7, radii=(15, 30, 50
return found[:target] return found[:target]
def coverage_for_zip(zip_code, country="us", sample=15, days=7, radii=(15, 30, 50),
apt_js=APT_JS, timeout=20):
"""Split the ZIP's most-detected birds by whether the repo can draw them.
Same gather as species_for_zip (grow the radius, then triangulate / eBird
for sparse spots) but UNFILTERED, capped at the top `sample` by count, then
partitioned. Returns (have, missing) as [{sci,com,n}] lists sorted by count:
`have` are bundled today, `missing` are the local birds with no cutout yet -
the set the installer offers to generate. `sample` sits a little above the
render's `target` (10) so a few extra cover BirdWeather count drift without
generating birds that never reach the collage. The split is always against
the current bundle, so adding illustrations upstream shrinks `missing` free.
"""
lat, lon = geocode(zip_code, country, timeout)
top = []
for miles in radii:
top = top_species(lat, lon, miles, days, 60, timeout)
if len(top) >= sample:
break
if len(top) < sample:
tri = triangulate(lat, lon, 3, days, timeout)
if len(tri) > len(top):
top = tri
if len(top) < sample:
eb = ebird_nearby(lat, lon, days * 2, timeout=timeout)
if len(eb) > len(top):
top = eb
top.sort(key=lambda s: -(s["n"] or 0))
top = top[:sample]
drawable = drawable_slugs(apt_js)
have = [s for s in top if slugify(s["sci"]) in drawable]
missing = [s for s in top if slugify(s["sci"]) not in drawable]
return have, missing
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
ap = argparse.ArgumentParser(description="Print BirdWeather top drawable species for a ZIP.") import sys
ap = argparse.ArgumentParser(description="BirdWeather top species for a ZIP.")
ap.add_argument("zip") ap.add_argument("zip")
ap.add_argument("--country", default="us") ap.add_argument("--country", default="us")
ap.add_argument("--target", type=int, default=10) ap.add_argument("--target", type=int, default=10)
ap.add_argument("--days", type=int, default=7) ap.add_argument("--days", type=int, default=7)
ap.add_argument("--missing", action="store_true",
help="Print the ZIP's top LOCAL birds the repo can't draw yet "
"as Sci|Com lines (feed to pregen.py --stdin); summary to stderr.")
ap.add_argument("--sample", type=int, default=15,
help="How many of the ZIP's top birds to weigh for --missing (default 15).")
a = ap.parse_args() a = ap.parse_args()
if a.missing:
try:
have, missing = coverage_for_zip(a.zip, a.country, a.sample, a.days)
except Exception as e:
# Never break the installer: no data just means no generation offer.
print(f"coverage lookup failed: {e}", file=sys.stderr)
sys.exit(0)
for s in missing:
print(f'{s["sci"]}|{s["com"]}')
total = len(have) + len(missing)
print(f"{len(have)} of the top {total} birds near {a.zip} are bundled; "
f"{len(missing)} are not.", file=sys.stderr)
else:
for s in species_for_zip(a.zip, a.country, a.target, a.days): for s in species_for_zip(a.zip, a.country, a.target, a.days):
print(f'{s["n"]:>8} {s["com"]} ({s["sci"]})') print(f'{s["n"]:>8} {s["com"]} ({s["sci"]})')
+8
View File
@@ -14,6 +14,14 @@ shoot = true
# Or read a PNG a sibling process drops next to us: # Or read a PNG a sibling process drops next to us:
# image = "~/.birdframe/frame.png" # image = "~/.birdframe/frame.png"
# Or render from BirdWeather for any ZIP, no mic (what install.sh --bird-weather
# writes). The panel still refreshes only when the local top birds change:
# species_source = "birdweather"
# zip = "94107"
# bw_days = 7 # BirdWeather lookback window, in days
# bw_country = "us" # geocoder country for the ZIP
# shoot = true # this Pi renders the collage
hours = 24 # window shown on the frame hours = 24 # window shown on the frame
# --- Titles and look (used when this Pi renders, i.e. shoot = true) ---------- # --- Titles and look (used when this Pi renders, i.e. shoot = true) ----------
+34 -9
View File
@@ -40,6 +40,10 @@ SPECTRA6 = [(236, 234, 223), (26, 26, 28), (165, 60, 56),
DEFAULTS = { DEFAULTS = {
"base_url": "http://birdnet.local", "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, "hours": 24,
"image": "", # local PNG written by the shooter "image": "", # local PNG written by the shooter
"image_url": "", # or a published screenshot URL "image_url": "", # or a published screenshot URL
@@ -93,6 +97,16 @@ def signature(species):
return hashlib.sha256(json.dumps(items).encode()).hexdigest()[:16] 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 ------------------------------------------------------------------ # --- image ------------------------------------------------------------------
def get_image(src, timeout, auth=None): def get_image(src, timeout, auth=None):
if re.match(r"^https?://", src): if re.match(r"^https?://", src):
@@ -190,17 +204,19 @@ def mat_and_center(img, mat, empty=False):
tb = _region_bbox(img, paper, top, split[0]) if split else None 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 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) box_w, box_h = A5_W * (1 - mat), A5_H * (1 - mat)
# No birds: the content under the title is just the one-line empty-state # No birds yet: hold the title where a full collage would put it and float
# note. Render a calm title card (a modest title with the small note # the one-line note where the birds would be, so a birdless frame reads like
# below) rather than blowing the lone title up to fill the opening. # a full one with the collage removed, not a title card drifted to the middle.
if empty and tb and cb: if empty and tb and cb:
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC) title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
note = _scale_w(img.crop(cb), box_w * 0.30) note = _scale_w(img.crop(cb), box_w * 0.30)
gap = round(box_h * 0.05) gap = round(box_h * GAP_FRAC)
region = box_w * COLLAGE_FRAC # stand in for a usual-size collage
cw = max(title.width, note.width) cw = max(title.width, note.width)
comp = Image.new("RGB", (cw, title.height + gap + note.height), paper) comp = Image.new("RGB", (cw, round(title.height + gap + region)), paper)
comp.paste(title, ((cw - title.width) // 2, 0)) comp.paste(title, ((cw - title.width) // 2, 0))
comp.paste(note, ((cw - note.width) // 2, title.height + gap)) ny = title.height + gap + max(0, (round(region) - note.height) // 2)
comp.paste(note, ((cw - note.width) // 2, ny))
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper) canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2)) canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
return canvas return canvas
@@ -298,7 +314,16 @@ def in_quiet_hours(cfg, hour):
# --- run -------------------------------------------------------------------- # --- run --------------------------------------------------------------------
def obtain_image(cfg): 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"]: if cfg["shoot"]:
from shoot import shoot from shoot import shoot
out = os.path.join(os.path.expanduser(cfg["cache"]), "shot.png") out = os.path.join(os.path.expanduser(cfg["cache"]), "shot.png")
@@ -322,7 +347,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
species = None species = None
if use_signature: if use_signature:
try: try:
species = fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg)) species = fetch_species(cfg, _auth(cfg))
sig = signature(species) sig = signature(species)
except Exception as e: except Exception as e:
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change
@@ -338,7 +363,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
print("refresh:", "changed" if changed else "heal") print("refresh:", "changed" if changed else "heal")
try: try:
img = fit_panel(obtain_image(cfg)) img = fit_panel(obtain_image(cfg, species))
except Exception as e: except Exception as e:
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
return return
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Generate the kachō-e illustrations a ZIP needs but the repo doesn't bundle.
Compares a ZIP's most-detected birds (BirdWeather) against the bundled
illustration set and, for the gap, runs the repo's illustration pipeline:
pregen.py (Gemini, cream ground)
-> cutout.py (BiRefNet matte via rembg)
-> build_masks.py (so the new birds become drawable)
The bundled set centers on the western U.S., so an out-of-region frame drops
its local birds until they are generated. Run this on a machine with the
pipeline deps (rembg + onnxruntime, per avian/scripts/requirements.txt) - a
laptop or workstation, not the frame Pi, which can't fit rembg in memory. Then
commit the new cutouts (or copy them to the Pi) and the frame draws them.
Needs a PAID Google Gemini API key: image generation is not on the free tier.
Get one at https://ai.google.dev, then pass it by env (keeps it out of shell
history) or let it prompt:
GEMINI_API_KEY=KEY python3 frame/generate_illustrations.py --zip 10001
python3 frame/generate_illustrations.py --zip 10001 # prompts for the key
python3 frame/generate_illustrations.py --zip 10001 --gemini-key KEY
--country and --sample carry through for non-US postcodes or a wider region.
"""
from __future__ import annotations
import argparse
import getpass
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPTS = os.path.join(HERE, "..", "avian", "scripts")
ILLUSTRATIONS = os.path.join(HERE, "..", "avian", "assets", "illustrations")
sys.path.insert(0, HERE)
import birdweather # noqa: E402 (local module, after sys.path is set)
def _generate(missing, key):
"""Run pregen -> cutout (rembg) -> build_masks for the missing species.
Returns 0 on success."""
lines = "".join(f'{s["sci"]}|{s["com"]}\n' for s in missing)
print(f"\nGenerating {len(missing)} birds x 2 poses with Gemini (a few minutes; "
f"~6s between calls to stay under the rate limit)...")
# Key via env, not argv, so it doesn't surface in `ps` during the long run.
env = dict(os.environ, GEMINI_API_KEY=key)
r = subprocess.run([sys.executable, os.path.join(SCRIPTS, "pregen.py"), "--stdin"],
input=lines, text=True, env=env)
if r.returncode not in (0, 1): # 1 = some species failed; still cut what landed
print("generation failed; leaving the bundle unchanged", file=sys.stderr)
return r.returncode
made = []
for s in missing:
base = birdweather.slugify(s["sci"])
made += [g for g in (base, base + "-2")
if os.path.isfile(os.path.join(ILLUSTRATIONS, g + ".png"))]
if not made:
print("no illustrations were produced; nothing to cut", file=sys.stderr)
return 1
print(f"\nCutting the cream ground off {len(made)} new illustrations (rembg)...")
c = subprocess.run([sys.executable, os.path.join(SCRIPTS, "cutout.py")] + made)
if c.returncode != 0:
print("cutout failed; not rebuilding masks", file=sys.stderr)
return c.returncode
print("\nRebuilding the collage masks so the new birds become drawable...")
b = subprocess.run([sys.executable, os.path.join(SCRIPTS, "build_masks.py")])
if b.returncode != 0:
return b.returncode
print(f"\nDone - {len(made)} new illustrations are cut and drawable. Commit them "
f"(or copy them to the frame Pi) and the collage will draw them.")
return 0
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--zip", required=True)
ap.add_argument("--country", default="us")
ap.add_argument("--gemini-key", default=os.environ.get("GEMINI_API_KEY", ""),
help="Paid Google Gemini API key (or GEMINI_API_KEY env).")
ap.add_argument("--sample", type=int, default=15,
help="How many of the ZIP's top birds to weigh (default 15).")
a = ap.parse_args()
try:
have, missing = birdweather.coverage_for_zip(a.zip, a.country, a.sample)
except Exception as e:
print(f"coverage lookup for {a.zip} failed ({e})", file=sys.stderr)
return 1
total = len(have) + len(missing)
if not missing:
if total == 0:
print(f"Couldn't find local birds for {a.zip} (BirdWeather may have no "
f"station nearby).")
else:
print(f"All {total} of the top birds near {a.zip} are already bundled - "
f"nothing to generate.")
return 0
names = ", ".join(s["com"] for s in missing[:6]) + (" ..." if len(missing) > 6 else "")
print(f"{len(missing)} of the top {total} birds near {a.zip} aren't bundled:\n {names}")
key = a.gemini_key
if not key:
if not sys.stdin.isatty():
print("\nPass --gemini-key <KEY> (a paid Google Gemini key, https://ai.google.dev) "
"to generate them.", file=sys.stderr)
return 1
key = getpass.getpass("\nPaste your Gemini API key (paid; https://ai.google.dev): ").strip()
if not key:
print("No key entered.", file=sys.stderr)
return 1
return _generate(missing, key)
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+44 -34
View File
@@ -141,42 +141,34 @@ elif [ "$MODE" = image ]; then
printf '%s\n' 'saturation = 0.6' printf '%s\n' 'saturation = 0.6'
} > "$CONFIG" } > "$CONFIG"
else else
{ printf '%s\n' '# birdframe-mode: birdweather'; cat config.example.toml; } > "$CONFIG" # birdweather: this Pi renders from BirdWeather near $ZIP, gated on the same
# signature as the other modes - it only redraws when the local top birds change.
{
printf '%s\n' '# birdframe-mode: birdweather'
printf '%s\n' '# AvianVisitors frame, BirdWeather mode: renders the top birds near a ZIP.'
printf '%s\n' 'species_source = "birdweather"'
printf 'zip = "%s"\n' "$ZIP"
printf '%s\n' 'bw_days = 7 # BirdWeather lookback window, in days'
printf '%s\n' 'bw_country = "us" # geocoder country for the ZIP'
printf '%s\n' 'shoot = true # this Pi renders the collage'
printf '%s\n' 'shoot_title = "Avian Visitors"'
printf '%s\n' 'shoot_subtitle = "Heard Today"'
printf '%s\n' 'rotate = 90 # flip to 270 if the frame hangs the other way up'
printf '%s\n' 'saturation = 0.6'
} > "$CONFIG"
fi fi
echo "5/5 Installing systemd service + timer..." echo "5/5 Installing systemd service + timer..."
if [ "$MODE" = birdweather ]; then # Every mode runs display.py against the config on the standard 15-minute timer;
PY="$FRAME/.venv/bin/python" # only the config differs. display.py renders inline for local + birdweather and
PNG="$HOME/.birdframe/frame.png" # pushes to the panel only when the birds change.
sudo tee /etc/systemd/system/birdframe.service >/dev/null <<SERVICE sed "s|/home/monalisa/AvianVisitors/frame|$FRAME|g; s|/home/monalisa|$HOME|g; s|User=monalisa|User=$USER|" \
[Unit]
Description=AvianVisitors frame, BirdWeather mode (ZIP $ZIP)
Documentation=https://github.com/Twarner491/AvianVisitors
Wants=network-online.target
After=network-online.target
[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
# Remote ZIPs with no nearby station fall back to eBird, which needs a key.
if [ -n "$EBIRD_KEY" ]; then
echo "Environment=EBIRD_API_KEY=$EBIRD_KEY" | sudo tee -a /etc/systemd/system/birdframe.service >/dev/null
fi
# 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
# local + image both run display.py against the config; only the config differs.
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 systemd/birdframe.service | sudo tee /etc/systemd/system/birdframe.service >/dev/null
sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer # BirdWeather's remote-ZIP eBird fallback reads its key from the unit environment.
if [ "$MODE" = birdweather ] && [ -n "$EBIRD_KEY" ]; then
echo "Environment=EBIRD_API_KEY=$EBIRD_KEY" | sudo tee -a /etc/systemd/system/birdframe.service >/dev/null
fi fi
sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now birdframe.timer # --now starts it immediately, not only on the next boot sudo systemctl enable --now birdframe.timer # --now starts it immediately, not only on the next boot
@@ -201,10 +193,28 @@ DONE
birdweather) birdweather)
cat <<DONE cat <<DONE
Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds near
near you on the Pi and refreshes every 6 hours. Cutouts come from the repo on you on the Pi and refreshes every 15 min, only when the local top birds change.
GitHub, so add illustrations there for any local birds it is missing.
DONE DONE
# The bundled illustrations center on the western U.S. If birds near this ZIP
# aren't in the cloned set the frame quietly skips them, which has tripped
# people up - surface it here and point at the generator.
MISSING="$("$FRAME/.venv/bin/python" "$FRAME/birdweather.py" "$ZIP" --missing 2>/dev/null || true)"
if [ -n "$MISSING" ]; then
N="$(printf '%s\n' "$MISSING" | grep -c . || true)"
NAMES="$(printf '%s\n' "$MISSING" | head -8 | sed 's/.*|/ /')"
if [ "$N" -gt 8 ]; then NAMES="$NAMES
... and $((N - 8)) more"; fi
cat <<FLAG
Heads up: $N local bird(s) near you aren't in the illustration set you cloned, so
the frame will skip them:
$NAMES
To add them, run this on a laptop or workstation (it needs rembg, which the Pi
can't fit) and commit or copy the new cutouts over:
python3 $FRAME/generate_illustrations.py --zip $ZIP --gemini-key <KEY>
A paid Google Gemini API key is needed: https://ai.google.dev
FLAG
fi
;; ;;
esac esac
+63 -23
View File
@@ -31,8 +31,10 @@ import urllib.parse
from playwright.sync_api import TimeoutError as PWTimeout from playwright.sync_api import TimeoutError as PWTimeout
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
# --bird-weather pulls cutouts straight from the repo's raw GitHub URLs, so the # --bird-weather resolves cutouts from the local clone first, then falls back to
# Pi never bundles the illustration set and picks up new birds with no redeploy. # the repo's raw GitHub URLs: a fresh install needs no illustration redeploy,
# upstream additions arrive with a git pull, and cutouts you generate and copy
# into the clone render even before they reach GitHub.
RAW_ILLUSTRATIONS = ("https://raw.githubusercontent.com/Twarner491/AvianVisitors/" RAW_ILLUSTRATIONS = ("https://raw.githubusercontent.com/Twarner491/AvianVisitors/"
"avian-visitors/avian/assets/illustrations/") "avian-visitors/avian/assets/illustrations/")
@@ -118,16 +120,22 @@ def _serve_frontend(directory):
return httpd, httpd.server_address[1] return httpd, httpd.server_address[1]
def _make_cutout_handler(base): def _make_cutout_handler(base, local_dir=None):
"""Redirect each cutout.php lookup to the bird's raw illustration on GitHub. """Resolve each cutout.php lookup to the bird's illustration. Serve a local
Trusts species_for_zip to pre-filter to drawable slugs, so a redirect only file first when `local_dir` has it - that is how cutouts you generate and copy
lands on a missing file if the repo is mid-update.""" into the clone render before they reach GitHub - otherwise 302 to the raw
GitHub copy. Trusts species_for_zip to pre-filter to drawable slugs, so the
GitHub fallback only lands on a missing file if the repo is mid-update."""
def handler(route): def handler(route):
try: try:
params = urllib.parse.parse_qs(urllib.parse.urlparse(route.request.url).query) 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("-") slug = re.sub(r"[^a-z0-9]+", "-", (params.get("sci") or [""])[0].lower()).strip("-")
if (params.get("pose") or ["1"])[0] == "2": if (params.get("pose") or ["1"])[0] == "2":
slug += "-2" slug += "-2"
if local_dir:
local = os.path.join(local_dir, slug + ".png")
if os.path.isfile(local):
return route.fulfill(path=local)
route.fulfill(status=302, headers={"location": base + slug + ".png"}) route.fulfill(status=302, headers={"location": base + slug + ".png"})
except Exception: except Exception:
_safe_continue(route) _safe_continue(route)
@@ -158,7 +166,8 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
headline_px=42, eyebrow_px=18, lowercase=False, headline_px=42, eyebrow_px=18, lowercase=False,
mat=0.04, collage_vh=52, cluster_xbias=1.0, cluster_ybias=1.2, 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, count_exp=0.4, cluster_pad=1, small_floor=0.04, window_hours=None,
timeout_ms=45000, user=None, password=None, species=None, cutout_base=None): timeout_ms=45000, user=None, password=None, species=None, cutout_base=None,
cutout_local=None):
pad_side, pad_top, pad_bottom = int(vw * mat), int(vh * mat * 0.92), int(vh * mat) 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 auth = "Basic " + base64.b64encode(f"{user}:{password or ''}".encode()).decode() if user else None
@@ -173,7 +182,7 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
page.route("**/birdnet-api.php**", _make_api_handler(small_floor, window_hours, auth, species)) 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)) page.route("**/apt.js*", _make_js_handler(cluster_xbias, cluster_ybias, count_exp, cluster_pad, auth, misses))
if cutout_base: if cutout_base:
page.route("**/cutout.php*", _make_cutout_handler(cutout_base)) page.route("**/cutout.php*", _make_cutout_handler(cutout_base, cutout_local))
css = HIDE_CSS + _frame_css(headline_px, eyebrow_px, lowercase, pad_top, pad_side, pad_bottom, collage_vh) css = HIDE_CSS + _frame_css(headline_px, eyebrow_px, lowercase, pad_top, pad_side, pad_bottom, collage_vh)
page.add_init_script( page.add_init_script(
@@ -216,6 +225,30 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
return out return out
def shoot_birdweather(out, species, *, title=None, subtitle=None, timeout_ms=45000, **look):
"""Render `species` ([{sci,com,n}]) as the BirdWeather collage into `out`.
The mic path screenshots a live site; this builds the same page from a
species list instead. It serves the bundled frontend on localhost, feeds it
the species, and routes cutouts to the local clone first then GitHub, so the
--bird-weather CLI and display.py's inline render share one setup. An empty
list renders the page's empty-state card, the same as the mic mode. `look`
overrides any shoot() tunable (the CLI passes its flags through)."""
if species is None:
raise RuntimeError("shoot_birdweather needs a species list")
here = os.path.dirname(os.path.abspath(__file__))
_httpd, port = _serve_frontend(os.path.join(here, "..", "avian", "frontend"))
cutout_local = os.path.join(here, "..", "avian", "assets", "illustrations")
# BirdWeather's flat 7-day counts need a steeper exponent for the same hero
# hierarchy; the slightly smaller titles match the mic frame's optical weight.
for k, v in (("count_exp", 1.0), ("headline_px", 39), ("eyebrow_px", 17)):
look.setdefault(k, v)
return shoot(f"http://127.0.0.1:{port}/", out,
title=title or "Avian Visitors", subtitle=subtitle or "Heard Today",
species=species, cutout_base=RAW_ILLUSTRATIONS, cutout_local=cutout_local,
timeout_ms=timeout_ms, **look)
def main(): def main():
ap = argparse.ArgumentParser(description="Screenshot the AvianVisitors collage for the e-ink frame.") ap = argparse.ArgumentParser(description="Screenshot the AvianVisitors collage for the e-ink frame.")
ap.add_argument("--url", default="http://birdnet.local") ap.add_argument("--url", default="http://birdnet.local")
@@ -248,7 +281,6 @@ def main():
ap.add_argument("--password") ap.add_argument("--password")
ap.add_argument("--timeout", type=int, default=45000) ap.add_argument("--timeout", type=int, default=45000)
a = ap.parse_args() a = ap.parse_args()
url, title, subtitle, species, cutout_base = a.url, a.title, a.subtitle, None, None
if a.bird_weather: if a.bird_weather:
if not a.zip: if not a.zip:
print("--bird-weather needs --zip", file=sys.stderr) print("--bird-weather needs --zip", file=sys.stderr)
@@ -259,25 +291,33 @@ def main():
if not species: if not species:
print(f"no drawable birds near {a.zip}; nothing to render", file=sys.stderr) print(f"no drawable birds near {a.zip}; nothing to render", file=sys.stderr)
sys.exit(3) sys.exit(3)
front = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend") # Pass the CLI's look flags through; shoot_birdweather fills the bird-weather
_httpd, port = _serve_frontend(front) # defaults (steeper count exponent, smaller titles) for anything left unset.
url = f"http://127.0.0.1:{port}/" look = {k: v for k, v in (("count_exp", a.count_exp), ("headline_px", a.headline_px),
cutout_base = RAW_ILLUSTRATIONS ("eyebrow_px", a.eyebrow_px)) if v is not None}
title = title or "Avian Visitors" look.update(vw=a.width, vh=a.height, dsf=a.dsf, mat=a.mat, collage_vh=a.collage_vh,
subtitle = subtitle or "Heard Today" cluster_xbias=a.cluster_xbias, cluster_ybias=a.cluster_ybias,
# BirdWeather's 7-day counts are flatter than a mic's, so they need a steeper cluster_pad=a.cluster_pad, small_floor=a.small_floor, lowercase=a.lowercase,
# exponent to get the same hero-bird hierarchy and collage shape. window_hours=a.window_hours, user=a.user, password=a.password)
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: try:
shoot(url, a.out, title=title, subtitle=subtitle, vw=a.width, vh=a.height, dsf=a.dsf, shoot_birdweather(a.out, species, title=a.title, subtitle=a.subtitle,
timeout_ms=a.timeout, **look)
except Exception as e:
print(f"shoot failed: {e}", file=sys.stderr)
sys.exit(1)
print(f"wrote {a.out}")
return
# Mic path: screenshot the live AvianVisitors site at --url.
count_exp = a.count_exp if a.count_exp is not None else 0.4
headline_px = a.headline_px if a.headline_px is not None else 42
eyebrow_px = a.eyebrow_px if a.eyebrow_px is not None 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=headline_px, eyebrow_px=eyebrow_px, lowercase=a.lowercase, headline_px=headline_px, eyebrow_px=eyebrow_px, lowercase=a.lowercase,
mat=a.mat, collage_vh=a.collage_vh, cluster_xbias=a.cluster_xbias, mat=a.mat, collage_vh=a.collage_vh, cluster_xbias=a.cluster_xbias,
cluster_ybias=a.cluster_ybias, count_exp=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, 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: except Exception as e:
print(f"shoot failed: {e}", file=sys.stderr) print(f"shoot failed: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+1 -1
View File
@@ -12,4 +12,4 @@ WorkingDirectory=/home/monalisa/AvianVisitors/frame
ExecStart=/home/monalisa/AvianVisitors/frame/.venv/bin/python /home/monalisa/AvianVisitors/frame/display.py --config /home/monalisa/.birdframe/config.toml ExecStart=/home/monalisa/AvianVisitors/frame/.venv/bin/python /home/monalisa/AvianVisitors/frame/display.py --config /home/monalisa/.birdframe/config.toml
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Nice=10 Nice=10
TimeoutStartSec=180 TimeoutStartSec=300