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) + + + + 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..680bace --- /dev/null +++ b/data_exporters/label_studio_exporter/expand_birdnet_detections.py @@ -0,0 +1,51 @@ +"""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_dict +import pandas as pd +import pickle + + +if __name__ == "__main__": + PARSER = argparse.ArgumentParser( + description='Input config path' + ) + PARSER.add_argument('-results', type=str, + help='Path to Birdnet concatenated results file.') + PARSER.add_argument('-output', type=str, + help='Path to output dir for metadata and segments.') + 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 = { + "audio": [], + "labels": [], + } + + metadata = [] + + # creates shortened segments that combine overlaps into 1, provides results in dataframe + for file_path, detections in results.groupby("File"): + 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"]) + + metadata.extend(metadata_list) + + dataframe = pd.DataFrame(metadata) + + with open(f"{out_dir}/output.pkl", "wb") as file: + pickle.dump(all_data, file) + + dataframe.to_csv(f"{out_dir}/metadata.csv", index=False) 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 @@ + + 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/__init__.py b/whoot/__init__.py index d815bc3..a3e381a 100644 --- a/whoot/__init__.py +++ b/whoot/__init__.py @@ -1,3 +1,3 @@ __version__ = "0.1.1.dev0" -from .audio_utils import expand_window +from .audio_utils import expand_window, check_overlap_dict diff --git a/whoot/audio_utils.py b/whoot/audio_utils.py index 3c7f365..d575b39 100644 --- a/whoot/audio_utils.py +++ b/whoot/audio_utils.py @@ -3,6 +3,8 @@ """ 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): @@ -64,3 +66,98 @@ 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_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. + + 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) + + detection = { + "start": start, + "end": end, + "label": row["Common name"], + } + + + 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": start, + "end": end, + "detections": [detection], + }) + + metadata_dict = { + "audio": [], + "labels": [], + } + + dataframe_list = [] + + for i, group in enumerate(groups): + group_start = group["start"] + group_end = group["end"] + length = (group_end - group_start) + 7000 + + clip, group_offset = expand_window( + audio, + group_start, + group_end, + length, + randomize=False, + ) + + segment_name = f"{Path(file_path).stem}_{i}.wav" + output_path = output_dir / segment_name + clip.export(output_path, format="wav") + dataframe_dict = { + "ls_filename": str(segment_name), + "original_file_path": str(file_path), + "offset": group_start, + "duration": length + } + dataframe_list.append(dataframe_dict) + + for detection in group["detections"]: + detection_offset = ( + group_offset + + detection["start"] + - group_start + ) + + metadata_dict["audio"].append({ + "bytes": None, + "path": str(output_path), + "offset": detection_offset / 1000, + "duration": ( + detection["end"] - detection["start"] + ) / 1000, + }) + + metadata_dict["labels"].append(detection["label"]) + + return metadata_dict, dataframe_list