Pre-share fixes from final review pass

Blockers
- http://birdnet.local/avian/ now serves the collage (added avian/index.html
  redirect to frontend/; previously the symlink target had no top-level
  index, so users saw a directory listing of api/ assets/ forwarding/
  frontend/ scripts/).
- Gemini model bumped from gemini-2.5-flash-image-preview (deprecated
  2026-01-15) to the stable gemini-2.5-flash-image. Pregen was non-
  functional out of the box.
- avian-mqtt.service: dropped %h (which expands to /root for system-mode
  units regardless of User=). Use WorkingDirectory=~ which DOES respect
  User=, plus a relative ExecStart.
- birdnet-api.php case 'lifelist' now aliases COUNT(*) AS n (was 'total'),
  matching what apt.js reads for atlas card counts. Atlas previously
  always displayed 0.

Bugs
- Retry-After parsing handles HTTP-date format without crashing.
- apt.js no longer fetches firstseen/timeseries (declared in state but
  never consumed). Two wasted round-trips per page load removed.
- update_birdnet.sh defaults branch=avian-visitors so the (opt-in)
  Sunday auto-update cron doesn't reset HEAD to upstream main and
  delete the avian/ overlay.
- recording.php: removed dead $BIRDS_JSON_CANDIDATES.

Polish
- Pregen --sleep default raised 4→6s (more headroom under free-tier RPM).
- en-dash in README BOM (~$35–80) replaced with hyphen for style match.
- Slop sweep across user-facing docs: no em-dashes, no en-dashes, no
  AI-slop word hits (robust/comprehensive/seamless/etc.).
This commit is contained in:
Twarner491
2026-05-28 11:11:20 -07:00
parent 41a051bc55
commit d3241f92cf
17 changed files with 114 additions and 145 deletions
+22 -22
View File
@@ -1,6 +1,8 @@
# AvianVisitors
*A live bird collage from your window.* See it running at [bird.onethreenine.net](https://bird.onethreenine.net).
*A live bird collage from your window.*
See it running at [bird.onethreenine.net](https://bird.onethreenine.net).
<img alt="avianvisitors collage" src="docs/thumb.png" />
@@ -10,61 +12,59 @@
| Qty | Description | Price | Link |
|-----|-------------|-------|------|
| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$3580 | [Raspberry Pi](https://www.raspberrypi.com/products/) |
| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$35-80 | [Raspberry Pi](https://www.raspberrypi.com/products/) |
| 1 | Micro SD Card (≥32 GB) | ~$10 | [Amazon](https://a.co/d/08aiL8c) |
| 1 | USB lavalier microphone | $14.99 | [Amazon](https://www.amazon.com/dp/B0176NRE1G) |
| 1 | Pi power supply | ~$10 | |
| 1 | Pi power supply | ~$10 | - |
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.
Optional: a [Gemini API key](https://aistudio.google.com/apikey) to restyle illustrations, an [eBird API key](https://ebird.org/api/keygen) to filter species by region.
---
## 1. Flash the SD card
Use [Raspberry Pi Imager](https://www.raspberrypi.com/software/). Pick **Raspberry Pi OS Lite (64-bit)**. In the customisation dialog, set:
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)
- Username
- WiFi SSID + password
- Hostname: `birdnet` (so it's reachable at `birdnet.local`)
- Hostname: `birdnet`
- Enable SSH with password auth
Plug the USB lavalier mic into the Pi, place the capsule in a window or mount it outside under an eave, and boot.
Plug the USB mic into the Pi. Place the capsule in a window or mount it outside. Boot.
---
## 2. Run the installer
SSH in and run one command:
```bash
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.
Clones this fork, installs BirdNET-Pi, symlinks the AvianVisitors overlay into the Caddy web root. Takes 20-40 minutes. Reboots when done.
Open `http://birdnet.local/avian/` from any device on your network. The BirdNET-Pi stock UI sits at `http://birdnet.local/`.
Collage: `http://birdnet.local/avian/`. Stock BirdNET-Pi UI: `http://birdnet.local/`.
---
## 3. (Optional) Restyle the illustrations
The repo ships with 450 bundled illustrations covering most North-American species. To change the visual style or add region-specific birds:
The repo ships with 450 bundled illustrations. To restyle or add region-specific species:
```bash
export GEMINI_API_KEY='your-gemini-key'
export GEMINI_API_KEY='your-key'
# Re-render every species in BirdNET-Pi's model:
python3 ~/BirdNET-Pi/avian/scripts/pregen.py --labels ~/BirdNET-Pi/model/labels.txt --force
# Or only species eBird has observed in your region:
export EBIRD_API_KEY='your-ebird-key'
# Or filter to species observed in your eBird region:
export EBIRD_API_KEY='your-key'
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
```
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.
Style lives in [`avian/scripts/prompt.template.md`](avian/scripts/prompt.template.md). Edit, re-run with `--force`.
---
@@ -72,9 +72,9 @@ Style is a single editable file at [`avian/scripts/prompt.template.md`](avian/sc
See [`avian/forwarding/`](avian/forwarding/) for three independent recipes:
- **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.
- **Cloudflare Tunnel** for a public HTTPS URL.
- **Home Assistant REST sensor** that exposes the latest detection.
- **MQTT bridge** that publishes every new detection.
---
@@ -95,7 +95,7 @@ Everything outside `avian/` is upstream BirdNET-Pi.
## License
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.
CC-BY-NC-SA-4.0, inherited from [BirdNET-Pi](https://github.com/Nachtzuster/BirdNET-Pi/blob/main/LICENSE). Non-commercial use only. See the [BirdNET-Pi README](https://github.com/Nachtzuster/BirdNET-Pi/blob/main/README.md) for full Cornell attribution.
---
+17 -15
View File
@@ -1,30 +1,30 @@
<?php
// AvianVisitors JSON facade over BirdNET-Pi's birds.db. Read-only.
// 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)
// lifelist every species with first_seen, last_seen, total_count
// 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
// stats - totals (detections, unique species, today, last hour)
// lifelist - every species with first_seen, last_seen, total_count
// 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/.
// /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');
// SCRIPT_FILENAME on the Pi resolves through the symlink to
// $HOME/BirdNET-Pi/avian/api/birdnet-api.php walk three dirs up 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
// 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';
@@ -84,9 +84,11 @@ switch ($action) {
}
case 'lifelist': {
// n = total calls (matches the `recent` action's alias so the
// frontend can read either response interchangeably).
$rs = rows($db,
"SELECT Sci_Name AS sci, Com_Name AS com, MIN(Date||' '||Time) AS first_seen, "
. " MAX(Date||' '||Time) AS last_seen, COUNT(*) AS total, MAX(Confidence) AS best_conf "
. " MAX(Date||' '||Time) AS last_seen, COUNT(*) AS n, MAX(Confidence) AS best_conf "
. "FROM detections GROUP BY Sci_Name ORDER BY first_seen ASC"
);
echo json_encode(['species' => $rs, 'as_of' => date('c')]);
@@ -145,9 +147,9 @@ switch ($action) {
case 'timeseries': {
// Aggregated time-bucketed counts for the stats charts.
// daily last $days days, detections + unique species per day
// by_hour detections grouped by hour of day, last 30 days
// The frontend backfills missing dates with zero sparse data days
// daily - last $days days, detections + unique species per day
// by_hour - detections grouped by hour of day, last 30 days
// The frontend backfills missing dates with zero - sparse data days
// are otherwise dropped by the GROUP BY.
$days = max(1, min(90, (int)($_GET['days'] ?? 30)));
$daily = rows($db,
@@ -172,7 +174,7 @@ switch ($action) {
}
case 'firstseen': {
// Most recent additions to the life list first detection per
// Most recent additions to the life list - first detection per
// species, sorted by first_seen DESC. Powers the "First Detections"
// section on the stats view.
$limit = max(1, min(50, (int)($_GET['limit'] ?? 10)));
+5 -5
View File
@@ -1,5 +1,5 @@
<?php
// AvianVisitors bird image resolver.
// AvianVisitors - bird image resolver.
//
// Lookup chain for /avian/api/cutout.php?sci=Calypte+anna:
// 1. ../assets/illustrations/<slug>.png (450+ bundled kachō-e renders)
@@ -7,11 +7,11 @@
// 3. cached rembg of a Wikipedia photo at $HOME/BirdSongs/Extracted/cutouts/
// 4. fresh Wikipedia → rembg → cache (skipped gracefully if rembg unset)
//
// The frontend's <img src> points here for every species bundled
// The frontend's <img src> points here for every species - bundled
// hits return instantly; cold misses fall through to the dynamic path.
//
// Default LAN deploy ships without auth. To expose publicly, gate
// /avian/api/* with basic_auth in your Caddyfile see avian/forwarding/.
// /avian/api/* with basic_auth in your Caddyfile - see avian/forwarding/.
declare(strict_types=1);
@@ -61,7 +61,7 @@ if (is_file($cachePath) && filesize($cachePath) > 1024) {
}
// 4. Fresh Wikipedia fetch + rembg. Skipped if rembg-cli isn't on
// PATH the resolver simply returns a 404 in that case rather
// 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)) {
@@ -107,7 +107,7 @@ if (!$imgBytes || strlen($imgBytes) < 1024) {
exit;
}
// rembg via the wrapper. u2netp = lightweight model (~50MB peak RAM
// 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-');
+3 -8
View File
@@ -1,5 +1,5 @@
<?php
// AvianVisitors serves the most-recent detection mp3 for a given
// 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>.
//
@@ -24,7 +24,7 @@ if ($sci === '' && $file === '') {
// Reject any sci-name that isn't a clean Genus species[ subspecies[ tri]]
// pattern. resolve_common() below falls back to str_replace(' ', '_', $sci)
// when there's no birds.json match without this guard, `?sci=../etc` would
// when there's no birds.json match - without this guard, `?sci=../etc` would
// flow through unmodified into a filesystem path.
if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) {
http_response_code(400);
@@ -93,14 +93,9 @@ if ($file !== '') {
readfile($path);
exit;
}
$BIRDS_JSON_CANDIDATES = [
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).
// Try birds.json first (preferred - has clean sci/com pairs).
foreach ([getenv('HOME') . '/BirdNET-Pi/scripts/birds.json'] as $f) {
if (is_readable($f)) {
$list = json_decode((string)file_get_contents($f), true);
+4 -4
View File
@@ -1,7 +1,7 @@
<?php
// AvianVisitors serves the spectrogram PNG that BirdNET-Pi generates
// 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
// the matching file under By_Date/<date>/<Common_Name>/) - just .png
// instead of .mp3.
//
// Endpoints:
@@ -10,7 +10,7 @@
// recording (atlas modal uses this so the
// strip below each play button is the
// spectrogram for that recording, not
// "the most recent" they can differ).
// "the most recent" - they can differ).
declare(strict_types=1);
@@ -44,7 +44,7 @@ if ($file !== '') {
exit;
}
// BirdNET-Pi names the spectrogram as the FULL mp3 filename plus
// ".png" e.g. "American_Crow-82-…-20:25:29.mp3" pairs with
// ".png" - e.g. "American_Crow-82-…-20:25:29.mp3" pairs with
// "American_Crow-82-…-20:25:29.mp3.png" (not "…-20:25:29.png").
// Accept either form gracefully.
if (substr($file, -4) === '.png') {
+11 -34
View File
@@ -1,18 +1,12 @@
# Optional: forwarding the collage off your local network
# Forwarding
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.
Each recipe is independent. Skip what you don't need.
Default install hosts the collage at `http://birdnet.local/avian/` on your LAN, no auth. The recipes below are independent. Pick what you need.
---
## 1. Cloudflare Tunnel — public HTTPS, no port forwarding
## 1. Cloudflare Tunnel
Free Cloudflare account required.
Install `cloudflared`:
Public HTTPS URL, no port forwarding. Needs a free Cloudflare account.
```bash
sudo apt install -y lsb-release
@@ -21,32 +15,22 @@ curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
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
```
Authenticate + create the tunnel:
```bash
cloudflared tunnel login
cloudflared tunnel create birds
cloudflared tunnel route dns birds birds.your-domain.com
```
Configure + start the tunnel:
```bash
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 UUID
sudo cloudflared service install
sudo systemctl restart cloudflared
```
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).
Add a password gate via Cloudflare Access (free for up to 50 users) or via Caddy basic_auth ([`caddy-auth.caddy`](caddy-auth.caddy)).
---
## 2. Home Assistant — surface latest detection as a sensor
## 2. Home Assistant sensor
Add to `configuration.yaml`:
@@ -65,25 +49,18 @@ rest:
- best_conf
```
`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
## 3. MQTT bridge
```bash
sudo pip3 install paho-mqtt --break-system-packages
cp ~/BirdNET-Pi/avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py
# Edit ~/avian-mqtt.py broker host, topic prefix, credentials
# 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
# 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 `/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.
Polls `birdnet-api.php?action=recent&hours=1` every 60 seconds. Publishes new species under `birdnet/<slug>` as JSON. Dedup is in-memory; restarts re-emit recent detections.
+6 -3
View File
@@ -4,10 +4,13 @@ After=network-online.target
[Service]
Type=simple
# Edit User= and the ExecStart path to match your Pi's username if
# you didn't install BirdNET-Pi as the `birdnet` user.
# Edit User= if BirdNET-Pi was installed under a different account.
# The ExecStart path uses an absolute path because systemd's %h
# specifier always expands to /root for system-mode units, ignoring
# User=. WorkingDirectory=~ does respect User=, so we use it.
User=birdnet
ExecStart=/usr/bin/python3 %h/avian-mqtt.py
WorkingDirectory=~
ExecStart=/usr/bin/python3 avian-mqtt.py
Restart=on-failure
RestartSec=10
+1 -1
View File
@@ -1,6 +1,6 @@
# Optional Basic-auth gate for the collage.
# Use only if you're NOT putting Cloudflare Access in front of the
# public URL. Replace the bcrypt hash below generate via:
# public URL. Replace the bcrypt hash below - generate via:
# caddy hash-password --plaintext 'your-pass'
#
# Drop this into /etc/caddy/conf.d/ alongside avian.caddy and reload.
+2 -2
View File
@@ -1,10 +1,10 @@
# Cloudflare Tunnel config copy to /etc/cloudflared/config.yml and
# Cloudflare Tunnel config - copy to /etc/cloudflared/config.yml and
# replace TUNNEL_UUID with the value printed by `cloudflared tunnel create`.
tunnel: TUNNEL_UUID
credentials-file: /etc/cloudflared/TUNNEL_UUID.json
ingress:
# Public collage points at the local Caddy on port 80.
# Public collage - points at the local Caddy on port 80.
- hostname: birds.your-domain.com
service: http://localhost:80
# Catch-all required by cloudflared
+10 -14
View File
@@ -1,18 +1,18 @@
/* AvianVisitors bird collage frontend.
/* AvianVisitors - bird collage frontend.
*
* 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 every bird always fits on
* every viewport.
* stats per-species mark on a time × count plot.
* atlas grid of every species ever detected, with detail modal.
* stats - per-species mark on a time × count plot.
* atlas - grid of every species ever detected, with detail modal.
*
* 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/.
*
* All API calls are relative they target the PHP shims in ../api/
* 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.
*/
@@ -44,7 +44,7 @@
}
// 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.
// 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;')
@@ -52,7 +52,7 @@
}
// ---- State ----
var DATA = { recent: null, lifelist: null, firstseen: null, timeseries: null };
var DATA = { recent: null, lifelist: null };
var MASKS = null, DIMS = null;
var currentHours = +readLS('av:window', '24') || 24;
@@ -153,14 +153,10 @@
var h = currentHours;
return Promise.all([
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];
DATA.timeseries = parts[2];
if (parts[3] && h === currentHours) DATA.recent = parts[3];
if (parts[1] && h === currentHours) DATA.recent = parts[1];
renderCollageFromData();
drawHistograms();
});
@@ -440,7 +436,7 @@
if (!sp.length) {
var p = document.createElement('p');
p.className = 'empty';
p.textContent = 'no species yet atlas fills in as the Pi detects birds.';
p.textContent = 'no species yet - atlas fills in as the Pi detects birds.';
list.innerHTML = '';
list.appendChild(p);
return;
@@ -510,7 +506,7 @@
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 || '');
(+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');
+1 -1
View File
@@ -56,7 +56,7 @@
<button type="button" class="modal-close" data-close="1" aria-label="close">×</button>
<p class="about-eyebrow">apartment birds</p>
<h2 id="aboutTitle">The birds outside my window</h2>
<p class="about-body">A tiny microphone listens for birds passing the window and identifies them with Cornell's <a href="https://birdnet.cornell.edu/" target="_blank" rel="noopener">BirdNET</a>. Each species shows up as an illustration in the collage sized by how much it's been heard.</p>
<p class="about-body">A tiny microphone listens for birds passing the window and identifies them with Cornell's <a href="https://birdnet.cornell.edu/" target="_blank" rel="noopener">BirdNET</a>. Each species shows up as an illustration in the collage - sized by how much it's been heard.</p>
<button type="button" class="about-explore" data-close="1">explore the birds →</button>
<p class="built-by">built with <a href="https://github.com/Twarner491/AvianVisitors" target="_blank" rel="noopener">AvianVisitors</a></p>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
/* AvianVisitors collage frontend styles.
/* AvianVisitors - collage frontend styles.
* Paper-and-ink aesthetic to match the bundled kachō-e illustrations. */
:root {
+6
View File
@@ -0,0 +1,6 @@
<!doctype html>
<meta charset="utf-8">
<title>AvianVisitors</title>
<script>location.replace('frontend/');</script>
<meta http-equiv="refresh" content="0; url=frontend/">
<a href="frontend/">collage</a>
+12 -8
View File
@@ -1,5 +1,5 @@
#!/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 (BirdNET-Pi's labels.txt, eBird, or stdin),
generates an illustration for each via the Gemini 2.5 Flash Image API,
@@ -7,7 +7,7 @@ and saves PNGs into avian/assets/illustrations/.
Each species gets two poses: <slug>.png (perched) and <slug>-2.png
(flight). Edit avian/scripts/prompt.template.md to change the visual
style the prompt body is re-sent verbatim per request with
style - the prompt body is re-sent verbatim per request with
{sci_name}, {com_name}, and {pose} substituted.
Usage:
@@ -43,7 +43,7 @@ from pathlib import Path
# 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-preview:generateContent"
"gemini-2.5-flash-image:generateContent"
)
POSES = {1: "perched", 2: "in flight with wings spread"}
@@ -117,7 +117,7 @@ def gen_one(api_key: str, prompt: str, sci: str, com: str, pose: int) -> bytes:
# 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
# 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,
@@ -134,7 +134,11 @@ def gen_one(api_key: str, prompt: str, sci: str, com: str, pose: int) -> bytes:
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)
ra = e.headers.get("Retry-After")
try:
retry_after = float(ra) if ra else backoff
except (TypeError, ValueError):
retry_after = backoff # HTTP-date format, fall back
time.sleep(retry_after)
backoff *= 2
continue
@@ -151,7 +155,7 @@ def gen_one(api_key: str, prompt: str, sci: str, com: str, pose: int) -> bytes:
inline = part.get("inlineData") or part.get("inline_data")
if inline and inline.get("data"):
return base64.b64decode(inline["data"])
# No image surface the blocking reason so users know what to fix.
# 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})")
@@ -180,8 +184,8 @@ def main() -> int:
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=4.0,
help="Seconds between API calls (default 4 = under free-tier RPM cap)")
ap.add_argument("--sleep", type=float, default=6.0,
help="Seconds between API calls (default 6 = headroom under free-tier RPM cap)")
ap.add_argument("--limit", type=int, default=0, help="Cap species count for testing")
args = ap.parse_args()
+9 -26
View File
@@ -1,42 +1,25 @@
# Bird illustration prompt
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.
The prompt sent to Gemini for every illustration. Edit the body to change the style.
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)
- `{sci_name}` is the binomial Latin name, e.g. `Calypte anna`
- `{com_name}` is the English common name, e.g. `Anna's Hummingbird`
- `{pose}` is 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.
Replace the body below with whatever style feels right for your
apartment.
The default below is kachō-e (Edo-period Japanese flower-and-bird woodblock prints). Replace it with whatever style fits your apartment.
---
## Prompt
Generate a {pose} {com_name} ({sci_name}) in the style of an Edo-period
Japanese kachō-e woodblock print. Confident sumi-e ink linework with
soft watercolor washes. Earthy, restrained palette — burnt umber,
ochre, indigo, vermillion, muted greens. Plumage details rendered with
short directional brush strokes; eye, beak, and feet drawn with crisp
ink. The bird should be the only subject — NO background, NO branch
unless the pose requires it (a single sparse twig is fine for
perched), NO border or frame, NO text or signature.
Generate a {pose} {com_name} ({sci_name}) in the style of an Edo-period Japanese kachō-e woodblock print. Confident sumi-e ink linework with soft watercolor washes. Earthy, restrained palette: burnt umber, ochre, indigo, vermillion, muted greens. Plumage details rendered with short directional brush strokes; eye, beak, and feet drawn with crisp ink. The bird is the only subject. NO background, NO branch unless the pose requires it (a single sparse twig is fine for perched), NO border or frame, NO text or signature.
Anatomy must be biologically accurate for the named species:
- Exactly two wings. Two legs. One head. One beak. One tail.
- Posture and feather pattern matching {com_name} field-guide
references — color, markings, and body proportions must match the
real bird.
- For perched poses: one wing folded against the body, the other
tucked behind. For flight: both wings extended in a natural flapping
position.
- Posture, color, markings, and body proportions matching {com_name} field-guide references.
- For perched poses: one wing folded against the body, the other tucked behind. For flight: both wings extended in a natural flapping position.
Render at high resolution on a fully transparent background. The bird
must be cut out cleanly — no shadow, no paper texture, no caption.
Render at high resolution on a fully transparent background. Cut the bird out cleanly. No shadow, no paper texture, no caption.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 308 KiB

+4 -1
View File
@@ -16,7 +16,10 @@ my_dir=$HOME/BirdNET-Pi/scripts
# Defaults
remote="origin"
branch="main"
# AvianVisitors fork: track our overlay branch by default so the
# Sunday auto-update cron (when enabled) doesn't switch back to
# upstream `main` and delete the avian/ directory.
branch="avian-visitors"
auto_update=""
while getopts ":r:b:a" o; do