AvianVisitors hardening pass — install via fork's newinstaller

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

Reviewer-flagged fixes:

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

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

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

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

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

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

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

Before

Width:  |  Height:  |  Size: 856 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

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