From 63002fc78e2537337b162ef23ac3d975eed99916 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Fri, 24 Jul 2026 15:16:27 +0200 Subject: [PATCH 1/2] BirdNET 3.0 support + 3.0 Geomodel --- birdnet_analyzer/analyze/core.py | 14 +++-- birdnet_analyzer/cli.py | 34 +++++++++--- birdnet_analyzer/config.py | 42 +++++++++++++- birdnet_analyzer/gui/analysis.py | 5 +- birdnet_analyzer/gui/presets.py | 8 ++- birdnet_analyzer/gui/utils.py | 61 ++++++++++++++++---- birdnet_analyzer/model_utils.py | 70 +++++++++++++++++++++-- tests/analyze/test_analyze.py | 95 ++++++++++++++++++++++++++++++-- tests/gui/test_presets.py | 8 +++ 9 files changed, 298 insertions(+), 39 deletions(-) diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index 362cbd836..6db786651 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -25,7 +25,7 @@ def analyze( output: str | None = None, *, model: str = "birdnet", - birdnet: ACOUSTIC_MODEL_VERSIONS = "2.4", + birdnet: ACOUSTIC_MODEL_VERSIONS = "3.0", min_conf: float = 0.25, classifier: str | None = None, cc_species_list: str | None = None, @@ -111,16 +111,19 @@ def analyze( species_list_file = slist if isinstance(slist, (str, Path)) else "" rtypes: list[RESULT_TYPES] = [rtype] if isinstance(rtype, str) else rtype - if lat is not None and lon is not None: + species_from_location = lat is not None and lon is not None + + if species_from_location: if slist is not None: raise ValueError( "Cannot use both location (lat/lon) and custom species list (slist) " "together." ) - slist = run_geomodel( - lat, lon, week=week, language=locale, threshold=sf_thresh - ).to_set() + # The geo model is global and uses its own taxonomy; run_inference reconciles + # its species with the selected acoustic model by scientific name, so the geo + # label language is irrelevant here and left at its default. + slist = run_geomodel(lat, lon, week=week, threshold=sf_thresh).to_set() predictions = run_inference( audio_input, @@ -139,6 +142,7 @@ def analyze( classifier=classifier, cc_species_list=cc_species_list, version=birdnet, + match_species_by_scientific_name=species_from_location, callback=on_update, n_workers=n_workers, n_producers=n_producers, diff --git a/birdnet_analyzer/cli.py b/birdnet_analyzer/cli.py index 36d743757..25b3a31eb 100644 --- a/birdnet_analyzer/cli.py +++ b/birdnet_analyzer/cli.py @@ -7,10 +7,15 @@ from birdnet.globals import ( ACOUSTIC_MODEL_VERSIONS, MODEL_LANGUAGE_EN_US, - MODEL_LANGUAGES, ) -from birdnet_analyzer.config import AUTOTUNE_METRICS, TRAINED_MODEL_OUTPUT_FORMATS +from birdnet_analyzer.config import ( + ALL_MODEL_LANGUAGES, + AUTOTUNE_METRICS, + DEFAULT_ACOUSTIC_MODEL_VERSION, + GEO_MODEL_LANGUAGES, + TRAINED_MODEL_OUTPUT_FORMATS, +) from birdnet_analyzer.logs import setup_logging SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__)) @@ -138,8 +143,8 @@ def birdnet_arg(): p = argparse.ArgumentParser(add_help=False) p.add_argument( "--birdnet", - default="2.4", - const="2.4", + default=DEFAULT_ACOUSTIC_MODEL_VERSION, + const=DEFAULT_ACOUSTIC_MODEL_VERSION, nargs="?", choices=get_args(ACOUSTIC_MODEL_VERSIONS), action=set_model_action("birdnet"), @@ -431,19 +436,25 @@ def min_conf_args(): return p -def locale_args(): +def locale_args(languages=None): """ Creates an argument parser for locale settings. This function creates an argument parser with a single argument `--locale` (or `-l`) which specifies the locale for translated species common names. - The default value is 'en' (US English). The available locale values include - 'af', 'en_UK', 'de', 'it', and others. + The default value is 'en_us' (US English). + + Args: + languages: The locale codes to offer. Defaults to the union of every model + version's languages; the birdnet library validates the concrete + (model version, locale) pair when the model is loaded. Pass a narrower + set for commands bound to a single model (e.g. the geo model). + Returns: argparse.ArgumentParser: An argument parser with the locale argument. """ p = argparse.ArgumentParser(add_help=False) - locale_choices = get_args(get_args(MODEL_LANGUAGES)[0]) + locale_choices = list(languages) if languages is not None else ALL_MODEL_LANGUAGES p.add_argument( "-l", "--locale", @@ -878,7 +889,12 @@ def species_parser(): """ parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, - parents=[species_list_args(), locale_args(), verbosity_args()], + parents=[ + species_list_args(), + # The species list comes from the geo model, so only its languages apply. + locale_args(languages=GEO_MODEL_LANGUAGES), + verbosity_args(), + ], ) parser.add_argument( diff --git a/birdnet_analyzer/config.py b/birdnet_analyzer/config.py index 95b1d061c..683772eea 100644 --- a/birdnet_analyzer/config.py +++ b/birdnet_analyzer/config.py @@ -1,11 +1,47 @@ import os -from typing import Literal +from typing import Literal, get_args + +from birdnet.globals import ( + ACOUSTIC_MODEL_VERSIONS, + GEO_MODEL_VERSIONS, + MODEL_LANGUAGE_EN_US, + MODEL_LANGUAGES, + VALID_MODEL_LANGUAGES_V2_4, + VALID_MODEL_LANGUAGES_V3_0, +) + + +def _newest_version(versions: tuple[str, ...]) -> str: + """Return the highest ``"."`` version from the given tuple.""" + return max(versions, key=lambda v: tuple(int(part) for part in v.split("."))) -from birdnet.globals import MODEL_LANGUAGE_EN_US, MODEL_LANGUAGES SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__)) RANDOM_SEED: int = 42 -MODEL_VERSION: str = "V2.4" + +# The acoustic/geo model version used by default. Derived from the versions the +# installed birdnet library ships, so the analyzer follows the newest model without +# a code change. The geo model is not offered as a choice: its newest version always +# replaces the older ones (see birdnet_analyzer.model_utils.run_geomodel). +DEFAULT_ACOUSTIC_MODEL_VERSION: str = _newest_version(get_args(ACOUSTIC_MODEL_VERSIONS)) +DEFAULT_GEO_MODEL_VERSION: str = _newest_version(get_args(GEO_MODEL_VERSIONS)) + +# The languages each acoustic model version can label its predictions in. v2.4 and +# v3.0 support different sets, so the CLI/GUI offer their union and the birdnet +# library validates the concrete (version, language) pair when a model is loaded. +ACOUSTIC_MODEL_LANGUAGES: dict[str, list[str]] = { + "2.4": list(VALID_MODEL_LANGUAGES_V2_4), + "3.0": list(VALID_MODEL_LANGUAGES_V3_0), +} +ALL_MODEL_LANGUAGES: list[str] = sorted( + set(VALID_MODEL_LANGUAGES_V2_4) | set(VALID_MODEL_LANGUAGES_V3_0) +) + +# Languages the (always newest) geo model can label its species list in. The v3.0 +# geo and acoustic models share the same language set. +GEO_MODEL_LANGUAGES: list[str] = list(VALID_MODEL_LANGUAGES_V3_0) + +MODEL_VERSION: str = f"V{DEFAULT_ACOUSTIC_MODEL_VERSION}" SCORE_FUNCTIONS = Literal["cosine", "euclidean", "dot"] CROP_MODES = Literal["center", "first", "segments"] CODES_FILE: str = os.path.join(SCRIPT_DIR, "eBird_taxonomy_codes_2024E.json") diff --git a/birdnet_analyzer/gui/analysis.py b/birdnet_analyzer/gui/analysis.py index bdca21c49..2183993c7 100644 --- a/birdnet_analyzer/gui/analysis.py +++ b/birdnet_analyzer/gui/analysis.py @@ -110,6 +110,9 @@ def run_analysis( custom_classifier_file if selected_model == gu._CUSTOM_CLASSIFIER else None ) use_perch = selected_model == gu._USE_PERCH + # Custom classifiers are trained on 2.4 embeddings and perch ignores the version, + # so only an explicit BirdNET model choice changes the acoustic model version. + birdnet_version = gu.birdnet_version(selected_model) slist = species_list_file if species_list_choice == gu._CUSTOM_SPECIES else None lat = lat if species_list_choice == gu._PREDICT_SPECIES else None # ty:ignore[invalid-assignment] lon = lon if species_list_choice == gu._PREDICT_SPECIES else None # ty:ignore[invalid-assignment] @@ -149,7 +152,7 @@ def run_analysis( merge_consecutive=merge_consecutive, additional_columns=additional_columns, model="perch" if use_perch else "birdnet", - birdnet="2.4", + birdnet=birdnet_version, classifier=custom_classifier, cc_species_list=None, # always default search path in GUI currently on_update=on_update, diff --git a/birdnet_analyzer/gui/presets.py b/birdnet_analyzer/gui/presets.py index b97faae9d..5f5d5823c 100644 --- a/birdnet_analyzer/gui/presets.py +++ b/birdnet_analyzer/gui/presets.py @@ -364,8 +364,12 @@ def load_analysis_params(path: str) -> dict[str, Any]: elif kwargs.get("model") == "perch": values["model_selection_radio"] = _species_choice("use-perch") elif "model" in kwargs: - # Not localized, must match gui.utils._USE_BIRDNET_2_4. - values["model_selection_radio"] = "BirdNET 2.4" + # Not localized, must match gui.utils._USE_BIRDNET_2_4 / _USE_BIRDNET_3_0. + # Files written before 3.0 have no version and were always 2.4. + version = str(kwargs.get("birdnet", "2.4")) + values["model_selection_radio"] = ( + "BirdNET 3.0" if version == "3.0" else "BirdNET 2.4" + ) return values diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index 0192e82a0..4dce64bbe 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -14,8 +14,9 @@ import gradio as gr import webview -from birdnet.globals import MODEL_LANGUAGE_EN_US, MODEL_LANGUAGES +from birdnet.globals import ACOUSTIC_MODEL_VERSIONS, MODEL_LANGUAGE_EN_US +import birdnet_analyzer.config as cfg import birdnet_analyzer.gui.localization as loc import birdnet_analyzer.gui.state as gs from birdnet_analyzer import settings, utils @@ -31,7 +32,14 @@ _CUSTOM_CLASSIFIER = loc.localize("species-list-radio-option-custom-classifier") _ALL_SPECIES = loc.localize("species-list-radio-option-all") _USE_PERCH = loc.localize("species-list-radio-option-use-perch") +# BirdNET acoustic model choices. Not localized: the version number is the label. _USE_BIRDNET_2_4 = "BirdNET 2.4" +_USE_BIRDNET_3_0 = "BirdNET 3.0" +_BIRDNET_MODEL_VERSIONS: dict[str, str] = { + _USE_BIRDNET_2_4: "2.4", + _USE_BIRDNET_3_0: "3.0", +} + _WINDOW: webview.Window | None = None _URL = "" _HEART_LOGO = "data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgd2lkdGg9IjE2IiBkYXRhLXZpZXctY29tcG9uZW50PSJ0cnVlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPg0KICAgIDxwYXRoIGQ9Im04IDE0LjI1LjM0NS42NjZhLjc1Ljc1IDAgMCAxLS42OSAwbC0uMDA4LS4wMDQtLjAxOC0uMDFhNy4xNTIgNy4xNTIgMCAwIDEtLjMxLS4xNyAyMi4wNTUgMjIuMDU1IDAgMCAxLTMuNDM0LTIuNDE0QzIuMDQ1IDEwLjczMSAwIDguMzUgMCA1LjUgMCAyLjgzNiAyLjA4NiAxIDQuMjUgMSA1Ljc5NyAxIDcuMTUzIDEuODAyIDggMy4wMiA4Ljg0NyAxLjgwMiAxMC4yMDMgMSAxMS43NSAxIDEzLjkxNCAxIDE2IDIuODM2IDE2IDUuNWMwIDIuODUtMi4wNDUgNS4yMzEtMy44ODUgNi44MThhMjIuMDY2IDIyLjA2NiAwIDAgMS0zLjc0NCAyLjU4NGwtLjAxOC4wMS0uMDA2LjAwM2gtLjAwMlpNNC4yNSAyLjVjLTEuMzM2IDAtMi43NSAxLjE2NC0yLjc1IDMgMCAyLjE1IDEuNTggNC4xNDQgMy4zNjUgNS42ODJBMjAuNTggMjAuNTggMCAwIDAgOCAxMy4zOTNhMjAuNTggMjAuNTggMCAwIDAgMy4xMzUtMi4yMTFDMTIuOTIgOS42NDQgMTQuNSA3LjY1IDE0LjUgNS41YzAtMS44MzYtMS40MTQtMy0yLjc1LTMtMS4zNzMgMC0yLjYwOS45ODYtMy4wMjkgMi40NTZhLjc0OS43NDkgMCAwIDEtMS40NDIgMEM2Ljg1OSAzLjQ4NiA1LjYyMyAyLjUgNC4yNSAyLjVaIj48L3BhdGg+DQo8L3N2Zz4=" # noqa: E501 @@ -72,6 +80,16 @@ } +def is_birdnet_model(model_choice: str) -> bool: + """Whether the selected model is an official BirdNET acoustic model.""" + return model_choice in _BIRDNET_MODEL_VERSIONS + + +def birdnet_version(model_choice: str) -> str: + """The acoustic model version for a BirdNET model choice (falls back to 2.4).""" + return _BIRDNET_MODEL_VERSIONS.get(model_choice, "2.4") + + def spectrogram_settings() -> dict: """Reads the spectrogram settings the user chose in the settings tab. @@ -534,20 +552,41 @@ def on_tab_select(value: gr.SelectData): def model_choices(): - """Returns the models that can be selected on the current platform.""" - values = [_USE_BIRDNET_2_4, _CUSTOM_CLASSIFIER, _USE_PERCH] + """Returns the models that can be selected on the current platform. + + The BirdNET acoustic versions are taken from the installed birdnet library, newest + first, so a new model becomes selectable (and the default) without a code change. + """ + available = get_args(ACOUSTIC_MODEL_VERSIONS) + birdnet_models = [ + label + for label, version in ( + (_USE_BIRDNET_3_0, "3.0"), + (_USE_BIRDNET_2_4, "2.4"), + ) + if version in available + ] + + values = [*birdnet_models, _CUSTOM_CLASSIFIER, _USE_PERCH] if platform.system() == "Darwin": - values.pop() # TODO: Remove when tf 2.21+ is available on macOS + values.remove(_USE_PERCH) # TODO: Remove when tf 2.21+ is available on macOS return values +def default_model(): + """The model selected by default: the newest available BirdNET acoustic model.""" + choices = model_choices() + + return _USE_BIRDNET_3_0 if _USE_BIRDNET_3_0 in choices else choices[0] + + def sample_species_model_settings(state: TabState, opened=True): # The model decides which sample and species settings are available, so it has to # be known before those are built, even though it is shown below them. is_perch = ( - state.get("model_selection_radio", _USE_BIRDNET_2_4, choices=model_choices()) + state.get("model_selection_radio", default_model(), choices=model_choices()) == _USE_PERCH ) @@ -748,12 +787,12 @@ def locale(state: TabState, visible=True): Returns: The dropdown element. """ - options = get_args(MODEL_LANGUAGES)[0] - + # The union of every acoustic model version's languages; the birdnet library + # validates the concrete (model version, locale) pair when the model is loaded. return state.persist( "locale_dropdown", gr.Dropdown, - choices=get_args(options), + choices=cfg.ALL_MODEL_LANGUAGES, value=cast("str", MODEL_LANGUAGE_EN_US), visible=visible, label=loc.localize("analyze-locale-dropdown-label"), @@ -963,7 +1002,7 @@ def model_selection(state: TabState, opened=True): "model_selection_radio", gr.Radio, choices=model_choices(), - value=_USE_BIRDNET_2_4, + value=default_model(), label=loc.localize("model-selection-radio-label"), info=loc.localize("model-selection-radio-info"), ) @@ -1015,7 +1054,7 @@ def on_custom_classifier_selection_click(): gr.update(value=labels, visible=True), ) - locale_settings = locale(state, visible=selected_model == _USE_BIRDNET_2_4) + locale_settings = locale(state, visible=is_birdnet_model(selected_model)) species_list_df = gr.List( value=[], @@ -1042,7 +1081,7 @@ def on_model_selection_change(choice: str, cc_state): return ( gr.update(visible=False), gr.update(visible=False), - gr.update(visible=choice == _USE_BIRDNET_2_4), + gr.update(visible=is_birdnet_model(choice)), ) model_selection_radio.change( diff --git a/birdnet_analyzer/model_utils.py b/birdnet_analyzer/model_utils.py index 9762fef85..97dbb2340 100644 --- a/birdnet_analyzer/model_utils.py +++ b/birdnet_analyzer/model_utils.py @@ -7,7 +7,7 @@ import birdnet if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Collection import numpy as np from birdnet.acoustic.inference.core.encoding.encoding_result import ( @@ -27,6 +27,48 @@ GLOBAL_PREFETCH_RATIO = 2 + +def _scientific_name(species_label: str) -> str: + """Return the scientific-name key of a ``"Scientific name_Common name"`` label.""" + return species_label.split("_", 1)[0] + + +def match_species_to_model( + requested_species: Collection[str], model_species: Collection[str] +) -> set[str]: + """Map requested species onto a model's labels by scientific name. + + The geo model and the acoustic model can use different taxonomies and label + languages, so their ``"Scientific name_Common name"`` strings rarely match + exactly even when they mean the same bird (the common name differs). The + scientific name is stable across both, so it is used as the join key. + + Returns the subset of ``model_species`` whose scientific name also occurs in + ``requested_species`` - i.e. the labels the acoustic model actually knows, which + is what its custom species list requires (an unknown species raises in the + library). This lets the (global) geo model filter any acoustic model version. + + Args: + requested_species: Species names to keep, e.g. a geo model prediction. + model_species: The acoustic model's own species labels. + + Returns: + The matching labels, taken verbatim from ``model_species``. + """ + model_by_scientific_name: dict[str, str] = {} + for label in model_species: + # First label wins should a scientific name ever appear twice. + model_by_scientific_name.setdefault(_scientific_name(label), label) + + requested_scientific_names = {_scientific_name(name) for name in requested_species} + + return { + label + for scientific_name, label in model_by_scientific_name.items() + if scientific_name in requested_scientific_names + } + + # list of sessions so they can be cancelled from another # thread. Access is guarded by a lock # because sessions are registered from Gradio worker threads while @@ -93,7 +135,7 @@ def cancel_active_analyses() -> int: def run_inference( path, model="birdnet", - version: ACOUSTIC_MODEL_VERSIONS = "2.4", + version: ACOUSTIC_MODEL_VERSIONS = "3.0", top_k: int | None = 5, batch_size=1, n_workers: int | None = None, @@ -109,14 +151,17 @@ def run_inference( label_language: MODEL_LANGUAGES = "en_us", classifier: str | None = None, cc_species_list: str | None = None, + match_species_by_scientific_name: bool = False, callback: Callable[[AcousticProgressStats], None] | None = None, ) -> AcousticFilePredictionResult: if classifier: if not cc_species_list: cc_species_list = classifier.replace(".tflite", "_Labels.txt", 1) + # Custom classifiers are trained on 2.4 embeddings (training does not support + # 3.0 yet), so they are loaded on the 2.4 base regardless of ``version``. acoustic_model = birdnet.load_custom( - "acoustic", version, "tf", classifier, cc_species_list + "acoustic", "2.4", "tf", classifier, cc_species_list ) elif model == "birdnet": acoustic_model = birdnet.load("acoustic", version, "tf", lang=label_language) @@ -128,6 +173,14 @@ def run_inference( "use a custom classifier." ) + # A species list derived from the geo model can name species the acoustic model + # does not know (the geo model is global and uses a different taxonomy), which the + # library would reject. Reconcile it against the loaded model by scientific name. + if custom_species_list is not None and match_species_by_scientific_name: + custom_species_list = match_species_to_model( + custom_species_list, acoustic_model.species_list + ) + from birdnet.acoustic.inference.configs import InferenceConfig input_files = InferenceConfig.validate_input_files(path) @@ -160,7 +213,16 @@ def run_inference( def run_geomodel( lat, lon, week=None, language: MODEL_LANGUAGES = "en_us", threshold: float = 0.03 ) -> birdnet.GeoPredictionResult: - model = birdnet.load("geo", "2.4", "tf", lang=language) + from birdnet_analyzer.config import DEFAULT_GEO_MODEL_VERSION + + # The newest geo model replaces the older ones outright; it is never a choice. + # ``language`` only affects the localized species names, so callers that match on + # scientific name (e.g. acoustic species filtering) can leave it at the default. + # + # The pb (SavedModel) backend is used rather than tf: the v3.0 geo TFLite backend + # only supports TensorFlow 2.18/2.19, while we require >=2.20 - pb has no such + # version constraint and works across both geo model versions. + model = birdnet.load("geo", DEFAULT_GEO_MODEL_VERSION, "pb", lang=language) return model.predict(lat, lon, week=week, min_confidence=threshold) diff --git a/tests/analyze/test_analyze.py b/tests/analyze/test_analyze.py index 3c48d3976..4d3f9b917 100644 --- a/tests/analyze/test_analyze.py +++ b/tests/analyze/test_analyze.py @@ -230,6 +230,7 @@ def test_analyze_with_speed_up_and_overlap( analyze( soundscape_path, env["output_dir"], + birdnet="2.4", audio_speed=audio_speed, top_n=1, overlap=overlap, @@ -271,10 +272,13 @@ def test_analyze_with_additional_columns_parquet(setup_test_environment): assert os.path.exists(soundscape_path), "Soundscape file does not exist" - # Call function under test + # Call function under test. Pinned to the 2.4 acoustic model (its labels are the + # baseline the "model" column is checked against); the geo species filter still + # uses the newest geo model, matched onto 2.4 by scientific name. analyze( soundscape_path, env["output_dir"], + birdnet="2.4", top_n=1, min_conf=0, additional_columns=[ @@ -344,6 +348,7 @@ def test_analyze_with_additional_columns(setup_test_environment): analyze( soundscape_path, env["output_dir"], + birdnet="2.4", top_n=1, min_conf=0, additional_columns=[ @@ -413,7 +418,7 @@ def test_sensitivity(setup_test_environment): low_sensitivity_result = {} high_sensitivity_result = {} - analyze(soundscape_path, env["output_dir"], top_n=1, min_conf=0) + analyze(soundscape_path, env["output_dir"], birdnet="2.4", top_n=1, min_conf=0) output_file = os.path.join(env["output_dir"], "BirdNET_SelectionTable.txt") assert os.path.exists(output_file) @@ -429,13 +434,27 @@ def extract_confidence_from_output(output_file, result_dict): extract_confidence_from_output(output_file, normal_sensitivity_result) - analyze(soundscape_path, env["output_dir"], top_n=1, sensitivity=0.75, min_conf=0) + analyze( + soundscape_path, + env["output_dir"], + birdnet="2.4", + top_n=1, + sensitivity=0.75, + min_conf=0, + ) output_file = os.path.join(env["output_dir"], "BirdNET_SelectionTable.txt") assert os.path.exists(output_file) extract_confidence_from_output(output_file, low_sensitivity_result) - analyze(soundscape_path, env["output_dir"], top_n=1, sensitivity=1.25, min_conf=0) + analyze( + soundscape_path, + env["output_dir"], + birdnet="2.4", + top_n=1, + sensitivity=1.25, + min_conf=0, + ) output_file = os.path.join(env["output_dir"], "BirdNET_SelectionTable.txt") assert os.path.exists(output_file) @@ -456,3 +475,71 @@ def extract_confidence_from_output(output_file, result_dict): "High sensitivity confidence should be greater than or equal to normal " "sensitivity" ) + + +@patch("birdnet_analyzer.model_utils.run_geomodel") +@patch("birdnet_analyzer.model_utils.run_inference") +def test_analyze_defaults_to_birdnet_3_0_and_matches_geo_by_scientific_name( + mock_run_inference, mock_run_geomodel, setup_test_environment +): + """The default analysis uses the 3.0 model and reconciles the geo species list.""" + env = setup_test_environment + + mock_run_geomodel.return_value.to_set.return_value = {"Cardinalis cardinalis_x"} + mock_run_inference.return_value = object() + + analyze( + env["input_dir"], + env["output_dir"], + lat=42.5, + lon=-76.45, + week=20, + _return_only=True, + ) + + mock_run_geomodel.assert_called_once() + mock_run_inference.assert_called_once() + call_kwargs = mock_run_inference.call_args.kwargs + assert call_kwargs["version"] == "3.0" + assert call_kwargs["match_species_by_scientific_name"] is True + # The geo prediction is handed to run_inference to be matched onto the model. + assert call_kwargs["custom_species_list"] == {"Cardinalis cardinalis_x"} + + +@patch("birdnet_analyzer.model_utils.run_inference") +def test_analyze_without_location_does_not_match_by_scientific_name( + mock_run_inference, setup_test_environment +): + """Without lat/lon there is no geo species list to reconcile.""" + env = setup_test_environment + mock_run_inference.return_value = object() + + analyze(env["input_dir"], env["output_dir"], _return_only=True) + + call_kwargs = mock_run_inference.call_args.kwargs + assert call_kwargs["match_species_by_scientific_name"] is False + assert call_kwargs["custom_species_list"] is None + + +def test_match_species_to_model_joins_on_scientific_name(): + """Geo species are mapped onto a model's labels by scientific name only.""" + from birdnet_analyzer.model_utils import match_species_to_model + + model_species = [ + "Cardinalis cardinalis_Northern Cardinal", + "Turdus migratorius_American Robin", + "Astur gentilis_Eurasian Goshawk", + ] + # Common names differ between taxonomies and one request is a non-bird the model + # does not know; only the shared scientific names should survive, as model labels. + requested = { + "Cardinalis cardinalis_Cardenal Norteno", + "Astur gentilis_Northern Goshawk", + "Tibicina garricola_A Cicada", + } + + assert match_species_to_model(requested, model_species) == { + "Cardinalis cardinalis_Northern Cardinal", + "Astur gentilis_Eurasian Goshawk", + } + assert match_species_to_model(set(), model_species) == set() diff --git a/tests/gui/test_presets.py b/tests/gui/test_presets.py index 7795f1e65..dd196ff61 100644 --- a/tests/gui/test_presets.py +++ b/tests/gui/test_presets.py @@ -280,6 +280,14 @@ def test_the_analysis_params_of_a_previous_run_are_read_back( } +def test_a_birdnet_3_0_analysis_restores_the_3_0_model_choice(appdir, tmp_path): + values = presets.load_analysis_params( + params_file(tmp_path, **{"BirdNET version": "3.0"}) + ) + + assert values["model_selection_radio"] == "BirdNET 3.0" + + def test_a_top_n_analysis_does_not_restore_the_confidence_placeholder(appdir, tmp_path): # With top N in use the analysis runs without a confidence threshold and stores 0. values = presets.load_analysis_params( From 94259afcef4726faa85985462ff11abe7296ae10 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 27 Jul 2026 10:50:26 +0200 Subject: [PATCH 2/2] Copilot comments --- birdnet_analyzer/gui/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index 4dce64bbe..e8b0a9b96 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -554,8 +554,9 @@ def on_tab_select(value: gr.SelectData): def model_choices(): """Returns the models that can be selected on the current platform. - The BirdNET acoustic versions are taken from the installed birdnet library, newest - first, so a new model becomes selectable (and the default) without a code change. + The known BirdNET acoustic versions (newest first) are filtered down to those the + installed birdnet library ships, so a dropped version stops being offered without a + code change. A brand-new major version still needs a label added here. """ available = get_args(ACOUSTIC_MODEL_VERSIONS) birdnet_models = [