Skip to content
Draft
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
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ matplotlib
pillow
pyarrow==20.0.0
soundfile
resampy
4 changes: 2 additions & 2 deletions scripts/advanced.php
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@
</td></tr><tr><td>
If different than 0 (keep all), defines the number of files to keep for each species, with priority given to files with higher confidence. This value does not include files from the last 7 days, these new files are protected against auto-deletion.
</td></tr><tr><td>
Note only the spectrogram and audio files are deleted, the obsevation data remains in the database.
Note only the spectrogram and audio files are deleted, the observation data remains in the database.
The files protected through the "lock" icon are also not affected.
<br>
<button type="submit" name="run_species_count" value="1" onclick="{this.innerHTML = 'Loading ... please wait.';this.classList.add('disabled')}"><i>[Click here for disk usage summary]</i></button>
Expand All @@ -345,7 +345,7 @@
Set Channels to the number of channels supported by your sound card. 32 max.<br><br>
<label for="recording_length">Recording Length: </label>
<input name="recording_length" oninput="document.getElementsByName('extraction_length')[0].setAttribute('max', this.value);" type="number" style="width:3em;" min="3" max="60" step="1" value="<?php print($newconfig['RECORDING_LENGTH']);?>" required/><br>
Set Recording Length in seconds between 6 and 60. Multiples of 3 are recommended, as BirdNET analyzes in 3-second chunks.<br><br>
Set Recording Length in seconds between 6 and 60. Multiples of 3 are recommended for BirdNET models, multiples of 5 for Perch.<br><br>
<label for="extraction_length">Extraction Length: </label>
<input name="extraction_length" oninput="this.setAttribute('max', document.getElementsByName('recording_length')[0].value);" type="number" style="width:3em;" min="3" value="<?php print($newconfig['EXTRACTION_LENGTH']);?>" /><br>
Set Extraction Length to something less than your Recording Length. Min=3 Max=Recording Length<br><br>
Expand Down
29 changes: 16 additions & 13 deletions scripts/birdnet_recording.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,32 @@ source /etc/birdnet/birdnet.conf

loop_ffmpeg(){
while true;do
if ! ffmpeg -hide_banner -xerror -loglevel $LOGGING_LEVEL -nostdin ${1} -i ${2} -vn -map a:0 -acodec pcm_s16le -ac 2 -ar 48000 -f segment -segment_format wav -segment_time ${RECORDING_LENGTH} -strftime 1 ${RECS_DIR}/StreamData/%F-birdnet-RTSP_${3}-%H:%M:%S.wav
if ! ffmpeg -hide_banner -xerror -loglevel $LOGGING_LEVEL -nostdin ${1} -i ${2} -vn -map a:0 -acodec pcm_s16le -ac 1 -ar ${SAMPLERATE} -f segment -segment_format wav -segment_time ${RECORDING_LENGTH} -strftime 1 ${RECS_DIR}/StreamData/%F-birdnet-RTSP_${3}-%H:%M:%S.wav
then
sleep 1
fi
done
}

# Read the logging level from the configuration option
LOGGING_LEVEL="${LogLevel_BirdnetRecordingService}"
# If empty for some reason default to log level of error
[ -z $LOGGING_LEVEL ] && LOGGING_LEVEL='error'
# Set logging level, default to 'error' if not set
LOGGING_LEVEL="${LogLevel_BirdnetRecordingService:-error}"

# Additionally if we're at debug or info level then allow printing of script commands and variables
if [ "$LOGGING_LEVEL" == "info" ] || [ "$LOGGING_LEVEL" == "debug" ];then
# Enable printing of commands/variables etc to terminal for debugging
set -x
fi

[ -z $RECORDING_LENGTH ] && RECORDING_LENGTH=15
REC_CARD="${REC_CARD:-default}"
RECORDING_LENGTH="${RECORDING_LENGTH:-15}"

# Set sample rate based on the model
if [[ "$MODEL" == "Perch_v2" ]]; then
SAMPLERATE=32000
else
SAMPLERATE=48000
fi

[ -d $RECS_DIR/StreamData ] || mkdir -p $RECS_DIR/StreamData

if [ -n "${RTSP_STREAM}" ];then
Expand Down Expand Up @@ -50,12 +58,7 @@ else
if pgrep arecord &> /dev/null ;then
echo "Recording"
else
if [ -z ${REC_CARD} ];then
arecord -f S16_LE -c${CHANNELS} -r48000 -t wav --max-file-time ${RECORDING_LENGTH}\
--use-strftime ${RECS_DIR}/StreamData/%F-birdnet-%H:%M:%S.wav
else
arecord -f S16_LE -c${CHANNELS} -r48000 -t wav --max-file-time ${RECORDING_LENGTH}\
-D "${REC_CARD}" --use-strftime ${RECS_DIR}/StreamData/%F-birdnet-%H:%M:%S.wav
fi
arecord -f S16_LE -c${CHANNELS} -r ${SAMPLERATE} -t wav --max-file-time ${RECORDING_LENGTH}\
-D "${REC_CARD}" --use-strftime ${RECS_DIR}/StreamData/%F-birdnet-%H:%M:%S.wav
fi
fi
17 changes: 13 additions & 4 deletions scripts/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ function() {
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('modelsel').addEventListener('change', function() {
if(this.value == "BirdNET_GLOBAL_6K_V2.4_Model_FP16"){
if(["BirdNET_GLOBAL_6K_V2.4_Model_FP16", "BirdNET-Go_classifier_20250916"].indexOf(this.value) > -1){
document.getElementById("soft").style.display="unset";
} else {
document.getElementById("soft").style.display="none";
Expand Down Expand Up @@ -267,7 +267,7 @@ function sendTestNotification(e) {
<label for="model">Select a Model: </label>
<select id="modelsel" name="model" class="testbtn">
<?php
$models = array("BirdNET_GLOBAL_6K_V2.4_Model_FP16", "BirdNET_6K_GLOBAL_MODEL");
$models = array("BirdNET_GLOBAL_6K_V2.4_Model_FP16", "BirdNET_6K_GLOBAL_MODEL", "BirdNET-Go_classifier_20250916", "Perch_v2");
foreach($models as $modelName){
$isSelected = "";
if($config['MODEL'] == $modelName){
Expand All @@ -279,7 +279,7 @@ function sendTestNotification(e) {
?>
</select>
<br>
<span <?php if($config['MODEL'] == "BirdNET_6K_GLOBAL_MODEL") { ?>style="display: none"<?php } ?> id="soft">
<span <?php if(!in_array($config['MODEL'], ["BirdNET_GLOBAL_6K_V2.4_Model_FP16", "BirdNET-Go_classifier_20250916"])) { ?>style="display: none"<?php } ?> id="soft">
<input type="checkbox" name="data_model_version" <?php if($config['DATA_MODEL_VERSION'] == 2) { echo "checked"; };?> >
<label for="data_model_version">Species range model V2.4 - V2</label> [ <a target="_blank" href="https://github.com/kahst/BirdNET-Analyzer/discussions/234">Info here</a> ]<br>
<label for="sf_thresh">Species Occurrence Frequency Threshold [0.0005, 0.99]: </label>
Expand Down Expand Up @@ -387,11 +387,20 @@ function runProcess() {
<br>
<dd id="ddnewline">This is the BirdNET-Analyzer model, the most advanced BirdNET model to date. Currently it supports over 6,000 species worldwide, giving quite good species coverage for people in most of the world.</dd>
<br>
<dt>BirdNET-Go_classifier_20250916</dt>
<br>
<dd id="ddnewline">Custom TensorFlow Lite format AI model classifiers for enhanced bird and wildlife identification. Extends the capabilities of the base BirdNET v2.4 model. <a target="_blank" href="https://github.com/tphakala/birdnet-go-classifiers">External model</a></dd>
<br>
<dt>Perch_v2</dt>
<br>
<dd id="ddnewline">Perch is a bioacoustics model trained to classify nearly 15,000 species. This model has relatively high system requirements: RPi5 or Intel Haswell level performance and 4GB of RAM. <a target="_blank" href="https://www.kaggle.com/models/google/bird-vocalization-classifier/tensorFlow2/perch_v2">External model</a></dd>
<br>
<dt>BirdNET_6K_GLOBAL_MODEL (2020)</dt>
<br>
<dd id="ddnewline">This is the BirdNET-Lite model, with bird sound recognition for more than 6,000 species worldwide. This has generally worse performance than the newer models but is kept as a legacy option.</dd>
<br>
<dt>[ In-depth technical write-up on the models <a target="_blank" href="https://github.com/mcguirepr89/BirdNET-Pi/wiki/BirdNET-Pi:-some-theory-on-classification-&-some-practical-hints">here</a> ]</dt>
<dt>[ External models are downloaded if not yet present, so make sure your internet connection is stable ]</dt>
<dt>[ In-depth technical write-up on the BirdNET models <a target="_blank" href="https://github.com/mcguirepr89/BirdNET-Pi/wiki/BirdNET-Pi:-some-theory-on-classification-&-some-practical-hints">here</a> ]</dt>
</dl>
</td></tr></table><br>

Expand Down
1 change: 1 addition & 0 deletions scripts/update_birdnet_snippets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ fi

ensure_python_package inotify inotify
ensure_python_package soundfile soundfile
ensure_python_package resampy resampy

if ! which inotifywait &>/dev/null;then
ensure_apt_updated
Expand Down
4 changes: 3 additions & 1 deletion scripts/utils/analysis.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
import re
import time

import librosa
Expand Down Expand Up @@ -101,9 +102,10 @@ def filter_humans(predictions):

# mask for humans
human_mask = [False] * len(predictions)
human = re.compile('(Human|Conversation|Speech|Child_|Female_|Male_|Yell)')
for i, prediction in enumerate(predictions):
for p in prediction[:human_cutoff]:
if 'Human' in p[0]:
if human.match(p[0]):
human_mask[i] = True
break

Expand Down
2 changes: 1 addition & 1 deletion scripts/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def get_model_labels(model=None):

def set_label_file():
lang = get_language()
labels = [f'{label}_{lang[label]}\n' for label in get_model_labels()]
labels = [f'{label}_{lang.get(label, label)}\n' for label in get_model_labels()]
file_name = os.path.join(MODEL_PATH, 'labels.txt')
if os.path.islink(file_name):
os.remove(file_name)
Expand Down
52 changes: 50 additions & 2 deletions scripts/utils/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import math
import operator
import os
import tarfile

import numpy as np
import requests

from .helpers import get_settings, get_model_labels, MODEL_PATH

Expand Down Expand Up @@ -50,6 +52,29 @@ def get_meta_model(model=None, version=None):
return MDataModel2(conf.getfloat('SF_THRESH'))


def download_file(url, file_path):
tmp_file = f"{file_path}_tmp"
session = requests.Session()
response = session.get(url, stream=True)
response.raise_for_status()
block_size = 32768

log.info('Downloading: %s' % os.path.basename(file_path))
downloaded_size = 0
try:
with open(tmp_file, "wb") as outfile:
for data in response.iter_content(block_size):
downloaded_size += len(data)
log.info(f'Progress: {downloaded_size}')
outfile.write(data)
except requests.exceptions.HTTPError as e:
if os.path.exists(tmp_file):
os.unlink(tmp_file)
raise e

os.rename(tmp_file, file_path)


class Basemodel:
chunk_duration = None
sample_rate = None
Expand All @@ -58,8 +83,9 @@ class Basemodel:
_output_layer = 0

def __init__(self):
model_path = os.path.join(MODEL_PATH, f'{self.model_name}.tflite')
self.interpreter = tflite.Interpreter(model_path)
self.model_path = os.path.join(MODEL_PATH, f'{self.model_name}.tflite')
self.ensure_model()
self.interpreter = tflite.Interpreter(self.model_path)
self.interpreter.allocate_tensors()
input_details = self.interpreter.get_input_details()
output_details = self.interpreter.get_output_details()
Expand All @@ -82,6 +108,9 @@ def set_meta_data(self, lat, lon, week):
def get_species_list(self):
return []

def ensure_model(self):
pass


class BirdNet(Basemodel):
chunk_duration = 3
Expand Down Expand Up @@ -182,10 +211,29 @@ def predict(self, chunk):
exp_x = np.exp(logits - np.max(logits)) # Stabilizing to prevent overflow
return self.label(exp_x / np.sum(exp_x))

def ensure_model(self):
if os.path.exists(self.model_path):
return
base_url = 'https://github.com/Nachtzuster/BirdNET-Pi/releases/download/v0.11'
file = 'Perch_v2.tar.gz'
tmp_file = os.path.join(MODEL_PATH, file)
download_file(f'{base_url}/{file}', tmp_file)
log.info(f'Extracting {file}...')
with tarfile.open(tmp_file, "r:gz") as tar:
tar.extractall(MODEL_PATH)
os.unlink(tmp_file)


class BirdNETGo20250916(BirdNetV2_4):
model_name = 'BirdNET-Go_classifier_20250916'

def ensure_model(self):
if os.path.exists(self.model_path):
return
base_url = 'https://raw.githubusercontent.com/tphakala/birdnet-go-classifiers/refs/heads/main/20250916'
for file in ['BirdNET-Go_classifier_20250916_Labels.txt', 'BirdNET-Go_classifier_20250916.tflite']:
download_file(f'{base_url}/{file}', os.path.join(MODEL_PATH, file))


class MDataModel:
model_name = None
Expand Down
62 changes: 59 additions & 3 deletions tests/test_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import unittest
from unittest.mock import patch

from scripts.utils.analysis import filter_humans
from scripts.utils.analysis import run_analysis
from scripts.utils.classes import ParseFileName
from tests.helpers import TESTDATA, Settings
from scripts.utils.analysis import filter_humans


class TestRunAnalysis(unittest.TestCase):
Expand Down Expand Up @@ -57,13 +57,15 @@ def test_filter_humans_no_human(self, mock_load_settings):
# Input detections without humans
detections = [
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_C', 0.7), ('Bird_D', 0.6)]
[('Bird_C', 0.9), ('Pacarina schumanni', 0.8)],
[('Bird_F', 0.7), ('Bird_F', 0.6)]
]

# Expected output
expected = [
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_C', 0.7), ('Bird_D', 0.6)]
[('Bird_C', 0.9), ('Pacarina schumanni', 0.8)],
[('Bird_F', 0.7), ('Bird_F', 0.6)]
]

# Run filter_humans
Expand Down Expand Up @@ -194,6 +196,60 @@ def test_filter_humans_with_human_deep(self, mock_load_settings):
# Assertions
self.assertEqual(result, expected)

@patch('scripts.utils.helpers._load_settings')
def test_filter_humans_for_perch(self, mock_load_settings):
mock_load_settings.return_value = Settings.with_defaults()

# Input detections with "humans" from FSD50K
# Child_speech_and_kid_speaking
# Conversation
# Female_singing
# Female_speech_and_woman_speaking
# Human_voice
# Male_singing
# Male_speech_and_man_speaking
# Speech
# Speech_synthesizer
# Yell

# Human_group_actions ??
detections = [
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_A', 0.8), ('Child_speech_and_kid_speaking', 0.75)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Conversation', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Female_singing', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Female_speech_and_woman_speaking', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Human_group_actions', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Human_voice', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Male_singing', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Male_speech_and_man_speaking', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Speech', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Speech_synthesizer', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
[('Bird_B', 0.9), ('Yell', 0.8)],
[('Bird_A', 0.9), ('Bird_B', 0.8)],
]

# Expected output
expected = [
[('Human_Human', 0.0)]
] * 23

# Run filter_humans
result = filter_humans(detections)

# Assertions
self.assertEqual(result, expected)


if __name__ == '__main__':
unittest.main()