Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
edc87f3
Add BirdNET-Gemodel 3.0
Josef-Haupt May 5, 2026
7b1aef3
Added PB Geomodel
Josef-Haupt May 5, 2026
be7e70e
Updated model-specific year-round week handling
Josef-Haupt May 7, 2026
cfdde20
updated tests and ci to catch log files
Josef-Haupt May 8, 2026
c839b0e
allow redirects again
Josef-Haupt May 8, 2026
ec9fafe
review suggestions
Josef-Haupt May 9, 2026
6ec751d
add variables for languages
stefantaubert May 10, 2026
7e3d2f3
Remove version check from geo/inference/session.py to backends
Josef-Haupt May 11, 2026
2207ae8
Merge branch 'geomodel-3.0' of https://github.com/birdnet-team/birdne…
Josef-Haupt May 11, 2026
5638d34
Added tensorflow validation to perch + skip 3.0 geomodel tests with i…
Josef-Haupt May 11, 2026
408d5ce
Allo ai-edge-litert>=2.1.4 install, but disable geomodel litert tests…
Josef-Haupt May 11, 2026
532e6f0
initial
Josef-Haupt May 11, 2026
f7990b2
updated paths in pkg docs
Josef-Haupt May 11, 2026
a004d3a
updated docs to current pkg structure
Josef-Haupt May 12, 2026
c41eada
limit ai_edge_litert install to available platforms
Josef-Haupt May 12, 2026
6c64243
try to fix ruff warnings + simple geo session test
Josef-Haupt May 12, 2026
f817df1
Merge remote-tracking branch 'origin/main' into geomodel-3.0
Josef-Haupt May 12, 2026
0d07b47
comply with PEP 639
Josef-Haupt May 12, 2026
b5f0514
fix potential racecondition when downloading models
Josef-Haupt May 12, 2026
d340093
Merge branch 'geomodel-3.0' into birdnet-3
Josef-Haupt May 12, 2026
4757d95
tests
Josef-Haupt May 12, 2026
edeb7c3
update closeness test values + streaminlined taxonomy
Josef-Haupt May 14, 2026
e21603f
streamline taxonomy
Josef-Haupt May 14, 2026
589b2ab
updated actions in github workflows
Josef-Haupt May 14, 2026
663f49a
copilot suggestion
Josef-Haupt Jun 15, 2026
029a937
copilot suggestions 2
Josef-Haupt Jun 15, 2026
245f2cb
Potential fix for pull request finding
Josef-Haupt Jun 15, 2026
d4776be
Merge pull request #47 from birdnet-team/birdnet-3
Josef-Haupt Jun 15, 2026
87f654f
f string
Josef-Haupt Jun 15, 2026
02a3fac
annotations
Josef-Haupt Jun 15, 2026
96b3b1c
more verbose error msg + updated onnx filesize
Josef-Haupt Jun 15, 2026
39cbc19
preview 3 models initial
Josef-Haupt Jun 22, 2026
c030f90
updated pt indices
Josef-Haupt Jun 24, 2026
10728c8
more verbose error messages
Josef-Haupt Jun 24, 2026
0c33dee
more tests + small fixes
Josef-Haupt Jun 26, 2026
ac3c977
fix download check ordering
Josef-Haupt Jul 1, 2026
3636ef9
potential fix for kagglehub exception
Josef-Haupt Jul 1, 2026
7aa4012
remove kagglehub dependency
Josef-Haupt Jul 1, 2026
9c4d366
Merge remote-tracking branch 'origin/main' into geomodel-3.0
Josef-Haupt Jul 3, 2026
a7fcdf3
copilot suggestions
Josef-Haupt Jul 3, 2026
a066421
restore
Josef-Haupt Jul 3, 2026
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,15 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: birdnet-team/birdnet

- name: Extract log path
run: |
LOG_PATH=$(grep -o '/var/folders[^ ]*\.log' output.log | head -n1)
echo "LOG_PATH=$LOG_PATH" >> $GITHUB_ENV

- name: Upload log file
if: failure() && env.LOG_PATH != ''
uses: actions/upload-artifact@v4
with:
name: birdnet-log
path: ${{ env.LOG_PATH }}
37 changes: 37 additions & 0 deletions docs/birdnet.geo_models.v3_0.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
birdnet.geo\_models.v3\_0 package
=================================

Submodules
----------

birdnet.geo\_models.v3\_0.model module
--------------------------------------

.. automodule:: birdnet.geo_models.v3_0.model
Comment thread
Josef-Haupt marked this conversation as resolved.
Outdated
:members:
:show-inheritance:
:undoc-members:

birdnet.geo\_models.v3\_0.tf module
------------------------------------

.. automodule:: birdnet.geo_models.v3_0.tf
:members:
:show-inheritance:
:undoc-members:

birdnet.geo\_models.v3\_0.pb module
------------------------------------

.. automodule:: birdnet.geo_models.v3_0.pb
:members:
:show-inheritance:
:undoc-members:

Module contents
---------------

.. automodule:: birdnet.geo_models.v3_0
:members:
:show-inheritance:
:undoc-members:
62 changes: 47 additions & 15 deletions src/birdnet/core/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,21 @@ def prediction_out_idx(cls) -> int: ...
@abstractmethod
def encoding_out_idx(cls) -> int | None: ...

@classmethod
def requires_flex_delegate(cls) -> bool:
return False

def load(self) -> None:
assert self._interp is None
if self.requires_flex_delegate():
# Flex ops (Select TF ops) need the full TF op registry to be populated
# before the interpreter tries to prepare flex kernels.
import_tf()
self._interp = load_tf_model(
self._model_path, self._inference_library, allocate_tensors=True
self._model_path,
self._inference_library,
allocate_tensors=True,
use_flex_delegate=self.requires_flex_delegate(),
)

def unload(self) -> None:
Expand Down Expand Up @@ -709,31 +720,39 @@ def load_tf_model(
model_path: Path,
library: Literal["tflite"],
allocate_tensors: bool = False,
use_flex_delegate: bool = False,
) -> TFInterpreter: ...
@overload
def load_tf_model(
model_path: Path,
library: Literal["litert"],
allocate_tensors: bool = False,
use_flex_delegate: bool = False,
) -> LiteRTInterpreter: ...


def load_tf_model(
model_path: Path,
library: LIBRARY_TYPES,
allocate_tensors: bool = False,
use_flex_delegate: bool = False,
):
if library == LIBRARY_TFLITE:
return load_lib_tf_model(model_path, allocate_tensors=allocate_tensors)
return load_lib_tf_model(
model_path, allocate_tensors=allocate_tensors, use_flex_delegate=use_flex_delegate
)
elif library == LIBRARY_LITERT:
return load_lib_litert_model(model_path, allocate_tensors=allocate_tensors)
return load_lib_litert_model(
model_path, allocate_tensors=allocate_tensors, use_flex_delegate=use_flex_delegate
)
else:
raise AssertionError()


def load_lib_tf_model(
model_path: Path,
allocate_tensors: bool = False,
use_flex_delegate: bool = False,
) -> TFInterpreter:
assert model_path.is_file()
assert tf_installed()
Expand Down Expand Up @@ -781,12 +800,18 @@ def load_lib_tf_model(
module="tensorflow.lite.python.interpreter",
)
try:
interp = tflite.Interpreter(
str(model_path.absolute()),
num_threads=1,
experimental_op_resolver_type=tflite.OpResolverType.BUILTIN_WITHOUT_DEFAULT_DELEGATES,
# tensor#187 is a dynamic-sized tensor # type: ignore
)
if use_flex_delegate:
interp = tflite.Interpreter(
str(model_path.absolute()),
num_threads=1,
)
else:
interp = tflite.Interpreter(
str(model_path.absolute()),
num_threads=1,
experimental_op_resolver_type=tflite.OpResolverType.BUILTIN_WITHOUT_DEFAULT_DELEGATES,
# tensor#187 is a dynamic-sized tensor # type: ignore
)
except ValueError as e:
raise ValueError(
f"Failed to load model '{model_path.absolute()}' using 'tensorflow'. "
Expand Down Expand Up @@ -816,6 +841,7 @@ def load_lib_tf_model(
def load_lib_litert_model(
model_path: Path,
allocate_tensors: bool = False,
use_flex_delegate: bool = False,
) -> LiteRTInterpreter:
assert model_path.is_file()
assert litert_installed()
Expand All @@ -824,12 +850,18 @@ def load_lib_litert_model(

start = time.perf_counter()
try:
interp = tflite.Interpreter(
str(model_path.absolute()),
num_threads=1,
experimental_op_resolver_type=tflite.OpResolverType.BUILTIN_WITHOUT_DEFAULT_DELEGATES,
# tensor#187 is a dynamic-sized tensor # type: ignore
)
if use_flex_delegate:
interp = tflite.Interpreter(
str(model_path.absolute()),
num_threads=1,
)
else:
interp = tflite.Interpreter(
str(model_path.absolute()),
num_threads=1,
experimental_op_resolver_type=tflite.OpResolverType.BUILTIN_WITHOUT_DEFAULT_DELEGATES,
# tensor#187 is a dynamic-sized tensor # type: ignore
)
except ValueError as e:
raise ValueError(
f"Failed to load model '{model_path.absolute()}' using 'ai_edge_litert'. "
Expand Down
29 changes: 23 additions & 6 deletions src/birdnet/geo/inference/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from ordered_set import OrderedSet

from birdnet.core.backends import VersionedBackendProtocol
from birdnet.globals import GEO_MODEL_VERSIONS
from birdnet.globals import (
GEO_MODEL_VERSIONS,
GEO_YEAR_ROUND_AGGREGATIONS,
VALID_GEO_YEAR_ROUND_AGGREGATIONS,
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -63,7 +67,8 @@ def validate_min_confidence(cls, min_confidence: Any) -> float: # noqa: ANN401
class RunConfig:
latitude: float
longitude: float
week: int
week: int | None
year_round_aggregation: GEO_YEAR_ROUND_AGGREGATIONS

@classmethod
def validate_latitude(cls, latitude: Any) -> float: # noqa: ANN401
Expand All @@ -86,20 +91,32 @@ def validate_longitude(cls, longitude: Any) -> float: # noqa: ANN401
return float(longitude)

@classmethod
def validate_week(cls, week: Any) -> int: # noqa: ANN401
def validate_week(cls, week: Any) -> int | None: # noqa: ANN401
if week is None:
return -1
return None

if not isinstance(week, int):
raise TypeError("Value for 'week' is invalid! It must be an integer.")

if week is not None and not (1 <= week <= 48):
if not (1 <= week <= 48):
raise ValueError(
"Value for 'week' is invalid! It needs to be either None or in interval [1, 48]."
"Value for 'week' is invalid! It needs to be either None or in"
" interval [1, 48]."
)

return week

@classmethod
def validate_year_round_aggregation(
cls, year_round_aggregation: Any # noqa: ANN401
) -> GEO_YEAR_ROUND_AGGREGATIONS:
if year_round_aggregation not in VALID_GEO_YEAR_ROUND_AGGREGATIONS:
raise ValueError(
"Value for 'year_round_aggregation' is invalid!"
" It must be either 'max' or 'average'."
)
return year_round_aggregation


@dataclass(frozen=True)
class InferenceConfig:
Expand Down
2 changes: 1 addition & 1 deletion src/birdnet/geo/inference/prediction_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def to_structured_array(

n_predictions = len(unmasked_species)

max_len = max(map(len, unmasked_species))
max_len = max(map(len, unmasked_species), default=1)
dtype = [
(VAR_SPECIES_NAME, f"<U{max_len}"),
(VAR_CONFIDENCE, self._species_probs.dtype),
Expand Down
63 changes: 50 additions & 13 deletions src/birdnet/geo/inference/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@
RunConfig,
)
from birdnet.geo.inference.prediction_result import GeoPredictionResult
from birdnet.globals import GEO_MODEL_VERSIONS
from birdnet.globals import (
GEO_MODEL_VERSION_V2_4,
GEO_MODEL_VERSIONS,
GEO_YEAR_ROUND_AGGREGATION_MAX,
GEO_YEAR_ROUND_AGGREGATIONS,
)
from birdnet.utils.helper import get_uint_dtype


Expand Down Expand Up @@ -56,17 +61,44 @@ def _run(self, run_config: RunConfig) -> GeoPredictionResult:
assert self._is_initialized
assert self._backend is not None

sample = np.expand_dims(
np.array(
[run_config.latitude, run_config.longitude, run_config.week],
dtype=np.float32,
),
0,
)

res = self._backend.predict(sample)

res = np.squeeze(res, axis=0)
if run_config.week is None:
if self._conf.model_conf.version == GEO_MODEL_VERSION_V2_4:
Comment thread
Josef-Haupt marked this conversation as resolved.
Outdated
# v2.4 was trained with -1 as the year-round sentinel
sample = np.expand_dims(
np.array(
[run_config.latitude, run_config.longitude, -1.0],
dtype=np.float32,
),
0,
)
res = self._backend.predict(sample)
res = np.squeeze(res, axis=0)
else:
# v3.0+: feed all 48 weeks as a batch and aggregate
samples = np.array(
[
[run_config.latitude, run_config.longitude, float(w)]
for w in range(1, 49)
],
dtype=np.float32,
) # shape: (48, 3)
res_batch = self._backend.predict(samples) # shape: (48, n_species)
if run_config.year_round_aggregation == GEO_YEAR_ROUND_AGGREGATION_MAX:
res = np.max(res_batch, axis=0)
else:
res = np.mean(res_batch, axis=0)
result_week = -1
Comment thread
Josef-Haupt marked this conversation as resolved.
else:
sample = np.expand_dims(
np.array(
[run_config.latitude, run_config.longitude, run_config.week],
dtype=np.float32,
),
0,
)
res = self._backend.predict(sample)
res = np.squeeze(res, axis=0)
result_week = run_config.week

n_species = self._conf.model_conf.n_species
species_ids = np.arange(
Expand All @@ -83,7 +115,7 @@ def _run(self, run_config: RunConfig) -> GeoPredictionResult:
model_precision=self._backend.precision(),
latitude=run_config.latitude,
longitude=run_config.longitude,
week=run_config.week,
week=result_week,
species_list=self._conf.model_conf.species_list,
species_probs=res,
species_ids=species_ids,
Expand Down Expand Up @@ -150,15 +182,20 @@ def run(
/,
*,
week: int | None = None,
year_round_aggregation: GEO_YEAR_ROUND_AGGREGATIONS = GEO_YEAR_ROUND_AGGREGATION_MAX, # noqa: E501
) -> GeoPredictionResult:
latitude = RunConfig.validate_latitude(latitude)
longitude = RunConfig.validate_longitude(longitude)
week = RunConfig.validate_week(week)
year_round_aggregation = RunConfig.validate_year_round_aggregation(
year_round_aggregation
)

return self._run(
RunConfig(
latitude=latitude,
longitude=longitude,
week=week,
year_round_aggregation=year_round_aggregation,
),
)
4 changes: 4 additions & 0 deletions src/birdnet/geo/models/v2_4/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from birdnet.globals import (
GEO_MODEL_VERSION_V2_4,
GEO_MODEL_VERSIONS,
GEO_YEAR_ROUND_AGGREGATION_MAX,
GEO_YEAR_ROUND_AGGREGATIONS,
MODEL_TYPE_GEO,
MODEL_TYPES,
)
Expand Down Expand Up @@ -157,6 +159,7 @@ def predict(
/,
*,
week: int | None = None,
year_round_aggregation: GEO_YEAR_ROUND_AGGREGATIONS = GEO_YEAR_ROUND_AGGREGATION_MAX, # noqa: E501
min_confidence: float = 0.03,
half_precision: bool = False,
device: str = "CPU",
Expand All @@ -170,4 +173,5 @@ def predict(
latitude,
longitude,
week=week,
year_round_aggregation=year_round_aggregation,
)
2 changes: 1 addition & 1 deletion src/birdnet/geo/models/v2_4/pb.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ def _check_geo_model_available(cls) -> bool:
model_is_downloaded = True
model_is_downloaded &= model_path.is_dir()
model_is_downloaded &= check_protobuf_model_files_exist(model_path)

model_is_downloaded &= lang_dir.is_dir()

for lang in cls.AVAILABLE_LANGUAGES:
model_is_downloaded &= (lang_dir / f"{lang}.txt").is_file()

Expand Down
1 change: 1 addition & 0 deletions src/birdnet/geo/models/v3_0/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#
Loading
Loading