Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,16 @@ deleting that directory is how you test a first-run experience.

## Conventions that are easy to get wrong

- **The GUI test environment has no pywebview and no plotly** — `gui-tests` installs
gradio, both others are `gui`-only. So no GUI module may import `webview` or
`plotly` at module import time: `gui/utils.py` imports `webview` inside the dialog
functions and `open_window` only, and its `_WINDOW` annotation is a string under
`TYPE_CHECKING`. The tests import `gui.utils` unstubbed on purpose. A test that
builds species-list controls must stub `gu.plot_map_scatter_mapbox` (it draws a
plotly map). A local venv with the `gui` extra hides both problems, so run GUI
tests once with `webview`/`plotly` made unimportable before pushing.
- **The GUI test environment has no pywebview, no plotly and no pywin32** —
`gui-tests` installs gradio only, the others are `gui`-only, and pywin32 is
imported in one place, inside `_get_network_shortcuts`. So no GUI module may
import `webview` or `plotly` at module import time: `gui/utils.py` imports
`webview` inside the dialog functions and `open_window` only, and its `_WINDOW`
annotation is a string under `TYPE_CHECKING`. The tests import `gui.utils`
unstubbed on purpose. A test that builds species-list controls must stub
`gu.plot_map_scatter_mapbox` (it draws a plotly map). A local venv with the `gui`
extra hides these problems, so run GUI tests once with `webview`, `plotly`,
`pythoncom` and `win32com` made unimportable before pushing.
- **Localization is all-or-nothing.** `lang/*.json` holds one file per language. A
new UI string has to be added to *every* file with a real translation — not the
English text copied over. Write them with a json load/dump round-trip
Expand Down
6 changes: 0 additions & 6 deletions birdnet_analyzer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import logging
import os
import warnings

from birdnet_analyzer.settings import apply_model_directory

Expand All @@ -21,7 +19,3 @@

__version__ = "2.4.0"
__all__ = ["analyze", "embeddings", "search", "segments", "species", "train"]
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
os.environ["WRAPT_DISABLE_EXTENSIONS"] = "true"

warnings.filterwarnings("ignore")
4 changes: 4 additions & 0 deletions birdnet_analyzer/gui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
def main():
import birdnet_analyzer.gui.localization as loc
import birdnet_analyzer.gui.multi_file as mfa
import birdnet_analyzer.gui.segments as gs
import birdnet_analyzer.gui.single_file as sfa
Expand All @@ -12,8 +13,11 @@ def main():
train,
)
from birdnet_analyzer.logs import setup_logging
from birdnet_analyzer.utils import configure_runtime

setup_logging()
configure_runtime()
loc.load_local_state()
gu.open_window(
[
sfa.build_single_analysis_tab,
Expand Down
32 changes: 17 additions & 15 deletions birdnet_analyzer/gui/localization.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,26 @@

from birdnet_analyzer import settings

LANGUAGE_LOOKUP = {}
TARGET_LANGUAGE = settings.FALLBACK_LANGUAGE
logger = logging.getLogger(__name__)


def available_languages() -> list[str]:
"""The ids of the languages the GUI has a language file for, sorted."""
return sorted(file.stem for file in Path(settings.LANG_DIR).glob("*.json"))


def _read_language_file(language: str) -> dict:
if language not in available_languages():
raise ValueError(f"Unknown language {language!r}")

with open(f"{settings.LANG_DIR}/{language}.json", encoding="utf-8") as f:
return json.load(f)


TARGET_LANGUAGE = settings.FALLBACK_LANGUAGE
LANGUAGE_LOOKUP = _read_language_file(TARGET_LANGUAGE)


def load_local_state():
"""
Loads the local language settings and populates the LANGUAGE_LOOKUP dictionary with
Expand All @@ -35,19 +50,6 @@ def load_local_state():
LANGUAGE_LOOKUP = fallback | LANGUAGE_LOOKUP


def available_languages() -> list[str]:
"""The ids of the languages the GUI has a language file for, sorted."""
return sorted(file.stem for file in Path(settings.LANG_DIR).glob("*.json"))


def _read_language_file(language: str) -> dict:
if language not in available_languages():
raise ValueError(f"Unknown language {language!r}")

with open(f"{settings.LANG_DIR}/{language}.json", encoding="utf-8") as f:
return json.load(f)


def localize(key: str) -> str:
"""
Translates a given key into its corresponding localized string.
Expand Down
65 changes: 35 additions & 30 deletions birdnet_analyzer/gui/single_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,24 @@

MATPLOTLIB_FIGURE_NUM = "single-file-tab-spectrogram-plot"
MAX_TABLE_ROWS = 50
HEADER_START_LBL = loc.localize("single-tab-output-header-start")
HEADER_END_LBL = loc.localize("single-tab-output-header-end")
HEADER_SCI_NAME_LBL = loc.localize("single-tab-output-header-sci-name")
HEADER_COMMON_NAME_LBL = loc.localize("single-tab-output-header-common-name")
HEADER_CONFIDENCE_LBL = loc.localize("single-tab-output-header-confidence")
_HEADER_LABELS = {
"HEADER_START_LBL": "single-tab-output-header-start",
"HEADER_END_LBL": "single-tab-output-header-end",
"HEADER_SCI_NAME_LBL": "single-tab-output-header-sci-name",
"HEADER_COMMON_NAME_LBL": "single-tab-output-header-common-name",
"HEADER_CONFIDENCE_LBL": "single-tab-output-header-confidence",
}


def _header_labels() -> list[str]:
return [loc.localize(key) for key in _HEADER_LABELS.values()]


def __getattr__(name: str) -> str:
if name in _HEADER_LABELS:
return loc.localize(_HEADER_LABELS[name])

raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


@gu.gui_runtime_error_handler
Expand Down Expand Up @@ -109,41 +122,40 @@ def convert_to_time_str(seconds: float) -> str:
)

n_rows = table.shape[0]
start_lbl, end_lbl, sci_name_lbl, common_name_lbl, confidence_lbl = _header_labels()

if n_rows > 0:
split_species = table["species_name"].str.split("_", n=1, expand=True)
table[HEADER_SCI_NAME_LBL] = split_species[0]
table[HEADER_COMMON_NAME_LBL] = (
table[sci_name_lbl] = split_species[0]
table[common_name_lbl] = (
split_species[1] if len(split_species.columns) > 1 else ""
)
else:
table[HEADER_SCI_NAME_LBL] = []
table[HEADER_COMMON_NAME_LBL] = []
table[sci_name_lbl] = []
table[common_name_lbl] = []

table[" "] = ["▶"] * n_rows
table.rename(
columns={
"start_time": HEADER_START_LBL,
"end_time": HEADER_END_LBL,
"confidence": HEADER_CONFIDENCE_LBL,
"start_time": start_lbl,
"end_time": end_lbl,
"confidence": confidence_lbl,
},
inplace=True,
)
table[HEADER_START_LBL] = table[HEADER_START_LBL].apply(convert_to_time_str)
table[HEADER_END_LBL] = table[HEADER_END_LBL].apply(convert_to_time_str)
table[start_lbl] = table[start_lbl].apply(convert_to_time_str)
table[end_lbl] = table[end_lbl].apply(convert_to_time_str)
table = table[
[
" ",
HEADER_START_LBL,
HEADER_END_LBL,
HEADER_SCI_NAME_LBL,
HEADER_COMMON_NAME_LBL,
HEADER_CONFIDENCE_LBL,
start_lbl,
end_lbl,
sci_name_lbl,
common_name_lbl,
confidence_lbl,
]
]
table[HEADER_CONFIDENCE_LBL] = table[HEADER_CONFIDENCE_LBL].apply(
lambda x: f"{x:0.3f}"
)
table[confidence_lbl] = table[confidence_lbl].apply(lambda x: f"{x:0.3f}")

return (
table,
Expand Down Expand Up @@ -241,14 +253,7 @@ def build_single_analysis_tab() -> gu.TAB_BUILDER_RESULT:

output_dataframe = gr.Dataframe(
type="pandas",
headers=[
" ",
HEADER_START_LBL,
HEADER_END_LBL,
HEADER_SCI_NAME_LBL,
HEADER_COMMON_NAME_LBL,
HEADER_CONFIDENCE_LBL,
],
headers=[" ", *_header_labels()],
elem_id="single-file-output",
interactive=False,
buttons=[],
Expand Down
66 changes: 38 additions & 28 deletions birdnet_analyzer/gui/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import platform
import sys
import threading
import warnings
from collections.abc import Callable
from contextlib import contextmanager, suppress
from html import escape
Expand All @@ -26,16 +25,16 @@
if TYPE_CHECKING:
import webview

warnings.filterwarnings("ignore")
loc.load_local_state()

logger = logging.getLogger(__name__)
SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__))
CUSTOM_SPECIES = loc.localize("species-list-radio-option-custom-list")
PREDICT_SPECIES = loc.localize("species-list-radio-option-predict-list")
CUSTOM_CLASSIFIER = loc.localize("species-list-radio-option-custom-classifier")
ALL_SPECIES = loc.localize("species-list-radio-option-all")
USE_PERCH = loc.localize("species-list-radio-option-use-perch")
# Localized on access, so they follow the language gui.main loads.
_LOCALIZED_LABELS = {
"CUSTOM_SPECIES": "species-list-radio-option-custom-list",
"PREDICT_SPECIES": "species-list-radio-option-predict-list",
"CUSTOM_CLASSIFIER": "species-list-radio-option-custom-classifier",
"ALL_SPECIES": "species-list-radio-option-all",
"USE_PERCH": "species-list-radio-option-use-perch",
}
# BirdNET acoustic model choices. Not localized: the version number is the label.
USE_BIRDNET_2_4 = "BirdNET 2.4"
USE_BIRDNET_3_0 = "BirdNET 3.0"
Expand Down Expand Up @@ -84,6 +83,17 @@
}


def _label(name: str) -> str:
return loc.localize(_LOCALIZED_LABELS[name])


def __getattr__(name: str) -> str:
if name in _LOCALIZED_LABELS:
return _label(name)

raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def is_birdnet_model(model_choice: str) -> bool:
"""Whether the selected model is an official BirdNET acoustic model."""
return model_choice in _BIRDNET_MODEL_VERSIONS
Expand All @@ -98,14 +108,16 @@ def model_supports_sensitivity(model_choice: str) -> bool:
"""Whether the sensitivity slider applies to the selected model choice."""
from birdnet_analyzer.model_utils import supports_sensitivity

return supports_sensitivity("perch" if model_choice == USE_PERCH else "birdnet")
return supports_sensitivity(
"perch" if model_choice == _label("USE_PERCH") else "birdnet"
)


def _species_choices(is_perch: bool) -> list[str]:
if is_perch:
return [ALL_SPECIES, CUSTOM_SPECIES]
return [_label("ALL_SPECIES"), _label("CUSTOM_SPECIES")]

return [ALL_SPECIES, CUSTOM_SPECIES, PREDICT_SPECIES]
return [_label("ALL_SPECIES"), _label("CUSTOM_SPECIES"), _label("PREDICT_SPECIES")]


def _max_overlap(is_perch: bool) -> float:
Expand Down Expand Up @@ -782,10 +794,11 @@ def model_choices():
"""Returns the models that can be selected on the current platform."""
birdnet_models = [label for label, _ in birdnet_model_choices()]

values = [*birdnet_models, CUSTOM_CLASSIFIER, USE_PERCH]
values = [*birdnet_models, _label("CUSTOM_CLASSIFIER"), _label("USE_PERCH")]

if platform.system() == "Darwin":
values.remove(USE_PERCH) # TODO: Remove when tf 2.21+ is available on macOS
# TODO: Remove when tf 2.21+ is available on macOS
values.remove(_label("USE_PERCH"))

return values

Expand All @@ -803,7 +816,7 @@ def sample_species_model_settings(state: TabState, opened=True):
model_choice = state.get(
"model_selection_radio", default_model(), choices=model_choices()
)
is_perch = model_choice == USE_PERCH
is_perch = model_choice == _label("USE_PERCH")

sample_settings = sample_sliders(
state,
Expand All @@ -815,7 +828,7 @@ def sample_species_model_settings(state: TabState, opened=True):
model_settings = model_selection(state, opened=opened)

def on_model_change(value, species_choice, overlap, current_locale):
is_perch = value == USE_PERCH
is_perch = value == _label("USE_PERCH")
choices = _species_choices(is_perch)
max_overlap = _max_overlap(is_perch)
shown = {
Expand All @@ -828,7 +841,7 @@ def on_model_change(value, species_choice, overlap, current_locale):
# Keep the current species selection (e.g. the one a preset was just
# applied with) as long as the new model offers it.
if species_choice not in choices:
fitted["species_list_radio"] = ALL_SPECIES
fitted["species_list_radio"] = _label("ALL_SPECIES")

if model_supports_sensitivity(value):
sensitivity_update = gr.update(
Expand Down Expand Up @@ -1285,13 +1298,13 @@ def show_species_choice(choice: str, file_input):
Column update,
]
"""
if choice == CUSTOM_SPECIES:
if choice == _label("CUSTOM_SPECIES"):
return [
gr.update(visible=False),
gr.update(visible=True),
gr.update(visible=bool(file_input)),
]
if choice == PREDICT_SPECIES:
if choice == _label("PREDICT_SPECIES"):
return [
gr.update(visible=True),
gr.update(visible=False),
Expand Down Expand Up @@ -1322,7 +1335,7 @@ def model_selection(state: TabState, opened=True):
selected_model = model_selection_radio.value

with gr.Column(
visible=selected_model == CUSTOM_CLASSIFIER
visible=selected_model == _label("CUSTOM_CLASSIFIER")
) as custom_classifier_selector:
classifier_selection_button = gr.Button(
loc.localize(
Expand Down Expand Up @@ -1406,7 +1419,7 @@ def on_custom_classifier_selection_click():
)

def on_model_selection_change(choice: str, cc_state):
if choice == CUSTOM_CLASSIFIER:
if choice == _label("CUSTOM_CLASSIFIER"):
return gr.update(visible=True), gr.update(visible=cc_state is not None)

return gr.update(visible=False), gr.update(visible=False)
Expand Down Expand Up @@ -1448,15 +1461,15 @@ def species_lists(
gr.Radio,
accepts={"choices": _species_choices(is_perch=False)},
choices=_species_choices(is_perch),
value=ALL_SPECIES,
value=_label("ALL_SPECIES"),
label=loc.localize("species-list-radio-label"),
info=loc.localize("species-list-radio-info"),
elem_classes="d-block",
)
selected_species_list = species_list_radio.value

with gr.Column(
visible=selected_species_list == PREDICT_SPECIES
visible=selected_species_list == _label("PREDICT_SPECIES")
) as position_row:
(
lat_number,
Expand All @@ -1469,7 +1482,7 @@ def species_lists(

species_file_input = gr.File(
file_types=[".txt"],
visible=selected_species_list == CUSTOM_SPECIES,
visible=selected_species_list == _label("CUSTOM_SPECIES"),
show_label=False,
)

Expand Down Expand Up @@ -1731,10 +1744,7 @@ def open_window(
multiprocessing.freeze_support()

with (
gr.Blocks(
theme=gr.themes.Default(),
analytics_enabled=False,
) as demo,
gr.Blocks(analytics_enabled=False) as demo,
):
build_header()

Expand Down
Loading
Loading