a3em-analysis 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a3em_analysis-0.0.1/.gitignore +6 -0
- a3em_analysis-0.0.1/LICENSE +21 -0
- a3em_analysis-0.0.1/PKG-INFO +410 -0
- a3em_analysis-0.0.1/README.md +396 -0
- a3em_analysis-0.0.1/build.sh +6 -0
- a3em_analysis-0.0.1/pyproject.toml +34 -0
- a3em_analysis-0.0.1/requirements.txt +3 -0
- a3em_analysis-0.0.1/src/a3em/__init__.py +2 -0
- a3em_analysis-0.0.1/src/a3em/datasets.py +350 -0
- a3em_analysis-0.0.1/src/a3em/requirements.txt +10 -0
- a3em_analysis-0.0.1/src/a3em/utils.py +108 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Vanderbilt University, A3EM Project Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: a3em-analysis
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A3EM provides utilities for preprocessing bioacoustic recordings, extracting acoustic features, and working with supported bioacoustic datasets.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vu-a3em/a3em-python-package
|
|
6
|
+
Project-URL: Issues, https://github.com/vu-a3em/a3em-python-package/issues
|
|
7
|
+
Author-email: Gabriel Barnard <gabriel.h.barnard@vanderbilt.edu>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# A3EM Package
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import a3em
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A3EM provides utilities for preprocessing bioacoustic recordings, extracting acoustic features, and working with supported bioacoustic datasets.
|
|
22
|
+
|
|
23
|
+
# `a3em.utils`
|
|
24
|
+
|
|
25
|
+
## `preprocess`
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
preprocess(audio, sample_rate, normalization=0.7)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Takes an audio clip as a `numpy.ndarray`, applies high-pass and low-pass filtering, and normalizes the signal.
|
|
32
|
+
|
|
33
|
+
### Example
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import librosa
|
|
37
|
+
from a3em.utils import preprocess
|
|
38
|
+
|
|
39
|
+
audio_path = "test.wav"
|
|
40
|
+
audio, sample_rate = librosa.load(audio_path)
|
|
41
|
+
|
|
42
|
+
preprocessed_audio = preprocess(audio, sample_rate)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## `extract_features`
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
extract_features(audio, sample_rate)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Takes an audio clip as a `numpy.ndarray` and extracts acoustic features from the signal.
|
|
54
|
+
|
|
55
|
+
### Example
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import librosa
|
|
59
|
+
from a3em.utils import preprocess, extract_features
|
|
60
|
+
|
|
61
|
+
audio_path = "test.wav"
|
|
62
|
+
audio, sample_rate = librosa.load(audio_path)
|
|
63
|
+
|
|
64
|
+
preprocessed_audio = preprocess(audio, sample_rate)
|
|
65
|
+
features = extract_features(preprocessed_audio, sample_rate)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
# `a3em.datasets`
|
|
69
|
+
|
|
70
|
+
The `a3em.datasets` module provides convenient access to supported A3EM datasets. Dataset classes handle downloading, preprocessing, clip extraction, and feature extraction.
|
|
71
|
+
|
|
72
|
+
## Arden
|
|
73
|
+
|
|
74
|
+
The `Arden` dataset contains collar-borne AudioMoth recordings collected in June 2025 in Samburu National Reserve, Kenya.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from a3em.datasets import Arden
|
|
78
|
+
import os
|
|
79
|
+
|
|
80
|
+
dataset = Arden(
|
|
81
|
+
path=os.getenv("DATA_PATH"),
|
|
82
|
+
token=os.getenv("API_TOKEN")
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`path` specifies where the dataset should be stored locally. `token` is used to authenticate downloads from Dryad.
|
|
87
|
+
|
|
88
|
+
The first time data is loaded, the dataset will automatically:
|
|
89
|
+
|
|
90
|
+
1. Check for the required audio and annotation files locally.
|
|
91
|
+
2. Download the dataset from Dryad if necessary.
|
|
92
|
+
3. Extract downloaded archives.
|
|
93
|
+
4. Load and filter annotation metadata.
|
|
94
|
+
5. Optionally generate background-noise examples.
|
|
95
|
+
6. Extract audio clips.
|
|
96
|
+
7. Preprocess the clips.
|
|
97
|
+
8. Compute acoustic features.
|
|
98
|
+
|
|
99
|
+
The extracted clips and features are cached on the `Arden` instance so that subsequent calls do not repeat feature extraction unless `reload=True` is specified.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## `load_data`
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
load_data(
|
|
107
|
+
random_state=None,
|
|
108
|
+
sample_rate=2000,
|
|
109
|
+
rumble_only=False,
|
|
110
|
+
reload=False
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Loads the dataset and returns the extracted acoustic features together with their binary labels.
|
|
115
|
+
|
|
116
|
+
### Example
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
features, labels = dataset.load_data(
|
|
120
|
+
random_state=123
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Parameters
|
|
125
|
+
|
|
126
|
+
| Parameter | Description |
|
|
127
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
128
|
+
| `random_state` | Seed used when shuffling metadata and generating background-noise clips. |
|
|
129
|
+
| `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
|
|
130
|
+
| `rumble_only` | If `True`, only annotated elephant rumbles are included. If `False`, background-noise examples are also generated. |
|
|
131
|
+
| `reload` | If `True`, regenerates metadata, clips, and features even if they have already been loaded. |
|
|
132
|
+
|
|
133
|
+
### Returns
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
features : pandas.DataFrame
|
|
137
|
+
labels : pandas.Series
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Labels are binary:
|
|
141
|
+
|
|
142
|
+
* `0` — Background noise
|
|
143
|
+
* `1` — Elephant rumble
|
|
144
|
+
|
|
145
|
+
Annotation quality values `2`, `3`, and `4` are mapped to the rumble label `1`. Background-noise examples have quality `0` and are mapped to label `0`.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## `load_data_ml`
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
load_data_ml(
|
|
153
|
+
test_split=0.2,
|
|
154
|
+
random_state=None,
|
|
155
|
+
sample_rate=2000,
|
|
156
|
+
rumble_only=False,
|
|
157
|
+
reload=False,
|
|
158
|
+
shuffle=False
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Loads the dataset and creates train/test splits suitable for machine-learning workflows.
|
|
163
|
+
|
|
164
|
+
Internally, this method uses `sklearn.model_selection.train_test_split`.
|
|
165
|
+
|
|
166
|
+
### Example
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
x_train, x_test, y_train, y_test = dataset.load_data_ml(
|
|
170
|
+
test_split=0.2,
|
|
171
|
+
random_state=123,
|
|
172
|
+
shuffle=True
|
|
173
|
+
)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Parameters
|
|
177
|
+
|
|
178
|
+
| Parameter | Description |
|
|
179
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
180
|
+
| `test_split` | Fraction of samples reserved for testing. Defaults to `0.2`. |
|
|
181
|
+
| `random_state` | Seed used for dataset generation and the train/test split. |
|
|
182
|
+
| `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
|
|
183
|
+
| `rumble_only` | If `True`, only annotated elephant rumbles are included. If `False`, background-noise examples are also generated. |
|
|
184
|
+
| `reload` | If `True`, regenerates metadata, clips, and features before splitting. |
|
|
185
|
+
| `shuffle` | Whether samples should be shuffled by `train_test_split` before creating the split. Defaults to `False`. |
|
|
186
|
+
|
|
187
|
+
### Returns
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
x_train : pandas.DataFrame
|
|
191
|
+
x_test : pandas.DataFrame
|
|
192
|
+
y_train : pandas.Series
|
|
193
|
+
y_test : pandas.Series
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## `load_clips`
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
load_clips(
|
|
202
|
+
random_state=None,
|
|
203
|
+
sample_rate=2000,
|
|
204
|
+
rumble_only=False,
|
|
205
|
+
reload=False
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Returns the extracted audio clips together with their computed acoustic features.
|
|
210
|
+
|
|
211
|
+
### Example
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
clips, features = dataset.load_clips(
|
|
215
|
+
random_state=123,
|
|
216
|
+
rumble_only=False
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Parameters
|
|
221
|
+
|
|
222
|
+
| Parameter | Description |
|
|
223
|
+
| -------------- | -------------------------------------------------------- |
|
|
224
|
+
| `random_state` | Seed used when generating the dataset. |
|
|
225
|
+
| `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
|
|
226
|
+
| `rumble_only` | If `True`, background-noise examples are not generated. |
|
|
227
|
+
| `reload` | If `True`, regenerates the clips and features. |
|
|
228
|
+
|
|
229
|
+
### Returns
|
|
230
|
+
|
|
231
|
+
* `clips` — list containing the extracted audio clips as NumPy arrays.
|
|
232
|
+
* `features` — `pandas.DataFrame` containing one row of acoustic features for each clip.
|
|
233
|
+
|
|
234
|
+
Each extracted clip includes a `0.2` second buffer before and after its annotated time range.
|
|
235
|
+
|
|
236
|
+
Clips shorter than two seconds after extraction are discarded during feature extraction.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Iteration
|
|
241
|
+
|
|
242
|
+
An `Arden` dataset can be iterated over directly.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
dataset = Arden(path, token)
|
|
246
|
+
|
|
247
|
+
for clip, features in dataset:
|
|
248
|
+
print(len(clip))
|
|
249
|
+
print(features)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
If the dataset has not already been loaded, iteration automatically initializes it using the default loading options.
|
|
253
|
+
|
|
254
|
+
Each iteration returns:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
(
|
|
258
|
+
numpy.ndarray, # audio clip
|
|
259
|
+
dict # extracted acoustic features
|
|
260
|
+
)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Indexing
|
|
266
|
+
|
|
267
|
+
Individual clips and their corresponding features can be accessed by index after the dataset has been loaded.
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
clip, features = dataset[10]
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The returned feature set is converted from its DataFrame row into a dictionary.
|
|
274
|
+
|
|
275
|
+
If the dataset has not yet been loaded, indexing returns:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
(None, None)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Dataset Length
|
|
284
|
+
|
|
285
|
+
The number of metadata entries currently loaded can be obtained with `len()`:
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
len(dataset)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Before the dataset has been initialized, its length is `0`.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Rumble Filtering
|
|
296
|
+
|
|
297
|
+
Arden annotations are filtered before clips are extracted.
|
|
298
|
+
|
|
299
|
+
Only entries satisfying all of the following conditions are retained:
|
|
300
|
+
|
|
301
|
+
* `call_type` is `RUM` or `BKG`
|
|
302
|
+
* `earflap` is `0` or `1`
|
|
303
|
+
* `overlap` is `N`
|
|
304
|
+
* `quality` is `0`, `2`, `3`, or `4`
|
|
305
|
+
* duration is greater than `2` seconds
|
|
306
|
+
|
|
307
|
+
When `rumble_only=True`, background-noise examples are not generated.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Background-Noise Generation
|
|
312
|
+
|
|
313
|
+
When `rumble_only=False`, background-noise (`BKG`) examples are automatically generated from regions outside the annotated event ranges.
|
|
314
|
+
|
|
315
|
+
The duration of generated noise clips is based on the mean and standard deviation of annotation durations in the corresponding recording.
|
|
316
|
+
|
|
317
|
+
Generated background-noise entries use:
|
|
318
|
+
|
|
319
|
+
```text
|
|
320
|
+
call_type = BKG
|
|
321
|
+
quality = 0
|
|
322
|
+
overlap = N
|
|
323
|
+
earflap = 0
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Because background-noise selection is randomized, use `random_state` when reproducible dataset generation is required.
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
features, labels = dataset.load_data(
|
|
330
|
+
random_state=123
|
|
331
|
+
)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Audio Processing
|
|
337
|
+
|
|
338
|
+
Audio recordings are loaded using `librosa` at the requested sample rate:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
sample_rate=2000
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
For each retained metadata entry:
|
|
345
|
+
|
|
346
|
+
1. The corresponding time range is extracted from the recording.
|
|
347
|
+
2. A `0.2` second buffer is added to each side.
|
|
348
|
+
3. The clip is passed through `a3em.utils.preprocess`.
|
|
349
|
+
4. Acoustic features are calculated using `a3em.utils.extract_features`.
|
|
350
|
+
|
|
351
|
+
The original extracted clip and its computed features are retained by the dataset instance.
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Reloading Data
|
|
356
|
+
|
|
357
|
+
Once clips and features have been generated, the `Arden` instance reuses them.
|
|
358
|
+
|
|
359
|
+
To force the dataset to regenerate its metadata, clips, and features:
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
features, labels = dataset.load_data(
|
|
363
|
+
reload=True
|
|
364
|
+
)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
This is useful when changing parameters such as:
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
sample_rate
|
|
371
|
+
rumble_only
|
|
372
|
+
random_state
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## Quick Start
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
import os
|
|
381
|
+
from a3em.datasets import Arden
|
|
382
|
+
|
|
383
|
+
dataset = Arden(
|
|
384
|
+
path=os.getenv("DATA_PATH"),
|
|
385
|
+
token=os.getenv("API_TOKEN")
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Load features and labels
|
|
389
|
+
features, labels = dataset.load_data(
|
|
390
|
+
random_state=123
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
print(features.head())
|
|
394
|
+
print(labels.head())
|
|
395
|
+
|
|
396
|
+
# Create a machine-learning split
|
|
397
|
+
x_train, x_test, y_train, y_test = dataset.load_data_ml(
|
|
398
|
+
test_split=0.2,
|
|
399
|
+
random_state=123,
|
|
400
|
+
shuffle=True
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Access raw clips and features
|
|
404
|
+
clips, clip_features = dataset.load_clips()
|
|
405
|
+
|
|
406
|
+
# Iterate through clips
|
|
407
|
+
for clip, feature_set in dataset:
|
|
408
|
+
print(clip.shape)
|
|
409
|
+
print(feature_set)
|
|
410
|
+
```
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# A3EM Package
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
import a3em
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
A3EM provides utilities for preprocessing bioacoustic recordings, extracting acoustic features, and working with supported bioacoustic datasets.
|
|
8
|
+
|
|
9
|
+
# `a3em.utils`
|
|
10
|
+
|
|
11
|
+
## `preprocess`
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
preprocess(audio, sample_rate, normalization=0.7)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Takes an audio clip as a `numpy.ndarray`, applies high-pass and low-pass filtering, and normalizes the signal.
|
|
18
|
+
|
|
19
|
+
### Example
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import librosa
|
|
23
|
+
from a3em.utils import preprocess
|
|
24
|
+
|
|
25
|
+
audio_path = "test.wav"
|
|
26
|
+
audio, sample_rate = librosa.load(audio_path)
|
|
27
|
+
|
|
28
|
+
preprocessed_audio = preprocess(audio, sample_rate)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## `extract_features`
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
extract_features(audio, sample_rate)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Takes an audio clip as a `numpy.ndarray` and extracts acoustic features from the signal.
|
|
40
|
+
|
|
41
|
+
### Example
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import librosa
|
|
45
|
+
from a3em.utils import preprocess, extract_features
|
|
46
|
+
|
|
47
|
+
audio_path = "test.wav"
|
|
48
|
+
audio, sample_rate = librosa.load(audio_path)
|
|
49
|
+
|
|
50
|
+
preprocessed_audio = preprocess(audio, sample_rate)
|
|
51
|
+
features = extract_features(preprocessed_audio, sample_rate)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
# `a3em.datasets`
|
|
55
|
+
|
|
56
|
+
The `a3em.datasets` module provides convenient access to supported A3EM datasets. Dataset classes handle downloading, preprocessing, clip extraction, and feature extraction.
|
|
57
|
+
|
|
58
|
+
## Arden
|
|
59
|
+
|
|
60
|
+
The `Arden` dataset contains collar-borne AudioMoth recordings collected in June 2025 in Samburu National Reserve, Kenya.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from a3em.datasets import Arden
|
|
64
|
+
import os
|
|
65
|
+
|
|
66
|
+
dataset = Arden(
|
|
67
|
+
path=os.getenv("DATA_PATH"),
|
|
68
|
+
token=os.getenv("API_TOKEN")
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`path` specifies where the dataset should be stored locally. `token` is used to authenticate downloads from Dryad.
|
|
73
|
+
|
|
74
|
+
The first time data is loaded, the dataset will automatically:
|
|
75
|
+
|
|
76
|
+
1. Check for the required audio and annotation files locally.
|
|
77
|
+
2. Download the dataset from Dryad if necessary.
|
|
78
|
+
3. Extract downloaded archives.
|
|
79
|
+
4. Load and filter annotation metadata.
|
|
80
|
+
5. Optionally generate background-noise examples.
|
|
81
|
+
6. Extract audio clips.
|
|
82
|
+
7. Preprocess the clips.
|
|
83
|
+
8. Compute acoustic features.
|
|
84
|
+
|
|
85
|
+
The extracted clips and features are cached on the `Arden` instance so that subsequent calls do not repeat feature extraction unless `reload=True` is specified.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## `load_data`
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
load_data(
|
|
93
|
+
random_state=None,
|
|
94
|
+
sample_rate=2000,
|
|
95
|
+
rumble_only=False,
|
|
96
|
+
reload=False
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Loads the dataset and returns the extracted acoustic features together with their binary labels.
|
|
101
|
+
|
|
102
|
+
### Example
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
features, labels = dataset.load_data(
|
|
106
|
+
random_state=123
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Parameters
|
|
111
|
+
|
|
112
|
+
| Parameter | Description |
|
|
113
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
114
|
+
| `random_state` | Seed used when shuffling metadata and generating background-noise clips. |
|
|
115
|
+
| `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
|
|
116
|
+
| `rumble_only` | If `True`, only annotated elephant rumbles are included. If `False`, background-noise examples are also generated. |
|
|
117
|
+
| `reload` | If `True`, regenerates metadata, clips, and features even if they have already been loaded. |
|
|
118
|
+
|
|
119
|
+
### Returns
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
features : pandas.DataFrame
|
|
123
|
+
labels : pandas.Series
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Labels are binary:
|
|
127
|
+
|
|
128
|
+
* `0` — Background noise
|
|
129
|
+
* `1` — Elephant rumble
|
|
130
|
+
|
|
131
|
+
Annotation quality values `2`, `3`, and `4` are mapped to the rumble label `1`. Background-noise examples have quality `0` and are mapped to label `0`.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## `load_data_ml`
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
load_data_ml(
|
|
139
|
+
test_split=0.2,
|
|
140
|
+
random_state=None,
|
|
141
|
+
sample_rate=2000,
|
|
142
|
+
rumble_only=False,
|
|
143
|
+
reload=False,
|
|
144
|
+
shuffle=False
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Loads the dataset and creates train/test splits suitable for machine-learning workflows.
|
|
149
|
+
|
|
150
|
+
Internally, this method uses `sklearn.model_selection.train_test_split`.
|
|
151
|
+
|
|
152
|
+
### Example
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
x_train, x_test, y_train, y_test = dataset.load_data_ml(
|
|
156
|
+
test_split=0.2,
|
|
157
|
+
random_state=123,
|
|
158
|
+
shuffle=True
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Parameters
|
|
163
|
+
|
|
164
|
+
| Parameter | Description |
|
|
165
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
166
|
+
| `test_split` | Fraction of samples reserved for testing. Defaults to `0.2`. |
|
|
167
|
+
| `random_state` | Seed used for dataset generation and the train/test split. |
|
|
168
|
+
| `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
|
|
169
|
+
| `rumble_only` | If `True`, only annotated elephant rumbles are included. If `False`, background-noise examples are also generated. |
|
|
170
|
+
| `reload` | If `True`, regenerates metadata, clips, and features before splitting. |
|
|
171
|
+
| `shuffle` | Whether samples should be shuffled by `train_test_split` before creating the split. Defaults to `False`. |
|
|
172
|
+
|
|
173
|
+
### Returns
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
x_train : pandas.DataFrame
|
|
177
|
+
x_test : pandas.DataFrame
|
|
178
|
+
y_train : pandas.Series
|
|
179
|
+
y_test : pandas.Series
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## `load_clips`
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
load_clips(
|
|
188
|
+
random_state=None,
|
|
189
|
+
sample_rate=2000,
|
|
190
|
+
rumble_only=False,
|
|
191
|
+
reload=False
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Returns the extracted audio clips together with their computed acoustic features.
|
|
196
|
+
|
|
197
|
+
### Example
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
clips, features = dataset.load_clips(
|
|
201
|
+
random_state=123,
|
|
202
|
+
rumble_only=False
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Parameters
|
|
207
|
+
|
|
208
|
+
| Parameter | Description |
|
|
209
|
+
| -------------- | -------------------------------------------------------- |
|
|
210
|
+
| `random_state` | Seed used when generating the dataset. |
|
|
211
|
+
| `sample_rate` | Sample rate used when loading audio. Defaults to `2000`. |
|
|
212
|
+
| `rumble_only` | If `True`, background-noise examples are not generated. |
|
|
213
|
+
| `reload` | If `True`, regenerates the clips and features. |
|
|
214
|
+
|
|
215
|
+
### Returns
|
|
216
|
+
|
|
217
|
+
* `clips` — list containing the extracted audio clips as NumPy arrays.
|
|
218
|
+
* `features` — `pandas.DataFrame` containing one row of acoustic features for each clip.
|
|
219
|
+
|
|
220
|
+
Each extracted clip includes a `0.2` second buffer before and after its annotated time range.
|
|
221
|
+
|
|
222
|
+
Clips shorter than two seconds after extraction are discarded during feature extraction.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Iteration
|
|
227
|
+
|
|
228
|
+
An `Arden` dataset can be iterated over directly.
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
dataset = Arden(path, token)
|
|
232
|
+
|
|
233
|
+
for clip, features in dataset:
|
|
234
|
+
print(len(clip))
|
|
235
|
+
print(features)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
If the dataset has not already been loaded, iteration automatically initializes it using the default loading options.
|
|
239
|
+
|
|
240
|
+
Each iteration returns:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
(
|
|
244
|
+
numpy.ndarray, # audio clip
|
|
245
|
+
dict # extracted acoustic features
|
|
246
|
+
)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Indexing
|
|
252
|
+
|
|
253
|
+
Individual clips and their corresponding features can be accessed by index after the dataset has been loaded.
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
clip, features = dataset[10]
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The returned feature set is converted from its DataFrame row into a dictionary.
|
|
260
|
+
|
|
261
|
+
If the dataset has not yet been loaded, indexing returns:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
(None, None)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Dataset Length
|
|
270
|
+
|
|
271
|
+
The number of metadata entries currently loaded can be obtained with `len()`:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
len(dataset)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Before the dataset has been initialized, its length is `0`.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Rumble Filtering
|
|
282
|
+
|
|
283
|
+
Arden annotations are filtered before clips are extracted.
|
|
284
|
+
|
|
285
|
+
Only entries satisfying all of the following conditions are retained:
|
|
286
|
+
|
|
287
|
+
* `call_type` is `RUM` or `BKG`
|
|
288
|
+
* `earflap` is `0` or `1`
|
|
289
|
+
* `overlap` is `N`
|
|
290
|
+
* `quality` is `0`, `2`, `3`, or `4`
|
|
291
|
+
* duration is greater than `2` seconds
|
|
292
|
+
|
|
293
|
+
When `rumble_only=True`, background-noise examples are not generated.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Background-Noise Generation
|
|
298
|
+
|
|
299
|
+
When `rumble_only=False`, background-noise (`BKG`) examples are automatically generated from regions outside the annotated event ranges.
|
|
300
|
+
|
|
301
|
+
The duration of generated noise clips is based on the mean and standard deviation of annotation durations in the corresponding recording.
|
|
302
|
+
|
|
303
|
+
Generated background-noise entries use:
|
|
304
|
+
|
|
305
|
+
```text
|
|
306
|
+
call_type = BKG
|
|
307
|
+
quality = 0
|
|
308
|
+
overlap = N
|
|
309
|
+
earflap = 0
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Because background-noise selection is randomized, use `random_state` when reproducible dataset generation is required.
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
features, labels = dataset.load_data(
|
|
316
|
+
random_state=123
|
|
317
|
+
)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Audio Processing
|
|
323
|
+
|
|
324
|
+
Audio recordings are loaded using `librosa` at the requested sample rate:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
sample_rate=2000
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
For each retained metadata entry:
|
|
331
|
+
|
|
332
|
+
1. The corresponding time range is extracted from the recording.
|
|
333
|
+
2. A `0.2` second buffer is added to each side.
|
|
334
|
+
3. The clip is passed through `a3em.utils.preprocess`.
|
|
335
|
+
4. Acoustic features are calculated using `a3em.utils.extract_features`.
|
|
336
|
+
|
|
337
|
+
The original extracted clip and its computed features are retained by the dataset instance.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Reloading Data
|
|
342
|
+
|
|
343
|
+
Once clips and features have been generated, the `Arden` instance reuses them.
|
|
344
|
+
|
|
345
|
+
To force the dataset to regenerate its metadata, clips, and features:
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
features, labels = dataset.load_data(
|
|
349
|
+
reload=True
|
|
350
|
+
)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
This is useful when changing parameters such as:
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
sample_rate
|
|
357
|
+
rumble_only
|
|
358
|
+
random_state
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Quick Start
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
import os
|
|
367
|
+
from a3em.datasets import Arden
|
|
368
|
+
|
|
369
|
+
dataset = Arden(
|
|
370
|
+
path=os.getenv("DATA_PATH"),
|
|
371
|
+
token=os.getenv("API_TOKEN")
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Load features and labels
|
|
375
|
+
features, labels = dataset.load_data(
|
|
376
|
+
random_state=123
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
print(features.head())
|
|
380
|
+
print(labels.head())
|
|
381
|
+
|
|
382
|
+
# Create a machine-learning split
|
|
383
|
+
x_train, x_test, y_train, y_test = dataset.load_data_ml(
|
|
384
|
+
test_split=0.2,
|
|
385
|
+
random_state=123,
|
|
386
|
+
shuffle=True
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Access raw clips and features
|
|
390
|
+
clips, clip_features = dataset.load_clips()
|
|
391
|
+
|
|
392
|
+
# Iterate through clips
|
|
393
|
+
for clip, feature_set in dataset:
|
|
394
|
+
print(clip.shape)
|
|
395
|
+
print(feature_set)
|
|
396
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"hatchling >= 1.26",
|
|
4
|
+
"pandas",
|
|
5
|
+
"numpy",
|
|
6
|
+
"librosa",
|
|
7
|
+
"soundfile",
|
|
8
|
+
"matplotlib",
|
|
9
|
+
"scikit-learn",
|
|
10
|
+
"python-dotenv",
|
|
11
|
+
"statsmodels",
|
|
12
|
+
"tqdm"
|
|
13
|
+
]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[project]
|
|
17
|
+
name = "a3em-analysis"
|
|
18
|
+
version = "0.0.1"
|
|
19
|
+
authors = [
|
|
20
|
+
{ name="Gabriel Barnard", email="gabriel.h.barnard@vanderbilt.edu" },
|
|
21
|
+
]
|
|
22
|
+
description = "A3EM provides utilities for preprocessing bioacoustic recordings, extracting acoustic features, and working with supported bioacoustic datasets."
|
|
23
|
+
readme = "README.md"
|
|
24
|
+
requires-python = ">=3.8"
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
]
|
|
29
|
+
license = "MIT"
|
|
30
|
+
license-files = ["LICENSE"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/vu-a3em/a3em-python-package"
|
|
34
|
+
Issues = "https://github.com/vu-a3em/a3em-python-package/issues"
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import a3em.utils
|
|
2
|
+
import librosa
|
|
3
|
+
import random
|
|
4
|
+
import requests
|
|
5
|
+
import zipfile
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from sklearn.model_selection import train_test_split
|
|
11
|
+
from tqdm import tqdm
|
|
12
|
+
|
|
13
|
+
class Dataset(ABC):
|
|
14
|
+
|
|
15
|
+
def __init__(self, path, token=None):
|
|
16
|
+
self.path = Path(path)
|
|
17
|
+
self.token = token
|
|
18
|
+
self._index = 0
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def load_data(self, test_split, random_state, shuffle):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def __iter__(self):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def __next__(self):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def __getitem__(self, key):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def __len__(self):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
class Arden(Dataset):
|
|
41
|
+
|
|
42
|
+
api = 'https://datadryad.org/'
|
|
43
|
+
doi = 'doi%3A10.5061%2Fdryad.xd2547dz3'
|
|
44
|
+
|
|
45
|
+
def __init__(self, path, token):
|
|
46
|
+
super().__init__(path, token)
|
|
47
|
+
self.prefetch = None
|
|
48
|
+
self.metadata = None
|
|
49
|
+
self.audiomoth_path = self.path / 'audiomoth'
|
|
50
|
+
self.annotations_path = self.path / 'manualAnnotations'
|
|
51
|
+
self._features = None
|
|
52
|
+
self._clips = None
|
|
53
|
+
|
|
54
|
+
def load_data(
|
|
55
|
+
self,
|
|
56
|
+
random_state=None,
|
|
57
|
+
sample_rate=2000,
|
|
58
|
+
rumble_only=False,
|
|
59
|
+
reload=False
|
|
60
|
+
):
|
|
61
|
+
if self._clips is None or self._features is None or reload:
|
|
62
|
+
self.__setup(random_state, sample_rate, rumble_only)
|
|
63
|
+
|
|
64
|
+
labels = (
|
|
65
|
+
self.metadata['quality']
|
|
66
|
+
.replace({0: 0, 2: 1, 3: 1, 4: 1})
|
|
67
|
+
.rename('label')
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return self._features, labels
|
|
71
|
+
|
|
72
|
+
def load_data_ml(
|
|
73
|
+
self,
|
|
74
|
+
test_split=0.2,
|
|
75
|
+
random_state=None,
|
|
76
|
+
sample_rate=2000,
|
|
77
|
+
rumble_only=False,
|
|
78
|
+
reload=False,
|
|
79
|
+
shuffle=False
|
|
80
|
+
):
|
|
81
|
+
if self._clips is None or self._features is None or reload:
|
|
82
|
+
self.__setup(random_state, sample_rate, rumble_only)
|
|
83
|
+
|
|
84
|
+
labels = (
|
|
85
|
+
self.metadata['quality']
|
|
86
|
+
.replace({0: 0, 2: 1, 3: 1, 4: 1})
|
|
87
|
+
.rename('label')
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return train_test_split(
|
|
91
|
+
self._features,
|
|
92
|
+
labels,
|
|
93
|
+
test_size=test_split,
|
|
94
|
+
random_state=random_state,
|
|
95
|
+
shuffle=shuffle
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def load_clips(
|
|
99
|
+
self,
|
|
100
|
+
random_state=None,
|
|
101
|
+
sample_rate=2000,
|
|
102
|
+
rumble_only=False,
|
|
103
|
+
reload=False
|
|
104
|
+
):
|
|
105
|
+
if self._clips is None or self._features is None or reload:
|
|
106
|
+
self.__setup(random_state, sample_rate, rumble_only)
|
|
107
|
+
return self._clips, self._features
|
|
108
|
+
|
|
109
|
+
def __iter__(
|
|
110
|
+
self,
|
|
111
|
+
random_state=None,
|
|
112
|
+
sample_rate=2000,
|
|
113
|
+
rumble_only=False,
|
|
114
|
+
reload=False
|
|
115
|
+
):
|
|
116
|
+
if self._clips is None or self._features is None or reload:
|
|
117
|
+
self.__setup(random_state, sample_rate, rumble_only)
|
|
118
|
+
self._index = 0
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __next__(self):
|
|
122
|
+
if self._index == len(self):
|
|
123
|
+
raise StopIteration
|
|
124
|
+
clip = self._clips[self._index]
|
|
125
|
+
features = self._features.iloc[self._index].to_dict()
|
|
126
|
+
self._index += 1
|
|
127
|
+
return clip, features
|
|
128
|
+
|
|
129
|
+
def __getitem__(self, key):
|
|
130
|
+
if self._clips is None or self._features is None:
|
|
131
|
+
return None, None
|
|
132
|
+
return self._clips[key], self._features.iloc[key].to_dict()
|
|
133
|
+
|
|
134
|
+
def __len__(self):
|
|
135
|
+
return 0 if self.metadata is None else len(self.metadata)
|
|
136
|
+
|
|
137
|
+
def __prefetch(self):
|
|
138
|
+
if not self.__validate_local_data():
|
|
139
|
+
self.__download_data()
|
|
140
|
+
audiomoth_files = sorted(self.audiomoth_path.glob('*.WAV'))
|
|
141
|
+
annotation_files = sorted(self.annotations_path.glob('*.txt'))
|
|
142
|
+
file_stems = [file.stem for file in audiomoth_files]
|
|
143
|
+
file_pairs = [
|
|
144
|
+
{'audio_path': x[0], 'annotation_path': x[1]}
|
|
145
|
+
for x in zip(audiomoth_files, annotation_files)
|
|
146
|
+
]
|
|
147
|
+
self.prefetch = dict(zip(file_stems, file_pairs))
|
|
148
|
+
print('prefetch complete')
|
|
149
|
+
|
|
150
|
+
def __setup(self, random_state, sample_rate, rumble_only):
|
|
151
|
+
self.__prefetch()
|
|
152
|
+
self.__load_metadata(random_state, rumble_only)
|
|
153
|
+
self.__load_audio_features(random_state, sample_rate)
|
|
154
|
+
|
|
155
|
+
def __validate_local_data(self):
|
|
156
|
+
if not (self.audiomoth_path.exists() and self.annotations_path.exists()):
|
|
157
|
+
return False
|
|
158
|
+
audiomoth_contents = sorted(self.audiomoth_path.glob('*.WAV'))
|
|
159
|
+
annotations_contents = sorted(self.annotations_path.glob('*.txt'))
|
|
160
|
+
annotations_stems = [file.stem for file in annotations_contents]
|
|
161
|
+
audiomoth_stems = [file.stem for file in audiomoth_contents]
|
|
162
|
+
return annotations_stems == audiomoth_stems
|
|
163
|
+
|
|
164
|
+
def __download_data(self):
|
|
165
|
+
# make sure the directory is clear
|
|
166
|
+
a3em.utils.clean_directory(self.path)
|
|
167
|
+
|
|
168
|
+
# pull files metadata
|
|
169
|
+
print('pulling metadata')
|
|
170
|
+
r = requests.get(f'{Arden.api}/api/v2/datasets/{Arden.doi}/versions')
|
|
171
|
+
if r.status_code != 200:
|
|
172
|
+
raise RuntimeError(r.text)
|
|
173
|
+
content = r.json()
|
|
174
|
+
latest_version = content['_embedded']['stash:versions'][0]
|
|
175
|
+
files_path = latest_version['_links']['stash:files']['href']
|
|
176
|
+
|
|
177
|
+
# get individual download links
|
|
178
|
+
print('locating files')
|
|
179
|
+
r = requests.get(f'{Arden.api}/{files_path}')
|
|
180
|
+
if r.status_code != 200:
|
|
181
|
+
raise RuntimeError(r.text)
|
|
182
|
+
content = r.json()
|
|
183
|
+
files = content['_embedded']['stash:files']
|
|
184
|
+
|
|
185
|
+
# download the files
|
|
186
|
+
print('download in progress')
|
|
187
|
+
for file in files:
|
|
188
|
+
download_src = file['_links']['stash:download']['href']
|
|
189
|
+
download_dst = self.path / file['path']
|
|
190
|
+
r = requests.get(f'{Arden.api}/{download_src}',
|
|
191
|
+
headers={'authorization': f'Bearer {self.token}'})
|
|
192
|
+
if r.status_code != 200:
|
|
193
|
+
raise RuntimeError(r.text)
|
|
194
|
+
with open(download_dst, 'wb') as fd:
|
|
195
|
+
for chunk in r.iter_content(chunk_size=128):
|
|
196
|
+
fd.write(chunk)
|
|
197
|
+
|
|
198
|
+
# unpack the files
|
|
199
|
+
print('unpacking')
|
|
200
|
+
zip_files = sorted(self.path.glob('*.zip'))
|
|
201
|
+
for zip_file in zip_files:
|
|
202
|
+
with zipfile.ZipFile(zip_file, 'r') as zip_ref:
|
|
203
|
+
zip_ref.extractall(self.path)
|
|
204
|
+
zip_file.unlink()
|
|
205
|
+
|
|
206
|
+
def __load_metadata(self, random_state, rumble_only):
|
|
207
|
+
metadata = pd.DataFrame()
|
|
208
|
+
for stem, prefetch in self.prefetch.items():
|
|
209
|
+
annotation_path = prefetch['annotation_path']
|
|
210
|
+
annotations = pd.read_csv(annotation_path, sep='\t')
|
|
211
|
+
if annotations.empty:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# extract elephant rumbles
|
|
215
|
+
file_metadata = Arden.__filter_annotations(annotations, stem)
|
|
216
|
+
|
|
217
|
+
# extract background noise if applicable
|
|
218
|
+
if not rumble_only:
|
|
219
|
+
noise_metadata = Arden.__extract_noise(annotations, stem)
|
|
220
|
+
file_metadata = pd.concat(
|
|
221
|
+
[file_metadata, noise_metadata],
|
|
222
|
+
ignore_index=True
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# filter rumbles and add to dataframe
|
|
226
|
+
file_metadata = Arden.__filter_clips(file_metadata)
|
|
227
|
+
metadata = pd.concat([metadata, file_metadata], ignore_index=True)
|
|
228
|
+
|
|
229
|
+
self.metadata = (
|
|
230
|
+
metadata
|
|
231
|
+
.sample(frac=1, random_state=random_state)
|
|
232
|
+
.reset_index(drop=True)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def __load_audio_features(self, random_state=None, sample_rate=2000):
|
|
236
|
+
random.seed(random_state)
|
|
237
|
+
clips = [None] * len(self)
|
|
238
|
+
features = [None] * len(self)
|
|
239
|
+
|
|
240
|
+
print('extracting features')
|
|
241
|
+
for stem, prefetch in tqdm(self.prefetch.items()):
|
|
242
|
+
# load in audio file
|
|
243
|
+
audio_file = prefetch['audio_path']
|
|
244
|
+
audio, _ = librosa.load(audio_file, sr=sample_rate)
|
|
245
|
+
|
|
246
|
+
df = self.metadata[self.metadata.file_stem == stem]
|
|
247
|
+
for index, row in df.iterrows():
|
|
248
|
+
start_time = row['Begin Time (s)']
|
|
249
|
+
end_time = row['End Time (s)']
|
|
250
|
+
|
|
251
|
+
clip, feature_set = Arden.__extract_clip_features(
|
|
252
|
+
audio,
|
|
253
|
+
sample_rate,
|
|
254
|
+
start_time,
|
|
255
|
+
end_time
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
clips[index] = clip
|
|
259
|
+
features[index] = feature_set
|
|
260
|
+
|
|
261
|
+
self._features = pd.DataFrame(features)
|
|
262
|
+
self._clips = clips
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def __filter_annotations(annotations, stem):
|
|
266
|
+
d = ['Selection', 'View', 'Channel', 'Low Freq (Hz)', 'High Freq (Hz)']
|
|
267
|
+
df = annotations.drop(columns=d)
|
|
268
|
+
df.insert(0, 'file_stem', [stem] * len(annotations))
|
|
269
|
+
df['duration'] = df['End Time (s)'] - df['Begin Time (s)']
|
|
270
|
+
return df
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def __extract_noise(rumble_annotations, stem):
|
|
274
|
+
# get ranges for all possible rumbles
|
|
275
|
+
rumble_event_ranges = list(zip(
|
|
276
|
+
rumble_annotations['Begin Time (s)'],
|
|
277
|
+
rumble_annotations['End Time (s)']
|
|
278
|
+
))
|
|
279
|
+
|
|
280
|
+
# get average duration of rumbles
|
|
281
|
+
rumble_deltas = [end - start for start, end in rumble_event_ranges]
|
|
282
|
+
rumble_delta_stats = np.mean(rumble_deltas), np.std(rumble_deltas)
|
|
283
|
+
|
|
284
|
+
# generate noise clips from regions without rumbles
|
|
285
|
+
noise_regions = Arden.__find_noise_regions(rumble_event_ranges)
|
|
286
|
+
clip_ranges = [
|
|
287
|
+
Arden.__random_clip_range(region, rumble_delta_stats)
|
|
288
|
+
for region in noise_regions
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
# create dataframe
|
|
292
|
+
return pd.DataFrame([
|
|
293
|
+
{
|
|
294
|
+
'file_stem': stem,
|
|
295
|
+
'call_type': 'BKG',
|
|
296
|
+
'Begin Time (s)': clip_start,
|
|
297
|
+
'End Time (s)': clip_end,
|
|
298
|
+
'quality': 0,
|
|
299
|
+
'overlap': 'N',
|
|
300
|
+
'earflap': 0,
|
|
301
|
+
'duration': clip_end - clip_start
|
|
302
|
+
}
|
|
303
|
+
for clip_start, clip_end in clip_ranges
|
|
304
|
+
])
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def __filter_clips(df):
|
|
308
|
+
df['earflap'] = pd.to_numeric(df['earflap'], errors='coerce')
|
|
309
|
+
return df[
|
|
310
|
+
(df['call_type'].isin(['RUM', 'BKG']))
|
|
311
|
+
& (df['earflap'].isin([0, 1]))
|
|
312
|
+
& (df['overlap'] == 'N')
|
|
313
|
+
& (df['quality'].isin([0, 2, 3, 4]))
|
|
314
|
+
& (df['duration'] > 2)
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def __find_noise_regions(rumble_ranges):
|
|
319
|
+
start_times = [x[0] for x in rumble_ranges]
|
|
320
|
+
end_times = [x[1] for x in rumble_ranges]
|
|
321
|
+
start_times.pop()
|
|
322
|
+
start_times.insert(0, 0.0)
|
|
323
|
+
return list(zip(start_times, end_times))
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
def __random_clip_range(boundary, delta_stats):
|
|
327
|
+
start_bound, end_bound = boundary
|
|
328
|
+
delta_mean, delta_std = delta_stats
|
|
329
|
+
center = (end_bound - start_bound) * random.random() + start_bound
|
|
330
|
+
clip_length = (
|
|
331
|
+
delta_mean
|
|
332
|
+
+ delta_std
|
|
333
|
+
* random.random()
|
|
334
|
+
* random.randrange(-1, 2, 2)
|
|
335
|
+
)
|
|
336
|
+
clip_start = max(start_bound, center - clip_length / 2)
|
|
337
|
+
clip_end = min(center + clip_length / 2, end_bound)
|
|
338
|
+
return clip_start, clip_end
|
|
339
|
+
|
|
340
|
+
@staticmethod
|
|
341
|
+
def __extract_clip_features(audio, sample_rate, start_time, end_time):
|
|
342
|
+
buffer = 0.2
|
|
343
|
+
start_sample = max(0, int((start_time - buffer) * sample_rate))
|
|
344
|
+
end_sample = int((end_time + buffer) * sample_rate)
|
|
345
|
+
clip = audio[start_sample:end_sample]
|
|
346
|
+
if len(clip) < sample_rate * 2:
|
|
347
|
+
return [], {}
|
|
348
|
+
preprocessed_clip = a3em.utils.preprocess(clip, sample_rate)
|
|
349
|
+
features = a3em.utils.extract_features(preprocessed_clip, sample_rate)
|
|
350
|
+
return clip, features
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import librosa
|
|
2
|
+
import numpy as np
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from scipy.signal import butter, sosfilt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def preprocess(audio: np.ndarray, sample_rate: int, normalization: float = 0.7):
|
|
9
|
+
# parameter validation
|
|
10
|
+
if normalization < 0.0 or normalization > 1.0:
|
|
11
|
+
raise ValueError('the normalization factor must be between 0.0 and 1.0')
|
|
12
|
+
|
|
13
|
+
# high pass filter
|
|
14
|
+
high_pass = butter(2, 15, btype='highpass', fs=sample_rate, output='sos')
|
|
15
|
+
audio = sosfilt(high_pass, audio)
|
|
16
|
+
|
|
17
|
+
# low pass filter
|
|
18
|
+
low_pass = butter(2, 200, btype='lowpass', fs=sample_rate, output='sos')
|
|
19
|
+
audio = sosfilt(low_pass, audio)
|
|
20
|
+
|
|
21
|
+
# normalize 70%
|
|
22
|
+
audio = audio / np.max(np.abs(audio)) * normalization
|
|
23
|
+
|
|
24
|
+
return audio
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_features(audio: np.ndarray, sample_rate: int) -> float:
|
|
28
|
+
# spectrograms
|
|
29
|
+
S = np.abs(librosa.stft(audio))**2
|
|
30
|
+
power_per_freq = S.mean(axis=1)
|
|
31
|
+
S_mel = librosa.feature.melspectrogram(y=audio, sr=sample_rate, n_mels=26)
|
|
32
|
+
|
|
33
|
+
#peak freq
|
|
34
|
+
freqs = librosa.fft_frequencies(sr=sample_rate)
|
|
35
|
+
freq_mask = (freqs > 15) & (freqs < 60)
|
|
36
|
+
peak_freq = freqs[freq_mask][np.argmax(power_per_freq[freq_mask])]
|
|
37
|
+
|
|
38
|
+
#centroid
|
|
39
|
+
centroid = librosa.feature.spectral_centroid(y=audio, sr=sample_rate)
|
|
40
|
+
mean_centroid = centroid.mean()
|
|
41
|
+
|
|
42
|
+
# bandwidth
|
|
43
|
+
bandwidth = librosa.feature.spectral_bandwidth(y=audio, sr=sample_rate)
|
|
44
|
+
mean_bandwidth = bandwidth.mean()
|
|
45
|
+
|
|
46
|
+
# 5% and 95% freqs
|
|
47
|
+
cumulative = np.cumsum(power_per_freq)
|
|
48
|
+
cumulative = cumulative /cumulative[-1]
|
|
49
|
+
freq_5 = freqs[np.searchsorted(cumulative, 0.05)]
|
|
50
|
+
freq_95 = freqs[np.searchsorted(cumulative, 0.95)]
|
|
51
|
+
|
|
52
|
+
# MFCCS
|
|
53
|
+
mfccs = librosa.feature.mfcc(y=audio, sr=sample_rate, n_mfcc=13)
|
|
54
|
+
mfcc_dict = {f'mfcc_{i+1}': mfccs[i].mean() for i in range(13)}
|
|
55
|
+
|
|
56
|
+
mel_means = S_mel.mean(axis=1)
|
|
57
|
+
mel_dict = {f'mel_mean_{i+1}': v for i, v in enumerate(mel_means)}
|
|
58
|
+
|
|
59
|
+
# TODO - spectral flatness
|
|
60
|
+
|
|
61
|
+
# Harmonic-to-noise ratio (HPSS approximation)
|
|
62
|
+
harmonic, percussive = librosa.effects.hpss(audio)
|
|
63
|
+
|
|
64
|
+
harmonic_energy = np.sum(harmonic ** 2)
|
|
65
|
+
noise_energy = np.sum(percussive ** 2)
|
|
66
|
+
|
|
67
|
+
hnr = 10 * np.log10(
|
|
68
|
+
(harmonic_energy + 1e-10) /
|
|
69
|
+
(noise_energy + 1e-10)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Low-frequency harmonic-to-noise ratio
|
|
73
|
+
low_pass = butter(2, 60, btype='lowpass', fs=sample_rate, output='sos')
|
|
74
|
+
low_audio = sosfilt(low_pass, audio)
|
|
75
|
+
|
|
76
|
+
harmonic, percussive = librosa.effects.hpss(low_audio)
|
|
77
|
+
|
|
78
|
+
harmonic_energy = np.sum(harmonic ** 2)
|
|
79
|
+
noise_energy = np.sum(percussive ** 2)
|
|
80
|
+
|
|
81
|
+
hnr_low = 10 * np.log10(
|
|
82
|
+
(harmonic_energy + 1e-10) /
|
|
83
|
+
(noise_energy + 1e-10)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
'peak_freq': peak_freq,
|
|
88
|
+
'centroid': mean_centroid,
|
|
89
|
+
'bandwidth': mean_bandwidth,
|
|
90
|
+
'freq_5': freq_5,
|
|
91
|
+
'freq_95': freq_95,
|
|
92
|
+
'hnr': hnr,
|
|
93
|
+
'hnr_low': hnr_low,
|
|
94
|
+
**mfcc_dict,
|
|
95
|
+
**mel_dict
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clean_directory(path: Path, base_path: bool = True):
|
|
100
|
+
contents = list(path.iterdir())
|
|
101
|
+
while len(contents) != 0:
|
|
102
|
+
if contents[0].is_dir():
|
|
103
|
+
clean_directory(contents[0], base_path=False)
|
|
104
|
+
else:
|
|
105
|
+
contents[0].unlink()
|
|
106
|
+
contents.pop(0)
|
|
107
|
+
if not base_path:
|
|
108
|
+
path.rmdir()
|