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
32 changes: 14 additions & 18 deletions birdnet_analyzer/analyze.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""Module to analyze audio samples.
"""
"""Module to analyze audio samples."""

import argparse
import datetime
Expand All @@ -25,7 +24,7 @@
)
CSV_HEADER = "Start (s),End (s),Scientific name,Common name,Confidence,File\n"
SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__))
ASCII_LOGO = r'''
ASCII_LOGO = r"""
.
.-=-
.:=++++.
Expand All @@ -51,7 +50,7 @@
**=====
***+==
****+
'''
"""


def loadCodes():
Expand Down Expand Up @@ -101,7 +100,7 @@ def generate_raven_table(timestamps: list[str], result: dict[str, list], afile_p
out_string += (
f"{selection_id}\tSpectrogram 1\t1\t0\t3\t{low_freq}\t{high_freq}\tnocall\tnocall\t1.0\t{afile_path}\t0\n"
)

utils.save_result_file(result_path, out_string)


Expand Down Expand Up @@ -255,12 +254,11 @@ def combine_raven_tables(saved_results: list[str]):
if not rfile:
continue
with open(rfile, "r", encoding="utf-8") as rf:

try:
lines = rf.readlines()

# make sure it's a selection table
if not "Selection" in lines[0] or not "File Offset" in lines[0]:
if "Selection" not in lines[0] or "File Offset" not in lines[0]:
continue

# skip header and add to file
Expand All @@ -270,7 +268,6 @@ def combine_raven_tables(saved_results: list[str]):
audiofiles.append(f_name)

for line in lines[1:]:

# empty line?
if not line.strip():
continue
Expand Down Expand Up @@ -312,12 +309,11 @@ def combine_rtable_files(saved_results: list[str]):

for rfile in saved_results:
with open(rfile, "r", encoding="utf-8") as rf:

try:
lines = rf.readlines()

# make sure it's a selection table
if not "filepath" in lines[0] or not "model" in lines[0]:
if "filepath" not in lines[0] or "model" not in lines[0]:
continue

# skip header and add to file
Expand All @@ -336,12 +332,11 @@ def combine_kaleidoscope_files(saved_results: list[str]):

for rfile in saved_results:
with open(rfile, "r", encoding="utf-8") as rf:

try:
lines = rf.readlines()

# make sure it's a selection table
if not "INDIR" in lines[0] or not "sensitivity" in lines[0]:
if "INDIR" not in lines[0] or "sensitivity" not in lines[0]:
continue

# skip header and add to file
Expand All @@ -360,12 +355,11 @@ def combine_csv_files(saved_results: list[str]):

for rfile in saved_results:
with open(rfile, "r", encoding="utf-8") as rf:

try:
lines = rf.readlines()

# make sure it's a selection table
if not "Start (s)" in lines[0] or not "Confidence" in lines[0]:
if "Start (s)" not in lines[0] or "Confidence" not in lines[0]:
continue

# skip header and add to file
Expand All @@ -378,7 +372,6 @@ def combine_csv_files(saved_results: list[str]):


def combineResults(saved_results: list[dict[str, str]]):

if "table" in cfg.RESULT_TYPES:
combine_raven_tables([f["table"] for f in saved_results if f])

Expand Down Expand Up @@ -445,7 +438,6 @@ def predict(samples):


def get_result_file_names(fpath: str):

result_names = {}

rpath = fpath.replace(cfg.INPUT_PATH, "")
Expand Down Expand Up @@ -588,7 +580,11 @@ def analyzeFile(item):
freeze_support()

# Parse arguments
parser = argparse.ArgumentParser(description=ASCII_LOGO, formatter_class=argparse.RawDescriptionHelpFormatter, usage="python -m birdnet_analyzer.analyze [options]")
parser = argparse.ArgumentParser(
description=ASCII_LOGO,
formatter_class=argparse.RawDescriptionHelpFormatter,
usage="python -m birdnet_analyzer.analyze [options]",
)
parser.add_argument("--i", default=os.path.join(SCRIPT_DIR, "example/"), help="Path to input file or folder.")
parser.add_argument("--o", default=os.path.join(SCRIPT_DIR, "example/"), help="Path to output folder.")
parser.add_argument("--lat", type=float, default=-1, help="Recording location latitude. Set -1 to ignore.")
Expand Down Expand Up @@ -720,7 +716,7 @@ def __call__(self, parser, args, values, option_string=None):
cfg.TRANSLATED_LABELS_PATH, os.path.basename(cfg.LABELS_FILE).replace(".txt", "_{}.txt".format(args.locale))
)

if not args.locale in ["en"] and os.path.isfile(lfile):
if args.locale not in ["en"] and os.path.isfile(lfile):
cfg.TRANSLATED_LABELS = utils.readLines(lfile)
else:
cfg.TRANSLATED_LABELS = cfg.LABELS
Expand Down
59 changes: 27 additions & 32 deletions birdnet_analyzer/audio.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Module containing audio helper functions.
"""
"""Module containing audio helper functions."""

import librosa
import numpy as np
import soundfile as sf
from scipy.signal import firwin, kaiserord, lfilter


import birdnet_analyzer.config as cfg

Expand All @@ -22,26 +26,23 @@ def openAudioFile(path: str, sample_rate=48000, offset=0.0, duration=None, fmin=
Returns the audio time series and the sampling rate.
"""
# Open file with librosa (uses ffmpeg or libav)
import librosa

sig, rate = librosa.load(path, sr=sample_rate, offset=offset, duration=duration, mono=True, res_type="kaiser_fast")

# Bandpass filter
if fmin != None and fmax != None:
if fmin is not None and fmax is not None:
sig = bandpass(sig, rate, fmin, fmax)
#sig = bandpassKaiserFIR(sig, rate, fmin, fmax)
# sig = bandpassKaiserFIR(sig, rate, fmin, fmax)

return sig, rate

def getAudioFileLength(path, sample_rate=48000):

def getAudioFileLength(path, sample_rate=48000):
# Open file with librosa (uses ffmpeg or libav)
import librosa

return librosa.get_duration(filename=path, sr=sample_rate)


def get_sample_rate(path: str):
import librosa
return librosa.get_samplerate(path)


Expand All @@ -52,7 +53,6 @@ def saveSignal(sig, fname: str):
sig: The signal to be saved.
fname: The file path.
"""
import soundfile as sf

sf.write(fname, sig, 48000, "PCM_16")

Expand Down Expand Up @@ -90,11 +90,11 @@ def pad(sig, seconds, srate, amount=None):
noise = np.zeros(noise_shape, dtype=sig.dtype)

return np.concatenate((sig, noise))

return sig


def splitSignal(sig, rate, seconds, overlap, minlen):
def splitSignal(sig, rate, seconds, overlap, minlen, amount=None):
"""Split signal with overlap.

Args:
Expand All @@ -103,7 +103,7 @@ def splitSignal(sig, rate, seconds, overlap, minlen):
seconds: The duration of a segment.
overlap: The overlapping seconds of segments.
minlen: Minimum length of a split.

Returns:
A list of splits.
"""
Expand All @@ -117,7 +117,7 @@ def splitSignal(sig, rate, seconds, overlap, minlen):
overlap = cfg.SIG_OVERLAP
if minlen is None or minlen <= 0 or minlen > seconds:
minlen = cfg.SIG_MINLEN

# Make sure overlap is smaller then signal duration
if overlap >= seconds:
overlap = seconds - 0.01
Expand Down Expand Up @@ -153,7 +153,7 @@ def splitSignal(sig, rate, seconds, overlap, minlen):
# Split signal with overlap
sig_splits = []
for i in range(0, 1 + lastchunkpos, stepsize):
sig_splits.append(data[i:i + chunksize])
sig_splits.append(data[i : i + chunksize])

return sig_splits

Expand All @@ -177,76 +177,71 @@ def cropCenter(sig, rate, seconds):

return sig

def bandpass(sig, rate, fmin, fmax, order=5):

def bandpass(sig, rate, fmin, fmax, order=5):
# Check if we have to bandpass at all
if fmin == cfg.SIG_FMIN and fmax == cfg.SIG_FMAX or fmin > fmax:
return sig

from scipy.signal import butter, lfilter

nyquist = 0.5 * rate

# Highpass?
if fmin > cfg.SIG_FMIN and fmax == cfg.SIG_FMAX:

if fmin > cfg.SIG_FMIN and fmax == cfg.SIG_FMAX:
low = fmin / nyquist
b, a = butter(order, low, btype="high")
sig = lfilter(b, a, sig)

# Lowpass?
elif fmin == cfg.SIG_FMIN and fmax < cfg.SIG_FMAX:

high = fmax / nyquist
b, a = butter(order, high, btype="low")
sig = lfilter(b, a, sig)

# Bandpass?
elif fmin > cfg.SIG_FMIN and fmax < cfg.SIG_FMAX:

low = fmin / nyquist
high = fmax / nyquist
b, a = butter(order, [low, high], btype="band")
sig = lfilter(b, a, sig)

return sig.astype("float32")


# Raven is using Kaiser window FIR filter, so we try to emulate it.
# Raven uses the Window method for FIR filter design.
# Raven uses the Window method for FIR filter design.
# A Kaiser window is used with a default transition bandwidth of 0.02 times
# the Nyquist frequency and a default stop band attenuation of 100 dB.
# For a complete description of this method, see Discrete-Time Signal Processing
# the Nyquist frequency and a default stop band attenuation of 100 dB.
# For a complete description of this method, see Discrete-Time Signal Processing
# (Second Edition), by Alan Oppenheim, Ronald Schafer, and John Buck, Prentice Hall 1998, pp. 474-476.
def bandpassKaiserFIR(sig, rate, fmin, fmax, width=0.02, stopband_attenuation_db=100):

# Check if we have to bandpass at all
if fmin == cfg.SIG_FMIN and fmax == cfg.SIG_FMAX or fmin > fmax:
return sig

from scipy.signal import kaiserord, firwin, lfilter
nyquist = 0.5 * rate

# Calculate the order and Kaiser parameter for the desired specifications.
N, beta = kaiserord(stopband_attenuation_db, width)

# Highpass?
if fmin > cfg.SIG_FMIN and fmax == cfg.SIG_FMAX:
if fmin > cfg.SIG_FMIN and fmax == cfg.SIG_FMAX:
low = fmin / nyquist
taps = firwin(N, low, window=('kaiser', beta), pass_zero=False)
taps = firwin(N, low, window=("kaiser", beta), pass_zero=False)

# Lowpass?
elif fmin == cfg.SIG_FMIN and fmax < cfg.SIG_FMAX:
high = fmax / nyquist
taps = firwin(N, high, window=('kaiser', beta), pass_zero=True)
taps = firwin(N, high, window=("kaiser", beta), pass_zero=True)

# Bandpass?
elif fmin > cfg.SIG_FMIN and fmax < cfg.SIG_FMAX:
low = fmin / nyquist
high = fmax / nyquist
taps = firwin(N, [low, high], window=('kaiser', beta), pass_zero=False)
taps = firwin(N, [low, high], window=("kaiser", beta), pass_zero=False)

# Apply the filter to the signal.
sig = lfilter(taps, 1.0, sig)

return sig.astype("float32")


13 changes: 9 additions & 4 deletions birdnet_analyzer/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Client to send requests to the server.
"""
"""Client to send requests to the server."""

import argparse
import json
import os
Expand Down Expand Up @@ -76,15 +76,20 @@ def saveResult(data, fpath):
help="Week of the year when the recording was made. Values in [1, 48] (4 weeks per month). Set -1 for year-round species list.",
)
parser.add_argument(
"--overlap", type=float, default=0.0, help="Overlap of prediction segments. Values in [0.0, 2.9]. Defaults to 0.0."
"--overlap",
type=float,
default=0.0,
help="Overlap of prediction segments. Values in [0.0, 2.9]. Defaults to 0.0.",
)
parser.add_argument(
"--sensitivity",
type=float,
default=1.0,
help="Detection sensitivity; Higher values result in higher sensitivity. Values in [0.5, 1.5]. Defaults to 1.0.",
)
parser.add_argument("--pmode", default="avg", help="Score pooling mode. Values in ['avg', 'max']. Defaults to 'avg'.")
parser.add_argument(
"--pmode", default="avg", help="Score pooling mode. Values in ['avg', 'max']. Defaults to 'avg'."
)
parser.add_argument("--num_results", type=int, default=5, help="Number of results per request. Defaults to 5.")
parser.add_argument(
"--sf_thresh",
Expand Down
1 change: 0 additions & 1 deletion birdnet_analyzer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,6 @@ def getConfig():


def setConfig(c):

global RANDOM_SEED
global MODEL_VERSION
global PB_MODEL
Expand Down
13 changes: 8 additions & 5 deletions birdnet_analyzer/embeddings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""Module used to extract embeddings for samples.
"""
"""Module used to extract embeddings for samples."""

import argparse
import datetime
Expand Down Expand Up @@ -107,7 +106,7 @@ def analyzeFile(item):
# Save as embeddings file
try:
# We have to check if output path is a file or directory
if not cfg.OUTPUT_PATH.rsplit(".", 1)[-1].lower() in ["txt", "csv"]:
if cfg.OUTPUT_PATH.rsplit(".", 1)[-1].lower() not in ["txt", "csv"]:
fpath = fpath.replace(cfg.INPUT_PATH, "")
fpath = fpath[1:] if fpath[0] in ["/", "\\"] else fpath

Expand Down Expand Up @@ -136,10 +135,14 @@ def analyzeFile(item):
# Parse arguments
parser = argparse.ArgumentParser(description="Extract feature embeddings with BirdNET")
parser.add_argument(
"--i", default=os.path.join(SCRIPT_DIR, "example/"), help="Path to input file or folder. If this is a file, --o needs to be a file too."
"--i",
default=os.path.join(SCRIPT_DIR, "example/"),
help="Path to input file or folder. If this is a file, --o needs to be a file too.",
)
parser.add_argument(
"--o", default=os.path.join(SCRIPT_DIR, "example/"), help="Path to output file or folder. If this is a file, --i needs to be a file too."
"--o",
default=os.path.join(SCRIPT_DIR, "example/"),
help="Path to output file or folder. If this is a file, --i needs to be a file too.",
)
parser.add_argument(
"--overlap",
Expand Down
Loading