From 770d3350e1dd5dea0456f5cb3d6d3bc7f6872da7 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Tue, 20 Oct 2026 21:21:35 +0200 Subject: [PATCH] GUI fixes --- birdnet_analyzer/gui/analysis.py | 6 +- birdnet_analyzer/gui/evaluation.py | 50 ++++-- birdnet_analyzer/gui/review.py | 40 ++--- birdnet_analyzer/gui/segments.py | 2 +- birdnet_analyzer/gui/single_file.py | 2 +- birdnet_analyzer/gui/train.py | 61 +++++-- birdnet_analyzer/gui/utils.py | 75 +++++--- birdnet_analyzer/lang/de.json | 2 + birdnet_analyzer/lang/en.json | 2 + birdnet_analyzer/lang/fi.json | 2 + birdnet_analyzer/lang/fr.json | 2 + birdnet_analyzer/lang/id.json | 2 + birdnet_analyzer/lang/pt-br.json | 2 + birdnet_analyzer/lang/ru.json | 2 + birdnet_analyzer/lang/se.json | 2 + birdnet_analyzer/lang/tlh.json | 2 + birdnet_analyzer/lang/zh_CN.json | 2 + birdnet_analyzer/lang/zh_TW.json | 2 + tests/gui/test_analysis.py | 52 ++++++ tests/gui/test_evaluation_tab.py | 155 ++++++++++++++++ tests/gui/test_review_tab.py | 79 +++++++++ tests/gui/test_segments_tab.py | 35 ++++ tests/gui/test_single_file_downloads.py | 40 +++++ tests/gui/test_train_tab.py | 225 ++++++++++++++++++++---- tests/gui/test_utils.py | 119 +++++++++++++ 25 files changed, 844 insertions(+), 119 deletions(-) create mode 100644 tests/gui/test_analysis.py create mode 100644 tests/gui/test_evaluation_tab.py create mode 100644 tests/gui/test_review_tab.py create mode 100644 tests/gui/test_segments_tab.py create mode 100644 tests/gui/test_single_file_downloads.py create mode 100644 tests/gui/test_utils.py diff --git a/birdnet_analyzer/gui/analysis.py b/birdnet_analyzer/gui/analysis.py index d396ab96b..99bb1c5bb 100644 --- a/birdnet_analyzer/gui/analysis.py +++ b/birdnet_analyzer/gui/analysis.py @@ -117,11 +117,7 @@ def run_analysis( lat = lat if species_list_choice == gu._PREDICT_SPECIES else None # ty:ignore[invalid-assignment] lon = lon if species_list_choice == gu._PREDICT_SPECIES else None # ty:ignore[invalid-assignment] week = None if use_yearlong else week # ty:ignore[invalid-assignment] - audio_speed = ( - max(0.1, 1.0 / (audio_speed * -1)) - if audio_speed < 0 - else max(1.0, float(audio_speed)) - ) + audio_speed = gu.slider_to_value(audio_speed) on_update = partial(on_progress, progress=progress) if callable(progress) else None if selected_model == gu._CUSTOM_CLASSIFIER and custom_classifier_file is None: diff --git a/birdnet_analyzer/gui/evaluation.py b/birdnet_analyzer/gui/evaluation.py index 0a064c693..44130a6a0 100644 --- a/birdnet_analyzer/gui/evaluation.py +++ b/birdnet_analyzer/gui/evaluation.py @@ -31,6 +31,24 @@ class ProcessorState(typing.NamedTuple): prediction_dir: str +def _parse_recording_duration(text: str | None) -> float | None: + text = (text or "").strip() + + return float(text) if text else None + + +def _require_built_recording_duration(proc_state: ProcessorState, text: str | None): + built_with = proc_state.processor.recording_duration + + try: + matches = _parse_recording_duration(text) == built_with + except ValueError: + matches = False + + if not matches: + raise gr.Error(loc.localize("eval-tab-error-no-valid-recording-duration")) + + def build_evaluation_tab() -> gu.TAB_BUILDER_RESULT: state = TabState("evaluation") @@ -139,11 +157,16 @@ def download_results_table( f"{loc.localize('eval-tab-error-saving-results-table')} {e}" ) from e - def download_data_table(processor_state: ProcessorState): + def download_data_table( + processor_state: ProcessorState, recording_duration_value: str + ): if processor_state is None: raise gr.Error( loc.localize("eval-tab-error-calc-metrics-first"), print_exception=False ) + + _require_built_recording_duration(processor_state, recording_duration_value) + try: file_location = gu.save_file_dialog( state_key="eval-data-table", @@ -205,13 +228,14 @@ def build_processor( if not annotation_dir or not prediction_dir: return None + # Refreshed on every keystroke, so a duration the processor would reject is + # left out here; the handlers using the processor refuse the mismatch. try: - rec_dur = ( - float(recording_duration_value.strip()) - if recording_duration_value - else None - ) - except (ValueError, TypeError): + rec_dur = _parse_recording_duration(recording_duration_value) + except ValueError: + rec_dur = None + + if rec_dur is not None and not 0 < (sample_duration_value or 0) <= rec_dur: rec_dur = None cols_ann = { @@ -680,7 +704,9 @@ def select_directory(current_files, current_dir): averaging_radio, ], ) - download_data_button.click(fn=download_data_table, inputs=[processor_state]) + download_data_button.click( + fn=download_data_table, inputs=[processor_state, recording_duration] + ) def download_plot_guarded(plot, plot_name): if plot is None: @@ -917,6 +943,7 @@ def calculate_metrics( class_wise_value, averaging_value, score_unannotated_value, + recording_duration_value, selected_classes_list, selected_recordings_list, *metrics_checkbox_values, @@ -927,6 +954,8 @@ def calculate_metrics( print_exception=False, ) + _require_built_recording_duration(proc_state, recording_duration_value) + metric_ids = tuple( metric_id for value, metric_id in zip( @@ -935,7 +964,7 @@ def calculate_metrics( if value ) if not metric_ids: - metric_ids = ("precision", "recall", "f1") + raise gr.Error(loc.localize("eval-tab-error-no-metric-selected")) if not selected_classes_list: selected_classes_list = list(proc_state.processor.classes) @@ -989,6 +1018,7 @@ def calculate_metrics( class_wise, averaging_radio, score_unannotated, + recording_duration, select_classes_checkboxgroup, select_recordings_checkboxgroup, *metrics_checkboxes.values(), @@ -1020,7 +1050,7 @@ def recompute_overall( return gr.update(value=overall_row, column_widths=widths) - averaging_radio.input( + averaging_radio.change( recompute_overall, inputs=[pa_state, predictions_state, labels_state, averaging_radio], outputs=[overall_table], diff --git a/birdnet_analyzer/gui/review.py b/birdnet_analyzer/gui/review.py index 7447df358..7a73f5808 100644 --- a/birdnet_analyzer/gui/review.py +++ b/birdnet_analyzer/gui/review.py @@ -21,6 +21,15 @@ SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__)) +def _confidence(path: str) -> float | None: + try: + value = float(os.path.basename(path).split("_", 1)[0]) + except ValueError: + return None + + return value if 0 <= value <= 1 else None + + def build_review_tab() -> gu.TAB_BUILDER_RESULT: state = TabState("review") @@ -76,17 +85,13 @@ def create_log_plot(positives, negatives, fig_num=None): x_vals = [] y_val = [] - for fl in positives + negatives: - try: - x_val = float(os.path.basename(fl).split("_", 1)[0]) - - if 0 > x_val > 1: - continue + for label, files in ((1, positives), (0, negatives)): + for fl in files: + x_val = _confidence(fl) - x_vals.append([x_val]) - y_val.append(1 if fl in positives else 0) - except ValueError: - pass + if x_val is not None: + x_vals.append([x_val]) + y_val.append(label) if (len(positives) + len(negatives)) >= 2 and len(set(y_val)) > 1: log_model = linear_model.LogisticRegression(C=55) @@ -348,17 +353,6 @@ def start_review(next_review_state): return {review_state: next_review_state} - def try_confidence(filename): - try: - val = float(os.path.basename(filename).split("_", 1)[0]) - - if 0 > val > 1: - return 0 - - return val - except ValueError: - return 0 - def update_review(next_review_state: dict, selected_species: str | None = None): next_review_state["history"] = [] next_review_state["skipped"] = [] @@ -381,7 +375,9 @@ def update_review(next_review_state: dict, selected_species: str | None = None): else next_review_state["input_directory"] ) - todo_files = sorted(todo_files, key=try_confidence, reverse=True) + todo_files = sorted( + todo_files, key=lambda f: _confidence(f) or 0, reverse=True + ) next_review_state |= { "files": todo_files, diff --git a/birdnet_analyzer/gui/segments.py b/birdnet_analyzer/gui/segments.py index 819b924a6..070d5acc7 100644 --- a/birdnet_analyzer/gui/segments.py +++ b/birdnet_analyzer/gui/segments.py @@ -49,7 +49,7 @@ def on_update(info): min_conf=min_conf, max_conf=max_conf, max_segments=num_seq, - audio_speed=audio_speed, + audio_speed=gu.slider_to_value(audio_speed), seg_length=seq_length, threads=threads, collection_mode=collection_mode, diff --git a/birdnet_analyzer/gui/single_file.py b/birdnet_analyzer/gui/single_file.py index 1743e2ee4..23f0e1b26 100644 --- a/birdnet_analyzer/gui/single_file.py +++ b/birdnet_analyzer/gui/single_file.py @@ -439,7 +439,7 @@ def download_kaleidoscope(prediction_state): file_location = gu.save_file_dialog( state_key="single-file-table", default_filename=cfg.OUTPUT_KALEIDOSCOPE_FILENAME, - filetypes=("Kaleidoscope (*.txt)",), + filetypes=("Kaleidoscope (*.csv)",), ) if file_location: diff --git a/birdnet_analyzer/gui/train.py b/birdnet_analyzer/gui/train.py index 68bfc5531..5fa14c2f0 100644 --- a/birdnet_analyzer/gui/train.py +++ b/birdnet_analyzer/gui/train.py @@ -117,9 +117,10 @@ def start_training( classifier_name: Name of the custom classifier. model_save_mode: Save mode for the model (replace or append). birdnet_version: The BirdNET version to train on. - cache_mode: Cache mode for training data (load, save, or None). - cache_file: Path to the cache file. - cache_file_name: Name of the cache file. + cache_mode: Cache mode for training data (none, load or save). + cache_file: The cache file to train from in "load" mode, or the directory to + write the cache to in "save" mode. + cache_file_name: Name of the cache file written in "save" mode. autotune: Whether to use hyperparameter autotuning. autotune_trials: Number of trials for autotuning. autotune_folds: Number of folds for autotuning. @@ -179,9 +180,15 @@ def start_training( (0, epochs), desc=loc.localize("progress-build-classifier"), unit="epochs" ) - if cache_mode == "load" and not os.path.isfile(cache_file): + if cache_mode == "load" and not (cache_file and os.path.isfile(cache_file)): raise gr.Error(loc.localize("validation-no-cache-file-selected")) + if cache_mode == "save" and not (cache_file and os.path.isdir(cache_file)): + raise gr.Error(loc.localize("validation-no-directory-selected")) + + if cache_mode == "save" and not (cache_file_name or "").strip(): + raise gr.Error(loc.localize("validation-no-valid-cache-file-name")) + if model_save_mode == "append" and "detached" in model_formats: gr.Warning(loc.localize("training-tab-warning-detached-classifier-append")) @@ -239,8 +246,8 @@ def trial_progression(trial): use_focal_loss=focal_loss, focal_loss_gamma=max(0.0, float(focal_loss_gamma)), focal_loss_alpha=max(0.0, min(1.0, float(focal_loss_alpha))), - fmin=max(0, min(15000, int(fmin))), - fmax=max(0, min(15000, int(fmax))), + fmin=int(fmin), + fmax=int(fmax), model_save_mode=model_save_mode, birdnet=birdnet_version, save_cache_to=os.path.join(cache_file, cache_file_name) @@ -254,9 +261,7 @@ def trial_progression(trial): on_epoch_end=epoch_progression, on_trial_result=trial_progression, on_data_load_end=data_load_progression, - audio_speed=max(0.1, 1.0 / (audio_speed * -1)) - if audio_speed < 0 - else max(1.0, float(audio_speed)), + audio_speed=gu.slider_to_value(audio_speed), autotune=autotune, autotune_trials=int(autotune_trials), autotune_n_splits=int(autotune_folds), @@ -603,9 +608,29 @@ def on_cache_mode_change(value, data_dir, test_data_dir): gr.update(interactive=value != "load"), gr.update(interactive=value != "load"), gr.update(interactive=value != "load"), - gr.update(interactive=value != "load"), ) + def clear_cache_location(): + return ( + None, + "", + gr.update(visible=False), + "", + gr.update(value=None, visible=False), + ) + + cache_mode.change( + clear_cache_location, + outputs=[ + cache_file_state, + selected_cache_dir_textbox, + cache_file_name, + selected_cache_file_textbox, + cache_file_input, + ], + show_progress="hidden", + ) + with ( gr.Group(), gr.Accordion( @@ -702,13 +727,20 @@ def on_cache_mode_change(value, data_dir, test_data_dir): info=loc.localize("training-tab-threads-number-info"), ) - def on_crop_select(new_crop_mode): + def on_crop_or_cache_mode_change(new_cache_mode, new_crop_mode): + visible = new_crop_mode in ("segments", "smart") + return gr.update( - visible=new_crop_mode in ["segments", "smart"], - interactive=new_crop_mode in ["segments", "smart"], + visible=visible, interactive=visible and new_cache_mode != "load" ) - crop_mode.change(on_crop_select, inputs=crop_mode, outputs=crop_overlap) + gr.on( + [cache_mode.change, crop_mode.change], + on_crop_or_cache_mode_change, + inputs=[cache_mode, crop_mode], + outputs=crop_overlap, + show_progress="hidden", + ) cache_mode.change( on_cache_mode_change, @@ -724,7 +756,6 @@ def on_crop_select(new_crop_mode): fmax_number, audio_speed_slider, crop_mode, - crop_overlap, threads_number, base_model_radio, ], diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index e31d57397..d57e4e393 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -1505,6 +1505,18 @@ def on_species_file_change(file): } +_PLOT_FORMATS = { + ".png": "PNG", + ".jpg": "JPEG", + ".jpeg": "JPEG", + ".webp": "WEBP", + ".bmp": "BMP", + ".gif": "GIF", + ".tif": "TIFF", + ".tiff": "TIFF", +} + + def download_plot(plot, filename=""): import webview from PIL import Image @@ -1521,17 +1533,21 @@ def download_plot(plot, filename=""): if isinstance(res, list | tuple): res: str = res[0] - file_ext = res.split(".", 1)[-1].upper() + file_ext = _PLOT_FORMATS.get(os.path.splitext(res)[1].lower()) if file_ext == "WEBP": with open(res, "wb") as f: f.write(imgdata) else: - if file_ext not in ["PNG", "JPEG"]: + if file_ext is None: file_ext = "PNG" res += ".png" img = Image.open(io.BytesIO(imgdata)) + + if file_ext != "PNG": + img = img.convert("RGB") + img.save(res, file_ext) @@ -1550,41 +1566,42 @@ def _get_network_shortcuts(): try: # CSIDL_NETHOOD: folder containing network shortcuts network_shortcuts = shell.SHGetFolderPath(0, shellcon.CSIDL_NETHOOD, None, 0) # pyright: ignore[reportArgumentType] - shortcuts = [] + items = os.listdir(network_shortcuts) + except Exception as e: + utils.write_error_log(e) + return [] - for item in os.listdir(network_shortcuts): - item_path = os.path.join(network_shortcuts, item) + shortcuts = [] - if os.path.isdir(item_path): - # network shortcuts are folders containing a target.lnk file - target_lnk = os.path.join(item_path, "target.lnk") + for item in items: + item_path = os.path.join(network_shortcuts, item) - if os.path.exists(target_lnk): - try: - shell_link = pythoncom.CoCreateInstance( # ty:ignore[unresolved-attribute] - shell.CLSID_ShellLink, - None, - pythoncom.CLSCTX_INPROC_SERVER, # ty:ignore[unresolved-attribute] - shell.IID_IShellLink, - ) + if os.path.isdir(item_path): + # network shortcuts are folders containing a target.lnk file + target_lnk = os.path.join(item_path, "target.lnk") - persist_file = shell_link.QueryInterface( - pythoncom.IID_IPersistFile # ty:ignore[unresolved-attribute] - ) + if os.path.exists(target_lnk): + try: + shell_link = pythoncom.CoCreateInstance( # ty:ignore[unresolved-attribute] + shell.CLSID_ShellLink, + None, + pythoncom.CLSCTX_INPROC_SERVER, # ty:ignore[unresolved-attribute] + shell.IID_IShellLink, + ) + + persist_file = shell_link.QueryInterface( + pythoncom.IID_IPersistFile # ty:ignore[unresolved-attribute] + ) - persist_file.Load(target_lnk) + persist_file.Load(target_lnk) - path_buffer, _ = shell_link.GetPath(shell.SLGP_RAWPATH) + path_buffer, _ = shell_link.GetPath(shell.SLGP_RAWPATH) - shortcuts.append(path_buffer) - except Exception as e: - logger.exception(f"Error reading {target_lnk}: {e}") - raise + shortcuts.append(path_buffer) + except Exception as e: + logger.exception(f"Error reading {target_lnk}: {e}") - return shortcuts - except Exception as e: - utils.write_error_log(e) - return [] + return shortcuts def _get_win_drives(): diff --git a/birdnet_analyzer/lang/de.json b/birdnet_analyzer/lang/de.json index 9ea586e94..e1dac5352 100644 --- a/birdnet_analyzer/lang/de.json +++ b/birdnet_analyzer/lang/de.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "Fehlende Spalte in den Dateien", "eval-tab-error-missing-col-info": "Bitte überprüfen Sie die Spaltennamen.", "eval-tab-error-no-class-selected": "Mindestens eine Klasse muss ausgewählt werden.", + "eval-tab-error-no-metric-selected": "Mindestens eine Metrik muss ausgewählt werden.", "eval-tab-error-no-valid-recording-duration": "Bitte geben Sie eine gültige Zahl für die Aufnahmedauer ein.", "eval-tab-error-plotting-confusion-matrix": "Fehler beim Darstellen der Konfusionsmatrix", "eval-tab-error-plotting-metrics": "Fehler beim Darstellen der Metriken", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Bitte wählen Sie eine Artenliste.", "validation-no-training-data-selected": "Bitte wählen Sie Ihre Trainingsdaten.", "validation-no-valid-batch-size": "Bitte geben Sie eine gültige Batch-Größe an.", + "validation-no-valid-cache-file-name": "Bitte geben Sie einen gültigen Namen für die Cache-Datei an.", "validation-no-valid-classifier-name": "Bitte geben Sie einen gültigen Namen für den Klassifikator an.", "validation-no-valid-epoch-number": "Bitte geben Sie eine gültige Anzahl von Epochen an.", "validation-no-valid-frequency": "Bitte geben Sie eine gültige Frequenz an", diff --git a/birdnet_analyzer/lang/en.json b/birdnet_analyzer/lang/en.json index 9ce1b453b..ed6228e32 100644 --- a/birdnet_analyzer/lang/en.json +++ b/birdnet_analyzer/lang/en.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "Column missing in files", "eval-tab-error-missing-col-info": "Please check the column names.", "eval-tab-error-no-class-selected": "At least one class must be selected.", + "eval-tab-error-no-metric-selected": "At least one metric must be selected.", "eval-tab-error-no-valid-recording-duration": "Please enter a valid number for recording duration.", "eval-tab-error-plotting-confusion-matrix": "Error plotting confusion matrix", "eval-tab-error-plotting-metrics": "Error plotting metrics", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Please select a species list.", "validation-no-training-data-selected": "Please select your training data.", "validation-no-valid-batch-size": "Please enter a valid batch size.", + "validation-no-valid-cache-file-name": "Please enter a valid name for the cache file.", "validation-no-valid-classifier-name": "Please enter a valid name for the classifier.", "validation-no-valid-epoch-number": "Please enter a valid number of epochs.", "validation-no-valid-frequency": "Please enter a valid frequency in", diff --git a/birdnet_analyzer/lang/fi.json b/birdnet_analyzer/lang/fi.json index a9b2b1452..f3b2f8151 100644 --- a/birdnet_analyzer/lang/fi.json +++ b/birdnet_analyzer/lang/fi.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "Puuttuva sarake tiedostoissa", "eval-tab-error-missing-col-info": "Tarkista sarakenimet.", "eval-tab-error-no-class-selected": "Valitse vähintään yksi luokka.", + "eval-tab-error-no-metric-selected": "Valitse vähintään yksi mittari.", "eval-tab-error-no-valid-recording-duration": "Anna kelvollinen numero äänitteen kestolle.", "eval-tab-error-plotting-confusion-matrix": "Virhe sekaannusmatriisin piirtämisessä", "eval-tab-error-plotting-metrics": "Virhe mittareiden piirtämisessä", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Valitse lajilista.", "validation-no-training-data-selected": "Valitse koulutusdatasi.", "validation-no-valid-batch-size": "Anna kelvollinen eräkoko.", + "validation-no-valid-cache-file-name": "Anna kelvollinen nimi välimuistitiedostolle.", "validation-no-valid-classifier-name": "Anna kelvollinen nimi luokittelijalle.", "validation-no-valid-epoch-number": "Anna kelvollinen aikakausien määrä.", "validation-no-valid-frequency": "Anna kelvollinen taajuus", diff --git a/birdnet_analyzer/lang/fr.json b/birdnet_analyzer/lang/fr.json index 9cbd4d978..196e3afea 100644 --- a/birdnet_analyzer/lang/fr.json +++ b/birdnet_analyzer/lang/fr.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "Colonne manquante dans les fichiers", "eval-tab-error-missing-col-info": "Veuillez vérifier les noms des colonnes.", "eval-tab-error-no-class-selected": "Au moins une classe doit être sélectionnée.", + "eval-tab-error-no-metric-selected": "Au moins une métrique doit être sélectionnée.", "eval-tab-error-no-valid-recording-duration": "Veuillez entrer un nombre valide pour la durée d'enregistrement.", "eval-tab-error-plotting-confusion-matrix": "Erreur lors du tracé de la matrice de confusion", "eval-tab-error-plotting-metrics": "Erreur lors du tracé des métriques", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Veuillez sélectionner une liste d’espèces", "validation-no-training-data-selected": "Veuillez sélectionner des données d’entraînement", "validation-no-valid-batch-size": "Veuillez saisir une taille de lot valide.", + "validation-no-valid-cache-file-name": "Veuillez saisir un nom valide pour le fichier cache.", "validation-no-valid-classifier-name": "Veuillez saisir un nom valide pour le classificateur.", "validation-no-valid-epoch-number": "Veuillez saisir un nombre valide d'époques.", "validation-no-valid-frequency": "Veuillez saisir une fréquence valide dans", diff --git a/birdnet_analyzer/lang/id.json b/birdnet_analyzer/lang/id.json index a6ca9cccb..8ead69b8a 100644 --- a/birdnet_analyzer/lang/id.json +++ b/birdnet_analyzer/lang/id.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "Kolom tidak ada di file", "eval-tab-error-missing-col-info": "Silakan periksa nama kolom.", "eval-tab-error-no-class-selected": "Setidaknya satu kelas harus dipilih.", + "eval-tab-error-no-metric-selected": "Setidaknya satu metrik harus dipilih.", "eval-tab-error-no-valid-recording-duration": "Silakan masukkan angka yang valid untuk durasi rekaman.", "eval-tab-error-plotting-confusion-matrix": "Error saat membuat confusion matrix", "eval-tab-error-plotting-metrics": "Error saat membuat metrik", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Silakan pilih daftar spesies.", "validation-no-training-data-selected": "Silakan pilih data pelatihan Anda.", "validation-no-valid-batch-size": "Silakan masukkan ukuran batch yang valid.", + "validation-no-valid-cache-file-name": "Silakan masukkan nama yang valid untuk berkas cache.", "validation-no-valid-classifier-name": "Silakan masukkan nama yang valid untuk klasifikator.", "validation-no-valid-epoch-number": "Silakan masukkan jumlah epochs yang valid.", "validation-no-valid-frequency": "Silakan masukkan frekuensi yang valid dalam", diff --git a/birdnet_analyzer/lang/pt-br.json b/birdnet_analyzer/lang/pt-br.json index 3ce8561ce..7c2db167d 100644 --- a/birdnet_analyzer/lang/pt-br.json +++ b/birdnet_analyzer/lang/pt-br.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "Coluna ausente nos arquivos", "eval-tab-error-missing-col-info": "Verifique os nomes das colunas.", "eval-tab-error-no-class-selected": "Selecione pelo menos uma classe.", + "eval-tab-error-no-metric-selected": "Selecione pelo menos uma métrica.", "eval-tab-error-no-valid-recording-duration": "Insira um valor válido para a duração da gravação.", "eval-tab-error-plotting-confusion-matrix": "Erro ao plotar matriz de confusão", "eval-tab-error-plotting-metrics": "Erro ao plotar métricas", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Selecione uma lista de espécies.", "validation-no-training-data-selected": "Selecione seus dados de treinamento.", "validation-no-valid-batch-size": "Adicionar um tamanho de lote (batch) válido.", + "validation-no-valid-cache-file-name": "Insira um nome válido para o arquivo de cache.", "validation-no-valid-classifier-name": "Adicionar um nome válido para o classificador.", "validation-no-valid-epoch-number": "Adicionar um número de épocas (epochs) válido.", "validation-no-valid-frequency": "Adicionar uma frequência válida em", diff --git a/birdnet_analyzer/lang/ru.json b/birdnet_analyzer/lang/ru.json index 0711ea93f..7604a2f1c 100644 --- a/birdnet_analyzer/lang/ru.json +++ b/birdnet_analyzer/lang/ru.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "Колонка отсутствует в файлах", "eval-tab-error-missing-col-info": "Пожалуйста, проверьте названия колонок.", "eval-tab-error-no-class-selected": "Необходимо выбрать хотя бы один класс.", + "eval-tab-error-no-metric-selected": "Необходимо выбрать хотя бы одну метрику.", "eval-tab-error-no-valid-recording-duration": "Пожалуйста, введите корректную длительность записи.", "eval-tab-error-plotting-confusion-matrix": "Ошибка построения матрицы ошибок", "eval-tab-error-plotting-metrics": "Ошибка построения метрик", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Пожалуйста, выберите список видов.", "validation-no-training-data-selected": "Выберите данные обучения.", "validation-no-valid-batch-size": "Пожалуйста, введите допустимый размер пакета.", + "validation-no-valid-cache-file-name": "Пожалуйста, введите имя для файла кэша.", "validation-no-valid-classifier-name": "Пожалуйста, введите имя для классификатора.", "validation-no-valid-epoch-number": "Пожалуйста, введите допустимое количество периодов.", "validation-no-valid-frequency": "Введите допустимую частоту в", diff --git a/birdnet_analyzer/lang/se.json b/birdnet_analyzer/lang/se.json index 6377b5624..51c3cad8a 100644 --- a/birdnet_analyzer/lang/se.json +++ b/birdnet_analyzer/lang/se.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "Kolumn saknas i filer", "eval-tab-error-missing-col-info": "Kontrollera kolumnnamnen.", "eval-tab-error-no-class-selected": "Minst en klass måste väljas.", + "eval-tab-error-no-metric-selected": "Minst ett mått måste väljas.", "eval-tab-error-no-valid-recording-duration": "Ange ett giltigt nummer för inspelningslängd.", "eval-tab-error-plotting-confusion-matrix": "Fel vid plottning av förvirringsmatris", "eval-tab-error-plotting-metrics": "Fel vid plottning av mått", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Välj en artlista.", "validation-no-training-data-selected": "Välj dina träningsdata.", "validation-no-valid-batch-size": "Ange en giltig batchstorlek.", + "validation-no-valid-cache-file-name": "Ange ett giltigt namn för cachefilen.", "validation-no-valid-classifier-name": "Ange ett giltigt namn för klassificeraren.", "validation-no-valid-epoch-number": "Ange ett giltigt antal epoker.", "validation-no-valid-frequency": "Ange en giltig frekvens i", diff --git a/birdnet_analyzer/lang/tlh.json b/birdnet_analyzer/lang/tlh.json index f5ae4fba0..ae00d24a6 100644 --- a/birdnet_analyzer/lang/tlh.json +++ b/birdnet_analyzer/lang/tlh.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "QIn tu'Ha'", "eval-tab-error-missing-col-info": "QIn pongmey yIqel", "eval-tab-error-no-class-selected": "Segh yIwIv", + "eval-tab-error-no-metric-selected": "qel yIwIv", "eval-tab-error-no-valid-recording-duration": "wav tup yIchoH", "eval-tab-error-plotting-confusion-matrix": "QapHa'", "eval-tab-error-plotting-metrics": "QapHa'", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "Seghmey wav yIwIv.", "validation-no-training-data-selected": "qeq De' yIwIv.", "validation-no-valid-batch-size": "batch chu' yIchoH.", + "validation-no-valid-cache-file-name": "polmeH teywI' pong yIchoH.", "validation-no-valid-classifier-name": "tu'law' pong yIchoH.", "validation-no-valid-epoch-number": "epQochmey yIchoH.", "validation-no-valid-frequency": "qeq 'eS patlh yIchoH", diff --git a/birdnet_analyzer/lang/zh_CN.json b/birdnet_analyzer/lang/zh_CN.json index aed4a0dbc..555047e72 100644 --- a/birdnet_analyzer/lang/zh_CN.json +++ b/birdnet_analyzer/lang/zh_CN.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "文件中缺少列", "eval-tab-error-missing-col-info": "请检查列名。", "eval-tab-error-no-class-selected": "必须至少选择一个类别。", + "eval-tab-error-no-metric-selected": "必须至少选择一个指标。", "eval-tab-error-no-valid-recording-duration": "请输入有效的录音持续时间数字。", "eval-tab-error-plotting-confusion-matrix": "绘制混淆矩阵时出错", "eval-tab-error-plotting-metrics": "绘制指标时出错", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "请选择物种列表。", "validation-no-training-data-selected": "请选择您的训练数据。", "validation-no-valid-batch-size": "请输入有效的批大小。", + "validation-no-valid-cache-file-name": "请为缓存文件输入有效名称。", "validation-no-valid-classifier-name": "请为分类器输入有效名称。", "validation-no-valid-epoch-number": "请输入有效的轮数。", "validation-no-valid-frequency": "请输入有效的频率", diff --git a/birdnet_analyzer/lang/zh_TW.json b/birdnet_analyzer/lang/zh_TW.json index fd39b21aa..92d2f4b8c 100644 --- a/birdnet_analyzer/lang/zh_TW.json +++ b/birdnet_analyzer/lang/zh_TW.json @@ -88,6 +88,7 @@ "eval-tab-error-missing-col": "文件中缺少欄位", "eval-tab-error-missing-col-info": "請檢查欄位名稱。", "eval-tab-error-no-class-selected": "必須至少選擇一個類別。", + "eval-tab-error-no-metric-selected": "必須至少選擇一個指標。", "eval-tab-error-no-valid-recording-duration": "請輸入有效的錄音持續時間數字。", "eval-tab-error-plotting-confusion-matrix": "繪製混淆矩陣出錯", "eval-tab-error-plotting-metrics": "繪製指標出錯", @@ -475,6 +476,7 @@ "validation-no-species-list-selected": "請選擇物種清單", "validation-no-training-data-selected": "請選擇訓練用資料", "validation-no-valid-batch-size": "請輸入有效的批次量(在同一時間分析的音檔數量)", + "validation-no-valid-cache-file-name": "請輸入有效的快取檔案名稱。", "validation-no-valid-classifier-name": "請輸入有效的分類器名稱", "validation-no-valid-epoch-number": "請輸入有效的訓練期(eopch)數量", "validation-no-valid-frequency": "請輸入有效的頻率", diff --git a/tests/gui/test_analysis.py b/tests/gui/test_analysis.py new file mode 100644 index 000000000..4db96e4b7 --- /dev/null +++ b/tests/gui/test_analysis.py @@ -0,0 +1,52 @@ +import importlib + +import pytest + +gr = pytest.importorskip("gradio") + +from birdnet_analyzer.gui import analysis as ga # noqa: E402 +from birdnet_analyzer.gui import utils as gu # noqa: E402 + + +@pytest.mark.parametrize(("slider", "speed"), [(-2, 0.5), (0, 1.0), (3, 3.0)]) +def test_the_speed_slider_reaches_the_analysis_as_a_factor(monkeypatch, slider, speed): + seen = {} + monkeypatch.setattr( + importlib.import_module("birdnet_analyzer.analyze"), + "analyze", + lambda **kwargs: seen.update(kwargs), + ) + + ga.run_analysis( + input_path="recording.wav", + output_path=None, + use_top_n=False, + top_n=1, + confidence=0.25, + sensitivity=1.0, + overlap=0.0, + merge_consecutive=1, + audio_speed=slider, + fmin=0, + fmax=15000, + species_list_choice=gu._ALL_SPECIES, + species_list_file=None, + lat=-1, + lon=-1, + week=-1, + use_yearlong=True, + sf_thresh=0.03, + selected_model=gu._USE_BIRDNET_3_0, + custom_classifier_file=None, + output_types="table", + additional_columns=None, + locale="en_us", + batch_size=1, + input_dir=None, + save_params=False, + n_producers=1, + n_workers=1, + progress=None, + ) + + assert seen["audio_speed"] == speed diff --git a/tests/gui/test_evaluation_tab.py b/tests/gui/test_evaluation_tab.py new file mode 100644 index 000000000..2e6c1467e --- /dev/null +++ b/tests/gui/test_evaluation_tab.py @@ -0,0 +1,155 @@ +import re + +import pandas as pd +import pytest + +gr = pytest.importorskip("gradio") + +import birdnet_analyzer.gui.localization as loc # noqa: E402 +from birdnet_analyzer import settings # noqa: E402 +from birdnet_analyzer.evaluation.preprocessing import data_processor # noqa: E402 +from birdnet_analyzer.gui import evaluation # noqa: E402 +from birdnet_analyzer.gui import state as gs # noqa: E402 +from birdnet_analyzer.gui import utils as gu # noqa: E402 + + +@pytest.fixture +def appdir(monkeypatch, 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 + + +@pytest.fixture +def demo(appdir): + with gr.Blocks() as blocks: + evaluation.build_evaluation_tab() + + return blocks + + +def _event(demo, name): + return next( + event + for event in demo.fns.values() + if getattr(event.fn, "__name__", "") == name + ) + + +class FakeProcessor: + classes = ("species",) + samples_df = pd.DataFrame({"filename": ["recording.wav"]}) + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.recording_duration = kwargs.get("recording_duration") + + +def _duration_textbox(): + label = loc.localize("eval-tab-recording-duration-textbox-label") + + return next( + c + for c in gs.persisted_components() + if isinstance(c, gr.Textbox) and c.label == label + ) + + +def _call(event, values): + return event.fn(*(values.get(c) for c in event.inputs)) + + +def _refresh(demo, recording_duration): + event = _event(demo, "refresh_processor") + columns = [""] * (len(event.inputs) - 9) + + return event.fn( + "annotations", + "predictions", + None, + 3, + 0.5, + recording_duration, + False, + None, + None, + *columns, + ) + + +def _processor_state(recording_duration=None): + return evaluation.ProcessorState( + FakeProcessor(recording_duration=recording_duration), + "annotations", + "predictions", + ) + + +def test_the_overall_row_follows_programmatic_averaging_changes(demo): + event = _event(demo, "recompute_overall") + averaging_radio = event.inputs[-1] + + assert event.targets == [(averaging_radio._id, "change")] + assert event.fn(None, None, None, "micro") == gr.update() + + +@pytest.mark.parametrize( + ("text", "duration"), + [("12.5", 12.5), (" 60 ", 60.0), ("3", 3.0), ("", None), (" ", None)], +) +def test_a_recording_duration_is_parsed(demo, monkeypatch, text, duration): + monkeypatch.setattr(data_processor, "DataProcessor", FakeProcessor) + + *_, proc_state = _refresh(demo, text) + + assert proc_state.processor.kwargs["recording_duration"] == duration + + +@pytest.mark.parametrize("text", ["12o", "1", "0", "-5"]) +def test_a_duration_the_processor_cannot_use_does_not_interrupt_the_refresh( + demo, monkeypatch, text +): + monkeypatch.setattr(data_processor, "DataProcessor", FakeProcessor) + + *_, proc_state = _refresh(demo, text) + + assert proc_state.processor.kwargs["recording_duration"] is None + + +@pytest.mark.parametrize("handler", ["calculate_metrics", "download_data_table"]) +@pytest.mark.parametrize("text", ["12o", "1", "15", "", "nan"]) +def test_a_duration_the_processor_was_not_built_with_is_rejected( + demo, monkeypatch, handler, text +): + event = _event(demo, handler) + dialogs = [] + monkeypatch.setattr(gu, "save_file_dialog", lambda **k: dialogs.append(k)) + message = loc.localize("eval-tab-error-no-valid-recording-duration") + + with pytest.raises(gr.Error, match=re.escape(message)): + _call( + event, {event.inputs[0]: _processor_state(12.0), _duration_textbox(): text} + ) + + assert dialogs == [] + + +@pytest.mark.parametrize("text", ["12", " 12.0 "]) +def test_the_duration_the_processor_was_built_with_is_accepted(demo, monkeypatch, text): + event = _event(demo, "download_data_table") + dialogs = [] + monkeypatch.setattr(gu, "save_file_dialog", lambda **k: dialogs.append(k)) + + _call(event, {event.inputs[0]: _processor_state(12.0), _duration_textbox(): text}) + + assert len(dialogs) == 1 + + +def test_calculating_without_a_metric_is_rejected(demo): + event = _event(demo, "calculate_metrics") + message = loc.localize("eval-tab-error-no-metric-selected") + + with pytest.raises(gr.Error, match=re.escape(message)): + _call(event, {event.inputs[0]: _processor_state()}) diff --git a/tests/gui/test_review_tab.py b/tests/gui/test_review_tab.py new file mode 100644 index 000000000..3d08a73f8 --- /dev/null +++ b/tests/gui/test_review_tab.py @@ -0,0 +1,79 @@ +import pytest + +gr = pytest.importorskip("gradio") + +import birdnet_analyzer.gui.localization as loc # noqa: E402 +from birdnet_analyzer import settings # noqa: E402 +from birdnet_analyzer.gui import review # noqa: E402 +from birdnet_analyzer.gui import state as gs # noqa: E402 +from birdnet_analyzer.gui import utils as gu # noqa: E402 + + +@pytest.fixture +def appdir(monkeypatch, 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 _handler(name): + with gr.Blocks() as demo: + review.build_review_tab() + + return next( + event.fn + for event in demo.fns.values() + if getattr(event.fn, "__name__", "") == name + ) + + +def test_out_of_range_confidences_are_left_out_of_the_regression( + appdir, monkeypatch, tmp_path +): + from sklearn import linear_model + + folder = tmp_path / "segments" + for label, names in ( + (review.POSITIVE_LABEL_DIR, ["0.900_a.wav", "1.500_b.wav"]), + (review.NEGATIVE_LABEL_DIR, ["0.200_c.wav", "0.100_d.wav"]), + ): + (folder / label).mkdir(parents=True) + for name in names: + (folder / label / name).touch() + + fitted = [] + + class RecordingRegression(linear_model.LogisticRegression): + def fit(self, X, y, *args, **kwargs): + fitted.append(sorted(zip((x for (x,) in X), y, strict=True))) + return super().fit(X, y, *args, **kwargs) + + monkeypatch.setattr(linear_model, "LogisticRegression", RecordingRegression) + monkeypatch.setattr(gu, "select_folder", lambda *a, **k: str(folder)) + empty = { + "input_directory": "", + "species_list": [], + "current_species": "", + "files": [], + review.POSITIVE_LABEL_DIR: [], + review.NEGATIVE_LABEL_DIR: [], + "skipped": [], + "history": [], + } + + updates = _handler("start_review")(empty) + + label = loc.localize("review-tab-regression-plot-label") + plot = next( + update + for component, update in updates.items() + if isinstance(component, gr.Plot) and component.label == label + ) + (axes,) = plot.constructor_args["value"].axes + points = sorted(map(tuple, axes.collections[-1].get_offsets().tolist())) + expected = [(0.1, 0), (0.2, 0), (0.9, 1)] + + assert points == pytest.approx(expected) + assert fitted == [expected] diff --git a/tests/gui/test_segments_tab.py b/tests/gui/test_segments_tab.py new file mode 100644 index 000000000..4c0ef8555 --- /dev/null +++ b/tests/gui/test_segments_tab.py @@ -0,0 +1,35 @@ +import pytest + +gr = pytest.importorskip("gradio") + +from birdnet_analyzer.gui import segments as gui_segments # noqa: E402 + + +@pytest.mark.parametrize(("slider", "speed"), [(-2, 0.5), (0, 1.0), (3, 3.0)]) +def test_the_speed_slider_reaches_the_extraction_as_a_factor( + monkeypatch, tmp_path, slider, speed +): + seen = {} + + def fake_segments(*args, **kwargs): + seen.update(kwargs) + return [] + + monkeypatch.setattr(gui_segments, "segments", fake_segments) + + gui_segments._extract_segments( + str(tmp_path), + str(tmp_path), + str(tmp_path), + 0.25, + 1.0, + 100, + slider, + 3.0, + 1, + "random", + 10, + progress=None, + ) + + assert seen["audio_speed"] == speed diff --git a/tests/gui/test_single_file_downloads.py b/tests/gui/test_single_file_downloads.py new file mode 100644 index 000000000..f9d61f8e7 --- /dev/null +++ b/tests/gui/test_single_file_downloads.py @@ -0,0 +1,40 @@ +import pytest + +gr = pytest.importorskip("gradio") + +import birdnet_analyzer.config as cfg # noqa: E402 +from birdnet_analyzer import settings # noqa: E402 +from birdnet_analyzer.gui import single_file # noqa: E402 +from birdnet_analyzer.gui import state as gs # noqa: E402 +from birdnet_analyzer.gui import utils as gu # noqa: E402 + + +@pytest.fixture +def appdir(monkeypatch, 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", []) + monkeypatch.setattr(gu, "plot_map_scatter_mapbox", lambda *a, **k: None) + return tmp_path + + +def test_the_kaleidoscope_dialog_offers_the_csv_it_saves(appdir, monkeypatch): + with gr.Blocks() as demo: + single_file.build_single_analysis_tab() + download = next( + event.fn + for event in demo.fns.values() + if getattr(event.fn, "__name__", "") == "download_kaleidoscope" + ) + dialogs = [] + monkeypatch.setattr( + gu, "save_file_dialog", lambda **kwargs: dialogs.append(kwargs) or None + ) + + download({"table": None}) + + (dialog,) = dialogs + assert dialog["default_filename"] == cfg.OUTPUT_KALEIDOSCOPE_FILENAME + assert cfg.OUTPUT_KALEIDOSCOPE_FILENAME.endswith(".csv") + assert dialog["filetypes"] == ("Kaleidoscope (*.csv)",) diff --git a/tests/gui/test_train_tab.py b/tests/gui/test_train_tab.py index 172f55074..8be457a5c 100644 --- a/tests/gui/test_train_tab.py +++ b/tests/gui/test_train_tab.py @@ -5,6 +5,10 @@ with, so loading one shows that version and disables the choice. """ +import os +import re +from types import SimpleNamespace + import numpy as np import pytest @@ -93,9 +97,7 @@ def test_cache_model_version_reads_the_record_or_the_embeddings_width(tmp_path): assert train.cache_model_version(str(tmp_path / "missing.npz")) is None -def test_the_base_choice_reaches_training(monkeypatch, tmp_path): - from types import SimpleNamespace - +def _start_training(monkeypatch, tmp_path, **overrides): from birdnet_analyzer.train import utils as train_utils seen = {} @@ -110,44 +112,195 @@ def fake_train_model(**kwargs): monkeypatch.setattr(train_utils, "train_model", fake_train_model) train.start_training( - data_dir=str(tmp_path), - test_data_dir=None, - crop_mode="center", - crop_overlap=0.0, - fmin=0, - fmax=15000, - output_dir=str(tmp_path), - classifier_name="cc", - model_save_mode="replace", - birdnet_version="2.4", - cache_mode="none", - cache_file=None, - cache_file_name="cache.npz", - autotune=False, - autotune_trials=1, - autotune_folds=2, - autotune_repeats=1, - epochs=1, - batch_size=1, - learning_rate=0.001, - focal_loss=False, - focal_loss_gamma=2.0, - focal_loss_alpha=0.25, - hidden_units=0, - dropout=0.0, - label_smoothing=False, - use_mixup=False, - upsampling_ratio=0.0, - upsampling_mode="repeat", - model_formats=["tflite"], - audio_speed=1.0, - threads=1, - progress=None, + **{ + "data_dir": str(tmp_path), + "test_data_dir": None, + "crop_mode": "center", + "crop_overlap": 0.0, + "fmin": 0, + "fmax": 15000, + "output_dir": str(tmp_path), + "classifier_name": "cc", + "model_save_mode": "replace", + "birdnet_version": "3.0", + "cache_mode": "none", + "cache_file": None, + "cache_file_name": "cache.npz", + "autotune": False, + "autotune_trials": 1, + "autotune_folds": 2, + "autotune_repeats": 1, + "epochs": 1, + "batch_size": 1, + "learning_rate": 0.001, + "focal_loss": False, + "focal_loss_gamma": 2.0, + "focal_loss_alpha": 0.25, + "hidden_units": 0, + "dropout": 0.0, + "label_smoothing": False, + "use_mixup": False, + "upsampling_ratio": 0.0, + "upsampling_mode": "repeat", + "model_formats": ["tflite"], + "audio_speed": 1.0, + "threads": 1, + "progress": None, + } + | overrides ) + return seen + + +def test_the_base_choice_reaches_training(monkeypatch, tmp_path): + seen = _start_training(monkeypatch, tmp_path, birdnet_version="2.4") + assert seen["birdnet"] == "2.4" +@pytest.mark.parametrize(("slider", "speed"), [(-2, 0.5), (0, 1.0), (3, 3.0)]) +def test_the_speed_slider_reaches_training_as_a_factor( + monkeypatch, tmp_path, slider, speed +): + seen = _start_training(monkeypatch, tmp_path, audio_speed=slider) + + assert seen["audio_speed"] == speed + + +def test_an_out_of_range_bandpass_is_rejected(monkeypatch, tmp_path): + message = loc.localize("validation-no-valid-frequency") + + with pytest.raises(gr.Error, match=re.escape(message)): + _start_training(monkeypatch, tmp_path, fmin=-1) + + +@pytest.mark.parametrize("picked", ["nothing", "a file"]) +def test_saving_a_cache_needs_a_directory(monkeypatch, tmp_path, picked): + cache_file = None + + if picked == "a file": + cache_file = tmp_path / "train_cache.npz" + cache_file.touch() + + message = loc.localize("validation-no-directory-selected") + + with pytest.raises(gr.Error, match=re.escape(message)): + _start_training( + monkeypatch, + tmp_path, + cache_mode="save", + cache_file=cache_file and str(cache_file), + ) + + +@pytest.mark.parametrize("name", ["", " "]) +def test_saving_a_cache_needs_a_file_name(monkeypatch, tmp_path, name): + message = loc.localize("validation-no-valid-cache-file-name") + + with pytest.raises(gr.Error, match=re.escape(message)): + _start_training( + monkeypatch, + tmp_path, + cache_mode="save", + cache_file=str(tmp_path), + cache_file_name=name, + ) + + +@pytest.mark.parametrize("picked", ["nothing", "a missing file"]) +def test_training_from_a_cache_needs_an_existing_file(monkeypatch, tmp_path, picked): + cache_file = None if picked == "nothing" else str(tmp_path / "missing.npz") + message = loc.localize("validation-no-cache-file-selected") + + with pytest.raises(gr.Error, match=re.escape(message)): + _start_training(monkeypatch, tmp_path, cache_mode="load", cache_file=cache_file) + + +def test_a_saved_cache_lands_in_the_picked_directory(monkeypatch, tmp_path): + seen = _start_training( + monkeypatch, + tmp_path, + cache_mode="save", + cache_file=str(tmp_path), + cache_file_name="cache.npz", + ) + + assert seen["save_cache_to"] == os.path.join(str(tmp_path), "cache.npz") + + +def _persisted(cls, label_key): + label = loc.localize(label_key) + + return next( + c for c in gs.persisted_components() if isinstance(c, cls) and c.label == label + ) + + +def _fire(demo, trigger, values): + """Runs every handler of ``trigger``'s change event, merging their updates.""" + updates = {} + + for event in demo.fns.values(): + if (trigger._id, "change") in event.targets: + result = event.fn(*(values.get(c) for c in event.inputs)) + results = result if len(event.outputs) > 1 else (result,) + + for output, update in zip(event.outputs, results, strict=True): + assert output not in updates, "two handlers write the same output" + updates[output] = update + + return updates + + +@pytest.mark.parametrize("cache", ["none", "load"]) +@pytest.mark.parametrize("crop", ["center", "segments"]) +def test_the_crop_overlap_follows_cache_and_crop_mode_whichever_changed( + appdir, cache, crop +): + with gr.Blocks() as demo: + train.build_train_tab() + cache_mode = _radio("training-tab-cache-mode-radio-label") + crop_mode = _radio("training-tab-crop-mode-radio-label") + crop_overlap = _persisted(gr.Slider, "training-tab-crop-overlap-number-label") + expected = { + "visible": crop == "segments", + "interactive": crop == "segments" and cache != "load", + } + + for trigger in (cache_mode, crop_mode): + update = _fire(demo, trigger, {cache_mode: cache, crop_mode: crop})[ + crop_overlap + ] + + assert {key: update.get(key) for key in expected} == expected + + +def test_changing_the_cache_mode_forgets_the_picked_cache_location(appdir): + with gr.Blocks() as demo: + train.build_train_tab() + cache_mode = _radio("training-tab-cache-mode-radio-label") + pickers = { + getattr(event.fn, "__name__", ""): event.outputs for event in demo.fns.values() + } + picked = { + *pickers["select_cache_directory_and_update"][:2], + *pickers["on_cache_file_selection_click"][:3], + } + + for mode in ("none", "load", "save"): + updates = _fire(demo, cache_mode, {cache_mode: mode}) + + for component in picked: + update = updates[component] + + if isinstance(update, dict): + assert "value" in update, component + update = update["value"] + + assert update in (None, "") + + def test_selecting_a_cache_shows_its_version(appdir, monkeypatch, tmp_path): from birdnet_analyzer.gui import utils as gu diff --git a/tests/gui/test_utils.py b/tests/gui/test_utils.py new file mode 100644 index 000000000..470e852d1 --- /dev/null +++ b/tests/gui/test_utils.py @@ -0,0 +1,119 @@ +import base64 +import io +import logging +import os +import sys +from types import SimpleNamespace + +import pytest + +gr = pytest.importorskip("gradio") + +from PIL import Image # noqa: E402 + +from birdnet_analyzer.gui import utils as gu # noqa: E402 + + +@pytest.fixture +def save_dialog(monkeypatch): + def answer(path): + webview = SimpleNamespace(FileDialog=SimpleNamespace(SAVE="save")) + window = SimpleNamespace(create_file_dialog=lambda *a, **k: (str(path),)) + monkeypatch.setitem(sys.modules, "webview", webview) + monkeypatch.setattr(gu, "_WINDOW", window) + + return answer + + +def _plot(): + """A semi-transparent image encoded the way gr.Plot hands a figure over.""" + buffer = io.BytesIO() + Image.new("RGBA", (4, 4), (255, 0, 0, 128)).save(buffer, "WEBP") + data = base64.b64encode(buffer.getvalue()).decode() + + return SimpleNamespace(plot=f"data:image/webp;base64,{data}") + + +def test_a_plot_is_saved_under_the_chosen_name_despite_dots_in_the_path( + save_dialog, tmp_path +): + folder = tmp_path / "j.doe" + folder.mkdir() + save_dialog(folder / "plot.png") + + gu.download_plot(_plot(), "plot") + + assert os.listdir(folder) == ["plot.png"] + assert Image.open(folder / "plot.png").format == "PNG" + + +@pytest.mark.parametrize( + ("name", "saved", "image_format"), + [ + ("x.jpg", "x.jpg", "JPEG"), + ("x.JPEG", "x.JPEG", "JPEG"), + ("x.webp", "x.webp", "WEBP"), + ("x.bmp", "x.bmp", "BMP"), + ("x.gif", "x.gif", "GIF"), + ("x.tif", "x.tif", "TIFF"), + ("x.xyz", "x.xyz.png", "PNG"), + ], +) +def test_a_plot_is_saved_in_the_format_its_name_asks_for( + save_dialog, tmp_path, name, saved, image_format +): + save_dialog(tmp_path / name) + + gu.download_plot(_plot(), "plot") + + assert os.listdir(tmp_path) == [saved] + assert Image.open(tmp_path / saved).format == image_format + + +def test_one_unreadable_network_shortcut_does_not_hide_the_others( + monkeypatch, tmp_path, caplog +): + for name in ("readable", "unreadable"): + (tmp_path / name).mkdir() + (tmp_path / name / "target.lnk").touch() + + class FakeShellLink: + def QueryInterface(self, iid): + return self + + def Load(self, path): + self.path = path + + def GetPath(self, flags): + share = os.path.basename(os.path.dirname(self.path)) + + if share == "unreadable": + raise OSError("broken shortcut") + + return rf"\\server\{share}", None + + shell = SimpleNamespace( + SHGetFolderPath=lambda *a: str(tmp_path), + CLSID_ShellLink=None, + IID_IShellLink=None, + SLGP_RAWPATH=None, + ) + pythoncom = SimpleNamespace( + CoCreateInstance=lambda *a: FakeShellLink(), + CLSCTX_INPROC_SERVER=None, + IID_IPersistFile=None, + ) + monkeypatch.setitem(sys.modules, "pythoncom", pythoncom) + monkeypatch.setitem( + sys.modules, + "win32com.shell", + SimpleNamespace(shell=shell, shellcon=SimpleNamespace(CSIDL_NETHOOD=None)), + ) + unhandled = [] + monkeypatch.setattr(gu.utils, "write_error_log", unhandled.append) + + with caplog.at_level(logging.ERROR, logger=gu.logger.name): + assert gu._get_network_shortcuts() == [r"\\server\readable"] + + assert unhandled == [] + assert [r.message for r in caplog.records if "unreadable" in r.message]