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:
@@ -2,7 +2,7 @@
|
||||
|
||||
*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" />
|
||||
|
||||
@@ -10,100 +10,84 @@ A frontend overlay for [BirdNET-Pi](https://github.com/Nachtzuster/BirdNET-Pi).
|
||||
|
||||
## BOM
|
||||
|
||||
| Qty | Description | Price | Link | Notes |
|
||||
|-----|-------------|-------|------|-------|
|
||||
| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$35–80 | [Raspberry Pi](https://www.raspberrypi.com/products/) | 4B+ recommended |
|
||||
| 1 | Micro SD Card | ~$10 | [Amazon](https://a.co/d/08aiL8c) | ≥32 GB |
|
||||
| 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 | Pi Power Supply | ~$10 | — | Matched to your Pi model |
|
||||
| Qty | Description | Price | Link |
|
||||
|-----|-------------|-------|------|
|
||||
| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$35–80 | [Raspberry Pi](https://www.raspberrypi.com/products/) |
|
||||
| 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) |
|
||||
| 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
|
||||
ssh monalisa@birdnet.local
|
||||
git clone https://github.com/Twarner491/AvianVisitors.git ~/AvianVisitors
|
||||
cd ~/AvianVisitors
|
||||
ssh <your-username>@birdnet.local
|
||||
curl -s https://raw.githubusercontent.com/Twarner491/AvianVisitors/avian-visitors/newinstaller.sh | bash
|
||||
```
|
||||
|
||||
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 20–40 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
|
||||
export GEMINI_API_KEY='your-gemini-key'
|
||||
|
||||
# Generate every species BirdNET-Pi knows:
|
||||
python3 avian/scripts/pregen.py --labels ~/BirdNET-Pi/model/labels.txt
|
||||
# Re-render every species in BirdNET-Pi's model:
|
||||
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'
|
||||
python3 avian/scripts/pregen.py \
|
||||
python3 ~/BirdNET-Pi/avian/scripts/pregen.py \
|
||||
--labels ~/BirdNET-Pi/model/labels.txt \
|
||||
--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
|
||||
bash avian/scripts/install.sh
|
||||
```
|
||||
|
||||
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.
|
||||
- **Cloudflare Tunnel** — public HTTPS URL, no port forwarding, optional Cloudflare Access password gate.
|
||||
- **Home Assistant REST sensor** — surfaces the latest detection for automations.
|
||||
- **MQTT bridge** — publishes every new detection as JSON.
|
||||
|
||||
---
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
avian/
|
||||
avian/ # everything we add to BirdNET-Pi
|
||||
├── frontend/ # static HTML/JS/CSS for the collage
|
||||
├── assets/ # bundled illustrations + cutouts + masks
|
||||
├── api/ # PHP shims served by BirdNET-Pi's existing PHP-FPM
|
||||
├── scripts/ # installer + Gemini pregen + editable prompt
|
||||
├── caddy/ # snippet mounting /collage on existing Caddy
|
||||
├── assets/ # 450 bundled illustrations + cutouts + masks
|
||||
├── api/ # PHP shims served by BirdNET-Pi's PHP-FPM
|
||||
├── scripts/ # pregen.py + editable prompt template
|
||||
└── forwarding/ # optional HA / MQTT / Cloudflare configs
|
||||
```
|
||||
|
||||
@@ -113,7 +97,7 @@ Everything outside `avian/` is upstream BirdNET-Pi.
|
||||
|
||||
## 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
@@ -1,26 +1,35 @@
|
||||
<?php
|
||||
// /home/monalisa/BirdSongs/Extracted/api.php — JSON facade over BirdNET-Pi's
|
||||
// birds.db, queryable by the bird.onethreenine.net Cloudflare Worker.
|
||||
//
|
||||
// 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.
|
||||
// AvianVisitors — JSON facade over BirdNET-Pi's birds.db. Read-only.
|
||||
// Symlinked into the BirdNET-Pi Caddy site root at /avian/api/.
|
||||
//
|
||||
// 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
|
||||
// recent — &hours=N (default 24): every detection in the window
|
||||
// species — &sci=<sci_name>: detail page for one species
|
||||
// recent — &hours=N (default 24): species heard in the window
|
||||
// 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);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
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)) {
|
||||
http_response_code(503);
|
||||
|
||||
+77
-42
@@ -1,20 +1,17 @@
|
||||
<?php
|
||||
// /home/monalisa/BirdSongs/Extracted/cutout.php — dynamic background-removed
|
||||
// bird-photo facade. Called by the bird.onethreenine.net Cloudflare Worker
|
||||
// (which fronts /api/img?sci=<name>).
|
||||
// AvianVisitors — bird image resolver.
|
||||
//
|
||||
// Lives in the Caddy file-server root so the BirdNET-Pi auto-update doesn't
|
||||
// touch it. Each new species hits this once: download Wikipedia/Macaulay
|
||||
// image → rembg via /usr/local/bin/rembg-cli → cache transparent PNG to
|
||||
// disk under cutouts/. Subsequent requests are instant readfile() from cache.
|
||||
// Lookup chain for /avian/api/cutout.php?sci=Calypte+anna:
|
||||
// 1. ../assets/illustrations/<slug>.png (450+ bundled kachō-e renders)
|
||||
// 2. ../assets/cutouts/<slug>.png (background-removed photo)
|
||||
// 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 top, so this script inherits the Worker-only access guarantee.
|
||||
// The frontend's <img src> points here for every species — bundled
|
||||
// hits return instantly; cold misses fall through to the dynamic path.
|
||||
//
|
||||
// Pre-reqs (run once via /install/rembg-setup.sh):
|
||||
// - /home/monalisa/rembg-env/bin/rembg (Python venv)
|
||||
// - /usr/local/bin/rembg-cli (wrapper)
|
||||
// - /home/monalisa/BirdSongs/Extracted/cutouts/ (cache dir, monalisa-owned)
|
||||
// Default LAN deploy ships without auth. To expose publicly, gate
|
||||
// /avian/api/* with basic_auth in your Caddyfile — see avian/forwarding/.
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
@@ -24,36 +21,63 @@ if ($sci === '') {
|
||||
echo 'sci required';
|
||||
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 = trim((string)$slug, '-');
|
||||
$cacheDir = '/home/monalisa/BirdSongs/Extracted/cutouts';
|
||||
$cachePath = "$cacheDir/$slug.png";
|
||||
|
||||
function serve_png(string $path): void {
|
||||
header('Content-Type: image/png');
|
||||
header('Cache-Control: public, max-age=2592000');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('Content-Length: ' . (string)filesize($path));
|
||||
readfile($path);
|
||||
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) {
|
||||
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);
|
||||
|
||||
// Resolve a source image URL. Wikipedia summary first (free, fast,
|
||||
// no auth, has a clean originalimage for most birds).
|
||||
// Wikipedia's REST API asks for a contact-able identifier. Override
|
||||
// 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([
|
||||
'http' => [
|
||||
'header' => "User-Agent: apartment-birds/1.0 (twarner491@gmail.com)\r\n",
|
||||
'timeout' => 12,
|
||||
],
|
||||
'http' => ['header' => "User-Agent: $ua\r\n", 'timeout' => 12],
|
||||
]);
|
||||
$wpUrl = 'https://en.wikipedia.org/api/rest_v1/page/summary/' . rawurlencode($sci);
|
||||
$wpJson = @file_get_contents($wpUrl, false, $ctx);
|
||||
@@ -62,13 +86,20 @@ if ($wpJson !== false) {
|
||||
$j = json_decode($wpJson, true);
|
||||
$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) {
|
||||
http_response_code(404);
|
||||
echo 'no upstream image for ' . htmlspecialchars($sci);
|
||||
echo 'no Wikipedia photo for ' . htmlspecialchars($sci);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Download the source image.
|
||||
$imgBytes = @file_get_contents($srcUrl, false, $ctx);
|
||||
if (!$imgBytes || strlen($imgBytes) < 1024) {
|
||||
http_response_code(503);
|
||||
@@ -76,16 +107,19 @@ if (!$imgBytes || strlen($imgBytes) < 1024) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Run rembg via the wrapper. We use temp files because rembg's CLI prefers
|
||||
// real paths. u2netp is the lightweight model (~50MB peak RAM) — important
|
||||
// on the Pi 3B+ (1GB total RAM). Call with --post-process-mask to clean
|
||||
// up edges.
|
||||
$tmpIn = tempnam('/tmp', 'rembg-in-') . '.jpg';
|
||||
$tmpOut = tempnam('/tmp', 'rembg-out-') . '.png';
|
||||
// rembg via the wrapper. u2netp = lightweight model (~50MB peak RAM —
|
||||
// matters on the Pi 3B+). Temp files because rembg's CLI prefers
|
||||
// real paths.
|
||||
$tmpInBase = tempnam(sys_get_temp_dir(), 'rembg-in-');
|
||||
$tmpOutBase = tempnam(sys_get_temp_dir(), 'rembg-out-');
|
||||
@unlink($tmpInBase); @unlink($tmpOutBase);
|
||||
$tmpIn = $tmpInBase . '.jpg';
|
||||
$tmpOut = $tmpOutBase . '.png';
|
||||
file_put_contents($tmpIn, $imgBytes);
|
||||
|
||||
$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($tmpOut)
|
||||
);
|
||||
@@ -96,13 +130,13 @@ if (!is_file($tmpOut) || filesize($tmpOut) < 1024) {
|
||||
@unlink($tmpOut);
|
||||
http_response_code(500);
|
||||
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;
|
||||
}
|
||||
|
||||
// 1. Alpha-crop to bounding box so each PNG == the bird's actual shape
|
||||
// (no transparent padding around it). Lets the layout pack tiles tight.
|
||||
// 2. Resize down to a max edge of 800px so the cache stays small.
|
||||
// Tight-crop to the bird's bounding box + downscale to 800px max edge
|
||||
// so cache stays small.
|
||||
$im = @imagecreatefrompng($tmpOut);
|
||||
if ($im !== false) {
|
||||
$cropped = @imagecropauto($im, IMG_CROP_TRANSPARENT);
|
||||
@@ -128,7 +162,8 @@ if ($im !== false) {
|
||||
imagedestroy($im);
|
||||
}
|
||||
|
||||
// Cache + serve.
|
||||
copy($tmpOut, $cachePath);
|
||||
@unlink($tmpOut);
|
||||
// Atomic install: rename is atomic on the same filesystem, so any
|
||||
// concurrent reader either sees the old cached file or the new one,
|
||||
// never a half-written PNG.
|
||||
@rename($tmpOut, $cachePath);
|
||||
serve_png($cachePath);
|
||||
|
||||
+12
-13
@@ -1,16 +1,15 @@
|
||||
<?php
|
||||
// /home/monalisa/BirdSongs/Extracted/recording.php — serves the most-recent
|
||||
// detection mp3 for a given scientific name. Called by the Cloudflare Worker
|
||||
// at /api/recording?sci=<name>.
|
||||
// AvianVisitors — serves the most-recent detection mp3 for a given
|
||||
// scientific name. Called by the collage detail modal at
|
||||
// /avian/api/recording.php?sci=<name>.
|
||||
//
|
||||
// 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
|
||||
// English common name (e.g. "Anna's_Hummingbird"), NOT the scientific name.
|
||||
// We resolve sci → common via the same lookup the Pi's web UI uses
|
||||
// (birds.json under /scripts/) and then walk the directory tree newest-first.
|
||||
// English common name (e.g. "Anna's_Hummingbird"), NOT the scientific
|
||||
// name. We resolve sci → common via birds.json under BirdNET-Pi/scripts/
|
||||
// and walk the directory tree newest-first.
|
||||
//
|
||||
// Auth: the Caddy site block 403s anything missing X-BirdNET-Proxy-Token.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$BY_DATE = '/home/monalisa/BirdSongs/Extracted/By_Date';
|
||||
$BY_DATE = getenv('HOME') . '/BirdSongs/Extracted/By_Date';
|
||||
|
||||
// ---- Direct-by-file lookup ----
|
||||
// Used by the atlas detail modal to play any past recording.
|
||||
@@ -95,14 +94,14 @@ if ($file !== '') {
|
||||
exit;
|
||||
}
|
||||
$BIRDS_JSON_CANDIDATES = [
|
||||
'/home/monalisa/BirdNET-Pi/scripts/birds.json',
|
||||
'/home/monalisa/BirdNET-Pi/model/labels.txt',
|
||||
getenv('HOME') . '/BirdNET-Pi/scripts/birds.json',
|
||||
getenv('HOME') . '/BirdNET-Pi/model/labels.txt',
|
||||
];
|
||||
|
||||
// ---- Resolve scientific name → common name (with underscores) ----
|
||||
function resolve_common(string $sci): ?string {
|
||||
// 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)) {
|
||||
$list = json_decode((string)file_get_contents($f), true);
|
||||
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.
|
||||
$labels = '/home/monalisa/BirdNET-Pi/model/labels.txt';
|
||||
$labels = getenv('HOME') . '/BirdNET-Pi/model/labels.txt';
|
||||
if (is_readable($labels)) {
|
||||
foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) {
|
||||
if (strpos($line, '_') !== false) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
// /home/monalisa/BirdSongs/Extracted/spectrogram.php — serves the
|
||||
// spectrogram PNG that BirdNET-Pi generates alongside each detection mp3.
|
||||
// Same lookup logic as recording.php (find the matching file under
|
||||
// By_Date/<date>/<Common_Name>/) — just .png instead of .mp3.
|
||||
// AvianVisitors — serves the spectrogram PNG that BirdNET-Pi generates
|
||||
// alongside each detection mp3. Same lookup logic as recording.php (find
|
||||
// the matching file under By_Date/<date>/<Common_Name>/) — just .png
|
||||
// instead of .mp3.
|
||||
//
|
||||
// Endpoints:
|
||||
// ?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;
|
||||
}
|
||||
|
||||
$BY_DATE = '/home/monalisa/BirdSongs/Extracted/By_Date';
|
||||
$BY_DATE = getenv('HOME') . '/BirdSongs/Extracted/By_Date';
|
||||
|
||||
// ---- Direct-by-file lookup ----
|
||||
// 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 {
|
||||
$f = '/home/monalisa/BirdNET-Pi/scripts/birds.json';
|
||||
$f = getenv('HOME') . '/BirdNET-Pi/scripts/birds.json';
|
||||
if (is_readable($f)) {
|
||||
$list = json_decode((string)file_get_contents($f), true);
|
||||
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)) {
|
||||
foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) {
|
||||
if (strpos($line, '_') !== false) {
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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
|
||||
home IP. Free Cloudflare account required.
|
||||
Free Cloudflare account required.
|
||||
|
||||
Install `cloudflared` on the Pi:
|
||||
Install `cloudflared`:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /usr/share/keyrings
|
||||
sudo apt install -y lsb-release
|
||||
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
|
||||
| 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 apt update && sudo apt install -y cloudflared
|
||||
```
|
||||
@@ -29,28 +28,21 @@ Authenticate + create the tunnel:
|
||||
```bash
|
||||
cloudflared tunnel login
|
||||
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
|
||||
```
|
||||
|
||||
Configure the tunnel to point at the local Caddy:
|
||||
Configure + start the tunnel:
|
||||
|
||||
```bash
|
||||
sudo cp avian/forwarding/cloudflared.yml /etc/cloudflared/config.yml
|
||||
# edit /etc/cloudflared/config.yml — set `tunnel:` to your tunnel UUID
|
||||
sudo cp ~/BirdNET-Pi/avian/forwarding/cloudflared.yml /etc/cloudflared/config.yml
|
||||
# Edit /etc/cloudflared/config.yml — set `tunnel:` to your tunnel UUID
|
||||
sudo cloudflared service install
|
||||
sudo systemctl restart cloudflared
|
||||
```
|
||||
|
||||
**Adding password protection on the public URL.** With Cloudflare in
|
||||
front, gate the public endpoint via Cloudflare Access (zero-trust
|
||||
free tier supports up to 50 users) — see Cloudflare docs. The local
|
||||
LAN URL remains unprotected. If you'd rather use HTTP Basic auth on
|
||||
Caddy itself, see [forwarding/caddy-auth.caddy](caddy-auth.caddy).
|
||||
To password-protect the public URL, set up Cloudflare Access (free
|
||||
tier: up to 50 users). The LAN URL stays open. If you'd rather use HTTP
|
||||
Basic auth, see [`caddy-auth.caddy`](caddy-auth.caddy).
|
||||
|
||||
---
|
||||
|
||||
@@ -60,13 +52,11 @@ Add to `configuration.yaml`:
|
||||
|
||||
```yaml
|
||||
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
|
||||
sensor:
|
||||
- name: "Latest Bird"
|
||||
value_template: >
|
||||
{% set top = value_json.species | sort(attribute='last_seen', reverse=true) | first %}
|
||||
{{ top.com if top else 'none' }}
|
||||
value_template: "{{ value_json.species[0].com if value_json.species else 'none' }}"
|
||||
json_attributes_path: "$.species[0]"
|
||||
json_attributes:
|
||||
- sci
|
||||
@@ -75,25 +65,25 @@ rest:
|
||||
- best_conf
|
||||
```
|
||||
|
||||
Use the sensor in automations — flash a light when a new species is
|
||||
heard, etc.
|
||||
`birdnet-api.php?action=recent` already returns `species` ordered by
|
||||
count desc; if you want most-recent first, replace the value_template
|
||||
with a sort filter.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
sudo pip3 install paho-mqtt --break-system-packages
|
||||
cp avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py
|
||||
# edit ~/avian-mqtt.py — broker host, topic prefix
|
||||
sudo cp avian/forwarding/avian-mqtt.service /etc/systemd/system/
|
||||
cp ~/BirdNET-Pi/avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py
|
||||
# Edit ~/avian-mqtt.py — broker host, topic prefix, credentials
|
||||
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 enable --now avian-mqtt
|
||||
```
|
||||
|
||||
The bridge polls `/api/recent.json?hours=1` once a minute and
|
||||
publishes new species under `birdnet/<slug>` with the full record as
|
||||
JSON payload.
|
||||
The bridge polls `/avian/api/birdnet-api.php?action=recent&hours=1`
|
||||
once a minute and publishes new species under `birdnet/<slug>` with the
|
||||
full record as a JSON payload. Dedup is in-memory only — downstream
|
||||
consumers should be idempotent.
|
||||
|
||||
@@ -4,7 +4,10 @@ After=network-online.target
|
||||
|
||||
[Service]
|
||||
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
|
||||
RestartSec=10
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Poll /api/recent.json on the Pi once a minute and publish each new
|
||||
detection to MQTT. Edit BROKER, TOPIC_PREFIX, and PI_URL below."""
|
||||
"""Poll AvianVisitors' recent-detections endpoint once a minute and publish
|
||||
each new species to MQTT. Edit BROKER, TOPIC_PREFIX, and PI_URL below."""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -11,7 +11,7 @@ PORT = 1883
|
||||
USER = ""
|
||||
PASSWORD = ""
|
||||
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()
|
||||
|
||||
@@ -36,6 +36,12 @@ def loop(client: mqtt.Client) -> None:
|
||||
time.sleep(60)
|
||||
|
||||
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()
|
||||
if USER:
|
||||
client.username_pw_set(USER, PASSWORD)
|
||||
|
||||
+173
-117
@@ -1,67 +1,79 @@
|
||||
/* 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
|
||||
* count. Layout normalises so all birds always fit on
|
||||
* count. Layout normalises so every bird always fits on
|
||||
* every viewport.
|
||||
* stats — per-species histogram across the selected time window.
|
||||
* atlas — alphabet of all detected species with detail modal.
|
||||
* stats — per-species mark on a time × count plot.
|
||||
* atlas — grid of every species ever detected, with detail modal.
|
||||
*
|
||||
* Reads four JSON endpoints exposed by the Pi (PHP shims in ../api/):
|
||||
* GET /api/recent.json?hours=N — species + counts in window
|
||||
* GET /api/lifelist.json — all species ever detected
|
||||
* GET /api/firstseen.json — earliest detection per species
|
||||
* GET /api/timeseries.json?days=D — daily counts per species
|
||||
* Lives at $HOME/BirdNET-Pi/avian/frontend/ on the Pi; the install
|
||||
* symlinks $HOME/BirdNET-Pi/avian → $EXTRACTED/avian, so this file
|
||||
* loads from http://birdnet.local/avian/frontend/apt.js with the
|
||||
* collage at http://birdnet.local/avian/frontend/.
|
||||
*
|
||||
* Plus two media proxies (also via PHP):
|
||||
* GET /api/img?sci=X[&com=Y] — bird illustration
|
||||
* GET /api/recording?sci=X — most-recent mp3
|
||||
* 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/.
|
||||
* All API calls are relative — they target the PHP shims in ../api/
|
||||
* served by BirdNET-Pi's existing Caddy + PHP-FPM stack. No frontend
|
||||
* configuration needed; works out of the box on any BirdNET-Pi host.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ---- Config ----
|
||||
// API_BASE: where the JSON endpoints live. Default '/' works when
|
||||
// Caddy mounts the frontend at the same host as the API. Override
|
||||
// with window.AV_API_BASE if you split origins.
|
||||
var API_BASE = (window.AV_API_BASE || '').replace(/\/$/, '');
|
||||
var IMG_VERSION = '1'; // bump after running scripts/pregen.py
|
||||
// Relative API helpers. Frontend lives at /avian/frontend/; the PHP
|
||||
// shims live at /avian/api/. The router PHP (birdnet-api.php)
|
||||
// dispatches on ?action= to the recent/lifelist/firstseen/timeseries
|
||||
// queries.
|
||||
function api(action, qs) {
|
||||
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 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ---- State ----
|
||||
var DATA = {
|
||||
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 DATA = { recent: null, lifelist: null, firstseen: null, timeseries: null };
|
||||
var MASKS = null, DIMS = null;
|
||||
var currentHours = +readLS('av:window', '24') || 24;
|
||||
|
||||
// ---- Boot: load mask/dim registries, then first data pull ----
|
||||
// ---- Boot ----
|
||||
Promise.all([
|
||||
fetchJson(api('/avian/masks.json')).then(function (j) { MASKS = j; }),
|
||||
fetchJson(api('/avian/dims.json')).then(function (j) { DIMS = j; }),
|
||||
fetchJson('./masks.json').then(function (j) { MASKS = j; }),
|
||||
fetchJson('./dims.json').then(function (j) { DIMS = j; }),
|
||||
]).then(function () {
|
||||
bindUI();
|
||||
refreshAll();
|
||||
setInterval(refreshRecent, 60000); // poll for new detections every min
|
||||
}).catch(function (e) { console.error('boot failed', e); });
|
||||
setInterval(refreshRecent, 60000);
|
||||
}).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 ----
|
||||
var slider = document.getElementById('slider');
|
||||
var winPick = document.getElementById('winPick');
|
||||
var views = document.querySelectorAll('.view');
|
||||
// ---- UI ----
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function syncPill(container) {
|
||||
if (!container) return;
|
||||
var pill = container.querySelector('.seg-pill');
|
||||
var active = container.querySelector('button[aria-current="true"]');
|
||||
if (!pill || !active) return;
|
||||
@@ -69,11 +81,15 @@
|
||||
pill.style.transform = 'translateX(' + active.offsetLeft + 'px)';
|
||||
}
|
||||
function syncAllPills() {
|
||||
[slider, winPick, document.getElementById('atlasSort')].forEach(function (c) { if (c) syncPill(c); });
|
||||
[$('slider'), $('winPick'), $('atlasSort')].forEach(syncPill);
|
||||
}
|
||||
function bindUI() {
|
||||
// View slider
|
||||
[].slice.call(slider.querySelectorAll('button')).forEach(function (b) {
|
||||
var slider = $('slider');
|
||||
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 () {
|
||||
[].slice.call(slider.querySelectorAll('button')).forEach(function (x) {
|
||||
x.setAttribute('aria-current', x === b ? 'true' : 'false');
|
||||
@@ -86,8 +102,8 @@
|
||||
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.addEventListener('click', function () {
|
||||
[].slice.call(winPick.querySelectorAll('button')).forEach(function (x) {
|
||||
@@ -99,9 +115,8 @@
|
||||
refreshRecent();
|
||||
});
|
||||
});
|
||||
// Atlas sort
|
||||
var atlasEl = document.getElementById('atlasSort');
|
||||
[].slice.call(atlasEl.querySelectorAll('button')).forEach(function (b) {
|
||||
|
||||
if (atlasEl) [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
[].slice.call(atlasEl.querySelectorAll('button')).forEach(function (x) {
|
||||
x.setAttribute('aria-current', x === b ? 'true' : 'false');
|
||||
@@ -111,16 +126,16 @@
|
||||
renderAtlas();
|
||||
});
|
||||
});
|
||||
// About modal
|
||||
document.getElementById('aboutLink').addEventListener('click', function () {
|
||||
document.getElementById('about-modal').setAttribute('aria-hidden', 'false');
|
||||
|
||||
var aboutLink = $('aboutLink');
|
||||
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;
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(rT);
|
||||
@@ -137,10 +152,10 @@
|
||||
function refreshAll() {
|
||||
var h = currentHours;
|
||||
return Promise.all([
|
||||
fetchJson(api('/api/lifelist.json')).catch(function () { return null; }),
|
||||
fetchJson(api('/api/firstseen.json')).catch(function () { return null; }),
|
||||
fetchJson(api('/api/timeseries.json?days=30')).catch(function () { return null; }),
|
||||
fetchJson(api('/api/recent.json?hours=' + h)).catch(function () { return null; }),
|
||||
fetchJson(api('lifelist')).catch(function () { return null; }),
|
||||
fetchJson(api('firstseen')).catch(function () { return null; }),
|
||||
fetchJson(api('timeseries', 'days=30')).catch(function () { return null; }),
|
||||
fetchJson(api('recent', 'hours=' + h)).catch(function () { return null; }),
|
||||
]).then(function (parts) {
|
||||
DATA.lifelist = parts[0];
|
||||
DATA.firstseen = parts[1];
|
||||
@@ -152,7 +167,7 @@
|
||||
}
|
||||
function refreshRecent() {
|
||||
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;
|
||||
DATA.recent = j;
|
||||
renderCollageFromData();
|
||||
@@ -160,9 +175,10 @@
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
// ---- Mask + slug helpers ----
|
||||
// ---- Slug + mask helpers ----
|
||||
var maskCache = {};
|
||||
function loadMask(slug) {
|
||||
if (!MASKS) return null;
|
||||
if (maskCache[slug]) return maskCache[slug];
|
||||
var rec = MASKS[slug];
|
||||
if (!rec) return null;
|
||||
@@ -182,17 +198,16 @@
|
||||
return sci.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
function aspect(sci) {
|
||||
var d = DIMS[slugify(sci)];
|
||||
var d = DIMS && DIMS[slugify(sci)];
|
||||
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) ----
|
||||
//
|
||||
// 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) {
|
||||
return {
|
||||
packingBudgetFrac: n <= 4 ? 0.46 : n <= 12 ? 0.40 : n <= 24 ? 0.34 : 0.28,
|
||||
@@ -278,14 +293,17 @@
|
||||
}
|
||||
|
||||
function renderCollageFromData() {
|
||||
var items = (DATA.recent && DATA.recent.species) || [];
|
||||
renderCollage(items);
|
||||
renderCollage((DATA.recent && DATA.recent.species) || []);
|
||||
}
|
||||
function renderCollage(items) {
|
||||
var collage = document.getElementById('collage');
|
||||
var collage = $('collage');
|
||||
if (!collage) return;
|
||||
collage.innerHTML = '';
|
||||
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;
|
||||
}
|
||||
var W = collage.clientWidth, H = collage.clientHeight;
|
||||
@@ -297,13 +315,15 @@
|
||||
var minArea = vpArea * T.minTileAreaFrac;
|
||||
|
||||
var tiles = items.map(function (s) {
|
||||
var slug = slugify(s.sci);
|
||||
var slug = slugify(s.sci || '');
|
||||
var mask = loadMask(slug);
|
||||
if (!mask) return null;
|
||||
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) };
|
||||
}).filter(Boolean);
|
||||
|
||||
if (!tiles.length) { collage.innerHTML = ''; return; }
|
||||
|
||||
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); });
|
||||
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; } });
|
||||
}
|
||||
|
||||
// Build tiles via createElement / textContent / setAttribute so user-
|
||||
// editable species names from labels.txt can't smuggle markup.
|
||||
placed.forEach(function (t) {
|
||||
var s = t.data;
|
||||
var img = api('/api/img?sci=' + encodeURIComponent(s.sci) +
|
||||
(s.com ? '&com=' + encodeURIComponent(s.com) : '') +
|
||||
'&v=' + IMG_VERSION);
|
||||
var label = s.com || s.sci;
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'gtile';
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('data-sci', s.sci);
|
||||
btn.setAttribute('aria-label', s.com || s.sci);
|
||||
btn.title = (s.com || s.sci) + ' · ' + (+s.n || 0) + ' calls';
|
||||
btn.dataset.sci = s.sci;
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.title = label + ' · ' + (+s.n || 0) + ' calls';
|
||||
btn.style.left = t.x + 'px';
|
||||
btn.style.top = t.y + 'px';
|
||||
btn.style.width = t.fullW + '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); });
|
||||
collage.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Stats: per-species histogram with last-seen positioning ----
|
||||
// ---- Stats ----
|
||||
function drawHistograms() {
|
||||
var tl = document.getElementById('statsTimeline');
|
||||
var tl = $('statsTimeline');
|
||||
if (!tl) return;
|
||||
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; }
|
||||
@@ -388,24 +413,38 @@
|
||||
var cap = Math.max(4, Math.floor(W / 28));
|
||||
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 html = '<div class="stats-tl-plot">';
|
||||
tl.innerHTML = '';
|
||||
var plot = document.createElement('div');
|
||||
plot.className = 'stats-tl-plot';
|
||||
sp.forEach(function (s) {
|
||||
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 n = +s.n || 0;
|
||||
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.innerHTML = html;
|
||||
tl.appendChild(plot);
|
||||
}
|
||||
|
||||
// ---- Atlas: gridded alphabet of all species ----
|
||||
// ---- Atlas ----
|
||||
function renderAtlas() {
|
||||
var list = document.getElementById('atlasList');
|
||||
var list = $('atlasList');
|
||||
if (!list) return;
|
||||
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');
|
||||
if (sort === 'count') sp.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); });
|
||||
else if (sort === 'recent') sp.sort(function (a, b) {
|
||||
@@ -414,49 +453,66 @@
|
||||
else sp.sort(function (a, b) {
|
||||
return Date.parse((a.first_seen || '').replace(' ', 'T')) - Date.parse((b.first_seen || '').replace(' ', 'T'));
|
||||
});
|
||||
list.innerHTML = sp.map(function (s) {
|
||||
var img = api('/api/img?sci=' + encodeURIComponent(s.sci) +
|
||||
(s.com ? '&com=' + encodeURIComponent(s.com) : '') +
|
||||
'&v=' + IMG_VERSION);
|
||||
return '<button class="atlas-card" data-sci="' + s.sci + '">' +
|
||||
'<img loading="lazy" decoding="async" src="' + img + '" alt="' + (s.com || s.sci) + '">' +
|
||||
'<span class="atlas-name">' + (s.com || s.sci) + '</span>' +
|
||||
'<span class="atlas-count">' + (+s.n || 0) + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
[].slice.call(list.querySelectorAll('.atlas-card')).forEach(function (b) {
|
||||
b.addEventListener('click', function () { openDetail({ sci: b.dataset.sci }); });
|
||||
list.innerHTML = '';
|
||||
sp.forEach(function (s) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'atlas-card';
|
||||
btn.type = 'button';
|
||||
btn.dataset.sci = s.sci;
|
||||
var img = document.createElement('img');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.alt = s.com || s.sci;
|
||||
img.src = imgUrl(s.sci, s.com);
|
||||
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) {
|
||||
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 img = api('/api/img?sci=' + encodeURIComponent(sci) +
|
||||
(rec.com ? '&com=' + encodeURIComponent(rec.com) : '') +
|
||||
'&v=' + IMG_VERSION);
|
||||
var rec_url = api('/api/recording?sci=' + encodeURIComponent(sci));
|
||||
var spec_url = api('/api/spectrogram?sci=' + encodeURIComponent(sci));
|
||||
var modal = document.getElementById('detail-modal');
|
||||
var label = rec.com || sci;
|
||||
|
||||
var modal = $('detail-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'detail-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
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 =
|
||||
'<div class="modal-backdrop" data-close="1"></div>' +
|
||||
'<div class="detail-card">' +
|
||||
' <button type="button" class="modal-close" data-close="1" aria-label="close">×</button>' +
|
||||
' <img class="detail-img" src="' + img + '" alt="' + (rec.com || sci) + '">' +
|
||||
' <h2 class="detail-title">' + (rec.com || sci) + '</h2>' +
|
||||
' <p class="detail-sci"><em>' + sci + '</em></p>' +
|
||||
' <p class="detail-stats">' + (+rec.n || 0) + ' calls · last heard ' + (rec.last_seen || '—') + '</p>' +
|
||||
' <audio class="detail-audio" controls src="' + rec_url + '"></audio>' +
|
||||
' <img class="detail-spec" src="' + spec_url + '" alt="spectrogram">' +
|
||||
' <img class="detail-img" alt="">' +
|
||||
' <h2 class="detail-title"></h2>' +
|
||||
' <p class="detail-sci"><em></em></p>' +
|
||||
' <p class="detail-stats"></p>' +
|
||||
' <audio class="detail-audio" controls></audio>' +
|
||||
' <img class="detail-spec" alt="spectrogram">' +
|
||||
'</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.querySelectorAll('[data-close]').forEach(function (el) {
|
||||
el.addEventListener('click', function () { modal.setAttribute('aria-hidden', 'true'); });
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>apartment birds</title>
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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
@@ -1,28 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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,
|
||||
and saves PNGs into avian/assets/illustrations/.
|
||||
|
||||
Each species gets two poses: <slug>.png (perched) and <slug>-2.png
|
||||
(flight). The prompt template lives at avian/scripts/prompt.template.md
|
||||
— edit it to change the visual style.
|
||||
(flight). Edit avian/scripts/prompt.template.md to change the visual
|
||||
style — the prompt body is re-sent verbatim per request with
|
||||
{sci_name}, {com_name}, and {pose} substituted.
|
||||
|
||||
Usage:
|
||||
# Generate the full BirdNET-Pi label set:
|
||||
python3 pregen.py --labels /home/$USER/BirdNET-Pi/model/labels.txt
|
||||
# Every species BirdNET-Pi knows:
|
||||
python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt
|
||||
|
||||
# Generate only species observed in eBird region US-CA:
|
||||
python3 pregen.py --labels labels.txt --ebird-region US-CA --ebird-key YOUR_KEY
|
||||
# Only species observed in an eBird region:
|
||||
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
|
||||
|
||||
# Re-render everything (after a prompt change you actually want applied):
|
||||
python3 pregen.py --labels labels.txt --force
|
||||
# Re-render everything after a prompt change:
|
||||
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
|
||||
import argparse
|
||||
@@ -37,108 +39,149 @@ import urllib.parse
|
||||
import urllib.request
|
||||
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 = (
|
||||
"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"}
|
||||
|
||||
|
||||
def slugify(sci: str) -> str:
|
||||
"""Match avian/frontend/apt.js slugify() exactly."""
|
||||
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:
|
||||
"""Pull the prompt body from the markdown template — everything
|
||||
after the `## Prompt` heading, stripped."""
|
||||
"""Return everything after the `## Prompt` heading, stripped to the
|
||||
next `##` heading (so doc preamble or trailing sections don't bleed
|
||||
into the API call)."""
|
||||
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()
|
||||
|
||||
|
||||
def parse_labels(p: Path) -> list[tuple[str, str]]:
|
||||
"""BirdNET-Pi labels.txt format: `Sci name_Common Name` per line."""
|
||||
out = []
|
||||
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"""
|
||||
def ebird_filter(species, region: str, key: str):
|
||||
"""Intersect a label set with the eBird species list for a region.
|
||||
Region codes: US-CA (state), US-CA-085 (county)."""
|
||||
url = f"https://api.ebird.org/v2/product/spplist/{region}"
|
||||
req = urllib.request.Request(url, headers={"X-eBirdApiToken": key})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
ebird_codes = set(json.loads(r.read()))
|
||||
# eBird returns 6-letter species codes — we need sci names. Use the
|
||||
# taxonomy endpoint to map.
|
||||
tax_url = f"https://api.ebird.org/v2/ref/taxonomy/ebird?fmt=json"
|
||||
tax_url = "https://api.ebird.org/v2/ref/taxonomy/ebird?fmt=json"
|
||||
req2 = urllib.request.Request(tax_url, headers={"X-eBirdApiToken": key})
|
||||
with urllib.request.urlopen(req2, timeout=60) as r:
|
||||
taxonomy = json.loads(r.read())
|
||||
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}
|
||||
return [(s, c) for s, c in species if s in allowed_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]
|
||||
|
||||
|
||||
def gen_one(api_key: str, prompt: str, sci: str, com: str, pose: int) -> bytes:
|
||||
"""Single Gemini call. Returns raw PNG bytes."""
|
||||
body = prompt.replace("{sci_name}", sci).replace("{com_name}", com).replace(
|
||||
"{pose}", POSES[pose]
|
||||
)
|
||||
"""Single Gemini call with bounded retry on 429 + transient 5xx.
|
||||
Returns raw PNG bytes."""
|
||||
body = (prompt
|
||||
.replace("{sci_name}", sci)
|
||||
.replace("{com_name}", com)
|
||||
.replace("{pose}", POSES[pose]))
|
||||
payload = {
|
||||
"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(
|
||||
GEMINI_URL.format(key=urllib.parse.quote(api_key)),
|
||||
GEMINI_URL,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers={"Content-Type": "application/json", "x-goog-api-key": api_key},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
backoff = 4.0
|
||||
for attempt in range(4):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
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 part in cand.get("content", {}).get("parts", []):
|
||||
inline = part.get("inlineData") or part.get("inline_data")
|
||||
if inline and inline.get("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:
|
||||
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.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=[],
|
||||
help="Manual species in format 'Sci name|Common Name' (repeatable)")
|
||||
src.add_argument("--stdin", action="store_true", help="Read species from stdin (one per line, same format)")
|
||||
help="Manual 'Sci|Com' (repeatable)")
|
||||
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-key", help="eBird API key (or set EBIRD_API_KEY)")
|
||||
ap.add_argument("--gemini-key", help="Gemini API key (or set GEMINI_API_KEY)")
|
||||
ap.add_argument("--out", type=Path, default=Path(__file__).resolve().parents[1] / "assets" / "illustrations",
|
||||
ap.add_argument("--ebird-key", help="eBird API key (or EBIRD_API_KEY env)")
|
||||
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",
|
||||
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")
|
||||
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("--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")
|
||||
args = ap.parse_args()
|
||||
|
||||
@@ -149,15 +192,13 @@ def main() -> int:
|
||||
|
||||
# Build species list
|
||||
if args.labels:
|
||||
species = parse_labels(args.labels)
|
||||
species, skipped = parse_species_list(args.labels.read_text().splitlines())
|
||||
elif args.stdin:
|
||||
species = parse_labels_lines(sys.stdin.read().splitlines())
|
||||
species, skipped = parse_species_list(sys.stdin.read().splitlines())
|
||||
else:
|
||||
species = []
|
||||
for s in args.species:
|
||||
if "|" in s:
|
||||
sci, com = s.split("|", 1)
|
||||
species.append((sci.strip(), com.strip()))
|
||||
species, skipped = parse_species_list(args.species)
|
||||
if skipped:
|
||||
print(f"[parse] skipped {skipped} malformed line(s)", file=sys.stderr)
|
||||
if not species:
|
||||
print("error: no species resolved", file=sys.stderr)
|
||||
return 2
|
||||
@@ -167,7 +208,7 @@ def main() -> int:
|
||||
if not ek:
|
||||
print("error: --ebird-region requires --ebird-key or EBIRD_API_KEY", file=sys.stderr)
|
||||
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)
|
||||
|
||||
if args.limit:
|
||||
@@ -177,48 +218,36 @@ def main() -> int:
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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
|
||||
for sci, com in species:
|
||||
done = skipped_existing = failed = 0
|
||||
first_fail = None
|
||||
for idx, (sci, com) in enumerate(species):
|
||||
slug = slugify(sci)
|
||||
for pose in args.poses:
|
||||
fname = f"{slug}.png" if pose == 1 else f"{slug}-{pose}.png"
|
||||
path = args.out / fname
|
||||
if path.exists() and not args.force:
|
||||
skipped += 1
|
||||
skipped_existing += 1
|
||||
continue
|
||||
try:
|
||||
data = gen_one(gemini_key, prompt, sci, com, pose)
|
||||
path.write_bytes(data)
|
||||
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:
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -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
|
||||
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`
|
||||
- `{com_name}` — the English common name, e.g. `Anna's Hummingbird`
|
||||
- `{pose}` — either `perched` (pose 1) or `in flight with wings spread` (pose 2)
|
||||
|
||||
The default style below is **kachō-e** — Edo-period Japanese
|
||||
flower-and-bird woodblock prints, rendered in ink and watercolor on
|
||||
warm paper. Replace the body of the prompt with whatever feels right
|
||||
for your apartment.
|
||||
flower-and-bird woodblock prints, rendered in ink and watercolor.
|
||||
Replace the body below with whatever style feels right for your
|
||||
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
|
||||
position.
|
||||
|
||||
Render at high resolution on a clean transparent background. Output
|
||||
the illustration with no surrounding scene, no caption, no shadow
|
||||
beneath the bird.
|
||||
Render at high resolution on a fully transparent background. The bird
|
||||
must be cut out cleanly — no shadow, no paper texture, no caption.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 856 KiB After Width: | Height: | Size: 1.0 MiB |
+2
-2
@@ -43,8 +43,8 @@ if [[ ! -z $PACKAGES_MISSING ]] ; then
|
||||
sudo apt -y install $PACKAGES_MISSING
|
||||
fi
|
||||
|
||||
branch=main
|
||||
git clone -b $branch --depth=1 https://github.com/Nachtzuster/BirdNET-Pi.git ${HOME}/BirdNET-Pi &&
|
||||
branch=avian-visitors
|
||||
git clone -b $branch --depth=1 https://github.com/Twarner491/AvianVisitors.git ${HOME}/BirdNET-Pi &&
|
||||
|
||||
$HOME/BirdNET-Pi/scripts/install_birdnet.sh
|
||||
if [ ${PIPESTATUS[0]} -eq 0 ];then
|
||||
|
||||
@@ -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/whitelist_species_list.txt $my_dir/scripts
|
||||
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/scripts ${EXTRACTED}
|
||||
sudo -u ${USER} ln -fs $my_dir/scripts/play.php ${EXTRACTED}
|
||||
|
||||
Reference in New Issue
Block a user