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:
@@ -1,6 +1,8 @@
|
|||||||
# AvianVisitors
|
# 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" />
|
<img alt="avianvisitors collage" src="docs/thumb.png" />
|
||||||
|
|
||||||
@@ -10,61 +12,59 @@
|
|||||||
|
|
||||||
| Qty | Description | Price | Link |
|
| Qty | Description | Price | Link |
|
||||||
|-----|-------------|-------|------|
|
|-----|-------------|-------|------|
|
||||||
| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$35–80 | [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 | 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 | 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
|
## 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
|
- WiFi SSID + password
|
||||||
- Hostname: `birdnet` (so it's reachable at `birdnet.local`)
|
- Hostname: `birdnet`
|
||||||
- Enable SSH with password auth
|
- 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
|
## 2. Run the installer
|
||||||
|
|
||||||
SSH in and run one command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh <your-username>@birdnet.local
|
ssh <your-username>@birdnet.local
|
||||||
curl -s https://raw.githubusercontent.com/Twarner491/AvianVisitors/avian-visitors/newinstaller.sh | bash
|
curl -s https://raw.githubusercontent.com/Twarner491/AvianVisitors/avian-visitors/newinstaller.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer clones this fork, sets up BirdNET-Pi (audio capture, model, web UI), and symlinks the AvianVisitors overlay into the Caddy web root. Takes 20–40 minutes. The Pi reboots when done.
|
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
|
## 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
|
```bash
|
||||||
export GEMINI_API_KEY='your-gemini-key'
|
export GEMINI_API_KEY='your-key'
|
||||||
|
|
||||||
# Re-render every species in BirdNET-Pi's model:
|
# Re-render every species in BirdNET-Pi's model:
|
||||||
python3 ~/BirdNET-Pi/avian/scripts/pregen.py --labels ~/BirdNET-Pi/model/labels.txt --force
|
python3 ~/BirdNET-Pi/avian/scripts/pregen.py --labels ~/BirdNET-Pi/model/labels.txt --force
|
||||||
|
|
||||||
# Or only species eBird has observed in your region:
|
# Or filter to species observed in your eBird region:
|
||||||
export EBIRD_API_KEY='your-ebird-key'
|
export EBIRD_API_KEY='your-key'
|
||||||
python3 ~/BirdNET-Pi/avian/scripts/pregen.py \
|
python3 ~/BirdNET-Pi/avian/scripts/pregen.py \
|
||||||
--labels ~/BirdNET-Pi/model/labels.txt \
|
--labels ~/BirdNET-Pi/model/labels.txt \
|
||||||
--ebird-region US-CA # state, or US-CA-085 for a county
|
--ebird-region US-CA
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
See [`avian/forwarding/`](avian/forwarding/) for three independent recipes:
|
||||||
|
|
||||||
- **Cloudflare Tunnel** — public HTTPS URL, no port forwarding, optional Cloudflare Access password gate.
|
- **Cloudflare Tunnel** for a public HTTPS URL.
|
||||||
- **Home Assistant REST sensor** — surfaces the latest detection for automations.
|
- **Home Assistant REST sensor** that exposes the latest detection.
|
||||||
- **MQTT bridge** — publishes every new detection as JSON.
|
- **MQTT bridge** that publishes every new detection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ Everything outside `avian/` is upstream BirdNET-Pi.
|
|||||||
|
|
||||||
## License
|
## 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
@@ -1,30 +1,30 @@
|
|||||||
<?php
|
<?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/.
|
// Symlinked into the BirdNET-Pi Caddy site root at /avian/api/.
|
||||||
//
|
//
|
||||||
// Endpoints (?action=...):
|
// Endpoints (?action=...):
|
||||||
// stats — totals (detections, unique species, today, last hour)
|
// stats - totals (detections, unique species, today, last hour)
|
||||||
// lifelist — every species with first_seen, last_seen, total_count
|
// lifelist - every species with first_seen, last_seen, total_count
|
||||||
// recent — &hours=N (default 24): species heard in the window
|
// recent - &hours=N (default 24): species heard in the window
|
||||||
// species — &sci=<sci_name>: per-species detail page
|
// species - &sci=<sci_name>: per-species detail page
|
||||||
// timeseries — &days=N: daily detection counts per species
|
// timeseries - &days=N: daily detection counts per species
|
||||||
// firstseen — every species' earliest detection
|
// firstseen - every species' earliest detection
|
||||||
//
|
//
|
||||||
// Default LAN deploy ships without auth. If you've exposed the Pi via
|
// 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
|
// 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);
|
declare(strict_types=1);
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: public, max-age=30');
|
header('Cache-Control: public, max-age=30');
|
||||||
|
|
||||||
// SCRIPT_FILENAME on the Pi resolves through the symlink to
|
// 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
|
// reach the install root, then point at scripts/birds.db. Lets a Pi
|
||||||
// installed under any username (the BirdNET-Pi installer uses $USER,
|
// installed under any username (the BirdNET-Pi installer uses $USER,
|
||||||
// not a fixed name) work without editing this file.
|
// not a fixed name) work without editing this file.
|
||||||
$DB_PATH = dirname(__DIR__, 3) . '/scripts/birds.db';
|
$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__.
|
// install path working even if SCRIPT_FILENAME oddities trip __DIR__.
|
||||||
if (!file_exists($DB_PATH)) {
|
if (!file_exists($DB_PATH)) {
|
||||||
$alt = getenv('HOME') . '/BirdNET-Pi/scripts/birds.db';
|
$alt = getenv('HOME') . '/BirdNET-Pi/scripts/birds.db';
|
||||||
@@ -84,9 +84,11 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'lifelist': {
|
case 'lifelist': {
|
||||||
|
// n = total calls (matches the `recent` action's alias so the
|
||||||
|
// frontend can read either response interchangeably).
|
||||||
$rs = rows($db,
|
$rs = rows($db,
|
||||||
"SELECT Sci_Name AS sci, Com_Name AS com, MIN(Date||' '||Time) AS first_seen, "
|
"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"
|
. "FROM detections GROUP BY Sci_Name ORDER BY first_seen ASC"
|
||||||
);
|
);
|
||||||
echo json_encode(['species' => $rs, 'as_of' => date('c')]);
|
echo json_encode(['species' => $rs, 'as_of' => date('c')]);
|
||||||
@@ -145,9 +147,9 @@ switch ($action) {
|
|||||||
|
|
||||||
case 'timeseries': {
|
case 'timeseries': {
|
||||||
// Aggregated time-bucketed counts for the stats charts.
|
// Aggregated time-bucketed counts for the stats charts.
|
||||||
// daily — last $days days, detections + unique species per day
|
// daily - last $days days, detections + unique species per day
|
||||||
// by_hour — detections grouped by hour of day, last 30 days
|
// by_hour - detections grouped by hour of day, last 30 days
|
||||||
// The frontend backfills missing dates with zero — sparse data days
|
// The frontend backfills missing dates with zero - sparse data days
|
||||||
// are otherwise dropped by the GROUP BY.
|
// are otherwise dropped by the GROUP BY.
|
||||||
$days = max(1, min(90, (int)($_GET['days'] ?? 30)));
|
$days = max(1, min(90, (int)($_GET['days'] ?? 30)));
|
||||||
$daily = rows($db,
|
$daily = rows($db,
|
||||||
@@ -172,7 +174,7 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'firstseen': {
|
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"
|
// species, sorted by first_seen DESC. Powers the "First Detections"
|
||||||
// section on the stats view.
|
// section on the stats view.
|
||||||
$limit = max(1, min(50, (int)($_GET['limit'] ?? 10)));
|
$limit = max(1, min(50, (int)($_GET['limit'] ?? 10)));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
// AvianVisitors — bird image resolver.
|
// AvianVisitors - bird image resolver.
|
||||||
//
|
//
|
||||||
// Lookup chain for /avian/api/cutout.php?sci=Calypte+anna:
|
// Lookup chain for /avian/api/cutout.php?sci=Calypte+anna:
|
||||||
// 1. ../assets/illustrations/<slug>.png (450+ bundled kachō-e renders)
|
// 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/
|
// 3. cached rembg of a Wikipedia photo at $HOME/BirdSongs/Extracted/cutouts/
|
||||||
// 4. fresh Wikipedia → rembg → cache (skipped gracefully if rembg unset)
|
// 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.
|
// hits return instantly; cold misses fall through to the dynamic path.
|
||||||
//
|
//
|
||||||
// Default LAN deploy ships without auth. To expose publicly, gate
|
// 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);
|
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
|
// 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.
|
// than burning a Wikipedia request we can't use.
|
||||||
$rembg = '/usr/local/bin/rembg-cli';
|
$rembg = '/usr/local/bin/rembg-cli';
|
||||||
if (!is_executable($rembg)) {
|
if (!is_executable($rembg)) {
|
||||||
@@ -107,7 +107,7 @@ if (!$imgBytes || strlen($imgBytes) < 1024) {
|
|||||||
exit;
|
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
|
// matters on the Pi 3B+). Temp files because rembg's CLI prefers
|
||||||
// real paths.
|
// real paths.
|
||||||
$tmpInBase = tempnam(sys_get_temp_dir(), 'rembg-in-');
|
$tmpInBase = tempnam(sys_get_temp_dir(), 'rembg-in-');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?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
|
// scientific name. Called by the collage detail modal at
|
||||||
// /avian/api/recording.php?sci=<name>.
|
// /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]]
|
// Reject any sci-name that isn't a clean Genus species[ subspecies[ tri]]
|
||||||
// pattern. resolve_common() below falls back to str_replace(' ', '_', $sci)
|
// 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.
|
// flow through unmodified into a filesystem path.
|
||||||
if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) {
|
if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -93,14 +93,9 @@ if ($file !== '') {
|
|||||||
readfile($path);
|
readfile($path);
|
||||||
exit;
|
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) ----
|
// ---- Resolve scientific name → common name (with underscores) ----
|
||||||
function resolve_common(string $sci): ?string {
|
function resolve_common(string $sci): ?string {
|
||||||
// Try birds.json first (preferred — has clean sci/com pairs).
|
// Try birds.json first (preferred - has clean sci/com pairs).
|
||||||
foreach ([getenv('HOME') . '/BirdNET-Pi/scripts/birds.json'] as $f) {
|
foreach ([getenv('HOME') . '/BirdNET-Pi/scripts/birds.json'] as $f) {
|
||||||
if (is_readable($f)) {
|
if (is_readable($f)) {
|
||||||
$list = json_decode((string)file_get_contents($f), true);
|
$list = json_decode((string)file_get_contents($f), true);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?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
|
// 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.
|
// instead of .mp3.
|
||||||
//
|
//
|
||||||
// Endpoints:
|
// Endpoints:
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// recording (atlas modal uses this so the
|
// recording (atlas modal uses this so the
|
||||||
// strip below each play button is the
|
// strip below each play button is the
|
||||||
// spectrogram for that recording, not
|
// spectrogram for that recording, not
|
||||||
// "the most recent" — they can differ).
|
// "the most recent" - they can differ).
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ if ($file !== '') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
// BirdNET-Pi names the spectrogram as the FULL mp3 filename plus
|
// 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").
|
// "American_Crow-82-…-20:25:29.mp3.png" (not "…-20:25:29.png").
|
||||||
// Accept either form gracefully.
|
// Accept either form gracefully.
|
||||||
if (substr($file, -4) === '.png') {
|
if (substr($file, -4) === '.png') {
|
||||||
|
|||||||
+11
-34
@@ -1,18 +1,12 @@
|
|||||||
# Optional: forwarding the collage off your local network
|
# Forwarding
|
||||||
|
|
||||||
Default install hosts the collage at `http://birdnet.local/avian/` on
|
Default install hosts the collage at `http://birdnet.local/avian/` on your LAN, no auth. The recipes below are independent. Pick what you need.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Cloudflare Tunnel — public HTTPS, no port forwarding
|
## 1. Cloudflare Tunnel
|
||||||
|
|
||||||
Free Cloudflare account required.
|
Public HTTPS URL, no port forwarding. Needs a free Cloudflare account.
|
||||||
|
|
||||||
Install `cloudflared`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install -y lsb-release
|
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" \
|
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \
|
||||||
| sudo tee /etc/apt/sources.list.d/cloudflared.list
|
| sudo tee /etc/apt/sources.list.d/cloudflared.list
|
||||||
sudo apt update && sudo apt install -y cloudflared
|
sudo apt update && sudo apt install -y cloudflared
|
||||||
```
|
|
||||||
|
|
||||||
Authenticate + create the tunnel:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cloudflared tunnel login
|
cloudflared tunnel login
|
||||||
cloudflared tunnel create birds
|
cloudflared tunnel create birds
|
||||||
cloudflared tunnel route dns birds birds.your-domain.com
|
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
|
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 cloudflared service install
|
||||||
sudo systemctl restart cloudflared
|
sudo systemctl restart cloudflared
|
||||||
```
|
```
|
||||||
|
|
||||||
To password-protect the public URL, set up Cloudflare Access (free
|
Add a password gate via Cloudflare Access (free for up to 50 users) or via Caddy basic_auth ([`caddy-auth.caddy`](caddy-auth.caddy)).
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Home Assistant — surface latest detection as a sensor
|
## 2. Home Assistant sensor
|
||||||
|
|
||||||
Add to `configuration.yaml`:
|
Add to `configuration.yaml`:
|
||||||
|
|
||||||
@@ -65,25 +49,18 @@ rest:
|
|||||||
- best_conf
|
- 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
|
```bash
|
||||||
sudo pip3 install paho-mqtt --break-system-packages
|
sudo pip3 install paho-mqtt --break-system-packages
|
||||||
cp ~/BirdNET-Pi/avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py
|
cp ~/BirdNET-Pi/avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py
|
||||||
# Edit ~/avian-mqtt.py — broker host, topic prefix, credentials
|
# Edit ~/avian-mqtt.py: broker host, topic prefix, credentials
|
||||||
sudo cp ~/BirdNET-Pi/avian/forwarding/avian-mqtt.service /etc/systemd/system/
|
sudo cp ~/BirdNET-Pi/avian/forwarding/avian-mqtt.service /etc/systemd/system/
|
||||||
# Edit /etc/systemd/system/avian-mqtt.service — set User= to your username
|
# Edit /etc/systemd/system/avian-mqtt.service: set User= to your username
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now avian-mqtt
|
sudo systemctl enable --now avian-mqtt
|
||||||
```
|
```
|
||||||
|
|
||||||
The bridge polls `/avian/api/birdnet-api.php?action=recent&hours=1`
|
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.
|
||||||
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,10 +4,13 @@ After=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
# Edit User= and the ExecStart path to match your Pi's username if
|
# Edit User= if BirdNET-Pi was installed under a different account.
|
||||||
# you didn't install BirdNET-Pi as the `birdnet` user.
|
# 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
|
User=birdnet
|
||||||
ExecStart=/usr/bin/python3 %h/avian-mqtt.py
|
WorkingDirectory=~
|
||||||
|
ExecStart=/usr/bin/python3 avian-mqtt.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Optional Basic-auth gate for the collage.
|
# Optional Basic-auth gate for the collage.
|
||||||
# Use only if you're NOT putting Cloudflare Access in front of the
|
# 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'
|
# caddy hash-password --plaintext 'your-pass'
|
||||||
#
|
#
|
||||||
# Drop this into /etc/caddy/conf.d/ alongside avian.caddy and reload.
|
# Drop this into /etc/caddy/conf.d/ alongside avian.caddy and reload.
|
||||||
|
|||||||
@@ -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`.
|
# replace TUNNEL_UUID with the value printed by `cloudflared tunnel create`.
|
||||||
tunnel: TUNNEL_UUID
|
tunnel: TUNNEL_UUID
|
||||||
credentials-file: /etc/cloudflared/TUNNEL_UUID.json
|
credentials-file: /etc/cloudflared/TUNNEL_UUID.json
|
||||||
|
|
||||||
ingress:
|
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
|
- hostname: birds.your-domain.com
|
||||||
service: http://localhost:80
|
service: http://localhost:80
|
||||||
# Catch-all required by cloudflared
|
# Catch-all required by cloudflared
|
||||||
|
|||||||
+10
-14
@@ -1,18 +1,18 @@
|
|||||||
/* AvianVisitors — bird collage frontend.
|
/* AvianVisitors - bird collage frontend.
|
||||||
*
|
*
|
||||||
* Three views over BirdNET-Pi detections:
|
* Three views over BirdNET-Pi detections:
|
||||||
* collage — mask-packed cluster of species illustrations, sized by
|
* collage - mask-packed cluster of species illustrations, sized by
|
||||||
* count. Layout normalises so every bird always fits on
|
* count. Layout normalises so every bird always fits on
|
||||||
* every viewport.
|
* every viewport.
|
||||||
* stats — per-species mark on a time × count plot.
|
* stats - per-species mark on a time × count plot.
|
||||||
* atlas — grid of every species ever detected, with detail modal.
|
* atlas - grid of every species ever detected, with detail modal.
|
||||||
*
|
*
|
||||||
* Lives at $HOME/BirdNET-Pi/avian/frontend/ on the Pi; the install
|
* Lives at $HOME/BirdNET-Pi/avian/frontend/ on the Pi; the install
|
||||||
* symlinks $HOME/BirdNET-Pi/avian → $EXTRACTED/avian, so this file
|
* symlinks $HOME/BirdNET-Pi/avian → $EXTRACTED/avian, so this file
|
||||||
* loads from http://birdnet.local/avian/frontend/apt.js with the
|
* loads from http://birdnet.local/avian/frontend/apt.js with the
|
||||||
* collage at http://birdnet.local/avian/frontend/.
|
* 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
|
* served by BirdNET-Pi's existing Caddy + PHP-FPM stack. No frontend
|
||||||
* configuration needed; works out of the box on any BirdNET-Pi host.
|
* 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.
|
// HTML-escape user-supplied strings before they land in innerHTML.
|
||||||
// Species names come from BirdNET-Pi's labels file, which is
|
// 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) {
|
function esc(s) {
|
||||||
return String(s == null ? '' : s)
|
return String(s == null ? '' : s)
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- State ----
|
// ---- State ----
|
||||||
var DATA = { recent: null, lifelist: null, firstseen: null, timeseries: null };
|
var DATA = { recent: null, lifelist: null };
|
||||||
var MASKS = null, DIMS = null;
|
var MASKS = null, DIMS = null;
|
||||||
var currentHours = +readLS('av:window', '24') || 24;
|
var currentHours = +readLS('av:window', '24') || 24;
|
||||||
|
|
||||||
@@ -153,14 +153,10 @@
|
|||||||
var h = currentHours;
|
var h = currentHours;
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
fetchJson(api('lifelist')).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; }),
|
fetchJson(api('recent', 'hours=' + h)).catch(function () { return null; }),
|
||||||
]).then(function (parts) {
|
]).then(function (parts) {
|
||||||
DATA.lifelist = parts[0];
|
DATA.lifelist = parts[0];
|
||||||
DATA.firstseen = parts[1];
|
if (parts[1] && h === currentHours) DATA.recent = parts[1];
|
||||||
DATA.timeseries = parts[2];
|
|
||||||
if (parts[3] && h === currentHours) DATA.recent = parts[3];
|
|
||||||
renderCollageFromData();
|
renderCollageFromData();
|
||||||
drawHistograms();
|
drawHistograms();
|
||||||
});
|
});
|
||||||
@@ -440,7 +436,7 @@
|
|||||||
if (!sp.length) {
|
if (!sp.length) {
|
||||||
var p = document.createElement('p');
|
var p = document.createElement('p');
|
||||||
p.className = 'empty';
|
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.innerHTML = '';
|
||||||
list.appendChild(p);
|
list.appendChild(p);
|
||||||
return;
|
return;
|
||||||
@@ -510,7 +506,7 @@
|
|||||||
card.querySelector('.detail-title').textContent = label;
|
card.querySelector('.detail-title').textContent = label;
|
||||||
card.querySelector('.detail-sci em').textContent = sci;
|
card.querySelector('.detail-sci em').textContent = sci;
|
||||||
card.querySelector('.detail-stats').textContent =
|
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-audio').src = media('recording.php', 'sci=' + encodeURIComponent(sci));
|
||||||
card.querySelector('.detail-spec').src = media('spectrogram.php', 'sci=' + encodeURIComponent(sci));
|
card.querySelector('.detail-spec').src = media('spectrogram.php', 'sci=' + encodeURIComponent(sci));
|
||||||
modal.setAttribute('aria-hidden', 'false');
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<button type="button" class="modal-close" data-close="1" aria-label="close">×</button>
|
<button type="button" class="modal-close" data-close="1" aria-label="close">×</button>
|
||||||
<p class="about-eyebrow">apartment birds</p>
|
<p class="about-eyebrow">apartment birds</p>
|
||||||
<h2 id="aboutTitle">The birds outside my window</h2>
|
<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>
|
<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>
|
<p class="built-by">built with <a href="https://github.com/Twarner491/AvianVisitors" target="_blank" rel="noopener">AvianVisitors</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* AvianVisitors — collage frontend styles.
|
/* AvianVisitors - collage frontend styles.
|
||||||
* Paper-and-ink aesthetic to match the bundled kachō-e illustrations. */
|
* Paper-and-ink aesthetic to match the bundled kachō-e illustrations. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""AvianVisitors — pre-generate kachō-e illustrations for a region.
|
"""AvianVisitors - pre-generate kachō-e illustrations for a region.
|
||||||
|
|
||||||
Reads a species list (BirdNET-Pi's labels.txt, eBird, or stdin),
|
Reads a species list (BirdNET-Pi's labels.txt, eBird, or stdin),
|
||||||
generates an illustration for each via the Gemini 2.5 Flash Image API,
|
generates an illustration for each via the Gemini 2.5 Flash Image API,
|
||||||
@@ -7,7 +7,7 @@ and saves PNGs into avian/assets/illustrations/.
|
|||||||
|
|
||||||
Each species gets two poses: <slug>.png (perched) and <slug>-2.png
|
Each species gets two poses: <slug>.png (perched) and <slug>-2.png
|
||||||
(flight). Edit avian/scripts/prompt.template.md to change the visual
|
(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.
|
{sci_name}, {com_name}, and {pose} substituted.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -43,7 +43,7 @@ from pathlib import Path
|
|||||||
# get a 404 here, check Google's model catalog and bump this.
|
# get a 404 here, check Google's model catalog and bump this.
|
||||||
GEMINI_URL = (
|
GEMINI_URL = (
|
||||||
"https://generativelanguage.googleapis.com/v1beta/models/"
|
"https://generativelanguage.googleapis.com/v1beta/models/"
|
||||||
"gemini-2.5-flash-image-preview:generateContent"
|
"gemini-2.5-flash-image:generateContent"
|
||||||
)
|
)
|
||||||
POSES = {1: "perched", 2: "in flight with wings spread"}
|
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).
|
# rejecting the request shape (image-only sometimes errors).
|
||||||
"generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
|
"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.
|
# request logs, proxy logs, and shell history.
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
GEMINI_URL,
|
GEMINI_URL,
|
||||||
@@ -134,7 +134,11 @@ def gen_one(api_key: str, prompt: str, sci: str, com: str, pose: int) -> bytes:
|
|||||||
break
|
break
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code in (429, 500, 502, 503, 504) and attempt < 3:
|
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)
|
time.sleep(retry_after)
|
||||||
backoff *= 2
|
backoff *= 2
|
||||||
continue
|
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")
|
inline = part.get("inlineData") or part.get("inline_data")
|
||||||
if inline and inline.get("data"):
|
if inline and inline.get("data"):
|
||||||
return base64.b64decode(inline["data"])
|
return base64.b64decode(inline["data"])
|
||||||
# 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", "?")
|
finish = (resp.get("candidates", [{}])[0]).get("finishReason", "?")
|
||||||
block = resp.get("promptFeedback", {}).get("blockReason", "")
|
block = resp.get("promptFeedback", {}).get("blockReason", "")
|
||||||
raise RuntimeError(f"no image (finish={finish} block={block})")
|
raise RuntimeError(f"no image (finish={finish} block={block})")
|
||||||
@@ -180,8 +184,8 @@ def main() -> int:
|
|||||||
choices=list(POSES.keys()),
|
choices=list(POSES.keys()),
|
||||||
help="Which poses to render. 1=perched, 2=flight. Default: both.")
|
help="Which poses to render. 1=perched, 2=flight. Default: both.")
|
||||||
ap.add_argument("--force", action="store_true", help="Re-render even if file exists")
|
ap.add_argument("--force", action="store_true", help="Re-render even if file exists")
|
||||||
ap.add_argument("--sleep", type=float, default=4.0,
|
ap.add_argument("--sleep", type=float, default=6.0,
|
||||||
help="Seconds between API calls (default 4 = under free-tier RPM cap)")
|
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")
|
ap.add_argument("--limit", type=int, default=0, help="Cap species count for testing")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,25 @@
|
|||||||
# Bird illustration prompt
|
# Bird illustration prompt
|
||||||
|
|
||||||
This is the prompt sent to Gemini for every bird illustration. Edit it
|
The prompt sent to Gemini for every illustration. Edit the body to change the style.
|
||||||
to change the style — the entire visual feel of the collage flows
|
|
||||||
through this template.
|
|
||||||
|
|
||||||
Three placeholders get replaced per request:
|
Three placeholders get replaced per request:
|
||||||
|
|
||||||
- `{sci_name}` — the binomial Latin name, e.g. `Calypte anna`
|
- `{sci_name}` is the binomial Latin name, e.g. `Calypte anna`
|
||||||
- `{com_name}` — the English common name, e.g. `Anna's Hummingbird`
|
- `{com_name}` is the English common name, e.g. `Anna's Hummingbird`
|
||||||
- `{pose}` — either `perched` (pose 1) or `in flight with wings spread` (pose 2)
|
- `{pose}` is either `perched` (pose 1) or `in flight with wings spread` (pose 2)
|
||||||
|
|
||||||
The default style below is **kachō-e** — Edo-period Japanese
|
The default below is kachō-e (Edo-period Japanese flower-and-bird woodblock prints). Replace it with whatever style fits your apartment.
|
||||||
flower-and-bird woodblock prints, rendered in ink and watercolor.
|
|
||||||
Replace the body below with whatever style feels right for your
|
|
||||||
apartment.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prompt
|
## Prompt
|
||||||
|
|
||||||
Generate a {pose} {com_name} ({sci_name}) in the style of an Edo-period
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
Anatomy must be biologically accurate for the named species:
|
Anatomy must be biologically accurate for the named species:
|
||||||
|
|
||||||
- Exactly two wings. Two legs. One head. One beak. One tail.
|
- Exactly two wings. Two legs. One head. One beak. One tail.
|
||||||
- Posture and feather pattern matching {com_name} field-guide
|
- Posture, color, markings, and body proportions matching {com_name} field-guide references.
|
||||||
references — color, markings, and body proportions must match the
|
- For perched poses: one wing folded against the body, the other tucked behind. For flight: both wings extended in a natural flapping position.
|
||||||
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.
|
|
||||||
|
|
||||||
Render at high resolution on a fully transparent background. The bird
|
Render at high resolution on a fully transparent background. Cut the bird out cleanly. No shadow, no paper texture, no caption.
|
||||||
must be cut out cleanly — no shadow, no paper texture, no caption.
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 308 KiB |
@@ -16,7 +16,10 @@ my_dir=$HOME/BirdNET-Pi/scripts
|
|||||||
|
|
||||||
# Defaults
|
# Defaults
|
||||||
remote="origin"
|
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=""
|
auto_update=""
|
||||||
|
|
||||||
while getopts ":r:b:a" o; do
|
while getopts ":r:b:a" o; do
|
||||||
|
|||||||
Reference in New Issue
Block a user