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 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,101 +10,85 @@ 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) | ~$35–80 | [Raspberry Pi](https://www.raspberrypi.com/products/) | 4B+ recommended |
|
| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$35–80 | [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 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
|
```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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything outside `avian/` is upstream BirdNET-Pi.
|
Everything outside `avian/` is upstream BirdNET-Pi.
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
+13
-14
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
# 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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +36,13 @@ def loop(client: mqtt.Client) -> None:
|
|||||||
time.sleep(60)
|
time.sleep(60)
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
client = mqtt.Client()
|
# 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:
|
if USER:
|
||||||
client.username_pw_set(USER, PASSWORD)
|
client.username_pw_set(USER, PASSWORD)
|
||||||
client.connect(BROKER, PORT, keepalive=60)
|
client.connect(BROKER, PORT, keepalive=60)
|
||||||
|
|||||||
+173
-117
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 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'); });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/"
|
|
||||||
+128
-99
@@ -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",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=120) as r:
|
|
||||||
resp = json.loads(r.read())
|
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 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
|
||||||
time.sleep(args.sleep)
|
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
|
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())
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user