From b280e4771399681ca5a14a0f07952311895f5569 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Thu, 16 Jul 2026 15:47:02 -0700 Subject: [PATCH 01/11] Add beginnings of readme instructions to follow as dev guide. --- data_exporters/label_studio_exporter/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/data_exporters/label_studio_exporter/README.md b/data_exporters/label_studio_exporter/README.md index c67ee6b..37d7f77 100644 --- a/data_exporters/label_studio_exporter/README.md +++ b/data_exporters/label_studio_exporter/README.md @@ -29,3 +29,19 @@ NOTE: Save the project_id from the URL of the project If keeping the data local on the instance, try to keep the file structure the same as is the audio file from your ML machine. For example, if some dataset is located at `mnt/datasets/audio_dataset_cool/AB/1/audio.wav` then you may want to make the path on label studio something like `label_studio_path/audio_dataset_cool/AB/1/audio.wav` for the easiest intergrations. Otherwise some minor file changes will be needed. 3) Run the script to apply annotations, see demo.py in this folder + + +# Importing BirdNET annotations into Label Studio + +*Assumes BirdNET analyzer has been run over data, and there exists an input directory of wavs and an output file with concatinated results. + +create python env +pip install whoot +whoot labelstudio_import +nano yaml +edit yaml with labelstudio project data, links, paths to the audio/birdnet labels +whoot run_import (it will automatically use the yaml) + + + + From b9750637188dd0d3c435353a7a596490d38b33e4 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Wed, 29 Jul 2026 14:05:09 -0700 Subject: [PATCH 02/11] add bones for creating clips from birdnet predictions --- .../expand_birdnet_detections.py | 45 ++++++++++++++++ whoot/__init__.py | 3 +- whoot/audio_utils.py | 54 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 data_exporters/label_studio_exporter/expand_birdnet_detections.py diff --git a/data_exporters/label_studio_exporter/expand_birdnet_detections.py b/data_exporters/label_studio_exporter/expand_birdnet_detections.py new file mode 100644 index 0000000..b2a092c --- /dev/null +++ b/data_exporters/label_studio_exporter/expand_birdnet_detections.py @@ -0,0 +1,45 @@ +"""Expand birdnet detections into longer clips for validation. + +This script takes in t combined table of birdnet detections for a given +group of wav files. It checks for overlap, and then creates an expanded clip +in order to provide acoustic context while validating the birdnet detections. +It also outputs a pkl file containing a dictionary of audio info including +the offset and duration of the label, as well as the label for each clip. + +""" +import argparse +from whoot import check_overlap +import pandas as pd + + +if __name__ == "__main__": + PARSER = argparse.ArgumentParser( + description='Input config path' + ) + PARSER.add_argument('-results', type=str, + help='Path to config file.') + PARSER.add_argument('-output', type=str, + help='Path to desired output.') + ARGS = PARSER.parse_args() + results = pd.read_csv(ARGS.results) + out_dir = ARGS.output + + results['File'] = results['File'].str.replace('Volumes/BUOW', 'mnt/restorage') + + all_data = pd.DataFrame(columns=['path', + 'offset', + 'duration', + 'label']) + # creates shortened segments that combine overlaps into 1, provides results in dataframe + for file_path, detections in results.groupby("File"): + print(file_path) + print(detections) + check_overlap(file_path, detections, out_dir) + #all_data = pd.concat([clip_df, all_data], ignore_index=True) + + #print(all_data) + + # turn all_data into a dictionary with audio, labels, accounting + # store the dictionary as a pkl + + diff --git a/whoot/__init__.py b/whoot/__init__.py index d815bc3..21f6c25 100644 --- a/whoot/__init__.py +++ b/whoot/__init__.py @@ -1,3 +1,4 @@ __version__ = "0.1.1.dev0" -from .audio_utils import expand_window +from .audio_utils import expand_window, check_overlap +from .label_studio import LabelStudioSetup diff --git a/whoot/audio_utils.py b/whoot/audio_utils.py index 3c7f365..0bdff27 100644 --- a/whoot/audio_utils.py +++ b/whoot/audio_utils.py @@ -3,6 +3,7 @@ """ import random from pydub import AudioSegment +from pathlib import Path def expand_window(audio, start_time, end_time, length=3000, randomize=False): @@ -64,3 +65,56 @@ def expand_window(audio, start_time, end_time, length=3000, randomize=False): start_offset = start_time - new_start return audio[new_start:clip_length], start_offset return audio[int(expanded_start):int(expanded_end)], half_diff + + +def check_overlap(file_path, detections, output_dir): + """Check for overlap with other detections before expanding window + and create a dictionary with the audio, the new path, the duration and + offset of the detection within the newly expanded window. + + Args: + file_path: + detections: + output_dir: + + Returns: + dict: A dictionary containing the clip path, offset/duration and label. + """ + + audio = AudioSegment.from_wav(file_path) + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + detections = detections.sort_values("Start (s)") + + groups = [] + + for _, row in detections.iterrows(): + start = int(row["Start (s)"] * 1000) + end = int(row["End (s)"] * 1000) + + expanded_start = start - 3500 + expanded_end = end + 3500 + + if groups and expanded_start <= groups[-1][1] + 3500: + groups[-1][1] = max(groups[-1][1], end) + else: + groups.append([start, end]) + + for i, (start, end) in enumerate(groups): + length = (end - start) + 7000 + + clip, _ = expand_window( + audio, + start, + end, + length, + randomize=False, + ) + + clip.export( + output_dir / f"{Path(file_path).stem}_{i}.wav", + format="wav", + ) + + #return dict to add to the main dict From fe94c305310843c76596a6e5beca82947d42db57 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Tue, 4 Aug 2026 10:26:28 -0700 Subject: [PATCH 03/11] Creates the segments from birdnet results and metadata Specific to the example run. Pkl is no longer needed, can be csv. And the replacement of the filepath is specific but should be a feature because it's a common issue --- .../expand_birdnet_detections.py | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/data_exporters/label_studio_exporter/expand_birdnet_detections.py b/data_exporters/label_studio_exporter/expand_birdnet_detections.py index b2a092c..108fc5a 100644 --- a/data_exporters/label_studio_exporter/expand_birdnet_detections.py +++ b/data_exporters/label_studio_exporter/expand_birdnet_detections.py @@ -10,6 +10,7 @@ import argparse from whoot import check_overlap import pandas as pd +import pickle if __name__ == "__main__": @@ -26,20 +27,16 @@ results['File'] = results['File'].str.replace('Volumes/BUOW', 'mnt/restorage') - all_data = pd.DataFrame(columns=['path', - 'offset', - 'duration', - 'label']) + all_data = { + "audio": [], + "labels": [], + } # creates shortened segments that combine overlaps into 1, provides results in dataframe for file_path, detections in results.groupby("File"): - print(file_path) - print(detections) - check_overlap(file_path, detections, out_dir) - #all_data = pd.concat([clip_df, all_data], ignore_index=True) + metadata = check_overlap(file_path, detections, out_dir) - #print(all_data) - - # turn all_data into a dictionary with audio, labels, accounting - # store the dictionary as a pkl - + all_data["audio"].extend(metadata["audio"]) + all_data["labels"].extend(metadata["labels"]) + with open("all_data_jun2026.pkl", "wb") as file: + pickle.dump(all_data, file) From cacdec3ca4b044e29edb5409ca2b79f525185e64 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Tue, 4 Aug 2026 10:28:13 -0700 Subject: [PATCH 04/11] fix the check overlap function to store metadata --- pyproject.toml | 2 +- whoot/audio_utils.py | 63 +++++++++++++++++++++++++++++++++----------- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4bcc736..d415c3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ data-exporters = [ cu128 = "https://download.pytorch.org/whl/cu128" [tool.setuptools] -packages = ["make_model", "assess_birdnet", "whoot_model_training"] +packages = ["make_model", "assess_birdnet", "whoot_model_training", "whoot"] [tool.uv.sources] pyha-analyzer = { git = "https://github.com/UCSD-E4E/pyha-analyzer-2.0.git", branch = "support_whoot" } diff --git a/whoot/audio_utils.py b/whoot/audio_utils.py index 0bdff27..22b3ab4 100644 --- a/whoot/audio_utils.py +++ b/whoot/audio_utils.py @@ -93,28 +93,61 @@ def check_overlap(file_path, detections, output_dir): start = int(row["Start (s)"] * 1000) end = int(row["End (s)"] * 1000) - expanded_start = start - 3500 - expanded_end = end + 3500 + detection = { + "start": start, + "end": end, + "label": row["Common name"], + } - if groups and expanded_start <= groups[-1][1] + 3500: - groups[-1][1] = max(groups[-1][1], end) + + if groups and start - 3500 <= groups[-1]["end"] + 3500: + groups[-1]["end"] = max(groups[-1]["end"], end) + groups[-1]["detections"].append(detection) else: - groups.append([start, end]) + groups.append({ + "start": start, + "end": end, + "detections": [detection], + }) + + metadata = { + "audio": [], + "labels": [], + } + - for i, (start, end) in enumerate(groups): - length = (end - start) + 7000 + for i, group in enumerate(groups): + group_start = group["start"] + group_end = group["end"] + length = (group_end - group_start) + 7000 - clip, _ = expand_window( + clip, group_offset = expand_window( audio, - start, - end, + group_start, + group_end, length, randomize=False, ) - clip.export( - output_dir / f"{Path(file_path).stem}_{i}.wav", - format="wav", - ) + output_path = output_dir / f"{Path(file_path).stem}_{i}.wav" + clip.export(output_path, format="wav") + + for detection in group["detections"]: + detection_offset = ( + group_offset + + detection["start"] + - group_start + ) + + metadata["audio"].append({ + "bytes": None, + "path": str(output_path), + "offset": detection_offset / 1000, + "duration": ( + detection["end"] - detection["start"] + ) / 1000, + }) + + metadata["labels"].append(detection["label"]) - #return dict to add to the main dict + return metadata From a3171ae3af88ee5dea2dbd71a9ba323968f396e3 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Thu, 20 Aug 2026 10:22:35 -0700 Subject: [PATCH 05/11] Add template for validating the birdnet samples --- labelstudio/README.md | 10 ++++++++++ labelstudio/buow_acoustic_labeling.xml | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 labelstudio/buow_acoustic_labeling.xml diff --git a/labelstudio/README.md b/labelstudio/README.md index f4bbe4e..240f433 100644 --- a/labelstudio/README.md +++ b/labelstudio/README.md @@ -11,3 +11,13 @@ are uploaded to labelstudio with the intention to validate these predictions. The labeler will mark whether a prediction is correct or incorrect, and if incorrect, will be shown options to select if it is a different vocalization type, something else, or they're not sure. + + +### buow_acoustic_labeling.xml + +This template is used to strongly label acoustic events of interest for +burrowing owl calls. It was created to validate birdnet detections, so +segments were thought to have a burrowing owl in them already. But this template +could also be used to label calls for the first time as well, without having been +preprocessed with birdnet. It automatically loads the spectrogram and waveform in +large sizes. diff --git a/labelstudio/buow_acoustic_labeling.xml b/labelstudio/buow_acoustic_labeling.xml new file mode 100644 index 0000000..d65e42c --- /dev/null +++ b/labelstudio/buow_acoustic_labeling.xml @@ -0,0 +1,16 @@ + + From 0184081254cd8fad1a60a9b664710e8a3efa990f Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Thu, 20 Aug 2026 10:23:52 -0700 Subject: [PATCH 06/11] Change function name to specify it outputs a dict I could see us making a similar function that doesn't output the dict so I felt it made sense to specify. The dict is needed to upload the predictions to labelstudio. However, you don't always do that. In that case, you might want some other metadata in some other form that isnt that specifically formatted dict. --- .../label_studio_exporter/expand_birdnet_detections.py | 6 +++--- whoot/__init__.py | 2 +- whoot/audio_utils.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/data_exporters/label_studio_exporter/expand_birdnet_detections.py b/data_exporters/label_studio_exporter/expand_birdnet_detections.py index 108fc5a..972c919 100644 --- a/data_exporters/label_studio_exporter/expand_birdnet_detections.py +++ b/data_exporters/label_studio_exporter/expand_birdnet_detections.py @@ -8,7 +8,7 @@ """ import argparse -from whoot import check_overlap +from whoot import check_overlap_dict import pandas as pd import pickle @@ -33,10 +33,10 @@ } # creates shortened segments that combine overlaps into 1, provides results in dataframe for file_path, detections in results.groupby("File"): - metadata = check_overlap(file_path, detections, out_dir) + metadata = check_overlap_dict(file_path, detections, out_dir) all_data["audio"].extend(metadata["audio"]) all_data["labels"].extend(metadata["labels"]) - with open("all_data_jun2026.pkl", "wb") as file: + with open("output.pkl", "wb") as file: pickle.dump(all_data, file) diff --git a/whoot/__init__.py b/whoot/__init__.py index 21f6c25..c1314af 100644 --- a/whoot/__init__.py +++ b/whoot/__init__.py @@ -1,4 +1,4 @@ __version__ = "0.1.1.dev0" -from .audio_utils import expand_window, check_overlap +from .audio_utils import expand_window, check_overlap_dict from .label_studio import LabelStudioSetup diff --git a/whoot/audio_utils.py b/whoot/audio_utils.py index 22b3ab4..6f03114 100644 --- a/whoot/audio_utils.py +++ b/whoot/audio_utils.py @@ -67,7 +67,7 @@ def expand_window(audio, start_time, end_time, length=3000, randomize=False): return audio[int(expanded_start):int(expanded_end)], half_diff -def check_overlap(file_path, detections, output_dir): +def check_overlap_dict(file_path, detections, output_dir): """Check for overlap with other detections before expanding window and create a dictionary with the audio, the new path, the duration and offset of the detection within the newly expanded window. From 2cfe059366802fbddab160ed04ca6d7ced5e9306 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Thu, 20 Aug 2026 12:09:39 -0700 Subject: [PATCH 07/11] Add metadata file to capture original file and offset from original Later when we want to generate samples from the labelstudio labels, we want to be able to trace the segment back to the original clip, and the original offset and duration of the clip from the original file. this way when we generate clips to use for training we have the additional context of the original file in case we need it when window expanding --- .../expand_birdnet_detections.py | 19 ++++++++++++++----- whoot/audio_utils.py | 17 +++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/data_exporters/label_studio_exporter/expand_birdnet_detections.py b/data_exporters/label_studio_exporter/expand_birdnet_detections.py index 972c919..65e1d58 100644 --- a/data_exporters/label_studio_exporter/expand_birdnet_detections.py +++ b/data_exporters/label_studio_exporter/expand_birdnet_detections.py @@ -18,9 +18,9 @@ description='Input config path' ) PARSER.add_argument('-results', type=str, - help='Path to config file.') + help='Path to Birdnet concatenated results file.') PARSER.add_argument('-output', type=str, - help='Path to desired output.') + help='Path to output dir for metadata and segments.') ARGS = PARSER.parse_args() results = pd.read_csv(ARGS.results) out_dir = ARGS.output @@ -31,12 +31,21 @@ "audio": [], "labels": [], } + + metadata = [] + # creates shortened segments that combine overlaps into 1, provides results in dataframe for file_path, detections in results.groupby("File"): - metadata = check_overlap_dict(file_path, detections, out_dir) + metadata_dict, metadata_list = check_overlap_dict(file_path, detections, out_dir) + + all_data["audio"].extend(metadata_dict["audio"]) + all_data["labels"].extend(metadata_dict["labels"]) - all_data["audio"].extend(metadata["audio"]) - all_data["labels"].extend(metadata["labels"]) + metadata.extend(metadata_list) + + dataframe = pd.DataFrame(metadata) with open("output.pkl", "wb") as file: pickle.dump(all_data, file) + + dataframe.to_csv("metadata.csv", index=False) diff --git a/whoot/audio_utils.py b/whoot/audio_utils.py index 6f03114..5eb9e27 100644 --- a/whoot/audio_utils.py +++ b/whoot/audio_utils.py @@ -4,6 +4,7 @@ import random from pydub import AudioSegment from pathlib import Path +import pandas as pd def expand_window(audio, start_time, end_time, length=3000, randomize=False): @@ -110,11 +111,12 @@ def check_overlap_dict(file_path, detections, output_dir): "detections": [detection], }) - metadata = { + metadata_dict = { "audio": [], "labels": [], } + dataframe_list = [] for i, group in enumerate(groups): group_start = group["start"] @@ -131,6 +133,13 @@ def check_overlap_dict(file_path, detections, output_dir): output_path = output_dir / f"{Path(file_path).stem}_{i}.wav" clip.export(output_path, format="wav") + dataframe_dict = { + "birdnet_expanded_file": str(output_path), + "original_file_path": str(file_path), + "offset": group_start, + "duration": length + } + dataframe_list.append(dataframe_dict) for detection in group["detections"]: detection_offset = ( @@ -139,7 +148,7 @@ def check_overlap_dict(file_path, detections, output_dir): - group_start ) - metadata["audio"].append({ + metadata_dict["audio"].append({ "bytes": None, "path": str(output_path), "offset": detection_offset / 1000, @@ -148,6 +157,6 @@ def check_overlap_dict(file_path, detections, output_dir): ) / 1000, }) - metadata["labels"].append(detection["label"]) + metadata_dict["labels"].append(detection["label"]) - return metadata + return metadata_dict, dataframe_list From 6f1b2666dc051c61e47dcfa51a3a6d4d441324f9 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Thu, 20 Aug 2026 12:19:24 -0700 Subject: [PATCH 08/11] Remove base path from segment name Labelstudio will have a different basepath in the reslts data when we export the labels so we don't need the basepath for the segment when we create it locally. --- whoot/audio_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/whoot/audio_utils.py b/whoot/audio_utils.py index 5eb9e27..845dd00 100644 --- a/whoot/audio_utils.py +++ b/whoot/audio_utils.py @@ -131,10 +131,11 @@ def check_overlap_dict(file_path, detections, output_dir): randomize=False, ) - output_path = output_dir / f"{Path(file_path).stem}_{i}.wav" + segment_name = f"{Path(file_path).stem}_{i}.wav" + output_path = output_dir / segment_name clip.export(output_path, format="wav") dataframe_dict = { - "birdnet_expanded_file": str(output_path), + "birdnet_expanded_file": str(segment_name), "original_file_path": str(file_path), "offset": group_start, "duration": length From 4f28a2befbb8a82864e23dbfff02378c8b1213f8 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Thu, 20 Aug 2026 12:22:19 -0700 Subject: [PATCH 09/11] Remove labelstudio from import That can be something done later but not needed for this pr --- whoot/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/whoot/__init__.py b/whoot/__init__.py index c1314af..a3e381a 100644 --- a/whoot/__init__.py +++ b/whoot/__init__.py @@ -1,4 +1,3 @@ __version__ = "0.1.1.dev0" from .audio_utils import expand_window, check_overlap_dict -from .label_studio import LabelStudioSetup From a5ff756e8886c4fcc2885e6f7c3579a8f6052a3e Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Thu, 20 Aug 2026 13:29:49 -0700 Subject: [PATCH 10/11] Put the results file in the output directory --- .../label_studio_exporter/expand_birdnet_detections.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data_exporters/label_studio_exporter/expand_birdnet_detections.py b/data_exporters/label_studio_exporter/expand_birdnet_detections.py index 65e1d58..680bace 100644 --- a/data_exporters/label_studio_exporter/expand_birdnet_detections.py +++ b/data_exporters/label_studio_exporter/expand_birdnet_detections.py @@ -45,7 +45,7 @@ dataframe = pd.DataFrame(metadata) - with open("output.pkl", "wb") as file: + with open(f"{out_dir}/output.pkl", "wb") as file: pickle.dump(all_data, file) - dataframe.to_csv("metadata.csv", index=False) + dataframe.to_csv(f"{out_dir}/metadata.csv", index=False) From 9262f59f5992b5e23cda052242a95cab073dfd06 Mon Sep 17 00:00:00 2001 From: Katie Garwood Date: Tue, 25 Aug 2026 16:56:31 -0700 Subject: [PATCH 11/11] Change column name to match other branch. When using this metadata elsewhere, we need the column names to match I changed it downstream because the column name made more sense, but it needed to be updated here as well because they need to match --- whoot/audio_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whoot/audio_utils.py b/whoot/audio_utils.py index 845dd00..d575b39 100644 --- a/whoot/audio_utils.py +++ b/whoot/audio_utils.py @@ -135,7 +135,7 @@ def check_overlap_dict(file_path, detections, output_dir): output_path = output_dir / segment_name clip.export(output_path, format="wav") dataframe_dict = { - "birdnet_expanded_file": str(segment_name), + "ls_filename": str(segment_name), "original_file_path": str(file_path), "offset": group_start, "duration": length