A self-hosted web frontend that renders a live bird collage in the kachō-e style of AvianVisitors, driven by a BirdNET-Go instance's HTTP API.
Saezuri is a display surface, not a detector: BirdNET-Go does the listening and identification; Saezuri visualizes recent detections as a woodblock-print collage that grows the birds you hear most.
- React + Vite + TypeScript + Tailwind single-page app, no SSR.
- The browser only ever talks to Saezuri's own origin, and reads only the static files Saezuri publishes — it never calls BirdNET-Go. In production, nginx serves the static bundle; it does not proxy the BirdNET-Go API.
- A small Node refresh service runs beside nginx: it holds BirdNET-Go's detection stream, counts detections per species over each time window, and publishes a static snapshot (plus per-species art and localized name dictionaries) that the browser polls and lays out with a silhouette-mask packing algorithm. Keeping BirdNET-Go backend-only means a Saezuri exposed to the internet never exposes the BirdNET-Go API, and it still works against a plain-HTTP LAN BirdNET-Go with no CORS or mixed-content.
- A collage of the species heard recently, each bird sized by how often it called in the chosen window. Only illustrated species are drawn; the rest are counted in the status line until their art arrives.
- Five time windows, each its own shareable URL:
/1h,/12h,/24h,/7d,/all. Anything else redirects to/24h. - Click or tab to a bird for a species card: local and scientific name, how many times it was heard, the first and last time it called in the window, and — when a recording was found — a play button with the recordist and licence credited.
- Light, dark, or follow-the-OS, and a display language for species names, both in the
settings menu. Both are per-browser, remembered in
localStorageundersaezuri:themeandsaezuri:lang. - An e-ink frame of every window at
/1h.png,/12h.png,/24h.png,/7d.png,/all.png— the same collage with no chrome, rendered server-side at a fixed pixel size for a panel to fetch. Sized with theFRAME_*settings below. - Honest empty states: a nest when nothing has been heard, a distinct message when species were heard but none are illustrated yet, and a loading indicator that only appears if the first load is actually slow.
Data stays fresh without a reload: the browser re-reads the snapshot every 12 seconds and the manifests every 30, revalidates the moment a tab regains focus, and stops polling while hidden. Nothing is cached staler than that, so two screens on the same deployment always agree.
Everything is configured by environment variable; no hosts are hardcoded. BIRDNETGO_URL is
the only required one — the rest have working defaults. .env.example has an
annotated copy of every setting.
| Variable | Default | Description |
|---|---|---|
BIRDNETGO_URL |
— | Required. Base URL of your BirdNET-Go instance, e.g. http://192.168.1.10:8080. |
BIRDNETGO_TOKEN |
unset | Auth token for an instance running with Security.PrivateMode. Used only by the refresh service, for the API and the SSE detection stream; it never reaches the browser. |
| Variable | Default | Description |
|---|---|---|
ILLUSTRATIONS_REPO |
vrwrts/saezuri-illustrations |
Source repo for the free pre-made cutouts, downloaded per detected species. Set it empty to turn downloading off entirely. |
ILLUSTRATIONS_REF |
main |
Branch or release tag to pull art from. Pin a tag for a fixed art set. |
ILLUSTRATIONS_BASE_URL |
derived jsDelivr URL | Overrides the whole download base URL, and wins over the two above. For testing against a local file server. |
GEMINI_API_KEY |
unset | Google AI (Gemini) key. Set it to also generate art for species the repo lacks (see below); unset relies on downloads only. |
GENERATE_SLEEP |
6 |
Seconds between image-API calls, to stay under the Gemini free tier. The throughput knob: lower it on a paid tier, raise it if you get throttled, 0 to remove the gap. |
SPECIES_NOTES |
_species-notes.json beside the art |
Prompt addenda for species that keep coming out wrong (see below). Layered over the set bundled with the pipeline. |
| Variable | Default | Description |
|---|---|---|
CALL_PROVIDERS |
commons |
Comma-list of archives to look recordings up in, tried in order. Only commons exists today. Set it empty to disable all outbound archive lookups — unlike the other comma-lists, empty here means off, not all. |
CALLS_MAX_PER_CYCLE |
4 |
Cap on species looked up per batch. |
| Variable | Default | Description |
|---|---|---|
FRAME_WIDTH |
800 |
Frame width in device pixels. Set it to your panel (a Waveshare 7.3" 7-colour is 800×480). A width of 700 or less switches to portrait packing. |
FRAME_HEIGHT |
480 |
Frame height in device pixels. |
FRAME_BG |
#fcfcfb |
Background fill. Use something like #17181c for a dark panel. |
FRAME_SHADOW |
1 |
Per-tile drop shadow; 0 disables it (some quantized panels muddy it). |
FRAME_WINDOWS |
all five | Comma-list of 1h,12h,24h,7d,all to render. Narrow it if the panel only ever shows one. |
| Variable | Default | Description |
|---|---|---|
SPECIES_DICT_LOCALES |
all 16 | Comma-list of the BirdNET-Go dictionary locales to publish for browser localization (cs,da,de,en,es,fi,fr,hu,it,lv,nb,nl,pl,pt,sk,sv). Narrow it to save disk and bandwidth, e.g. de,nl,en. |
| Variable | Default | Description |
|---|---|---|
PUBLISH_DEBOUNCE_MS |
20000 |
Minimum gap between publishes triggered by new detections. |
AGING_INTERVAL_MS |
120000 |
Periodic republish, so bounded windows shed detections that aged out. |
SUMMARY_INTERVAL_MS |
1800000 |
How often the all-time species summary — the one expensive call — is refreshed. |
| Variable | Default | Description |
|---|---|---|
FRAME_HTML_DIR |
/usr/share/nginx/html |
Root the service publishes into. Set it to ./public to run the service in dev. |
CACHE_DIR |
/var/cache/saezuri |
Scratch directory for the illustration pipeline. |
PYTHON_BIN |
python3 |
Interpreter used to run the pipeline. |
WORKER_SCRIPT |
/opt/saezuri/pipeline/worker.py |
Pipeline entry script. |
Two parsing quirks worth knowing, because neither fails loudly: a numeric setting that isn't a
number greater than zero falls back to its default, and a comma-list that names nothing valid
falls back to the full set — so FRAME_WINDOWS=6h renders every window rather than erroring.
CALL_PROVIDERS is the documented exception: empty means off.
BIRDNETGO_URL must be reachable from inside the container, and its host is forwarded
upstream by the refresh service as the Host header (and SNI, for https). A LAN IP is simplest;
a hostname works too, including one behind a reverse proxy or Cloudflare Tunnel that routes by
Host. When BirdNET-Go also runs in Docker on the same host, the cleanest option is to put Saezuri
on its Docker network and point BIRDNETGO_URL at the service name + internal port (e.g.
http://birdnet-go:8080) so traffic stays on the local network — see
docker-compose.yml.
docker run -d -p 8090:8080 \
-e BIRDNETGO_URL=http://<birdnet-go-host>:8080 \
-v saezuri-illustrations:/data/illustrations \
-v saezuri-calls:/data/calls \
ghcr.io/vrwrts/saezuri:latestThen open http://localhost:8090. The container listens on 8080, which is unprivileged so it never needs root to bind. The host port is 8090 rather than 8080 on purpose: 8080 is BirdNET-Go's own default, so the two would collide whenever they share a host, which is the common case. Images are published multi-arch (amd64 + arm64), so they run on a Raspberry Pi as well as an x86 host. The two volumes keep the illustrations and reference recordings it collects, so replacing the container doesn't start it over — both sections below explain what lands in them.
/data/illustrations and /data/calls are where the files actually live;
/usr/share/nginx/html/assets/illustrations and .../assets/calls are symlinks to them. Docker
resolves a symlinked mount destination, so both spellings mount the same directory: the short one
is just less to type, and an existing deployment mounted on the long path keeps working unchanged.
The image runs as uid/gid 1000, not root, so files it writes into a mounted volume belong to a real account rather than to root. Override it the ordinary Docker way:
docker run -d -p 8090:8080 --user 1000:1000 ...Any uid works — the directories the container writes to are mode-granted rather than
owner-granted, so there is no PUID/PGID to set. Two things follow from it:
-
A bind-mounted host directory has to be owned by the uid you pass; Docker never changes ownership on a mount. Named volumes are seeded from the image and need nothing.
-
Upgrading from a release before this one, your existing volumes are still root-owned, so the container refuses to start and tells you to take ownership once, per volume:
docker run --rm -v saezuri-illustrations:/d alpine chown -R 1000:1000 /d
One stock nginx knob is unsupported as a result: NGINX_ENTRYPOINT_WORKER_PROCESSES_AUTOTUNE
rewrites /etc/nginx/nginx.conf, which an unprivileged container cannot do.
The free downloads above only cover species someone has contributed art for. To also fill in
anything the repo doesn't have — generated fresh in the same kachō-e style — set GEMINI_API_KEY:
docker run -d -p 8090:8080 \
-e BIRDNETGO_URL=http://<birdnet-go-host>:8080 \
-e GEMINI_API_KEY=<your-google-ai-key> \
-v saezuri-illustrations:/data/illustrations \
ghcr.io/vrwrts/saezuri:latestThe refresh service holds BirdNET-Go's detection SSE stream; the moment a new species is heard it first tries the free download, and if the repo doesn't have it, generates the cutout (via the bundled pipeline), then refreshes the layout manifest the frontend polls. Silhouettes turn into real birds on their own within seconds to hours.
Art is acquired one pose at a time, perched first. A species needs only its perched cutout to stop being a silhouette, so that render lands and shows up before the flight pose is even started. The free downloads and the paid generation run independently, so a species whose art is already in the repo appears immediately rather than queueing behind someone else's render.
Things to know:
- It uses the paid Gemini image API with your key — you pay for what it generates.
Only detected species the repo doesn't already have are generated (typically a handful).
Generation is paced by
GENERATE_SLEEP(default 6s) to stay under the free tier; that gap is the only throughput control, since the limit here is the API's request rate. - A pose the model declines is left alone for a day rather than re-attempted every refresh, so a stubborn species can't quietly drain quota. See Free illustrations for how gaps are remembered.
- Persist the art with the named volume above so container upgrades don't re-spend those API calls. The manifest is rebuilt from the volume at startup.
- The generator is bundled in every image — vendored at build time from the
saezuri-illustrations pipeline at a pinned
version (numpy/scipy cutout, no ML model, so the
nginx:alpineimage stays modest).GEMINI_API_KEYunset simply means the worker never generates; the container is otherwise identical. - A species that keeps coming out wrong needs a better prompt, not more attempts — see Species notes.
- Licensing. Generating art locally for your own display is personal use. The style derives from the CC-BY-NC-SA lineage (see below) — confirm the obligations before publishing generated images.
Sometimes a species comes out wrong no matter how many times you regenerate it — the model's prior is simply off, and re-rolling the dice won't fix it. A note is a sentence or two appended to that species' prompt only:
{
"Turdus merula": "Solid glossy black, no pale markings; bill and eye-ring bright orange-yellow.",
"parus-major": "Black crown and throat stripe, bright white cheeks, yellow underparts."
}Save it as _species-notes.json beside the art (inside the persisted volume, so it survives
upgrades), or point SPECIES_NOTES anywhere you like. A key may be either the scientific name or
its slug — the slug is what you see in the illustration filenames. Keys beginning with _ are
comments.
How it behaves:
- Edits apply on their own. When you change a species' note, its art is re-rendered on the next cycle; you don't need to delete anything or restart the container.
- It only affects art Saezuri generated. A cutout downloaded from the illustrations repo is
left alone, because that repo is the state of the art and everyone benefits from it being right.
If a note fixes a species the repo gets wrong, contribute it
upstream rather than keeping the fix local — the
pipeline ships its own
species-notes.jsonthat yours is layered over, and that is the file to send a PR to. - It needs
GEMINI_API_KEY. A note is an instruction to the generator; with no key there is nothing to instruct.
You don't have to pay for generation to get real art. On by default, the moment BirdNET-Go reports a species the refresh service downloads its ready-made cutout from the saezuri-illustrations repo (via the jsDelivr CDN) — no API key needed. Just mount the volume so it persists:
docker run -d -p 8090:8080 \
-e BIRDNETGO_URL=http://<birdnet-go-host>:8080 \
-v saezuri-illustrations:/data/illustrations \
ghcr.io/vrwrts/saezuri:latestHow it behaves:
- Per detected species, once. Both of a species' cutouts (perched + flight) are fetched when it is first heard and kept in the volume, so a restart re-downloads nothing. A species only appears once it has both. A fresh display fills in over the first hours as birds are heard (not all at t=0).
- Composes with on-demand generation. Download is tried first (free); if
GEMINI_API_KEYis set, a species the repo doesn't have still falls back to Gemini generation. - Requires
BIRDNETGO_URL— the refresh service (which fetches art and builds the manifest) only runs when it's set. - Offline-safe / disable. A failed fetch is non-fatal (silhouette until art exists). Set
ILLUSTRATIONS_REPO=(empty) to turn downloading off entirely; pinILLUSTRATIONS_REFto a release tag instead ofmainfor a fixed art set. - Gaps it can't fill are remembered, not retried forever. A cutout the repo doesn't have (or
that generation declined) is logged once and left alone for a while — a week for a repo miss, a
day for a generation miss — instead of being re-requested on every refresh. The record lives in
_art-state.jsonin the illustrations volume; delete it to retry everything immediately. - Self-healing. Delete a cutout from the volume and the service notices, re-downloads it (or regenerates it), and rebuilds the manifest — which is also how you replace art you don't like. This works for a single pose of a pair, and for a bird that hasn't been heard in weeks: every refresh compares what's on disk against what should be there and repairs the difference, at startup as well as while running.
- Licensing. The illustrations (and the generation pipeline the image bundles) are CC-BY-NC-SA-4.0 — non-commercial. See the illustrations repo and Credits and licensing below.
Want to contribute art for more species? Generate them with your key and open a PR — see the saezuri-illustrations repo.
On by default. When a species is heard, the refresh service looks up a freely-licensed recording of its call, caches it in a volume, and publishes it — so selecting a bird on the collage offers a play button for what it sounds like. Mount the volume so it persists:
docker run -d -p 8090:8080 \
-e BIRDNETGO_URL=http://<birdnet-go-host>:8080 \
-v saezuri-calls:/data/calls \
ghcr.io/vrwrts/saezuri:latestHow it behaves:
- The browser never talks to the archives. Only the refresh service does; clients play the cached copy from Saezuri's own origin, like every other asset.
- Per species, once. A lookup happens the first time a species is heard. Species with no recording are remembered so they aren't re-queried every cycle, and retried after a week.
- Source: Wikimedia Commons — no account or API key. Commons only hosts free licences (CC0 / CC BY / CC BY-SA), so everything it yields is safe to cache and re-serve provided the recordist is credited, which the species card does.
- Matched on the binomial, not on free text, so a recording of a different bird that merely mentions the species is never picked. Playing the wrong call is worse than playing none.
- Disable with
CALL_PROVIDERS=(empty) to stop all outbound archive lookups.
Not every species has a recording, and that's expected — the card simply offers no playback.
Uses pnpm (via Corepack — corepack enable).
pnpm install
pnpm dev # Vite dev server (serves the app + static files from ./public)
pnpm dev:mock # no backend needed — species synthesized from the manifest
pnpm test # unit tests (Vitest); test:watch to watch
pnpm typecheck # tsc --noEmit
pnpm check # lint + format check (Biome); check:fix to autofix
pnpm build # type-check + production bundle
pnpm preview # serve the built bundleTo run the backend against a real instance, give it the settings in the environment — nothing in this repo reads a dotenv file:
BIRDNETGO_URL=http://<birdnet-go-host>:8080 FRAME_HTML_DIR=./public pnpm refresh:devThat publishes the snapshot, the layout and call manifests, the name dictionaries and the e-ink
frames into ./public, and downloads (or generates) art as species come in — the same work it
does in the container. refresh:dev bundles the service first via build:server. Copying
.env.example is still a useful reference for what to set, but it is not loaded
automatically; node --env-file=.env.local dist-server/refresh.mjs works if you'd rather keep the
values in a file.
In dev:mock there is no backend and so no real recordings; node src/dev/mockCalls.mjs writes
short synthetic tones and a matching call manifest into ./public so playback can be exercised.
cp .env.example .env # set BIRDNETGO_URL (and BIRDNETGO_TOKEN if needed)
docker compose up --buildSaezuri also ships as a Home Assistant app, what Home Assistant called an add-on until
recently: add https://github.com/vrwrts/saezuri as a repository and it installs from
the app store, appears in the sidebar through ingress, and finds a BirdNET-Go app on the
same machine by itself. Everything Home Assistant specific lives in addon/,
named for the Supervisor's own /addons layout; the page users read inside Home Assistant
is addon/DOCS.md.
A static one-pager (Astro) lives in site/ and shares the app's design tokens
(shared/theme.css). It is a separate project, kept out of the Docker
image, and deploys to Cloudflare Pages — see site/README.md.
-
CI (
.github/workflows/ci.yml) runs Biome (lint + format), type-check, tests, and a production build on every push tomainand every pull request. -
Docker build (
.github/workflows/docker.yml) builds the runtime image on fork pull requests to prove the Dockerfile still works. It never pushes. Same-repo branches are covered by the preview build instead, so the heavy image isn't built twice per PR. -
Preview image (
.github/workflows/preview.yml) builds a throwaway multi-arch image on every push to a branch other thanmainand pushes it toghcr.io/vrwrts/saezuri:<branch>, so a change can be installed on a real device before it merges. The package is private, so pulling it needsdocker login ghcr.io. Both this and the Docker build skip pushes that only touchsite/,fixtures/, or Markdown. -
Release (
.github/workflows/release.yml) is continuous and semver-based: on every push tomainthat touches app code, semantic-release reads the Conventional Commits since the last release and, if there is a releasable change (feat/fix/perf/breaking), it tagsvX.Y.Zand publishes a GitHub Release with auto-generated notes. The same run then builds the multi-arch image (linux/amd64+linux/arm64) and pushes it toghcr.io/vrwrts/saezurias:X.Y.Z,:X.Y, and:latest, then the Home Assistant wrapper toghcr.io/vrwrts/saezuri-addonat the same version. No manual tagging. It can also be run by hand from the Actions tab, for retrying a release that failed on infrastructure. -
Site vs app: changes under
site/and docs never cut an app release — the landing site deploys itself to Cloudflare. The generation pipeline lives in the saezuri-illustrations repo (versioned there); the app adopts a new one by bumping thePIPELINE_VERSIONbuild arg in the Dockerfile, which cuts a normal app release. -
Release credentials: a release also pushes a commit to
main, bumping the version pinned inaddon/config.yamlandaddon/build.yaml. It has to: the Home Assistant app store reads that version out of the default branch, so it is the only way a new version reaches an installed app, and a version with no matching published image makes the app uninstallable. Tags and GitHub Releases are unaffected by branch protection, but a commit is, so the defaultGITHUB_TOKENcannot do this whilemainrequires a pull request. The workflow therefore authenticates semantic-release with aRELEASE_TOKENsecret, and fails fast with a clear message if it is missing. To set it up:- Create a fine-grained PAT with Contents: read and write on this repo (a GitHub App installation token works too, and is preferable if you would rather not tie releases to a personal account).
- Add it as the repository secret
RELEASE_TOKEN. - In Settings → Branches →
main, add that identity to Allow specified actors to bypass required pull requests.
The release commit carries
[skip ci]. That matters here: a push authenticated with a PAT triggers workflows, where one withGITHUB_TOKENdoes not, so without it every release would start a second, pointless Release run. -
One-time setup: after the first release, set the
saezuriandsaezuri-addonpackages to public in the org's GHCR package settings so anonymousdocker pullworks, and link them to the repo. The app store pullssaezuri-addonanonymously, so leaving that one private makes the app fail to install with an image-pull error.
Saezuri is an original, clean-room reimplementation of a collage frontend. It shares no source with AvianVisitors; it matches the look and feel and reuses only the backend-agnostic illustration tooling, which lives in the saezuri-illustrations repo and is vendored into the Docker image.
- Design, collage aesthetic, and the illustration pipeline are owed to AvianVisitors by Teddy Warner.
- AvianVisitors builds on BirdNET-Pi (Patrick McGuire), which in turn uses BirdNET-Lite from the K. Lisa Yang Center for Conservation Bioacoustics, Cornell Lab of Ornithology, Cornell University.
- Detections come from BirdNET-Go by Tomi Phakala.
- Reference calls come from Wikimedia Commons and the recordists who contributed them — much of the bird audio there originates from xeno-canto. Each recording is cached per deployment and is individually licensed (CC0 / CC BY / CC BY-SA); the recordist, licence, and a link back are shown on the species card whenever a call can be played. These recordings are not redistributed by this repo or bundled into the image — each deployment fetches its own.
The reused illustrations and pipeline carry the CC-BY-NC-SA-4.0 license inherited from
BirdNET-Pi — non-commercial use only. In this repo that covers the empty-state nest
illustration (public/assets/nest.webp), bundled and shipped under that license. The Docker
image additionally bundles the vendored pipeline and any downloaded bird illustrations, all
CC-BY-NC-SA-4.0 — so the published image is non-commercial (see LICENSE). Purely
local, personal use does not trigger distribution terms, but publishing images or a derived
repository does; confirm the obligations before doing so.