From b17cded6252caf4131586d4d8528d947a5bc9da9 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 7 Sep 2026 14:18:49 +0200 Subject: [PATCH 1/2] Fix frozen single file on large result table --- birdnet_analyzer/analyze/core.py | 2 + birdnet_analyzer/cli.py | 5 +- birdnet_analyzer/gui/single_file.py | 13 +++ birdnet_analyzer/gui/utils.py | 6 +- birdnet_analyzer/lang/de.json | 2 + birdnet_analyzer/lang/en.json | 2 + birdnet_analyzer/lang/fi.json | 2 + birdnet_analyzer/lang/fr.json | 2 + birdnet_analyzer/lang/id.json | 2 + birdnet_analyzer/lang/pt-br.json | 2 + birdnet_analyzer/lang/ru.json | 2 + birdnet_analyzer/lang/se.json | 2 + birdnet_analyzer/lang/tlh.json | 2 + birdnet_analyzer/lang/zh_CN.json | 2 + birdnet_analyzer/lang/zh_TW.json | 2 + birdnet_analyzer/model_utils.py | 19 +++- docs/implementation-details.rst | 1 + .../resumable-analysis.rst | 100 ++++++++++++++++++ docs/usage/cli.rst | 7 ++ docs/usage/gui.rst | 18 ++++ tests/gui/test_single_file_table.py | 91 ++++++++++++++++ tests/test_model_utils.py | 42 ++++++++ 22 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 docs/implementation-details/resumable-analysis.rst create mode 100644 tests/gui/test_single_file_table.py diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index 623647f04..c71b75e25 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -120,11 +120,13 @@ def analyze( effective_sensitivity, run_geomodel, run_inference, + validate_min_conf, ) from birdnet_analyzer.utils import save_params_file # Settled before the params file, result columns and resume fingerprint see it. sensitivity = effective_sensitivity(sensitivity, model, birdnet, classifier) + validate_min_conf(min_conf) species_list_file = slist if isinstance(slist, (str, Path)) else "" rtypes: list[RESULT_TYPES] = [rtype] if isinstance(rtype, str) else rtype diff --git a/birdnet_analyzer/cli.py b/birdnet_analyzer/cli.py index c5ace58a6..06c88ef95 100644 --- a/birdnet_analyzer/cli.py +++ b/birdnet_analyzer/cli.py @@ -349,7 +349,10 @@ def min_conf_args(): "--min_conf", default=0.25, type=lambda a: max(0.00001, min(0.99, float(a))), - help="Minimum confidence threshold. Values in [0.00001, 0.99].", + help="Minimum confidence threshold. Values in [0.00001, 0.99]. Perch " + "confidences are softmax probabilities: simultaneous vocalizations " + "share the probability mass, so dense soundscapes may need a lower " + "threshold than BirdNET models.", ) return p diff --git a/birdnet_analyzer/gui/single_file.py b/birdnet_analyzer/gui/single_file.py index 339a4dd3a..f1b9cba10 100644 --- a/birdnet_analyzer/gui/single_file.py +++ b/birdnet_analyzer/gui/single_file.py @@ -15,6 +15,7 @@ from birdnet_analyzer.gui.state import TabState MATPLOTLIB_FIGURE_NUM = "single-file-tab-spectrogram-plot" +MAX_TABLE_ROWS = 50 HEADER_START_LBL = loc.localize("single-tab-output-header-start") HEADER_END_LBL = loc.localize("single-tab-output-header-end") HEADER_SCI_NAME_LBL = loc.localize("single-tab-output-header-sci-name") @@ -91,6 +92,18 @@ def convert_to_time_str(seconds: float) -> str: return time_str table = predictions.to_dataframe() + n_total = table.shape[0] + + if n_total > MAX_TABLE_ROWS: + table = table.nlargest(MAX_TABLE_ROWS, "confidence").sort_values( + ["start_time", "end_time"] + ) + gr.Warning( + loc.localize("single-tab-results-truncated-warning").format( + shown=MAX_TABLE_ROWS, total=n_total + ) + ) + n_rows = table.shape[0] if n_rows > 0: diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index 7b1dfb6d0..bfd6c9ec7 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -242,9 +242,11 @@ def _show_download_update(update, progress: "gr.Progress | None") -> None: if update.status == "started" and progress is None: gr.Info(label) elif update.status in ("progress", "finished") and progress is not None: - # "progress" is throttled, so only "finished" reliably shows the bar full. if update.status == "finished": - progress(1.0, desc=f"{label} ({_format_bytes(update.bytes_done)})") + progress( + 1.0, + desc=f"{loc.localize('progress-preparing-model')}: {name} ...", + ) elif update.bytes_total: done = _format_bytes(update.bytes_done) total = _format_bytes(update.bytes_total) diff --git a/birdnet_analyzer/lang/de.json b/birdnet_analyzer/lang/de.json index a7afa37b0..9c4f04e3a 100644 --- a/birdnet_analyzer/lang/de.json +++ b/birdnet_analyzer/lang/de.json @@ -233,6 +233,7 @@ "progress-downloading-model": "Modell wird heruntergeladen", "progress-extracting-segments": "Segmente extrahieren", "progress-loading-data": "Daten für", + "progress-preparing-model": "Modell wird vorbereitet", "progress-saving": "Gespeichert unter", "progress-search": "Dateien suchen", "progress-starting": "Starten", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "Ende", "single-tab-output-header-sci-name": "Wissenschaftlicher Name", "single-tab-output-header-start": "Start", + "single-tab-results-truncated-warning": "Angezeigt werden die {shown} Erkennungen mit den höchsten Werten von insgesamt {total}. Die vollständigen Ergebnisse gibt es über die Download-Buttons.", "single-tab-select-file-button-label": "Audiodatei auswählen", "single-tab-spectrogram-checkbox-info": "Potentiell langsam für sehr lange Dateien.", "single-tab-spectrogram-checkbox-label": "Spektrogramm generieren", diff --git a/birdnet_analyzer/lang/en.json b/birdnet_analyzer/lang/en.json index 70d357f8a..9228cc853 100644 --- a/birdnet_analyzer/lang/en.json +++ b/birdnet_analyzer/lang/en.json @@ -233,6 +233,7 @@ "progress-downloading-model": "Downloading model", "progress-extracting-segments": "Extracting segments", "progress-loading-data": "Loading data for", + "progress-preparing-model": "Preparing model", "progress-saving": "Saving at", "progress-search": "Searching files", "progress-starting": "Starting", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "End", "single-tab-output-header-sci-name": "Scientific name", "single-tab-output-header-start": "Start", + "single-tab-results-truncated-warning": "Showing the {shown} highest-scoring of {total} detections. Use the download buttons for the complete results.", "single-tab-select-file-button-label": "Select Audio File", "single-tab-spectrogram-checkbox-info": "Potentially slow for long audio files.", "single-tab-spectrogram-checkbox-label": "Generate spectrogram", diff --git a/birdnet_analyzer/lang/fi.json b/birdnet_analyzer/lang/fi.json index d7c62df5e..ccc60180d 100644 --- a/birdnet_analyzer/lang/fi.json +++ b/birdnet_analyzer/lang/fi.json @@ -233,6 +233,7 @@ "progress-downloading-model": "Ladataan mallia", "progress-extracting-segments": "Segmenttejä puretaan", "progress-loading-data": "Ladataan dataa kohteelle", + "progress-preparing-model": "Valmistellaan mallia", "progress-saving": "Tallennetaan kohteeseen", "progress-search": "Haetaan tiedostoja", "progress-starting": "Aloitetaan", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "Loppu", "single-tab-output-header-sci-name": "Tieteellinen nimi", "single-tab-output-header-start": "Alku", + "single-tab-results-truncated-warning": "Näytetään {shown} korkeimman pistemäärän havaintoa {total} havainnosta. Täydelliset tulokset saat latauspainikkeilla.", "single-tab-select-file-button-label": "Valitse äänitiedosto", "single-tab-spectrogram-checkbox-info": "Saattaa olla hidasta pitkillä äänitiedostoilla.", "single-tab-spectrogram-checkbox-label": "Luo spektrogrammi", diff --git a/birdnet_analyzer/lang/fr.json b/birdnet_analyzer/lang/fr.json index 60335e869..ec3acd046 100644 --- a/birdnet_analyzer/lang/fr.json +++ b/birdnet_analyzer/lang/fr.json @@ -233,6 +233,7 @@ "progress-downloading-model": "Téléchargement du modèle", "progress-extracting-segments": "Extraction des segments", "progress-loading-data": "Chargement des données pour", + "progress-preparing-model": "Préparation du modèle", "progress-saving": "Enregistrement à", "progress-search": "Recherche des fichier...", "progress-starting": "Démarrage...", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "Arrêter", "single-tab-output-header-sci-name": "Nom scientifique", "single-tab-output-header-start": "Commencer", + "single-tab-results-truncated-warning": "Affichage des {shown} détections aux scores les plus élevés sur {total}. Utilisez les boutons de téléchargement pour les résultats complets.", "single-tab-select-file-button-label": "Sélectionner un fichier audio", "single-tab-spectrogram-checkbox-info": "Potentiellement lent pour les fichiers audio longs.", "single-tab-spectrogram-checkbox-label": "Générer le spectrogramme", diff --git a/birdnet_analyzer/lang/id.json b/birdnet_analyzer/lang/id.json index 8fe001ae5..d75c85801 100644 --- a/birdnet_analyzer/lang/id.json +++ b/birdnet_analyzer/lang/id.json @@ -233,6 +233,7 @@ "progress-downloading-model": "Mengunduh model", "progress-extracting-segments": "Mengekstrak segmen", "progress-loading-data": "Memuat data untuk", + "progress-preparing-model": "Menyiapkan model", "progress-saving": "Menyimpan di", "progress-search": "Mencari file", "progress-starting": "Memulai", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "Selesai", "single-tab-output-header-sci-name": "Nama ilmiah", "single-tab-output-header-start": "Mulai", + "single-tab-results-truncated-warning": "Menampilkan {shown} deteksi dengan skor tertinggi dari {total}. Gunakan tombol unduh untuk hasil lengkap.", "single-tab-select-file-button-label": "Pilih Berkas Audio", "single-tab-spectrogram-checkbox-info": "Berpotensi lambat untuk file audio yang panjang.", "single-tab-spectrogram-checkbox-label": "Hasilkan spektrogram", diff --git a/birdnet_analyzer/lang/pt-br.json b/birdnet_analyzer/lang/pt-br.json index fd92e5c2e..2abe9e1c2 100644 --- a/birdnet_analyzer/lang/pt-br.json +++ b/birdnet_analyzer/lang/pt-br.json @@ -233,6 +233,7 @@ "progress-downloading-model": "Baixando modelo", "progress-extracting-segments": "Extraindo segmentos", "progress-loading-data": "Carregando os dados para", + "progress-preparing-model": "Preparando modelo", "progress-saving": "slvando em", "progress-search": "Procurando arquivos", "progress-starting": "Começando", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "Fim", "single-tab-output-header-sci-name": "Nome científico", "single-tab-output-header-start": "Início", + "single-tab-results-truncated-warning": "Exibindo as {shown} detecções com maior pontuação de {total}. Use os botões de download para os resultados completos.", "single-tab-select-file-button-label": "Selecionar Arquivo de Áudio", "single-tab-spectrogram-checkbox-info": "Pode ser lento para arquivos de áudio longos.", "single-tab-spectrogram-checkbox-label": "Gerar espectrograma", diff --git a/birdnet_analyzer/lang/ru.json b/birdnet_analyzer/lang/ru.json index e52e09b6f..ede4cf134 100644 --- a/birdnet_analyzer/lang/ru.json +++ b/birdnet_analyzer/lang/ru.json @@ -233,6 +233,7 @@ "progress-downloading-model": "Загрузка модели", "progress-extracting-segments": "Извлечение сегментов", "progress-loading-data": "Загрузка данных для", + "progress-preparing-model": "Подготовка модели", "progress-saving": "Сохранение в", "progress-search": "Поиск файлов", "progress-starting": "Начать", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "Конец (сек)", "single-tab-output-header-sci-name": "Научное название", "single-tab-output-header-start": "Старт (сек)", + "single-tab-results-truncated-warning": "Показаны {shown} обнаружений с наивысшими оценками из {total}. Полные результаты доступны по кнопкам скачивания.", "single-tab-select-file-button-label": "Выбрать аудиофайл", "single-tab-spectrogram-checkbox-info": "Потенциально медленная операция для длинных аудиофайлов.", "single-tab-spectrogram-checkbox-label": "Генерирование спектрограммы", diff --git a/birdnet_analyzer/lang/se.json b/birdnet_analyzer/lang/se.json index aee5d7c4b..e7e526857 100644 --- a/birdnet_analyzer/lang/se.json +++ b/birdnet_analyzer/lang/se.json @@ -233,6 +233,7 @@ "progress-downloading-model": "Laddar ner modell", "progress-extracting-segments": "Extraherar segment", "progress-loading-data": "Laddar data för", + "progress-preparing-model": "Förbereder modell", "progress-saving": "Sparar i", "progress-search": "Söker filer", "progress-starting": "Startar", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "Slut", "single-tab-output-header-sci-name": "Vetenskapligt namn", "single-tab-output-header-start": "Start", + "single-tab-results-truncated-warning": "Visar de {shown} detektioner med högst poäng av {total}. Använd nedladdningsknapparna för de fullständiga resultaten.", "single-tab-select-file-button-label": "Välj ljudfil", "single-tab-spectrogram-checkbox-info": "Kan vara långsamt för långa ljudfiler.", "single-tab-spectrogram-checkbox-label": "Generera spektrogram", diff --git a/birdnet_analyzer/lang/tlh.json b/birdnet_analyzer/lang/tlh.json index 2b2f44899..60da7bcf4 100644 --- a/birdnet_analyzer/lang/tlh.json +++ b/birdnet_analyzer/lang/tlh.json @@ -233,6 +233,7 @@ "progress-downloading-model": "model'e' Downloadlu'", "progress-extracting-segments": "'ay'mey lInglu'", "progress-loading-data": "De' poQ", + "progress-preparing-model": "model'e' ghuHlu'", "progress-saving": "pol", "progress-search": "wavmey qel", "progress-starting": "tagh", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "Dor (s)", "single-tab-output-header-sci-name": "Hol qonwI' pong", "single-tab-output-header-start": "Tagh (s)", + "single-tab-results-truncated-warning": "{total} Sammey'e' {shown} nIvbogh mI'mey neH cha'lu'. Hoch Sammey DaneHchugh Download leQmey yIlo'.", "single-tab-select-file-button-label": "QoQ teywI' yIwIv", "single-tab-spectrogram-checkbox-info": "wavmey tInmo' QapHa'laH.", "single-tab-spectrogram-checkbox-label": "nuvmey vItlhutlh", diff --git a/birdnet_analyzer/lang/zh_CN.json b/birdnet_analyzer/lang/zh_CN.json index d7e434edc..8c29641e2 100644 --- a/birdnet_analyzer/lang/zh_CN.json +++ b/birdnet_analyzer/lang/zh_CN.json @@ -233,6 +233,7 @@ "progress-downloading-model": "正在下载模型", "progress-extracting-segments": "正在提取片段", "progress-loading-data": "正在为以下内容加载数据", + "progress-preparing-model": "正在准备模型", "progress-saving": "正在保存至", "progress-search": "正在搜索文件", "progress-starting": "正在开始", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "结束", "single-tab-output-header-sci-name": "学名", "single-tab-output-header-start": "开始", + "single-tab-results-truncated-warning": "仅显示 {total} 条检测结果中分数最高的 {shown} 条。完整结果请使用下载按钮。", "single-tab-select-file-button-label": "选择音频文件", "single-tab-spectrogram-checkbox-info": "对于较长的音频文件可能较慢。", "single-tab-spectrogram-checkbox-label": "生成频谱图", diff --git a/birdnet_analyzer/lang/zh_TW.json b/birdnet_analyzer/lang/zh_TW.json index fec203acb..4d28b0f63 100644 --- a/birdnet_analyzer/lang/zh_TW.json +++ b/birdnet_analyzer/lang/zh_TW.json @@ -233,6 +233,7 @@ "progress-downloading-model": "正在下載模型", "progress-extracting-segments": "正在擷取片段", "progress-loading-data": "載入資料中", + "progress-preparing-model": "正在準備模型", "progress-saving": "儲存中", "progress-search": "找尋檔案中", "progress-starting": "正在開始", @@ -334,6 +335,7 @@ "single-tab-output-header-end": "結束", "single-tab-output-header-sci-name": "學名", "single-tab-output-header-start": "開始", + "single-tab-results-truncated-warning": "僅顯示 {total} 筆偵測結果中分數最高的 {shown} 筆。完整結果請使用下載按鈕。", "single-tab-select-file-button-label": "選擇音訊檔案", "single-tab-spectrogram-checkbox-info": "對於長音頻文件可能較慢。", "single-tab-spectrogram-checkbox-label": "產生頻譜圖", diff --git a/birdnet_analyzer/model_utils.py b/birdnet_analyzer/model_utils.py index 545d65971..ac7dbe6d1 100644 --- a/birdnet_analyzer/model_utils.py +++ b/birdnet_analyzer/model_utils.py @@ -316,8 +316,8 @@ def supports_sensitivity( BirdNET 2.4 and custom classifiers (which run on the 2.4 base) do. BirdNET 3.0 applies the sigmoid inside the model graph, and the birdnet library rejects a - sensitivity other than 1.0 for it. Perch outputs raw logits and is run without a - sigmoid here (its scores are not probabilities), so sensitivity does not apply. + sensitivity other than 1.0 for it. Perch outputs logits that are normalized + with a softmax (not a sigmoid), so sensitivity does not apply. Newer BirdNET versions are assumed to behave like 3.0 until known otherwise. """ if classifier: @@ -344,6 +344,20 @@ def effective_sensitivity( return 1.0 +def validate_min_conf(min_conf: float) -> None: + """Rejects confidence thresholds outside [0, 1). + + Every model's scores are normalized to probabilities (sigmoid for BirdNET + and custom classifiers, softmax for Perch), so a threshold of 1 or more + would silently discard every detection. + """ + if not 0 <= min_conf < 1: + raise ValueError( + f"min_conf {min_conf} is out of range: confidence scores are " + "probabilities, so the threshold must lie in [0, 1)." + ) + + def run_inference( path, model="birdnet", @@ -433,6 +447,7 @@ def run_inference( n_workers=n_workers, n_producers=n_producers, apply_sigmoid=model != "perch", + apply_softmax=model == "perch", max_n_files=len(input_files), on_file_complete=on_file_complete, ) as session: diff --git a/docs/implementation-details.rst b/docs/implementation-details.rst index c0e42726e..e3e06c938 100644 --- a/docs/implementation-details.rst +++ b/docs/implementation-details.rst @@ -9,3 +9,4 @@ Implementation details implementation-details/training-hyperparameters implementation-details/segment-collection-mode implementation-details/sensitivity + implementation-details/resumable-analysis diff --git a/docs/implementation-details/resumable-analysis.rst b/docs/implementation-details/resumable-analysis.rst new file mode 100644 index 000000000..4ea47eec2 --- /dev/null +++ b/docs/implementation-details/resumable-analysis.rst @@ -0,0 +1,100 @@ +.. _resumable-analysis: + +Resumable Analysis +================== + +When you analyze a directory of audio files, BirdNET-Analyzer keeps a crash-safe +journal of its progress. If the run is interrupted (by a crash, a power loss, a closed +window, or the **Pause** button in the GUI), you can continue it later instead of +starting over: files that were already analyzed are skipped, and their stored +detections are combined with the fresh results as if the run had never stopped. + +How to resume a run +------------------- + +Progress is stored in a hidden ``.birdnet-resume`` folder inside the output +directory (or inside the input directory, if no separate output directory was +chosen). To continue an interrupted run, simply start the same analysis again: + +* **CLI**: re-run the same ``birdnet_analyzer.analyze`` command with the same + arguments and output folder. +* **GUI**: in the batch analysis tab, select the same input and output folders. A + status line shows how many files were already completed and the start button + changes to **Continue analysis**. + +Files that were already analyzed are skipped, the remaining files are processed, and +the output files are written for the complete set. After a successful run the +journal folder is deleted, so a finished analysis leaves nothing behind. + +Which settings have to match +---------------------------- + +The journal records a fingerprint of every setting that affects *which detections +are produced*: the input directory, the model and its version, a custom classifier, +minimum confidence, top-N, sensitivity, overlap, the bandpass frequencies, audio +speed, latitude/longitude/week, a custom species list, the species filter threshold, +and the species-name language. + +A run only continues if all of these match the interrupted run. If any of them +changed, the stored progress is discarded and the analysis starts from the beginning +— results produced with different settings are never mixed. + +.. attention:: + + The GUI does not restore the settings of the interrupted run when you click + **Continue analysis** — it uses whatever is currently set in the interface. If + you changed one of the settings listed above in the meantime, the saved progress + is discarded and the analysis silently starts over. Loading the ``*-params.csv`` + file written next to the results restores the original settings. + +Settings that only affect how results are *formatted* may change freely between the +interrupted run and its resume: the output formats, additional columns, table +splitting, merging of consecutive detections, batch size, and the number of workers +and producers. The journal stores raw detections, so the outputs are always written +with the settings of the resuming run. + +Changed and added files +----------------------- + +Each stored result is keyed on the input file's path *and* its size and modification +time. A file that changed on disk since it was first analyzed — edited, regenerated, +or merely touched — is re-analyzed instead of reusing the stale result. Files added +to the input directory are picked up and analyzed; stored results for files that no +longer exist are ignored. + +How it works internally +----------------------- + +The implementation lives in ``birdnet_analyzer/analyze/resume.py``. The journal +directory contains: + +* ``manifest.json`` — the parameter fingerprint (a hash over the detection-relevant + settings), a human-readable snapshot of those settings, the total file count, and + the model metadata needed to write outputs when a resume finds no work left. +* ``results/.parquet`` — one file per completed input file, holding that file's + detections. ```` hashes the input path plus its size and modification time. + Files the inference library reported as unprocessable are stored with an + ``.invalid.parquet`` suffix so a resume does not retry them. + +While an analysis runs, the ``birdnet`` library invokes an ``on_file_complete`` +callback after each finished file, off the inference hot path. The callback writes +the file's detections to a temporary name and moves it into place atomically, so a +result file's presence *is* the "this file is done" marker — a crash can never leave +a half-written result that a resume would trust. The callback never raises: a +persistence problem (a full disk, say) costs the resumability of that one file, not +the running analysis. + +On the next run, ``analyze()`` opens the journal before inference starts. A journal +whose fingerprint does not match the current settings is wiped and replaced. With a +matching fingerprint, the completed files are removed from the inference input, the +remaining files are analyzed (journaled the same way), and the stored detections are +merged with the fresh ones in input order — so outputs that accumulate offsets +across files, like the combined Raven table, come out exactly as they would from an +uninterrupted run. If every file was already completed, no inference happens at all +and the outputs are rebuilt from the journal alone, using the model metadata saved +in the manifest. Once the outputs are written, the journal is deleted. + +The GUI builds on the same mechanism: **Pause** cancels the running inference +session while leaving the journal in place, and selecting the input/output folders +calls ``ResumeJournal.inspect()`` to display the paused progress and relabel the +start button. diff --git a/docs/usage/cli.rst b/docs/usage/cli.rst index 59a6afd80..29d42581c 100644 --- a/docs/usage/cli.rst +++ b/docs/usage/cli.rst @@ -25,6 +25,13 @@ birdnet_analyzer.analyze python3 -m birdnet_analyzer.analyze example/ --lat 42.5 --lon -76.45 --week 4 --sensitivity 1.0 + Directory analyses are resumable: progress is saved continuously to a + ``.birdnet-resume`` folder in the output directory, so an interrupted run (crash, + power loss, Ctrl+C) can be continued by re-running the same command — files that + were already analyzed are skipped. The settings that affect the detections have to + match the interrupted run, otherwise the analysis starts over; see + :ref:`resumable-analysis` for details. + birdnet_analyzer.embeddings --------------------------- diff --git a/docs/usage/gui.rst b/docs/usage/gui.rst index 6c9fcd552..6124610ae 100644 --- a/docs/usage/gui.rst +++ b/docs/usage/gui.rst @@ -17,6 +17,24 @@ For more information about the command line arguments, please refer to the :ref: `Alternatively download the installer to run the GUI on your system`. +Pausing and resuming a batch analysis +------------------------------------- + +A running batch analysis can be interrupted with the **Pause** button — the progress +made so far is kept in the output folder. To continue later (even after closing the +GUI), select the same input and output folders again: a status line shows how many +files were already analyzed and the start button changes to **Continue analysis**. +The same happens after a crash or power loss, since progress is saved continuously +while the analysis runs. + +Continuing uses the settings *currently set in the GUI*, not the ones the +interrupted run started with. Settings that affect the detections — the model, +minimum confidence, species list, and so on — must therefore be left unchanged (or +restored by loading the run's ``*-params.csv`` file); if one of them differs, the +saved progress is discarded and the analysis starts over. Output settings such as +the output format or additional columns may be changed freely. See +:ref:`resumable-analysis` for the details. + Segment review -------------- diff --git a/tests/gui/test_single_file_table.py b/tests/gui/test_single_file_table.py new file mode 100644 index 000000000..3d3790880 --- /dev/null +++ b/tests/gui/test_single_file_table.py @@ -0,0 +1,91 @@ +"""The single-file result table renders only the top detections. + +The gradio dataframe blocks the webview for minutes once a result has a few +hundred rows, so the handler caps the table at the highest-confidence +detections and points at the download buttons for the rest. +""" + +import pandas as pd +import pytest + +gr = pytest.importorskip("gradio") + +import birdnet_analyzer.gui.analysis as ga # noqa: E402 +import birdnet_analyzer.gui.single_file as sfa # noqa: E402 + + +class FakePredictions: + def __init__(self, df): + self._df = df + + def to_dataframe(self): + return self._df.copy() + + +def make_result(n_rows): + return FakePredictions( + pd.DataFrame( + { + "input": ["a.wav"] * n_rows, + "start_time": [float(3 * i) for i in range(n_rows)], + "end_time": [float(3 * i + 3) for i in range(n_rows)], + "species_name": [f"Sci{i}_Common{i}" for i in range(n_rows)], + "confidence": [(i + 1) / n_rows for i in range(n_rows)], + } + ) + ) + + +def run(monkeypatch, n_rows): + warnings = [] + monkeypatch.setattr(gr, "Warning", lambda msg, **kw: warnings.append(msg)) + monkeypatch.setattr(ga, "run_analysis", lambda **kw: make_result(n_rows)) + + table, _, _ = sfa.run_single_file_analysis( + "a.wav", + False, + 5, + 0.25, + 1.0, + 0.0, + 1, + 1.0, + 0, + 15000, + "all", + None, + -1, + -1, + -1, + True, + 0.03, + "BirdNET 3.0", + None, + "en_us", + ) + return table, warnings + + +def test_small_results_are_shown_in_full(monkeypatch): + table, warnings = run(monkeypatch, 10) + + assert table.shape[0] == 10 + assert not warnings + + +def test_large_results_keep_top_confidences_in_time_order(monkeypatch): + n_rows = 4 * sfa.MAX_TABLE_ROWS + table, warnings = run(monkeypatch, n_rows) + + assert table.shape[0] == sfa.MAX_TABLE_ROWS + + confidences = table[sfa.HEADER_CONFIDENCE_LBL].astype(float) + cutoff = (n_rows - sfa.MAX_TABLE_ROWS) / n_rows + assert (confidences > cutoff).all(), "only the highest-scoring rows remain" + + starts = list(table[sfa.HEADER_START_LBL]) + assert starts == sorted(starts), "rows stay in chronological order" + + assert len(warnings) == 1 + assert str(sfa.MAX_TABLE_ROWS) in warnings[0] + assert str(n_rows) in warnings[0] diff --git a/tests/test_model_utils.py b/tests/test_model_utils.py index 9dd98fd24..dee71a10b 100644 --- a/tests/test_model_utils.py +++ b/tests/test_model_utils.py @@ -1,5 +1,7 @@ """Tests for analysis session pause/cancel behavior in model_utils.""" +import pytest + from birdnet_analyzer import model_utils @@ -72,6 +74,46 @@ def test_supports_sensitivity_only_for_2_4_based_models(): assert not model_utils.supports_sensitivity("perch") +def test_validate_min_conf_rejects_non_probabilities(): + model_utils.validate_min_conf(0.25) + model_utils.validate_min_conf(0.0) # the GUI's top-n path passes 0 + + with pytest.raises(ValueError, match="probabilities"): + model_utils.validate_min_conf(1.0) + + with pytest.raises(ValueError, match="probabilities"): + model_utils.validate_min_conf(-0.1) + + +def test_run_inference_normalizes_perch_with_softmax(monkeypatch, tmp_path): + from contextlib import contextmanager + from unittest.mock import MagicMock + + seen = {} + + @contextmanager + def fake_predict_session(**kwargs): + seen.update(kwargs) + session = MagicMock() + session.run.return_value = "result" + yield session + + fake_model = MagicMock() + fake_model.predict_session = fake_predict_session + monkeypatch.setattr( + model_utils.birdnet, "load_perch_v2", lambda *a, **k: fake_model + ) + + audio = tmp_path / "a.wav" + audio.write_bytes(b"") + + result = model_utils.run_inference(str(audio), model="perch") + + assert result == "result" + assert seen["apply_softmax"] is True + assert seen["apply_sigmoid"] is False + + def test_run_inference_drops_sensitivity_for_3_0(monkeypatch, tmp_path): from contextlib import contextmanager from unittest.mock import MagicMock From 1b9db32994f866dcc3d51ba04106c74342401ef9 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 7 Sep 2026 15:01:57 +0200 Subject: [PATCH 2/2] Convert to float32 befor .nlargest --- birdnet_analyzer/gui/single_file.py | 2 ++ tests/gui/test_single_file_table.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/birdnet_analyzer/gui/single_file.py b/birdnet_analyzer/gui/single_file.py index f1b9cba10..af5bfc3c6 100644 --- a/birdnet_analyzer/gui/single_file.py +++ b/birdnet_analyzer/gui/single_file.py @@ -95,6 +95,8 @@ def convert_to_time_str(seconds: float) -> str: n_total = table.shape[0] if n_total > MAX_TABLE_ROWS: + # nlargest has no float16 kernel + table["confidence"] = table["confidence"].astype("float32") table = table.nlargest(MAX_TABLE_ROWS, "confidence").sort_values( ["start_time", "end_time"] ) diff --git a/tests/gui/test_single_file_table.py b/tests/gui/test_single_file_table.py index 3d3790880..51d017eb5 100644 --- a/tests/gui/test_single_file_table.py +++ b/tests/gui/test_single_file_table.py @@ -5,6 +5,7 @@ detections and points at the download buttons for the rest. """ +import numpy as np import pandas as pd import pytest @@ -30,7 +31,10 @@ def make_result(n_rows): "start_time": [float(3 * i) for i in range(n_rows)], "end_time": [float(3 * i + 3) for i in range(n_rows)], "species_name": [f"Sci{i}_Common{i}" for i in range(n_rows)], - "confidence": [(i + 1) / n_rows for i in range(n_rows)], + # float16 like the library's half_precision output + "confidence": np.array( + [(i + 1) / n_rows for i in range(n_rows)], dtype=np.float16 + ), } ) )