diff --git a/README.md b/README.md index fedb551..d6a1386 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ License: MIT code, AGPL-3.0 project Python 3.12 - Species 282 @@ -29,7 +28,7 @@ Everything you need, including installation instructions, an interactive demo, m ## Demo -Try the [Demo](https://birdnet-team.github.io/BirdBox/demo/) in your browser: it runs the Just-Bird model on your device, with no installation and no upload. Load a recording, tune the confidence and phrase gap, review and edit the boxes, and export them in the BirdBox output formats. +Try the [Demo](https://birdnet-team.github.io/BirdBox/demo/) in your browser: it runs the Just-Bird model (or All-In-One, from the model menu) on your device, with no installation and no upload. Load a recording, tune the confidence and phrase gap, review and edit the boxes, and export them in the BirdBox output formats. diff --git a/docs/demo.md b/docs/demo.md index f9932e7..590862c 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -9,16 +9,19 @@ hide: ## About this demo -The demo runs the self-contained Just-Bird graph `docs/models/Just-Bird_fp16.onnx` with [ONNX Runtime Web](https://onnxruntime.ai/docs/tutorials/web/){ target="_blank" rel="noopener noreferrer" } in your browser. The graph computes the PCEN spectrogram, runs the YOLO detector on 3 s windows every 1.5 s and applies NMS, just like [`detect_birds.py`](cli/detect-birds.md) with the Just-Bird model. There are no species labels: every box is a bird vocalization (`bird`). +The demo runs a self-contained BirdBox graph with [ONNX Runtime Web](https://onnxruntime.ai/docs/tutorials/web/){ target="_blank" rel="noopener noreferrer" } in your browser. The graph computes the PCEN spectrogram, runs the YOLO detector on 3 s windows every 1.5 s and applies NMS, just like [`detect_birds.py`](cli/detect-birds.md) with the same model. The model menu at the top picks the graph: -**Instant sliders.** The model runs once per recording, at the lowest confidence of the slider (0.05). The demo asks the graph for its per-window boxes (`raw_all`) and repeats the graph's window fusion in JavaScript: window ownership and weighted box fusion, joining calls into phrases, removing nested boxes. NMS only lets higher-scoring boxes suppress lower ones, so the boxes for any confidence and phrase gap are the same as a graph run with those values. +- **Just-Bird** (`docs/models/Just-Bird_fp16.onnx`, the default): every box is a bird vocalization (`bird`), without species labels. For species, combine the boxes with [BirdNET](https://github.com/birdnet-team/BirdNET-Analyzer){ target="_blank" rel="noopener noreferrer" }. +- **All-In-One** (`docs/models/All-In-One_fp16.onnx`): the [All-in-One](models-and-metrics/all-in-one.md) model with its 282 classes. The table names the species of each box. + +**Instant sliders.** The model runs once per recording, at the lowest confidence of the slider (0.05). The demo asks the graph for its per-window boxes (`raw_all`) and repeats the graph's merge in JavaScript. For Just-Bird that is window fusion: window ownership and weighted box fusion, joining calls into phrases, removing nested boxes. For All-In-One it is song reconstruction: boxes of one species closer than the song gap become one box. NMS only lets higher-scoring boxes suppress lower ones, so the boxes for any confidence and gap are the same as a graph run with those values. **Spectrogram.** The viewer shows an STFT spectrogram by default. In its display settings, *PCEN* switches to the PCEN mel spectrogram BirdBox detects on. **Review.** Boxes can be verified, moved, resized, removed and drawn. The exports use the BirdBox [output formats](data/outputs.md): the JSON of `--output-format json-with-algorithm-metadata`, the simplified CSV and the Raven selection table, plus a CSV with every column of the table. !!! warning "Browser limits" - - The model is downloaded once (about 35 MB) and then comes from the browser cache. + - The model is downloaded once (about 20 MB for Just-Bird, 51 MB for All-In-One) and then comes from the browser cache. - Audio is decoded locally, resampled to 32 kHz mono and truncated to **10 minutes**. - The browser's resampler is not librosa's, so files that are not recorded at 32 kHz can give slightly different boxes than the CLI. - ONNX Runtime runs single-threaded here: about 10 s for 2 minutes of audio on a laptop. Safari can be slower than Chromium or Firefox. @@ -28,7 +31,7 @@ The sample soundscape is the example recording of [BirdNET-Analyzer](https://git ## Adding models -The demo runs the first model in `docs/models/manifest.json` with `"merge_method": "fusion"`. Only window-fusion exports work: the instant sliders need the graph's per-window boxes (`raw_all`) and the JavaScript port of its window fusion (`docs/javascripts/demo/fusion.js`). The export uses window fusion for Just-Bird, Hawaii and Southern-Sierra-Nevada (`--merge auto`); `--merge fusion` forces it for the other models. +The model menu lists the models in `docs/models/manifest.json`; the first one is the default. The instant sliders need the graph's per-window boxes (`raw_all`) and a JavaScript port of its merge: `docs/javascripts/demo/fusion.js` for window fusion (`fusion`, the export's default for Just-Bird, Hawaii and Southern-Sierra-Nevada) and `docs/javascripts/demo/songs.js` for song reconstruction (`reconstruct`, the default for the other models). The Just-Bird frequency gate (`--merge reconstruct` with Just-Bird) has no port; such entries are skipped. 1. Export the model. @@ -53,11 +56,13 @@ The demo runs the first model in `docs/models/manifest.json` with `"merge_method --precision fp16 ``` - `--precision fp16` stores the network weights in fp16, which makes the file about a third smaller (35 MB instead of 53 MB for Just-Bird). Feature extraction and merging stay in fp32. On 13 test recordings, 99.4% of the fp32 graph's boxes were reproduced (mean IoU 0.996). The export prints the merge it baked in (`Merge (baked in)`) and the suggested defaults (`Default conf`, `Default song_gap`), which it also stores in the ONNX metadata. + For another model, add `--pt-model`, for example `--pt-model models/All-In-One.pt --output-path docs/models/All-In-One_fp16.onnx`. + + `--precision fp16` stores the network weights in fp16, which halves the file (20 MB instead of 38 MB for Just-Bird, 51 MB instead of 99 MB for All-In-One). Feature extraction and merging stay in fp32. On 13 test recordings (`tests/onnx_graph_parity.py`), the fp16 graphs reproduced the boxes of `detect_birds.py` with the `.pt` model: 99.8% for Just-Bird (mean IoU 0.995) and 99.7% for All-In-One, with the same species (mean IoU 0.997). The export prints the merge it baked in (`Merge (baked in)`) and the suggested defaults (`Default conf`, `Default song_gap`), which it also stores in the ONNX metadata. 2. Place the `.onnx` file under `docs/models/` if the export path was elsewhere. -3. Add an entry to `docs/models/manifest.json`, first in the list if the demo should run it. +3. Add an entry to `docs/models/manifest.json`, first in the list if the demo should start with it. ```json { @@ -70,6 +75,16 @@ The demo runs the first model in `docs/models/manifest.json` with `"merge_method "phrase_gap": 0.05, "species_mapping": "Just-Bird", "names": { "0": "bird" } + }, + { + "file": "All-In-One_fp16.onnx", + "label": "All-In-One_fp16.onnx", + "merge_method": "reconstruct", + "conf": 0.2, + "song_gap": 0.1, + "species_mapping": "All-In-One", + "names": { "0": "????", "1": "amabaw1", "…": "…" }, + "common_names": { "0": "Unknown", "1": "Amazonian Barred-Woodcreeper", "…": "…" } } ] } @@ -78,19 +93,23 @@ The demo runs the first model in `docs/models/manifest.json` with `"merge_method | Field | Meaning | | :--- | :--- | | `file` | The ONNX file, relative to `docs/models/` | - | `label` | Name shown at the top of the demo | - | `merge_method` | `fusion`, as printed by the export. Other entries are skipped. | + | `label` | Name in the model menu | + | `merge_method` | `fusion` or `reconstruct`, as printed by the export. Other entries are skipped. | | `conf` | Where the confidence slider starts: the printed `Default conf` (0.15 for Just-Bird, 0.2 for the other models) | - | `phrase_gap` | Where the phrase gap slider starts: the printed `Default song_gap` (0.05 for Just-Bird, 0 for Hawaii and Southern-Sierra-Nevada) | + | `phrase_gap` | `fusion` only: where the phrase gap slider starts, the printed `Default song_gap` (0.05 for Just-Bird, 0 for Hawaii and Southern-Sierra-Nevada) | + | `song_gap` | `reconstruct` only: where the song gap slider starts, the printed `Default song_gap` (0.1) | | `species_mapping` | Written to `model_config` of the exported JSON | - | `names` | Class labels for the exports, by class index (ONNX Runtime Web does not expose ONNX metadata) | + | `names` | eBird codes by class index, for the table and the exports (ONNX Runtime Web does not expose ONNX metadata) | + | `common_names` | Optional: common names by class index, shown in the table instead of the codes | + + For a species model, `names` and `common_names` come from `id_to_ebird` and `ebird_to_name` of its mapping in `src/config.py`. With more than one class, the table gets a species column. -4. Check that the demo's fusion still matches the graph: +4. Check that the demo's merge still matches the graph: ```bash cd tests/js && npm install - node demo_fusion_parity.mjs ../../docs/models/Just-Bird_fp16.onnx + node demo_merge_parity.mjs ../../docs/models/Just-Bird_fp16.onnx ``` - The script compares `fusion.js` with the graph's own detections for 16 slider settings on `tests/test.wav` and fails on any difference. Run it after exporting a new model or changing the fusion in `src/inference/onnx_export.py`. + The script compares `fusion.js` or `songs.js`, whichever the manifest entry's `merge_method` names, with the graph's own detections for 16 slider settings on `tests/test.wav` and fails on any difference. Run it after exporting a new model or changing a merge in `src/inference/onnx_export.py`. diff --git a/docs/index.md b/docs/index.md index f965498..fcaa3b3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,8 +6,7 @@

Deep Learning Bird Call Detection & Evaluation System

License: MIT code, AGPL-3.0 project - Python 3.12 - Species 282 + Python 3.12 @@ -58,7 +57,7 @@ The inference software found 4 different species vocalizations and was able to l ## Quick Links - [Installation](installation.md) - set up the environment -- [Demo](demo.md) - Just-Bird in your browser +- [Demo](demo.md) - BirdBox in your browser - [How it works](how-it-works/overview.md) - pipeline description - [Data In/Out](data/fileflows.md) - datasets and output-format descriptions - [Models and Metrics](models-and-metrics/overview.md) - list of models with corresponding metrics diff --git a/docs/javascripts/demo/demo.js b/docs/javascripts/demo/demo.js index 0527cb3..0fdd470 100644 --- a/docs/javascripts/demo/demo.js +++ b/docs/javascripts/demo/demo.js @@ -2,23 +2,25 @@ // SPDX-License-Identifier: MIT /** * BirdBox browser demo (the Demo tab of the docs): bird vocalization boxes - * with the self-contained Just-Bird ONNX graph (PCEN, YOLO, NMS inside). + * with a self-contained BirdBox ONNX graph (PCEN, YOLO, NMS inside), Just-Bird + * by default; the models are listed in docs/models/manifest.json. * * The graph runs once per recording at the lowest confidence and returns its - * per-window boxes. fusion.js merges them exactly like the graph's window - * fusion for any (confidence, phrase gap) setting, so both sliders update - * instantly. + * per-window boxes. fusion.js (window fusion, Just-Bird) or songs.js (song + * reconstruction, the species models) merges them exactly like the graph for + * any (confidence, gap) setting, so both sliders update instantly. * * On the spectrogram, the selected box can be moved and resized and new * boxes can be drawn. Exports use the BirdBox output formats. */ import { AudioSource, MAX_SECONDS } from './audio-source.js'; import { SAMPLE_RATE as SR } from './audio.js'; -import { detector, modelEntry, RAW_CONF } from './detector.js'; +import { detector, models, RAW_CONF } from './detector.js'; import { fuseBoxes } from './fusion.js'; import { player } from './player.js'; +import { reconstructSongs } from './songs.js'; import { SpecView } from './spec-view.js'; -import { csvField, downloadFile, fill, fmtBytes, fmtNumber, fmtTime, icon, statLine, toast } from './util.js'; +import { csvField, downloadFile, esc, fill, fmtBytes, fmtNumber, fmtTime, icon, statLine, toast } from './util.js'; const MS_PER_CLIP_KEY = 'birdbox-demo-ms-per-clip'; const CLIP = 3, HOP = 1.5; // the graph's window grid (onnx_export.py) @@ -26,19 +28,25 @@ const NMS_IOU = 0.7; // baked into the graph (DEFAULT_NMS_IOU) const FREQ_MAX = 15000; // top of the spectrogram and of any box const MIN_DUR = 0.05; // smallest box: seconds const MIN_BAND = 100; // smallest box: Hz +const MERGE = { fusion: fuseBoxes, reconstruct: reconstructSongs }; +const GAP_TEXT = { + fusion: ['Phrase gap', 'Calls in the same frequency band closer than this are joined into one phrase box. 0 keeps single calls.'], + reconstruct: ['Song gap', 'Boxes of the same species closer than this are merged into one song box.'], +}; const COLORS = { detected: '#22d3ee', verified: '#4ade80', drawn: '#fbbf24', removed: 'rgba(148,163,184,0.7)' }; const $ = id => document.getElementById(id); const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); -/** Detector boxes keep their identity through slider changes by their geometry. */ -const boxKey = b => `${b.t0.toFixed(4)}|${b.t1.toFixed(4)}|${b.flo}|${b.fhi}`; +/** Detector boxes keep their identity through slider changes by their geometry and class. */ +const boxKey = b => `${b.t0.toFixed(4)}|${b.t1.toFixed(4)}|${b.flo}|${b.fhi}|${b.cls}`; const geomOf = g => ({ t0: g.t0, t1: g.t1, flo: g.flo, fhi: g.fhi }); const round = (v, digits) => Number(v.toFixed(digits)); class Demo { constructor() { this.audio = null; + this.model = null; // manifest entry of the selected model this.raw = null; // { data, rows, ms }: raw_all of the current recording this.songs = []; // detector boxes at the current sliders this.userBoxes = []; // boxes drawn by the user @@ -87,18 +95,51 @@ class Demo { this._showHelp(false); // closed until the ? button opens it $('bbd-conf').min = RAW_CONF; this._bind(); - modelEntry().then(m => { - this.model = m; - // Where the sliders start: the model's suggested values from the manifest - if (m.conf != null) $('bbd-conf').value = m.conf; - if (m.phrase_gap != null) $('bbd-gap').value = m.phrase_gap; - this._syncSliders(); - this._update(); - $('bbd-model-name').textContent = m.label || m.file; + this.modelsReady = models().then(list => { + this.models = list; + $('bbd-model').replaceChildren(...list.map((m, i) => new Option(m.label || m.file, String(i)))); + $('bbd-model').disabled = list.length < 2; + this._setModel(list[0]); }).catch(err => this._error(err.message)); this._refresh(); } + /** Use a manifest model: sliders to its suggested values, gap slider and table to its merge and classes. */ + _setModel(m) { + this.model = m; + $('bbd-model').value = String(this.models.indexOf(m)); + const fusion = m.merge_method === 'fusion'; + if (m.conf != null) $('bbd-conf').value = m.conf; + const gap = fusion ? m.phrase_gap : m.song_gap; + if (gap != null) $('bbd-gap').value = gap; + const [label, help] = GAP_TEXT[m.merge_method]; + $('bbd-gap-label').textContent = label; + $('bbd-gap-help').textContent = help; + $('bbd-table').querySelector('.bbd-c-species').hidden = !this._multiClass(); + this._syncSliders(); + this._renderModel(); + this._update(); + } + + /** Switch the model: boxes of the old one and changes to them go, drawn boxes stay. */ + _changeModel(m) { + if (m === this.model) return; + this.runToken++; + detector.abort(); + this._setProgress(null); + const edited = [...this.edits.values()].some(e => Object.keys(e).length); + this.raw = null; + this.edits.clear(); + this._setModel(m); + if (edited) toast('Your changes to detected boxes were reset for the new model.'); + this.run(); + } + + /** Several classes (species models): the table and the tooltip name them. */ + _multiClass() { + return Object.keys(this.model?.names || {}).length > 1; + } + /** The spectrogram takes what the page leaves it, within limits. */ _specHeight() { return Math.round(clamp(window.innerHeight * 0.42, 240, 560)); @@ -106,6 +147,7 @@ class Demo { _bind() { this.source.addEventListener('audio', e => this._onAudio(e.detail)); + $('bbd-model').addEventListener('change', e => { const m = this.models?.[+e.target.value]; if (m) this._changeModel(m); }); for (const id of ['bbd-conf', 'bbd-gap']) $(id).addEventListener('input', () => { this._syncSliders(); this._update(); }); this._syncSliders(); $('bbd-export').addEventListener('click', e => { @@ -147,7 +189,8 @@ class Demo { const g = this._geom(b), n = this.boxes.indexOf(b) + 1, edit = this._edit(b); const origin = b.manual ? 'Drawn by you' : `Confidence ${fmtNumber(b.avg, 2)} · max ${fmtNumber(b.max, 2)} · ${b.n} window boxes${edit.verified ? ' · verified' : ''}`; - this.spec.showTooltip(`
#${n} · ${fmtTime(g.t0, 2)} – ${fmtTime(g.t1, 2)}
${fmtNumber(g.flo)}–${fmtNumber(g.fhi)} Hz
${origin}
`, d.x, d.y); + const species = this._multiClass() && !b.manual ? `
${esc(this._speciesName(b))}
` : ''; + this.spec.showTooltip(`
#${n} · ${fmtTime(g.t0, 2)} – ${fmtTime(g.t1, 2)}
${species}
${fmtNumber(g.flo)}–${fmtNumber(g.fhi)} Hz
${origin}
`, d.x, d.y); }); // Editing keys work anywhere on the page except while typing in a field. // Caught on the way down and stopped there: Material's own shortcuts would @@ -195,7 +238,8 @@ class Demo { _syncSliders() { const conf = +$('bbd-conf').value, gap = +$('bbd-gap').value; $('bbd-conf-val').textContent = fmtNumber(conf, 2); - $('bbd-gap-val').textContent = gap > 0 ? `${fmtNumber(gap, 2)} s` : 'off'; + // Phrase gap 0 turns the joining off; song gap 0 still merges overlapping boxes + $('bbd-gap-val').textContent = gap > 0 || this.model?.merge_method !== 'fusion' ? `${fmtNumber(gap, 2)} s` : 'off'; } _showHelp(show) { @@ -283,7 +327,10 @@ class Demo { let ticker = null; try { this._setProgress(0.01, 'Preparing BirdBox…'); - await detector.ensure(p => { + await this.modelsReady; + if (!this.model || token !== this.runToken) return; + const msKey = `${MS_PER_CLIP_KEY}:${this.model.file}`; + await detector.ensure(this.model, p => { if (token !== this.runToken) return; if (p.starting) this._setProgress(0.3, 'Starting ONNX Runtime…'); else if (p.total) this._setProgress(p.loaded / p.total * 0.3, fill('Downloading the model: {done} of {total}', { done: fmtBytes(p.loaded), total: fmtBytes(p.total) })); @@ -293,7 +340,7 @@ class Demo { if (token !== this.runToken) return; const clips = Math.max(1, Math.ceil((this.audio.duration - CLIP) / HOP) + 1); let msPerClip = 280; - try { msPerClip = +localStorage.getItem(MS_PER_CLIP_KEY) || msPerClip; } catch { /* storage blocked */ } + try { msPerClip = +localStorage.getItem(msKey) || msPerClip; } catch { /* storage blocked */ } const t0 = performance.now(); // ONNX Runtime reports no progress inside one run; pace a counter on the clip grid. ticker = setInterval(() => { @@ -305,7 +352,7 @@ class Demo { }, 100); const res = await detector.detect(this.audio.pcm.slice()); const ms = performance.now() - t0; - try { localStorage.setItem(MS_PER_CLIP_KEY, String(Math.round(ms / clips))); } catch { /* not persisted */ } + try { localStorage.setItem(msKey, String(Math.round(ms / clips))); } catch { /* not persisted */ } if (token !== this.runToken) return; this.raw = { data: res.data, rows: res.rows, ms }; } catch (err) { @@ -324,16 +371,16 @@ class Demo { } _renderModel() { - const chip = $('bbd-model-state'); - chip.textContent = detector.loaded ? 'loaded' : ''; - chip.hidden = !detector.loaded; + const chip = $('bbd-model-state'), loaded = detector.loaded && detector.entry === this.model; + chip.textContent = loaded ? 'loaded' : ''; + chip.hidden = !loaded; } /* ---------------- boxes + edits ---------------- */ _update() { const conf = +$('bbd-conf').value, gap = +$('bbd-gap').value; - this.songs = this.raw ? fuseBoxes(this.raw.data, this.raw.rows, conf, gap) : []; + this.songs = this.raw ? MERGE[this.model.merge_method](this.raw.data, this.raw.rows, conf, gap) : []; this.songs.forEach(b => { b.id = boxKey(b); }); this._refresh(); } @@ -560,14 +607,14 @@ class Demo { } _renderTable() { - const tbody = $('bbd-table').querySelector('tbody'); + const tbody = $('bbd-table').querySelector('tbody'), multi = this._multiClass(), cols = multi ? 6 : 5; $('bbd-results').classList.toggle('has-results', !!this.raw); if (!this.raw) { - tbody.innerHTML = `${this.audio ? 'Detection is running…' : 'Load a recording to see its boxes here.'}`; + tbody.innerHTML = `${this.audio ? 'Detection is running…' : 'Load a recording to see its boxes here.'}`; return; } if (!this.boxes.length) { - tbody.innerHTML = 'No bird vocalizations above this confidence. Try a lower threshold.'; + tbody.innerHTML = 'No bird vocalizations above this confidence. Try a lower threshold.'; return; } tbody.innerHTML = this.boxes.map((b, i) => { @@ -581,13 +628,15 @@ class Demo { const verify = b.manual ? `${icon('check-on')}` : ``; + const species = !multi ? '' : b.manual ? '–' + : `${esc(this._speciesName(b))}${esc(this._class(b)[1])}`; const conf = b.manual ? '–' : `${fmtNumber(b.avg, 2)}max ${fmtNumber(b.max, 2)}`; return ` ${fmtTime(g.t0, 2)} – ${fmtTime(g.t1, 2)}${fmtNumber(g.t1 - g.t0, 2)} s ${fmtNumber(g.flo / 1000, 1)} – ${fmtNumber(g.fhi / 1000, 1)} kHz${mark} - ${conf} + ${species}${conf} ${verify} `; }).join(''); @@ -707,6 +756,12 @@ class Demo { return [id, names[id] ?? `class_${id}`]; } + /** Common name of a detected box's class, else its eBird code. */ + _speciesName(b) { + const [id, code] = this._class(b); + return this.model?.common_names?.[id] || code; + } + _detections() { return this._included().map(r => ({ species: this._class(r.b)[1], @@ -733,12 +788,12 @@ class Demo { const payload = { audio_file: name, model_config: { - model: this.model?.file || 'Just-Bird_fp16.onnx', + model: this.model.file, confidence_threshold: conf, nms_iou_threshold: NMS_IOU, - species_mapping: this.model?.species_mapping ?? null, - merge_method: 'fusion', - phrase_gap: gap, + species_mapping: this.model.species_mapping ?? null, + merge_method: this.model.merge_method, + ...(this.model.merge_method === 'fusion' ? { phrase_gap: gap } : { song_gap_threshold: gap }), }, demo: { source: 'BirdBox browser demo', diff --git a/docs/javascripts/demo/detector.js b/docs/javascripts/demo/detector.js index e2d18bd..dbeee10 100644 --- a/docs/javascripts/demo/detector.js +++ b/docs/javascripts/demo/detector.js @@ -1,16 +1,19 @@ // SPDX-FileCopyrightText: 2026 BirdNET-Team // SPDX-License-Identifier: MIT /** - * The BirdBox graph in a worker (ort-worker.js): loads the model listed in + * The BirdBox graph in a worker (ort-worker.js): loads a model listed in * docs/models/manifest.json and runs it once per recording. * * A run returns the graph's per-window boxes (raw_all) at the lowest slider - * confidence; fusion.js turns them into the boxes for any slider setting. + * confidence; fusion.js or songs.js turns them into the boxes for any slider + * setting, depending on the model's merge. */ const MANIFEST = new URL('../../models/manifest.json', import.meta.url); export const RAW_OUTPUT = 'raw_all'; export const RAW_CONF = 0.05; // lowest value of the confidence slider +/** Merges the demo repeats in JS: fusion.js and songs.js */ +export const MERGES = ['fusion', 'reconstruct']; export class CancelledError extends Error { constructor() { super('cancelled'); this.cancelled = true; } @@ -18,15 +21,15 @@ export class CancelledError extends Error { let manifest = null; -/** The first window-fusion model of the manifest (Just-Bird). */ -export async function modelEntry() { +/** The manifest's models with a merge the demo can repeat; the first is the default. */ +export async function models() { if (!manifest) { manifest = (async () => { const res = await fetch(MANIFEST, { cache: 'no-cache' }); if (!res.ok) throw new Error(`Could not load the model list (HTTP ${res.status})`); - const entry = ((await res.json()).models || []).find(m => m.merge_method === 'fusion'); - if (!entry) throw new Error('docs/models/manifest.json lists no window-fusion model'); - return { ...entry, url: new URL(entry.file, MANIFEST).href }; + const entries = ((await res.json()).models || []).filter(m => MERGES.includes(m.merge_method)); + if (!entries.length) throw new Error('docs/models/manifest.json lists no model with a fusion or reconstruct merge'); + return entries.map(m => ({ ...m, url: new URL(m.file, MANIFEST).href })); })(); manifest.catch(() => { manifest = null; }); } @@ -59,15 +62,24 @@ class Detector { get loaded() { return !!this.worker && !this.starting; } - /** Download the model and start the worker; onProgress gets { loaded, total } while downloading. */ - ensure(onProgress) { + /** + * Download the model of `entry` and start the worker; onProgress gets + * { loaded, total } while downloading. Another entry replaces the current model. + */ + ensure(entry, onProgress) { + if (this.entry !== entry) { + this.reset(); + this.entry = entry; + this.model = null; + } if (!this.ready) { this.starting = true; - this.ready = (async () => { - const entry = await modelEntry(); + const ready = this.ready = (async () => { // Kept, so a stopped run restarts the worker without a new download - this.model ??= await download(entry.url, onProgress); - const model = this.model.slice(0); + const bytes = this.model ?? await download(entry.url, onProgress); + if (this.ready !== ready) throw new CancelledError(); // another model was picked meanwhile + this.model = bytes; + const model = bytes.slice(0); onProgress?.({ starting: true }); const worker = new Worker(new URL('./ort-worker.js', import.meta.url), { type: 'module', name: 'birdbox' }); await new Promise((resolve, reject) => { @@ -78,6 +90,7 @@ class Detector { worker.onerror = e => { e.preventDefault?.(); reject(new Error(e.message || 'The detector could not start')); }; worker.postMessage({ type: 'init', model, output: RAW_OUTPUT }, [model]); }).catch(err => { worker.terminate(); throw err; }); + if (this.ready !== ready) { worker.terminate(); throw new CancelledError(); } worker.onmessage = ({ data }) => this._done(data); worker.onerror = e => { e.preventDefault?.(); @@ -87,7 +100,11 @@ class Detector { this.starting = false; return entry; })(); - this.ready.catch(() => { this.ready = null; this.starting = false; }); + ready.catch(() => { + if (this.ready !== ready) return; + this.ready = null; + this.starting = false; + }); } return this.ready; } @@ -132,6 +149,7 @@ class Detector { this.worker = null; this.job = null; this.ready = null; + this.starting = false; } } diff --git a/docs/javascripts/demo/ort-worker.js b/docs/javascripts/demo/ort-worker.js index e9d2ad5..2353c89 100644 --- a/docs/javascripts/demo/ort-worker.js +++ b/docs/javascripts/demo/ort-worker.js @@ -45,7 +45,7 @@ async function detect(msg) { const feeds = { audio: new ort.Tensor('float32', msg.audio, [msg.audio.length]), conf: new ort.Tensor('float32', Float32Array.of(msg.conf), [1]), - // Only feeds the graph's own fusion, which this run skips + // Only feeds the graph's own merge, which this run skips song_gap: new ort.Tensor('float32', Float32Array.of(0), [1]), }; const out = await session.run(feeds, [output]); diff --git a/docs/javascripts/demo/songs.js b/docs/javascripts/demo/songs.js new file mode 100644 index 0000000..3579662 --- /dev/null +++ b/docs/javascripts/demo/songs.js @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 BirdNET-Team +// SPDX-License-Identifier: MIT +/** + * BirdBox song reconstruction in JS, a float32 port of merge_raw_songs() with + * merge_method "reconstruct" (reconstruct_songs_tensor) in + * src/inference/onnx_export.py, the merge of the species models such as + * All-In-One. + * + * Input are the per-window boxes of the graph (its raw_all tensor, 9 columns: + * clip, t0, t1, f_lo, f_hi, score, class, clip start, file end), computed once + * at the lowest confidence. As with fusion.js, keeping the rows above a + * threshold equals a graph run at that threshold. + * + * Boxes of one class are sorted by start time; a box joins the open song while + * it starts at most `songGap` seconds after the song's end, otherwise it opens + * a new one. Frequencies are not compared. + */ + +const f32 = Math.fround; +const COLS = 9; + +/** + * Boxes of the BirdBox graph for one (confidence, song gap) setting. + * @param {Float32Array} raw raw_all rows, 9 columns each + * @param {number} rows + * @param {number} conf confidence threshold (boxes must score above it) + * @param {number} songGap seconds + */ +export function reconstructSongs(raw, rows, conf, songGap) { + const c = f32(conf), gap = f32(songGap), dets = []; + for (let r = 0; r < rows; r++) { + const d = raw.subarray(r * COLS, r * COLS + COLS); + if (d[5] > c) dets.push(d); + } + // Class, then start time; the graph sorts this key in float64 + const key = d => d[6] * 1e6 + d[1]; + dets.sort((a, b) => key(a) - key(b)); + const songs = []; + let s = null; + for (const d of dets) { + if (s && d[6] === s.cls && f32(d[1] - s.t1) <= gap) { + s.t1 = Math.max(s.t1, d[2]); s.flo = Math.min(s.flo, d[3]); s.fhi = Math.max(s.fhi, d[4]); + s.sum = f32(s.sum + d[5]); s.max = Math.max(s.max, d[5]); s.n++; + } else { + s = { t0: d[1], t1: d[2], flo: d[3], fhi: d[4], sum: d[5], max: d[5], n: 1, cls: d[6] }; + songs.push(s); + } + } + return songs + .map(({ sum, ...b }) => ({ ...b, avg: f32(sum / b.n) })) + .sort((a, b) => a.t0 - b.t0); +} diff --git a/docs/models/All-In-One_fp16.onnx b/docs/models/All-In-One_fp16.onnx new file mode 100644 index 0000000..98bf01d Binary files /dev/null and b/docs/models/All-In-One_fp16.onnx differ diff --git a/docs/models/Just-Bird_fp16.onnx b/docs/models/Just-Bird_fp16.onnx index 4a705b1..ed4d5c5 100644 Binary files a/docs/models/Just-Bird_fp16.onnx and b/docs/models/Just-Bird_fp16.onnx differ diff --git a/docs/models/manifest.json b/docs/models/manifest.json index 5d12cdf..8e2c2c6 100644 --- a/docs/models/manifest.json +++ b/docs/models/manifest.json @@ -10,6 +10,582 @@ "names": { "0": "bird" } + }, + { + "file": "All-In-One_fp16.onnx", + "label": "All-In-One_fp16.onnx", + "merge_method": "reconstruct", + "conf": 0.2, + "song_gap": 0.1, + "species_mapping": "All-In-One", + "names": { + "0": "????", + "1": "amabaw1", + "2": "amapyo1", + "3": "astgna1", + "4": "baffal1", + "5": "barant1", + "6": "bartin2", + "7": "batman1", + "8": "blacar1", + "9": "blbthr1", + "10": "blcbec1", + "11": "blctro1", + "12": "blfant1", + "13": "blfcot1", + "14": "blfjac1", + "15": "blfnun1", + "16": "blgdov1", + "17": "blhpar1", + "18": "bltant2", + "19": "blttro1", + "20": "bobfly1", + "21": "brratt1", + "22": "bsbeye1", + "23": "btfgle1", + "24": "bubgro2", + "25": "bubwre1", + "26": "bucmot4", + "27": "buffal1", + "28": "butsal1", + "29": "butwoo1", + "30": "chwfog1", + "31": "cinmou1", + "32": "cintin1", + "33": "citwoo1", + "34": "coffal1", + "35": "coltro1", + "36": "compot1", + "37": "cowpar1", + "38": "crfgle1", + "39": "ducatt1", + "40": "ducfly", + "41": "ducgre1", + "42": "duhpar", + "43": "dutant2", + "44": "elewoo1", + "45": "eulfly1", + "46": "fasant1", + "47": "fepowl", + "48": "forela1", + "49": "garkin1", + "50": "gilbar1", + "51": "gnbtro1", + "52": "gocspa1", + "53": "goeant1", + "54": "gogwoo1", + "55": "gramou1", + "56": "grasal3", + "57": "grcfly1", + "58": "greant1", + "59": "greibi1", + "60": "gretin1", + "61": "grfdov1", + "62": "gryant1", + "63": "gryant2", + "64": "gycfly1", + "65": "gycwor1", + "66": "hauthr1", + "67": "horscr1", + "68": "letbar1", + "69": "littin1", + "70": "litwoo2", + "71": "lobwoo1", + "72": "lowant1", + "73": "meapar", + "74": "muswre2", + "75": "olioro1", + "76": "oliwoo1", + "77": "partan1", + "78": "pavpig2", + "79": "pirfly1", + "80": "plbwoo1", + "81": "pltant1", + "82": "pluant1", + "83": "plupig2", + "84": "plwant1", + "85": "puteup1", + "86": "putfru1", + "87": "pygant1", + "88": "rcatan1", + "89": "rebmac2", + "90": "renwoo1", + "91": "rinant2", + "92": "rinkin1", + "93": "rinwoo1", + "94": "royfly1", + "95": "ruboro1", + "96": "rucant2", + "97": "rudpig", + "98": "rufant3", + "99": "ruftof1", + "100": "ruqdov", + "101": "scapig2", + "102": "scbwoo5", + "103": "scrpih1", + "104": "sobcac1", + "105": "specha3", + "106": "spigua1", + "107": "spwant2", + "108": "squcuc1", + "109": "stbwoo2", + "110": "strcuc1", + "111": "strwoo2", + "112": "strxen1", + "113": "stwqua1", + "114": "tabsco1", + "115": "thlwre1", + "116": "undtin1", + "117": "viotro3", + "118": "wespuf1", + "119": "whbtot1", + "120": "whcspa1", + "121": "whfant2", + "122": "whltyr1", + "123": "whnrob1", + "124": "whrsir1", + "125": "whttou1", + "126": "whtwoo2", + "127": "whwbec1", + "128": "wibpip1", + "129": "yectyr1", + "130": "yemfly1", + "131": "yercac1", + "132": "yetwoo2", + "133": "hawama", + "134": "ercfra", + "135": "jabwar", + "136": "apapan", + "137": "skylar", + "138": "houfin", + "139": "blkfra", + "140": "yefcan", + "141": "reblei", + "142": "warwhe1", + "143": "kalphe", + "144": "elepai", + "145": "hawhaw", + "146": "melthr", + "147": "iiwi", + "148": "calqua", + "149": "norcar", + "150": "wiltur", + "151": "chukar", + "152": "palila", + "153": "hawcre", + "154": "comwax", + "155": "hawpet1", + "156": "barpet", + "157": "akepa1", + "158": "hawgoo", + "159": "omao", + "160": "aldfly", + "161": "amecro", + "162": "amegfi", + "163": "amered", + "164": "amerob", + "165": "amewoo", + "166": "balori", + "167": "bcnher", + "168": "belkin1", + "169": "bkbwar", + "170": "bkcchi", + "171": "blujay", + "172": "bnhcow", + "173": "boboli", + "174": "brdowl", + "175": "brncre", + "176": "btnwar", + "177": "buhvir", + "178": "buwwar", + "179": "cangoo", + "180": "cedwax", + "181": "chswar", + "182": "comgra", + "183": "comrav", + "184": "comyel", + "185": "coohaw", + "186": "daejun", + "187": "dowwoo", + "188": "easblu", + "189": "easkin", + "190": "easpho", + "191": "eastow", + "192": "eawpew", + "193": "eursta", + "194": "gockin", + "195": "grbher3", + "196": "grcfly", + "197": "grycat", + "198": "haiwoo", + "199": "herthr", + "200": "hoowar", + "201": "houwre", + "202": "killde", + "203": "mallar3", + "204": "moudov", + "205": "naswar", + "206": "norfli", + "207": "norwat", + "208": "ovenbi1", + "209": "pilwoo", + "210": "pinsis", + "211": "purfin", + "212": "rebnut", + "213": "rebwoo", + "214": "redcro", + "215": "reevir1", + "216": "rewbla", + "217": "ribgul", + "218": "robgro", + "219": "ruckin", + "220": "rusbla", + "221": "scatan", + "222": "snogoo", + "223": "solsan", + "224": "sonspa", + "225": "swaspa", + "226": "treswa", + "227": "tuftit", + "228": "tunswa", + "229": "veery", + "230": "warvir", + "231": "whbnut", + "232": "whtspa", + "233": "wooduc", + "234": "woothr", + "235": "yebsap", + "236": "yelwar", + "237": "yerwar", + "238": "yetvir", + "239": "amepip", + "240": "brebla", + "241": "casfin", + "242": "clanut", + "243": "dusfly", + "244": "foxspa", + "245": "gcrfin", + "246": "moublu", + "247": "mouchi", + "248": "orcwar", + "249": "rocwre", + "250": "sposan", + "251": "whcspa", + "252": "acowoo", + "253": "annhum", + "254": "batpig1", + "255": "bewwre", + "256": "bkhgro", + "257": "btywar", + "258": "casvir", + "259": "chbchi", + "260": "evegro", + "261": "gnttow", + "262": "hamfly", + "263": "herwar", + "264": "hutvir", + "265": "lazbun", + "266": "linspa", + "267": "macwar", + "268": "mouqua", + "269": "olsfly", + "270": "pasfly", + "271": "spotow", + "272": "stejay", + "273": "swathr", + "274": "towsol", + "275": "towwar", + "276": "vesspa", + "277": "westan", + "278": "wewpew", + "279": "whhwoo", + "280": "wilsap", + "281": "wlswar" + }, + "common_names": { + "0": "Unknown", + "1": "Amazonian Barred-Woodcreeper", + "2": "Amazonian Pygmy-Owl", + "3": "Ash-throated Gnateater", + "4": "Barred Forest-Falcon", + "5": "Barred Antshrike", + "6": "Bartlett's Tinamou", + "7": "Band-tailed Manakin", + "8": "Black Caracara", + "9": "Black-billed Thrush", + "10": "Black-capped Becard", + "11": "Blue-crowned Trogon", + "12": "Black-faced Antthrush", + "13": "Black-faced Cotinga", + "14": "Bluish-fronted Jacamar", + "15": "Black-fronted Nunbird", + "16": "Blue Ground Dove", + "17": "Blue-headed Parrot", + "18": "Black-throated Antbird", + "19": "Black-tailed Trogon", + "20": "Boat-billed Flycatcher", + "21": "Bright-rumped Attila", + "22": "Black-spotted Bare-eye", + "23": "Buff-throated Foliage-gleaner", + "24": "Amazonian Grosbeak", + "25": "Buff-breasted Wren", + "26": "Amazonian Motmot", + "27": "Buckley's Forest-Falcon", + "28": "Buff-throated Saltator", + "29": "Buff-throated Woodcreeper", + "30": "Chestnut-winged Foliage-gleaner", + "31": "Cinereous Mourner", + "32": "Cinereous Tinamou", + "33": "Cinnamon-throated Woodcreeper", + "34": "Collared Forest-Falcon", + "35": "Collared Trogon", + "36": "Common Potoo", + "37": "Cobalt-winged Parakeet", + "38": "Cinnamon-rumped Foliage-gleaner", + "39": "Dull-capped Attila", + "40": "Dusky-capped Flycatcher", + "41": "Dusky-capped Greenlet", + "42": "Dusky-headed Parakeet", + "43": "Dusky-throated Antshrike", + "44": "Elegant Woodcreeper", + "45": "Euler's Flycatcher", + "46": "Fasciated Antshrike", + "47": "Ferruginous Pygmy-Owl", + "48": "Forest Elaenia", + "49": "Green-and-rufous Kingfisher", + "50": "Gilded Barbet", + "51": "Green-backed Trogon", + "52": "Golden-crowned Spadebill", + "53": "Goeldi's Antbird", + "54": "Golden-green Woodpecker", + "55": "Grayish Mourner", + "56": "Blue-gray Saltator", + "57": "Gray-capped Flycatcher", + "58": "Great Antshrike", + "59": "Green Ibis", + "60": "Great Tinamou", + "61": "Gray-fronted Dove", + "62": "Gray Antwren", + "63": "Gray Antbird", + "64": "Gray-crowned Flycatcher", + "65": "Gray-cowled Wood-Rail", + "66": "Hauxwell's Thrush", + "67": "Horned Screamer", + "68": "Lemon-throated Barbet", + "69": "Little Tinamou", + "70": "Little Woodpecker", + "71": "Long-billed Woodcreeper", + "72": "Long-winged Antwren", + "73": "Mealy Parrot", + "74": "Musician Wren", + "75": "Olive Oropendola", + "76": "Olivaceous Woodcreeper", + "77": "Paradise Tanager", + "78": "Pale-vented Pigeon", + "79": "Piratic Flycatcher", + "80": "Plain-brown Woodcreeper", + "81": "Plain-throated Antwren", + "82": "Plumbeous Antbird", + "83": "Plumbeous Pigeon", + "84": "Plain-winged Antshrike", + "85": "Purple-throated Euphonia", + "86": "Purple-throated Fruitcrow", + "87": "Pygmy Antwren", + "88": "Red-crowned Ant-Tanager", + "89": "Red-bellied Macaw", + "90": "Red-necked Woodpecker", + "91": "Ringed Antpipit", + "92": "Ringed Kingfisher", + "93": "Ringed Woodpecker", + "94": "Royal Flycatcher", + "95": "Russet-backed Oropendola", + "96": "Rufous-capped Antthrush", + "97": "Ruddy Pigeon", + "98": "Rufous-fronted Antthrush", + "99": "Rusty-fronted Tody-Flycatcher", + "100": "Ruddy Quail-Dove", + "101": "Scaled Pigeon", + "102": "Scale-breasted Woodpecker", + "103": "Screaming Piha", + "104": "Solitary Black Cacique", + "105": "Speckled Chachalaca", + "106": "Spix's Guan", + "107": "Spot-winged Antshrike", + "108": "Squirrel Cuckoo", + "109": "Straight-billed Woodcreeper", + "110": "Striped Cuckoo", + "111": "Striped Woodcreeper", + "112": "Streaked Xenops", + "113": "Starred Wood-Quail", + "114": "Tawny-bellied Screech-Owl", + "115": "Thrush-like Wren", + "116": "Undulated Tinamou", + "117": "Amazonian Trogon", + "118": "Western Striolated-Puffbird", + "119": "White-bellied Tody-Tyrant", + "120": "White-crested Spadebill", + "121": "White-flanked Antwren", + "122": "White-lored Tyrannulet", + "123": "White-necked Thrush", + "124": "White-rumped Sirystes", + "125": "White-throated Toucan", + "126": "White-throated Woodpecker", + "127": "White-winged Becard", + "128": "Wing-barred Piprites", + "129": "Yellow-crowned Tyrannulet", + "130": "Yellow-margined Flycatcher", + "131": "Yellow-rumped Cacique", + "132": "Yellow-tufted Woodpecker", + "133": "Hawaii Amakihi", + "134": "Erckel's Francolin", + "135": "Japanese Bush Warbler", + "136": "Apapane", + "137": "Eurasian Skylark", + "138": "House Finch", + "139": "Black Francolin", + "140": "Yellow-fronted Canary", + "141": "Red-billed Leiothrix", + "142": "Warbling White-eye", + "143": "Kalij Pheasant", + "144": "Hawaii Elepaio", + "145": "Hawaiian Hawk", + "146": "Chinese Hwamei", + "147": "Iiwi", + "148": "California Quail", + "149": "Northern Cardinal", + "150": "Wild Turkey", + "151": "Chukar", + "152": "Palila", + "153": "Hawaii Creeper", + "154": "Common Waxbill", + "155": "Hawaiian Petrel", + "156": "Band-rumped Storm-Petrel", + "157": "Hawaii Akepa", + "158": "Hawaiian Goose", + "159": "Omao", + "160": "Alder Flycatcher", + "161": "American Crow", + "162": "American Goldfinch", + "163": "American Redstart", + "164": "American Robin", + "165": "American Woodcock", + "166": "Baltimore Oriole", + "167": "Black-crowned Night-Heron", + "168": "Belted Kingfisher", + "169": "Blackburnian Warbler", + "170": "Black-capped Chickadee", + "171": "Blue Jay", + "172": "Brown-headed Cowbird", + "173": "Bobolink", + "174": "Barred Owl", + "175": "Brown Creeper", + "176": "Black-throated Green Warbler", + "177": "Blue-headed Vireo", + "178": "Blue-winged Warbler", + "179": "Canada Goose", + "180": "Cedar Waxwing", + "181": "Chestnut-sided Warbler", + "182": "Common Grackle", + "183": "Common Raven", + "184": "Common Yellowthroat", + "185": "Cooper's Hawk", + "186": "Dark-eyed Junco", + "187": "Downy Woodpecker", + "188": "Eastern Bluebird", + "189": "Eastern Kingbird", + "190": "Eastern Phoebe", + "191": "Eastern Towhee", + "192": "Eastern Wood-Pewee", + "193": "European Starling", + "194": "Golden-crowned Kinglet", + "195": "Great Blue Heron", + "196": "Great Crested Flycatcher", + "197": "Gray Catbird", + "198": "Hairy Woodpecker", + "199": "Hermit Thrush", + "200": "Hooded Warbler", + "201": "House Wren", + "202": "Killdeer", + "203": "Mallard", + "204": "Mourning Dove", + "205": "Nashville Warbler", + "206": "Northern Flicker", + "207": "Northern Waterthrush", + "208": "Ovenbird", + "209": "Pileated Woodpecker", + "210": "Pine Siskin", + "211": "Purple Finch", + "212": "Red-breasted Nuthatch", + "213": "Red-bellied Woodpecker", + "214": "Red Crossbill", + "215": "Red-eyed Vireo", + "216": "Red-winged Blackbird", + "217": "Ring-billed Gull", + "218": "Rose-breasted Grosbeak", + "219": "Ruby-crowned Kinglet", + "220": "Rusty Blackbird", + "221": "Scarlet Tanager", + "222": "Snow Goose", + "223": "Solitary Sandpiper", + "224": "Song Sparrow", + "225": "Swamp Sparrow", + "226": "Tree Swallow", + "227": "Tufted Titmouse", + "228": "Tundra Swan", + "229": "Veery", + "230": "Warbling Vireo", + "231": "White-breasted Nuthatch", + "232": "White-throated Sparrow", + "233": "Wood Duck", + "234": "Wood Thrush", + "235": "Yellow-bellied Sapsucker", + "236": "Yellow Warbler", + "237": "Yellow-rumped Warbler", + "238": "Yellow-throated Vireo", + "239": "American Pipit", + "240": "Brewer's Blackbird", + "241": "Cassin's Finch", + "242": "Clark's Nutcracker", + "243": "Dusky Flycatcher", + "244": "Fox Sparrow", + "245": "Gray-crowned Rosy-Finch", + "246": "Mountain Bluebird", + "247": "Mountain Chickadee", + "248": "Orange-crowned Warbler", + "249": "Rock Wren", + "250": "Spotted Sandpiper", + "251": "White-crowned Sparrow", + "252": "Acorn Woodpecker", + "253": "Anna's Hummingbird", + "254": "Band-tailed Pigeon", + "255": "Bewick's Wren", + "256": "Black-headed Grosbeak", + "257": "Black-throated Gray Warbler", + "258": "Cassin's Vireo", + "259": "Chestnut-backed Chickadee", + "260": "Evening Grosbeak", + "261": "Green-tailed Towhee", + "262": "Hammond's Flycatcher", + "263": "Hermit Warbler", + "264": "Hutton's Vireo", + "265": "Lazuli Bunting", + "266": "Lincoln's Sparrow", + "267": "MacGillivray's Warbler", + "268": "Mountain Quail", + "269": "Olive-sided Flycatcher", + "270": "Pacific-slope Flycatcher", + "271": "Spotted Towhee", + "272": "Steller's Jay", + "273": "Swainson's Thrush", + "274": "Townsend's Solitaire", + "275": "Townsend's Warbler", + "276": "Vesper Sparrow", + "277": "Western Tanager", + "278": "Western Wood-Pewee", + "279": "White-headed Woodpecker", + "280": "Williamson's Sapsucker", + "281": "Wilson's Warbler" + } } ] } diff --git a/docs/stylesheets/demo.css b/docs/stylesheets/demo.css index e99620a..70cd918 100644 --- a/docs/stylesheets/demo.css +++ b/docs/stylesheets/demo.css @@ -61,6 +61,19 @@ font-size: 0.62rem; white-space: nowrap; } +.bbd-chip__select { + max-width: 14rem; + padding: 0 0.1rem; + border: 0; + background: none; + color: var(--md-default-fg-color); + font: inherit; + font-weight: 600; + text-overflow: ellipsis; + cursor: pointer; +} +.bbd-chip__select:disabled { appearance: none; cursor: default; } +.bbd-chip__select option { background: var(--md-default-bg-color); color: var(--md-default-fg-color); } .bbd-chip__state { color: var(--bbd-verified); font-weight: 600; } .bbd-chip__state::before { content: "· "; color: var(--md-default-fg-color--light); } @@ -305,6 +318,7 @@ .bbd-table td span { display: block; color: var(--md-default-fg-color--light); font-size: 0.58rem; } .md-typeset .bbd-table .bbd-table__empty td { padding: 1.2rem; color: var(--md-default-fg-color--light); text-align: center; } .bbd-c-play { width: 3.6rem; } +.bbd-c-species { max-width: 11rem; overflow-wrap: anywhere; } .bbd-c-act { width: 4rem; text-align: right; white-space: nowrap; } .bbd-play { display: inline-flex; diff --git a/overrides/demo.html b/overrides/demo.html index e430f3d..5cde1fe 100644 --- a/overrides/demo.html +++ b/overrides/demo.html @@ -20,12 +20,14 @@
-

Just-Bird in your browser

+

BirdBox in your browser

BirdBox finds bird vocalizations as time-frequency boxes. The model runs on your device; audio is never uploaded.

- Just-Bird +
@@ -87,9 +89,9 @@

Settings

Boxes must score above this. The slider starts at the model's suggested value.

- + -

Calls in the same frequency band closer than this are joined into one phrase box. 0 keeps single calls.

+

Calls in the same frequency band closer than this are joined into one phrase box. 0 keeps single calls.

The model runs once per recording, so both sliders update the boxes instantly.

@@ -152,6 +154,7 @@

Results

# Time Frequency + Species Confidence Verify or remove diff --git a/src/inference/onnx_export.py b/src/inference/onnx_export.py index 3b2a79c..74101f4 100644 --- a/src/inference/onnx_export.py +++ b/src/inference/onnx_export.py @@ -50,7 +50,9 @@ the graph) and torch (graph operations): * flattop analysis window (``scipy.signal.get_window``) -* STFT, as a strided convolution with a DFT basis (``librosa.stft``) +* STFT, as a strided convolution with a DFT basis (``librosa.stft``). The file + stores one period of the basis's phases; the graph rebuilds the full basis + (15.7 MB of float32) when the model loads. * HTK mel filterbank (``librosa.filters.mel``) * PCEN, as a parallel-prefix EMA instead of ``scipy.signal.lfilter`` * colormap lookup and figure rasterization (``librosa.display.specshow``) @@ -202,6 +204,9 @@ RAW_COL_FILE_END = 8 # end of the file's last clip (window fusion) RAW_COLUMNS = 9 +# BirdDetectionGraph.dft_kernel inside the composed loop body ("b_" prefix) +DFT_KERNEL_NAME = "b_core.dft_kernel" + def _pad_to_clip_length(samples: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """Right-pad with silence until the last clip window is complete. @@ -357,6 +362,60 @@ def dft_convolution_kernel(window: np.ndarray, n_bins: int) -> np.ndarray: return kernel[:, np.newaxis, :] +def dft_kernel_builder(window: np.ndarray, n_bins: int, output_name: str, prefix: str = "dft_"): + """ + ONNX nodes that rebuild ``dft_convolution_kernel(window, n_bins)`` when the + model loads, so the file stores one period of phases instead of the whole + kernel (15.7 MB of float32 for the default settings). + + The phase of bin ``k`` at sample ``n`` only depends on ``(k * n) % n_fft``, + so the kernel is a lookup into one period of cosine and sine values times + the window. Lookup and products run in float64 and are cast to float32 at + the end, as in ``dft_convolution_kernel``, so the rebuilt kernel is + bit-identical to the stored one. The numpy check below makes sure of that. + + Returns: + ``(nodes, initializers)``. The nodes produce ``output_name`` with shape + ``(2 * n_bins, 1, len(window))``. + """ + from onnx import TensorProto, helper, numpy_helper + + n_fft = len(window) + # The expression of dft_convolution_kernel, for one period of indices + phase = 2.0 * np.pi * np.arange(n_fft, dtype=np.int64) / n_fft + cos_table, neg_sin_table = np.cos(phase), -np.sin(phase) + bins = np.arange(n_bins, dtype=np.int64)[:, np.newaxis] + samples = np.arange(n_fft, dtype=np.int64) + index = (bins * samples) % n_fft + rebuilt = np.concatenate([cos_table[index] * window, neg_sin_table[index] * window]) + expected = dft_convolution_kernel(window, n_bins).astype(np.float32) + if not np.array_equal(rebuilt.astype(np.float32)[:, np.newaxis, :], expected): + raise RuntimeError("The DFT kernel lookup does not reproduce dft_convolution_kernel") + + p = prefix + initializers = [ + numpy_helper.from_array(bins, p + "bins"), + numpy_helper.from_array(samples, p + "samples"), + numpy_helper.from_array(np.array(n_fft, dtype=np.int64), p + "n_fft"), + numpy_helper.from_array(cos_table, p + "cos"), + numpy_helper.from_array(neg_sin_table, p + "neg_sin"), + numpy_helper.from_array(np.asarray(window, dtype=np.float64), p + "window"), + numpy_helper.from_array(np.array([1], dtype=np.int64), p + "channel_axis"), + ] + nodes = [ + helper.make_node("Mul", [p + "bins", p + "samples"], [p + "product"]), + helper.make_node("Mod", [p + "product", p + "n_fft"], [p + "index"]), + helper.make_node("Gather", [p + "cos", p + "index"], [p + "cos_at"], axis=0), + helper.make_node("Gather", [p + "neg_sin", p + "index"], [p + "neg_sin_at"], axis=0), + helper.make_node("Mul", [p + "cos_at", p + "window"], [p + "real"]), + helper.make_node("Mul", [p + "neg_sin_at", p + "window"], [p + "imaginary"]), + helper.make_node("Concat", [p + "real", p + "imaginary"], [p + "kernel64"], axis=0), + helper.make_node("Cast", [p + "kernel64"], [p + "kernel32"], to=TensorProto.FLOAT), + helper.make_node("Unsqueeze", [p + "kernel32", p + "channel_axis"], [output_name]), + ] + return nodes, initializers + + def pcen_smoothing_coefficient( sr: int = SAMPLE_RATE, hop_length: int = HOP_LENGTH, @@ -1705,16 +1764,25 @@ def replace_name(graph, old: str, new: str) -> None: ), helper.make_tensor_value_info("conf_out", TensorProto.FLOAT, [1]), ], - list(body_graph.initializer), + [t for t in body_graph.initializer if t.name != DFT_KERNEL_NAME], ) + # The DFT kernel is the largest constant of the graph. The parent graph + # rebuilds it from one period of phases (dft_kernel_builder); the loop body + # reads it from there as an outer-scope value. + kernel_shape = next(t.dims for t in body_graph.initializer if t.name == DFT_KERNEL_NAME) + window = flattop_window(WIN_LENGTH) + if kernel_shape[2] != len(window): + raise RuntimeError(f"Unexpected DFT kernel shape {list(kernel_shape)}") + dft_nodes, dft_initializers = dft_kernel_builder(window, kernel_shape[0] // 2, DFT_KERNEL_NAME) + recon_graph = copy.deepcopy(recon_model.graph) rename_graph_values( recon_graph, "r_", {"song_gap": "song_gap", "detections": "detections"} ) replace_name(recon_graph, "r_raw", "raw_all") - initializers = list(recon_graph.initializer) + [ + initializers = list(recon_graph.initializer) + dft_initializers + [ numpy_helper.from_array(np.array(True), "true_const"), numpy_helper.from_array( np.zeros((0, RAW_COLUMNS), dtype=np.float32), "acc_empty" @@ -1727,6 +1795,7 @@ def replace_name(graph, old: str, new: str) -> None: ] parent_nodes = [ + *dft_nodes, helper.make_node("Shape", ["audio"], ["audio_shape"]), helper.make_node("Gather", ["audio_shape", "zero_i"], ["num_samples"], axis=0), helper.make_node("Add", ["num_samples", "seg_minus_1"], ["num_plus"]), diff --git a/tests/js/demo_fusion_parity.mjs b/tests/js/demo_merge_parity.mjs similarity index 66% rename from tests/js/demo_fusion_parity.mjs rename to tests/js/demo_merge_parity.mjs index 4625fdb..614de66 100644 --- a/tests/js/demo_fusion_parity.mjs +++ b/tests/js/demo_merge_parity.mjs @@ -1,23 +1,26 @@ // SPDX-FileCopyrightText: 2026 BirdNET-Team // SPDX-License-Identifier: MIT /** - * Parity of the Demo tab's window fusion (docs/javascripts/demo/fusion.js) - * with the fusion inside the ONNX graph. + * Parity of the Demo tab's merges with the merge inside the ONNX graph: + * window fusion (docs/javascripts/demo/fusion.js) and song reconstruction + * (docs/javascripts/demo/songs.js). * - * The demo runs docs/models/Just-Bird_fp16.onnx once per recording, asks for - * the per-window boxes (raw_all) and fuses them in JavaScript, so the sliders - * need no new run. This script runs the same ONNX Runtime Web bundle as the - * demo (WASM, single thread), once for raw_all and once per (conf, phrase gap) + * The demo runs a model of docs/models/manifest.json once per recording, asks + * for the per-window boxes (raw_all) and merges them in JavaScript, so the + * sliders need no new run. This script runs the same ONNX Runtime Web bundle + * as the demo (WASM, single thread), once for raw_all and once per (conf, gap) * setting for the graph's own detections, and compares every box. * - * Rerun it after changing the fusion in src/inference/onnx_export.py (or its + * Rerun it after changing the merges in src/inference/onnx_export.py (or the * FUSION_* / PHRASE_* constants), or after exporting a new model. * * Usage (Node 18 or newer): * cd tests/js && npm install - * node demo_fusion_parity.mjs [model.onnx] [audio.wav ...] + * node demo_merge_parity.mjs [model.onnx] [--merge=fusion|reconstruct] [audio.wav ...] * - * Model: default docs/models/Just-Bird_fp16.onnx, the model of the demo. + * Model: default docs/models/Just-Bird_fp16.onnx, the default model of the + * demo. The merge comes from the model's manifest entry (by file name), or + * from --merge for models not in the manifest. * * Audio: 16-bit PCM or float WAV at 32 kHz; default tests/test.wav (5 minutes * of audio, about 8 minutes to run on a laptop). @@ -27,11 +30,18 @@ import { readFileSync } from 'node:fs'; const root = new URL('../../', import.meta.url); const ort = await import(new URL('./node_modules/onnxruntime-web/dist/ort.wasm.min.mjs', import.meta.url)); const { fuseBoxes } = await import(new URL('docs/javascripts/demo/fusion.js', root)); +const { reconstructSongs } = await import(new URL('docs/javascripts/demo/songs.js', root)); const { addGraphOutput, FLOAT } = await import(new URL('docs/javascripts/demo/onnx-graph.js', root)); const args = process.argv.slice(2); -const MODEL = args.find(a => a.endsWith('.onnx')) || new URL('docs/models/Just-Bird_fp16.onnx', root); -const SETTINGS = [0.05, 0.15, 0.3, 0.5].flatMap(conf => [0, 0.05, 0.3, 1].map(gap => [conf, gap])); +const MODEL = args.find(a => a.endsWith('.onnx')) || new URL('docs/models/Just-Bird_fp16.onnx', root).pathname; +const manifest = JSON.parse(readFileSync(new URL('docs/models/manifest.json', root))); +const METHOD = args.find(a => a.startsWith('--merge='))?.slice(8) + || manifest.models.find(m => m.file === MODEL.split('/').pop())?.merge_method; +const MERGE = { fusion: fuseBoxes, reconstruct: reconstructSongs }[METHOD]; +if (!MERGE) throw new Error(`${MODEL}: not in docs/models/manifest.json, pass --merge=fusion or --merge=reconstruct`); +const GAPS = { fusion: [0, 0.05, 0.3, 1], reconstruct: [0, 0.1, 0.3, 1] }[METHOD]; +const SETTINGS = [0.05, 0.15, 0.3, 0.5].flatMap(conf => GAPS.map(gap => [conf, gap])); const COLS = ['t0', 't1', 'flo', 'fhi', 'avg', 'max', 'n', 'cls']; const TOLERANCE = 1e-5; // relative; the port is meant to be exact @@ -67,7 +77,7 @@ ort.env.logLevel = 'error'; const model = addGraphOutput(new Uint8Array(readFileSync(MODEL)), 'raw_all', FLOAT, ['num_raw', 9]); const session = await ort.InferenceSession.create(model, { executionProviders: ['wasm'], graphOptimizationLevel: 'all' }); -const files = args.filter(a => !a.endsWith('.onnx')); +const files = args.filter(a => !a.endsWith('.onnx') && !a.startsWith('--')); if (!files.length) files.push(new URL('tests/test.wav', root).pathname); let failed = 0, total = 0; for (const file of files) { @@ -79,10 +89,10 @@ for (const file of files) { }); // As in the demo: raw_all at the lowest slider confidence const raw = (await session.run(feeds(0.05, 0), ['raw_all'])).raw_all; - console.log(`${file.split('/').pop()}: ${(audio.length / 32000).toFixed(1)} s, ${raw.dims[0]} window boxes`); + console.log(`${file.split('/').pop()}: ${(audio.length / 32000).toFixed(1)} s, ${raw.dims[0]} window boxes, ${METHOD}`); for (const [conf, gap] of SETTINGS) { const ref = (await session.run(feeds(conf, gap), ['detections'])).detections; - const js = fuseBoxes(raw.data, raw.dims[0], conf, gap); + const js = MERGE(raw.data, raw.dims[0], conf, gap); let dev = js.length === ref.dims[0] ? 0 : Infinity; if (dev === 0) js.forEach((b, i) => COLS.forEach((k, j) => { const r = ref.data[i * 8 + j]; @@ -91,7 +101,7 @@ for (const file of files) { total++; const ok = dev <= TOLERANCE; if (!ok) failed++; - console.log(` ${ok ? 'PASS' : 'FAIL'} conf ${conf.toFixed(2)} gap ${gap.toFixed(2)}: graph ${ref.dims[0]} boxes, fusion.js ${js.length}` + (Number.isFinite(dev) ? `, max rel deviation ${dev.toExponential(1)}` : '')); + console.log(` ${ok ? 'PASS' : 'FAIL'} conf ${conf.toFixed(2)} gap ${gap.toFixed(2)}: graph ${ref.dims[0]} boxes, JS ${js.length}` + (Number.isFinite(dev) ? `, max rel deviation ${dev.toExponential(1)}` : '')); } } console.log(`${total - failed}/${total} settings identical`); diff --git a/tests/js/package.json b/tests/js/package.json index 2b9c1dd..9b3d2cb 100644 --- a/tests/js/package.json +++ b/tests/js/package.json @@ -5,7 +5,7 @@ "license": "MIT", "type": "module", "scripts": { - "test": "node demo_fusion_parity.mjs" + "test": "node demo_merge_parity.mjs" }, "devDependencies": { "onnxruntime-web": "1.20.1"