From a8ac685600e04b1200c1c28aacf91ccd782c4433 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Sun, 25 Oct 2026 03:25:06 +0100 Subject: [PATCH] Writer dispatch + segments parser --- birdnet_analyzer/analyze/__init__.py | 14 +- birdnet_analyzer/analyze/core.py | 479 +++++++++--------- birdnet_analyzer/cli.py | 12 +- .../preprocessing/data_processor.py | 4 +- birdnet_analyzer/params.py | 198 ++++---- birdnet_analyzer/segments/utils.py | 340 ++++++------- birdnet_analyzer/train/utils.py | 62 +-- docs/best-practices/segment-review.rst | 2 +- docs/breaking-changes.rst | 8 +- tests/analyze/test_analyze.py | 147 +++++- tests/analyze/test_resume.py | 48 ++ tests/analyze/test_writers.py | 110 +++- .../assessment/test_performance_assessor.py | 4 +- .../preprocessing/test_data_processor.py | 4 +- tests/evaluation/test_process_data.py | 16 +- tests/segments/test_segments.py | 207 +++++++- tests/segments/test_utils.py | 150 +++--- tests/test_cli.py | 20 + tests/test_params.py | 10 + tests/train/test_train.py | 47 ++ 20 files changed, 1225 insertions(+), 657 deletions(-) diff --git a/birdnet_analyzer/analyze/__init__.py b/birdnet_analyzer/analyze/__init__.py index 5c22b4936..447fc0bfd 100644 --- a/birdnet_analyzer/analyze/__init__.py +++ b/birdnet_analyzer/analyze/__init__.py @@ -1,3 +1,6 @@ +from typing import get_args + +import birdnet_analyzer.config as cfg from birdnet_analyzer.analyze.core import ( analyze, save_as_audacity, @@ -6,16 +9,7 @@ save_as_rtable, ) -POSSIBLE_ADDITIONAL_COLUMNS = [ - "lat", - "lon", - "week", - "overlap", - "sensitivity", - "min_conf", - "species_list", - "model", -] +POSSIBLE_ADDITIONAL_COLUMNS = list(get_args(cfg.ADDITIONAL_COLUMNS)) __all__ = [ "analyze", diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index 59b359540..06242e4d7 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import TYPE_CHECKING +import birdnet_analyzer.config as cfg + if TYPE_CHECKING: from collections.abc import Callable, Collection, Sequence @@ -17,6 +19,38 @@ from birdnet_analyzer.analyze.resume import RunMetadata from birdnet_analyzer.config import ADDITIONAL_COLUMNS, RESULT_TYPES +_RESULT_FILES = { + "table": (cfg.OUTPUT_RAVEN_FILENAME, ".BirdNET.selection.table.txt"), + "csv": (cfg.OUTPUT_CSV_FILENAME, ".BirdNET.results.csv"), + "kaleidoscope": ( + cfg.OUTPUT_KALEIDOSCOPE_FILENAME, + ".BirdNET.results.kaleidoscope.csv", + ), + "audacity": (cfg.OUTPUT_AUDACITY_FILENAME, ".BirdNET.results.txt"), + "parquet": (cfg.OUTPUT_PARQUET_FILENAME, ".BirdNET.results.parquet"), +} + +# The columns of the csv and parquet results, with the types that keep the schema +# of a parquet file independent of its detections. +_TABULAR_COLUMNS = { + "Start (s)": "float32", + "End (s)": "float32", + "Scientific name": "string", + "Common name": "string", + "Confidence": "float32", + "File": "string", +} +_ADDITIONAL_COLUMN_TYPES = { + "lat": "float64", + "lon": "float64", + "week": "int64", + "overlap": "float64", + "sensitivity": "float64", + "min_conf": "float64", + "species_list": "string", + "model": "string", +} + def analyze( audio_input: str, @@ -140,7 +174,6 @@ def analyze( where it left off (if the analysis parameters match), and the journal is removed after the outputs are written. """ - import birdnet_analyzer.config as cfg from birdnet_analyzer.analyze.resume import ResumeJournal, RunMetadata from birdnet_analyzer.model_utils import ( effective_sensitivity, @@ -148,6 +181,7 @@ def analyze( run_inference, validate_min_conf, ) + from birdnet_analyzer.params import ANALYSIS_PARAMS, by_header from birdnet_analyzer.utils import save_params_file if classifier and model == "perch": @@ -273,106 +307,75 @@ def analyze( df, merge_consecutive, hop_size=meta.hop_duration_s ) + write_options = { + "bandpass_fmin": fmin, + "bandpass_fmax": fmax, + "audio_speed": audio_speed, + "meta": meta, + "additional_columns": additional_columns, + "column_values": _column_values( + lat=lat, + lon=lon, + week=week, + overlap=overlap, + min_conf=min_conf, + sensitivity=sensitivity, + species_list_file=species_list_file, + model_path=meta.model_path, + ), + } + + inputs = _analysed_inputs(predictions, journal, input_files) + if split_tables: _split_tables( + df, inputs, audio_input_path, Path(output), rtypes, **write_options + ) + else: + _write_outputs( df, - audio_input_path, - Path(output), - fmin, - fmax, - meta, - audio_speed, + {rtype: Path(output) / name for rtype, (name, _) in _RESULT_FILES.items()}, rtypes, - additional_columns, - lat, - lon, - week, - overlap, - min_conf, - sensitivity, - species_list_file, + inputs, + **write_options, ) - else: - if "table" in rtypes: - save_as_rtable( - df, - fmin, - fmax, - meta.model_fmin, - meta.model_fmax, - audio_speed, - Path(output) / cfg.OUTPUT_RAVEN_FILENAME, - ) - - if "csv" in rtypes: - save_as_csv( - df, - Path(output) / cfg.OUTPUT_CSV_FILENAME, - additional_columns, - lat=lat, - lon=lon, - week=week, - overlap=overlap, - min_conf=min_conf, - sensitivity=sensitivity, - species_list_file=species_list_file, - model_path=meta.model_path, - ) - - if "kaleidoscope" in rtypes: - save_as_kaleidoscope(df, Path(output) / cfg.OUTPUT_KALEIDOSCOPE_FILENAME) - - if "audacity" in rtypes: - save_as_audacity(df, Path(output) / cfg.OUTPUT_AUDACITY_FILENAME) - - if "parquet" in rtypes: - save_as_parquet( - df, - Path(output) / cfg.OUTPUT_PARQUET_FILENAME, - additional_columns, - lat=lat, - lon=lon, - week=week, - overlap=overlap, - min_conf=min_conf, - sensitivity=sensitivity, - species_list_file=species_list_file, - model_path=meta.model_path, - ) if save_params: save_params_file( Path(output) / cfg.ANALYSIS_PARAMS_FILENAME, - { - "Model": model, - "BirdNET version": birdnet, - "Segment length": meta.segment_duration_s, - "Sample rate": meta.model_sr, - "Segment overlap": overlap, - "Bandpass filter minimum": fmin, - "Bandpass filter maximum": fmax, - "Merge consecutive detections": merge_consecutive, - "Audio speed": audio_speed, - "Minimum confidence": min_conf, - "Sensitivity": sensitivity, - "Top N": top_n or "", - "Batch size": batch_size, - "Number of workers": n_workers or "", - "Number of producers": n_producers, - "Result type(s)": ", ".join(rtypes), - "Additional columns": ", ".join(additional_columns) - if additional_columns - else "", - "Latitude": "" if lat is None else lat, - "Longitude": "" if lon is None else lon, - "Week": "" if week is None else week, - "Species filter threshold": sf_thresh, - "Species list file": species_list_file or "", - "Locale": locale, - "Custom classifier path": classifier or "", - "Custom classifier species list": cc_species_list or "", - "Split tables": split_tables, - }, + by_header( + ANALYSIS_PARAMS, + { + "model": model, + "birdnet": birdnet, + "segment_length": meta.segment_duration_s, + "sample_rate": meta.model_sr, + "overlap": overlap, + "fmin": fmin, + "fmax": fmax, + "merge_consecutive": merge_consecutive, + "audio_speed": audio_speed, + "min_conf": min_conf, + "sensitivity": sensitivity, + "top_n": top_n or "", + "batch_size": batch_size, + "n_workers": n_workers or "", + "n_producers": n_producers, + "rtype": ", ".join(rtypes), + "additional_columns": ", ".join(additional_columns) + if additional_columns + else "", + "lat": "" if lat is None else lat, + "lon": "" if lon is None else lon, + "week": "" if week is None else week, + "sf_thresh": sf_thresh, + "slist": species_list_file or "", + "locale": locale, + "classifier": classifier or "", + "cc_species_list": cc_species_list or "", + "split_tables": split_tables, + }, + ), ) if journal is not None: @@ -401,83 +404,92 @@ def _resume_fingerprint_params(**params) -> dict: return params +def _analysed_inputs(predictions, journal, input_files: list[Path]) -> list[str]: + """The inputs an analysis read, whether or not they hold detections.""" + files = input_files or [Path(f) for f in predictions.inputs] + unprocessable = journal.invalid_subset(input_files) if journal else set() + + if predictions is not None: + unprocessable |= predictions.get_unprocessed_files() + + return [str(f) for f in files if f not in unprocessable] + + +def _path_key(path) -> str: + return os.path.normcase(os.path.abspath(path)) + + def _split_tables( df: pd.DataFrame, + inputs: Sequence[str], audio_input_path: Path, output: Path, - bandpass_fmin: int, - bandpass_fmax: int, - meta: RunMetadata, - audio_speed: float, - rtypes: Sequence[str], - additional_columns, - lat, - lon, - week, - overlap, - min_conf, - sensitivity, - species_list_file, + rtypes: Collection[str], + **write_options, ): + """Writes the results of each of ``inputs`` to files of their own.""" input_root = ( Path(os.path.abspath(audio_input_path)) if audio_input_path.is_dir() else None ) + frames = {_path_key(input_file): (input_file, df.iloc[:0]) for input_file in inputs} for input_file, df_file in df.groupby("input", sort=False, observed=True): + frames[_path_key(input_file)] = (input_file, df_file) + + for input_file, df_file in frames.values(): rpath = Path(os.path.abspath(input_file)) rpath = rpath.relative_to(input_root) if input_root else Path(rpath.name) file_shorthand = str(rpath.with_suffix("")) - if "table" in rtypes: + _write_outputs( + df_file, + { + rtype: output / (file_shorthand + suffix) + for rtype, (_, suffix) in _RESULT_FILES.items() + }, + rtypes, + [input_file], + **write_options, + ) + + +def _write_outputs( + df: pd.DataFrame, + paths: dict[str, Path], + rtypes: Collection[str], + inputs: Sequence[str], + *, + bandpass_fmin: int, + bandpass_fmax: int, + audio_speed: float, + meta: RunMetadata, + additional_columns: list[ADDITIONAL_COLUMNS] | None, + column_values: dict, +): + """Writes ``df`` as each of ``rtypes``, to the path ``paths`` holds for it.""" + for rtype, path in paths.items(): + if rtype not in rtypes: + continue + + if rtype == "table": save_as_rtable( - df_file, + df, bandpass_fmin, bandpass_fmax, meta.model_fmin, meta.model_fmax, audio_speed, - output / (file_shorthand + ".BirdNET.selection.table.txt"), - ) - - if "csv" in rtypes: - save_as_csv( - df_file, - output / (file_shorthand + ".BirdNET.results.csv"), - additional_columns, - lat=lat, - lon=lon, - week=week, - overlap=overlap, - min_conf=min_conf, - sensitivity=sensitivity, - species_list_file=species_list_file, - model_path=meta.model_path, - ) - - if "kaleidoscope" in rtypes: - save_as_kaleidoscope( - df_file, output / (file_shorthand + ".BirdNET.results.kaleidoscope.csv") - ) - - if "audacity" in rtypes: - save_as_audacity( - df_file, output / (file_shorthand + ".BirdNET.results.txt") - ) - - if "parquet" in rtypes: - save_as_parquet( - df_file, - output / (file_shorthand + ".BirdNET.results.parquet"), - additional_columns, - lat=lat, - lon=lon, - week=week, - overlap=overlap, - min_conf=min_conf, - sensitivity=sensitivity, - species_list_file=species_list_file, - model_path=meta.model_path, + path, + inputs=inputs, ) + elif rtype == "csv": + _save_csv(df, path, additional_columns, column_values) + elif rtype == "kaleidoscope": + save_as_kaleidoscope(df, path) + elif rtype == "audacity": + save_as_audacity(df, path) + elif rtype == "parquet": + _save_parquet(df, path, additional_columns, column_values) def result_dataframe(predictions, merge_consecutive: int) -> pd.DataFrame: @@ -607,6 +619,7 @@ def save_as_rtable( model_fmax, audio_speed, outfile: Path, + inputs: Sequence[str] = (), ): from functools import partial @@ -648,13 +661,23 @@ def read_high_freq(file_path, sig_fmax, bandpass_fmax, audio_speed, file_infos): }, ) timestamp_dtype = df["Begin Time (s)"].dtype - file_offsets = {} + durations = {_path_key(file): file_infos[file]["duration"] for file in files} + offsets_by_key = {} offset = timestamp_dtype.type(0) - for file in files: - file_offsets[file] = offset - offset += timestamp_dtype.type(file_infos[file]["duration"]) + for file in [*inputs, *files]: + key = _path_key(file) + + if key in offsets_by_key: + continue + offsets_by_key[key] = offset + duration = ( + durations[key] if key in durations else get_audio_info(file)["duration"] + ) + offset += timestamp_dtype.type(duration) + + file_offsets = {file: offsets_by_key[_path_key(file)] for file in files} offsets = df["Begin Path"].astype(object).map(file_offsets) df["Begin Time (s)"] = df["Begin Time (s)"] + offsets df["End Time (s)"] = df["End Time (s)"] + offsets @@ -694,28 +717,47 @@ def save_as_csv( species_list_file=None, model_path=None, ): - df = df.copy() - n_rows = df.shape[0] - - if additional_columns: - possible_cols = { - "lat": [lat if lat is not None else ""], - "lon": [lon if lon is not None else ""], - "week": [week if week is not None else ""], - "overlap": [overlap if overlap is not None else ""], - "sensitivity": [sensitivity if sensitivity is not None else ""], - "min_conf": [min_conf if min_conf is not None else ""], - "species_list": [species_list_file], - "model": [os.path.basename(model_path or "")], - } - additional_columns = [col for col in additional_columns if col in possible_cols] - - for col in possible_cols: - if col in additional_columns: - df[col] = possible_cols[col] * n_rows + _save_csv( + df, + output, + additional_columns, + _column_values( + lat=lat, + lon=lon, + week=week, + overlap=overlap, + min_conf=min_conf, + sensitivity=sensitivity, + species_list_file=species_list_file, + model_path=model_path, + ), + ) - df["Scientific name"], df["Common name"] = _split_species_name(df["species_name"]) +def _column_values( + *, lat, lon, week, overlap, min_conf, sensitivity, species_list_file, model_path +) -> dict: + """The values of the additional columns of the csv and parquet results.""" + return { + "lat": lat, + "lon": lon, + "week": week, + "overlap": overlap, + "sensitivity": sensitivity, + "min_conf": min_conf, + "species_list": None + if species_list_file is None + else os.fspath(species_list_file), + "model": os.path.basename(model_path or ""), + } + + +def _tabular( + df: pd.DataFrame, + additional_columns: list[ADDITIONAL_COLUMNS] | None, + column_values: dict, +) -> pd.DataFrame: + """The detections as the csv and parquet results hold them.""" df = df.rename( columns={ "input": "File", @@ -724,27 +766,38 @@ def save_as_csv( "confidence": "Confidence", } ) - - order = [ - "Start (s)", - "End (s)", - "Scientific name", - "Common name", - "Confidence", - "File", + df["Scientific name"], df["Common name"] = _split_species_name(df["species_name"]) + additional_columns = [ + col for col in additional_columns or () if col in column_values ] - cols = [*order, *additional_columns] if additional_columns else order - df = df[cols] + + for col in additional_columns: + df[col] = column_values[col] + + return df[[*_TABULAR_COLUMNS, *additional_columns]] + + +def _save_csv(df, output: Path, additional_columns, column_values: dict): + table = _tabular(df, additional_columns, column_values) output.parent.mkdir(parents=True, exist_ok=True) - df.to_csv(output, index=False) + table.to_csv(output, index=False) + + +def _save_parquet(df, output: Path, additional_columns, column_values: dict): + import pyarrow as pa + + table = _tabular(df, additional_columns, column_values) + types = {**_TABULAR_COLUMNS, **_ADDITIONAL_COLUMN_TYPES} + schema = pa.schema([(col, pa.type_for_alias(types[col])) for col in table.columns]) + + output.parent.mkdir(parents=True, exist_ok=True) + table.to_parquet(output, index=False, schema=schema) def save_as_kaleidoscope(df: pd.DataFrame, output: Path): import pandas as pd - import birdnet_analyzer.config as cfg - paths = df["input"].map(Path) _, common_names = _split_species_name(df["species_name"]) columns = ( @@ -783,46 +836,18 @@ def save_as_parquet( species_list_file=None, model_path=None, ): - df = df.copy() - n_rows = df.shape[0] - df["Scientific name"], df["Common name"] = _split_species_name(df["species_name"]) - - df = df.rename( - columns={ - "input": "File", - "start_time": "Start (s)", - "end_time": "End (s)", - "confidence": "Confidence", - } + _save_parquet( + df, + output, + additional_columns, + _column_values( + lat=lat, + lon=lon, + week=week, + overlap=overlap, + min_conf=min_conf, + sensitivity=sensitivity, + species_list_file=species_list_file, + model_path=model_path, + ), ) - - order = [ - "Start (s)", - "End (s)", - "Scientific name", - "Common name", - "Confidence", - "File", - ] - - if additional_columns: - possible_cols = { - "lat": [lat if lat is not None else ""], - "lon": [lon if lon is not None else ""], - "week": [week if week is not None else ""], - "overlap": [overlap if overlap is not None else ""], - "sensitivity": [sensitivity if sensitivity is not None else ""], - "min_conf": [min_conf if min_conf is not None else ""], - "species_list": [species_list_file], - "model": [os.path.basename(model_path or "")], - } - additional_columns = [col for col in additional_columns if col in possible_cols] - - for col in additional_columns: - df[col] = possible_cols[col] * n_rows - - cols = [*order, *additional_columns] if additional_columns else order - df: pd.DataFrame = df[cols] - - output.parent.mkdir(parents=True, exist_ok=True) - df.to_parquet(output, index=False) diff --git a/birdnet_analyzer/cli.py b/birdnet_analyzer/cli.py index ff24e70d6..2511d499e 100644 --- a/birdnet_analyzer/cli.py +++ b/birdnet_analyzer/cli.py @@ -466,24 +466,26 @@ def analyzer_parser(): parents=parents, ) - class UniqueSetAction(argparse.Action): + class UniqueListAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, self.dest, {v.lower() for v in values}) + setattr( + namespace, self.dest, list(dict.fromkeys(v.lower() for v in values)) + ) parser.add_argument( "--rtype", - default={"table"}, + default=["table"], choices=get_args(RESULT_TYPES), nargs="+", help="Specifies output format.", - action=UniqueSetAction, + action=UniqueListAction, ) parser.add_argument( "--additional_columns", choices=POSSIBLE_ADDITIONAL_COLUMNS, nargs="+", help="Additional columns to include in the output, only applied to the csv and parquet output formats.", - action=UniqueSetAction, + action=UniqueListAction, ) parser.add_argument( "-c", diff --git a/birdnet_analyzer/evaluation/preprocessing/data_processor.py b/birdnet_analyzer/evaluation/preprocessing/data_processor.py index 3e19ad127..9e1d9de03 100644 --- a/birdnet_analyzer/evaluation/preprocessing/data_processor.py +++ b/birdnet_analyzer/evaluation/preprocessing/data_processor.py @@ -494,9 +494,7 @@ def determine_file_duration( if end_time_col_annot in annot_df.columns else 0.0 ) - duration = max( - filter(pd.notna, (max_end_pred, max_end_annot)), default=0.0 - ) + duration = max(filter(pd.notna, (max_end_pred, max_end_annot)), default=0.0) if duration < 0: duration = 0.0 diff --git a/birdnet_analyzer/params.py b/birdnet_analyzer/params.py index 578b6f591..444ddec4d 100644 --- a/birdnet_analyzer/params.py +++ b/birdnet_analyzer/params.py @@ -7,14 +7,16 @@ those files -- and the ones written before the 2.x releases, which held fewer parameters in one column per parameter -- back into keyword arguments for :func:`birdnet_analyzer.analyze.analyze` and :func:`birdnet_analyzer.train.train`. +The writers take their headers from :data:`ANALYSIS_PARAMS` and :data:`TRAIN_PARAMS`. They are shared by the CLI, which turns them into argument defaults so explicit command line arguments override the file, and by the GUI, which maps them onto its components in :mod:`birdnet_analyzer.gui.presets`. """ +from collections.abc import Callable from contextlib import suppress -from typing import Any, get_args +from typing import Any, NamedTuple, get_args from birdnet.globals import ACOUSTIC_MODEL_VERSIONS @@ -52,26 +54,6 @@ def read_params(path: str) -> dict[str, str]: return dict(zip(rows[0], rows[1], strict=False)) -def _parser(params: dict[str, str], values: dict[str, Any]): - """Builds the parse function that maps file parameters onto keyword arguments. - - The returned function takes the argument name, a converter, and the names the - parameter has carried over the versions. The first name found in the file wins; - empty and unconvertible values are left out. - """ - - def parse(key: str, converter, *headers: str): - for header in headers: - raw = params.get(header, "").strip() - - if raw: - with suppress(ValueError): - values[key] = converter(raw) - return - - return parse - - def _to_int(raw: str) -> int: return int(float(raw)) @@ -94,6 +76,111 @@ def _to_acoustic_version(raw: str) -> str: return raw +class _Param(NamedTuple): + key: str + # None for a parameter that is only recorded, not read back. + convert: Callable[[str], Any] | None = None + legacy_headers: tuple[str, ...] = () + # An empty selection is still a selection. + keep_empty: bool = False + + +_VERSION_HEADERS = ("BirdNET model version", "BirdNET Model version") + +# The rows of the parameters files by header, in the order they are written. +ANALYSIS_PARAMS: dict[str, _Param] = { + "Model": _Param("model", str), + "BirdNET version": _Param("birdnet", str), + "Segment length": _Param("segment_length"), + "Sample rate": _Param("sample_rate"), + "Segment overlap": _Param("overlap", float), + "Bandpass filter minimum": _Param("fmin", _to_int), + "Bandpass filter maximum": _Param("fmax", _to_int), + "Merge consecutive detections": _Param("merge_consecutive", _to_int), + "Audio speed": _Param("audio_speed", float), + "Minimum confidence": _Param("min_conf", float), + "Sensitivity": _Param("sensitivity", float), + "Top N": _Param("top_n", _to_int), + "Batch size": _Param("batch_size", _to_int), + "Number of workers": _Param("n_workers", _to_int), + "Number of producers": _Param("n_producers", _to_int), + "Result type(s)": _Param("rtype", _to_list, keep_empty=True), + "Additional columns": _Param("additional_columns", _to_list, keep_empty=True), + "Latitude": _Param("lat", float), + "Longitude": _Param("lon", float), + "Week": _Param("week", _to_int), + "Species filter threshold": _Param("sf_thresh", float), + "Species list file": _Param("slist", str), + "Locale": _Param("locale", str), + "Custom classifier path": _Param("classifier", str), + "Custom classifier species list": _Param("cc_species_list", str), + "Split tables": _Param("split_tables", _to_bool), +} +TRAIN_PARAMS: dict[str, _Param] = { + "Classifier name": _Param("classifier_name", str), + "Model formats": _Param("model_formats", _to_list), + "Model save mode": _Param("model_save_mode", str), + "Bandpass filter minimum": _Param("fmin", _to_int), + "Bandpass filter maximum": _Param("fmax", _to_int), + "Audio speed": _Param("audio_speed", float), + "Crop mode": _Param("crop_mode", str), + "Crop overlap": _Param("overlap", float), + "Autotune": _Param("autotune", _to_bool), + "Autotune trials": _Param("autotune_trials", _to_int), + "Autotune folds": _Param("autotune_n_splits", _to_int), + "Autotune repeats": _Param("autotune_n_repeats", _to_int), + "Autotune metric": _Param("autotune_metric", str), + "Epochs": _Param("epochs", _to_int), + "Batch size": _Param("batch_size", _to_int, ("Batchsize",)), + "Learning rate": _Param("learning_rate", float), + "Hidden units": _Param("hidden_units", _to_int), + "Dropout": _Param("dropout", float), + "Weight decay": _Param("weight_decay"), + "Use label smoothing": _Param( + "label_smoothing", _to_bool, ("use label smoothing",) + ), + "Use mixup": _Param("mixup", _to_bool, ("use mixup",)), + "Use focal loss": _Param("use_focal_loss", _to_bool, ("use focal loss",)), + "Focal loss gamma": _Param("focal_loss_gamma", float, ("focal loss gamma",)), + "Focal loss alpha": _Param("focal_loss_alpha", float, ("focal loss alpha",)), + "Upsampling mode": _Param("upsampling_mode", str), + "Upsampling ratio": _Param("upsampling_ratio", float), + _VERSION_HEADERS[0]: _Param("birdnet", _to_acoustic_version, _VERSION_HEADERS[1:]), +} + + +def by_header(table: dict[str, _Param], values: dict[str, Any]) -> dict[str, Any]: + """Keys the values of a run, given by keyword, by the headers of ``table``. + + Raises: + KeyError: If ``values`` lacks a parameter of ``table``. + """ + return {header: values[param.key] for header, param in table.items()} + + +def _read_values(params: dict[str, str], table: dict[str, _Param]) -> dict[str, Any]: + """Maps the parameters of a file onto keyword arguments. + + The first of a parameter's headers found in the file wins; empty and + unconvertible values are left out. + """ + values: dict[str, Any] = {} + + for header, param in table.items(): + if param.convert is None: + continue + + for name in (header, *param.legacy_headers): + raw = params.get(name, "").strip() + + if raw or (param.keep_empty and name in params): + with suppress(ValueError): + values[param.key] = param.convert(raw) + break + + return values + + def load_analysis_params(path: str) -> dict[str, Any]: """Reads the parameters of a previous analysis back as ``analyze()`` arguments. @@ -112,41 +199,7 @@ def load_analysis_params(path: str) -> dict[str, Any]: Raises: ValueError: If the file is not an analysis parameters file. """ - params = read_params(path) - values: dict[str, Any] = {} - parse = _parser(params, values) - - parse("min_conf", float, "Minimum confidence") - parse("sensitivity", float, "Sensitivity") - parse("overlap", float, "Segment overlap") - parse("merge_consecutive", _to_int, "Merge consecutive detections") - parse("audio_speed", float, "Audio speed") - parse("fmin", _to_int, "Bandpass filter minimum") - parse("fmax", _to_int, "Bandpass filter maximum") - parse("sf_thresh", float, "Species filter threshold") - parse("batch_size", _to_int, "Batch size") - parse("n_producers", _to_int, "Number of producers") - parse("n_workers", _to_int, "Number of workers") - parse("top_n", _to_int, "Top N") - parse("lat", float, "Latitude") - parse("lon", float, "Longitude") - parse("week", _to_int, "Week") - parse("locale", str, "Locale") - parse("model", str, "Model") - parse("birdnet", str, "BirdNET version") - parse("slist", str, "Species list file") - parse("classifier", str, "Custom classifier path") - parse("cc_species_list", str, "Custom classifier species list") - parse("split_tables", _to_bool, "Split tables") - - # An empty selection is still a selection, so these apply whenever the - # parameter is present. - for key, header in ( - ("rtype", "Result type(s)"), - ("additional_columns", "Additional columns"), - ): - if header in params: - values[key] = _to_list(params[header]) + values = _read_values(read_params(path), ANALYSIS_PARAMS) if len(values) < _MIN_RECOGNIZED_PARAMS: raise ValueError(f"Not an analysis parameters file: {path}") @@ -173,35 +226,7 @@ def load_train_params(path: str) -> dict[str, Any]: ValueError: If the file is not a training parameters file. """ params = read_params(path) - values: dict[str, Any] = {} - parse = _parser(params, values) - - parse("classifier_name", str, "Classifier name") - parse("model_formats", _to_list, "Model formats") - parse("model_save_mode", str, "Model save mode") - parse("fmin", _to_int, "Bandpass filter minimum") - parse("fmax", _to_int, "Bandpass filter maximum") - parse("audio_speed", float, "Audio speed") - parse("crop_mode", str, "Crop mode") - parse("overlap", float, "Crop overlap") - parse("autotune", _to_bool, "Autotune") - parse("autotune_trials", _to_int, "Autotune trials") - parse("autotune_n_splits", _to_int, "Autotune folds") - parse("autotune_n_repeats", _to_int, "Autotune repeats") - parse("autotune_metric", str, "Autotune metric") - parse("epochs", _to_int, "Epochs") - parse("batch_size", _to_int, "Batch size", "Batchsize") - parse("learning_rate", float, "Learning rate") - parse("hidden_units", _to_int, "Hidden units") - parse("dropout", float, "Dropout") - parse("upsampling_mode", str, "Upsampling mode") - parse("upsampling_ratio", float, "Upsampling ratio") - parse("label_smoothing", _to_bool, "Use label smoothing", "use label smoothing") - parse("mixup", _to_bool, "Use mixup", "use mixup") - parse("use_focal_loss", _to_bool, "Use focal loss", "use focal loss") - parse("focal_loss_gamma", float, "Focal loss gamma", "focal loss gamma") - parse("focal_loss_alpha", float, "Focal loss alpha", "focal loss alpha") - parse("birdnet", _to_acoustic_version, *_VERSION_HEADERS) + values = _read_values(params, TRAIN_PARAMS) if len(values) < _MIN_RECOGNIZED_PARAMS: raise ValueError(f"Not a training parameters file: {path}") @@ -211,6 +236,3 @@ def load_train_params(path: str) -> dict[str, Any]: values["birdnet"] = "2.4" return values - - -_VERSION_HEADERS = ("BirdNET model version", "BirdNET Model version") diff --git a/birdnet_analyzer/segments/utils.py b/birdnet_analyzer/segments/utils.py index 68be248b8..ccfe3990f 100644 --- a/birdnet_analyzer/segments/utils.py +++ b/birdnet_analyzer/segments/utils.py @@ -6,6 +6,8 @@ import csv import logging import os +import re +from collections import defaultdict import numpy as np @@ -15,6 +17,8 @@ RNG = np.random.default_rng(cfg.RANDOM_SEED) logger = logging.getLogger(__name__) +_RESULT_INFIX = re.compile(r"\.BirdNET\.", re.IGNORECASE) + # As written by the 2.x releases. _LEGACY_KALEIDOSCOPE_COLUMNS = ( "INDIR", @@ -92,6 +96,118 @@ def _parse_kaleidoscope_line( return afile, start, start + float(duration), species, float(confidence) +def _native_path(path: str) -> str: + return path.replace("/", os.sep).replace("\\", os.sep) + + +def _parse_table_line( + line: str, header_mapping: dict, combined: bool +) -> tuple[str, float, float, str, float]: + d = line.split("\t") + start = float(d[header_mapping["Begin Time (s)"]]) + end = float(d[header_mapping["End Time (s)"]]) + afile = "" + + if combined: + file_offset = float(d[header_mapping["File Offset (s)"]]) + start, end = file_offset, file_offset + (end - start) + afile = _native_path(d[header_mapping["Begin Path"]]) + + return ( + afile, + start, + end, + d[header_mapping["Common Name"]], + float(d[header_mapping["Confidence"]]), + ) + + +def _parse_csv_line( + line: str, header_mapping: dict, combined: bool +) -> tuple[str, float, float, str, float]: + d = _split_csv_line(line) + afile = _native_path(d[header_mapping["File"]]) if combined else "" + + return ( + afile, + float(d[header_mapping["Start (s)"]]), + float(d[header_mapping["End (s)"]]), + d[header_mapping["Common name"]], + float(d[header_mapping["Confidence"]]), + ) + + +def _parse_audacity_line( + line: str, header_mapping: dict, combined: bool +) -> tuple[str, float, float, str, float]: + d = line.split("\t") + # "Scientific_Common" as written since 3.0, "Scientific, Common" before. + separator = "_" if "_" in d[2] else ", " + + return "", float(d[0]), float(d[1]), d[2].split(separator, 1)[-1], float(d[-1]) + + +_LINE_PARSERS = { + "table": _parse_table_line, + "csv": _parse_csv_line, + "kaleidoscope": lambda line, header_mapping, combined: _parse_kaleidoscope_line( + line, header_mapping + ), + "audacity": _parse_audacity_line, +} + + +def _read_detections(rfile: str, afile: str | None = None) -> list[dict] | None: + """Reads the detections of a result file, one dict per data row. + + Without ``afile``, ``rfile`` is a combined table whose rows name their audio + files. + + Returns: + The detections, or None for an empty per-file result: the Audacity labels of + an audio file without detections. + + Raises: + ValueError: If ``rfile`` is an Audacity file but ``afile`` is not given; its + rows name no audio file. + """ + lines = [line for line in utils.read_lines(rfile) if line.strip()] + combined = afile is None + + if not lines: + if not combined: + return None + + logger.warning(f"Skipping the empty result file {rfile}.") + return [] + + rtype = _detect_rtype(lines[0]) + + if rtype != "audacity": + header_mapping, rows = _get_header_mapping(lines[0]), lines[1:] + elif combined: + raise ValueError("Audacity files are not supported for combined results.") + else: + header_mapping, rows = {}, lines + + parse = _LINE_PARSERS[rtype] + detections = [] + + for line in rows: + path, start, end, species, confidence = parse(line, header_mapping, combined) + detections.append( + { + "audio": path if combined else afile, + "start": start, + "end": end, + "species": species, + "confidence": confidence, + } + ) + + return detections + + def parse_folders( apath: str, rpath: str, allowed_result_filetypes: tuple[str, ...] = ("txt", "csv") ) -> list[dict]: @@ -126,8 +242,8 @@ def parse_folders( if f.rsplit(".", 1)[ -1 ].lower() in cfg.ALLOWED_FILETYPES and not f.startswith("."): - table_key = os.path.join( - os.path.relpath(root, apath), f.rsplit(".", 1)[0] + table_key = os.path.normcase( + os.path.join(os.path.relpath(root, apath), f.rsplit(".", 1)[0]) ) data[table_key] = {"audio": os.path.join(root, f), "result": ""} @@ -135,12 +251,11 @@ def parse_folders( for root, _, files in os.walk(rpath): for f in files: - if ( - f.rsplit(".", 1)[-1].lower() in result_filetypes - and ".BirdNET." in f - ): - table_key = os.path.join( - os.path.relpath(root, rpath), f.split(".BirdNET.", 1)[0] + stem, *suffix = _RESULT_INFIX.split(f, maxsplit=1) + + if f.rsplit(".", 1)[-1].lower() in result_filetypes and suffix: + table_key = os.path.normcase( + os.path.join(os.path.relpath(root, rpath), stem) ) if table_key in data: data[table_key]["result"] = os.path.join(root, f) @@ -189,34 +304,24 @@ def parse_files( ] segments = parse_files(flist, max_segments=50) """ - species_segments: dict[str, list] = {} - is_combined_rfile = len(flist) == 1 and flist[0].get("isCombinedFile", False) - - if is_combined_rfile: - rfile = flist[0]["result"] - segments = _find_segments_from_combined( - rfile, min_conf=min_conf, max_conf=max_conf - ) - - for s in segments: - if s["species"] not in species_segments: - species_segments[s["species"]] = [] + species_segments: dict[str, list] = defaultdict(list) - species_segments[s["species"]].append(s) + if len(flist) == 1 and flist[0].get("isCombinedFile", False): + files = [(None, flist[0]["result"])] else: - for f in flist: - afile = f["audio"] - rfile = f["result"] + files = [(f["audio"], f["result"]) for f in flist] - segments = _find_segments( - afile, rfile, min_conf=min_conf, max_conf=max_conf - ) + n_empty = 0 - for s in segments: - if s["species"] not in species_segments: - species_segments[s["species"]] = [] + for afile, rfile in files: + found = _find_segments(afile, rfile, min_conf=min_conf, max_conf=max_conf) - species_segments[s["species"]].append(s) + if found is None: + n_empty += 1 + continue + + for s in found: + species_segments[s["species"]].append(s) for s in species_segments: if collection_mode == "random": @@ -226,16 +331,17 @@ def parse_files( species_segments[s].sort(key=lambda x: x["confidence"], reverse=True) species_segments[s] = species_segments[s][:max_segments] elif collection_mode == "balanced": - bin_thresholds = np.linspace(min_conf, max_conf, num=n_bins) + bin_edges = np.linspace(min_conf, max_conf, num=n_bins + 1) max_segments_per_bin = max_segments // n_bins segments_by_bin: list[list] = [[] for _ in range(n_bins)] confidences = np.array([seg["confidence"] for seg in species_segments[s]]) - bin_indices = np.digitize(confidences, bin_thresholds, right=False) + bin_indices = np.clip( + np.digitize(confidences, bin_edges) - 1, 0, n_bins - 1 + ) - for seg, bin_idx in zip(species_segments[s], bin_indices, strict=False): - if bin_idx < n_bins: - segments_by_bin[bin_idx].append(seg) + for seg, bin_idx in zip(species_segments[s], bin_indices, strict=True): + segments_by_bin[bin_idx].append(seg) species_segments[s] = [] @@ -257,101 +363,21 @@ def parse_files( segments[seg["audio"]].append(seg) seg_cnt += 1 + if n_empty: + logger.info(f"Found {n_empty} empty result files, which hold no detections.") + logger.info(f"Found {seg_cnt} segments in {len(segments)} audio files.") return [tuple(e) for e in segments.items()] -def _find_segments_from_combined( - rfile: str, min_conf: float = 0.25, max_conf: float = 1.0 -) -> list[dict]: - """Extracts the segments from a combined results file - - Args: - rfile (str): Path to the result file. - - Returns: - list[dict]: A list of dicts in the form of - { - "audio": afile, - "start": start, - "end": end, - "species": species, - "confidence": confidence - } - """ - segments: list[dict] = [] - - lines = utils.read_lines(rfile) - - rtype = _detect_rtype(lines[0]) - - if rtype == "audacity": - raise Exception("Audacity files are not supported for combined results.") - - header_mapping = _get_header_mapping(lines[0]) - - confidence = 0 - start = end = 0.0 - species = "" - afile = "" - - for i, line in enumerate(lines): - if rtype == "table" and i > 0: - d = line.split("\t") - file_offset = float(d[header_mapping["File Offset (s)"]]) - start = file_offset - end = file_offset + ( - float(d[header_mapping["End Time (s)"]]) - - float(d[header_mapping["Begin Time (s)"]]) - ) - species = d[header_mapping["Common Name"]] - confidence = float(d[header_mapping["Confidence"]]) - afile = ( - d[header_mapping["Begin Path"]] - .replace("/", os.sep) - .replace("\\", os.sep) - ) - - elif rtype == "kaleidoscope" and i > 0: - afile, start, end, species, confidence = _parse_kaleidoscope_line( - line, header_mapping - ) - - elif rtype == "csv" and i > 0: - d = _split_csv_line(line) - start = float(d[header_mapping["Start (s)"]]) - end = float(d[header_mapping["End (s)"]]) - species = d[header_mapping["Common name"]] - confidence = float(d[header_mapping["Confidence"]]) - afile = d[header_mapping["File"]].replace("/", os.sep).replace("\\", os.sep) - - if ( - confidence >= min_conf - and confidence <= max_conf - and species.lower() != "nocall" - and afile - ): - segments.append( - { - "audio": afile, - "start": start, - "end": end, - "species": species, - "confidence": confidence, - } - ) - - return segments - - def _find_segments( - afile: str, rfile: str, min_conf: float = 0.25, max_conf: float = 1.0 -) -> list[dict]: - """Extracts the segments for an audio file from the results file + afile: str | None, rfile: str, min_conf: float = 0.25, max_conf: float = 1.0 +) -> list[dict] | None: + """Extracts the segments of a result file within the confidence range. Args: - afile: Path to the audio file. + afile: Path to the audio file, or None for a combined result file. rfile: Path to the result file. Returns: @@ -363,62 +389,20 @@ def _find_segments( "species": species, "confidence": confidence } + or None for an empty per-file result. """ - segments: list[dict] = [] - - lines = utils.read_lines(rfile) - - rtype = _detect_rtype(lines[0]) - - header_mapping = _get_header_mapping(lines[0]) - - confidence = 0 - start = end = 0.0 - species = "" - - for i, line in enumerate(lines): - if rtype == "table" and i > 0: - d = line.split("\t") - start = float(d[header_mapping["Begin Time (s)"]]) - end = float(d[header_mapping["End Time (s)"]]) - species = d[header_mapping["Common Name"]] - confidence = float(d[header_mapping["Confidence"]]) - - elif rtype == "audacity": - d = line.split("\t") - start = float(d[0]) - end = float(d[1]) - species = d[2].split(", ")[1] - confidence = float(d[-1]) - - elif rtype == "kaleidoscope" and i > 0: - _, start, end, species, confidence = _parse_kaleidoscope_line( - line, header_mapping - ) - - elif rtype == "csv" and i > 0: - d = _split_csv_line(line) - start = float(d[header_mapping["Start (s)"]]) - end = float(d[header_mapping["End (s)"]]) - species = d[header_mapping["Common name"]] - confidence = float(d[header_mapping["Confidence"]]) - - if ( - confidence >= min_conf - and confidence <= max_conf - and species.lower() != "nocall" - ): - segments.append( - { - "audio": afile, - "start": start, - "end": end, - "species": species, - "confidence": confidence, - } - ) - - return segments + detections = _read_detections(rfile, afile) + + if detections is None: + return None + + return [ + s + for s in detections + if min_conf <= s["confidence"] <= max_conf + and s["species"].lower() != "nocall" + and s["audio"] + ] def extract_segments( diff --git a/birdnet_analyzer/train/utils.py b/birdnet_analyzer/train/utils.py index c4ab649ab..a939271d3 100644 --- a/birdnet_analyzer/train/utils.py +++ b/birdnet_analyzer/train/utils.py @@ -27,6 +27,7 @@ TRAIN_PARAMS_SUFFIX, ) from birdnet_analyzer.model_utils import GLOBAL_PREFETCH_RATIO +from birdnet_analyzer.params import TRAIN_PARAMS, by_header # Encode batch size per base model, measured on CPU inside a warm session: the 2.4 # tflite interpreter re-plans on every batch-shape change, so small batches win @@ -937,35 +938,38 @@ def objective(trial: optuna.trial.Trial): # are the tuned ones. utils.save_params_file( classifier_path + TRAIN_PARAMS_SUFFIX, - { - "Classifier name": os.path.basename(classifier_path), - "Model formats": ", ".join(formats), - "Model save mode": model_save_mode, - "Bandpass filter minimum": fmin, - "Bandpass filter maximum": fmax, - "Audio speed": audio_speed, - "Crop mode": crop_mode, - "Crop overlap": overlap, - "Autotune": autotune, - "Autotune trials": autotune_trials, - "Autotune folds": autotune_n_splits, - "Autotune repeats": autotune_n_repeats, - "Autotune metric": autotune_metric, - "Epochs": epochs, - "Batch size": batch_size, - "Learning rate": learning_rate, - "Hidden units": hidden_units, - "Dropout": dropout, - "Weight decay": weight_decay, - "Use label smoothing": label_smoothing, - "Use mixup": mixup, - "Use focal loss": use_focal_loss, - "Focal loss gamma": focal_loss_gamma, - "Focal loss alpha": focal_loss_alpha, - "Upsampling mode": upsampling_mode, - "Upsampling ratio": upsampling_ratio, - "BirdNET model version": version, - }, + by_header( + TRAIN_PARAMS, + { + "classifier_name": os.path.basename(classifier_path), + "model_formats": ", ".join(formats), + "model_save_mode": model_save_mode, + "fmin": fmin, + "fmax": fmax, + "audio_speed": audio_speed, + "crop_mode": crop_mode, + "overlap": overlap, + "autotune": autotune, + "autotune_trials": autotune_trials, + "autotune_n_splits": autotune_n_splits, + "autotune_n_repeats": autotune_n_repeats, + "autotune_metric": autotune_metric, + "epochs": epochs, + "batch_size": batch_size, + "learning_rate": learning_rate, + "hidden_units": hidden_units, + "dropout": dropout, + "weight_decay": weight_decay, + "label_smoothing": label_smoothing, + "mixup": mixup, + "use_focal_loss": use_focal_loss, + "focal_loss_gamma": focal_loss_gamma, + "focal_loss_alpha": focal_loss_alpha, + "upsampling_mode": upsampling_mode, + "upsampling_ratio": upsampling_ratio, + "birdnet": version, + }, + ), ) if "tflite" in formats: diff --git a/docs/best-practices/segment-review.rst b/docs/best-practices/segment-review.rst index b7d6be01a..68cc87995 100644 --- a/docs/best-practices/segment-review.rst +++ b/docs/best-practices/segment-review.rst @@ -22,7 +22,7 @@ To obtain batch analysis result tables, run the analysis via the GUI or the :ref .. warning:: - The output format "audacity" is not supported for the segments tool since it is missing certain columns. Use "table", "kaleidoscope", or "csv" formats instead. + Audacity labels name no audio file, so the segments tool reads them only as the per-file results of an analysis with ``--split_tables``, not the combined ``BirdNET_AudacityLabels.txt``. Use "table", "kaleidoscope", or "csv" formats for combined results. Using the "Segments" Tool in the GUI or Command Line ----------------------------------------------------- diff --git a/docs/breaking-changes.rst b/docs/breaking-changes.rst index 2661a2233..8eeba43d3 100644 --- a/docs/breaking-changes.rst +++ b/docs/breaking-changes.rst @@ -199,8 +199,8 @@ combined files only with ``--combine_results``. (or another per-file result) next to each recording find nothing. **What to do.** Pass ``--split_tables`` for one result file per audio file, which -replaces the combined files. Unlike v2.4.0, it writes no file for an audio file without -detections. +replaces the combined files. As in v2.4.0, an audio file without detections still gets +its files, which hold only the header. Result and parameters file formats @@ -210,7 +210,9 @@ Result and parameters file formats * Raven selection tables add a ``Scientific Name`` column and order the columns differently. They have no ``nocall`` row for a file without detections, and no - ``BirdNET_SelectionTable.list.txt`` is written. + ``BirdNET_SelectionTable.list.txt`` is written. As before, the times of a combined + table run on across all analysed files in path order, including those without + detections. * Audacity labels read ``Scientific name_Common name`` instead of ``Scientific name, Common name``. * The analysis parameters are written to ``birdnet.analyze-params.csv`` instead of diff --git a/tests/analyze/test_analyze.py b/tests/analyze/test_analyze.py index da6ca5cb9..193ee3f07 100644 --- a/tests/analyze/test_analyze.py +++ b/tests/analyze/test_analyze.py @@ -6,6 +6,7 @@ import shutil import subprocess import tempfile +from pathlib import Path from typing import get_args from unittest.mock import patch @@ -955,6 +956,59 @@ def test_a_location_at_zero_is_kept_in_the_params_file( assert (kwargs.get("lat"), kwargs.get("lon")) == (0.0, 0.0) +def _mock_result(mock_run_inference, detections, inputs=(), unprocessable=()): + result = mock_run_inference.return_value + result.to_dataframe.return_value = detections + result.inputs = np.array([str(f) for f in inputs], dtype=object) + result.get_unprocessed_files.return_value = {Path(f) for f in unprocessable} + result.model_path = "model.tflite" + result.model_fmin, result.model_fmax, result.model_sr = 0, 15000, 48000 + result.segment_duration_s = result.hop_duration_s = 3.0 + + +@pytest.mark.parametrize( + "species", + [{"slist": "species_list.txt"}, {"lat": 50.83, "lon": 12.92, "week": 20}], + ids=["species-list", "location"], +) +@patch("birdnet_analyzer.model_utils.run_geomodel") +@patch("birdnet_analyzer.model_utils.run_inference") +def test_the_params_file_reads_back_as_the_arguments_of_the_analysis( + mock_run_inference, mock_run_geomodel, tmp_path, species +): + _mock_result(mock_run_inference, _detections_in_three_files(tmp_path / "in")) + arguments = { + "model": "birdnet", + "birdnet": "2.4", + "min_conf": 0.3, + "classifier": str(tmp_path / "cc.tflite"), + "cc_species_list": str(tmp_path / "cc_Labels.txt"), + "sensitivity": 1.25, + "overlap": 0.5, + "fmin": 150, + "fmax": 12000, + "audio_speed": 2.0, + "batch_size": 4, + "n_workers": 2, + "n_producers": 3, + "rtype": ["csv"], + "sf_thresh": 0.05, + "top_n": 5, + "merge_consecutive": 3, + "locale": "de", + "additional_columns": ["lat", "model"], + "split_tables": False, + **species, + } + + analyze(str(tmp_path / "in"), str(tmp_path / "out"), save_params=True, **arguments) + + assert ( + load_analysis_params(str(tmp_path / "out" / cfg.ANALYSIS_PARAMS_FILENAME)) + == arguments + ) + + @pytest.mark.parametrize( ("rtype", "expected_warnings"), [ @@ -1024,8 +1078,10 @@ def _link_directory(link, target): def _split(detections, audio_input_path, output, rtypes): _split_tables( detections, + inputs=list(detections["input"].unique()), audio_input_path=audio_input_path, output=output, + rtypes=rtypes, bandpass_fmin=0, bandpass_fmax=15000, meta=RunMetadata( @@ -1037,15 +1093,8 @@ def _split(detections, audio_input_path, output, rtypes): hop_duration_s=3.0, ), audio_speed=1.0, - rtypes=rtypes, additional_columns=None, - lat=None, - lon=None, - week=None, - overlap=0, - min_conf=0.25, - sensitivity=1.0, - species_list_file="", + column_values={}, ) @@ -1144,6 +1193,88 @@ def counting_load(fp, *args, **kwargs): assert parsed == [cfg.CODES_FILE] +SPLIT_SUFFIXES = [ + "results.csv", + "results.kaleidoscope.csv", + "results.parquet", + "results.txt", + "selection.table.txt", +] + + +def _split_files(output): + return sorted(p.relative_to(output).as_posix() for p in output.rglob("*.BirdNET.*")) + + +@patch("birdnet_analyzer.model_utils.run_inference") +def test_split_tables_write_every_result_type_for_each_analysed_input( + mock_run_inference, tmp_path +): + detections = _detections_in_three_files(tmp_path / "in") + b = str(tmp_path / "in" / "b.wav") + _mock_result(mock_run_inference, detections[detections["input"] != b]) + + analyze( + str(tmp_path / "in"), + str(tmp_path / "out"), + rtype=list(get_args(cfg.RESULT_TYPES)), + split_tables=True, + ) + + assert _split_files(tmp_path / "out") == [ + f"{stem}.BirdNET.{suffix}" + for stem in ("a", "b", "sub/c") + for suffix in SPLIT_SUFFIXES + ] + assert pd.read_csv(tmp_path / "out" / "b.BirdNET.results.csv").empty + + +@patch("birdnet_analyzer.model_utils.run_inference") +def test_raven_times_run_on_across_the_files_without_detections( + mock_run_inference, tmp_path +): + detections = _detections_in_three_files(tmp_path / "in") + b = str(tmp_path / "in" / "b.wav") + _mock_result(mock_run_inference, detections[detections["input"] != b]) + + analyze(str(tmp_path / "in"), str(tmp_path / "out")) + + table = pd.read_csv(tmp_path / "out" / cfg.OUTPUT_RAVEN_FILENAME, sep="\t") + assert table["Begin Time (s)"].tolist() == [0.0, 3.0, 12.0, 15.0] + assert table["File Offset (s)"].tolist() == [0.0, 3.0, 0.0, 3.0] + + +@patch("birdnet_analyzer.model_utils.run_inference") +def test_split_tables_of_a_file_without_detections_write_its_header( + mock_run_inference, tmp_path +): + detections = _detections_in_three_files(tmp_path / "in") + a = tmp_path / "in" / "a.wav" + _mock_result(mock_run_inference, detections.iloc[:0], inputs=[a]) + + analyze(str(a), str(tmp_path / "out"), rtype="csv", split_tables=True) + + assert pd.read_csv(tmp_path / "out" / "a.BirdNET.results.csv").empty + + +@patch("birdnet_analyzer.model_utils.run_inference") +def test_split_tables_write_no_file_for_an_unprocessable_input( + mock_run_inference, tmp_path +): + detections = _detections_in_three_files(tmp_path / "in") + b = str(tmp_path / "in" / "b.wav") + _mock_result( + mock_run_inference, detections[detections["input"] != b], unprocessable=[b] + ) + + analyze(str(tmp_path / "in"), str(tmp_path / "out"), rtype="csv", split_tables=True) + + assert _split_files(tmp_path / "out") == [ + "a.BirdNET.results.csv", + "sub/c.BirdNET.results.csv", + ] + + def test_split_tables_write_nothing_for_an_unused_input_category(tmp_path): detections = _detections_in_three_files(tmp_path / "in") categories = [*detections["input"].unique(), str(tmp_path / "in" / "unused.wav")] diff --git a/tests/analyze/test_resume.py b/tests/analyze/test_resume.py index 939b03f64..347480972 100644 --- a/tests/analyze/test_resume.py +++ b/tests/analyze/test_resume.py @@ -29,6 +29,9 @@ def __init__(self, files, rows, invalid_indices=()): self.model_sr = 48000 self.segment_duration_s = 3.0 + def get_unprocessed_files(self): + return {Path(self.inputs[i]) for i in self.unprocessable_inputs} + def to_dataframe(self): df = pd.DataFrame(self._rows, columns=RESULT_COLUMNS) @@ -266,6 +269,51 @@ def test_analyze_with_invalid_file_completes_and_cleans_up(env): assert not (env["output_dir"] / JOURNAL_DIRNAME).exists() +def test_a_resume_leaves_out_a_file_found_unprocessable_before(env): + invalid = env["files"][1] + invalid.write_bytes(b"not audio" * 100) + fake, _ = make_fake_run_inference(crash_after=2, invalid_files=[invalid]) + + with ( + patch("birdnet_analyzer.model_utils.run_inference", fake), + pytest.raises(RuntimeError, match="simulated crash"), + ): + analyze(str(env["input_dir"]), str(env["output_dir"]), rtype="table") + + fake, _ = make_fake_run_inference() + + with patch("birdnet_analyzer.model_utils.run_inference", fake): + analyze(str(env["input_dir"]), str(env["output_dir"]), rtype="table") + + table = pd.read_csv(env["output_dir"] / "BirdNET_SelectionTable.txt", sep="\t") + assert set(table["Begin Path"]) == {str(f) for f in env["files"] if f != invalid} + + +@pytest.mark.skipif(os.name != "nt", reason="Only Windows ignores the case of paths.") +def test_a_resume_under_a_differently_cased_path_keeps_the_raven_times(env): + fake, _ = make_fake_run_inference(crash_after=2) + + with ( + patch("birdnet_analyzer.model_utils.run_inference", fake), + pytest.raises(RuntimeError, match="simulated crash"), + ): + analyze(str(env["input_dir"]), str(env["output_dir"]), rtype="table") + + fake, calls = make_fake_run_inference() + + with patch("birdnet_analyzer.model_utils.run_inference", fake): + analyze(str(env["input_dir"]).upper(), str(env["output_dir"]), rtype="table") + + assert len(calls[0]) == 2 + table = pd.read_csv(env["output_dir"] / "BirdNET_SelectionTable.txt", sep="\t") + first_begin = table.groupby(table["Begin Path"].map(os.path.normcase))[ + "Begin Time (s)" + ].min() + assert first_begin[[os.path.normcase(f) for f in env["files"]]].tolist() == ( + pytest.approx([0.0, 0.1, 0.2, 0.3], abs=1e-6) + ) + + def test_single_file_input_does_not_create_journal(env): single = env["files"][0] diff --git a/tests/analyze/test_writers.py b/tests/analyze/test_writers.py index e23142312..71ac2ad10 100644 --- a/tests/analyze/test_writers.py +++ b/tests/analyze/test_writers.py @@ -2,10 +2,13 @@ import numpy as np import pandas as pd +import pyarrow as pa +import pyarrow.parquet as pq import pytest import soundfile as sf import birdnet_analyzer.config as cfg +from birdnet_analyzer.analyze import POSSIBLE_ADDITIONAL_COLUMNS from birdnet_analyzer.analyze.core import ( save_as_audacity, save_as_csv, @@ -15,7 +18,6 @@ ) from birdnet_analyzer.segments.utils import ( _find_segments, - _find_segments_from_combined, parse_files, parse_folders, ) @@ -82,6 +84,84 @@ def test_without_detections_a_result_file_holds_only_its_header( assert list(table.columns) == list(_read(with_detection).columns) +COLUMN_VALUES = { + "lat": 50.83, + "lon": 12.92, + "week": 20, + "overlap": 0.5, + "min_conf": 0.25, + "sensitivity": 1.0, + "species_list_file": "species_list.txt", + "model_path": "models/BirdNET_v3.0.tflite", +} + + +def test_csv_and_parquet_results_have_the_same_columns(tmp_path): + detections = _detections( + [(SOUNDSCAPE, 0.0, 3.0, "Turdus migratorius_American Robin", 0.5)] + ) + additional_columns = list(reversed(POSSIBLE_ADDITIONAL_COLUMNS)) + + save_as_csv(detections, tmp_path / "r.csv", additional_columns, **COLUMN_VALUES) + save_as_parquet( + detections, tmp_path / "r.parquet", additional_columns, **COLUMN_VALUES + ) + + columns = list(_read(tmp_path / "r.csv").columns) + assert columns == [ + "Start (s)", + "End (s)", + "Scientific name", + "Common name", + "Confidence", + "File", + *additional_columns, + ] + assert list(_read(tmp_path / "r.parquet").columns) == columns + + +@pytest.mark.parametrize( + "column_values", + [COLUMN_VALUES, dict.fromkeys(COLUMN_VALUES)], + ids=["values", "no-values"], +) +def test_an_empty_parquet_result_has_the_schema_of_a_non_empty_one( + tmp_path, column_values +): + detections = _detections( + [(SOUNDSCAPE, 0.0, 3.0, "Turdus migratorius_American Robin", 0.5)] + ) + + for name, df in (("one", detections), ("none", detections.iloc[:0])): + save_as_parquet( + df, + tmp_path / f"{name}.parquet", + POSSIBLE_ADDITIONAL_COLUMNS, + **column_values, + ) + + one, none = (pq.read_table(tmp_path / f"{n}.parquet") for n in ("one", "none")) + assert none.schema.remove_metadata() == one.schema.remove_metadata() + assert pa.concat_tables([one, none]).num_rows == 1 + + +def test_a_species_list_path_is_written_as_text(tmp_path): + detections = _detections( + [(SOUNDSCAPE, 0.0, 3.0, "Turdus migratorius_American Robin", 0.5)] + ) + species_list = Path("lists") / "species_list.txt" + + for save, name in ((save_as_csv, "r.csv"), (save_as_parquet, "r.parquet")): + save( + detections, + tmp_path / name, + ["species_list"], + species_list_file=species_list, + ) + + assert _read(tmp_path / name)["species_list"].tolist() == [str(species_list)] + + def test_without_detections_the_audacity_labels_are_empty(tmp_path): output = tmp_path / cfg.OUTPUT_AUDACITY_FILENAME @@ -158,9 +238,9 @@ def test_segments_read_fields_containing_commas(tmp_path, write, filename): result, ) - assert [ - (s["audio"], s["species"]) for s in _find_segments_from_combined(str(result)) - ] == [(afile, "Whales, Dolphins and Porpoises")] + assert [(s["audio"], s["species"]) for s in _find_segments(None, str(result))] == [ + (afile, "Whales, Dolphins and Porpoises") + ] assert [s["species"] for s in _find_segments(afile, str(result))] == [ "Whales, Dolphins and Porpoises" ] @@ -187,6 +267,26 @@ def test_raven_times_are_offset_by_the_durations_of_the_preceding_files(tmp_path assert table["File Offset (s)"].tolist() == [0.0, 3.0, 0.0, 3.0] +def test_a_combined_raven_table_is_read_back_by_segments(tmp_path): + a = _wav(tmp_path / "a.wav", 9) + b = _wav(tmp_path / "b.wav", 6) + rows = [ + (a, 0.0, 3.0, "Turdus migratorius_American Robin", 0.5), + (b, 3.0, 6.0, "Cardinalis cardinalis_Northern Cardinal", 0.75), + ] + output = tmp_path / cfg.OUTPUT_RAVEN_FILENAME + + _save_as_rtable(_detections(rows), output) + + segments = parse_files( + [{"isCombinedFile": True, "result": str(output)}], + collection_mode="confidence", + ) + assert sorted( + (s["audio"], s["start"], s["end"]) for _, segs in segments for s in segs + ) == [(a, 0.0, 3.0), (b, 3.0, 6.0)] + + def test_raven_table_accepts_a_categorical_input_column(tmp_path): a = _wav(tmp_path / "a.wav", 6) b = _wav(tmp_path / "b.wav", 9) @@ -233,4 +333,4 @@ def test_kaleidoscope_indir_of_a_file_below_the_root_is_the_root(tmp_path): assert pd.read_csv(result, keep_default_na=False)["INDIR"].tolist() == [ tmp_path.anchor ] - assert [s["audio"] for s in _find_segments_from_combined(str(result))] == [afile] + assert [s["audio"] for s in _find_segments(None, str(result))] == [afile] diff --git a/tests/evaluation/assessment/test_performance_assessor.py b/tests/evaluation/assessment/test_performance_assessor.py index 3ca5784e5..932dca25e 100644 --- a/tests/evaluation/assessment/test_performance_assessor.py +++ b/tests/evaluation/assessment/test_performance_assessor.py @@ -329,9 +329,7 @@ def test_empty_class_excluded_from_overall(self): def test_drop_empty_false_includes_empty_class(self): pa = self._assessor() - overall = pa.calculate_metrics( - self.predictions, self.labels, drop_empty=False - ) + overall = pa.calculate_metrics(self.predictions, self.labels, drop_empty=False) # The empty class contributes precision 0, pulling the macro mean below 1. assert overall.loc["Precision", "Overall"] < 1.0 diff --git a/tests/evaluation/preprocessing/test_data_processor.py b/tests/evaluation/preprocessing/test_data_processor.py index 1fed3e8b9..284b60e1d 100644 --- a/tests/evaluation/preprocessing/test_data_processor.py +++ b/tests/evaluation/preprocessing/test_data_processor.py @@ -652,9 +652,7 @@ def test_load_data_accepts_partially_missing_confidence(self, mock_read_concat): @patch( "birdnet_analyzer.evaluation.preprocessing.data_processor.read_and_concatenate_files_in_directory" ) - def test_load_data_rejects_confidence_column_without_values( - self, mock_read_concat - ): + def test_load_data_rejects_confidence_column_without_values(self, mock_read_concat): mock_read_concat.return_value = pd.DataFrame( { "Class": ["A", "B"], diff --git a/tests/evaluation/test_process_data.py b/tests/evaluation/test_process_data.py index fb211fbc4..4ed839ac9 100644 --- a/tests/evaluation/test_process_data.py +++ b/tests/evaluation/test_process_data.py @@ -41,9 +41,7 @@ def test_unmatched_prediction_file_dropped_by_default(tmp_path, caplog): _write_table(ann / "rec1.txt", ANN_HEADER, [[0, 3, "X"]]) _write_table(pred / "rec1.txt", PRED_HEADER, [[0, 3, "X", 0.9]]) # rec2 has no annotation file at all. - _write_table( - pred / "rec2.txt", PRED_HEADER, [[0, 3, "X", 0.8], [3, 6, "X", 0.7]] - ) + _write_table(pred / "rec2.txt", PRED_HEADER, [[0, 3, "X", 0.8], [3, 6, "X", 0.7]]) result = process_data( annotation_path=str(ann), @@ -67,9 +65,7 @@ def test_unmatched_prediction_file_scored_as_empty_when_opted_in(tmp_path, caplo ann, pred = _dirs(tmp_path) _write_table(ann / "rec1.txt", ANN_HEADER, [[0, 3, "X"]]) _write_table(pred / "rec1.txt", PRED_HEADER, [[0, 3, "X", 0.9]]) - _write_table( - pred / "rec2.txt", PRED_HEADER, [[0, 3, "X", 0.8], [3, 6, "X", 0.7]] - ) + _write_table(pred / "rec2.txt", PRED_HEADER, [[0, 3, "X", 0.8], [3, 6, "X", 0.7]]) result = process_data( annotation_path=str(ann), @@ -113,9 +109,7 @@ def test_empty_class_excluded_from_overall(tmp_path): ann, pred = _dirs(tmp_path) _write_table(ann / "rec1.txt", ANN_HEADER, [[0, 3, "X"]]) # Y is predicted but never annotated -> an empty class. - _write_table( - pred / "rec1.txt", PRED_HEADER, [[0, 3, "X", 0.9], [0, 3, "Y", 0.9]] - ) + _write_table(pred / "rec1.txt", PRED_HEADER, [[0, 3, "X", 0.9], [0, 3, "Y", 0.9]]) result = process_data( annotation_path=str(ann), @@ -314,9 +308,7 @@ def test_predictions_without_the_confidence_column_are_rejected(tmp_path): ) -def test_annotation_only_recording_is_kept_without_recording_duration( - tmp_path, caplog -): +def test_annotation_only_recording_is_kept_without_recording_duration(tmp_path, caplog): caplog.set_level(logging.WARNING, logger=DataProcessor.__module__) ann, pred = _dirs(tmp_path) _write_table(ann / "rec1.txt", ANN_HEADER, [[0, 3, "X"]]) diff --git a/tests/segments/test_segments.py b/tests/segments/test_segments.py index 9860d17c0..8fe76d720 100644 --- a/tests/segments/test_segments.py +++ b/tests/segments/test_segments.py @@ -1,3 +1,4 @@ +import logging import os import shutil import tempfile @@ -9,7 +10,6 @@ from birdnet_analyzer.segments.core import segments from birdnet_analyzer.segments.utils import ( _find_segments, - _find_segments_from_combined, parse_files, parse_folders, ) @@ -172,7 +172,7 @@ def test_kaleidoscope_results_of_2x_releases_are_still_read(tmp_path): "confidence": 0.8, } - assert _find_segments_from_combined(str(rfile)) == [expected] + assert _find_segments(None, str(rfile)) == [expected] assert _find_segments(audio, str(rfile)) == [expected] @@ -184,7 +184,7 @@ def test_kaleidoscope_rows_with_an_empty_indir_are_anchored_at_the_root(tmp_path encoding="utf-8", ) - assert [s["audio"] for s in _find_segments_from_combined(str(rfile))] == [ + assert [s["audio"] for s in _find_segments(None, str(rfile))] == [ os.path.join(os.sep, "audio", "x.wav") ] @@ -235,6 +235,207 @@ def test_parse_folders_matches_result_extensions_case_insensitively( assert flist == [{"audio": str(audio), "result": str(result)}] +def test_parse_folders_matches_the_birdnet_infix_case_insensitively(tmp_path): + audio = tmp_path / "X.wav" + result = tmp_path / "X.birdnet.results.csv" + audio.touch() + result.touch() + + assert parse_folders(str(tmp_path), str(tmp_path)) == [ + {"audio": str(audio), "result": str(result)} + ] + + +def test_parse_folders_ignores_the_case_of_stems_on_windows_only(tmp_path): + audio = tmp_path / "rec.wav" + result = tmp_path / "REC.BirdNET.results.csv" + audio.touch() + result.touch() + pairs = [{"audio": str(audio), "result": str(result)}] + + assert parse_folders(str(tmp_path), str(tmp_path)) == ( + pairs if os.name == "nt" else [] + ) + + +@pytest.mark.parametrize( + ("name", "text"), + [ + ( + "rec.BirdNET.selection.table.txt", + RAVEN_HEADER + "\n1\tSpectrogram 1\t1\t0\t3\t0\t15000\tAmerican Robin\t" + "Turdus migratorius\tamerob\t0.5\t{audio}\t0\n", + ), + ( + "rec.BirdNET.results.csv", + "Start (s),End (s),Scientific name,Common name,Confidence,File\n" + "0.0,3.0,Turdus migratorius,American Robin,0.5,{audio}\n", + ), + ( + "rec.BirdNET.results.kaleidoscope.csv", + "INDIR,FOLDER,IN FILE,OFFSET,DURATION,TOP1MATCH,TOP1DIST\n" + "{indir},{folder},rec.wav,0.0,3.0,American Robin,0.5\n", + ), + ( + "rec.BirdNET.results.txt", + "0.0\t3.0\tTurdus migratorius_American Robin\t0.5\n", + ), + ], + ids=["table", "csv", "kaleidoscope", "audacity"], +) +def test_blank_lines_in_a_result_file_are_skipped(tmp_path, name, text): + audio = tmp_path / "site" / "rec.wav" + rfile = tmp_path / name + first, _, rest = text.partition("\n") + rfile.write_text( + f"{first}\n\n{rest}\n \n".format( + audio=audio, indir=tmp_path, folder=audio.parent.name + ), + encoding="utf-8", + ) + + assert [ + (s["start"], s["end"], s["species"]) + for _, segs in parse_files([{"audio": str(audio), "result": str(rfile)}]) + for s in segs + ] == [(0.0, 3.0, "American Robin")] + + +def _flist(rfile, audio, combined): + if combined: + return [{"isCombinedFile": True, "result": str(rfile)}] + + return [{"audio": audio, "result": str(rfile)}] + + +@pytest.mark.parametrize("combined", [True, False]) +def test_a_header_only_table_holds_no_segment(tmp_path, combined): + rfile = tmp_path / "rec.BirdNET.selection.table.txt" + rfile.write_text(RAVEN_HEADER + "\n", encoding="utf-8") + + flist = _flist(rfile, str(tmp_path / "rec.wav"), combined) + + assert parse_files(flist, min_conf=0) == [] + + +def _empty_file_summaries(caplog): + return [ + r.getMessage().split() + for r in caplog.records + if r.levelno == logging.INFO and "empty" in r.getMessage() + ] + + +def test_an_empty_combined_result_file_is_skipped_with_a_warning(tmp_path, caplog): + rfile = tmp_path / "BirdNET_CombinedTable.csv" + rfile.touch() + caplog.set_level(logging.INFO) + + assert parse_files([{"isCombinedFile": True, "result": str(rfile)}]) == [] + assert [r.levelno for r in caplog.records if str(rfile) in r.getMessage()] == [ + logging.WARNING + ] + assert _empty_file_summaries(caplog) == [] + + +def test_an_empty_per_file_result_holds_no_detections(tmp_path, caplog): + rfile = tmp_path / "rec.BirdNET.results.txt" + rfile.touch() + flist = [{"audio": str(tmp_path / "rec.wav"), "result": str(rfile)}] + caplog.set_level(logging.INFO) + + assert parse_files(flist) == [] + assert [r for r in caplog.records if r.levelno >= logging.WARNING] == [] + assert [("1" in words) for words in _empty_file_summaries(caplog)] == [True] + + +@pytest.mark.parametrize("n_empty", [0, 2]) +def test_empty_per_file_results_are_counted_in_one_line(tmp_path, caplog, n_empty): + flist = [] + + for i in range(n_empty + 1): + rfile = tmp_path / f"rec{i}.BirdNET.results.txt" + rfile.write_text( + "" if i else "0.0\t3.0\tTurdus migratorius_American Robin\t0.5\n", + encoding="utf-8", + ) + flist.append({"audio": str(tmp_path / f"rec{i}.wav"), "result": str(rfile)}) + + caplog.set_level(logging.INFO) + + result = parse_files(flist) + + assert [(audio, len(segs)) for audio, segs in result] == [(flist[0]["audio"], 1)] + assert [r for r in caplog.records if r.levelno >= logging.WARNING] == [] + assert [(str(n_empty) in words) for words in _empty_file_summaries(caplog)] == ( + [True] if n_empty else [] + ) + + +@pytest.mark.parametrize( + ("label", "species"), + [ + ("Turdus migratorius_American Robin", "American Robin"), + ("Turdus migratorius, American Robin", "American Robin"), + ("Cetacea_Whales, Dolphins and Porpoises", "Whales, Dolphins and Porpoises"), + ("Turdus merula", "Turdus merula"), + ], + ids=["current", "legacy", "comma-in-common-name", "no-common-name"], +) +def test_audacity_labels_are_read(tmp_path, label, species): + audio = str(tmp_path / "rec.wav") + rfile = tmp_path / "rec.BirdNET.results.txt" + rfile.write_text(f"3.0\t6.0\t{label}\t0.5\n", encoding="utf-8") + + assert parse_files([{"audio": audio, "result": str(rfile)}]) == [ + ( + audio, + [ + { + "audio": audio, + "start": 3.0, + "end": 6.0, + "species": species, + "confidence": 0.5, + } + ], + ) + ] + + +@pytest.mark.parametrize("combined", [True, False]) +def test_the_nocall_rows_of_2_4_tables_are_no_segments(tmp_path, combined): + audio = str(tmp_path / "rec.wav") + rfile = tmp_path / "rec.BirdNET.selection.table.txt" + rfile.write_text( + f"{RAVEN_HEADER}\n" + "1\tSpectrogram 1\t1\t0\t3\t0\t15000\tnocall\tnocall\tnocall\t1.0\t" + f"{audio}\t0\n" + f"2\tSpectrogram 1\t1\t3\t6\t0\t15000\tAmerican Robin\tTurdus migratorius\t" + f"amerob\t0.5\t{audio}\t3\n", + encoding="utf-8", + ) + + result = parse_files(_flist(rfile, audio, combined)) + + assert [s["species"] for _, segs in result for s in segs] == ["American Robin"] + + +def test_rows_of_a_combined_table_without_an_audio_file_are_no_segments(tmp_path): + audio = str(tmp_path / "rec.wav") + rfile = tmp_path / "BirdNET_CombinedTable.csv" + rfile.write_text( + "Start (s),End (s),Scientific name,Common name,Confidence,File\n" + "0.0,3.0,Turdus migratorius,American Robin,0.5,\n" + f"3.0,6.0,Turdus migratorius,American Robin,0.5,{audio}\n", + encoding="utf-8", + ) + + result = parse_files([{"isCombinedFile": True, "result": str(rfile)}]) + + assert [(a, [s["start"] for s in segs]) for a, segs in result] == [(audio, [3.0])] + + @pytest.mark.parametrize("combined", [True, False]) def test_parse_files_drops_detections_above_max_conf(tmp_path, combined): audio = str(tmp_path / "rec.wav") diff --git a/tests/segments/test_utils.py b/tests/segments/test_utils.py index 19517778b..a5fa9049c 100644 --- a/tests/segments/test_utils.py +++ b/tests/segments/test_utils.py @@ -1,8 +1,9 @@ """Correctness and benchmark tests for the np.digitize-based confidence-bin assignment in birdnet_analyzer.segments.utils.parse_files. -The reference implementation below is the exact original nested-loop code -that was replaced, kept here so the tests remain self-contained. +The reference implementation below states the same binning rule as nested loops: +n_bins equal bins over [min_conf, max_conf], the last one closed, confidences +outside the range in the nearest bin. """ import timeit @@ -26,30 +27,21 @@ def _assign_balanced_bins_ref( max_segments: int, rng: np.random.Generator, ) -> list[dict]: - """Original O(n x m) implementation extracted verbatim from parse_files.""" - confidence_bins = [] - bin_thresholds = np.linspace(min_conf, max_conf, num=n_bins) - - for i in range(len(bin_thresholds)): - if i == 0: - confidence_bins.append((0, bin_thresholds[i])) - else: - confidence_bins.append((bin_thresholds[i - 1], bin_thresholds[i])) - - max_segments_per_bin = max_segments // len(confidence_bins) - segments_by_bin = {confidence_bin: [] for confidence_bin in confidence_bins} + """The binning rule as O(n x m) loops.""" + bin_edges = np.linspace(min_conf, max_conf, num=n_bins + 1) + max_segments_per_bin = max_segments // n_bins + segments_by_bin: list[list] = [[] for _ in range(n_bins)] segments.sort(key=lambda x: x["confidence"], reverse=True) for seg in segments: - for confidence_bin in confidence_bins: - if seg["confidence"] >= confidence_bin[1]: - continue - if seg["confidence"] >= confidence_bin[0]: - segments_by_bin[confidence_bin].append(seg) + for i in range(n_bins): + if seg["confidence"] < bin_edges[i + 1] or i == n_bins - 1: + segments_by_bin[i].append(seg) + break result = [] - for bin_segs in segments_by_bin.values(): + for bin_segs in segments_by_bin: if len(bin_segs) > max_segments_per_bin: rng.shuffle(bin_segs) result.extend(bin_segs[:max_segments_per_bin]) @@ -68,16 +60,15 @@ def _assign_balanced_bins_new( rng: np.random.Generator, ) -> list[dict]: """New np.digitize implementation extracted verbatim from parse_files.""" - bin_thresholds = np.linspace(min_conf, max_conf, num=n_bins) + bin_edges = np.linspace(min_conf, max_conf, num=n_bins + 1) max_segments_per_bin = max_segments // n_bins segments_by_bin: list[list] = [[] for _ in range(n_bins)] confidences = np.array([seg["confidence"] for seg in segments]) - bin_indices = np.digitize(confidences, bin_thresholds, right=False) + bin_indices = np.clip(np.digitize(confidences, bin_edges) - 1, 0, n_bins - 1) - for seg, bin_idx in zip(segments, bin_indices, strict=False): - if bin_idx < n_bins: - segments_by_bin[bin_idx].append(seg) + for seg, bin_idx in zip(segments, bin_indices, strict=True): + segments_by_bin[bin_idx].append(seg) result = [] for bin_segs in segments_by_bin: @@ -91,33 +82,26 @@ def _assign_balanced_bins_new( def _get_bin_contents(segments, min_conf, max_conf, n_bins): - """Return a list[frozenset] of segment ids for each bin, using new logic.""" - bin_thresholds = np.linspace(min_conf, max_conf, num=n_bins) + """Return a list[set] of segment ids for each bin, using new logic.""" + bin_edges = np.linspace(min_conf, max_conf, num=n_bins + 1) confidences = np.array([s["confidence"] for s in segments]) - indices = np.digitize(confidences, bin_thresholds, right=False) - bins: list[set] = [set() for _ in range(n_bins + 1)] - for seg, idx in zip(segments, indices, strict=False): + indices = np.clip(np.digitize(confidences, bin_edges) - 1, 0, n_bins - 1) + bins: list[set] = [set() for _ in range(n_bins)] + for seg, idx in zip(segments, indices, strict=True): bins[idx].add((seg["start"], seg["confidence"])) return bins def _get_bin_contents_ref(segments, min_conf, max_conf, n_bins): - """Return a list[set] of segment ids for each bin, using old logic.""" - bin_thresholds = np.linspace(min_conf, max_conf, num=n_bins) - confidence_bins = [] - for i in range(len(bin_thresholds)): - if i == 0: - confidence_bins.append((0, bin_thresholds[i])) - else: - confidence_bins.append((bin_thresholds[i - 1], bin_thresholds[i])) - bins: dict = {cb: set() for cb in confidence_bins} + """Return a list[set] of segment ids for each bin, using the reference loops.""" + bin_edges = np.linspace(min_conf, max_conf, num=n_bins + 1) + bins: list[set] = [set() for _ in range(n_bins)] for seg in segments: - for cb in confidence_bins: - if seg["confidence"] >= cb[1]: - continue - if seg["confidence"] >= cb[0]: - bins[cb].add((seg["start"], seg["confidence"])) - return list(bins.values()) + for i in range(n_bins): + if seg["confidence"] < bin_edges[i + 1] or i == n_bins - 1: + bins[i].add((seg["start"], seg["confidence"])) + break + return bins # --------------------------------------------------------------------------- @@ -180,14 +164,11 @@ def test_bin_contents_match_reference(self, n_segs, n_bins): new_bins = _get_bin_contents(segs, 0.25, 1.0, n_bins) ref_bins = _get_bin_contents_ref(segs, 0.25, 1.0, n_bins) - # new_bins has n_bins+1 entries (last = conf >= max_conf), ref has n_bins - assert len(ref_bins) == n_bins + assert len(ref_bins) == len(new_bins) == n_bins for i in range(n_bins): assert new_bins[i] == ref_bins[i], ( f"Bin {i} mismatch: new={new_bins[i]}, ref={ref_bins[i]}" ) - # new bin n_bins holds conf >= max_conf; should be empty for uniform(0.25, 1.0) - assert new_bins[n_bins] == set() def test_exact_same_output_when_no_bin_truncation(self): """When no bin is over-full both implementations return the same set. @@ -198,13 +179,10 @@ def test_exact_same_output_when_no_bin_truncation(self): """ n_bins = 5 n_per_bin = 3 - thresholds = np.linspace( - 0.25, 1.0, n_bins - ) # [0.25, 0.4375, 0.625, 0.8125, 1.0] - # Place n_per_bin confidences inside each of the n_bins-1 upper bins + edges = np.linspace(0.25, 1.0, n_bins + 1) # [0.25, 0.4, ..., 0.85, 1.0] confidences = [] - for i in range(1, n_bins): - lo, hi = float(thresholds[i - 1]), float(thresholds[i]) + for i in range(n_bins): + lo, hi = float(edges[i]), float(edges[i + 1]) confidences.extend(np.linspace(lo + 0.01, hi - 0.01, n_per_bin).tolist()) segs = _make_segments(confidences) @@ -223,24 +201,17 @@ def test_exact_same_output_when_no_bin_truncation(self): assert _seg_ids(result_new) == _seg_ids(result_ref) - def test_at_max_conf_excluded_in_both(self): - """ - Segment with confidence == max_conf must not appear in either implementation. - """ + def test_max_conf_lands_in_the_last_bin_in_both(self): segs = _make_segments([0.5, 1.0]) - rng_ref = np.random.default_rng(0) - rng_new = np.random.default_rng(0) - result_ref = _assign_balanced_bins_ref(list(segs), 0.25, 1.0, 5, 100, rng_ref) - result_new = _assign_balanced_bins_new(list(segs), 0.25, 1.0, 5, 100, rng_new) - assert all(s["confidence"] < 1.0 for s in result_ref) - assert all(s["confidence"] < 1.0 for s in result_new) + + new_bins = _get_bin_contents(segs, 0.25, 1.0, 5) + ref_bins = _get_bin_contents_ref(segs, 0.25, 1.0, 5) + + assert new_bins == ref_bins + assert new_bins[-1] == {(1.0, 1.0)} def test_below_min_conf_lands_in_bin_zero(self): - """Segments below min_conf go to bin 0 in both implementations (bin_0 - covers [0, min_conf) in the original, and digitize index 0 covers the same - range). In practice _find_segments pre-filters by min_conf so this bin - is always empty during normal operation. - """ + """Segments below min_conf go to bin 0 in both implementations.""" segs = _make_segments([0.1, 0.15, 0.2, 0.5, 0.8]) new_bins = _get_bin_contents(segs, 0.25, 1.0, 5) ref_bins = _get_bin_contents_ref(segs, 0.25, 1.0, 5) @@ -261,13 +232,13 @@ def test_respects_max_segments_per_bin(self): def test_boundary_at_bin_threshold_same_bin_in_both(self): """A confidence exactly at a threshold must go to the same bin in both.""" - n_bins = 4 - # thresholds = np.linspace(0.25, 1.0, n_bins) # [0.25, 0.5, 0.75, 1.0] - # 0.5 is exactly thresholds[1] + n_bins = 3 + # edges = np.linspace(0.25, 1.0, n_bins + 1) # [0.25, 0.5, 0.75, 1.0] segs = _make_segments([0.5]) new_bins = _get_bin_contents(segs, 0.25, 1.0, n_bins) ref_bins = _get_bin_contents_ref(segs, 0.25, 1.0, n_bins) - assert new_bins[:n_bins] == ref_bins + assert new_bins == ref_bins + assert new_bins[1] == {(0.0, 0.5)} def test_empty_input(self): rng = np.random.default_rng(0) @@ -283,14 +254,12 @@ def test_output_count_bounded_by_max_segments(self): result = _assign_balanced_bins_new(list(segs), 0.25, 1.0, 10, max_seg, rng) assert len(result) <= max_seg - def test_output_segment_confidences_in_valid_range(self): - """Every output segment must have confidence in [min_conf, max_conf).""" + def test_every_segment_lands_in_a_bin(self): confidences = RNG.uniform(0.1, 1.05, 200).tolist() segs = _make_segments(confidences) rng = np.random.default_rng(0) - result = _assign_balanced_bins_new(list(segs), 0.25, 1.0, 10, 200, rng) - for s in result: - assert 0.0 <= s["confidence"] < 1.0 + result = _assign_balanced_bins_new(list(segs), 0.25, 1.0, 10, 2000, rng) + assert _seg_ids(result) == _seg_ids(segs) # --------------------------------------------------------------------------- @@ -365,6 +334,29 @@ def test_only_in_range_segments_in_output(self): assert len(all_segs) == 4 assert all(0.25 <= s["confidence"] < 1.0 for s in all_segs) + def test_detections_spread_over_the_range_fill_max_segments(self): + confidences = np.linspace(0.25, 1.0, 200).tolist() + flist = [{"audio": "fake.wav", "result": "fake.txt"}] + + with patch( + "birdnet_analyzer.segments.utils._find_segments", + return_value=self._mock_find_segments(confidences), + ): + result = parse_files(flist, max_segments=100, collection_mode="balanced") + + assert sum(len(segs) for _, segs in result) == 100 + + def test_a_detection_at_max_conf_is_kept(self): + flist = [{"audio": "fake.wav", "result": "fake.txt"}] + + with patch( + "birdnet_analyzer.segments.utils._find_segments", + return_value=self._mock_find_segments([0.5, 1.0]), + ): + result = parse_files(flist, collection_mode="balanced") + + assert sorted(s["confidence"] for _, segs in result for s in segs) == [0.5, 1.0] + # --------------------------------------------------------------------------- # Benchmarks diff --git a/tests/test_cli.py b/tests/test_cli.py index c1d948564..ad82d8c8f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,3 +40,23 @@ def test_the_cli_choices_are_the_values_of_their_config_type(parser, dest, value action = next(action for action in parser()._actions if action.dest == dest) assert set(action.choices) == set(get_args(values)) + + +def test_result_types_and_additional_columns_keep_their_order_without_repeats(): + args = cli.analyzer_parser().parse_args( + [ + "recordings", + "--rtype", + "parquet", + "table", + "parquet", + "--additional_columns", + "week", + "lat", + "model", + "lat", + ] + ) + + assert args.rtype == ["parquet", "table"] + assert args.additional_columns == ["week", "lat", "model"] diff --git a/tests/test_params.py b/tests/test_params.py index fa662ee75..71cdea21f 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -163,6 +163,16 @@ def test_the_values_are_returned_exactly_as_the_analysis_ran_with_them(tmp_path) assert kwargs["min_conf"] == 0 +def test_an_empty_selection_is_read_back_as_one(tmp_path): + file = tall_file( + tmp_path, analysis_values(**{"Result type(s)": "", "Additional columns": ""}) + ) + + kwargs = params.load_analysis_params(file) + + assert (kwargs["rtype"], kwargs["additional_columns"]) == ([], []) + + def test_files_of_an_analysis_are_read_back(tmp_path): file = tall_file( tmp_path, diff --git a/tests/train/test_train.py b/tests/train/test_train.py index a54818a8b..91a94dd6a 100644 --- a/tests/train/test_train.py +++ b/tests/train/test_train.py @@ -639,6 +639,53 @@ def test_the_params_file_records_the_autotune_metric(tmp_path): assert recorded["autotune_metric"] == "val_AUROC" +@pytest.mark.usefixtures("synthetic_training_data") +def test_the_params_file_reads_back_as_the_arguments_of_the_training( + monkeypatch, tmp_path +): + from birdnet_analyzer import params + from birdnet_analyzer.config import TRAIN_PARAMS_SUFFIX + from birdnet_analyzer.train import utils as train_utils + + for save in ("save_linear_classifier", "save_raven_model"): + monkeypatch.setattr(train_utils.model, save, lambda *args, **kwargs: None) + + arguments = { + "model_formats": ["tflite", "raven"], + "model_save_mode": "append", + "fmin": 150, + "fmax": 12000, + "audio_speed": 2.0, + "crop_mode": "segments", + "overlap": 1.5, + "autotune": False, + "autotune_trials": 7, + "autotune_n_splits": 3, + "autotune_n_repeats": 2, + "autotune_metric": "val_AUROC", + "epochs": 1, + "batch_size": 8, + "learning_rate": 0.0005, + "hidden_units": 16, + "dropout": 0.25, + "label_smoothing": True, + "mixup": True, + "use_focal_loss": True, + "focal_loss_gamma": 3.0, + "focal_loss_alpha": 0.5, + "upsampling_mode": "mean", + "upsampling_ratio": 0.5, + "birdnet": "3.0", + } + + train_utils.train_model("unused", output=str(tmp_path / "cc"), **arguments) + + assert params.load_train_params(str(tmp_path / "cc") + TRAIN_PARAMS_SUFFIX) == { + "classifier_name": "cc", + **arguments, + } + + @pytest.mark.usefixtures("synthetic_training_data") def test_off_grid_defaults_run_as_the_first_trial(monkeypatch, tmp_path): _, study = _autotune(