diff --git a/.gitignore b/.gitignore index 2e6d5d136..26a556926 100644 --- a/.gitignore +++ b/.gitignore @@ -181,6 +181,8 @@ autotune/ gui-settings.json state.json BirdNET_analysis_params.csv +birdnet.analyze-params.csv +*.birdnet.train-params.csv # Build files entitlements.plist \ No newline at end of file diff --git a/birdnet_analyzer/analyze/cli.py b/birdnet_analyzer/analyze/cli.py index 5bc5caadf..aa7356052 100644 --- a/birdnet_analyzer/analyze/cli.py +++ b/birdnet_analyzer/analyze/cli.py @@ -9,11 +9,12 @@ def main(): import os from multiprocessing import freeze_support - from birdnet_analyzer import cli + from birdnet_analyzer import cli, params freeze_support() parser = cli.analyzer_parser() + cli.apply_params_file_defaults(parser, params.load_analysis_params) args = parser.parse_args() with contextlib.suppress(Exception): @@ -40,5 +41,6 @@ def main(): analyze_args = vars(args) analyze_args.pop("use_perch") # handled via model param + analyze_args.pop("load_params") # already applied as defaults analyze(**analyze_args) diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index 727b8046c..362cbd836 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -106,7 +106,7 @@ def analyze( """ import birdnet_analyzer.config as cfg from birdnet_analyzer.model_utils import run_geomodel, run_inference - from birdnet_analyzer.utils import save_params_to_file + from birdnet_analyzer.utils import save_params_file species_list_file = slist if isinstance(slist, (str, Path)) else "" rtypes: list[RESULT_TYPES] = [rtype] if isinstance(rtype, str) else rtype @@ -227,62 +227,38 @@ def analyze( ) if save_params: - save_params_to_file( + save_params_file( Path(output) / cfg.ANALYSIS_PARAMS_FILENAME, - ( - "Model", - "BirdNET version", - "Segment length", - "Sample rate", - "Segment overlap", - "Bandpass filter minimum", - "Bandpass filter maximum", - "Merge consecutive detections", - "Audio speed", - "Minimum confidence", - "Sensitivity", - "Top N", - "Batch size", - "Number of workers", - "Number of producers", - "Result type(s)", - "Latitude", - "Longitude", - "Week", - "Species filter threshold", - "Species list file", - "Locale", - "Custom classifier path", - "Custom classifier species list", - "Split tables", - ), - ( - model, - birdnet, - predictions.segment_duration_s, - predictions.model_sr, - overlap, - fmin, - fmax, - merge_consecutive, - audio_speed, - min_conf, - sensitivity, - top_n or "", - batch_size, - n_workers or "", - n_producers, - ", ".join(rtypes), - lat or "", - lon or "", - week or "", - sf_thresh, - species_list_file or "", - locale, - classifier or "", - cc_species_list or "", - split_tables, - ), + { + "Model": model, + "BirdNET version": birdnet, + "Segment length": predictions.segment_duration_s, + "Sample rate": predictions.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": lat or "", + "Longitude": lon or "", + "Week": week or "", + "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, + }, ) return predictions diff --git a/birdnet_analyzer/cli.py b/birdnet_analyzer/cli.py index c141dfd30..afa468f5f 100644 --- a/birdnet_analyzer/cli.py +++ b/birdnet_analyzer/cli.py @@ -41,6 +41,60 @@ """ # noqa: W291 +def apply_params_file_defaults(parser, loader, argv=None): + """Makes the values of a ``--load_params`` file the defaults of a parser. + + Reads the file before the actual parsing, so arguments given on the command line + override the values from the file, which in turn override the built-in defaults. + Values the parser has no argument for are ignored. + + Args: + parser: The fully built argument parser. + loader: Reads the parameters file into keyword arguments, e.g. + :func:`birdnet_analyzer.params.load_analysis_params`. + argv: The command line to read the file path from. Defaults to ``sys.argv``. + """ + pre_parser = argparse.ArgumentParser(add_help=False) + pre_parser.add_argument("--load_params") + known, _ = pre_parser.parse_known_args(argv) + + if not known.load_params: + return + + try: + values = loader(known.load_params) + except ValueError as e: + parser.error(str(e)) + + dests = {action.dest for action in parser._actions} + parser.set_defaults(**{key: value for key, value in values.items() if key in dests}) + + +def load_params_args(run: str, files_hint: str): + """ + Creates an argument parser for reading the settings of a previous run. + + Args: + run: What the parameters file belongs to, e.g. "analysis". + files_hint: The file names to point the user to. + + Returns: + argparse.ArgumentParser: The argument parser with the `--load_params` + argument. + """ + p = argparse.ArgumentParser(add_help=False) + + p.add_argument( + "--load_params", + metavar="PARAMS_FILE", + help=f"Read default settings from the parameters file of a previous {run} " + f"({files_hint}). Arguments given on the command line take precedence. " + "Parameters files of earlier BirdNET-Analyzer versions are understood too.", + ) + + return p + + def store_model_action(model_name: str): class StoreModelAction(argparse.Action): def __init__( @@ -456,6 +510,7 @@ def analyzer_parser(): locale_args(), bs_args(), computing_resources_args(), + load_params_args("analysis", "birdnet.analyze-params.csv"), ] parser = argparse.ArgumentParser( @@ -509,7 +564,8 @@ def __call__(self, parser, namespace, values, option_string=None): ) parser.add_argument( "--split_tables", - action="store_true", + action=argparse.BooleanOptionalAction, + default=False, help="Saves separate result tables for each input audio file in the output.", ) parser.set_defaults(model="birdnet") @@ -793,6 +849,7 @@ def train_parser(): overlap_args( help_string="Overlap of training data segments in seconds if crop_mode is 'segments'." ), + load_params_args("training run", "*.birdnet.train-params.csv"), ], ) c = ( @@ -840,7 +897,8 @@ def train_parser(): parser.add_argument( "--focal-loss", dest="use_focal_loss", - action="store_true", + action=argparse.BooleanOptionalAction, + default=False, help="Use focal loss for training (helps with imbalanced classes and hard examples).", ) parser.add_argument( @@ -869,11 +927,15 @@ def train_parser(): ) parser.add_argument( "--label_smoothing", - action="store_true", + action=argparse.BooleanOptionalAction, + default=False, help="Whether to use label smoothing for training.", ) parser.add_argument( - "--mixup", action="store_true", help="Whether to use mixup for training." + "--mixup", + action=argparse.BooleanOptionalAction, + default=False, + help="Whether to use mixup for training.", ) parser.add_argument( "--upsampling_ratio", @@ -905,7 +967,8 @@ def train_parser(): ) parser.add_argument( "--autotune", - action="store_true", + action=argparse.BooleanOptionalAction, + default=False, help="Whether to use automatic hyperparameter tuning (this will execute multiple training runs to search for optimal hyperparameters).", ) parser.add_argument( diff --git a/birdnet_analyzer/config.py b/birdnet_analyzer/config.py index 5cb39e5c3..95b1d061c 100644 --- a/birdnet_analyzer/config.py +++ b/birdnet_analyzer/config.py @@ -28,7 +28,8 @@ OUTPUT_CSV_FILENAME: str = "BirdNET_CombinedTable.csv" OUTPUT_AUDACITY_FILENAME: str = "BirdNET_AudacityLabels.txt" OUTPUT_PARQUET_FILENAME: str = "BirdNET_CombinedTable.parquet" -ANALYSIS_PARAMS_FILENAME: str = "BirdNET_analysis_params.csv" +ANALYSIS_PARAMS_FILENAME: str = "birdnet.analyze-params.csv" +TRAIN_PARAMS_SUFFIX: str = ".birdnet.train-params.csv" LABEL_LANGUAGE: MODEL_LANGUAGES = MODEL_LANGUAGE_EN_US SAMPLE_CROP_MODES = Literal["center", "first", "segments", "smart"] NON_EVENT_CLASSES: list[str] = ["noise", "other", "background", "silence"] diff --git a/birdnet_analyzer/gui/multi_file.py b/birdnet_analyzer/gui/multi_file.py index 1c8cd781d..ef1a9b2e8 100644 --- a/birdnet_analyzer/gui/multi_file.py +++ b/birdnet_analyzer/gui/multi_file.py @@ -3,6 +3,7 @@ import birdnet_analyzer.gui.localization as loc import birdnet_analyzer.gui.utils as gu +from birdnet_analyzer.gui.presets import PresetControls, load_analysis_params from birdnet_analyzer.gui.state import TabState @@ -121,6 +122,14 @@ def build_multi_analysis_tab() -> gu.TAB_BUILDER_RESULT: title=loc.localize("multi-tab-info-title"), ) + preset_controls = PresetControls( + "multi", + params_loader=load_analysis_params, + params_button_label=loc.localize( + "presets-load-analyze-params-button-label" + ), + ) + with gr.Group(), gr.Row(equal_height=True): select_directory_btn = gr.Button( loc.localize("multi-tab-input-selection-button-label"), @@ -298,6 +307,13 @@ def show_additional_columns(values): inputs=output_type_radio, outputs=additional_columns_, ) + preset_controls.wire( + state, + species_file_input=species_settings["species_file_input"], + classifier_state=model_settings["selected_classifier_state"], + classifier_file_input=model_settings["classifier_file_input"], + classifier_labels_df=model_settings["classifier_labels_df"], + ) return ( species_settings["lat_number"], diff --git a/birdnet_analyzer/gui/presets.py b/birdnet_analyzer/gui/presets.py new file mode 100644 index 000000000..b97faae9d --- /dev/null +++ b/birdnet_analyzer/gui/presets.py @@ -0,0 +1,423 @@ +"""Named presets of the settings of a GUI tab. + +A preset stores the settings a tab persists between sessions (see +:mod:`birdnet_analyzer.gui.state`) under a name the user chooses, so a configuration +that belongs to a project can be recalled at any time. Both tabs can additionally +read their settings back from the parameters file a previous run saved: the analysis +from the ``birdnet.analyze-params.csv`` in its output directory, the training from +the ``.birdnet.train-params.csv`` next to the trained classifier. The +loaders also understand the files written before the 2.x releases +(``BirdNET_analysis_params.csv`` and ``_Params.csv``), which held the +same parameters in one column per parameter instead of one row. + +Presets of the multi-file tab also remember the species list and custom classifier +files. They are stored as paths: a path that no longer exists when the preset is +applied is skipped with a warning, like every other value the tab cannot take. +""" + +import os +from typing import TYPE_CHECKING, Any + +import gradio as gr + +import birdnet_analyzer.gui.localization as loc +from birdnet_analyzer import params, settings, utils + +if TYPE_CHECKING: + from birdnet_analyzer.gui.state import TabState + +# The file entries of a preset. They belong to components that are deliberately not +# persisted between sessions, so they live in the preset only. +SPECIES_FILE_KEY = "species_list_file" +CLASSIFIER_FILE_KEY = "custom_classifier_file" + + +class PresetControls: + """The preset controls of a single tab. + + Built in two steps: the constructor renders the controls where it is called, + :meth:`wire` attaches their handlers once every component of the tab exists. + """ + + def __init__(self, tab: str, params_loader=None, params_button_label=None): + """ + Args: + tab: The id of the tab the presets belong to, e.g. "multi". + params_loader: Reads the parameters file of a previous run into settings, + e.g. :func:`load_analysis_params`. If given, a button to load such a + file is shown, labelled with ``params_button_label``. + params_button_label: The label of the load-from-file button. + """ + self.tab = tab + self.load_params_file_button = None + self._params_loader = params_loader + + with ( + gr.Accordion(loc.localize("presets-accordion-label"), open=False), + gr.Group(), + ): + with gr.Group(), gr.Row(equal_height=True): + self.preset_dropdown = gr.Dropdown( + choices=settings.list_presets(tab), + value=None, + label=loc.localize("presets-dropdown-label"), + scale=4, + ) + self.load_button = gr.Button( + loc.localize("presets-load-button-label"), variant="primary" + ) + self.delete_button = gr.Button( + loc.localize("presets-delete-button-label") + ) + + with gr.Row(equal_height=True): + self.name_textbox = gr.Textbox( + label=loc.localize("presets-name-textbox-label"), + max_lines=1, + scale=5.2, # type: ignore + ) + self.save_button = gr.Button(loc.localize("presets-save-button-label")) + + if params_loader is not None: + self.load_params_file_button = gr.Button(params_button_label) + + def wire( + self, + state: "TabState", + species_file_input=None, + classifier_state=None, + classifier_file_input=None, + classifier_labels_df=None, + ): + """Attaches the handlers to the controls. + + Has to be called after every persisted component of the tab is built. The four + file components are either all passed (the multi-file tab) or all left out. + + Args: + state: The persisted settings of the tab. + species_file_input: The species list file component of the tab, if the + presets should include the selected species list. + classifier_state: The state holding the selected custom classifier. + classifier_file_input: The component showing the selected classifier. + classifier_labels_df: The list showing the labels of the classifier. + """ + components = state.components() + with_files = species_file_input is not None + file_inputs = [species_file_input, classifier_state] if with_files else [] + file_outputs = ( + [ + species_file_input, + classifier_state, + classifier_file_input, + classifier_labels_df, + ] + if with_files + else [] + ) + outputs = components + file_outputs + + def apply_values(values: dict[str, Any]) -> list: + values = dict(values) + species_file = values.pop(SPECIES_FILE_KEY, None) if with_files else None + classifier_file = ( + values.pop(CLASSIFIER_FILE_KEY, None) if with_files else None + ) + + updates, skipped = state.updates_for(values) + + if with_files: + updates += _file_updates(species_file, classifier_file, skipped) + + if skipped: + gr.Warning( + loc.localize("presets-skipped-values-warning") + + " " + + ", ".join(skipped) + ) + + return updates + + def on_save(name, *values): + name = (name or "").strip() + + if not settings.is_valid_preset_name(name): + raise gr.Error(loc.localize("presets-invalid-name-error")) + + preset = state.snapshot(values[: len(components)]) + + if with_files: + species_file, classifier_file = values[len(components) :] + + if species_file: + preset[SPECIES_FILE_KEY] = str(species_file) + if classifier_file: + preset[CLASSIFIER_FILE_KEY] = str(classifier_file) + + try: + settings.save_preset(self.tab, name, preset) + except OSError as e: + settings.write_error_log(e) + raise gr.Error(loc.localize("presets-save-failed-error")) from e + + gr.Info(loc.localize("presets-saved-info")) + + return gr.update(choices=settings.list_presets(self.tab), value=name) + + def on_load(name): + if not name: + gr.Warning(loc.localize("presets-none-selected-warning")) + return [gr.skip()] * len(outputs) + + preset = settings.load_preset(self.tab, name) + + if preset is None: + raise gr.Error(loc.localize("presets-missing-preset-error")) + + updates = apply_values(preset) + gr.Info(loc.localize("presets-loaded-info")) + + return updates + + def on_delete(name): + if not name: + gr.Warning(loc.localize("presets-none-selected-warning")) + return gr.skip() + + settings.delete_preset(self.tab, name) + gr.Info(loc.localize("presets-deleted-info")) + + return gr.update(choices=settings.list_presets(self.tab), value=None) + + self.save_button.click( + on_save, + inputs=[self.name_textbox, *components, *file_inputs], + outputs=self.preset_dropdown, + show_progress="hidden", + ) + self.load_button.click( + on_load, + inputs=self.preset_dropdown, + outputs=outputs, + show_progress="hidden", + ) + self.delete_button.click( + on_delete, + inputs=self.preset_dropdown, + outputs=self.preset_dropdown, + show_progress="hidden", + ) + + if self.load_params_file_button is not None: + + def on_load_params_file(): + import birdnet_analyzer.gui.utils as gu + + file = gu.select_file( + ("CSV (*.csv)",), state_key=f"{self.tab}-params-file" + ) + + if not file: + return [gr.skip()] * len(outputs) + + updates = apply_values(self._params_loader(file)) + gr.Info(loc.localize("presets-loaded-info")) + + return updates + + self.load_params_file_button.click( + on_load_params_file, outputs=outputs, show_progress="hidden" + ) + + +def _file_updates(species_file, classifier_file, skipped: list[str]) -> list: + """Builds the updates for the file components of the multi-file tab. + + A file that no longer exists is skipped and its component keeps its current value. + + Args: + species_file: The species list path stored in the preset, if any. + classifier_file: The classifier path stored in the preset, if any. + skipped: The keys skipped so far. Extended in place. + + Returns: + Updates for [species file input, classifier state, classifier file input, + classifier labels list]. + """ + species_update = gr.skip() + classifier_updates = [gr.skip(), gr.skip(), gr.skip()] + + if species_file: + if os.path.isfile(species_file): + species_update = gr.update(value=species_file) + else: + skipped.append(SPECIES_FILE_KEY) + + if classifier_file: + if os.path.isfile(classifier_file): + labels = utils.read_classifier_labels(classifier_file) + classifier_updates = [ + classifier_file, + gr.update(value=classifier_file, visible=True), + gr.update(value=labels, visible=True) + if labels + else gr.update(visible=False), + ] + else: + skipped.append(CLASSIFIER_FILE_KEY) + + return [species_update, *classifier_updates] + + +def _species_choice(option: str) -> str: + # The keys gui.utils localizes the species and model radio labels with. + return loc.localize(f"species-list-radio-option-{option}") + + +def _load_params_file(loader, path: str, error_key: str) -> dict[str, Any]: + try: + return loader(path) + except ValueError as e: + raise gr.Error(loc.localize(error_key)) from e + + +def _rename(kwargs: dict[str, Any], mapping: dict[str, str]) -> dict[str, Any]: + """Renames run parameters to the keys of the components showing them.""" + return { + component_key: kwargs[key] + for key, component_key in mapping.items() + if key in kwargs + } + + +def _speed_to_slider(speed: float) -> int: + # Undo gui.utils.slider_to_value: factors below 1 sit on the negative side. + return round(speed) if speed >= 1 else -round(1 / speed) + + +def load_analysis_params(path: str) -> dict[str, Any]: + """Reads the settings of a previous analysis back from its parameters file. + + Maps the parameters read by :func:`birdnet_analyzer.params.load_analysis_params` + onto the settings of the multi-file tab, undoing the transformations the GUI + applies when it starts an analysis (e.g. the audio speed factor back into the + slider value). + + Args: + path: The path to the parameters file. + + Returns: + The settings by key, as `TabState.updates_for` expects them. + + Raises: + gr.Error: If the file is not an analysis parameters file. + """ + kwargs = _load_params_file( + params.load_analysis_params, path, "presets-invalid-analyze-params-file-error" + ) + values = _rename( + kwargs, + { + "sensitivity": "sensitivity_slider", + "overlap": "overlap_slider", + "merge_consecutive": "merge_consecutive_slider", + "fmin": "fmin_number", + "fmax": "fmax_number", + "sf_thresh": "sf_thresh_number", + "batch_size": "batch_size_number", + "n_producers": "producers_number", + "n_workers": "workers_number", + "top_n": "top_n_input", + "lat": "lat_number", + "lon": "lon_number", + "week": "week_number", + "locale": "locale_dropdown", + "split_tables": "split_tables_checkbox", + "rtype": "output_type_checkboxgroup", + "additional_columns": "additional_columns_checkboxgroup", + }, + ) + + if "audio_speed" in kwargs: + values["audio_speed_slider"] = _speed_to_slider(kwargs["audio_speed"]) + + values["use_top_n_checkbox"] = "top_n" in kwargs + + # With top N in use the analysis ran without a confidence threshold and stored 0, + # which is not a value the confidence slider offers. + if "top_n" not in kwargs and "min_conf" in kwargs: + values["confidence_slider"] = kwargs["min_conf"] + + values["yearlong_checkbox"] = "week" not in kwargs + + if "slist" in kwargs: + values["species_list_radio"] = _species_choice("custom-list") + values[SPECIES_FILE_KEY] = kwargs["slist"] + elif "lat" in kwargs and "lon" in kwargs: + values["species_list_radio"] = _species_choice("predict-list") + else: + values["species_list_radio"] = _species_choice("all") + + if "classifier" in kwargs: + values["model_selection_radio"] = _species_choice("custom-classifier") + values[CLASSIFIER_FILE_KEY] = kwargs["classifier"] + 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" + + return values + + +def load_train_params(path: str) -> dict[str, Any]: + """Reads the settings of a previous training run back from its parameters file. + + Maps the parameters read by :func:`birdnet_analyzer.params.load_train_params` + onto the settings of the train tab. + + Args: + path: The path to the parameters file. + + Returns: + The settings by key, as `TabState.updates_for` expects them. + + Raises: + gr.Error: If the file is not a training parameters file. + """ + kwargs = _load_params_file( + params.load_train_params, path, "presets-invalid-train-params-file-error" + ) + values = _rename( + kwargs, + { + "classifier_name": "classifier_name_textbox", + "model_formats": "output_format_checkboxgroup", + "model_save_mode": "model_save_mode_radio", + "fmin": "fmin_number", + "fmax": "fmax_number", + "crop_mode": "crop_mode_radio", + "overlap": "crop_overlap_slider", + "autotune": "autotune_checkbox", + "autotune_trials": "autotune_trials_number", + "autotune_n_splits": "autotune_folds_number", + "autotune_n_repeats": "autotune_repeats_number", + "epochs": "epochs_number", + "batch_size": "batch_size_number", + "learning_rate": "learning_rate_number", + "hidden_units": "hidden_units_number", + "dropout": "dropout_number", + "label_smoothing": "use_label_smoothing_checkbox", + "mixup": "use_mixup_checkbox", + "use_focal_loss": "use_focal_loss_checkbox", + "focal_loss_gamma": "focal_loss_gamma_slider", + "focal_loss_alpha": "focal_loss_alpha_slider", + "upsampling_mode": "upsampling_mode_radio", + "upsampling_ratio": "upsampling_ratio_slider", + }, + ) + + if "audio_speed" in kwargs: + values["audio_speed_slider"] = _speed_to_slider(kwargs["audio_speed"]) + + return values diff --git a/birdnet_analyzer/gui/state.py b/birdnet_analyzer/gui/state.py index 5bad1ff62..61e099e43 100644 --- a/birdnet_analyzer/gui/state.py +++ b/birdnet_analyzer/gui/state.py @@ -12,7 +12,7 @@ settings file can never keep a tab from starting up. """ -from typing import Any +from typing import Any, NamedTuple import gradio as gr @@ -23,6 +23,16 @@ _PERSISTED: list[tuple[gr.components.Component, Any]] = [] +class _Field(NamedTuple): + """A persisted component and the constraints its values are validated against.""" + + component: gr.components.Component + default: Any + choices: Any + minimum: float | None + maximum: float | None + + def _validate( value, default, @@ -92,6 +102,7 @@ def __init__(self, tab: str) -> None: """ self.tab = tab self._values = settings.get_tab_settings(tab) + self._fields: dict[str, _Field] = {} def get( self, @@ -151,6 +162,13 @@ def persist(self, key: str, constructor, **kwargs): component = constructor(**kwargs) _PERSISTED.append((component, default)) + self._fields[key] = _Field( + component, + default, + kwargs.get("choices"), + kwargs.get("minimum"), + kwargs.get("maximum"), + ) # Only user edits are persisted. Values a component receives from another # component's event handler are derived from settings that are persisted @@ -168,6 +186,67 @@ def persist(self, key: str, constructor, **kwargs): return component + def components(self) -> list[gr.components.Component]: + """Returns every component persisted in this tab, in build order.""" + return [field.component for field in self._fields.values()] + + def snapshot(self, values) -> dict[str, Any]: + """Pairs the current component values with their setting keys. + + Args: + values: The current values of `components()`, in the same order. + + Returns: + The values by setting key, e.g. to be saved as a preset. + """ + return dict(zip(self._fields, values, strict=True)) + + def updates_for(self, values: dict) -> tuple[list[dict], list[str]]: + """Builds the component updates that show a set of persisted values. + + Every value is validated against the component it belongs to, exactly like a + value restored from a previous session. A value that does not fit -- or a key + no component was built for -- is skipped and the component keeps its current + value. The applied values are persisted, so they survive a restart just like + user edits. + + Args: + values: The values to show, by setting key. E.g. a saved preset. + + Returns: + A tuple of the updates (one per component, in build order) and the keys + whose values could not be applied. + """ + updates = [] + applied = {} + skipped = [key for key in values if key not in self._fields] + + for key, field in self._fields.items(): + if key not in values: + updates.append(gr.update()) + continue + + value = values[key] + accepted = _validate( + value, + field.default, + choices=field.choices, + minimum=field.minimum, + maximum=field.maximum, + ) + + # _validate hands the value object itself back iff the component takes it. + if accepted is value: + updates.append(gr.update(value=value)) + applied[key] = value + else: + updates.append(gr.update()) + skipped.append(key) + + settings.update_tab_settings(self.tab, applied) + + return updates, skipped + def persisted_components() -> list[gr.components.Component]: """Returns every component built through a `TabState`, in build order.""" diff --git a/birdnet_analyzer/gui/train.py b/birdnet_analyzer/gui/train.py index 3abd0fd3b..a89b14f62 100644 --- a/birdnet_analyzer/gui/train.py +++ b/birdnet_analyzer/gui/train.py @@ -7,6 +7,7 @@ import birdnet_analyzer.gui.localization as loc import birdnet_analyzer.gui.utils as gu from birdnet_analyzer import utils +from birdnet_analyzer.gui.presets import PresetControls, load_train_params from birdnet_analyzer.gui.state import TabState _GRID_MAX_HEIGHT = 240 @@ -267,6 +268,12 @@ def build_train_tab() -> gu.TAB_BUILDER_RESULT: title=loc.localize("training-tab-info-title"), ) + preset_controls = PresetControls( + "train", + params_loader=load_train_params, + params_button_label=loc.localize("presets-load-train-params-button-label"), + ) + with gr.Group(), gr.Row(equal_height=True): select_directory_btn = gr.Button( loc.localize("training-tab-input-selection-button-label"), @@ -963,6 +970,8 @@ def train_and_show_metrics(*args): outputs=[train_history_plot, metrics_table], ) + preset_controls.wire(state) + if __name__ == "__main__": gu.open_window(build_train_tab) diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index df99613d5..97613c610 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -400,23 +400,31 @@ def sample_species_model_settings(state: TabState, opened=True): species_settings = species_lists(state, opened=opened, is_perch=is_perch) model_settings = model_selection(state, opened=opened) - def on_species_list_change(value): + def on_species_list_change(value, species_choice): is_perch = value == _USE_PERCH + choices = ( + [_CUSTOM_SPECIES, _ALL_SPECIES] + if is_perch + else [_CUSTOM_SPECIES, _PREDICT_SPECIES, _ALL_SPECIES] + ) return ( gr.update(interactive=not is_perch), gr.update(maximum=4.9 if is_perch else 2.9), + # Keep the current species selection (e.g. the one a preset was just + # applied with) as long as the new model offers it. gr.update( - choices=[_CUSTOM_SPECIES, _ALL_SPECIES] - if is_perch - else [_CUSTOM_SPECIES, _PREDICT_SPECIES, _ALL_SPECIES], - value=_ALL_SPECIES, + choices=choices, + value=species_choice if species_choice in choices else _ALL_SPECIES, ), ) model_settings["model_selection_radio"].change( on_species_list_change, - inputs=model_settings["model_selection_radio"], + inputs=[ + model_settings["model_selection_radio"], + species_settings["species_list_radio"], + ], outputs=[ sample_settings["sensitivity_slider"], sample_settings["overlap_slider"], @@ -831,13 +839,9 @@ def on_custom_classifier_selection_click(): if not file: return None, None, None - base_name = os.path.splitext(file)[0] - labels = base_name + "_Labels.txt" - - if not os.path.isfile(labels): - labels = file.replace("Model_FP32.tflite", "Labels.txt") + labels = utils.read_classifier_labels(file) - if not os.path.isfile(labels): + if labels is None: gr.Warning( loc.localize( "species-list-custom-classifier-no-labelfile-warning" @@ -853,10 +857,7 @@ def on_custom_classifier_selection_click(): return ( file, gr.update(value=file, visible=True), - gr.update( - value=utils.read_lines(labels, fail_on_blank_lines=True), - visible=True, - ), + gr.update(value=labels, visible=True), ) locale_settings = locale(state, visible=selected_model == _USE_BIRDNET_2_4) @@ -899,6 +900,8 @@ def on_model_selection_change(choice: str, cc_state): return { "model_selection_radio": model_selection_radio, "selected_classifier_state": selected_classifier_state, + "classifier_file_input": classifier_file_input, + "classifier_labels_df": species_list_df, "locale_dropdown": locale_settings, } diff --git a/birdnet_analyzer/lang/de.json b/birdnet_analyzer/lang/de.json index e4cc777f1..bb78f02e7 100644 --- a/birdnet_analyzer/lang/de.json +++ b/birdnet_analyzer/lang/de.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "Wenn aktiviert, wird für jede Eingabedatei eine separate Ergebnistabelle erstellt. Andernfalls werden alle Ergebnisse in einer einzigen Tabelle zusammengefasst.", "multi-tab-split-table-checkbox-label": "Tabellen pro Eingabedatei aufteilen", "multi-tab-title": "Batch-Analyse", + "presets-accordion-label": "Voreinstellungen", + "presets-delete-button-label": "Voreinstellung löschen", + "presets-deleted-info": "Voreinstellung gelöscht.", + "presets-dropdown-label": "Gespeicherte Voreinstellungen", + "presets-invalid-analyze-params-file-error": "Die ausgewählte Datei ist keine gültige Analyseparameter-Datei.", + "presets-invalid-name-error": "Bitte einen gültigen Namen für die Voreinstellung eingeben.", + "presets-invalid-train-params-file-error": "Die ausgewählte Datei ist keine gültige Trainingsparameter-Datei.", + "presets-load-analyze-params-button-label": "Einstellungen aus einer Analyseparameter-Datei laden (birdnet.analyze-params.csv)", + "presets-load-button-label": "Voreinstellung laden", + "presets-load-train-params-button-label": "Einstellungen aus einer Trainingsparameter-Datei laden (*.birdnet.train-params.csv)", + "presets-loaded-info": "Einstellungen übernommen.", + "presets-missing-preset-error": "Die Voreinstellung konnte nicht gelesen werden.", + "presets-name-textbox-label": "Name der Voreinstellung", + "presets-none-selected-warning": "Bitte zuerst eine Voreinstellung auswählen.", + "presets-save-button-label": "Voreinstellung speichern", + "presets-save-failed-error": "Die Voreinstellung konnte nicht gespeichert werden.", + "presets-saved-info": "Voreinstellung gespeichert.", + "presets-skipped-values-warning": "Einige Werte konnten nicht übernommen werden und bleiben unverändert:", "progress-analyzing": "Analysiere", "progress-autotune": "Autotune läuft", "progress-build-classifier": "Daten laden & Klassifikator erstellen", diff --git a/birdnet_analyzer/lang/en.json b/birdnet_analyzer/lang/en.json index 7ed17374b..d88f6c136 100644 --- a/birdnet_analyzer/lang/en.json +++ b/birdnet_analyzer/lang/en.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "When enabled, a separate result table is created for each input file. Otherwise, all results are combined into a single table.", "multi-tab-split-table-checkbox-label": "Split tables by input file", "multi-tab-title": "Batch analysis", + "presets-accordion-label": "Presets", + "presets-delete-button-label": "Delete preset", + "presets-deleted-info": "Preset deleted.", + "presets-dropdown-label": "Saved presets", + "presets-invalid-analyze-params-file-error": "The selected file is not a valid analysis parameters file.", + "presets-invalid-name-error": "Please enter a valid preset name.", + "presets-invalid-train-params-file-error": "The selected file is not a valid training parameters file.", + "presets-load-analyze-params-button-label": "Load settings from an analysis parameters file (birdnet.analyze-params.csv)", + "presets-load-button-label": "Load preset", + "presets-load-train-params-button-label": "Load settings from a training parameters file (*.birdnet.train-params.csv)", + "presets-loaded-info": "Settings applied.", + "presets-missing-preset-error": "The preset could not be read.", + "presets-name-textbox-label": "Preset name", + "presets-none-selected-warning": "Please select a preset first.", + "presets-save-button-label": "Save preset", + "presets-save-failed-error": "The preset could not be saved.", + "presets-saved-info": "Preset saved.", + "presets-skipped-values-warning": "Some values could not be applied and were left unchanged:", "progress-analyzing": "Analyzing", "progress-autotune": "Autotune in progress", "progress-build-classifier": "Loading data & building classifier", diff --git a/birdnet_analyzer/lang/fi.json b/birdnet_analyzer/lang/fi.json index c9bc1cf7a..93c5bb4e5 100644 --- a/birdnet_analyzer/lang/fi.json +++ b/birdnet_analyzer/lang/fi.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "Kun tämä asetus on käytössä, jokaiselle syötetiedostolle luodaan oma tulostaulukko. Muussa tapauksessa kaikki tulokset yhdistetään yhteen taulukkoon.", "multi-tab-split-table-checkbox-label": "Jaa taulukot syötetiedoston mukaan", "multi-tab-title": "Eräanalyysi", + "presets-accordion-label": "Esiasetukset", + "presets-delete-button-label": "Poista esiasetus", + "presets-deleted-info": "Esiasetus poistettu.", + "presets-dropdown-label": "Tallennetut esiasetukset", + "presets-invalid-analyze-params-file-error": "Valittu tiedosto ei ole kelvollinen analyysiparametritiedosto.", + "presets-invalid-name-error": "Anna kelvollinen esiasetuksen nimi.", + "presets-invalid-train-params-file-error": "Valittu tiedosto ei ole kelvollinen koulutusparametritiedosto.", + "presets-load-analyze-params-button-label": "Lataa asetukset analyysiparametritiedostosta (birdnet.analyze-params.csv)", + "presets-load-button-label": "Lataa esiasetus", + "presets-load-train-params-button-label": "Lataa asetukset koulutusparametritiedostosta (*.birdnet.train-params.csv)", + "presets-loaded-info": "Asetukset otettu käyttöön.", + "presets-missing-preset-error": "Esiasetusta ei voitu lukea.", + "presets-name-textbox-label": "Esiasetuksen nimi", + "presets-none-selected-warning": "Valitse ensin esiasetus.", + "presets-save-button-label": "Tallenna esiasetus", + "presets-save-failed-error": "Esiasetusta ei voitu tallentaa.", + "presets-saved-info": "Esiasetus tallennettu.", + "presets-skipped-values-warning": "Joitakin arvoja ei voitu ottaa käyttöön, ja ne jäivät ennalleen:", "progress-analyzing": "Analysoidaan", "progress-autotune": "Autoviritys käynnissä", "progress-build-classifier": "Ladataan dataa & rakennetaan luokittelijaa", diff --git a/birdnet_analyzer/lang/fr.json b/birdnet_analyzer/lang/fr.json index afabeeac6..b1b7969ee 100644 --- a/birdnet_analyzer/lang/fr.json +++ b/birdnet_analyzer/lang/fr.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "Lorsqu'elle est activée, une table de résultats distincte est créée pour chaque fichier d'entrée. Sinon, tous les résultats sont regroupés dans une seule table.", "multi-tab-split-table-checkbox-label": "Séparer les tables par fichier d'entrée", "multi-tab-title": "Analyse par lots", + "presets-accordion-label": "Préréglages", + "presets-delete-button-label": "Supprimer le préréglage", + "presets-deleted-info": "Préréglage supprimé.", + "presets-dropdown-label": "Préréglages enregistrés", + "presets-invalid-analyze-params-file-error": "Le fichier sélectionné n'est pas un fichier de paramètres d'analyse valide.", + "presets-invalid-name-error": "Veuillez saisir un nom de préréglage valide.", + "presets-invalid-train-params-file-error": "Le fichier sélectionné n'est pas un fichier de paramètres d'entraînement valide.", + "presets-load-analyze-params-button-label": "Charger les paramètres depuis un fichier de paramètres d'analyse (birdnet.analyze-params.csv)", + "presets-load-button-label": "Charger le préréglage", + "presets-load-train-params-button-label": "Charger les paramètres depuis un fichier de paramètres d'entraînement (*.birdnet.train-params.csv)", + "presets-loaded-info": "Paramètres appliqués.", + "presets-missing-preset-error": "Le préréglage n'a pas pu être lu.", + "presets-name-textbox-label": "Nom du préréglage", + "presets-none-selected-warning": "Veuillez d'abord sélectionner un préréglage.", + "presets-save-button-label": "Enregistrer le préréglage", + "presets-save-failed-error": "Le préréglage n'a pas pu être enregistré.", + "presets-saved-info": "Préréglage enregistré.", + "presets-skipped-values-warning": "Certaines valeurs n'ont pas pu être appliquées et restent inchangées :", "progress-analyzing": "Analyse en cours", "progress-autotune": "Autotune en progression", "progress-build-classifier": "Chargement des données et construction d'un classificateur", diff --git a/birdnet_analyzer/lang/id.json b/birdnet_analyzer/lang/id.json index f6cb76e0f..6388de83d 100644 --- a/birdnet_analyzer/lang/id.json +++ b/birdnet_analyzer/lang/id.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "Jika diaktifkan, tabel hasil terpisah akan dibuat untuk setiap berkas masukan. Jika tidak, semua hasil akan digabungkan ke dalam satu tabel.", "multi-tab-split-table-checkbox-label": "Pisahkan tabel berdasarkan berkas masukan", "multi-tab-title": "Analisis batch", + "presets-accordion-label": "Prasetel", + "presets-delete-button-label": "Hapus prasetel", + "presets-deleted-info": "Prasetel dihapus.", + "presets-dropdown-label": "Prasetel tersimpan", + "presets-invalid-analyze-params-file-error": "Berkas yang dipilih bukan berkas parameter analisis yang valid.", + "presets-invalid-name-error": "Masukkan nama prasetel yang valid.", + "presets-invalid-train-params-file-error": "Berkas yang dipilih bukan berkas parameter pelatihan yang valid.", + "presets-load-analyze-params-button-label": "Muat pengaturan dari berkas parameter analisis (birdnet.analyze-params.csv)", + "presets-load-button-label": "Muat prasetel", + "presets-load-train-params-button-label": "Muat pengaturan dari berkas parameter pelatihan (*.birdnet.train-params.csv)", + "presets-loaded-info": "Pengaturan diterapkan.", + "presets-missing-preset-error": "Prasetel tidak dapat dibaca.", + "presets-name-textbox-label": "Nama prasetel", + "presets-none-selected-warning": "Silakan pilih prasetel terlebih dahulu.", + "presets-save-button-label": "Simpan prasetel", + "presets-save-failed-error": "Prasetel tidak dapat disimpan.", + "presets-saved-info": "Prasetel disimpan.", + "presets-skipped-values-warning": "Beberapa nilai tidak dapat diterapkan dan tetap tidak berubah:", "progress-analyzing": "Menganalisis", "progress-autotune": "Autotune dalam progres", "progress-build-classifier": "Memuat data & membangun klasifikator", diff --git a/birdnet_analyzer/lang/pt-br.json b/birdnet_analyzer/lang/pt-br.json index abedc9251..8e2e8f899 100644 --- a/birdnet_analyzer/lang/pt-br.json +++ b/birdnet_analyzer/lang/pt-br.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "Quando ativado, uma tabela de resultados separada é criada para cada arquivo de entrada. Caso contrário, todos os resultados são combinados em uma única tabela.", "multi-tab-split-table-checkbox-label": "Separar tabelas por arquivo de entrada", "multi-tab-title": "Análise em lote", + "presets-accordion-label": "Predefinições", + "presets-delete-button-label": "Excluir predefinição", + "presets-deleted-info": "Predefinição excluída.", + "presets-dropdown-label": "Predefinições salvas", + "presets-invalid-analyze-params-file-error": "O arquivo selecionado não é um arquivo de parâmetros de análise válido.", + "presets-invalid-name-error": "Insira um nome válido para a predefinição.", + "presets-invalid-train-params-file-error": "O arquivo selecionado não é um arquivo de parâmetros de treinamento válido.", + "presets-load-analyze-params-button-label": "Carregar configurações de um arquivo de parâmetros de análise (birdnet.analyze-params.csv)", + "presets-load-button-label": "Carregar predefinição", + "presets-load-train-params-button-label": "Carregar configurações de um arquivo de parâmetros de treinamento (*.birdnet.train-params.csv)", + "presets-loaded-info": "Configurações aplicadas.", + "presets-missing-preset-error": "Não foi possível ler a predefinição.", + "presets-name-textbox-label": "Nome da predefinição", + "presets-none-selected-warning": "Selecione uma predefinição primeiro.", + "presets-save-button-label": "Salvar predefinição", + "presets-save-failed-error": "Não foi possível salvar a predefinição.", + "presets-saved-info": "Predefinição salva.", + "presets-skipped-values-warning": "Alguns valores não puderam ser aplicados e permaneceram inalterados:", "progress-analyzing": "Analisando", "progress-autotune": "Autotune em progresss", "progress-build-classifier": "Carregando dados e construindo classificador", diff --git a/birdnet_analyzer/lang/ru.json b/birdnet_analyzer/lang/ru.json index 761a2f49d..0f2fa4b1e 100644 --- a/birdnet_analyzer/lang/ru.json +++ b/birdnet_analyzer/lang/ru.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "Если включено, для каждого входного файла будет создана отдельная таблица результатов. В противном случае все результаты будут объединены в одной таблице.", "multi-tab-split-table-checkbox-label": "Разделять таблицы по входным файлам", "multi-tab-title": "Пакетный анализ", + "presets-accordion-label": "Пресеты", + "presets-delete-button-label": "Удалить пресет", + "presets-deleted-info": "Пресет удалён.", + "presets-dropdown-label": "Сохранённые пресеты", + "presets-invalid-analyze-params-file-error": "Выбранный файл не является корректным файлом параметров анализа.", + "presets-invalid-name-error": "Пожалуйста, введите корректное имя пресета.", + "presets-invalid-train-params-file-error": "Выбранный файл не является корректным файлом параметров обучения.", + "presets-load-analyze-params-button-label": "Загрузить настройки из файла параметров анализа (birdnet.analyze-params.csv)", + "presets-load-button-label": "Загрузить пресет", + "presets-load-train-params-button-label": "Загрузить настройки из файла параметров обучения (*.birdnet.train-params.csv)", + "presets-loaded-info": "Настройки применены.", + "presets-missing-preset-error": "Не удалось прочитать пресет.", + "presets-name-textbox-label": "Имя пресета", + "presets-none-selected-warning": "Сначала выберите пресет.", + "presets-save-button-label": "Сохранить пресет", + "presets-save-failed-error": "Не удалось сохранить пресет.", + "presets-saved-info": "Пресет сохранён.", + "presets-skipped-values-warning": "Некоторые значения не удалось применить, они остались без изменений:", "progress-analyzing": "Анализ", "progress-autotune": "Выполняется автонастройка", "progress-build-classifier": "Загрузка данных и создание классификатора", diff --git a/birdnet_analyzer/lang/se.json b/birdnet_analyzer/lang/se.json index e0fba8e13..c575d6b1a 100644 --- a/birdnet_analyzer/lang/se.json +++ b/birdnet_analyzer/lang/se.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "När detta är aktiverat skapas en separat resultattabell för varje indatafil. Annars sammanställs alla resultat i en enda tabell.", "multi-tab-split-table-checkbox-label": "Dela upp tabeller per indatafil", "multi-tab-title": "Batchanalys", + "presets-accordion-label": "Förinställningar", + "presets-delete-button-label": "Ta bort förinställning", + "presets-deleted-info": "Förinställning borttagen.", + "presets-dropdown-label": "Sparade förinställningar", + "presets-invalid-analyze-params-file-error": "Den valda filen är inte en giltig analysparameterfil.", + "presets-invalid-name-error": "Ange ett giltigt namn för förinställningen.", + "presets-invalid-train-params-file-error": "Den valda filen är inte en giltig träningsparameterfil.", + "presets-load-analyze-params-button-label": "Läs in inställningar från en analysparameterfil (birdnet.analyze-params.csv)", + "presets-load-button-label": "Läs in förinställning", + "presets-load-train-params-button-label": "Läs in inställningar från en träningsparameterfil (*.birdnet.train-params.csv)", + "presets-loaded-info": "Inställningar tillämpade.", + "presets-missing-preset-error": "Förinställningen kunde inte läsas.", + "presets-name-textbox-label": "Namn på förinställning", + "presets-none-selected-warning": "Välj en förinställning först.", + "presets-save-button-label": "Spara förinställning", + "presets-save-failed-error": "Förinställningen kunde inte sparas.", + "presets-saved-info": "Förinställning sparad.", + "presets-skipped-values-warning": "Vissa värden kunde inte tillämpas och lämnades oförändrade:", "progress-analyzing": "Analyserar", "progress-autotune": "Autojustering pågår", "progress-build-classifier": "Laddar data och bygger klassificerare", diff --git a/birdnet_analyzer/lang/tlh.json b/birdnet_analyzer/lang/tlh.json index 7c4afb8d5..24bf9ad91 100644 --- a/birdnet_analyzer/lang/tlh.json +++ b/birdnet_analyzer/lang/tlh.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "chu'lu'chugh, Hoch wejDIch De' ngaSmeH nagh qechmeyvaD vaj wa' Sep ghItlhmey chenmoHlu'. chu'be'lu'chugh, Hoch QInmey wa' ghItlhmeyDaq tatlhlu'.", "multi-tab-split-table-checkbox-label": "De' ngaSmeH naghmeyvo' ghItlhmey Sep", "multi-tab-title": "law' poj", + "presets-accordion-label": "chutmey pollu'pu'bogh", + "presets-delete-button-label": "chutmey pol Qaw'", + "presets-deleted-info": "chutmey pol Qaw'lu'pu'.", + "presets-dropdown-label": "chutmey pollu'pu'bogh", + "presets-invalid-analyze-params-file-error": "teywI' wIvlu'pu'bogh 'oHbe' poj chutmey teywI''e'.", + "presets-invalid-name-error": "pong lugh yI'el.", + "presets-invalid-train-params-file-error": "teywI' wIvlu'pu'bogh 'oHbe' qeqmeH chutmey teywI''e'.", + "presets-load-analyze-params-button-label": "poj chutmey teywI'vo' chutmey chu' (birdnet.analyze-params.csv)", + "presets-load-button-label": "chutmey pol chu'", + "presets-load-train-params-button-label": "qeqmeH chutmey teywI'vo' chutmey chu' (*.birdnet.train-params.csv)", + "presets-loaded-info": "chutmey chu'lu'pu'.", + "presets-missing-preset-error": "chutmey pol laDlaHbe'lu'.", + "presets-name-textbox-label": "chutmey pol pong", + "presets-none-selected-warning": "wa'DIch chutmey pol yIwIv.", + "presets-save-button-label": "chutmey pol", + "presets-save-failed-error": "chutmey pollaHbe'lu'.", + "presets-saved-info": "chutmey pollu'pu'.", + "presets-skipped-values-warning": "'op mI' lo'laHbe'lu'; choHbe'lu':", "progress-analyzing": "poj", "progress-autotune": "autotune Qap", "progress-build-classifier": "De' poQ & tu'law' rurbogh", diff --git a/birdnet_analyzer/lang/zh_TW.json b/birdnet_analyzer/lang/zh_TW.json index 68e744f86..7fc145856 100644 --- a/birdnet_analyzer/lang/zh_TW.json +++ b/birdnet_analyzer/lang/zh_TW.json @@ -191,6 +191,24 @@ "multi-tab-split-table-checkbox-info": "啟用後,系統會為每個輸入檔案建立獨立的結果表格。否則,所有結果將合併到同一個表格中。", "multi-tab-split-table-checkbox-label": "依輸入檔案分割表格", "multi-tab-title": "多筆音檔", + "presets-accordion-label": "預設集", + "presets-delete-button-label": "刪除預設集", + "presets-deleted-info": "已刪除預設集。", + "presets-dropdown-label": "已儲存的預設集", + "presets-invalid-analyze-params-file-error": "所選檔案不是有效的分析參數檔案。", + "presets-invalid-name-error": "請輸入有效的預設集名稱。", + "presets-invalid-train-params-file-error": "所選檔案不是有效的訓練參數檔案。", + "presets-load-analyze-params-button-label": "從分析參數檔案載入設定 (birdnet.analyze-params.csv)", + "presets-load-button-label": "載入預設集", + "presets-load-train-params-button-label": "從訓練參數檔案載入設定 (*.birdnet.train-params.csv)", + "presets-loaded-info": "已套用設定。", + "presets-missing-preset-error": "無法讀取預設集。", + "presets-name-textbox-label": "預設集名稱", + "presets-none-selected-warning": "請先選擇一個預設集。", + "presets-save-button-label": "儲存預設集", + "presets-save-failed-error": "無法儲存預設集。", + "presets-saved-info": "已儲存預設集。", + "presets-skipped-values-warning": "部分數值無法套用,維持不變:", "progress-analyzing": "分析中", "progress-autotune": "自動調諧進行中", "progress-build-classifier": "載入資料中、建立分類器", diff --git a/birdnet_analyzer/model.py b/birdnet_analyzer/model.py index dac8c0568..0f2bac7b3 100644 --- a/birdnet_analyzer/model.py +++ b/birdnet_analyzer/model.py @@ -14,7 +14,6 @@ import tensorflow as tf from birdnet.acoustic.models.v2_4.pb import AcousticPBDownloaderV2_4 -from birdnet_analyzer import utils from birdnet_analyzer.config import RANDOM_SEED from birdnet_analyzer.train import custom_models @@ -647,7 +646,6 @@ def save_linear_classifier( model_path: str, labels: list[str], mode: Literal["replace", "append"] = "replace", - params: tuple[list[str], list] | None = None, ): """Saves the classifier as a tflite model, as well as the used labels in a .txt. @@ -693,9 +691,6 @@ def save_linear_classifier( with open(model_path.replace(".tflite", "_Labels.txt"), "w", encoding="utf-8") as f: f.writelines(label + "\n" for label in labels) - if params: - utils.save_params_to_file(model_path.replace(".tflite", "_Params.csv"), *params) - def save_raven_model( classifier, @@ -705,7 +700,6 @@ def save_raven_model( sig_fmin=0, sig_fmax=15000, model_version="2.4", - params: tuple[list[str], list] | None = None, ): """ Save a TensorFlow model with a custom classifier and associated metadata for use @@ -815,11 +809,6 @@ def basic(inputs): json.dump(modelconfig, modelconfigfile, indent=2) - model_params = os.path.join(model_path, "model_params.csv") - - if params: - utils.save_params_to_file(model_params, *params) - def save_detached_classifier( classifier, diff --git a/birdnet_analyzer/params.py b/birdnet_analyzer/params.py new file mode 100644 index 000000000..079ddba82 --- /dev/null +++ b/birdnet_analyzer/params.py @@ -0,0 +1,199 @@ +"""Reading the parameters files of previous runs back into settings. + +An analysis saves its parameters into its output directory as +``birdnet.analyze-params.csv``, a training run saves them next to the trained +classifier as ``.birdnet.train-params.csv`` (see +:func:`birdnet_analyzer.utils.save_params_file`). The loaders in this module read +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`. + +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 contextlib import suppress +from typing import Any + +# How many parameters a file has to yield to count as a parameters file. High enough +# to reject the parameters file of the other command, which shares a handful of names +# (audio speed, bandpass limits, batch size), and any other CSV output. +_MIN_RECOGNIZED_PARAMS = 5 + + +def read_params(path: str) -> dict[str, str]: + """Reads a parameters file into a name -> raw value dict. + + Understands both layouts: the two-column "Parameter,Value" rows the current + version writes and the old layout of two rows with one column per parameter. + + Args: + path: The path to the parameters file. + + Raises: + ValueError: If the file cannot be read as a CSV file. + """ + import csv + + try: + with open(path, encoding="utf-8-sig", newline="") as f: + rows = [row for row in csv.reader(f) if row] + except (OSError, UnicodeDecodeError) as e: + raise ValueError(f"Cannot read parameters file: {path}") from e + + if len(rows) < 2: + raise ValueError(f"Not a parameters file: {path}") + + if rows[0][:2] == ["Parameter", "Value"]: + return {row[0]: row[1] for row in rows[1:] if len(row) >= 2} + + 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)) + + +def _to_bool(raw: str) -> bool: + if raw not in ("True", "False"): + raise ValueError(f"Not a boolean: {raw!r}") + + return raw == "True" + + +def _to_list(raw: str) -> list[str]: + return [entry.strip() for entry in raw.split(",") if entry.strip()] + + +def load_analysis_params(path: str) -> dict[str, Any]: + """Reads the parameters of a previous analysis back as ``analyze()`` arguments. + + All values are returned exactly as the analysis ran with them, so passing them + back reproduces the original run. That includes the placeholder confidence of 0 + an analysis with top N stores. + + Args: + path: The path to the parameters file. + + Returns: + The recorded parameters as keyword arguments for + :func:`birdnet_analyzer.analyze.analyze`. Parameters that cannot be read are + left out. + + 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]) + + if len(values) < _MIN_RECOGNIZED_PARAMS: + raise ValueError(f"Not an analysis parameters file: {path}") + + return values + + +def load_train_params(path: str) -> dict[str, Any]: + """Reads the parameters of a previous training run back as ``train()`` arguments. + + When the run used autotune, the recorded values are the tuned ones, so passing + them back trains with the found hyperparameters. + + Args: + path: The path to the parameters file. + + Returns: + The recorded parameters as keyword arguments for + :func:`birdnet_analyzer.train.train`, plus ``classifier_name`` (the name the + classifier was saved under), which ``train()`` does not take. Parameters + that cannot be read are left out. + + Raises: + 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("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") + + if len(values) < _MIN_RECOGNIZED_PARAMS: + raise ValueError(f"Not a training parameters file: {path}") + + return values diff --git a/birdnet_analyzer/settings.py b/birdnet_analyzer/settings.py index 7662373de..40de67f3f 100644 --- a/birdnet_analyzer/settings.py +++ b/birdnet_analyzer/settings.py @@ -1,7 +1,9 @@ import json import os +import re import sys import traceback +from contextlib import suppress from pathlib import Path APP_NAME = "BirdNET-Analyzer-GUI" @@ -159,6 +161,20 @@ def set_tab_setting(tab: str, key: str, value): key (str): The name of the setting inside the tab. value: The value to persist. Must be JSON serializable. """ + update_tab_settings(tab, {key: value}) + + +def update_tab_settings(tab: str, values: dict): + """ + Persists several GUI settings of a tab in a single write. + + Args: + tab (str): The id of the tab, e.g. "multi". + values (dict): The settings to persist, by name. Must be JSON serializable. + """ + if not values: + return + try: state = get_state_dict() tab_settings = state.get(TAB_SETTINGS_KEY) @@ -166,12 +182,12 @@ def set_tab_setting(tab: str, key: str, value): if not isinstance(tab_settings, dict): tab_settings = state[TAB_SETTINGS_KEY] = {} - values = tab_settings.get(tab) + current = tab_settings.get(tab) - if not isinstance(values, dict): - values = tab_settings[tab] = {} + if not isinstance(current, dict): + current = tab_settings[tab] = {} - values[key] = value + current.update(values) _write_state_dict(state) except Exception as e: @@ -193,6 +209,108 @@ def reset_tab_settings(): write_error_log(e) +PRESET_NAME_MAX_LENGTH = 60 +# Letters, digits, spaces, dashes, dots and underscores keep a preset name usable as +# a file name on every platform. +_PRESET_NAME_PATTERN = re.compile(r"^\w[\w .-]*$") + + +def is_valid_preset_name(name: str) -> bool: + """ + Checks whether a preset name can be used as a file name. + + Args: + name (str): The name the user chose for the preset. + + Returns: + bool: True if the name is safe to use as a file name. + """ + return ( + len(name) <= PRESET_NAME_MAX_LENGTH + and not name.endswith((" ", ".")) + and bool(_PRESET_NAME_PATTERN.match(name)) + ) + + +def _preset_file(tab: str, name: str) -> Path: + if not is_valid_preset_name(name): + raise ValueError(f"Invalid preset name: {name!r}") + + return APPDIR / "presets" / tab / f"{name}.json" + + +def list_presets(tab: str) -> list[str]: + """ + Returns the names of the saved presets of a tab, sorted alphabetically. + + Args: + tab (str): The id of the tab, e.g. "multi". + """ + try: + names = [file.stem for file in (APPDIR / "presets" / tab).glob("*.json")] + except OSError as e: + write_error_log(e) + return [] + + return sorted(names, key=str.casefold) + + +def save_preset(tab: str, name: str, values: dict) -> None: + """ + Saves the settings of a tab as a named preset. + + An existing preset of the same name is overwritten. + + Args: + tab (str): The id of the tab, e.g. "multi". + name (str): The name the preset is saved under. + values (dict): The settings to save, by name. Must be JSON serializable. + + Raises: + ValueError: If the name cannot be used as a file name. + OSError: If the preset cannot be written. + """ + file = _preset_file(tab, name) + file.parent.mkdir(parents=True, exist_ok=True) + + with open(file, "w", encoding="utf-8") as f: + json.dump(values, f, indent=4) + + +def load_preset(tab: str, name: str) -> dict | None: + """ + Reads a named preset of a tab. + + Args: + tab (str): The id of the tab, e.g. "multi". + name (str): The name of the preset. + + Returns: + dict | None: The saved settings, or None if the preset does not exist or + cannot be read. + """ + try: + with open(_preset_file(tab, name), encoding="utf-8") as f: + values = json.load(f) + except (OSError, ValueError) as e: + write_error_log(e) + return None + + return values if isinstance(values, dict) else None + + +def delete_preset(tab: str, name: str) -> None: + """ + Deletes a named preset of a tab. Nothing happens if it does not exist. + + Args: + tab (str): The id of the tab, e.g. "multi". + name (str): The name of the preset. + """ + with suppress(OSError): + _preset_file(tab, name).unlink(missing_ok=True) + + def ensure_settings_file(): """ Ensures that the settings file exists at the specified path. If the file does not diff --git a/birdnet_analyzer/train/cli.py b/birdnet_analyzer/train/cli.py index 79f0a0eae..aaf28ab67 100644 --- a/birdnet_analyzer/train/cli.py +++ b/birdnet_analyzer/train/cli.py @@ -3,9 +3,13 @@ @runtime_error_handler def main(): - from birdnet_analyzer import cli, train + from birdnet_analyzer import cli, params, train parser = cli.train_parser() + cli.apply_params_file_defaults(parser, params.load_train_params) args = parser.parse_args() - train(**vars(args)) + train_args = vars(args) + train_args.pop("load_params") # already applied as defaults + + train(**train_args) diff --git a/birdnet_analyzer/train/utils.py b/birdnet_analyzer/train/utils.py index c2a65968f..ac0a1f319 100644 --- a/birdnet_analyzer/train/utils.py +++ b/birdnet_analyzer/train/utils.py @@ -20,6 +20,7 @@ ALLOWED_FILETYPES, AUTOTUNE_METRICS, NON_EVENT_CLASSES, + TRAIN_PARAMS_SUFFIX, ) from birdnet_analyzer.model_utils import GLOBAL_PREFETCH_RATIO @@ -753,54 +754,51 @@ def generate_splits( try: # Remove activation from last layer before saving classifier.pop() - params = ( - [ - "Hidden units", - "Dropout", - "Batchsize", - "Learning rate", - "Weight decay", - "Crop mode", - "Crop overlap", - "Audio speed", - "Upsampling mode", - "Upsampling ratio", - "use mixup", - "use label smoothing", - "use focal loss", - "focal loss alpha", - "focal loss gamma", - "BirdNET Model version", - ], - [ - hidden_units, - dropout, - batch_size, - learning_rate, - weight_decay, - crop_mode, - overlap, - audio_speed, - upsampling_mode, - upsampling_ratio, - mixup, - label_smoothing, - use_focal_loss, - focal_loss_alpha, - focal_loss_gamma, - "2.4", - ], + formats = [model_formats] if isinstance(model_formats, str) else model_formats + classifier_path = output.removesuffix(".tflite") + + # The settings the classifier was trained with, saved next to it both as a + # record and so the GUI can load them again. When autotune ran, the values + # 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, + "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": "2.4", + }, ) - if "tflite" in model_formats: + if "tflite" in formats: model.save_linear_classifier( - classifier, output, labels, mode=model_save_mode, params=params - ) - if "raven" in model_formats: - model.save_raven_model( - classifier, output, labels, mode=model_save_mode, params=params + classifier, output, labels, mode=model_save_mode ) - if "detached" in model_formats: + if "raven" in formats: + model.save_raven_model(classifier, output, labels, mode=model_save_mode) + if "detached" in formats: model.save_detached_classifier( classifier, output, diff --git a/birdnet_analyzer/utils.py b/birdnet_analyzer/utils.py index 0cba8ee7b..aa7320aed 100644 --- a/birdnet_analyzer/utils.py +++ b/birdnet_analyzer/utils.py @@ -224,6 +224,37 @@ def read_lines( return cleaned_lines +# The shipped BirdNET models drop this suffix to name their label file, e.g. +# BirdNET_GLOBAL_6K_V2.4_Model_FP32.tflite -> BirdNET_GLOBAL_6K_V2.4_Labels.txt, while +# a trained classifier keeps its full name, e.g. Custom.tflite -> Custom_Labels.txt. +_BIRDNET_SUFFIX = "Model_FP32.tflite" + + +def read_classifier_labels(classifier_file: str) -> list[str] | None: + """Reads the labels belonging to a custom classifier. + + Looks for the label file next to the classifier, following the naming used when a + custom classifier is trained, and falls back to the naming of the shipped BirdNET + models. + + Args: + classifier_file: Absolute path to the classifier file. + + Returns: + The labels, or None if no label file was found. + """ + base_name = os.path.splitext(classifier_file)[0] + labels_file = base_name + "_Labels.txt" + + if not os.path.isfile(labels_file) and classifier_file.endswith(_BIRDNET_SUFFIX): + labels_file = classifier_file.removesuffix(_BIRDNET_SUFFIX) + "Labels.txt" + + if not os.path.isfile(labels_file): + return None + + return read_lines(labels_file, fail_on_blank_lines=True) + + def list_subdirectories(path: str): """Lists all directories inside a path. @@ -257,19 +288,19 @@ def img2base64(path): return base64.b64encode(img_file.read()).decode("utf-8") -def save_params_to_file(file_path, headers, values): - """Saves the params used to train the custom classifier. +def save_params_file(file_path, params: dict): + """Saves the parameters of an analysis or training run as a two-column CSV. - The hyperparams will be saved to disk in a file named 'model_params.csv'. + One parameter per row, so the file reads as a table in a spreadsheet or text + editor. Written with a BOM so spreadsheet applications pick up the encoding. Args: file_path: The path to the file. - headers: The headers of the csv file. - values: The values of the csv file. + params: The parameters to save, by their human-readable names. """ import csv - with open(file_path, "w", newline="") as paramsfile: + with open(file_path, "w", newline="", encoding="utf-8-sig") as paramsfile: paramswriter = csv.writer(paramsfile) - paramswriter.writerow(headers) - paramswriter.writerow(values) + paramswriter.writerow(("Parameter", "Value")) + paramswriter.writerows(params.items()) diff --git a/docs/best-practices/training.rst b/docs/best-practices/training.rst index 03c0cbb86..4387cebd8 100644 --- a/docs/best-practices/training.rst +++ b/docs/best-practices/training.rst @@ -146,7 +146,7 @@ After the training process is finished your output folder should like this: classifier-output/ ├── CustomClassifier.tflite ├── CustomClassifier_Labels.txt - ├── CustomClassifier_Params.csv + ├── CustomClassifier.birdnet.train-params.csv └── ... To use this classifier select the "Custom classifier" option in the species selection section of the BirdNET-Analyzer GUI and select the .tflite file. diff --git a/tests/analyze/test_analyze.py b/tests/analyze/test_analyze.py index ef531f4dc..3c48d3976 100644 --- a/tests/analyze/test_analyze.py +++ b/tests/analyze/test_analyze.py @@ -102,6 +102,7 @@ def test_analyze_cli_accepts_full_parser_surface( kwargs = vars(args) assert kwargs["use_perch"] is True kwargs.pop("use_perch", None) + kwargs.pop("load_params") analyze(**kwargs, _return_only=True) diff --git a/tests/gui/test_presets.py b/tests/gui/test_presets.py new file mode 100644 index 000000000..7795f1e65 --- /dev/null +++ b/tests/gui/test_presets.py @@ -0,0 +1,515 @@ +import csv + +import pytest + +# Building components needs gradio, which only comes with the gui and gui-tests extras. +gr = pytest.importorskip("gradio") + +import birdnet_analyzer.gui.localization as loc # noqa: E402 +from birdnet_analyzer import settings, utils # noqa: E402 +from birdnet_analyzer.gui import presets # noqa: E402 +from birdnet_analyzer.gui import state as gs # noqa: E402 + +# The analysis parameters as the versions before 2.x wrote them: one column per +# parameter, a header row and a value row. +PARAMS_HEADERS = [ + "Model", + "BirdNET version", + "Segment length", + "Sample rate", + "Segment overlap", + "Bandpass filter minimum", + "Bandpass filter maximum", + "Merge consecutive detections", + "Audio speed", + "Minimum confidence", + "Sensitivity", + "Top N", + "Batch size", + "Number of workers", + "Number of producers", + "Result type(s)", + "Additional columns", + "Latitude", + "Longitude", + "Week", + "Species filter threshold", + "Species list file", + "Locale", + "Custom classifier path", + "Custom classifier species list", + "Split tables", +] + + +@pytest.fixture +def appdir(monkeypatch, tmp_path): + """Points the presets and the GUI state at tmp_path.""" + monkeypatch.setattr(settings, "APPDIR", tmp_path) + monkeypatch.setattr(settings, "STATE_SETTINGS_PATH", str(tmp_path / "state.json")) + monkeypatch.setattr(settings, "ERROR_LOG_FILE", str(tmp_path / "error_log.txt")) + monkeypatch.setattr(gs, "_PERSISTED", []) + + return tmp_path + + +def analysis_params(**overrides): + values = dict.fromkeys(PARAMS_HEADERS, "") + values.update( + { + "Model": "birdnet", + "BirdNET version": "2.4", + "Segment length": "3.0", + "Sample rate": "48000", + "Segment overlap": "0.5", + "Bandpass filter minimum": "150", + "Bandpass filter maximum": "12000", + "Merge consecutive detections": "3", + "Audio speed": "1.0", + "Minimum confidence": "0.3", + "Sensitivity": "1.2", + "Batch size": "8", + "Number of workers": "4", + "Number of producers": "2", + "Result type(s)": "table, csv", + "Additional columns": "lat, lon", + "Species filter threshold": "0.05", + "Locale": "en_us", + "Split tables": "False", + } + ) + values.update(overrides) + + return values + + +def params_file(tmp_path, **overrides): + """Writes an analysis parameters file in the old one-column-per-parameter layout.""" + values = analysis_params(**overrides) + path = tmp_path / "BirdNET_analysis_params.csv" + + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(PARAMS_HEADERS) + writer.writerow([values[header] for header in PARAMS_HEADERS]) + + return str(path) + + +def tall_params_file(tmp_path, **overrides): + """Writes an analysis parameters file the way the current version saves it.""" + path = tmp_path / "birdnet.analyze-params.csv" + utils.save_params_file(path, analysis_params(**overrides)) + + return str(path) + + +def test_a_saved_preset_can_be_loaded_and_deleted(appdir): + settings.save_preset("multi", "Project A", {"confidence_slider": 0.5}) + + assert settings.list_presets("multi") == ["Project A"] + assert settings.load_preset("multi", "Project A") == {"confidence_slider": 0.5} + + settings.delete_preset("multi", "Project A") + + assert settings.list_presets("multi") == [] + assert settings.load_preset("multi", "Project A") is None + + +def test_presets_are_kept_per_tab(appdir): + settings.save_preset("multi", "Wetlands", {"confidence_slider": 0.5}) + settings.save_preset("train", "Wetlands", {"epochs_number": 100}) + + assert settings.load_preset("multi", "Wetlands") == {"confidence_slider": 0.5} + assert settings.load_preset("train", "Wetlands") == {"epochs_number": 100} + + +@pytest.mark.parametrize( + "name", + [ + "", + " ", + "..", + "../escape", + "a/b", + "a\\b", + "trailing.", + "trailing ", + ".hidden", + "x" * (settings.PRESET_NAME_MAX_LENGTH + 1), + ], +) +def test_a_name_that_does_not_fit_a_file_is_rejected(appdir, name): + with pytest.raises(ValueError, match="Invalid preset name"): + settings.save_preset("multi", name, {}) + + assert settings.list_presets("multi") == [] + + +@pytest.mark.parametrize("name", ["Project A", "wetlands_2026", "v1.2-final"]) +def test_a_usable_name_is_accepted(appdir, name): + settings.save_preset("multi", name, {}) + + assert settings.list_presets("multi") == [name] + + +def build_tab(): + tab = gs.TabState("multi") + + with gr.Blocks() as demo: + controls = presets.PresetControls("multi") + tab.persist( + "confidence_slider", gr.Slider, minimum=0.05, maximum=0.95, value=0.25 + ) + tab.persist( + "output_type_checkboxgroup", + gr.CheckboxGroup, + choices=[("Raven", "table"), ("CSV", "csv")], + value=["table"], + ) + controls.wire(tab) + + return tab, controls, demo + + +def test_snapshot_pairs_the_values_with_their_keys(appdir): + tab, _, _ = build_tab() + + assert tab.snapshot([0.5, ["csv"]]) == { + "confidence_slider": 0.5, + "output_type_checkboxgroup": ["csv"], + } + + +def test_updates_apply_only_the_values_the_components_take(appdir): + tab, _, _ = build_tab() + + updates, skipped = tab.updates_for( + { + "confidence_slider": 0.5, + "output_type_checkboxgroup": ["csv", "parquet"], # parquet is not offered + "epochs_number": 100, # not a setting of this tab + } + ) + + assert updates == [gr.update(value=0.5), gr.update()] + assert sorted(skipped) == ["epochs_number", "output_type_checkboxgroup"] + # What was applied is persisted, like a user edit. + assert settings.get_tab_settings("multi") == {"confidence_slider": 0.5} + + +def find_click_handler(demo, button): + events = [ + event + for event in demo.fns.values() + if event.targets and event.targets[0] == (button._id, "click") + ] + + assert len(events) == 1 + + return events[0].fn + + +def test_saving_and_loading_a_preset_through_the_controls(appdir): + _, controls, demo = build_tab() + + dropdown_update = find_click_handler(demo, controls.save_button)( + " Project A ", 0.5, ["csv"] + ) + + assert dropdown_update["choices"] == ["Project A"] + assert dropdown_update["value"] == "Project A" + assert settings.load_preset("multi", "Project A") == { + "confidence_slider": 0.5, + "output_type_checkboxgroup": ["csv"], + } + + updates = find_click_handler(demo, controls.load_button)("Project A") + + assert updates == [gr.update(value=0.5), gr.update(value=["csv"])] + + dropdown_update = find_click_handler(demo, controls.delete_button)("Project A") + + assert dropdown_update["choices"] == [] + assert settings.list_presets("multi") == [] + + +def test_saving_under_an_invalid_name_shows_an_error(appdir): + _, controls, demo = build_tab() + + with pytest.raises(gr.Error): + find_click_handler(demo, controls.save_button)("../escape", 0.5, ["csv"]) + + assert settings.list_presets("multi") == [] + + +def test_loading_without_a_selection_changes_nothing(appdir): + _, controls, demo = build_tab() + + updates = find_click_handler(demo, controls.load_button)(None) + + assert updates == [gr.skip(), gr.skip()] + + +@pytest.mark.parametrize("write_file", [params_file, tall_params_file]) +def test_the_analysis_params_of_a_previous_run_are_read_back( + appdir, tmp_path, write_file +): + values = presets.load_analysis_params(write_file(tmp_path)) + + assert values == { + "confidence_slider": 0.3, + "sensitivity_slider": 1.2, + "overlap_slider": 0.5, + "merge_consecutive_slider": 3, + "audio_speed_slider": 1, + "fmin_number": 150, + "fmax_number": 12000, + "sf_thresh_number": 0.05, + "batch_size_number": 8, + "workers_number": 4, + "producers_number": 2, + "use_top_n_checkbox": False, + "yearlong_checkbox": True, + "locale_dropdown": "en_us", + "output_type_checkboxgroup": ["table", "csv"], + "additional_columns_checkboxgroup": ["lat", "lon"], + "split_tables_checkbox": False, + "species_list_radio": loc.localize("species-list-radio-option-all"), + "model_selection_radio": "BirdNET 2.4", + } + + +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( + params_file(tmp_path, **{"Top N": "5", "Minimum confidence": "0"}) + ) + + assert values["use_top_n_checkbox"] is True + assert values["top_n_input"] == 5 + assert "confidence_slider" not in values + + +def test_a_location_based_analysis_restores_the_predicted_species_list( + appdir, tmp_path +): + values = presets.load_analysis_params( + params_file(tmp_path, Latitude="42.5", Longitude="-76.4", Week="20") + ) + + assert values["species_list_radio"] == loc.localize( + "species-list-radio-option-predict-list" + ) + assert values["lat_number"] == 42.5 + assert values["lon_number"] == -76.4 + assert values["week_number"] == 20 + assert values["yearlong_checkbox"] is False + + +def test_a_species_list_analysis_restores_the_custom_list(appdir, tmp_path): + values = presets.load_analysis_params( + params_file(tmp_path, **{"Species list file": "/data/birds.txt"}) + ) + + assert values["species_list_radio"] == loc.localize( + "species-list-radio-option-custom-list" + ) + assert values[presets.SPECIES_FILE_KEY] == "/data/birds.txt" + + +def test_a_custom_classifier_analysis_restores_the_classifier(appdir, tmp_path): + values = presets.load_analysis_params( + params_file(tmp_path, **{"Custom classifier path": "/models/my.tflite"}) + ) + + assert values["model_selection_radio"] == loc.localize( + "species-list-radio-option-custom-classifier" + ) + assert values[presets.CLASSIFIER_FILE_KEY] == "/models/my.tflite" + + +@pytest.mark.parametrize( + ("stored_speed", "slider"), + [ + ("2.0", 2), # faster than realtime stays on the positive side + ("0.5", -2), # slower than realtime is undone back to the negative side + ("1.0", 1), + ], +) +def test_the_audio_speed_factor_is_undone_to_the_slider_value( + appdir, tmp_path, stored_speed, slider +): + values = presets.load_analysis_params( + params_file(tmp_path, **{"Audio speed": stored_speed}) + ) + + assert values["audio_speed_slider"] == slider + + +def test_a_file_that_is_no_params_file_is_rejected(appdir, tmp_path): + path = tmp_path / "results.csv" + path.write_text("Start (s),End (s),Confidence\n0,3,0.8\n", encoding="utf-8") + + with pytest.raises(gr.Error): + presets.load_analysis_params(str(path)) + + with pytest.raises(gr.Error): + presets.load_train_params(str(path)) + + +def test_params_are_saved_one_per_row_with_a_bom(tmp_path): + path = tmp_path / "params.csv" + + utils.save_params_file(path, {"Minimum confidence": 0.25, "Split tables": False}) + + # The BOM lets spreadsheet applications pick up the encoding. + assert path.read_bytes().startswith(b"\xef\xbb\xbf") + assert path.read_text(encoding="utf-8-sig").splitlines() == [ + "Parameter,Value", + "Minimum confidence,0.25", + "Split tables,False", + ] + + +def train_params_file(tmp_path, **overrides): + """Writes a training parameters file the way the current version saves it.""" + values = { + "Classifier name": "MyClassifier", + "Model formats": "tflite, raven", + "Model save mode": "replace", + "Bandpass filter minimum": 150, + "Bandpass filter maximum": 12000, + "Audio speed": 2.0, + "Crop mode": "segments", + "Crop overlap": 1.5, + "Autotune": False, + "Autotune trials": 50, + "Autotune folds": 5, + "Autotune repeats": 1, + "Epochs": 100, + "Batch size": 64, + "Learning rate": 0.0005, + "Hidden units": 512, + "Dropout": 0.25, + "Weight decay": 0.004, + "Use label smoothing": True, + "Use mixup": True, + "Use focal loss": True, + "Focal loss gamma": 2.0, + "Focal loss alpha": 0.25, + "Upsampling mode": "repeat", + "Upsampling ratio": 0.5, + "BirdNET model version": "2.4", + } + values.update(overrides) + path = tmp_path / "MyClassifier.birdnet.train-params.csv" + utils.save_params_file(path, values) + + return str(path) + + +def test_the_train_params_of_a_previous_run_are_read_back(appdir, tmp_path): + values = presets.load_train_params(train_params_file(tmp_path)) + + assert values == { + "classifier_name_textbox": "MyClassifier", + "output_format_checkboxgroup": ["tflite", "raven"], + "model_save_mode_radio": "replace", + "fmin_number": 150, + "fmax_number": 12000, + "audio_speed_slider": 2, + "crop_mode_radio": "segments", + "crop_overlap_slider": 1.5, + "autotune_checkbox": False, + "autotune_trials_number": 50, + "autotune_folds_number": 5, + "autotune_repeats_number": 1, + "epochs_number": 100, + "batch_size_number": 64, + "learning_rate_number": 0.0005, + "hidden_units_number": 512, + "dropout_number": 0.25, + "use_label_smoothing_checkbox": True, + "use_mixup_checkbox": True, + "use_focal_loss_checkbox": True, + "focal_loss_gamma_slider": 2.0, + "focal_loss_alpha_slider": 0.25, + "upsampling_mode_radio": "repeat", + "upsampling_ratio_slider": 0.5, + } + + +def test_an_old_train_params_file_is_still_understood(appdir, tmp_path): + # The layout and names the versions before 2.x saved next to the classifier. + headers = [ + "Hidden units", + "Dropout", + "Batchsize", + "Learning rate", + "Weight decay", + "Crop mode", + "Crop overlap", + "Audio speed", + "Upsampling mode", + "Upsampling ratio", + "use mixup", + "use label smoothing", + "use focal loss", + "focal loss alpha", + "focal loss gamma", + "BirdNET Model version", + ] + row = [ + 512, + 0.25, + 32, + 0.0001, + 0.004, + "center", + 0.0, + 0.5, + "repeat", + 0.75, + True, + False, + False, + 0.25, + 2.0, + "2.4", + ] + path = tmp_path / "MyClassifier_Params.csv" + + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(headers) + writer.writerow(row) + + values = presets.load_train_params(str(path)) + + assert values == { + "hidden_units_number": 512, + "dropout_number": 0.25, + "batch_size_number": 32, + "learning_rate_number": 0.0001, + "crop_mode_radio": "center", + "crop_overlap_slider": 0.0, + "audio_speed_slider": -2, + "upsampling_mode_radio": "repeat", + "upsampling_ratio_slider": 0.75, + "use_mixup_checkbox": True, + "use_label_smoothing_checkbox": False, + "use_focal_loss_checkbox": False, + "focal_loss_alpha_slider": 0.25, + "focal_loss_gamma_slider": 2.0, + } + + +def test_the_params_file_of_the_other_tab_is_rejected(appdir, tmp_path): + # The tabs share a handful of parameter names (audio speed, bandpass limits, + # batch size), which must not be enough to pass as the right file. + with pytest.raises(gr.Error): + presets.load_train_params(tall_params_file(tmp_path)) + + with pytest.raises(gr.Error): + presets.load_analysis_params(train_params_file(tmp_path)) diff --git a/tests/test_params.py b/tests/test_params.py new file mode 100644 index 000000000..1b0f92917 --- /dev/null +++ b/tests/test_params.py @@ -0,0 +1,378 @@ +import csv + +import pytest + +from birdnet_analyzer import cli, params, utils + +# The analysis parameters as the versions before 2.x wrote them: one column per +# parameter, a header row and a value row. +OLD_ANALYSIS_HEADERS = [ + "Model", + "BirdNET version", + "Segment length", + "Sample rate", + "Segment overlap", + "Bandpass filter minimum", + "Bandpass filter maximum", + "Merge consecutive detections", + "Audio speed", + "Minimum confidence", + "Sensitivity", + "Top N", + "Batch size", + "Number of workers", + "Number of producers", + "Result type(s)", + "Latitude", + "Longitude", + "Week", + "Species filter threshold", + "Species list file", + "Locale", + "Custom classifier path", + "Custom classifier species list", + "Split tables", +] + + +def analysis_values(**overrides): + values = dict.fromkeys(OLD_ANALYSIS_HEADERS, "") + values.update( + { + "Model": "birdnet", + "BirdNET version": "2.4", + "Segment length": "3.0", + "Sample rate": "48000", + "Segment overlap": "0.5", + "Bandpass filter minimum": "150", + "Bandpass filter maximum": "12000", + "Merge consecutive detections": "3", + "Audio speed": "1.0", + "Minimum confidence": "0.3", + "Sensitivity": "1.2", + "Batch size": "8", + "Number of workers": "4", + "Number of producers": "2", + "Result type(s)": "table, csv", + "Species filter threshold": "0.05", + "Locale": "en_us", + "Split tables": "False", + } + ) + values.update(overrides) + + return values + + +EXPECTED_ANALYSIS_KWARGS = { + "model": "birdnet", + "birdnet": "2.4", + "overlap": 0.5, + "fmin": 150, + "fmax": 12000, + "merge_consecutive": 3, + "audio_speed": 1.0, + "min_conf": 0.3, + "sensitivity": 1.2, + "batch_size": 8, + "n_workers": 4, + "n_producers": 2, + "rtype": ["table", "csv"], + "sf_thresh": 0.05, + "locale": "en_us", + "split_tables": False, +} + + +def wide_file(tmp_path, values): + """Writes a parameters file in the old one-column-per-parameter layout.""" + path = tmp_path / "BirdNET_analysis_params.csv" + + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(values.keys()) + writer.writerow(values.values()) + + return str(path) + + +def tall_file(tmp_path, values, name="birdnet.analyze-params.csv"): + """Writes a parameters file the way the current version saves it.""" + path = tmp_path / name + utils.save_params_file(path, values) + + return str(path) + + +def train_values(**overrides): + values = { + "Classifier name": "MyClassifier", + "Model formats": "tflite, raven", + "Model save mode": "replace", + "Bandpass filter minimum": 150, + "Bandpass filter maximum": 12000, + "Audio speed": 2.0, + "Crop mode": "segments", + "Crop overlap": 1.5, + "Autotune": False, + "Autotune trials": 50, + "Autotune folds": 5, + "Autotune repeats": 1, + "Epochs": 100, + "Batch size": 64, + "Learning rate": 0.0005, + "Hidden units": 512, + "Dropout": 0.25, + "Weight decay": 0.004, + "Use label smoothing": True, + "Use mixup": True, + "Use focal loss": True, + "Focal loss gamma": 2.0, + "Focal loss alpha": 0.25, + "Upsampling mode": "repeat", + "Upsampling ratio": 0.5, + "BirdNET model version": "2.4", + } + values.update(overrides) + + return values + + +@pytest.mark.parametrize("write_file", [wide_file, tall_file]) +def test_analysis_params_are_read_back_as_analyze_arguments(tmp_path, write_file): + kwargs = params.load_analysis_params(write_file(tmp_path, analysis_values())) + + assert kwargs == EXPECTED_ANALYSIS_KWARGS + + +def test_the_values_are_returned_exactly_as_the_analysis_ran_with_them(tmp_path): + # No GUI transformations: the speed factor stays a factor, and the placeholder + # confidence of a top-N analysis is kept, so the original call is reproduced. + file = tall_file( + tmp_path, + analysis_values( + **{"Audio speed": "0.25", "Top N": "5", "Minimum confidence": "0"} + ), + ) + + kwargs = params.load_analysis_params(file) + + assert kwargs["audio_speed"] == 0.25 + assert kwargs["top_n"] == 5 + assert kwargs["min_conf"] == 0 + + +def test_files_of_an_analysis_are_read_back(tmp_path): + file = tall_file( + tmp_path, + analysis_values( + **{ + "Species list file": "/data/birds.txt", + "Custom classifier path": "/models/my.tflite", + } + ), + ) + + kwargs = params.load_analysis_params(file) + + assert kwargs["slist"] == "/data/birds.txt" + assert kwargs["classifier"] == "/models/my.tflite" + + +def test_train_params_are_read_back_as_train_arguments(tmp_path): + file = tall_file( + tmp_path, train_values(), name="MyClassifier.birdnet.train-params.csv" + ) + + assert params.load_train_params(file) == { + "classifier_name": "MyClassifier", + "model_formats": ["tflite", "raven"], + "model_save_mode": "replace", + "fmin": 150, + "fmax": 12000, + "audio_speed": 2.0, + "crop_mode": "segments", + "overlap": 1.5, + "autotune": False, + "autotune_trials": 50, + "autotune_n_splits": 5, + "autotune_n_repeats": 1, + "epochs": 100, + "batch_size": 64, + "learning_rate": 0.0005, + "hidden_units": 512, + "dropout": 0.25, + "label_smoothing": True, + "mixup": True, + "use_focal_loss": True, + "focal_loss_gamma": 2.0, + "focal_loss_alpha": 0.25, + "upsampling_mode": "repeat", + "upsampling_ratio": 0.5, + } + + +def test_an_old_train_params_file_is_still_understood(tmp_path): + # The layout and names the versions before 2.x saved next to the classifier. + old = { + "Hidden units": 512, + "Dropout": 0.25, + "Batchsize": 32, + "Learning rate": 0.0001, + "Weight decay": 0.004, + "Crop mode": "center", + "Crop overlap": 0.0, + "Audio speed": 0.5, + "Upsampling mode": "repeat", + "Upsampling ratio": 0.75, + "use mixup": True, + "use label smoothing": False, + "use focal loss": False, + "focal loss alpha": 0.25, + "focal loss gamma": 2.0, + "BirdNET Model version": "2.4", + } + + kwargs = params.load_train_params(wide_file(tmp_path, old)) + + assert kwargs == { + "hidden_units": 512, + "dropout": 0.25, + "batch_size": 32, + "learning_rate": 0.0001, + "crop_mode": "center", + "overlap": 0.0, + "audio_speed": 0.5, + "upsampling_mode": "repeat", + "upsampling_ratio": 0.75, + "mixup": True, + "label_smoothing": False, + "use_focal_loss": False, + "focal_loss_alpha": 0.25, + "focal_loss_gamma": 2.0, + } + + +def test_files_that_are_no_params_files_are_rejected(tmp_path): + results = tmp_path / "results.csv" + results.write_text("Start (s),End (s),Confidence\n0,3,0.8\n", encoding="utf-8") + + for loader in (params.load_analysis_params, params.load_train_params): + with pytest.raises(ValueError, match="parameters file"): + loader(str(results)) + + +def test_the_params_file_of_the_other_command_is_rejected(tmp_path): + # The commands share a handful of parameter names (audio speed, bandpass limits, + # batch size), which must not be enough to pass as the right file. + analysis_file = tall_file(tmp_path, analysis_values()) + train_file = tall_file(tmp_path, train_values(), name="train-params.csv") + + with pytest.raises(ValueError, match="training parameters"): + params.load_train_params(analysis_file) + + with pytest.raises(ValueError, match="analysis parameters"): + params.load_analysis_params(train_file) + + +def test_analysis_params_become_cli_defaults_that_explicit_arguments_override( + tmp_path, +): + file = tall_file( + tmp_path, + analysis_values( + **{ + "Minimum confidence": "0.4", + "Audio speed": "0.25", + "Split tables": "True", + "Species list file": "/data/birds.txt", + } + ), + ) + parser = cli.analyzer_parser() + + cli.apply_params_file_defaults( + parser, params.load_analysis_params, argv=["--load_params", file] + ) + args = parser.parse_args(["recordings"]) + + assert args.min_conf == 0.4 + assert args.audio_speed == 0.25 + assert args.split_tables is True + assert args.slist == "/data/birds.txt" + assert args.rtype == ["table", "csv"] + + overridden = parser.parse_args( + ["recordings", "--min_conf", "0.9", "--no-split_tables"] + ) + + assert overridden.min_conf == 0.9 + assert overridden.split_tables is False + # Values without an explicit argument keep the file's value. + assert overridden.audio_speed == 0.25 + + +def test_train_params_become_cli_defaults_that_explicit_arguments_override(tmp_path): + file = tall_file( + tmp_path, + train_values(Autotune=True, Epochs=75), + name="MyClassifier.birdnet.train-params.csv", + ) + parser = cli.train_parser() + + cli.apply_params_file_defaults( + parser, params.load_train_params, argv=["--load_params", file] + ) + args = parser.parse_args(["train_data"]) + + assert args.epochs == 75 + assert args.autotune is True + assert args.mixup is True + assert args.model_formats == ["tflite", "raven"] + # The classifier name has no train() argument, the output path is that identity. + assert "classifier_name" not in vars(args) + + overridden = parser.parse_args(["train_data", "--no-autotune", "--epochs", "10"]) + + assert overridden.autotune is False + assert overridden.epochs == 10 + + +def test_an_unreadable_params_file_stops_the_cli(tmp_path, capsys): + results = tmp_path / "results.csv" + results.write_text("Start (s),End (s),Confidence\n0,3,0.8\n", encoding="utf-8") + parser = cli.analyzer_parser() + + with pytest.raises(SystemExit): + cli.apply_params_file_defaults( + parser, params.load_analysis_params, argv=["--load_params", str(results)] + ) + + assert "parameters file" in capsys.readouterr().err + + +def test_without_the_argument_the_defaults_stay_untouched(): + parser = cli.analyzer_parser() + + cli.apply_params_file_defaults(parser, params.load_analysis_params, argv=[]) + + assert parser.parse_args(["recordings"]).min_conf == 0.25 + + +def test_the_cli_arguments_stay_in_sync_with_the_api(): + # The mains pass the parsed arguments straight into analyze()/train(), which is + # also what makes the loaded parameters files line up with the parsers. + import inspect + + from birdnet_analyzer import analyze, train + + analyze_args = vars(cli.analyzer_parser().parse_args(["recordings"])) + analyze_args.pop("use_perch") + analyze_args.pop("load_params") + + assert set(analyze_args) <= set(inspect.signature(analyze).parameters) + + train_args = vars(cli.train_parser().parse_args(["train_data"])) + train_args.pop("load_params") + + assert set(train_args) <= set(inspect.signature(train).parameters) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 000000000..51589fcb9 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,41 @@ +from birdnet_analyzer import utils + +# The first bytes of a TFLite model. Not valid UTF-8, so reading it as text fails. +TFLITE_BYTES = bytes([0x1C, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4C, 0x33, 0xFF, 0xFE, 0x80]) + + +def classifier(tmp_path, name="CustomClassifier.tflite"): + path = tmp_path / name + path.write_bytes(TFLITE_BYTES) + + return str(path) + + +def test_the_labels_of_a_trained_classifier_are_read(tmp_path): + path = classifier(tmp_path) + (tmp_path / "CustomClassifier_Labels.txt").write_text( + "Species one\nSpecies two\n", encoding="utf-8" + ) + + assert utils.read_classifier_labels(path) == ["Species one", "Species two"] + + +def test_the_labels_of_a_birdnet_model_are_read(tmp_path): + path = classifier(tmp_path, "BirdNET_GLOBAL_6K_V2.4_Model_FP32.tflite") + (tmp_path / "BirdNET_GLOBAL_6K_V2.4_Labels.txt").write_text( + "Cyanocitta cristata_Blue Jay\n", encoding="utf-8" + ) + + assert utils.read_classifier_labels(path) == ["Cyanocitta cristata_Blue Jay"] + + +def test_a_classifier_without_a_label_file_has_no_labels(tmp_path): + # The classifier must not be mistaken for its own label file, which would read the + # model as UTF-8 text. + assert utils.read_classifier_labels(classifier(tmp_path)) is None + + +def test_a_birdnet_model_without_a_label_file_has_no_labels(tmp_path): + path = classifier(tmp_path, "BirdNET_GLOBAL_6K_V2.4_Model_FP32.tflite") + + assert utils.read_classifier_labels(path) is None diff --git a/tests/train/test_train.py b/tests/train/test_train.py index 476a1d24e..c877548c8 100644 --- a/tests/train/test_train.py +++ b/tests/train/test_train.py @@ -39,8 +39,7 @@ def test_train_cli(mock_train_model, setup_test_environment): # Remove CLI-only args not accepted by train() kwargs = vars(args) - kwargs.pop("cache_mode", None) - kwargs.pop("cache_file", None) + kwargs.pop("load_params") train(**kwargs) @@ -120,7 +119,11 @@ def test_train_cli_accepts_full_parser_surface( ] ) - train(**vars(args)) + # Remove CLI-only args not accepted by train() + kwargs = vars(args) + kwargs.pop("load_params") + + train(**kwargs) mock_train_model.assert_called_once() call_kwargs = mock_train_model.call_args[1]