AvianVisitors hardening pass — install via fork's newinstaller

Restructures the install so users run ONE command. The fork's
newinstaller.sh clones this repo (instead of upstream Nachtzuster) and
runs BirdNET-Pi's installer. install_services.sh symlinks the avian/
overlay into the Caddy site root, so http://birdnet.local/avian/ comes
up after reboot with no Caddy config needed.

Reviewer-flagged fixes:

Frontend (avian/frontend/apt.js)
- Switch all API paths from /api/*.json (which didn't route) to relative
  action-based URLs (./api/birdnet-api.php?action=recent etc.).
- Fetch ./masks.json + ./dims.json relatively (were /avian/*.json).
- Replace every innerHTML user-content sink with createElement +
  textContent + setAttribute — species labels are user-editable in
  BirdNET-Pi, so untrusted.
- Add esc() helper for the few remaining innerHTML attribute contexts.
- fetchJson throws on non-200 (was silently parsing 404 HTML as JSON).
- Defensive null-check in loadMask if MASKS hasn't loaded.

Backend (avian/api/*.php)
- birdnet-api.php now derives DB_PATH from dirname(__DIR__, 3) so any
  BirdNET-Pi install user works (no hardcoded /home/birdnet).
- recording/spectrogram/cutout use getenv('HOME') for the same reason.
- cutout.php reworked as the canonical image resolver: bundled
  illustration → bundled cutout → cached rembg → fresh
  Wikipedia+rembg, with the binomial regex guard and atomic rename
  on cache write. Wikipedia URL host pinned to wikimedia.org /
  wikipedia.org to block SSRF.
- Stale 'X-BirdNET-Proxy-Token' auth claims removed everywhere.

Pregen (avian/scripts/pregen.py)
- Gemini key moved from URL query to x-goog-api-key header.
- Model bumped to gemini-2.5-flash-image-preview.
- Bounded retry on 429 + 5xx with Retry-After honoured.
- Default --sleep raised from 1s to 4s (under free-tier RPM).
- Unified parser handles |/_/comma in all three input modes.
- responseModalities now [TEXT, IMAGE] (was IMAGE-only).
- --poses validated against POSES keys.
- ASCII [ok]/[fail] markers (was ✓/✗ — Windows console crash).
- Surfaces finishReason + blockReason on safety blocks.

Prompt template
- 'Two placeholders' typo → three.
- 'warm paper' / 'transparent background' contradiction resolved.

Forwarding
- mqtt-bridge.py: paho-mqtt 2.x CallbackAPIVersion compat shim.
- avian-mqtt.service: dropped broken %i, set User=birdnet + %h path.
- forwarding/README.md uses the actual action-based URLs.

README
- One-line install via curl … newinstaller.sh.
- No more 'install BirdNET-Pi separately' step (our fork does it).
- License section uses absolute github.com links.
- bird.onethreenine.net replaces the not-yet-existent project writeup.
- docs/thumb.png replaced with the 24-bird Twitter capture.

Removed
- avian/caddy/ and avian/scripts/install.sh — no longer needed; the
  overlay symlink in install_services.sh handles everything.
This commit is contained in:
Twarner491
2026-05-28 10:49:41 -07:00
parent d0ad1454c4
commit 2ec45b9138
17 changed files with 516 additions and 504 deletions
+43 -59
View File
@@ -2,7 +2,7 @@
*A live bird collage from your apartment window.* *A live bird collage from your apartment window.*
A frontend overlay for [BirdNET-Pi](https://github.com/Nachtzuster/BirdNET-Pi). Listens to your balcony, identifies every passing bird with Cornell's [BirdNET](https://birdnet.cornell.edu/), and renders the detections as a tile-packed kachō-e print at `http://birdnet.local/collage/`. Full project writeup at [teddywarner.org/Projects/AvianVisitors](https://teddywarner.org/Projects/AvianVisitors/). A fork of [BirdNET-Pi](https://github.com/Nachtzuster/BirdNET-Pi) that adds a tile-packed kachō-e collage UI. Listens to your balcony with Cornell's [BirdNET](https://birdnet.cornell.edu/), illustrates every detected species in the style of Edo-period Japanese woodblock prints, and renders them sized by call count. See it running at [bird.onethreenine.net](https://bird.onethreenine.net).
<img alt="avianvisitors collage" src="docs/thumb.png" /> <img alt="avianvisitors collage" src="docs/thumb.png" />
@@ -10,100 +10,84 @@ A frontend overlay for [BirdNET-Pi](https://github.com/Nachtzuster/BirdNET-Pi).
## BOM ## BOM
| Qty | Description | Price | Link | Notes | | Qty | Description | Price | Link |
|-----|-------------|-------|------|-------| |-----|-------------|-------|------|
| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$3580 | [Raspberry Pi](https://www.raspberrypi.com/products/) | 4B+ recommended | | 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$3580 | [Raspberry Pi](https://www.raspberrypi.com/products/) |
| 1 | Micro SD Card | ~$10 | [Amazon](https://a.co/d/08aiL8c) | ≥32 GB | | 1 | Micro SD Card (≥32 GB) | ~$10 | [Amazon](https://a.co/d/08aiL8c) |
| 1 | USB Lavalier Microphone | $14.99 | [Amazon](https://www.amazon.com/dp/B0176NRE1G) | The one I used. Any USB mic with a half-decent capsule works. | | 1 | USB lavalier microphone | $14.99 | [Amazon](https://www.amazon.com/dp/B0176NRE1G) |
| 1 | Pi Power Supply | ~$10 | — | Matched to your Pi model | | 1 | Pi power supply | ~$10 | — |
You'll also need a [Gemini API key](https://aistudio.google.com/apikey) (free tier is enough for an apartment-scale lifelist) and, optionally, an [eBird API key](https://ebird.org/api/keygen) for region-filtering the species pre-gen. Optional: a [Gemini API key](https://aistudio.google.com/apikey) (free tier covers a regional regen) for restyling or adding species, and an [eBird API key](https://ebird.org/api/keygen) for filtering the species list to your area.
--- ---
## 1. Install BirdNET-Pi ## 1. Flash the SD card
Follow the [BirdNET-Pi installation guide](https://github.com/mcguirepr89/BirdNET-Pi/wiki/Installation-Guide) on your Pi. Confirm the stock BirdNET-Pi UI is reachable at `http://birdnet.local/` before continuing. Use [Raspberry Pi Imager](https://www.raspberrypi.com/software/). Pick **Raspberry Pi OS Lite (64-bit)**. In the customisation dialog, set:
- Username (your choice — the installer uses whatever you pick)
- WiFi SSID + password
- Hostname: `birdnet` (so it's reachable at `birdnet.local`)
- Enable SSH with password auth
Boot the Pi.
--- ---
## 2. Clone AvianVisitors ## 2. Run the installer
SSH in and run one command:
```bash ```bash
ssh monalisa@birdnet.local ssh <your-username>@birdnet.local
git clone https://github.com/Twarner491/AvianVisitors.git ~/AvianVisitors curl -s https://raw.githubusercontent.com/Twarner491/AvianVisitors/avian-visitors/newinstaller.sh | bash
cd ~/AvianVisitors
``` ```
The installer clones this fork, sets up BirdNET-Pi (audio capture, model, web UI), and symlinks the AvianVisitors overlay into the Caddy web root. Takes 2040 minutes. The Pi reboots when done.
Open `http://birdnet.local/avian/` from any device on your network. The BirdNET-Pi stock UI sits at `http://birdnet.local/`.
--- ---
## 3. Pre-generate illustrations for your region (optional but recommended) ## 3. (Optional) Restyle the illustrations
The repo ships with ~450 bundled illustrations. To restyle, regenerate, or add region-specific species, run the pregen script with your Gemini key. The repo ships with 450 bundled illustrations covering most North-American species. To change the visual style or add region-specific birds:
```bash ```bash
export GEMINI_API_KEY='your-gemini-key' export GEMINI_API_KEY='your-gemini-key'
# Generate every species BirdNET-Pi knows: # Re-render every species in BirdNET-Pi's model:
python3 avian/scripts/pregen.py --labels ~/BirdNET-Pi/model/labels.txt python3 ~/BirdNET-Pi/avian/scripts/pregen.py --labels ~/BirdNET-Pi/model/labels.txt --force
# Or filter to species observed in your state/county via eBird: # Or only species eBird has observed in your region:
export EBIRD_API_KEY='your-ebird-key' export EBIRD_API_KEY='your-ebird-key'
python3 avian/scripts/pregen.py \ python3 ~/BirdNET-Pi/avian/scripts/pregen.py \
--labels ~/BirdNET-Pi/model/labels.txt \ --labels ~/BirdNET-Pi/model/labels.txt \
--ebird-region US-CA # state, or US-CA-085 for a county --ebird-region US-CA # state, or US-CA-085 for a county
``` ```
To change the art style, edit [`avian/scripts/prompt.template.md`](avian/scripts/prompt.template.md) and re-run with `--force`. The template has `{sci_name}`, `{com_name}`, and `{pose}` placeholders; swap the body for whatever style you want (woodblock, ink wash, scientific plate, etc.). Style is a single editable file at [`avian/scripts/prompt.template.md`](avian/scripts/prompt.template.md). Replace the body, re-run pregen with `--force`, done.
--- ---
## 4. Install the frontend ## 4. (Optional) Forward off your LAN
One-shot installer — drops the static collage UI into `/var/www/avian`, the JSON shims into BirdNET-Pi's PHP root, and a Caddy snippet that mounts everything at `http://birdnet.local/collage/`. See [`avian/forwarding/`](avian/forwarding/) for three independent recipes:
```bash - **Cloudflare Tunnel** — public HTTPS URL, no port forwarding, optional Cloudflare Access password gate.
bash avian/scripts/install.sh - **Home Assistant REST sensor** — surfaces the latest detection for automations.
``` - **MQTT bridge** — publishes every new detection as JSON.
That's it. Open `http://birdnet.local/collage/` from any device on your network. As BirdNET-Pi accumulates detections, they fade into the collage — sized by how often each species has been heard.
---
## 5. Loading the Gemini key on the Pi (for live JIT generation)
The bundled illustrations cover most North-American species. For new birds the Pi hasn't seen before, AvianVisitors can render them on the fly. Drop the key into a systemd environment file so the JIT generator picks it up:
```bash
sudo mkdir -p /etc/avian
echo "GEMINI_API_KEY=your-gemini-key" | sudo tee /etc/avian/env >/dev/null
sudo chmod 600 /etc/avian/env
sudo systemctl restart php8.2-fpm
```
The PHP shim at `avian/api/cutout.php` reads `/etc/avian/env` and falls through to a Wikipedia photo if the key is missing — so you can run completely Gemini-free if you'd rather.
---
## 6. Optional: forward off your local network
For public access, Home Assistant integration, or MQTT fan-out, see [`avian/forwarding/`](avian/forwarding/). Each recipe is independent:
- **Cloudflare Tunnel** — public HTTPS URL, no port forwarding, optional Cloudflare Access for password protection.
- **Home Assistant REST sensor** — surfaces the latest detection as a sensor.value for automations.
- **MQTT bridge** — publishes every new detection to a broker as JSON.
--- ---
## Repo layout ## Repo layout
``` ```
avian/ avian/ # everything we add to BirdNET-Pi
├── frontend/ # static HTML/JS/CSS for the collage ├── frontend/ # static HTML/JS/CSS for the collage
├── assets/ # bundled illustrations + cutouts + masks ├── assets/ # 450 bundled illustrations + cutouts + masks
├── api/ # PHP shims served by BirdNET-Pi's existing PHP-FPM ├── api/ # PHP shims served by BirdNET-Pi's PHP-FPM
├── scripts/ # installer + Gemini pregen + editable prompt ├── scripts/ # pregen.py + editable prompt template
├── caddy/ # snippet mounting /collage on existing Caddy
└── forwarding/ # optional HA / MQTT / Cloudflare configs └── forwarding/ # optional HA / MQTT / Cloudflare configs
``` ```
@@ -113,7 +97,7 @@ Everything outside `avian/` is upstream BirdNET-Pi.
## License ## License
CC-BY-NC-SA-4.0 (inherited from BirdNET-Pi). Non-commercial use only; share-alike on derivatives. See [`LICENSE`](LICENSE) and [`README.upstream.md`](README.upstream.md). CC-BY-NC-SA-4.0, inherited from [BirdNET-Pi's upstream license](https://github.com/Nachtzuster/BirdNET-Pi/blob/main/LICENSE). Non-commercial use only; share-alike on derivatives. See the [BirdNET-Pi README](https://github.com/Nachtzuster/BirdNET-Pi/blob/main/README.md) for the full Cornell attribution.
--- ---
+23 -14
View File
@@ -1,26 +1,35 @@
<?php <?php
// /home/monalisa/BirdSongs/Extracted/api.php — JSON facade over BirdNET-Pi's // AvianVisitors — JSON facade over BirdNET-Pi's birds.db. Read-only.
// birds.db, queryable by the bird.onethreenine.net Cloudflare Worker. // Symlinked into the BirdNET-Pi Caddy site root at /avian/api/.
//
// Lives in the Caddy file-server root (NOT in BirdNET-Pi/scripts/) so the
// Sunday auto-update doesn't clobber it. Reachable as /api.php on the Pi.
//
// Auth: callers must send X-BirdNET-Proxy-Token matching the Caddy gate set
// up earlier — Caddy 403s anything missing it before this script runs, so we
// inherit that protection for free. The Cloudflare Worker is the only thing
// that ever sets the header.
// //
// Endpoints (?action=...): // Endpoints (?action=...):
// stats — totals: detections, unique species, today, last hour, etc. // stats — totals (detections, unique species, today, last hour)
// lifelist — every species with first_seen, last_seen, total_count // lifelist — every species with first_seen, last_seen, total_count
// recent — &hours=N (default 24): every detection in the window // recent — &hours=N (default 24): species heard in the window
// species — &sci=<sci_name>: detail page for one species // species — &sci=<sci_name>: per-species detail page
// timeseries — &days=N: daily detection counts per species
// firstseen — every species' earliest detection
//
// Default LAN deploy ships without auth. If you've exposed the Pi via
// Cloudflare or a tunnel, add a Caddy `basic_auth` matcher around the
// /avian/api/* path — see avian/forwarding/.
declare(strict_types=1); declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: public, max-age=30'); header('Cache-Control: public, max-age=30');
$DB_PATH = '/home/monalisa/BirdNET-Pi/scripts/birds.db'; // SCRIPT_FILENAME on the Pi resolves through the symlink to
// $HOME/BirdNET-Pi/avian/api/birdnet-api.php — walk three dirs up to
// reach the install root, then point at scripts/birds.db. Lets a Pi
// installed under any username (the BirdNET-Pi installer uses $USER,
// not a fixed name) work without editing this file.
$DB_PATH = dirname(__DIR__, 3) . '/scripts/birds.db';
// Fallback if the symlink layout ever changes — keeps the most common
// install path working even if SCRIPT_FILENAME oddities trip __DIR__.
if (!file_exists($DB_PATH)) {
$alt = getenv('HOME') . '/BirdNET-Pi/scripts/birds.db';
if (file_exists($alt)) $DB_PATH = $alt;
}
if (!file_exists($DB_PATH)) { if (!file_exists($DB_PATH)) {
http_response_code(503); http_response_code(503);
+77 -42
View File
@@ -1,20 +1,17 @@
<?php <?php
// /home/monalisa/BirdSongs/Extracted/cutout.php — dynamic background-removed // AvianVisitors — bird image resolver.
// bird-photo facade. Called by the bird.onethreenine.net Cloudflare Worker
// (which fronts /api/img?sci=<name>).
// //
// Lives in the Caddy file-server root so the BirdNET-Pi auto-update doesn't // Lookup chain for /avian/api/cutout.php?sci=Calypte+anna:
// touch it. Each new species hits this once: download Wikipedia/Macaulay // 1. ../assets/illustrations/<slug>.png (450+ bundled kachō-e renders)
// image → rembg via /usr/local/bin/rembg-cli → cache transparent PNG to // 2. ../assets/cutouts/<slug>.png (background-removed photo)
// disk under cutouts/. Subsequent requests are instant readfile() from cache. // 3. cached rembg of a Wikipedia photo at $HOME/BirdSongs/Extracted/cutouts/
// 4. fresh Wikipedia → rembg → cache (skipped gracefully if rembg unset)
// //
// Auth: the Caddy site block 403s anything missing X-BirdNET-Proxy-Token at // The frontend's <img src> points here for every species — bundled
// the top, so this script inherits the Worker-only access guarantee. // hits return instantly; cold misses fall through to the dynamic path.
// //
// Pre-reqs (run once via /install/rembg-setup.sh): // Default LAN deploy ships without auth. To expose publicly, gate
// - /home/monalisa/rembg-env/bin/rembg (Python venv) // /avian/api/* with basic_auth in your Caddyfile — see avian/forwarding/.
// - /usr/local/bin/rembg-cli (wrapper)
// - /home/monalisa/BirdSongs/Extracted/cutouts/ (cache dir, monalisa-owned)
declare(strict_types=1); declare(strict_types=1);
@@ -24,36 +21,63 @@ if ($sci === '') {
echo 'sci required'; echo 'sci required';
exit; exit;
} }
// Binomial / trinomial pattern. Rejects path-traversal payloads and
// junk before any filesystem or upstream lookup.
if (!preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) {
http_response_code(400);
echo 'invalid sci';
exit;
}
// Slugify scientific name for the cache filename. // Slugify scientific name for filename + cache key.
$slug = preg_replace('/[^a-z0-9]+/', '-', strtolower($sci)); $slug = preg_replace('/[^a-z0-9]+/', '-', strtolower($sci));
$slug = trim((string)$slug, '-'); $slug = trim((string)$slug, '-');
$cacheDir = '/home/monalisa/BirdSongs/Extracted/cutouts';
$cachePath = "$cacheDir/$slug.png";
function serve_png(string $path): void { function serve_png(string $path): void {
header('Content-Type: image/png'); header('Content-Type: image/png');
header('Cache-Control: public, max-age=2592000'); header('Cache-Control: public, max-age=86400');
header('Content-Length: ' . filesize($path)); header('Content-Length: ' . (string)filesize($path));
readfile($path); readfile($path);
exit; exit;
} }
// Cache hit — short-circuit. // 1. Bundled illustration (the kachō-e PNG the repo ships with).
$bundled = dirname(__DIR__) . "/assets/illustrations/$slug.png";
if (is_file($bundled) && filesize($bundled) > 1024) {
serve_png($bundled);
}
// 2. Bundled cutout (background-removed photo, fallback for species
// without an illustration).
$cutout = dirname(__DIR__) . "/assets/cutouts/$slug.png";
if (is_file($cutout) && filesize($cutout) > 1024) {
serve_png($cutout);
}
// 3. Dynamic cache from a previous Wikipedia + rembg run.
$cacheDir = getenv('HOME') . '/BirdSongs/Extracted/cutouts';
$cachePath = "$cacheDir/$slug.png";
if (is_file($cachePath) && filesize($cachePath) > 1024) { if (is_file($cachePath) && filesize($cachePath) > 1024) {
serve_png($cachePath); serve_png($cachePath);
} }
// Make sure the cache dir exists (first run). // 4. Fresh Wikipedia fetch + rembg. Skipped if rembg-cli isn't on
// PATH — the resolver simply returns a 404 in that case rather
// than burning a Wikipedia request we can't use.
$rembg = '/usr/local/bin/rembg-cli';
if (!is_executable($rembg)) {
http_response_code(404);
echo 'no illustration bundled for ' . htmlspecialchars($sci) . ' (install rembg-cli to enable Wikipedia fallback)';
exit;
}
if (!is_dir($cacheDir)) @mkdir($cacheDir, 0755, true); if (!is_dir($cacheDir)) @mkdir($cacheDir, 0755, true);
// Resolve a source image URL. Wikipedia summary first (free, fast, // Wikipedia's REST API asks for a contact-able identifier. Override
// no auth, has a clean originalimage for most birds). // via the AV_USER_AGENT env var (set in /etc/php/*/fpm/pool.d/www.conf
// or your shell) if your install hammers their endpoint at scale.
$ua = getenv('AV_USER_AGENT') ?: 'AvianVisitors/1.0 (+https://github.com/Twarner491/AvianVisitors)';
$ctx = stream_context_create([ $ctx = stream_context_create([
'http' => [ 'http' => ['header' => "User-Agent: $ua\r\n", 'timeout' => 12],
'header' => "User-Agent: apartment-birds/1.0 (twarner491@gmail.com)\r\n",
'timeout' => 12,
],
]); ]);
$wpUrl = 'https://en.wikipedia.org/api/rest_v1/page/summary/' . rawurlencode($sci); $wpUrl = 'https://en.wikipedia.org/api/rest_v1/page/summary/' . rawurlencode($sci);
$wpJson = @file_get_contents($wpUrl, false, $ctx); $wpJson = @file_get_contents($wpUrl, false, $ctx);
@@ -62,13 +86,20 @@ if ($wpJson !== false) {
$j = json_decode($wpJson, true); $j = json_decode($wpJson, true);
$srcUrl = $j['originalimage']['source'] ?? $j['thumbnail']['source'] ?? null; $srcUrl = $j['originalimage']['source'] ?? $j['thumbnail']['source'] ?? null;
} }
// Defensive: only follow URLs on Wikimedia / Wikipedia hosts so a
// poisoned summary endpoint can't redirect us to arbitrary servers.
if ($srcUrl !== null) {
$host = parse_url((string)$srcUrl, PHP_URL_HOST) ?: '';
if (!preg_match('/(?:^|\.)(?:wikimedia\.org|wikipedia\.org)$/i', $host)) {
$srcUrl = null;
}
}
if (!$srcUrl) { if (!$srcUrl) {
http_response_code(404); http_response_code(404);
echo 'no upstream image for ' . htmlspecialchars($sci); echo 'no Wikipedia photo for ' . htmlspecialchars($sci);
exit; exit;
} }
// Download the source image.
$imgBytes = @file_get_contents($srcUrl, false, $ctx); $imgBytes = @file_get_contents($srcUrl, false, $ctx);
if (!$imgBytes || strlen($imgBytes) < 1024) { if (!$imgBytes || strlen($imgBytes) < 1024) {
http_response_code(503); http_response_code(503);
@@ -76,16 +107,19 @@ if (!$imgBytes || strlen($imgBytes) < 1024) {
exit; exit;
} }
// Run rembg via the wrapper. We use temp files because rembg's CLI prefers // rembg via the wrapper. u2netp = lightweight model (~50MB peak RAM —
// real paths. u2netp is the lightweight model (~50MB peak RAM) — important // matters on the Pi 3B+). Temp files because rembg's CLI prefers
// on the Pi 3B+ (1GB total RAM). Call with --post-process-mask to clean // real paths.
// up edges. $tmpInBase = tempnam(sys_get_temp_dir(), 'rembg-in-');
$tmpIn = tempnam('/tmp', 'rembg-in-') . '.jpg'; $tmpOutBase = tempnam(sys_get_temp_dir(), 'rembg-out-');
$tmpOut = tempnam('/tmp', 'rembg-out-') . '.png'; @unlink($tmpInBase); @unlink($tmpOutBase);
$tmpIn = $tmpInBase . '.jpg';
$tmpOut = $tmpOutBase . '.png';
file_put_contents($tmpIn, $imgBytes); file_put_contents($tmpIn, $imgBytes);
$cmd = sprintf( $cmd = sprintf(
'/usr/local/bin/rembg-cli i -m u2netp -ppm %s %s 2>&1', '%s i -m u2netp -ppm %s %s 2>&1',
escapeshellarg($rembg),
escapeshellarg($tmpIn), escapeshellarg($tmpIn),
escapeshellarg($tmpOut) escapeshellarg($tmpOut)
); );
@@ -96,13 +130,13 @@ if (!is_file($tmpOut) || filesize($tmpOut) < 1024) {
@unlink($tmpOut); @unlink($tmpOut);
http_response_code(500); http_response_code(500);
header('Content-Type: text/plain'); header('Content-Type: text/plain');
echo "rembg failed:\n" . ($out ?? '(no output)'); echo "rembg failed (see your Pi's logs for details)";
error_log("rembg failed for $sci: " . ($out ?? '(no output)'));
exit; exit;
} }
// 1. Alpha-crop to bounding box so each PNG == the bird's actual shape // Tight-crop to the bird's bounding box + downscale to 800px max edge
// (no transparent padding around it). Lets the layout pack tiles tight. // so cache stays small.
// 2. Resize down to a max edge of 800px so the cache stays small.
$im = @imagecreatefrompng($tmpOut); $im = @imagecreatefrompng($tmpOut);
if ($im !== false) { if ($im !== false) {
$cropped = @imagecropauto($im, IMG_CROP_TRANSPARENT); $cropped = @imagecropauto($im, IMG_CROP_TRANSPARENT);
@@ -128,7 +162,8 @@ if ($im !== false) {
imagedestroy($im); imagedestroy($im);
} }
// Cache + serve. // Atomic install: rename is atomic on the same filesystem, so any
copy($tmpOut, $cachePath); // concurrent reader either sees the old cached file or the new one,
@unlink($tmpOut); // never a half-written PNG.
@rename($tmpOut, $cachePath);
serve_png($cachePath); serve_png($cachePath);
+12 -13
View File
@@ -1,16 +1,15 @@
<?php <?php
// /home/monalisa/BirdSongs/Extracted/recording.php — serves the most-recent // AvianVisitors — serves the most-recent detection mp3 for a given
// detection mp3 for a given scientific name. Called by the Cloudflare Worker // scientific name. Called by the collage detail modal at
// at /api/recording?sci=<name>. // /avian/api/recording.php?sci=<name>.
// //
// BirdNET-Pi writes audio + spectrograms to // BirdNET-Pi writes audio + spectrograms to
// ~/BirdSongs/Extracted/By_Date/YYYY-MM-DD/<Common_Name>/<base>.mp3 // $HOME/BirdSongs/Extracted/By_Date/YYYY-MM-DD/<Common_Name>/<base>.mp3
// (with a matching .png next to it). Common_Name is the SPACE-stripped // (with a matching .png next to it). Common_Name is the SPACE-stripped
// English common name (e.g. "Anna's_Hummingbird"), NOT the scientific name. // English common name (e.g. "Anna's_Hummingbird"), NOT the scientific
// We resolve sci → common via the same lookup the Pi's web UI uses // name. We resolve sci → common via birds.json under BirdNET-Pi/scripts/
// (birds.json under /scripts/) and then walk the directory tree newest-first. // and walk the directory tree newest-first.
// //
// Auth: the Caddy site block 403s anything missing X-BirdNET-Proxy-Token.
declare(strict_types=1); declare(strict_types=1);
@@ -33,7 +32,7 @@ if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci
exit; exit;
} }
$BY_DATE = '/home/monalisa/BirdSongs/Extracted/By_Date'; $BY_DATE = getenv('HOME') . '/BirdSongs/Extracted/By_Date';
// ---- Direct-by-file lookup ---- // ---- Direct-by-file lookup ----
// Used by the atlas detail modal to play any past recording. // Used by the atlas detail modal to play any past recording.
@@ -95,14 +94,14 @@ if ($file !== '') {
exit; exit;
} }
$BIRDS_JSON_CANDIDATES = [ $BIRDS_JSON_CANDIDATES = [
'/home/monalisa/BirdNET-Pi/scripts/birds.json', getenv('HOME') . '/BirdNET-Pi/scripts/birds.json',
'/home/monalisa/BirdNET-Pi/model/labels.txt', getenv('HOME') . '/BirdNET-Pi/model/labels.txt',
]; ];
// ---- Resolve scientific name → common name (with underscores) ---- // ---- Resolve scientific name → common name (with underscores) ----
function resolve_common(string $sci): ?string { function resolve_common(string $sci): ?string {
// Try birds.json first (preferred — has clean sci/com pairs). // Try birds.json first (preferred — has clean sci/com pairs).
foreach (['/home/monalisa/BirdNET-Pi/scripts/birds.json'] as $f) { foreach ([getenv('HOME') . '/BirdNET-Pi/scripts/birds.json'] as $f) {
if (is_readable($f)) { if (is_readable($f)) {
$list = json_decode((string)file_get_contents($f), true); $list = json_decode((string)file_get_contents($f), true);
if (is_array($list)) { if (is_array($list)) {
@@ -118,7 +117,7 @@ function resolve_common(string $sci): ?string {
} }
} }
// Fallback: labels.txt has "<sci>_<com>" or "<sci>, <com>" per line. // Fallback: labels.txt has "<sci>_<com>" or "<sci>, <com>" per line.
$labels = '/home/monalisa/BirdNET-Pi/model/labels.txt'; $labels = getenv('HOME') . '/BirdNET-Pi/model/labels.txt';
if (is_readable($labels)) { if (is_readable($labels)) {
foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) { foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) {
if (strpos($line, '_') !== false) { if (strpos($line, '_') !== false) {
+7 -7
View File
@@ -1,8 +1,8 @@
<?php <?php
// /home/monalisa/BirdSongs/Extracted/spectrogram.php — serves the // AvianVisitors — serves the spectrogram PNG that BirdNET-Pi generates
// spectrogram PNG that BirdNET-Pi generates alongside each detection mp3. // alongside each detection mp3. Same lookup logic as recording.php (find
// Same lookup logic as recording.php (find the matching file under // the matching file under By_Date/<date>/<Common_Name>/) — just .png
// By_Date/<date>/<Common_Name>/) — just .png instead of .mp3. // instead of .mp3.
// //
// Endpoints: // Endpoints:
// ?sci=<sci_name> → newest spectrogram for that species // ?sci=<sci_name> → newest spectrogram for that species
@@ -31,7 +31,7 @@ if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci
exit; exit;
} }
$BY_DATE = '/home/monalisa/BirdSongs/Extracted/By_Date'; $BY_DATE = getenv('HOME') . '/BirdSongs/Extracted/By_Date';
// ---- Direct-by-file lookup ---- // ---- Direct-by-file lookup ----
// BirdNET-Pi writes <base>.mp3 and <base>.png next to each other under // BirdNET-Pi writes <base>.mp3 and <base>.png next to each other under
@@ -93,7 +93,7 @@ if ($file !== '') {
} }
function resolve_common(string $sci): ?string { function resolve_common(string $sci): ?string {
$f = '/home/monalisa/BirdNET-Pi/scripts/birds.json'; $f = getenv('HOME') . '/BirdNET-Pi/scripts/birds.json';
if (is_readable($f)) { if (is_readable($f)) {
$list = json_decode((string)file_get_contents($f), true); $list = json_decode((string)file_get_contents($f), true);
if (is_array($list)) { if (is_array($list)) {
@@ -107,7 +107,7 @@ function resolve_common(string $sci): ?string {
} }
} }
} }
$labels = '/home/monalisa/BirdNET-Pi/model/labels.txt'; $labels = getenv('HOME') . '/BirdNET-Pi/model/labels.txt';
if (is_readable($labels)) { if (is_readable($labels)) {
foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) { foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) {
if (strpos($line, '_') !== false) { if (strpos($line, '_') !== false) {
-37
View File
@@ -1,37 +0,0 @@
# AvianVisitors — Caddy snippet.
# Drop into /etc/caddy/conf.d/ and add `import /etc/caddy/conf.d/*.caddy`
# to the main Caddyfile (the installer does this for you).
#
# Default: serves the collage locally at http://birdnet.local/collage/
# with no auth. For the optional Cloudflare/HA forwarded deployment,
# see ../forwarding/.
(avian_common) {
# CORS — collage is a single-origin static app; loose for the JSON
# endpoints so HA dashboards etc. can read them.
@json path /api/*.json
header @json {
Access-Control-Allow-Origin "*"
Cache-Control "no-cache"
}
}
# Frontend lives at http://birdnet.local/collage/
http://birdnet.local {
import avian_common
# Static collage UI
handle_path /collage/* {
root * /var/www/avian
file_server
}
redir /collage /collage/
# PHP shims for recent.json / lifelist.json / firstseen.json /
# timeseries.json — served from BirdNET-Pi's existing PHP root.
handle_path /api/* {
root * /home/monalisa/BirdSongs/Extracted
php_fastcgi unix//run/php/php8.2-fpm.sock
file_server
}
}
+25 -35
View File
@@ -1,6 +1,6 @@
# Optional: forwarding the collage off your local network # Optional: forwarding the collage off your local network
Default install hosts the collage at `http://birdnet.local/collage/` on Default install hosts the collage at `http://birdnet.local/avian/` on
your LAN with no auth. If you want it accessible from anywhere — or your LAN with no auth. If you want it accessible from anywhere — or
piped into Home Assistant / MQTT — pick one of the recipes below. piped into Home Assistant / MQTT — pick one of the recipes below.
@@ -8,18 +8,17 @@ Each recipe is independent. Skip what you don't need.
--- ---
## 1. Cloudflare Tunnel (recommended for public access) ## 1. Cloudflare Tunnel — public HTTPS, no port forwarding
Gives you a public HTTPS URL with no port forwarding and no exposed Free Cloudflare account required.
home IP. Free Cloudflare account required.
Install `cloudflared` on the Pi: Install `cloudflared`:
```bash ```bash
sudo mkdir -p /usr/share/keyrings sudo apt install -y lsb-release
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main' \ echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/cloudflared.list | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install -y cloudflared sudo apt update && sudo apt install -y cloudflared
``` ```
@@ -29,28 +28,21 @@ Authenticate + create the tunnel:
```bash ```bash
cloudflared tunnel login cloudflared tunnel login
cloudflared tunnel create birds cloudflared tunnel create birds
```
Add a public route — pick a hostname on a zone you own:
```bash
cloudflared tunnel route dns birds birds.your-domain.com cloudflared tunnel route dns birds birds.your-domain.com
``` ```
Configure the tunnel to point at the local Caddy: Configure + start the tunnel:
```bash ```bash
sudo cp avian/forwarding/cloudflared.yml /etc/cloudflared/config.yml sudo cp ~/BirdNET-Pi/avian/forwarding/cloudflared.yml /etc/cloudflared/config.yml
# edit /etc/cloudflared/config.yml — set `tunnel:` to your tunnel UUID # Edit /etc/cloudflared/config.yml — set `tunnel:` to your tunnel UUID
sudo cloudflared service install sudo cloudflared service install
sudo systemctl restart cloudflared sudo systemctl restart cloudflared
``` ```
**Adding password protection on the public URL.** With Cloudflare in To password-protect the public URL, set up Cloudflare Access (free
front, gate the public endpoint via Cloudflare Access (zero-trust tier: up to 50 users). The LAN URL stays open. If you'd rather use HTTP
free tier supports up to 50 users) — see Cloudflare docs. The local Basic auth, see [`caddy-auth.caddy`](caddy-auth.caddy).
LAN URL remains unprotected. If you'd rather use HTTP Basic auth on
Caddy itself, see [forwarding/caddy-auth.caddy](caddy-auth.caddy).
--- ---
@@ -60,13 +52,11 @@ Add to `configuration.yaml`:
```yaml ```yaml
rest: rest:
- resource: http://birdnet.local/api/recent.json?hours=1 - resource: http://birdnet.local/avian/api/birdnet-api.php?action=recent&hours=1
scan_interval: 60 scan_interval: 60
sensor: sensor:
- name: "Latest Bird" - name: "Latest Bird"
value_template: > value_template: "{{ value_json.species[0].com if value_json.species else 'none' }}"
{% set top = value_json.species | sort(attribute='last_seen', reverse=true) | first %}
{{ top.com if top else 'none' }}
json_attributes_path: "$.species[0]" json_attributes_path: "$.species[0]"
json_attributes: json_attributes:
- sci - sci
@@ -75,25 +65,25 @@ rest:
- best_conf - best_conf
``` ```
Use the sensor in automations — flash a light when a new species is `birdnet-api.php?action=recent` already returns `species` ordered by
heard, etc. count desc; if you want most-recent first, replace the value_template
with a sort filter.
--- ---
## 3. MQTT — fan out detections to other services ## 3. MQTT — fan out detections to other services
If you already run an MQTT broker, publish every new detection.
Install `paho-mqtt` and run the bridge:
```bash ```bash
sudo pip3 install paho-mqtt --break-system-packages sudo pip3 install paho-mqtt --break-system-packages
cp avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py cp ~/BirdNET-Pi/avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py
# edit ~/avian-mqtt.py — broker host, topic prefix # Edit ~/avian-mqtt.py — broker host, topic prefix, credentials
sudo cp avian/forwarding/avian-mqtt.service /etc/systemd/system/ sudo cp ~/BirdNET-Pi/avian/forwarding/avian-mqtt.service /etc/systemd/system/
# Edit /etc/systemd/system/avian-mqtt.service — set User= to your username
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now avian-mqtt sudo systemctl enable --now avian-mqtt
``` ```
The bridge polls `/api/recent.json?hours=1` once a minute and The bridge polls `/avian/api/birdnet-api.php?action=recent&hours=1`
publishes new species under `birdnet/<slug>` with the full record as once a minute and publishes new species under `birdnet/<slug>` with the
JSON payload. full record as a JSON payload. Dedup is in-memory only — downstream
consumers should be idempotent.
+4 -1
View File
@@ -4,7 +4,10 @@ After=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/bin/python3 /home/%i/avian-mqtt.py # Edit User= and the ExecStart path to match your Pi's username if
# you didn't install BirdNET-Pi as the `birdnet` user.
User=birdnet
ExecStart=/usr/bin/python3 %h/avian-mqtt.py
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
+9 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Poll /api/recent.json on the Pi once a minute and publish each new """Poll AvianVisitors' recent-detections endpoint once a minute and publish
detection to MQTT. Edit BROKER, TOPIC_PREFIX, and PI_URL below.""" each new species to MQTT. Edit BROKER, TOPIC_PREFIX, and PI_URL below."""
import json import json
import time import time
import urllib.request import urllib.request
@@ -11,7 +11,7 @@ PORT = 1883
USER = "" USER = ""
PASSWORD = "" PASSWORD = ""
TOPIC_PREFIX = "birdnet" TOPIC_PREFIX = "birdnet"
PI_URL = "http://birdnet.local/api/recent.json?hours=1" PI_URL = "http://birdnet.local/avian/api/birdnet-api.php?action=recent&hours=1"
seen_keys: set[str] = set() seen_keys: set[str] = set()
@@ -36,6 +36,12 @@ def loop(client: mqtt.Client) -> None:
time.sleep(60) time.sleep(60)
def main() -> None: def main() -> None:
# paho-mqtt 2.x requires CallbackAPIVersion; the constructor below
# also works on 1.x (the kwarg is just ignored). Pin to VERSION2 so
# we get the modern callback signatures going forward.
try:
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
except AttributeError: # paho-mqtt 1.x
client = mqtt.Client() client = mqtt.Client()
if USER: if USER:
client.username_pw_set(USER, PASSWORD) client.username_pw_set(USER, PASSWORD)
+173 -117
View File
@@ -1,67 +1,79 @@
/* AvianVisitors — bird collage frontend. /* AvianVisitors — bird collage frontend.
* *
* Renders three views over BirdNET-Pi detections: * Three views over BirdNET-Pi detections:
* collage — mask-packed cluster of species illustrations, sized by * collage — mask-packed cluster of species illustrations, sized by
* count. Layout normalises so all birds always fit on * count. Layout normalises so every bird always fits on
* every viewport. * every viewport.
* stats — per-species histogram across the selected time window. * stats — per-species mark on a time × count plot.
* atlas — alphabet of all detected species with detail modal. * atlas — grid of every species ever detected, with detail modal.
* *
* Reads four JSON endpoints exposed by the Pi (PHP shims in ../api/): * Lives at $HOME/BirdNET-Pi/avian/frontend/ on the Pi; the install
* GET /api/recent.json?hours=N — species + counts in window * symlinks $HOME/BirdNET-Pi/avian → $EXTRACTED/avian, so this file
* GET /api/lifelist.json — all species ever detected * loads from http://birdnet.local/avian/frontend/apt.js with the
* GET /api/firstseen.json — earliest detection per species * collage at http://birdnet.local/avian/frontend/.
* GET /api/timeseries.json?days=D — daily counts per species
* *
* Plus two media proxies (also via PHP): * All API calls are relative — they target the PHP shims in ../api/
* GET /api/img?sci=X[&com=Y] — bird illustration * served by BirdNET-Pi's existing Caddy + PHP-FPM stack. No frontend
* GET /api/recording?sci=X — most-recent mp3 * configuration needed; works out of the box on any BirdNET-Pi host.
* GET /api/spectrogram?sci=X — matching spectrogram png
*
* Local-network deploys ship without auth. The PHP shims accept the
* X-AvianVisitors-Token header for the optional Cloudflare-forwarded
* deployment — see ../forwarding/.
*/ */
(function () { (function () {
'use strict'; 'use strict';
// ---- Config ---- // Relative API helpers. Frontend lives at /avian/frontend/; the PHP
// API_BASE: where the JSON endpoints live. Default '/' works when // shims live at /avian/api/. The router PHP (birdnet-api.php)
// Caddy mounts the frontend at the same host as the API. Override // dispatches on ?action= to the recent/lifelist/firstseen/timeseries
// with window.AV_API_BASE if you split origins. // queries.
var API_BASE = (window.AV_API_BASE || '').replace(/\/$/, ''); function api(action, qs) {
var IMG_VERSION = '1'; // bump after running scripts/pregen.py return '../api/birdnet-api.php?action=' + action + (qs ? '&' + qs : '');
}
function media(file, qs) {
return '../api/' + file + (qs ? '?' + qs : '');
}
// Cache-bust query for image URLs. Bump after running pregen.py with
// --force so browsers + CDNs drop their cached copies of every bird.
var IMG_VERSION = '1';
function api(p) { return API_BASE + p; }
function readLS(k, d) { try { return localStorage.getItem(k) || d; } catch (e) { return d; } } function readLS(k, d) { try { return localStorage.getItem(k) || d; } catch (e) { return d; } }
function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function fetchJson(u) { return fetch(u, { cache: 'no-store' }).then(function (r) { return r.json(); }); } function fetchJson(u) {
return fetch(u, { cache: 'no-store' }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + u);
return r.json();
});
}
// HTML-escape user-supplied strings before they land in innerHTML.
// Species names come from BirdNET-Pi's labels file, which is
// user-editable (custom species lists, l18n) — so they're untrusted.
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ---- State ---- // ---- State ----
var DATA = { var DATA = { recent: null, lifelist: null, firstseen: null, timeseries: null };
recent: null, // /api/recent.json?hours=N (refetched on picker change)
lifelist: null, // /api/lifelist.json
firstseen: null, // /api/firstseen.json
timeseries: null, // /api/timeseries.json
};
var MASKS = null, DIMS = null; var MASKS = null, DIMS = null;
var currentHours = +readLS('av:window', '24') || 24; var currentHours = +readLS('av:window', '24') || 24;
// ---- Boot: load mask/dim registries, then first data pull ---- // ---- Boot ----
Promise.all([ Promise.all([
fetchJson(api('/avian/masks.json')).then(function (j) { MASKS = j; }), fetchJson('./masks.json').then(function (j) { MASKS = j; }),
fetchJson(api('/avian/dims.json')).then(function (j) { DIMS = j; }), fetchJson('./dims.json').then(function (j) { DIMS = j; }),
]).then(function () { ]).then(function () {
bindUI(); bindUI();
refreshAll(); refreshAll();
setInterval(refreshRecent, 60000); // poll for new detections every min setInterval(refreshRecent, 60000);
}).catch(function (e) { console.error('boot failed', e); }); }).catch(function (e) {
console.error('boot failed', e);
var c = document.getElementById('collage');
if (c) c.innerHTML = '<p class="empty">collage failed to load: ' + esc(e.message) + '</p>';
});
// ---- UI bindings ---- // ---- UI ----
var slider = document.getElementById('slider'); function $(id) { return document.getElementById(id); }
var winPick = document.getElementById('winPick');
var views = document.querySelectorAll('.view');
function syncPill(container) { function syncPill(container) {
if (!container) return;
var pill = container.querySelector('.seg-pill'); var pill = container.querySelector('.seg-pill');
var active = container.querySelector('button[aria-current="true"]'); var active = container.querySelector('button[aria-current="true"]');
if (!pill || !active) return; if (!pill || !active) return;
@@ -69,11 +81,15 @@
pill.style.transform = 'translateX(' + active.offsetLeft + 'px)'; pill.style.transform = 'translateX(' + active.offsetLeft + 'px)';
} }
function syncAllPills() { function syncAllPills() {
[slider, winPick, document.getElementById('atlasSort')].forEach(function (c) { if (c) syncPill(c); }); [$('slider'), $('winPick'), $('atlasSort')].forEach(syncPill);
} }
function bindUI() { function bindUI() {
// View slider var slider = $('slider');
[].slice.call(slider.querySelectorAll('button')).forEach(function (b) { var winPick = $('winPick');
var atlasEl = $('atlasSort');
var views = document.querySelectorAll('.view');
if (slider) [].slice.call(slider.querySelectorAll('button')).forEach(function (b) {
b.addEventListener('click', function () { b.addEventListener('click', function () {
[].slice.call(slider.querySelectorAll('button')).forEach(function (x) { [].slice.call(slider.querySelectorAll('button')).forEach(function (x) {
x.setAttribute('aria-current', x === b ? 'true' : 'false'); x.setAttribute('aria-current', x === b ? 'true' : 'false');
@@ -86,8 +102,8 @@
if (i === 2) renderAtlas(); if (i === 2) renderAtlas();
}); });
}); });
// Window picker
[].slice.call(winPick.querySelectorAll('button')).forEach(function (b) { if (winPick) [].slice.call(winPick.querySelectorAll('button')).forEach(function (b) {
b.setAttribute('aria-current', (+b.dataset.h === currentHours) ? 'true' : 'false'); b.setAttribute('aria-current', (+b.dataset.h === currentHours) ? 'true' : 'false');
b.addEventListener('click', function () { b.addEventListener('click', function () {
[].slice.call(winPick.querySelectorAll('button')).forEach(function (x) { [].slice.call(winPick.querySelectorAll('button')).forEach(function (x) {
@@ -99,9 +115,8 @@
refreshRecent(); refreshRecent();
}); });
}); });
// Atlas sort
var atlasEl = document.getElementById('atlasSort'); if (atlasEl) [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (b) {
[].slice.call(atlasEl.querySelectorAll('button')).forEach(function (b) {
b.addEventListener('click', function () { b.addEventListener('click', function () {
[].slice.call(atlasEl.querySelectorAll('button')).forEach(function (x) { [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (x) {
x.setAttribute('aria-current', x === b ? 'true' : 'false'); x.setAttribute('aria-current', x === b ? 'true' : 'false');
@@ -111,16 +126,16 @@
renderAtlas(); renderAtlas();
}); });
}); });
// About modal
document.getElementById('aboutLink').addEventListener('click', function () { var aboutLink = $('aboutLink');
document.getElementById('about-modal').setAttribute('aria-hidden', 'false'); var aboutModal = $('about-modal');
if (aboutLink && aboutModal) {
aboutLink.addEventListener('click', function () { aboutModal.setAttribute('aria-hidden', 'false'); });
}
document.querySelectorAll('#about-modal [data-close]').forEach(function (el) {
el.addEventListener('click', function () { aboutModal && aboutModal.setAttribute('aria-hidden', 'true'); });
}); });
document.querySelectorAll('[data-close]').forEach(function (el) {
el.addEventListener('click', function () {
document.getElementById('about-modal').setAttribute('aria-hidden', 'true');
});
});
// Resize: re-pack collage and re-pill
var rT; var rT;
window.addEventListener('resize', function () { window.addEventListener('resize', function () {
clearTimeout(rT); clearTimeout(rT);
@@ -137,10 +152,10 @@
function refreshAll() { function refreshAll() {
var h = currentHours; var h = currentHours;
return Promise.all([ return Promise.all([
fetchJson(api('/api/lifelist.json')).catch(function () { return null; }), fetchJson(api('lifelist')).catch(function () { return null; }),
fetchJson(api('/api/firstseen.json')).catch(function () { return null; }), fetchJson(api('firstseen')).catch(function () { return null; }),
fetchJson(api('/api/timeseries.json?days=30')).catch(function () { return null; }), fetchJson(api('timeseries', 'days=30')).catch(function () { return null; }),
fetchJson(api('/api/recent.json?hours=' + h)).catch(function () { return null; }), fetchJson(api('recent', 'hours=' + h)).catch(function () { return null; }),
]).then(function (parts) { ]).then(function (parts) {
DATA.lifelist = parts[0]; DATA.lifelist = parts[0];
DATA.firstseen = parts[1]; DATA.firstseen = parts[1];
@@ -152,7 +167,7 @@
} }
function refreshRecent() { function refreshRecent() {
var h = currentHours; var h = currentHours;
return fetchJson(api('/api/recent.json?hours=' + h)).then(function (j) { return fetchJson(api('recent', 'hours=' + h)).then(function (j) {
if (h !== currentHours) return; if (h !== currentHours) return;
DATA.recent = j; DATA.recent = j;
renderCollageFromData(); renderCollageFromData();
@@ -160,9 +175,10 @@
}).catch(function () {}); }).catch(function () {});
} }
// ---- Mask + slug helpers ---- // ---- Slug + mask helpers ----
var maskCache = {}; var maskCache = {};
function loadMask(slug) { function loadMask(slug) {
if (!MASKS) return null;
if (maskCache[slug]) return maskCache[slug]; if (maskCache[slug]) return maskCache[slug];
var rec = MASKS[slug]; var rec = MASKS[slug];
if (!rec) return null; if (!rec) return null;
@@ -182,17 +198,16 @@
return sci.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); return sci.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
} }
function aspect(sci) { function aspect(sci) {
var d = DIMS[slugify(sci)]; var d = DIMS && DIMS[slugify(sci)];
return d ? d[0] / d[1] : 1.4; return d ? d[0] / d[1] : 1.4;
} }
function imgUrl(sci, com) {
return media('cutout.php', 'sci=' + encodeURIComponent(sci) +
(com ? '&com=' + encodeURIComponent(com) : '') +
'&v=' + IMG_VERSION);
}
// ---- Collage layout (mask-aware, viewport-budget) ---- // ---- Collage layout (mask-aware, viewport-budget) ----
//
// Each species ships a low-res alpha mask. We maintain an occupancy
// grid at viewport resolution; for each tile we spiral outward from
// the cluster center and pick the closest non-overlapping placement.
// Total area is normalised so the entire cluster fits any viewport,
// and we iterate shrink+repack until every bird lands on-screen.
function tuning(n) { function tuning(n) {
return { return {
packingBudgetFrac: n <= 4 ? 0.46 : n <= 12 ? 0.40 : n <= 24 ? 0.34 : 0.28, packingBudgetFrac: n <= 4 ? 0.46 : n <= 12 ? 0.40 : n <= 24 ? 0.34 : 0.28,
@@ -278,14 +293,17 @@
} }
function renderCollageFromData() { function renderCollageFromData() {
var items = (DATA.recent && DATA.recent.species) || []; renderCollage((DATA.recent && DATA.recent.species) || []);
renderCollage(items);
} }
function renderCollage(items) { function renderCollage(items) {
var collage = document.getElementById('collage'); var collage = $('collage');
if (!collage) return;
collage.innerHTML = ''; collage.innerHTML = '';
if (!items.length) { if (!items.length) {
collage.innerHTML = '<p class="empty">no birds heard in this window.</p>'; var p = document.createElement('p');
p.className = 'empty';
p.textContent = 'no birds heard in this window.';
collage.appendChild(p);
return; return;
} }
var W = collage.clientWidth, H = collage.clientHeight; var W = collage.clientWidth, H = collage.clientHeight;
@@ -297,13 +315,15 @@
var minArea = vpArea * T.minTileAreaFrac; var minArea = vpArea * T.minTileAreaFrac;
var tiles = items.map(function (s) { var tiles = items.map(function (s) {
var slug = slugify(s.sci); var slug = slugify(s.sci || '');
var mask = loadMask(slug); var mask = loadMask(slug);
if (!mask) return null; if (!mask) return null;
var n = +s.n; if (!n || isNaN(n)) n = 1; var n = +s.n; if (!n || isNaN(n)) n = 1;
return { mask: mask, data: s, ar: aspect(s.sci), score: Math.pow(Math.max(1, n), T.countExp) }; return { mask: mask, data: s, ar: aspect(s.sci), score: Math.pow(Math.max(1, n), T.countExp) };
}).filter(Boolean); }).filter(Boolean);
if (!tiles.length) { collage.innerHTML = ''; return; }
var sumScore = tiles.reduce(function (a, t) { return a + t.score; }, 0) || 1; var sumScore = tiles.reduce(function (a, t) { return a + t.score; }, 0) || 1;
tiles.forEach(function (t) { t.area = Math.max(minArea, budget * t.score / sumScore); }); tiles.forEach(function (t) { t.area = Math.max(minArea, budget * t.score / sumScore); });
var sumA = tiles.reduce(function (a, t) { return a + t.area; }, 0); var sumA = tiles.reduce(function (a, t) { return a + t.area; }, 0);
@@ -353,30 +373,35 @@
placed.forEach(function (t) { if (t.x > -1000) { t.x += dx; t.y += dy; } }); placed.forEach(function (t) { if (t.x > -1000) { t.x += dx; t.y += dy; } });
} }
// Build tiles via createElement / textContent / setAttribute so user-
// editable species names from labels.txt can't smuggle markup.
placed.forEach(function (t) { placed.forEach(function (t) {
var s = t.data; var s = t.data;
var img = api('/api/img?sci=' + encodeURIComponent(s.sci) + var label = s.com || s.sci;
(s.com ? '&com=' + encodeURIComponent(s.com) : '') +
'&v=' + IMG_VERSION);
var btn = document.createElement('button'); var btn = document.createElement('button');
btn.className = 'gtile'; btn.className = 'gtile';
btn.type = 'button'; btn.type = 'button';
btn.setAttribute('data-sci', s.sci); btn.dataset.sci = s.sci;
btn.setAttribute('aria-label', s.com || s.sci); btn.setAttribute('aria-label', label);
btn.title = (s.com || s.sci) + ' · ' + (+s.n || 0) + ' calls'; btn.title = label + ' · ' + (+s.n || 0) + ' calls';
btn.style.left = t.x + 'px'; btn.style.left = t.x + 'px';
btn.style.top = t.y + 'px'; btn.style.top = t.y + 'px';
btn.style.width = t.fullW + 'px'; btn.style.width = t.fullW + 'px';
btn.style.height = t.fullH + 'px'; btn.style.height = t.fullH + 'px';
btn.innerHTML = '<img loading="lazy" decoding="async" src="' + img + '" alt="' + (s.com || s.sci) + '">'; var img = document.createElement('img');
img.loading = 'lazy';
img.decoding = 'async';
img.alt = label;
img.src = imgUrl(s.sci, s.com);
btn.appendChild(img);
btn.addEventListener('click', function () { openDetail(s); }); btn.addEventListener('click', function () { openDetail(s); });
collage.appendChild(btn); collage.appendChild(btn);
}); });
} }
// ---- Stats: per-species histogram with last-seen positioning ---- // ---- Stats ----
function drawHistograms() { function drawHistograms() {
var tl = document.getElementById('statsTimeline'); var tl = $('statsTimeline');
if (!tl) return; if (!tl) return;
var sp = ((DATA.recent && DATA.recent.species) || []).slice(); var sp = ((DATA.recent && DATA.recent.species) || []).slice();
if (!sp.length) { tl.innerHTML = '<div class="stats-tl-empty">no detections in this window</div>'; return; } if (!sp.length) { tl.innerHTML = '<div class="stats-tl-empty">no detections in this window</div>'; return; }
@@ -388,24 +413,38 @@
var cap = Math.max(4, Math.floor(W / 28)); var cap = Math.max(4, Math.floor(W / 28));
if (sp.length > cap) sp = sp.slice(0, cap); if (sp.length > cap) sp = sp.slice(0, cap);
var maxN = sp.reduce(function (m, s) { return Math.max(m, +s.n || 0); }, 1); var maxN = sp.reduce(function (m, s) { return Math.max(m, +s.n || 0); }, 1);
var html = '<div class="stats-tl-plot">'; tl.innerHTML = '';
var plot = document.createElement('div');
plot.className = 'stats-tl-plot';
sp.forEach(function (s) { sp.forEach(function (s) {
var ts = Date.parse((s.last_seen || '').replace(' ', 'T')); var ts = Date.parse((s.last_seen || '').replace(' ', 'T'));
var leftPct = isNaN(ts) ? 50 : ((Math.max(windowStart, Math.min(now, ts)) - windowStart) / windowSpan) * 100; var leftPct = isNaN(ts) ? 50 : ((Math.max(windowStart, Math.min(now, ts)) - windowStart) / windowSpan) * 100;
var n = +s.n || 0; var n = +s.n || 0;
var bottomPct = (n / maxN) * 50; var bottomPct = (n / maxN) * 50;
html += '<div class="stats-tl-mark" style="left:' + leftPct.toFixed(1) + '%;bottom:' + bottomPct.toFixed(1) + '%" data-sci="' + s.sci + '" title="' + (s.com || s.sci) + ' · ' + n + ' calls"></div>'; var mark = document.createElement('div');
mark.className = 'stats-tl-mark';
mark.style.left = leftPct.toFixed(1) + '%';
mark.style.bottom = bottomPct.toFixed(1) + '%';
mark.dataset.sci = s.sci;
mark.title = (s.com || s.sci) + ' · ' + n + ' calls';
plot.appendChild(mark);
}); });
html += '</div>'; tl.appendChild(plot);
tl.innerHTML = html;
} }
// ---- Atlas: gridded alphabet of all species ---- // ---- Atlas ----
function renderAtlas() { function renderAtlas() {
var list = document.getElementById('atlasList'); var list = $('atlasList');
if (!list) return; if (!list) return;
var sp = ((DATA.lifelist && DATA.lifelist.species) || []).slice(); var sp = ((DATA.lifelist && DATA.lifelist.species) || []).slice();
if (!sp.length) { list.innerHTML = '<p class="empty">no species yet — atlas fills in as the Pi detects birds.</p>'; return; } if (!sp.length) {
var p = document.createElement('p');
p.className = 'empty';
p.textContent = 'no species yet — atlas fills in as the Pi detects birds.';
list.innerHTML = '';
list.appendChild(p);
return;
}
var sort = readLS('av:atlasSort', 'count'); var sort = readLS('av:atlasSort', 'count');
if (sort === 'count') sp.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); }); if (sort === 'count') sp.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); });
else if (sort === 'recent') sp.sort(function (a, b) { else if (sort === 'recent') sp.sort(function (a, b) {
@@ -414,49 +453,66 @@
else sp.sort(function (a, b) { else sp.sort(function (a, b) {
return Date.parse((a.first_seen || '').replace(' ', 'T')) - Date.parse((b.first_seen || '').replace(' ', 'T')); return Date.parse((a.first_seen || '').replace(' ', 'T')) - Date.parse((b.first_seen || '').replace(' ', 'T'));
}); });
list.innerHTML = sp.map(function (s) { list.innerHTML = '';
var img = api('/api/img?sci=' + encodeURIComponent(s.sci) + sp.forEach(function (s) {
(s.com ? '&com=' + encodeURIComponent(s.com) : '') + var btn = document.createElement('button');
'&v=' + IMG_VERSION); btn.className = 'atlas-card';
return '<button class="atlas-card" data-sci="' + s.sci + '">' + btn.type = 'button';
'<img loading="lazy" decoding="async" src="' + img + '" alt="' + (s.com || s.sci) + '">' + btn.dataset.sci = s.sci;
'<span class="atlas-name">' + (s.com || s.sci) + '</span>' + var img = document.createElement('img');
'<span class="atlas-count">' + (+s.n || 0) + '</span>' + img.loading = 'lazy';
'</button>'; img.decoding = 'async';
}).join(''); img.alt = s.com || s.sci;
[].slice.call(list.querySelectorAll('.atlas-card')).forEach(function (b) { img.src = imgUrl(s.sci, s.com);
b.addEventListener('click', function () { openDetail({ sci: b.dataset.sci }); }); var name = document.createElement('span');
name.className = 'atlas-name';
name.textContent = s.com || s.sci;
var count = document.createElement('span');
count.className = 'atlas-count';
count.textContent = String(+s.n || 0);
btn.appendChild(img);
btn.appendChild(name);
btn.appendChild(count);
btn.addEventListener('click', function () { openDetail(s); });
list.appendChild(btn);
}); });
} }
// ---- Detail modal (basic — opens recording + spectrogram for a species) ---- // ---- Detail modal ----
function openDetail(s) { function openDetail(s) {
var sci = s.sci || s; var sci = s.sci || s;
// Lookup full record from lifelist if available
var rec = ((DATA.lifelist && DATA.lifelist.species) || []).find(function (x) { return x.sci === sci; }) || s; var rec = ((DATA.lifelist && DATA.lifelist.species) || []).find(function (x) { return x.sci === sci; }) || s;
var img = api('/api/img?sci=' + encodeURIComponent(sci) + var label = rec.com || sci;
(rec.com ? '&com=' + encodeURIComponent(rec.com) : '') +
'&v=' + IMG_VERSION); var modal = $('detail-modal');
var rec_url = api('/api/recording?sci=' + encodeURIComponent(sci));
var spec_url = api('/api/spectrogram?sci=' + encodeURIComponent(sci));
var modal = document.getElementById('detail-modal');
if (!modal) { if (!modal) {
modal = document.createElement('div'); modal = document.createElement('div');
modal.id = 'detail-modal'; modal.id = 'detail-modal';
modal.setAttribute('role', 'dialog'); modal.setAttribute('role', 'dialog');
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// Single innerHTML for the chrome (no user content), then build the
// user-content elements via createElement so labels are safe.
modal.innerHTML = modal.innerHTML =
'<div class="modal-backdrop" data-close="1"></div>' + '<div class="modal-backdrop" data-close="1"></div>' +
'<div class="detail-card">' + '<div class="detail-card">' +
' <button type="button" class="modal-close" data-close="1" aria-label="close">×</button>' + ' <button type="button" class="modal-close" data-close="1" aria-label="close">×</button>' +
' <img class="detail-img" src="' + img + '" alt="' + (rec.com || sci) + '">' + ' <img class="detail-img" alt="">' +
' <h2 class="detail-title">' + (rec.com || sci) + '</h2>' + ' <h2 class="detail-title"></h2>' +
' <p class="detail-sci"><em>' + sci + '</em></p>' + ' <p class="detail-sci"><em></em></p>' +
' <p class="detail-stats">' + (+rec.n || 0) + ' calls · last heard ' + (rec.last_seen || '—') + '</p>' + ' <p class="detail-stats"></p>' +
' <audio class="detail-audio" controls src="' + rec_url + '"></audio>' + ' <audio class="detail-audio" controls></audio>' +
' <img class="detail-spec" src="' + spec_url + '" alt="spectrogram">' + ' <img class="detail-spec" alt="spectrogram">' +
'</div>'; '</div>';
var card = modal.querySelector('.detail-card');
card.querySelector('.detail-img').src = imgUrl(sci, rec.com);
card.querySelector('.detail-img').alt = label;
card.querySelector('.detail-title').textContent = label;
card.querySelector('.detail-sci em').textContent = sci;
card.querySelector('.detail-stats').textContent =
(+rec.n || 0) + ' calls · last heard ' + (rec.last_seen || '—');
card.querySelector('.detail-audio').src = media('recording.php', 'sci=' + encodeURIComponent(sci));
card.querySelector('.detail-spec').src = media('spectrogram.php', 'sci=' + encodeURIComponent(sci));
modal.setAttribute('aria-hidden', 'false'); modal.setAttribute('aria-hidden', 'false');
modal.querySelectorAll('[data-close]').forEach(function (el) { modal.querySelectorAll('[data-close]').forEach(function (el) {
el.addEventListener('click', function () { modal.setAttribute('aria-hidden', 'true'); }); el.addEventListener('click', function () { modal.setAttribute('aria-hidden', 'true'); });
-1
View File
@@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>apartment birds</title> <title>apartment birds</title>
<meta name="description" content="A live bird collage from your apartment window."> <meta name="description" content="A live bird collage from your apartment window.">
<link rel="icon" type="image/png" href="./favicon.png">
<link rel="stylesheet" href="./styles.css"> <link rel="stylesheet" href="./styles.css">
</head> </head>
<body> <body>
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# AvianVisitors — install the collage frontend on a BirdNET-Pi host.
#
# Assumes you already ran the BirdNET-Pi installer. Drops the frontend
# into /var/www/avian, mounts it under Caddy at http://birdnet.local/collage,
# and adds the JSON shims that the frontend reads from.
#
# Re-run safely — every step is idempotent.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WEB_ROOT="${WEB_ROOT:-/var/www/avian}"
CADDY_SNIPPET="${CADDY_SNIPPET:-/etc/caddy/conf.d/avian.caddy}"
USER_NAME="${USER_NAME:-$USER}"
echo "[avian] repo: $REPO_ROOT"
echo "[avian] web: $WEB_ROOT"
echo "[avian] caddy: $CADDY_SNIPPET"
# 1. Web root with frontend + assets + masks
sudo mkdir -p "$WEB_ROOT"
sudo cp -R "$REPO_ROOT/frontend/." "$WEB_ROOT/"
sudo mkdir -p "$WEB_ROOT/assets"
sudo cp -R "$REPO_ROOT/assets/." "$WEB_ROOT/assets/"
sudo chown -R "$USER_NAME":"$USER_NAME" "$WEB_ROOT"
echo "[avian] frontend deployed → $WEB_ROOT"
# 2. PHP shims (under BirdNET-Pi's web root so PHP-FPM picks them up)
PHP_ROOT="${PHP_ROOT:-/home/$USER_NAME/BirdSongs/Extracted}"
if [ -d "$PHP_ROOT" ]; then
for f in birdnet-api.php recording.php spectrogram.php cutout.php; do
if [ -f "$REPO_ROOT/api/$f" ]; then
sudo cp "$REPO_ROOT/api/$f" "$PHP_ROOT/$f"
sudo chown "$USER_NAME":"$USER_NAME" "$PHP_ROOT/$f"
echo "[avian] api/$f$PHP_ROOT/$f"
fi
done
else
echo "[avian] WARN: $PHP_ROOT not found — copy avian/api/*.php to your BirdNET-Pi PHP root manually."
fi
# 3. Caddy snippet — mounts /collage and proxies /api to PHP
sudo mkdir -p "$(dirname "$CADDY_SNIPPET")"
sudo cp "$REPO_ROOT/caddy/avian.caddy" "$CADDY_SNIPPET"
echo "[avian] caddy snippet → $CADDY_SNIPPET"
# Make sure the main Caddyfile imports the snippet directory
if ! sudo grep -q "import /etc/caddy/conf.d/\*.caddy" /etc/caddy/Caddyfile 2>/dev/null; then
echo "[avian] adding import to /etc/caddy/Caddyfile"
echo -e "\nimport /etc/caddy/conf.d/*.caddy" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
# 4. Optional: install Gemini pregen Python deps (none — uses stdlib)
# Just leave a note so users find the regen flow.
echo "[avian] (optional) regenerate illustrations:"
echo " export GEMINI_API_KEY=your-key"
echo " python3 $REPO_ROOT/scripts/pregen.py --labels /home/$USER_NAME/BirdNET-Pi/model/labels.txt"
# 5. Reload Caddy
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy
echo "[avian] caddy reloaded — collage live at http://birdnet.local/collage/"
+125 -96
View File
@@ -1,28 +1,30 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""AvianVisitors — pre-generate kachō-e illustrations for a region. """AvianVisitors — pre-generate kachō-e illustrations for a region.
Reads a species list (from BirdNET-Pi's labels.txt, eBird, or stdin), Reads a species list (BirdNET-Pi's labels.txt, eBird, or stdin),
generates an illustration for each via the Gemini 2.5 Flash Image API, generates an illustration for each via the Gemini 2.5 Flash Image API,
and saves PNGs into avian/assets/illustrations/. and saves PNGs into avian/assets/illustrations/.
Each species gets two poses: <slug>.png (perched) and <slug>-2.png Each species gets two poses: <slug>.png (perched) and <slug>-2.png
(flight). The prompt template lives at avian/scripts/prompt.template.md (flight). Edit avian/scripts/prompt.template.md to change the visual
— edit it to change the visual style. style — the prompt body is re-sent verbatim per request with
{sci_name}, {com_name}, and {pose} substituted.
Usage: Usage:
# Generate the full BirdNET-Pi label set: # Every species BirdNET-Pi knows:
python3 pregen.py --labels /home/$USER/BirdNET-Pi/model/labels.txt python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt
# Generate only species observed in eBird region US-CA: # Only species observed in an eBird region:
python3 pregen.py --labels labels.txt --ebird-region US-CA --ebird-key YOUR_KEY python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt \\
--ebird-region US-CA --ebird-key YOUR_KEY
# Re-render a single species (useful when you tweak the prompt): # Re-render a single species (useful after editing the prompt):
python3 pregen.py --species "Calypte anna|Anna's Hummingbird" --force python3 pregen.py --species "Calypte anna|Anna's Hummingbird" --force
# Re-render everything (after a prompt change you actually want applied): # Re-render everything after a prompt change:
python3 pregen.py --labels labels.txt --force python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt --force
Set GEMINI_API_KEY in the environment or pass --gemini-key. Set GEMINI_API_KEY in the environment (preferred) or pass --gemini-key.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -37,108 +39,149 @@ import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
# Gemini's image-out model. The endpoint changes occasionally; if you
# get a 404 here, check Google's model catalog and bump this.
GEMINI_URL = ( GEMINI_URL = (
"https://generativelanguage.googleapis.com/v1beta/models/" "https://generativelanguage.googleapis.com/v1beta/models/"
"gemini-2.5-flash-image:generateContent?key={key}" "gemini-2.5-flash-image-preview:generateContent"
) )
POSES = {1: "perched", 2: "in flight with wings spread"} POSES = {1: "perched", 2: "in flight with wings spread"}
def slugify(sci: str) -> str: def slugify(sci: str) -> str:
"""Match avian/frontend/apt.js slugify() exactly."""
return re.sub(r"[^a-z0-9]+", "-", sci.lower()).strip("-") return re.sub(r"[^a-z0-9]+", "-", sci.lower()).strip("-")
def parse_species_line(line: str) -> tuple[str, str] | None:
"""Accept any of: 'Sci|Com', 'Sci_Com', 'Sci,Com'. Skip blanks + #."""
line = line.strip()
if not line or line.startswith("#"):
return None
for sep in ("|", "_", ","):
if sep in line:
sci, com = line.split(sep, 1)
sci, com = sci.strip(), com.strip()
if sci and com:
return (sci, com)
return None
def parse_species_list(lines: list[str]) -> tuple[list[tuple[str, str]], int]:
"""Returns (parsed, skipped_count)."""
out, skipped = [], 0
for line in lines:
parsed = parse_species_line(line)
if parsed:
out.append(parsed)
elif line.strip() and not line.lstrip().startswith("#"):
skipped += 1
return out, skipped
def load_prompt(path: Path) -> str: def load_prompt(path: Path) -> str:
"""Pull the prompt body from the markdown template — everything """Return everything after the `## Prompt` heading, stripped to the
after the `## Prompt` heading, stripped.""" next `##` heading (so doc preamble or trailing sections don't bleed
into the API call)."""
text = path.read_text() text = path.read_text()
m = re.search(r"##\s*Prompt\s*\n(.+)$", text, flags=re.DOTALL) m = re.search(r"##\s*Prompt\s*\n(.+?)(?=\n##\s|\Z)", text, flags=re.DOTALL)
return (m.group(1) if m else text).strip() return (m.group(1) if m else text).strip()
def parse_labels(p: Path) -> list[tuple[str, str]]: def ebird_filter(species, region: str, key: str):
"""BirdNET-Pi labels.txt format: `Sci name_Common Name` per line.""" """Intersect a label set with the eBird species list for a region.
out = [] Region codes: US-CA (state), US-CA-085 (county)."""
for line in p.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Split on first underscore (BirdNET format)
if "_" in line:
sci, com = line.split("_", 1)
elif "," in line:
sci, com = (s.strip() for s in line.split(",", 1))
else:
continue
sci = sci.strip()
com = com.strip()
if sci and com:
out.append((sci, com))
return out
def ebird_filter(species: list[tuple[str, str]], region: str, key: str) -> list[tuple[str, str]]:
"""Intersect a label set with the eBird observed-species list for a
region. Region codes look like US-CA (state) or US-CA-085 (county).
See https://documenter.getpostman.com/view/664302/S1ENwy59"""
url = f"https://api.ebird.org/v2/product/spplist/{region}" url = f"https://api.ebird.org/v2/product/spplist/{region}"
req = urllib.request.Request(url, headers={"X-eBirdApiToken": key}) req = urllib.request.Request(url, headers={"X-eBirdApiToken": key})
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
ebird_codes = set(json.loads(r.read())) ebird_codes = set(json.loads(r.read()))
# eBird returns 6-letter species codes — we need sci names. Use the tax_url = "https://api.ebird.org/v2/ref/taxonomy/ebird?fmt=json"
# taxonomy endpoint to map.
tax_url = f"https://api.ebird.org/v2/ref/taxonomy/ebird?fmt=json"
req2 = urllib.request.Request(tax_url, headers={"X-eBirdApiToken": key}) req2 = urllib.request.Request(tax_url, headers={"X-eBirdApiToken": key})
with urllib.request.urlopen(req2, timeout=60) as r: with urllib.request.urlopen(req2, timeout=60) as r:
taxonomy = json.loads(r.read()) taxonomy = json.loads(r.read())
code_to_sci = {t["speciesCode"]: t["sciName"] for t in taxonomy} code_to_sci = {t["speciesCode"]: t["sciName"] for t in taxonomy}
allowed_sci = {code_to_sci[c] for c in ebird_codes if c in code_to_sci} allowed = {code_to_sci[c] for c in ebird_codes if c in code_to_sci}
return [(s, c) for s, c in species if s in allowed_sci] return [(s, c) for s, c in species if s in allowed]
def gen_one(api_key: str, prompt: str, sci: str, com: str, pose: int) -> bytes: def gen_one(api_key: str, prompt: str, sci: str, com: str, pose: int) -> bytes:
"""Single Gemini call. Returns raw PNG bytes.""" """Single Gemini call with bounded retry on 429 + transient 5xx.
body = prompt.replace("{sci_name}", sci).replace("{com_name}", com).replace( Returns raw PNG bytes."""
"{pose}", POSES[pose] body = (prompt
) .replace("{sci_name}", sci)
.replace("{com_name}", com)
.replace("{pose}", POSES[pose]))
payload = { payload = {
"contents": [{"parts": [{"text": body}]}], "contents": [{"parts": [{"text": body}]}],
"generationConfig": {"responseModalities": ["IMAGE"]}, # TEXT included so Gemini can surface safety messaging without
# rejecting the request shape (image-only sometimes errors).
"generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
} }
# API key as header, NOT URL — keeps the key out of Google's
# request logs, proxy logs, and shell history.
req = urllib.request.Request( req = urllib.request.Request(
GEMINI_URL.format(key=urllib.parse.quote(api_key)), GEMINI_URL,
data=json.dumps(payload).encode(), data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json", "x-goog-api-key": api_key},
method="POST", method="POST",
) )
backoff = 4.0
for attempt in range(4):
try:
with urllib.request.urlopen(req, timeout=120) as r: with urllib.request.urlopen(req, timeout=120) as r:
resp = json.loads(r.read()) resp = json.loads(r.read())
break
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503, 504) and attempt < 3:
retry_after = float(e.headers.get("Retry-After") or backoff)
time.sleep(retry_after)
backoff *= 2
continue
raise
except urllib.error.URLError:
if attempt < 3:
time.sleep(backoff)
backoff *= 2
continue
raise
for cand in resp.get("candidates", []): for cand in resp.get("candidates", []):
for part in cand.get("content", {}).get("parts", []): for part in cand.get("content", {}).get("parts", []):
inline = part.get("inlineData") or part.get("inline_data") inline = part.get("inlineData") or part.get("inline_data")
if inline and inline.get("data"): if inline and inline.get("data"):
return base64.b64decode(inline["data"]) return base64.b64decode(inline["data"])
raise RuntimeError(f"no image in response: {json.dumps(resp)[:300]}") # No image — surface the blocking reason so users know what to fix.
finish = (resp.get("candidates", [{}])[0]).get("finishReason", "?")
block = resp.get("promptFeedback", {}).get("blockReason", "")
raise RuntimeError(f"no image (finish={finish} block={block})")
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
src = ap.add_mutually_exclusive_group(required=True) src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--labels", type=Path, help="BirdNET-Pi labels.txt path") src.add_argument("--labels", type=Path, help="Path to BirdNET-Pi labels.txt (or any file of Sci|Com lines)")
src.add_argument("--species", action="append", default=[], src.add_argument("--species", action="append", default=[],
help="Manual species in format 'Sci name|Common Name' (repeatable)") help="Manual 'Sci|Com' (repeatable)")
src.add_argument("--stdin", action="store_true", help="Read species from stdin (one per line, same format)") src.add_argument("--stdin", action="store_true", help="Read Sci|Com lines from stdin")
ap.add_argument("--ebird-region", help="eBird region code (e.g. US-CA, US-CA-085) to filter labels") ap.add_argument("--ebird-region", help="eBird region code (e.g. US-CA, US-CA-085) to filter labels")
ap.add_argument("--ebird-key", help="eBird API key (or set EBIRD_API_KEY)") ap.add_argument("--ebird-key", help="eBird API key (or EBIRD_API_KEY env)")
ap.add_argument("--gemini-key", help="Gemini API key (or set GEMINI_API_KEY)") ap.add_argument("--gemini-key", help="Gemini API key (or GEMINI_API_KEY env)")
ap.add_argument("--out", type=Path, default=Path(__file__).resolve().parents[1] / "assets" / "illustrations", ap.add_argument("--out", type=Path,
default=Path(__file__).resolve().parents[1] / "assets" / "illustrations",
help="Output directory (default: avian/assets/illustrations/)") help="Output directory (default: avian/assets/illustrations/)")
ap.add_argument("--prompt", type=Path, default=Path(__file__).resolve().parent / "prompt.template.md", ap.add_argument("--prompt", type=Path,
default=Path(__file__).resolve().parent / "prompt.template.md",
help="Prompt template path") help="Prompt template path")
ap.add_argument("--poses", nargs="+", type=int, default=[1, 2], ap.add_argument("--poses", nargs="+", type=int, default=[1, 2],
help="Which poses to render (1=perched, 2=flight). Default: both.") choices=list(POSES.keys()),
help="Which poses to render. 1=perched, 2=flight. Default: both.")
ap.add_argument("--force", action="store_true", help="Re-render even if file exists") ap.add_argument("--force", action="store_true", help="Re-render even if file exists")
ap.add_argument("--sleep", type=float, default=1.0, help="Seconds between API calls (rate limit)") ap.add_argument("--sleep", type=float, default=4.0,
help="Seconds between API calls (default 4 = under free-tier RPM cap)")
ap.add_argument("--limit", type=int, default=0, help="Cap species count for testing") ap.add_argument("--limit", type=int, default=0, help="Cap species count for testing")
args = ap.parse_args() args = ap.parse_args()
@@ -149,15 +192,13 @@ def main() -> int:
# Build species list # Build species list
if args.labels: if args.labels:
species = parse_labels(args.labels) species, skipped = parse_species_list(args.labels.read_text().splitlines())
elif args.stdin: elif args.stdin:
species = parse_labels_lines(sys.stdin.read().splitlines()) species, skipped = parse_species_list(sys.stdin.read().splitlines())
else: else:
species = [] species, skipped = parse_species_list(args.species)
for s in args.species: if skipped:
if "|" in s: print(f"[parse] skipped {skipped} malformed line(s)", file=sys.stderr)
sci, com = s.split("|", 1)
species.append((sci.strip(), com.strip()))
if not species: if not species:
print("error: no species resolved", file=sys.stderr) print("error: no species resolved", file=sys.stderr)
return 2 return 2
@@ -167,7 +208,7 @@ def main() -> int:
if not ek: if not ek:
print("error: --ebird-region requires --ebird-key or EBIRD_API_KEY", file=sys.stderr) print("error: --ebird-region requires --ebird-key or EBIRD_API_KEY", file=sys.stderr)
return 2 return 2
print(f"[ebird] filtering {len(species)} species against region {args.ebird_region}") print(f"[ebird] filtering {len(species)} species against {args.ebird_region}")
species = ebird_filter(species, args.ebird_region, ek) species = ebird_filter(species, args.ebird_region, ek)
if args.limit: if args.limit:
@@ -177,48 +218,36 @@ def main() -> int:
args.out.mkdir(parents=True, exist_ok=True) args.out.mkdir(parents=True, exist_ok=True)
total = len(species) * len(args.poses) total = len(species) * len(args.poses)
print(f"generating {total} illustrations into {args.out}/") print(f"generating up to {total} illustrations into {args.out}/")
done = skipped = failed = 0 done = skipped_existing = failed = 0
for sci, com in species: first_fail = None
for idx, (sci, com) in enumerate(species):
slug = slugify(sci) slug = slugify(sci)
for pose in args.poses: for pose in args.poses:
fname = f"{slug}.png" if pose == 1 else f"{slug}-{pose}.png" fname = f"{slug}.png" if pose == 1 else f"{slug}-{pose}.png"
path = args.out / fname path = args.out / fname
if path.exists() and not args.force: if path.exists() and not args.force:
skipped += 1 skipped_existing += 1
continue continue
try: try:
data = gen_one(gemini_key, prompt, sci, com, pose) data = gen_one(gemini_key, prompt, sci, com, pose)
path.write_bytes(data) path.write_bytes(data)
done += 1 done += 1
print(f" {fname} ({len(data)//1024} KB)") print(f" [ok] {fname} ({len(data)//1024} KB)")
except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as e: except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as e:
failed += 1 failed += 1
print(f"{fname}: {e}", file=sys.stderr) first_fail = first_fail or fname
print(f" [fail] {fname}: {e}", file=sys.stderr)
# Don't sleep after the last species' last pose.
if not (idx == len(species) - 1 and pose == args.poses[-1]):
time.sleep(args.sleep) time.sleep(args.sleep)
print(f"\ngenerated {done} · skipped {skipped} · failed {failed}") print(f"\ngenerated {done} · skipped {skipped_existing} · failed {failed}")
if first_fail:
print(f"first failure: {first_fail} (re-run without --force to retry only the misses)", file=sys.stderr)
return 0 if failed == 0 else 1 return 0 if failed == 0 else 1
def parse_labels_lines(lines: list[str]) -> list[tuple[str, str]]:
out = []
for line in lines:
line = line.strip()
if not line:
continue
if "|" in line:
sci, com = line.split("|", 1)
elif "_" in line:
sci, com = line.split("_", 1)
elif "," in line:
sci, com = line.split(",", 1)
else:
continue
out.append((sci.strip(), com.strip()))
return out
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+6 -7
View File
@@ -4,16 +4,16 @@ This is the prompt sent to Gemini for every bird illustration. Edit it
to change the style — the entire visual feel of the collage flows to change the style — the entire visual feel of the collage flows
through this template. through this template.
Two placeholders get replaced per request: Three placeholders get replaced per request:
- `{sci_name}` — the binomial Latin name, e.g. `Calypte anna` - `{sci_name}` — the binomial Latin name, e.g. `Calypte anna`
- `{com_name}` — the English common name, e.g. `Anna's Hummingbird` - `{com_name}` — the English common name, e.g. `Anna's Hummingbird`
- `{pose}` — either `perched` (pose 1) or `in flight with wings spread` (pose 2) - `{pose}` — either `perched` (pose 1) or `in flight with wings spread` (pose 2)
The default style below is **kachō-e** — Edo-period Japanese The default style below is **kachō-e** — Edo-period Japanese
flower-and-bird woodblock prints, rendered in ink and watercolor on flower-and-bird woodblock prints, rendered in ink and watercolor.
warm paper. Replace the body of the prompt with whatever feels right Replace the body below with whatever style feels right for your
for your apartment. apartment.
--- ---
@@ -38,6 +38,5 @@ Anatomy must be biologically accurate for the named species:
tucked behind. For flight: both wings extended in a natural flapping tucked behind. For flight: both wings extended in a natural flapping
position. position.
Render at high resolution on a clean transparent background. Output Render at high resolution on a fully transparent background. The bird
the illustration with no surrounding scene, no caption, no shadow must be cut out cleanly — no shadow, no paper texture, no caption.
beneath the bird.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+2 -2
View File
@@ -43,8 +43,8 @@ if [[ ! -z $PACKAGES_MISSING ]] ; then
sudo apt -y install $PACKAGES_MISSING sudo apt -y install $PACKAGES_MISSING
fi fi
branch=main branch=avian-visitors
git clone -b $branch --depth=1 https://github.com/Nachtzuster/BirdNET-Pi.git ${HOME}/BirdNET-Pi && git clone -b $branch --depth=1 https://github.com/Twarner491/AvianVisitors.git ${HOME}/BirdNET-Pi &&
$HOME/BirdNET-Pi/scripts/install_birdnet.sh $HOME/BirdNET-Pi/scripts/install_birdnet.sh
if [ ${PIPESTATUS[0]} -eq 0 ];then if [ ${PIPESTATUS[0]} -eq 0 ];then
+2
View File
@@ -69,6 +69,8 @@ create_necessary_dirs() {
sudo -u ${USER} ln -fs $my_dir/include_species_list.txt $my_dir/scripts sudo -u ${USER} ln -fs $my_dir/include_species_list.txt $my_dir/scripts
sudo -u ${USER} ln -fs $my_dir/whitelist_species_list.txt $my_dir/scripts sudo -u ${USER} ln -fs $my_dir/whitelist_species_list.txt $my_dir/scripts
sudo -u ${USER} ln -fs $my_dir/homepage/* ${EXTRACTED} sudo -u ${USER} ln -fs $my_dir/homepage/* ${EXTRACTED}
# AvianVisitors overlay — served at http://birdnet.local/avian/
[ -d $my_dir/avian ] && sudo -u ${USER} ln -fs $my_dir/avian ${EXTRACTED}/avian
sudo -u ${USER} ln -fs $my_dir/model/labels.txt ${my_dir}/scripts sudo -u ${USER} ln -fs $my_dir/model/labels.txt ${my_dir}/scripts
sudo -u ${USER} ln -fs $my_dir/scripts ${EXTRACTED} sudo -u ${USER} ln -fs $my_dir/scripts ${EXTRACTED}
sudo -u ${USER} ln -fs $my_dir/scripts/play.php ${EXTRACTED} sudo -u ${USER} ln -fs $my_dir/scripts/play.php ${EXTRACTED}