From 38f22f4e118d15320b13aa9a033ae6da8fe2d0ae Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Fri, 17 Jul 2026 10:27:04 +0200 Subject: [PATCH] Continue interrupted analysis --- birdnet_analyzer/analyze/core.py | 181 ++++++++++++---- birdnet_analyzer/analyze/resume.py | 328 +++++++++++++++++++++++++++++ birdnet_analyzer/gui/multi_file.py | 191 +++++++++++++---- birdnet_analyzer/lang/de.json | 3 + birdnet_analyzer/lang/en.json | 3 + birdnet_analyzer/lang/fi.json | 3 + birdnet_analyzer/lang/fr.json | 3 + birdnet_analyzer/lang/id.json | 3 + birdnet_analyzer/lang/pt-br.json | 3 + birdnet_analyzer/lang/ru.json | 3 + birdnet_analyzer/lang/se.json | 3 + birdnet_analyzer/lang/tlh.json | 3 + birdnet_analyzer/lang/zh_TW.json | 3 + birdnet_analyzer/model_utils.py | 40 ++++ tests/analyze/test_resume.py | 265 +++++++++++++++++++++++ tests/test_model_utils.py | 36 ++++ 16 files changed, 988 insertions(+), 83 deletions(-) create mode 100644 birdnet_analyzer/analyze/resume.py create mode 100644 tests/analyze/test_resume.py create mode 100644 tests/test_model_utils.py diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index 727b8046c..3d3fd7c7d 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -12,11 +12,9 @@ from birdnet.acoustic.inference.core.perf_tracker import ( AcousticProgressStats, ) - from birdnet.acoustic.inference.core.prediction.prediction_result import ( - AcousticResultBase, - ) from birdnet.globals import ACOUSTIC_MODEL_VERSIONS, MODEL_LANGUAGES + from birdnet_analyzer.analyze.resume import RunMetadata from birdnet_analyzer.config import ADDITIONAL_COLUMNS, RESULT_TYPES @@ -103,13 +101,50 @@ def analyze( Notes: - The function ensures the BirdNET model is available before analysis. - Analysis parameters are saved to a file in the output directory. + - Directory analyses are resumable: per-file results are journaled to + ``/.birdnet-resume/`` as they complete, an interrupted run + picks up where it left off (if the analysis parameters match), and + the journal is removed after outputs are written. When resuming a + run that had already completed every file, no new inference happens + and ``None`` is returned instead of a prediction result. """ import birdnet_analyzer.config as cfg - from birdnet_analyzer.model_utils import run_geomodel, run_inference + from birdnet_analyzer.analyze.resume import ResumeJournal, RunMetadata + from birdnet_analyzer.model_utils import ( + run_geomodel, + run_inference, + supports_on_file_complete, + ) from birdnet_analyzer.utils import save_params_to_file species_list_file = slist if isinstance(slist, (str, Path)) else "" rtypes: list[RESULT_TYPES] = [rtype] if isinstance(rtype, str) else rtype + resume_params = _resume_fingerprint_params( + audio_input=audio_input, + model=model, + birdnet=birdnet, + min_conf=min_conf, + classifier=classifier, + cc_species_list=cc_species_list, + lat=lat, + lon=lon, + week=week, + slist=slist, + sensitivity=sensitivity, + overlap=overlap, + fmin=fmin, + fmax=fmax, + audio_speed=audio_speed, + top_n=top_n, + sf_thresh=sf_thresh, + locale=locale, + ) + + if not output: + if os.path.isfile(audio_input): + output = os.path.dirname(audio_input) + else: + output = audio_input if lat is not None and lon is not None: if slist is not None: @@ -122,43 +157,76 @@ def analyze( lat, lon, week=week, language=locale, threshold=sf_thresh ).to_set() - predictions = run_inference( - audio_input, - model=model, - top_k=top_n, - batch_size=batch_size, - prefetch_ratio=3, - overlap_duration_s=overlap, - bandpass_fmin=fmin, - bandpass_fmax=fmax, - sigmoid_sensitivity=sensitivity, - speed=audio_speed, - min_confidence=min_conf, - custom_species_list=slist, - label_language=locale, - classifier=classifier, - cc_species_list=cc_species_list, - version=birdnet, - callback=on_update, - n_workers=n_workers, - n_producers=n_producers, - ) + # For directory analyses, keep a crash-safe journal of per-file results in + # the output directory so an interrupted run can be resumed: files that + # already have a stored result are skipped, everything else is analyzed and + # persisted as it completes. + journal = None + input_files: list[Path] = [] + inference_input = audio_input + + if not _return_only and os.path.isdir(audio_input) and supports_on_file_complete(): + from birdnet.acoustic.inference.configs import InferenceConfig + + input_files = InferenceConfig.validate_input_files(audio_input) + journal = ResumeJournal.open( + output, resume_params, n_files_total=len(input_files) + ) + completed = journal.completed_subset(input_files) + inference_input = [f for f in input_files if f not in completed] + + predictions = None + + if inference_input: + predictions = run_inference( + inference_input, + model=model, + top_k=top_n, + batch_size=batch_size, + prefetch_ratio=3, + overlap_duration_s=overlap, + bandpass_fmin=fmin, + bandpass_fmax=fmax, + sigmoid_sensitivity=sensitivity, + speed=audio_speed, + min_confidence=min_conf, + custom_species_list=slist, + label_language=locale, + classifier=classifier, + cc_species_list=cc_species_list, + version=birdnet, + callback=on_update, + n_workers=n_workers, + n_producers=n_producers, + on_file_complete=journal.on_file_complete if journal else None, + ) if _return_only: return predictions + if predictions is not None: + meta = RunMetadata.from_result(predictions) + df = predictions.to_dataframe() + else: + # Everything was already completed by an interrupted run; outputs are + # built entirely from the journal. + meta = journal.metadata() if journal else None + df = None + + if meta is None: + raise RuntimeError( + "Resume state in the output directory is incomplete. Delete " + f"'{Path(output) / '.birdnet-resume'}' and run the analysis again." + ) + + if journal is not None: + df = journal.combined_dataframe(df, input_files) + audio_input_path: Path = Path(audio_input) - df = predictions.to_dataframe() df = _merge_consecutive_segments( - df, merge_consecutive, hop_size=predictions.hop_duration_s + df, merge_consecutive, hop_size=meta.hop_duration_s ) - if not output: - if os.path.isfile(audio_input): - output = os.path.dirname(audio_input) - else: - output = audio_input - if split_tables: _split_tables( df, @@ -166,7 +234,7 @@ def analyze( Path(output), fmin, fmax, - predictions, + meta, audio_speed, rtypes, additional_columns, @@ -184,8 +252,8 @@ def analyze( df, fmin, fmax, - predictions.model_fmin, - predictions.model_fmax, + meta.model_fmin, + meta.model_fmax, audio_speed, Path(output) / cfg.OUTPUT_RAVEN_FILENAME, ) @@ -202,7 +270,7 @@ def analyze( min_conf=min_conf, sensitivity=sensitivity, species_list_file=species_list_file, - model_path=predictions.model_path, + model_path=meta.model_path, ) if "kaleidoscope" in rtypes: @@ -223,7 +291,7 @@ def analyze( min_conf=min_conf, sensitivity=sensitivity, species_list_file=species_list_file, - model_path=predictions.model_path, + model_path=meta.model_path, ) if save_params: @@ -259,8 +327,8 @@ def analyze( ( model, birdnet, - predictions.segment_duration_s, - predictions.model_sr, + meta.segment_duration_s, + meta.model_sr, overlap, fmin, fmax, @@ -285,16 +353,39 @@ def analyze( ), ) + if journal is not None: + journal.finalize() + return predictions +def _resume_fingerprint_params(**params) -> dict: + """Normalize the parameters that identify a resumable run. + + Only parameters that affect which detections are produced belong here; + output formatting (rtype, merge_consecutive, split_tables, ...) may change + between an interrupted run and its resume. + """ + slist = params["slist"] + + if slist is None or isinstance(slist, (str, Path)): + params["slist"] = str(slist) if slist is not None else None + else: + params["slist"] = sorted(map(str, slist)) + + params["audio_input"] = os.path.normcase( + os.path.normpath(os.path.abspath(params["audio_input"])) + ) + return params + + def _split_tables( df: pd.DataFrame, audio_input_path: Path, output: Path, bandpass_fmin: int, bandpass_fmax: int, - predictions: AcousticResultBase, + meta: RunMetadata, audio_speed: float, rtypes: Sequence[str], additional_columns, @@ -321,8 +412,8 @@ def _split_tables( df_file, bandpass_fmin, bandpass_fmax, - predictions.model_fmin, - predictions.model_fmax, + meta.model_fmin, + meta.model_fmax, audio_speed, output / (file_shorthand + ".BirdNET.selection.table.txt"), ) @@ -339,7 +430,7 @@ def _split_tables( min_conf=min_conf, sensitivity=sensitivity, species_list_file=species_list_file, - model_path=predictions.model_path, + model_path=meta.model_path, ) if "kaleidoscope" in rtypes: @@ -364,7 +455,7 @@ def _split_tables( min_conf=min_conf, sensitivity=sensitivity, species_list_file=species_list_file, - model_path=predictions.model_path, + model_path=meta.model_path, ) diff --git a/birdnet_analyzer/analyze/resume.py b/birdnet_analyzer/analyze/resume.py new file mode 100644 index 000000000..17c01f39d --- /dev/null +++ b/birdnet_analyzer/analyze/resume.py @@ -0,0 +1,328 @@ +"""Crash-safe resume journal for multi-file analyses. + +While an analysis runs, the journal persists every finished file's detections to +``/.birdnet-resume/`` via the birdnet library's ``on_file_complete`` +callback. If the run is interrupted (crash, power loss, cancel), a subsequent +run with the same parameters skips the already-completed files and combines +their stored detections with the fresh results. On successful completion the +journal directory is deleted. + +Layout of the journal directory: + +- ``manifest.json``: parameter fingerprint, a human-readable parameter + snapshot, total file count and the run metadata needed to write outputs + when no new inference happens (all files were already completed). +- ``results/.parquet``: one file per completed input file, holding that + file's detection dataframe. ```` is a hash of the normalized absolute + input path. Files the library reported as unprocessable are stored with a + ``.invalid.parquet`` suffix instead. Partials are written to a temp name and + moved into place with :func:`os.replace`, so their presence is an atomic + "this file is done" marker — no separate completed-list is needed. + +The completion callback runs on the library's dispatcher thread, off the +inference hot path. It must never raise: a raising callback cancels the whole +analysis, and a persistence problem (e.g. full disk) should not kill an +otherwise working run — that file simply won't be resumable. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import shutil +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + + import pandas as pd + +logger = logging.getLogger(__name__) + +JOURNAL_DIRNAME = ".birdnet-resume" +MANIFEST_FILENAME = "manifest.json" +RESULTS_DIRNAME = "results" +COMPLETED_SUFFIX = ".parquet" +INVALID_SUFFIX = ".invalid.parquet" +MANIFEST_VERSION = 1 + +# Parquet cannot store float16, which the library uses for time/confidence +# columns; cast on write (downstream code casts to float32 anyway). +_FLOAT16_COLUMNS = ("start_time", "end_time", "confidence") + + +@dataclass(frozen=True) +class RunMetadata: + """Result metadata needed to write outputs without a fresh inference run.""" + + model_path: str + model_fmin: int + model_fmax: int + model_sr: int + segment_duration_s: float + hop_duration_s: float + + @classmethod + def from_result(cls, result) -> RunMetadata: + return cls( + model_path=str(result.model_path), + model_fmin=int(result.model_fmin), + model_fmax=int(result.model_fmax), + model_sr=int(result.model_sr), + segment_duration_s=float(result.segment_duration_s), + hop_duration_s=float(result.hop_duration_s), + ) + + +@dataclass(frozen=True) +class ResumeProgress: + """Lightweight progress snapshot of an interrupted run (e.g. for the GUI).""" + + n_completed: int + n_files_total: int + params: dict + + +def compute_fingerprint(params: dict) -> str: + """Hash the analysis parameters that affect detection results. + + Output-formatting parameters (rtype, merge_consecutive, split_tables, ...) + must not be part of ``params``: the journal stores raw detections, so those + may change freely between an interrupted run and its resume. + """ + canonical = json.dumps(params, sort_keys=True, default=str) + return hashlib.sha1(canonical.encode("utf-8")).hexdigest() + + +def _file_key(path) -> str: + normalized = os.path.normcase(os.path.normpath(str(path))) + return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:24] + + +class ResumeJournal: + def __init__(self, directory: Path, fingerprint: str) -> None: + self._directory = directory + self._fingerprint = fingerprint + self._results_dir = directory / RESULTS_DIRNAME + self._manifest_path = directory / MANIFEST_FILENAME + self._metadata_written = False + + @classmethod + def open(cls, output_dir, params: dict, n_files_total: int) -> ResumeJournal: + """Open the journal for ``output_dir``, resuming or starting fresh. + + An existing journal is kept only if its parameter fingerprint matches; + otherwise it is wiped so results from different settings never mix. + """ + directory = Path(output_dir) / JOURNAL_DIRNAME + fingerprint = compute_fingerprint(params) + journal = cls(directory, fingerprint) + + manifest = journal._read_manifest() + + if manifest is not None and manifest.get("fingerprint") == fingerprint: + journal._metadata_written = manifest.get("metadata") is not None + if manifest.get("n_files_total") != n_files_total: + manifest["n_files_total"] = n_files_total + journal._write_manifest(manifest) + return journal + + shutil.rmtree(directory, ignore_errors=True) + journal._results_dir.mkdir(parents=True, exist_ok=True) + journal._write_manifest( + { + "version": MANIFEST_VERSION, + "fingerprint": fingerprint, + "params": params, + "n_files_total": n_files_total, + "metadata": None, + } + ) + return journal + + @staticmethod + def inspect(output_dir) -> ResumeProgress | None: + """Return progress of an interrupted run in ``output_dir``, if any. + + Intended for the GUI to detect resumable state when the user selects + an output directory. Returns ``None`` if there is no (readable) + journal. + """ + directory = Path(output_dir) / JOURNAL_DIRNAME + try: + with open(directory / MANIFEST_FILENAME, encoding="utf-8") as f: + manifest = json.load(f) + n_completed = sum( + 1 + for p in (directory / RESULTS_DIRNAME).iterdir() + if p.name.endswith(COMPLETED_SUFFIX) + ) + except (OSError, ValueError): + return None + + return ResumeProgress( + n_completed=n_completed, + n_files_total=int(manifest.get("n_files_total", 0)), + params=manifest.get("params", {}), + ) + + def completed_subset(self, files: Iterable) -> set[Path]: + """Return the subset of ``files`` that already have a stored result.""" + return {Path(file) for file in files if self._partial_path(file) is not None} + + def invalid_subset(self, files: Iterable) -> set[Path]: + """Return the subset of ``files`` stored as unprocessable.""" + return { + Path(file) + for file in files + if (self._results_dir / (_file_key(file) + INVALID_SUFFIX)).exists() + } + + def on_file_complete(self, result) -> None: + """Persist a single-file result; passed to the library as callback. + + Never raises: a raising callback cancels the whole analysis. + """ + try: + # Metadata first: a stored partial must imply stored metadata, so + # the all-files-completed resume path can always write outputs. + self._ensure_metadata(result) + + df = result.to_dataframe() + for col in _FLOAT16_COLUMNS: + if col in df.columns: + df[col] = df[col].astype("float32") + + file_path = str(result.inputs[0]) + invalid = len(result.unprocessable_inputs) > 0 + key = _file_key(file_path) + suffix = INVALID_SUFFIX if invalid else COMPLETED_SUFFIX + + tmp_path = self._results_dir / (key + ".tmp") + df.to_parquet(tmp_path, index=False) + os.replace(tmp_path, self._results_dir / (key + suffix)) + except Exception: + logger.warning( + "Failed to persist resume state for %s; the file will be " + "re-analyzed if the run is resumed.", + getattr(result, "inputs", [""])[0], + exc_info=True, + ) + + def combined_dataframe( + self, current_df: pd.DataFrame | None, input_files: list + ) -> pd.DataFrame: + """Combine stored partials with this run's results, in input order. + + ``current_df`` holds the detections of the files analyzed in this run + (may be ``None`` when everything was already completed); stored + partials provide the previously completed files. Rows are ordered by + position in ``input_files``, then by time, so combined outputs (e.g. + the Raven table's accumulated offsets) stay grouped per file exactly + like in an uninterrupted run. + """ + import pandas as pd + + def norm(path) -> str: + return os.path.normcase(os.path.normpath(str(path))) + + # Compare normalized paths: stored partials may carry path strings from + # a previous invocation with different casing (e.g. drive letter). + current_inputs = ( + {norm(p) for p in current_df["input"].unique()} + if current_df is not None + else set() + ) + parts = [] + + for file in input_files: + # This run's in-memory result wins over a stored partial (both can + # exist for the same file, e.g. after a mid-run persist failure). + if norm(file) in current_inputs: + continue + + partial_path = self._partial_path(file) + + if partial_path is not None: + parts.append(pd.read_parquet(partial_path)) + + if current_df is not None: + parts.append(current_df) + + if not parts: + raise ValueError("No results to combine.") + + # Empty frames (files without detections) contribute no rows but would + # degrade dtypes in concat; keep one only if everything is empty. + non_empty = [p for p in parts if not p.empty] + df = pd.concat(non_empty, ignore_index=True) if non_empty else parts[0].copy() + + # Stored partials are float32, fresh results float16; unify — pandas + # cannot sort float16 columns anyway. + for col in _FLOAT16_COLUMNS: + if col in df.columns: + df[col] = df[col].astype("float32") + + order = {norm(file): i for i, file in enumerate(input_files)} + df["_file_order"] = df["input"].map(lambda p: order.get(norm(p))) + df = df.sort_values( + by=["_file_order", "start_time", "end_time"], kind="stable" + ).drop(columns="_file_order") + return df.reset_index(drop=True) + + def metadata(self) -> RunMetadata | None: + manifest = self._read_manifest() + + if manifest is None or not manifest.get("metadata"): + return None + + return RunMetadata(**manifest["metadata"]) + + def finalize(self) -> None: + """Delete the journal after a successful run.""" + shutil.rmtree(self._directory, ignore_errors=True) + + def _partial_path(self, file) -> Path | None: + key = _file_key(file) + + for suffix in (COMPLETED_SUFFIX, INVALID_SUFFIX): + path = self._results_dir / (key + suffix) + if path.exists(): + return path + + return None + + def _ensure_metadata(self, result) -> None: + if self._metadata_written: + return + + manifest = self._read_manifest() or { + "version": MANIFEST_VERSION, + "fingerprint": self._fingerprint, + "params": {}, + "n_files_total": 0, + "metadata": None, + } + manifest["metadata"] = asdict(RunMetadata.from_result(result)) + self._write_manifest(manifest) + self._metadata_written = True + + def _read_manifest(self) -> dict | None: + try: + with open(self._manifest_path, encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return None + + def _write_manifest(self, manifest: dict) -> None: + self._directory.mkdir(parents=True, exist_ok=True) + tmp_path = self._manifest_path.with_suffix(".json.tmp") + + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, default=str) + + os.replace(tmp_path, self._manifest_path) diff --git a/birdnet_analyzer/gui/multi_file.py b/birdnet_analyzer/gui/multi_file.py index 1c8cd781d..7bab4636b 100644 --- a/birdnet_analyzer/gui/multi_file.py +++ b/birdnet_analyzer/gui/multi_file.py @@ -1,3 +1,5 @@ +import threading + import gradio as gr from birdnet.globals import MODEL_LANGUAGE_EN_US @@ -5,6 +7,10 @@ import birdnet_analyzer.gui.utils as gu from birdnet_analyzer.gui.state import TabState +# Set when the user presses pause, so the cancellation error raised by the +# birdnet session can be told apart from a real failure. +_PAUSE_REQUESTED = threading.Event() + def _output_type_map(): return { @@ -63,39 +69,57 @@ def run_batch_analysis( gu.validate(input_dir, loc.localize("validation-no-directory-selected")) - results = run_analysis( - input_path=None, - output_path=output_path, - use_top_n=use_top_n, - top_n=top_n, - confidence=confidence, - sensitivity=sensitivity, - overlap=overlap, - merge_consecutive=merge_consecutive, - audio_speed=audio_speed, - fmin=fmin, - fmax=fmax, - species_list_choice=species_list_choice, - species_list_file=species_list_file, - lat=lat, - lon=lon, - week=week, - use_yearlong=use_yearlong, - sf_thresh=sf_thresh, - selected_model=selected_model, - custom_classifier_file=custom_classifier_file, - output_types=output_type, - additional_columns=additional_columns, - locale=locale or MODEL_LANGUAGE_EN_US, - batch_size=batch_size if batch_size and batch_size > 0 else 1, - input_dir=input_dir, - save_params=True, - progress=progress, - n_producers=producers_number, - n_workers=workers_number, - split_tables=split_tables_checkbox, + _PAUSE_REQUESTED.clear() + + try: + results = run_analysis( + input_path=None, + output_path=output_path, + use_top_n=use_top_n, + top_n=top_n, + confidence=confidence, + sensitivity=sensitivity, + overlap=overlap, + merge_consecutive=merge_consecutive, + audio_speed=audio_speed, + fmin=fmin, + fmax=fmax, + species_list_choice=species_list_choice, + species_list_file=species_list_file, + lat=lat, + lon=lon, + week=week, + use_yearlong=use_yearlong, + sf_thresh=sf_thresh, + selected_model=selected_model, + custom_classifier_file=custom_classifier_file, + output_types=output_type, + additional_columns=additional_columns, + locale=locale or MODEL_LANGUAGE_EN_US, + batch_size=batch_size if batch_size and batch_size > 0 else 1, + input_dir=input_dir, + save_params=True, + progress=progress, + n_producers=producers_number, + n_workers=workers_number, + split_tables=split_tables_checkbox, + ) + except RuntimeError: + # The birdnet session raises when it is cancelled. A deliberate pause + # is not an error: the resume journal keeps the progress and the next + # run continues where this one stopped. + if _PAUSE_REQUESTED.is_set(): + _PAUSE_REQUESTED.clear() + return gr.update() + + raise + + # results is None when a resumed analysis had no files left to process. + skipped_files = ( + [results.inputs[ui] for ui in results.unprocessable_inputs] + if results is not None + else [] ) - skipped_files = [results.inputs[ui] for ui in results.unprocessable_inputs] header = ( [loc.localize("multi-tab-result-dataframe-column-invalid-file-header")] if skipped_files @@ -109,6 +133,48 @@ def run_batch_analysis( ) +@gu.gui_runtime_error_handler +def pause_batch_analysis(): + """Cancel the running analysis while keeping its resume journal.""" + from birdnet_analyzer import model_utils + + _PAUSE_REQUESTED.set() + + if not model_utils.pause_active_analyses(): + # Nothing was running (yet); keep the button usable. + _PAUSE_REQUESTED.clear() + return gr.update() + + return gr.update(interactive=False) + + +def refresh_resume_status(input_dir, output_dir): + """Show paused progress for the effective output directory, if any. + + Returns updates for the resume-status markdown and the start button, whose + label switches to "continue" when an interrupted analysis is found. + """ + from birdnet_analyzer.analyze.resume import ResumeJournal + + # analyze() writes to the input directory when no output is selected. + target_dir = output_dir or input_dir + progress = ResumeJournal.inspect(target_dir) if target_dir else None + + if progress is None: + return ( + gr.update(visible=False), + gr.update(value=loc.localize("analyze-start-button-label")), + ) + + status = loc.localize("multi-tab-resume-status-text").format( + completed=progress.n_completed, total=progress.n_files_total + ) + return ( + gr.update(value=f"⏸ {status}", visible=True), + gr.update(value=f"▶ {loc.localize('multi-tab-continue-button-label')}"), + ) + + def build_multi_analysis_tab() -> gu.TAB_BUILDER_RESULT: state = TabState("multi") @@ -188,7 +254,7 @@ def select_directory_on_empty(): gr.update(), ] - select_directory_btn.click( + input_select_event = select_directory_btn.click( select_directory_on_empty, outputs=[input_directory_state, selected_input_textbox, directory_input], show_progress="full", @@ -213,7 +279,7 @@ def select_directory_wrapper(): folder = gu.select_folder(state_key="batch-analysis-output-dir") return (folder, folder) if folder else (gr.update(), gr.update()) - select_out_directory_btn.click( + output_select_event = select_out_directory_btn.click( select_directory_wrapper, outputs=[output_directory_predict_state, selected_out_textbox], show_progress="hidden", @@ -253,9 +319,21 @@ def select_directory_wrapper(): ) bs_number, producers_number, workers_number = gu.computing_settings(state) - start_batch_analysis_btn = gr.Button( - loc.localize("analyze-start-button-label"), variant="primary" - ) + resume_status_md = gr.Markdown(visible=False) + + with gr.Row(equal_height=True): + start_batch_analysis_btn = gr.Button( + loc.localize("analyze-start-button-label"), + variant="primary", + scale=3, + ) + pause_batch_analysis_btn = gr.Button( + f"⏸ {loc.localize('multi-tab-pause-button-label')}", + variant="stop", + visible=False, + scale=1, + ) + result_grid = gr.List(headers=[""], buttons=[]) inputs = [ output_directory_predict_state, @@ -290,8 +368,45 @@ def select_directory_wrapper(): def show_additional_columns(values): return gr.update(visible="csv" in values) + resume_status_inputs = [input_directory_state, output_directory_predict_state] + resume_status_outputs = [resume_status_md, start_batch_analysis_btn] + + # Surface paused progress as soon as the user picks the directories. + for select_event in (input_select_event, output_select_event): + select_event.then( + refresh_resume_status, + inputs=resume_status_inputs, + outputs=resume_status_outputs, + show_progress="hidden", + ) + + def prepare_run_ui(): + return ( + gr.update(interactive=False), + gr.update(visible=True, interactive=True), + ) + + def restore_run_ui(): + return gr.update(interactive=True), gr.update(visible=False) + start_batch_analysis_btn.click( - run_batch_analysis, inputs=inputs, outputs=result_grid + prepare_run_ui, + outputs=[start_batch_analysis_btn, pause_batch_analysis_btn], + show_progress="hidden", + ).then(run_batch_analysis, inputs=inputs, outputs=result_grid).then( + restore_run_ui, + outputs=[start_batch_analysis_btn, pause_batch_analysis_btn], + show_progress="hidden", + ).then( + refresh_resume_status, + inputs=resume_status_inputs, + outputs=resume_status_outputs, + show_progress="hidden", + ) + pause_batch_analysis_btn.click( + pause_batch_analysis, + outputs=pause_batch_analysis_btn, + show_progress="hidden", ) output_type_radio.change( show_additional_columns, diff --git a/birdnet_analyzer/lang/de.json b/birdnet_analyzer/lang/de.json index e4cc777f1..f3d31b94a 100644 --- a/birdnet_analyzer/lang/de.json +++ b/birdnet_analyzer/lang/de.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Woche", "multi-tab-additional-columns-checkbox-info": "Fügt zusätzliche Spalten zur Ausgabe hinzu. \"Start (s)\", \"End (s)\", \"Scientific name\", \"Common name\", \"Confidence\", \"File\" sind bereits enthalten.", "multi-tab-additional-columns-checkbox-label": "Zusätzliche Spalten für CSV", + "multi-tab-continue-button-label": "Analyse fortsetzen", "multi-tab-info-text": "Wählen Sie das Verzeichnis mit Ihren Audiodateien aus, indem Sie auf „Eingabeverzeichnis auswählen (rekursiv)“ klicken. Der Analyzer findet alle darin enthaltenen Audiodateien, auch wenn sie in Unterverzeichnissen liegen.\n\nDie Analyse erstellt Ergebnisdateien; wenn Sie kein Ausgabeverzeichnis angeben, werden sie in Ihrem Eingabeverzeichnis abgelegt.\n\nSchwellenwert- und Audiooptionen können in den Inferenzeinstellungen angepasst werden.\n\nUm nach Arten zu filtern, können Sie im Bereich der Artenauswahl benutzerdefinierte Artenlisten verwenden oder anhand vorgegebener Koordinaten eine Artenliste erzeugen.\n\nWenn Sie bereits einen benutzerdefinierten Klassifikator trainiert haben oder nicht BirdNET (die Standardeinstellung) verwenden möchten, können Sie im Bereich der Modellauswahl ein anderes Modell auswählen. Für die BirdNET-Modelle gibt es übersetzte Bezeichnungen, die auf die Ergebnisdateien angewendet werden.\n\nIn den Ausgabeeinstellungen können Sie die benötigten Ausgabeformate festlegen. Standardmäßig werden alle Ergebnisse in einer Ergebnisdatei zusammengefasst. Um sie nach Audiodatei zu trennen, aktivieren Sie die Option zum Aufteilen.", "multi-tab-info-title": "Info", "multi-tab-input-selection-button-label": "Eingabeverzeichnis auswählen (rekursiv)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Raven-Auswahltabelle", + "multi-tab-pause-button-label": "Pause", "multi-tab-result-dataframe-column-invalid-file-header": "Ungültige Audiodateien", "multi-tab-result-dataframe-column-success-header": "Alle Dateien wurden analysiert!", + "multi-tab-resume-status-text": "{completed} von {total} Dateien wurden bereits analysiert – die Analyse wird dort fortgesetzt, wo sie unterbrochen wurde.", "multi-tab-samples-dataframe-column-duration-header": "Länge", "multi-tab-samples-dataframe-column-subpath-header": "Unterpfad", "multi-tab-samples-dataframe-no-files-found": "Keine Dateien gefunden", diff --git a/birdnet_analyzer/lang/en.json b/birdnet_analyzer/lang/en.json index 7ed17374b..faa448d5d 100644 --- a/birdnet_analyzer/lang/en.json +++ b/birdnet_analyzer/lang/en.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Week", "multi-tab-additional-columns-checkbox-info": "Adds additional columns to the output. \"Start (s)\", \"End (s)\", \"Scientific name\", \"Common name\", \"Confidence\", \"File\" are already included.", "multi-tab-additional-columns-checkbox-label": "Additional columns for CSV", + "multi-tab-continue-button-label": "Continue analysis", "multi-tab-info-text": "Select the directory that contains your audio files by clicking on \"Select input directory (recursive)\". The analyzer will find all audio files within, even if they are contained within subdirectories.\n\nThe analysis will create result files, if you don't specify an output directory, they will be placed inside your input directory.\n\nThresholding and audio options can be adjusted in the inference settings.\n\nTo filter by species you can use custom species lists in the species selection section or generate a species list with given coordinates.\n\nIf you have already trained a custom classifier or do not want to use BirdNET (the default) you can select another model in the model selection section. There are translated labels for the birdnet models, which will be applied to the result files.\n\nYou can decide on the output formats you need in the output settings. All results be combined in one result file by default. To seperate them by audio file, check the split option.", "multi-tab-info-title": "Info", "multi-tab-input-selection-button-label": "Select input directory (recursive)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Raven selection table", + "multi-tab-pause-button-label": "Pause", "multi-tab-result-dataframe-column-invalid-file-header": "Invalid audio files", "multi-tab-result-dataframe-column-success-header": "All files analyzed!", + "multi-tab-resume-status-text": "{completed} of {total} files have already been analyzed — the analysis will continue where it stopped.", "multi-tab-samples-dataframe-column-duration-header": "Length", "multi-tab-samples-dataframe-column-subpath-header": "Subpath", "multi-tab-samples-dataframe-no-files-found": "No files found", diff --git a/birdnet_analyzer/lang/fi.json b/birdnet_analyzer/lang/fi.json index c9bc1cf7a..857d73fdd 100644 --- a/birdnet_analyzer/lang/fi.json +++ b/birdnet_analyzer/lang/fi.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Viikko", "multi-tab-additional-columns-checkbox-info": "Lisää tulosteeseen lisäsarakkeita. \"Aloitus (s)\", \"Lopetus (s)\", \"Tieteellinen nimi\", \"Yleisnimi\", \"Luottamus\", \"Tiedosto\" sisältyvät jo.", "multi-tab-additional-columns-checkbox-label": "Lisäsarakkeet CSV:lle", + "multi-tab-continue-button-label": "Jatka analyysiä", "multi-tab-info-text": "Valitse äänitiedostosi sisältävä hakemisto napsauttamalla ”Valitse syötehakemisto (rekursiivinen)”. Analysaattori löytää kaikki sen sisältämät äänitiedostot, vaikka ne olisivat alihakemistoissa.\n\nAnalyysi luo tulostiedostoja; jos et määritä tallennushakemistoa, ne sijoitetaan syötehakemistoosi.\n\nKynnys- ja ääniasetuksia voidaan säätää päättelyasetuksissa.\n\nSuodattaaksesi lajin mukaan voit käyttää mukautettuja lajiluetteloita lajivalintaosiossa tai luoda lajiluettelon annetuilla koordinaateilla.\n\nJos olet jo kouluttanut mukautetun luokittimen tai et halua käyttää BirdNETiä (oletus), voit valita toisen mallin mallinvalintaosiossa. BirdNET-malleille on käännettyjä nimikkeitä, jotka lisätään tulostiedostoihin.\n\nVoit valita tarvitsemasi tulostusmuodot tulostusasetuksissa. Oletuksena kaikki tulokset yhdistetään yhteen tulostiedostoon. Erotellaksesi ne äänitiedostoittain valitse jakoasetus.", "multi-tab-info-title": "Tietoja", "multi-tab-input-selection-button-label": "Valitse syötehakemisto (rekursiivinen)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Raven-valintataulukko", + "multi-tab-pause-button-label": "Tauko", "multi-tab-result-dataframe-column-invalid-file-header": "Virheelliset äänitiedostot", "multi-tab-result-dataframe-column-success-header": "Kaikki tiedostot analysoitu!", + "multi-tab-resume-status-text": "{completed}/{total} tiedostoa on jo analysoitu – analyysi jatkuu siitä, mihin se jäi.", "multi-tab-samples-dataframe-column-duration-header": "Pituus", "multi-tab-samples-dataframe-column-subpath-header": "Alihakemisto", "multi-tab-samples-dataframe-no-files-found": "Tiedostoja ei löytynyt", diff --git a/birdnet_analyzer/lang/fr.json b/birdnet_analyzer/lang/fr.json index afabeeac6..101af97ed 100644 --- a/birdnet_analyzer/lang/fr.json +++ b/birdnet_analyzer/lang/fr.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Semaine", "multi-tab-additional-columns-checkbox-info": "Ajoute des colonnes supplémentaires à la sortie. \"Début (s)\", \"Fin (s)\", \"Nom scientifique\", \"Nom commun\", \"Confiance\", \"Fichier\" sont déjà inclus.", "multi-tab-additional-columns-checkbox-label": "Colonnes supplémentaires pour CSV", + "multi-tab-continue-button-label": "Reprendre l'analyse", "multi-tab-info-text": "Sélectionnez le répertoire contenant vos fichiers audio en cliquant sur « Sélectionner le répertoire d'entrée (récursif) ». L'analyseur trouvera tous les fichiers audio qu'il contient, même s'ils se trouvent dans des sous-répertoires.\n\nL'analyse crée des fichiers de résultats ; si vous ne spécifiez pas de répertoire de sortie, ils sont placés dans votre répertoire d'entrée.\n\nLes options de seuil et audio peuvent être ajustées dans les paramètres d'inférence.\n\nPour filtrer par espèce, vous pouvez utiliser des listes d'espèces personnalisées dans la section de sélection des espèces ou générer une liste d'espèces à partir de coordonnées données.\n\nSi vous avez déjà entraîné un classificateur personnalisé ou si vous ne souhaitez pas utiliser BirdNET (l'option par défaut), vous pouvez sélectionner un autre modèle dans la section de sélection du modèle. Des libellés traduits sont disponibles pour les modèles BirdNET et seront appliqués aux fichiers de résultats.\n\nVous pouvez choisir les formats de sortie dont vous avez besoin dans les paramètres de sortie. Par défaut, tous les résultats sont regroupés dans un seul fichier de résultats. Pour les séparer par fichier audio, cochez l'option de division.", "multi-tab-info-title": "Informations", "multi-tab-input-selection-button-label": "Sélection du répertoire d'entrée (récursif)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Table de sélection Raven", + "multi-tab-pause-button-label": "Pause", "multi-tab-result-dataframe-column-invalid-file-header": "Fichiers audio non valides", "multi-tab-result-dataframe-column-success-header": "Tous les fichiers ont été analysés !", + "multi-tab-resume-status-text": "{completed} fichiers sur {total} ont déjà été analysés – l'analyse reprendra là où elle s'est arrêtée.", "multi-tab-samples-dataframe-column-duration-header": "Longueur", "multi-tab-samples-dataframe-column-subpath-header": "Sous-chemin", "multi-tab-samples-dataframe-no-files-found": "Aucun fichiers trouvés", diff --git a/birdnet_analyzer/lang/id.json b/birdnet_analyzer/lang/id.json index f6cb76e0f..ea3c70496 100644 --- a/birdnet_analyzer/lang/id.json +++ b/birdnet_analyzer/lang/id.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Minggu", "multi-tab-additional-columns-checkbox-info": "Menambahkan kolom tambahan ke output. \"Mulai (d)\", \"Akhir (d)\", \"Nama ilmiah\", \"Nama umum\", \"Keyakinan\", \"Berkas\" sudah termasuk.", "multi-tab-additional-columns-checkbox-label": "Kolom tambahan untuk CSV", + "multi-tab-continue-button-label": "Lanjutkan analisis", "multi-tab-info-text": "Pilih direktori yang berisi berkas audio Anda dengan mengeklik ”Pilih direktori masukan (rekursif)”. Penganalisis akan menemukan semua berkas audio di dalamnya, bahkan jika berada dalam subdirektori.\n\nAnalisis akan membuat berkas hasil; jika Anda tidak menentukan direktori keluaran, berkas tersebut akan ditempatkan di dalam direktori masukan Anda.\n\nOpsi ambang batas dan audio dapat disesuaikan di pengaturan inferensi.\n\nUntuk memfilter berdasarkan spesies, Anda dapat menggunakan daftar spesies kustom di bagian pemilihan spesies atau membuat daftar spesies dengan koordinat tertentu.\n\nJika Anda sudah melatih pengklasifikasi kustom atau tidak ingin menggunakan BirdNET (bawaan), Anda dapat memilih model lain di bagian pemilihan model. Terdapat label terjemahan untuk model BirdNET, yang akan diterapkan pada berkas hasil.\n\nAnda dapat menentukan format keluaran yang Anda butuhkan di pengaturan keluaran. Secara bawaan, semua hasil digabungkan dalam satu berkas hasil. Untuk memisahkannya berdasarkan berkas audio, centang opsi pemisahan.", "multi-tab-info-title": "Info", "multi-tab-input-selection-button-label": "Pilih direktori input (rekursif)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Tabel seleksi Raven", + "multi-tab-pause-button-label": "Jeda", "multi-tab-result-dataframe-column-invalid-file-header": "File audio tidak valid", "multi-tab-result-dataframe-column-success-header": "Semua file telah dianalisis!", + "multi-tab-resume-status-text": "{completed} dari {total} file telah dianalisis – analisis akan dilanjutkan dari titik terakhir.", "multi-tab-samples-dataframe-column-duration-header": "Panjang", "multi-tab-samples-dataframe-column-subpath-header": "Subjalur", "multi-tab-samples-dataframe-no-files-found": "Tidak ada file yang ditemukan", diff --git a/birdnet_analyzer/lang/pt-br.json b/birdnet_analyzer/lang/pt-br.json index abedc9251..9addd9d87 100644 --- a/birdnet_analyzer/lang/pt-br.json +++ b/birdnet_analyzer/lang/pt-br.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Semana", "multi-tab-additional-columns-checkbox-info": "Adiciona colunas adicionais à saída. \"Início (s)\", \"Fim (s)\", \"Nome científico\", \"Nome comum\", \"Confiança\", \"Arquivo\" já estão incluídos.", "multi-tab-additional-columns-checkbox-label": "Colunas adicionais para CSV", + "multi-tab-continue-button-label": "Continuar análise", "multi-tab-info-text": "Selecione o diretório que contém os seus arquivos de áudio clicando em ”Selecionar diretório de entrada (recursivo)”. O analisador encontrará todos os arquivos de áudio nele, mesmo que estejam em subdiretórios.\n\nA análise criará arquivos de resultados; se você não especificar um diretório de saída, eles serão colocados dentro do seu diretório de entrada.\n\nAs opções de limiar e de áudio podem ser ajustadas nas configurações de inferência.\n\nPara filtrar por espécie, você pode usar listas de espécies personalizadas na seção de seleção de espécies ou gerar uma lista de espécies com coordenadas específicas.\n\nSe você já treinou um classificador personalizado ou não deseja usar o BirdNET (o padrão), pode selecionar outro modelo na seção de seleção de modelo. Há rótulos traduzidos para os modelos BirdNET, que serão aplicados aos arquivos de resultados.\n\nVocê pode definir os formatos de saída de que precisa nas configurações de saída. Por padrão, todos os resultados são combinados em um único arquivo de resultados. Para separá-los por arquivo de áudio, marque a opção de divisão.", "multi-tab-info-title": "Informações", "multi-tab-input-selection-button-label": "Selecione o diretório de entrada (recursivo)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Tabela de seleção Raven", + "multi-tab-pause-button-label": "Pausar", "multi-tab-result-dataframe-column-invalid-file-header": "Arquivos de áudio inválidos", "multi-tab-result-dataframe-column-success-header": "Todos os arquivos foram analisados!", + "multi-tab-resume-status-text": "{completed} de {total} arquivos já foram analisados – a análise continuará de onde parou.", "multi-tab-samples-dataframe-column-duration-header": "Duração", "multi-tab-samples-dataframe-column-subpath-header": "Subcaminho", "multi-tab-samples-dataframe-no-files-found": "Nenhum arquivo encontrado", diff --git a/birdnet_analyzer/lang/ru.json b/birdnet_analyzer/lang/ru.json index 761a2f49d..4c322fd8a 100644 --- a/birdnet_analyzer/lang/ru.json +++ b/birdnet_analyzer/lang/ru.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Неделя", "multi-tab-additional-columns-checkbox-info": "Добавляет дополнительные столбцы в вывод. \"Начало (с)\", \"Конец (с)\", \"Научное название\", \"Общее название\", \"Уверенность\", \"Файл\" уже включены.", "multi-tab-additional-columns-checkbox-label": "Дополнительные столбцы для CSV", + "multi-tab-continue-button-label": "Продолжить анализ", "multi-tab-info-text": "Выберите каталог с вашими аудиофайлами, нажав «Выбрать входной каталог (рекурсивно)». Анализатор найдёт все аудиофайлы внутри него, даже если они находятся в подкаталогах.\n\nАнализ создаст файлы результатов; если вы не укажете выходной каталог, они будут помещены в ваш входной каталог.\n\nПараметры порога и аудио можно настроить в настройках вывода модели.\n\nЧтобы отфильтровать по видам, вы можете использовать пользовательские списки видов в разделе выбора видов или создать список видов по заданным координатам.\n\nЕсли вы уже обучили пользовательский классификатор или не хотите использовать BirdNET (по умолчанию), вы можете выбрать другую модель в разделе выбора модели. Для моделей BirdNET доступны переведённые метки, которые будут применены к файлам результатов.\n\nВ настройках вывода вы можете выбрать нужные форматы вывода. По умолчанию все результаты объединяются в один файл результатов. Чтобы разделить их по аудиофайлам, установите флажок разделения.", "multi-tab-info-title": "Информация", "multi-tab-input-selection-button-label": "Выберите входной каталог (рекурсивный)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Таблица выбора Raven", + "multi-tab-pause-button-label": "Пауза", "multi-tab-result-dataframe-column-invalid-file-header": "Недопустимые аудиофайлы", "multi-tab-result-dataframe-column-success-header": "Все файлы проанализированы!", + "multi-tab-resume-status-text": "{completed} из {total} файлов уже проанализированы — анализ продолжится с места остановки.", "multi-tab-samples-dataframe-column-duration-header": "Длина", "multi-tab-samples-dataframe-column-subpath-header": "Подпуть", "multi-tab-samples-dataframe-no-files-found": "Файлы не найдены", diff --git a/birdnet_analyzer/lang/se.json b/birdnet_analyzer/lang/se.json index e0fba8e13..1409bf914 100644 --- a/birdnet_analyzer/lang/se.json +++ b/birdnet_analyzer/lang/se.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Vecka", "multi-tab-additional-columns-checkbox-info": "Lägger till ytterligare kolumner i utdata. \"Start (s)\", \"Slut (s)\", \"Vetenskapligt namn\", \"Allmänt namn\", \"Förtroende\", \"Fil\" ingår redan.", "multi-tab-additional-columns-checkbox-label": "Ytterligare kolumner för CSV", + "multi-tab-continue-button-label": "Fortsätt analysen", "multi-tab-info-text": "Välj katalogen som innehåller dina ljudfiler genom att klicka på ”Välj indatakatalog (rekursivt)”. Analysatorn hittar alla ljudfiler i den, även om de ligger i underkataloger.\n\nAnalysen skapar resultatfiler; om du inte anger en utdatakatalog placeras de i din indatakatalog.\n\nTröskel- och ljudalternativ kan justeras i inferensinställningarna.\n\nFör att filtrera efter art kan du använda anpassade artlistor i avsnittet för artval eller generera en artlista utifrån angivna koordinater.\n\nOm du redan har tränat en anpassad klassificerare eller inte vill använda BirdNET (standard) kan du välja en annan modell i avsnittet för modellval. Det finns översatta etiketter för BirdNET-modellerna, som tillämpas på resultatfilerna.\n\nDu kan välja de utdataformat du behöver i utdatainställningarna. Som standard kombineras alla resultat i en resultatfil. För att separera dem per ljudfil, markera delningsalternativet.", "multi-tab-info-title": "Info", "multi-tab-input-selection-button-label": "Välj indatakatalog (rekursiv)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Raven-urvalstabell", + "multi-tab-pause-button-label": "Paus", "multi-tab-result-dataframe-column-invalid-file-header": "Ogiltiga ljudfiler", "multi-tab-result-dataframe-column-success-header": "Alla filer har analyserats!", + "multi-tab-resume-status-text": "{completed} av {total} filer har redan analyserats – analysen fortsätter där den avbröts.", "multi-tab-samples-dataframe-column-duration-header": "Längd", "multi-tab-samples-dataframe-column-subpath-header": "Undersökväg", "multi-tab-samples-dataframe-no-files-found": "Inga filer hittades", diff --git a/birdnet_analyzer/lang/tlh.json b/birdnet_analyzer/lang/tlh.json index 7c4afb8d5..562aa2dfd 100644 --- a/birdnet_analyzer/lang/tlh.json +++ b/birdnet_analyzer/lang/tlh.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "Hogh", "multi-tab-additional-columns-checkbox-info": "De' chu' mIwDaq chel. \"tagh (s)\", \"Dor (s)\", \"Sarmaq pong\", \"pop pong\", \"voQSIp\", \"chovnatlh\" already tu'.", "multi-tab-additional-columns-checkbox-label": "CSV mey chu'", + "multi-tab-continue-button-label": "poj yItaHqa'", "multi-tab-info-text": "ghogh teywI'lIj ngaSbogh ghom yIwIv, «'el ghom yIwIv (recursive)» yI'uy. Hoch ghogh teywI'mey Sam juvwI', ghom bIngmey ngaSchugh je.\n\njuv Sammey teywI'mey chenmoH; 'el ghom Dalabbe'chugh, 'el ghomlIjDaq lanlu'.\n\ninference choHmeyDaq threshold 'ej ghogh choHmey yIchoH.\n\nHa'DIbaHmey yISIvmeH, Ha'DIbaH wIv poHDaq Ha'DIbaH permey yIlo' qoj Hechmey yInobDI' Ha'DIbaH per yIchenmoH.\n\npat DalIjta'chugh qoj BirdNET (default) DaneHbe'chugh, model wIv poHDaq latlh pat yIwIv. BirdNET modelmeyvaD mughta'bogh permey tu'lu', Sammey teywI'meyDaq lanlu'bogh.\n\noutput choHmeyDaq output formatmey DaneHbogh yIwIv. default, wa' Sammey teywI'Daq Hoch Sammey boSlu'. ghogh teywI' pIm pImmoHmeH, split choH yIwIv.", "multi-tab-info-title": "De'", "multi-tab-input-selection-button-label": "wav Segh lo'", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Raven wIv ghItlh", + "multi-tab-pause-button-label": "yev", "multi-tab-result-dataframe-column-invalid-file-header": "QoywI' De' qal", "multi-tab-result-dataframe-column-success-header": "Hoch tey' lunuDta'!", + "multi-tab-resume-status-text": "pojlu'pu' {completed}/{total} De'mey — mevpu'bogh Daqvo' taHqa' poj.", "multi-tab-samples-dataframe-column-duration-header": "tup", "multi-tab-samples-dataframe-column-subpath-header": "wavmey", "multi-tab-samples-dataframe-no-files-found": "wavmey tu'Ha'", diff --git a/birdnet_analyzer/lang/zh_TW.json b/birdnet_analyzer/lang/zh_TW.json index 68e744f86..c30be5fbc 100644 --- a/birdnet_analyzer/lang/zh_TW.json +++ b/birdnet_analyzer/lang/zh_TW.json @@ -169,6 +169,7 @@ "multi-tab-additional-column-week-label": "週", "multi-tab-additional-columns-checkbox-info": "將額外欄位加入輸出中。「開始(秒)」、「結束(秒)」、「學名」、「常見名稱」、「信心度」、「檔案」已包含在內。", "multi-tab-additional-columns-checkbox-label": "CSV 額外欄位", + "multi-tab-continue-button-label": "繼續分析", "multi-tab-info-text": "按一下「選擇輸入目錄(遞迴)」以選擇包含您音訊檔案的目錄。分析器會找出其中的所有音訊檔案,即使它們位於子目錄中。\n\n分析會建立結果檔案;若您未指定輸出目錄,這些檔案會放在您的輸入目錄中。\n\n閾值與音訊選項可在推論設定中調整。\n\n若要依物種篩選,您可以在物種選擇區段使用自訂物種清單,或以指定座標產生物種清單。\n\n若您已訓練自訂分類器,或不想使用 BirdNET(預設),可在模型選擇區段選擇其他模型。BirdNET 模型有已翻譯的標籤,會套用至結果檔案。\n\n您可以在輸出設定中決定所需的輸出格式。預設情況下,所有結果會合併於單一結果檔案中。若要依音訊檔案分開,請勾選分割選項。", "multi-tab-info-title": "資訊", "multi-tab-input-selection-button-label": "選擇音檔資料夾(遞迴)", @@ -183,8 +184,10 @@ "multi-tab-output-type-csv-label": "CSV", "multi-tab-output-type-kaleidoscope-label": "Kaleidoscope", "multi-tab-output-type-raven-label": "Raven 選擇表", + "multi-tab-pause-button-label": "暫停", "multi-tab-result-dataframe-column-invalid-file-header": "無效的音訊檔案", "multi-tab-result-dataframe-column-success-header": "所有檔案皆已分析!", + "multi-tab-resume-status-text": "已分析 {completed}/{total} 個檔案 — 分析將從中斷處繼續。", "multi-tab-samples-dataframe-column-duration-header": "長度", "multi-tab-samples-dataframe-column-subpath-header": "音檔路徑", "multi-tab-samples-dataframe-no-files-found": "找無檔案", diff --git a/birdnet_analyzer/model_utils.py b/birdnet_analyzer/model_utils.py index 9762fef85..920f431e1 100644 --- a/birdnet_analyzer/model_utils.py +++ b/birdnet_analyzer/model_utils.py @@ -90,6 +90,27 @@ def cancel_active_analyses() -> int: return len(sessions) +def pause_active_analyses() -> int: + """Cancel every in-flight analysis without latching shutdown. + + Unlike :func:`cancel_active_analyses`, new analyses may still be started + afterwards. Used by the GUI to pause a run: the resume journal keeps the + per-file progress, so re-running the same analysis continues where it + stopped. + + Returns: + The number of sessions that were asked to cancel. + """ + with _ACTIVE_SESSIONS_LOCK: + sessions = list(_ACTIVE_SESSIONS) + + for session in sessions: + with suppress(Exception): + session.cancel() + + return len(sessions) + + def run_inference( path, model="birdnet", @@ -110,6 +131,7 @@ def run_inference( classifier: str | None = None, cc_species_list: str | None = None, callback: Callable[[AcousticProgressStats], None] | None = None, + on_file_complete: Callable[[AcousticFilePredictionResult], None] | None = None, ) -> AcousticFilePredictionResult: if classifier: if not cc_species_list: @@ -132,6 +154,12 @@ def run_inference( input_files = InferenceConfig.validate_input_files(path) + # Only pass the kwarg when used: birdnet releases without the per-file + # completion hook reject it (see supports_on_file_complete()). + session_kwargs = ( + {"on_file_complete": on_file_complete} if on_file_complete is not None else {} + ) + with acoustic_model.predict_session( top_k=top_k, batch_size=batch_size, @@ -149,6 +177,7 @@ def run_inference( n_producers=n_producers, apply_sigmoid=model != "perch", max_n_files=len(input_files), + **session_kwargs, ) as session: _register_session(session) try: @@ -157,6 +186,17 @@ def run_inference( _unregister_session(session) +def supports_on_file_complete() -> bool: + """Whether the installed birdnet provides the per-file completion hook. + + Resumable analysis needs ``on_file_complete`` (birdnet-team/birdnet#57); + on older releases the feature is silently disabled. + """ + from importlib.util import find_spec + + return find_spec("birdnet.acoustic.inference.core.file_completion") is not None + + def run_geomodel( lat, lon, week=None, language: MODEL_LANGUAGES = "en_us", threshold: float = 0.03 ) -> birdnet.GeoPredictionResult: diff --git a/tests/analyze/test_resume.py b/tests/analyze/test_resume.py new file mode 100644 index 000000000..27d848e98 --- /dev/null +++ b/tests/analyze/test_resume.py @@ -0,0 +1,265 @@ +"""Tests for the crash-safe resume journal and the resumable analyze() flow.""" + +import os +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import pandas as pd +import pytest +import soundfile as sf + +from birdnet_analyzer.analyze.core import analyze +from birdnet_analyzer.analyze.resume import JOURNAL_DIRNAME, ResumeJournal + +RESULT_COLUMNS = ["input", "start_time", "end_time", "species_name", "confidence"] + + +class FakeResult: + """Stand-in for the library's AcousticFilePredictionResult.""" + + def __init__(self, files, rows, invalid_indices=()): + self.inputs = np.array([str(f) for f in files]) + self.unprocessable_inputs = list(invalid_indices) + self._rows = rows + self.hop_duration_s = 3.0 + self.model_fmin = 0 + self.model_fmax = 15000 + self.model_path = "fake_model.tflite" + self.model_sr = 48000 + self.segment_duration_s = 3.0 + + def to_dataframe(self): + df = pd.DataFrame(self._rows, columns=RESULT_COLUMNS) + + if not df.empty: + # The library uses float16 for these columns. + for col in ("start_time", "end_time", "confidence"): + df[col] = df[col].astype("float16") + + return df + + +def detection_rows(file): + """Two deterministic, file-specific detection rows.""" + name = Path(file).stem + return [ + (str(file), 0.0, 3.0, f"Sci{name}_Common{name}", 0.8), + (str(file), 3.0, 6.0, f"Sci{name}_Common{name}", 0.6), + ] + + +def make_fake_run_inference(crash_after=None, invalid_files=()): + """Build a run_inference replacement that drives on_file_complete per file. + + Args: + crash_after: Simulate a crash (raise) after this many files completed. + None runs to completion. + invalid_files: Files to report as unprocessable (empty result). + + Returns: + The fake function and a list recording the files of each invocation. + """ + calls = [] + + def fake_run_inference(path, on_file_complete=None, **kwargs): + files = sorted(Path(p) for p in path) if not isinstance(path, str) else [] + calls.append(files) + all_rows = [] + + for i, file in enumerate(files): + invalid = file in set(invalid_files) + rows = [] if invalid else detection_rows(file) + all_rows.extend(rows) + + if on_file_complete is not None: + on_file_complete( + FakeResult([file], rows, invalid_indices=[0] if invalid else []) + ) + + if crash_after is not None and i + 1 >= crash_after: + raise RuntimeError("simulated crash") + + return FakeResult(files, all_rows) + + return fake_run_inference, calls + + +@pytest.fixture +def env(tmp_path): + input_dir = tmp_path / "input" + output_dir = tmp_path / "output" + input_dir.mkdir() + output_dir.mkdir() + + audio = np.zeros(4800, dtype=np.float32) + files = [] + + for i in range(4): + file = input_dir / f"rec_{i}.wav" + sf.write(file, audio, 48000) + files.append(file.absolute()) + + return {"input_dir": input_dir, "output_dir": output_dir, "files": files} + + +def run_analyze(env, **kwargs): + return analyze(str(env["input_dir"]), str(env["output_dir"]), rtype="csv", **kwargs) + + +def read_combined_csv(env): + return pd.read_csv(env["output_dir"] / "BirdNET_CombinedTable.csv") + + +def test_journal_persistence_and_inspect(env): + files = env["files"] + params = {"min_conf": 0.25, "model": "birdnet"} + journal = ResumeJournal.open(env["output_dir"], params, n_files_total=4) + + # Persist two files, one of them unprocessable. + journal.on_file_complete(FakeResult([files[0]], detection_rows(files[0]))) + journal.on_file_complete(FakeResult([files[1]], [], invalid_indices=[0])) + + assert journal.completed_subset(files) == {files[0], files[1]} + assert journal.invalid_subset(files) == {files[1]} + + progress = ResumeJournal.inspect(env["output_dir"]) + assert progress is not None + assert progress.n_completed == 2 + assert progress.n_files_total == 4 + + # Reopening with identical params keeps the stored partials... + journal = ResumeJournal.open(env["output_dir"], params, n_files_total=4) + assert journal.completed_subset(files) == {files[0], files[1]} + assert journal.metadata() is not None + + # ...while changed params wipe the journal. + journal = ResumeJournal.open( + env["output_dir"], {**params, "min_conf": 0.5}, n_files_total=4 + ) + assert journal.completed_subset(files) == set() + + journal.finalize() + assert not (env["output_dir"] / JOURNAL_DIRNAME).exists() + assert ResumeJournal.inspect(env["output_dir"]) is None + + +def test_combined_dataframe_orders_by_input(env): + files = env["files"] + journal = ResumeJournal.open(env["output_dir"], {"p": 1}, n_files_total=4) + + # Later files completed first (out of order), plus fresh results for the rest. + journal.on_file_complete(FakeResult([files[3]], detection_rows(files[3]))) + journal.on_file_complete(FakeResult([files[1]], detection_rows(files[1]))) + current_df = FakeResult( + [files[0], files[2]], + detection_rows(files[0]) + detection_rows(files[2]), + ).to_dataframe() + + df = journal.combined_dataframe(current_df, files) + + assert list(df["input"].unique()) == [str(f) for f in files] + assert len(df) == 8 + # Rows within a file stay time-ordered. + assert df.groupby("input", sort=False)["start_time"].is_monotonic_increasing.all() + + +def test_analyze_resumes_after_crash(env): + # First run crashes after two files were persisted. + fake, calls = make_fake_run_inference(crash_after=2) + + with ( + patch("birdnet_analyzer.model_utils.run_inference", fake), + pytest.raises(RuntimeError, match="simulated crash"), + ): + run_analyze(env) + + journal_dir = env["output_dir"] / JOURNAL_DIRNAME + assert journal_dir.exists() + assert ResumeJournal.inspect(env["output_dir"]).n_completed == 2 + + # Second run only gets the files the first run did not complete. The fake + # processes files in sorted order, so the first two were persisted. + fake, calls = make_fake_run_inference() + + with patch("birdnet_analyzer.model_utils.run_inference", fake): + run_analyze(env) + + assert len(calls) == 1 + assert set(calls[0]) == set(env["files"][2:]) + + df = read_combined_csv(env) + assert set(df["File"]) == {str(f) for f in env["files"]} + assert len(df) == 8 + assert not journal_dir.exists(), "journal must be removed after success" + + +def test_analyze_all_files_already_completed(env): + # Crash after every file was persisted but before outputs were written. + fake, _ = make_fake_run_inference(crash_after=4) + + with ( + patch("birdnet_analyzer.model_utils.run_inference", fake), + pytest.raises(RuntimeError, match="simulated crash"), + ): + run_analyze(env) + + assert ResumeJournal.inspect(env["output_dir"]).n_completed == 4 + + # The resume must not run any inference and still produce the outputs. + def must_not_run(*args, **kwargs): + raise AssertionError("run_inference must not be called") + + with patch("birdnet_analyzer.model_utils.run_inference", must_not_run): + result = run_analyze(env) + + assert result is None + df = read_combined_csv(env) + assert set(df["File"]) == {str(f) for f in env["files"]} + assert not (env["output_dir"] / JOURNAL_DIRNAME).exists() + + +def test_changed_params_invalidate_resume_state(env): + fake, _ = make_fake_run_inference(crash_after=2) + + with ( + patch("birdnet_analyzer.model_utils.run_inference", fake), + pytest.raises(RuntimeError, match="simulated crash"), + ): + run_analyze(env, min_conf=0.25) + + # A different detection parameter must trigger a full re-analysis. + fake, calls = make_fake_run_inference() + + with patch("birdnet_analyzer.model_utils.run_inference", fake): + run_analyze(env, min_conf=0.5) + + assert len(calls[0]) == 4 + assert not (env["output_dir"] / JOURNAL_DIRNAME).exists() + + +def test_analyze_with_invalid_file_completes_and_cleans_up(env): + invalid = env["files"][1] + fake, _ = make_fake_run_inference(invalid_files=[invalid]) + + with patch("birdnet_analyzer.model_utils.run_inference", fake): + run_analyze(env) + + df = read_combined_csv(env) + assert str(invalid) not in set(df["File"]) + assert len(df) == 6 + assert not (env["output_dir"] / JOURNAL_DIRNAME).exists() + + +def test_single_file_input_does_not_create_journal(env): + single = env["files"][0] + + def fake_single(path, on_file_complete=None, **kwargs): + assert on_file_complete is None + return FakeResult([single], detection_rows(single)) + + with patch("birdnet_analyzer.model_utils.run_inference", fake_single): + analyze(str(single), str(env["output_dir"]), rtype="csv") + + assert not (env["output_dir"] / JOURNAL_DIRNAME).exists() + assert os.path.exists(env["output_dir"] / "BirdNET_CombinedTable.csv") diff --git a/tests/test_model_utils.py b/tests/test_model_utils.py new file mode 100644 index 000000000..fde9106a4 --- /dev/null +++ b/tests/test_model_utils.py @@ -0,0 +1,36 @@ +"""Tests for analysis session pause/cancel behavior in model_utils.""" + +from birdnet_analyzer import model_utils + + +class FakeSession: + def __init__(self): + self.cancelled = False + + def cancel(self): + self.cancelled = True + + +def test_pause_cancels_sessions_without_latching_shutdown(): + session = FakeSession() + model_utils._register_session(session) + + try: + assert model_utils.pause_active_analyses() == 1 + assert session.cancelled + assert not model_utils._SHUTDOWN.is_set(), "pause must not latch shutdown" + + # After a pause, newly registered sessions must not be auto-cancelled + # (unlike after cancel_active_analyses), so the run can be continued. + new_session = FakeSession() + model_utils._register_session(new_session) + assert not new_session.cancelled + model_utils._unregister_session(new_session) + finally: + model_utils._unregister_session(session) + model_utils._SHUTDOWN.clear() + + +def test_pause_with_no_active_sessions_is_a_noop(): + assert model_utils.pause_active_analyses() == 0 + assert not model_utils._SHUTDOWN.is_set()