diff --git a/birdnet_analyzer/cli.py b/birdnet_analyzer/cli.py index de08dddcb..e1a78a156 100644 --- a/birdnet_analyzer/cli.py +++ b/birdnet_analyzer/cli.py @@ -932,10 +932,5 @@ def train_parser(): choices=get_args(AUTOTUNE_METRICS), help="Metric to optimize during hyperparameter tuning. This can be any metric that is returned by the training process and is included in the training history object. Common choices are 'val_loss', 'val_AUPRC' or 'val_AUROC'.", ) - parser.add_argument( - "--save_detached_classifier", - action="store_true", - help="Whether to additionally save a detached version of the trained classifier. This can be useful if you want to use the trained classifier in another model or application without having to load the entire model.", - ) return parser diff --git a/birdnet_analyzer/train/core.py b/birdnet_analyzer/train/core.py index 98c5ce2ff..7a01e56f7 100644 --- a/birdnet_analyzer/train/core.py +++ b/birdnet_analyzer/train/core.py @@ -45,7 +45,6 @@ def train( autotune_n_repeats: int = 1, autotune_n_splits: int = 3, autotune_metric: AUTOTUNE_METRICS = "val_AUPRC", - save_detached_classifier: bool = False, ): """ Trains a custom classifier model using the BirdNET-Analyzer framework. @@ -141,5 +140,4 @@ def train( autotune_n_repeats=autotune_n_repeats, autotune_n_splits=autotune_n_splits, autotune_metric=autotune_metric, - save_detached_classifier=save_detached_classifier, ) diff --git a/birdnet_analyzer/train/utils.py b/birdnet_analyzer/train/utils.py index c38626db2..c2a65968f 100644 --- a/birdnet_analyzer/train/utils.py +++ b/birdnet_analyzer/train/utils.py @@ -447,7 +447,6 @@ def train_model( on_epoch_end=None, on_trial_result=None, on_data_load_end=None, - save_detached_classifier: bool = False, ): """Trains a custom classifier. @@ -805,7 +804,7 @@ def generate_splits( model.save_detached_classifier( classifier, output, - labels=labels if model_save_mode == "append" else None, + labels=labels, ) except Exception as e: raise Exception("Error saving model") from e diff --git a/tests/analyze/test_analyze.py b/tests/analyze/test_analyze.py index e5f0634f8..ef531f4dc 100644 --- a/tests/analyze/test_analyze.py +++ b/tests/analyze/test_analyze.py @@ -3,6 +3,7 @@ import platform import shutil import tempfile +from unittest.mock import patch import birdnet import numpy as np @@ -10,6 +11,7 @@ import pytest from birdnet_analyzer.analyze.core import analyze +from birdnet_analyzer.cli import analyzer_parser @pytest.fixture @@ -31,6 +33,89 @@ def setup_test_environment(): shutil.rmtree(test_dir) +@patch("birdnet_analyzer.model_utils.run_inference") +def test_analyze_cli_accepts_full_parser_surface( + mock_run_inference, setup_test_environment +): + env = setup_test_environment + + mock_run_inference.return_value = object() + + parser = analyzer_parser() + species_list_path = os.path.join(env["test_dir"], "species_list.txt") + classifier_path = os.path.join(env["test_dir"], "classifier.tflite") + cc_species_list_path = os.path.join(env["test_dir"], "classifier_labels.txt") + args = parser.parse_args( + [ + env["input_dir"], + "--output", + env["output_dir"], + "--birdnet", + "2.4", + "--min_conf", + "0.1", + "--classifier", + classifier_path, + "--cc_species_list", + cc_species_list_path, + "--slist", + species_list_path, + "--sensitivity", + "1.2", + "--overlap", + "0.5", + "--fmin", + "100", + "--fmax", + "10000", + "--audio_speed", + "1.1", + "-b", + "4", + "--n_workers", + "2", + "--n_producers", + "3", + "--rtype", + "csv", + "parquet", + "--additional_columns", + "lat", + "lon", + "week", + "model", + "overlap", + "sensitivity", + "species_list", + "min_conf", + "--top_n", + "5", + "--merge_consecutive", + "2", + "--locale", + "de", + "--use_perch", + "--split_tables", + ] + ) + + kwargs = vars(args) + assert kwargs["use_perch"] is True + kwargs.pop("use_perch", None) + + analyze(**kwargs, _return_only=True) + + mock_run_inference.assert_called_once() + call_kwargs = mock_run_inference.call_args.kwargs + assert call_kwargs["top_k"] == 5 + assert call_kwargs["batch_size"] == 4 + assert call_kwargs["n_workers"] == 2 + assert call_kwargs["n_producers"] == 3 + assert call_kwargs["bandpass_fmin"] == 100 + assert call_kwargs["bandpass_fmax"] == 10000 + assert call_kwargs["sigmoid_sensitivity"] == 1.2 + + def test_analyze_with_real_custom_classifier(setup_test_environment): """Test analyzing with a real custom classifier.""" env = setup_test_environment diff --git a/tests/embeddings/test_embeddings.py b/tests/embeddings/test_embeddings.py index 3cb067ff1..9fb17bad2 100644 --- a/tests/embeddings/test_embeddings.py +++ b/tests/embeddings/test_embeddings.py @@ -71,6 +71,64 @@ def test_embeddings_cli( assert call_kwargs[1]["version"] == "2.4" +@patch("birdnet_analyzer.embeddings.core.create_csv_output") +@patch("birdnet_analyzer.embeddings.core._check_database_settings") +@patch("birdnet_analyzer.embeddings.core.get_or_create_database") +@patch("birdnet_analyzer.model_utils.get_embeddings") +def test_embeddings_cli_accepts_full_parser_surface( + mock_get_embeddings: MagicMock, + mock_get_db: MagicMock, + mock_check_settings: MagicMock, + mock_csv_output: MagicMock, + setup_test_environment, +): + env = setup_test_environment + + mock_get_embeddings.return_value = _make_empty_encoding_result() + mock_db = MagicMock() + mock_get_db.return_value = mock_db + + parser = embeddings_parser() + file_output = os.path.join(env["output_dir"], "embeddings.csv") + args = parser.parse_args( + [ + "--input", + env["input_dir"], + "-db", + env["output_dir"], + "--overlap", + "0.5", + "--audio_speed", + "1.1", + "--fmin", + "100", + "--fmax", + "10000", + "-b", + "4", + "--n_workers", + "2", + "--n_producers", + "3", + "--file_output", + file_output, + ] + ) + + embeddings(**vars(args)) + + mock_get_embeddings.assert_called_once() + call_kwargs = mock_get_embeddings.call_args.kwargs + assert call_kwargs["batch_size"] == 4 + assert call_kwargs["overlap_duration_s"] == 0.5 + assert call_kwargs["bandpass_fmin"] == 100 + assert call_kwargs["bandpass_fmax"] == 10000 + assert call_kwargs["speed"] == 1.1 + assert call_kwargs["n_workers"] == 2 + assert call_kwargs["n_producers"] == 3 + assert mock_csv_output.called + + @patch("birdnet_analyzer.embeddings.core._ensure_recording") @patch("birdnet_analyzer.embeddings.core._ensure_deployment") @patch("birdnet_analyzer.embeddings.core._check_database_settings") diff --git a/tests/search/test_search.py b/tests/search/test_search.py new file mode 100644 index 000000000..d56c081e2 --- /dev/null +++ b/tests/search/test_search.py @@ -0,0 +1,88 @@ +import os +import shutil +import tempfile +from unittest.mock import MagicMock, patch + +from birdnet_analyzer.cli import search_parser +from birdnet_analyzer.search.core import search + + +def _make_test_environment(): + test_dir = tempfile.mkdtemp() + input_dir = os.path.join(test_dir, "input") + output_dir = os.path.join(test_dir, "output") + + os.makedirs(input_dir, exist_ok=True) + os.makedirs(output_dir, exist_ok=True) + + return { + "test_dir": test_dir, + "input_dir": input_dir, + "output_dir": output_dir, + } + + +@patch("birdnet_analyzer.audio.save_signal") +@patch("birdnet_analyzer.audio.open_audio_file") +@patch("birdnet_analyzer.search.utils.get_search_results") +@patch("birdnet_analyzer.search.core.get_database") +def test_search_cli_accepts_full_parser_surface( + mock_get_database, + mock_get_search_results, + mock_open_audio_file, + mock_save_signal, +): + env = _make_test_environment() + try: + parser = search_parser() + args = parser.parse_args( + [ + "-q", + os.path.join(env["test_dir"], "query.wav"), + "-o", + env["output_dir"], + "--n_results", + "5", + "--score_function", + "dot", + "--crop_mode", + "segments", + "--audio_root", + env["input_dir"], + "-db", + os.path.join(env["test_dir"], "database.sqlite"), + "--overlap", + "0.5", + ] + ) + + mock_db = MagicMock() + mock_db.get_metadata.return_value = { + "BANDPASS_FMIN": 100, + "BANDPASS_FMAX": 10000, + "AUDIO_SPEED": 1.1, + "SIG_LENGTH": 3.0, + } + mock_db.get_window.return_value = MagicMock(recording_id=7, offsets=[1.0, 2.0]) + mock_db.get_recording.return_value = MagicMock(filename="query_source.wav") + mock_db.db = MagicMock() + mock_get_database.return_value = mock_db + + mock_get_search_results.return_value = [ + MagicMock(window_id=11, sort_score=0.12345) + ] + mock_open_audio_file.return_value = (b"signal", 48000) + + search(**vars(args)) + + mock_get_search_results.assert_called_once() + search_kwargs = mock_get_search_results.call_args.args + assert search_kwargs[0] == args.queryfile + assert search_kwargs[2] == 5 + assert search_kwargs[6] == "dot" + assert search_kwargs[7] == "segments" + assert search_kwargs[8] == 0.5 + mock_open_audio_file.assert_called_once() + mock_save_signal.assert_called_once() + finally: + shutil.rmtree(env["test_dir"]) diff --git a/tests/segments/test_segments.py b/tests/segments/test_segments.py index 40fb5e9fd..a7b1a28b4 100644 --- a/tests/segments/test_segments.py +++ b/tests/segments/test_segments.py @@ -82,3 +82,61 @@ def test_segments_cli( mock_parse_folders.assert_called_once() mock_parse_files.assert_called_once() mock_extract_segments.assert_called() + + +@patch("birdnet_analyzer.segments.utils.extract_segments") +@patch("birdnet_analyzer.segments.utils.parse_files") +@patch("birdnet_analyzer.segments.utils.parse_folders") +def test_segments_cli_accepts_full_parser_surface( + mock_parse_folders: MagicMock, + mock_parse_files: MagicMock, + mock_extract_segments: MagicMock, + setup_test_environment, +): + env = setup_test_environment + + parser = segments_parser() + args = parser.parse_args( + [ + env["input_dir"], + "--results", + env["results_dir"], + "--output", + env["output_dir"], + "--max_segments", + "5", + "--seg_length", + "4.5", + "--max_conf", + "0.9", + "--collection_mode", + "confidence", + "--n_bins", + "12", + "--audio_speed", + "1.25", + "--threads", + "1", + "--min_conf", + "0.15", + ] + ) + + mock_parse_files.return_value = [ + ( + env["file_list"][0]["audio"], + [{"start": 0, "end": 3, "species": "sp1", "confidence": 0.9}], + ), + ] + + segments(**vars(args)) + + mock_parse_folders.assert_called_once_with(env["input_dir"], env["results_dir"]) + mock_parse_files.assert_called_once() + parse_files_kwargs = mock_parse_files.call_args.kwargs + assert parse_files_kwargs["max_segments"] == 5 + assert parse_files_kwargs["collection_mode"] == "confidence" + assert parse_files_kwargs["n_bins"] == 12 + assert parse_files_kwargs["min_conf"] == 0.15 + assert parse_files_kwargs["max_conf"] == 0.9 + mock_extract_segments.assert_called_once() diff --git a/tests/species/test_species.py b/tests/species/test_species.py index dffab4a0f..4a247ffa8 100644 --- a/tests/species/test_species.py +++ b/tests/species/test_species.py @@ -40,3 +40,35 @@ def test_species_cli(mock_get_species_list, setup_test_environment): mock_get_species_list.assert_called_once_with( lat=-1, lon=-1, week=None, threshold=0.03, lang="en_us" ) + + +@patch("birdnet_analyzer.species.utils.get_species_list") +def test_species_cli_accepts_full_parser_surface( + mock_get_species_list, setup_test_environment +): + env = setup_test_environment + + mock_get_species_list.return_value = ["Species1", "Species2"] + + parser = species_parser() + args = parser.parse_args( + [ + "--lat", + "42.5", + "--lon", + "-76.45", + "--week", + "20", + "--sf_thresh", + "0.12", + "--locale", + "de", + env["output_dir"], + ] + ) + + species(**vars(args)) + + mock_get_species_list.assert_called_once_with( + lat=42.5, lon=-76.45, week=20, threshold=0.12, lang="de" + ) diff --git a/tests/train/test_train.py b/tests/train/test_train.py index 011d336fc..476a1d24e 100644 --- a/tests/train/test_train.py +++ b/tests/train/test_train.py @@ -50,6 +50,89 @@ def test_train_cli(mock_train_model, setup_test_environment): assert mock_train_model.call_args[0][0] == env["input_dir"] +@patch("birdnet_analyzer.train.utils.train_model") +def test_train_cli_accepts_full_parser_surface( + mock_train_model, setup_test_environment + ): + env = setup_test_environment + + parser = train_parser() + cache_path = os.path.join(env["test_dir"], "train_cache.npz") + args = parser.parse_args( + [ + env["input_dir"], + "--test_data", + env["output_dir"], + "--crop_mode", + "smart", + "-o", + env["classifier_output"], + "--epochs", + "3", + "--val_split", + "0.25", + "--learning_rate", + "0.001", + "--focal-loss", + "--focal-loss-gamma", + "2.5", + "--focal-loss-alpha", + "0.5", + "--hidden_units", + "16", + "--dropout", + "0.3", + "--label_smoothing", + "--mixup", + "--upsampling_ratio", + "0.5", + "--upsampling_mode", + "mean", + "--model_formats", + "tflite", + "raven", + "detached", + "--model_save_mode", + "append", + "--save_cache_to", + cache_path, + "--fmin", + "100", + "--fmax", + "10000", + "--audio_speed", + "1.1", + "--threads", + "2", + "--overlap", + "1.5", + "-b", + "4", + "--autotune", + "--autotune_trials", + "3", + "--autotune_n_repeats", + "2", + "--autotune_n_splits", + "2", + "--autotune_metric", + "val_loss", + ] + ) + + train(**vars(args)) + + mock_train_model.assert_called_once() + call_kwargs = mock_train_model.call_args[1] + assert call_kwargs["test_data"] == env["output_dir"] + assert call_kwargs["crop_mode"] == "smart" + assert call_kwargs["model_formats"] == ["tflite", "raven", "detached"] + assert call_kwargs["model_save_mode"] == "append" + assert call_kwargs["save_cache_to"] == cache_path + assert call_kwargs["autotune"] is True + assert call_kwargs["autotune_metric"] == "val_loss" + + def _make_dummy_history(): class DummyHistory: history = {"val_AUPRC": [0.123]} # noqa: RUF012