From a54aef95d2becbf6ae88f4fa9ce65677e2b12584 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 4 Feb 2025 14:32:28 -0500 Subject: [PATCH 01/43] initial nabat models/api script --- bats_ai/core/models/nabat/__init__.py | 7 + bats_ai/core/models/nabat/acoustic_batch.py | 99 +++++++ .../nabat/nabat_compressed_spectrogram.py | 112 ++++++++ .../core/models/nabat/nabat_spectrogram.py | 266 ++++++++++++++++++ scripts/.gitignore | 1 + scripts/USGS/naBatInfo.py | 83 ++++++ 6 files changed, 568 insertions(+) create mode 100644 bats_ai/core/models/nabat/__init__.py create mode 100644 bats_ai/core/models/nabat/acoustic_batch.py create mode 100644 bats_ai/core/models/nabat/nabat_compressed_spectrogram.py create mode 100644 bats_ai/core/models/nabat/nabat_spectrogram.py create mode 100644 scripts/.gitignore create mode 100644 scripts/USGS/naBatInfo.py diff --git a/bats_ai/core/models/nabat/__init__.py b/bats_ai/core/models/nabat/__init__.py new file mode 100644 index 00000000..3f1dd7dc --- /dev/null +++ b/bats_ai/core/models/nabat/__init__.py @@ -0,0 +1,7 @@ +from .acoustic_batch import AcousticBatch +from .nabat_spectrogram import NABatSpectrogram + +__all__ = [ + 'NABatSpectrogram', + 'AcousticBatch', +] diff --git a/bats_ai/core/models/nabat/acoustic_batch.py b/bats_ai/core/models/nabat/acoustic_batch.py new file mode 100644 index 00000000..bbfb2f89 --- /dev/null +++ b/bats_ai/core/models/nabat/acoustic_batch.py @@ -0,0 +1,99 @@ +import logging + +from django.contrib.gis.db import models +from django_extensions.db.models import TimeStampedModel + +from bats_ai.core.models import Species + +logger = logging.getLogger(__name__) + +COLORMAP = None + + +class colormap: + def __init__(self, colormap=None): + self.colormap = colormap + self.previous = None + + def __enter__(self): + global COLORMAP + + self.previous = COLORMAP + COLORMAP = self.colormap + + def __exit__(self, exc_type, exc_value, exc_tb): + global COLORMAP + + COLORMAP = self.previous + + +# TimeStampedModel also provides "created" and "modified" fields +class AcousticBatch(TimeStampedModel, models.Model): + name = models.CharField(max_length=255) + batch_id = models.BigIntegerField(blank=False, null=False) + equipment = models.TextField(blank=True, null=True) + comments = models.TextField(blank=True, null=True) + recording_location = models.GeometryField(srid=4326, blank=True, null=True) + grts_cell_id = models.IntegerField(blank=True, null=True) + grts_cell = models.IntegerField(blank=True, null=True) + public = models.BooleanField(default=False) + software_name = models.TextField(blank=True, null=True) + software_developer = models.TextField(blank=True, null=True) + software_version = models.TextField(blank=True, null=True) + detector = models.TextField(blank=True, null=True) + nabat_auto_species = models.ForeignKey(Species, null=True) + nabat_manual_species = models.ForeignKey(Species, null=True) + species_list = models.TextField(blank=True, null=True) + computed_species = models.ManyToManyField( + Species, related_name='recording_computed_species' + ) # species from a computed sense + official_species = models.ManyToManyField( + Species, related_name='recording_official_species' + ) # species that are detemrined by the owner or from annotations as official species list + unusual_occurrences = models.TextField(blank=True, null=True) + + @property + def has_spectrogram(self): + return len(self.spectrograms) > 0 + + @property + def spectrograms(self): + from bats_ai.core.models.nabat import NABatSpectrogram + + query = NABatSpectrogram.objects.filter(acoustic_batch=self, colormap=COLORMAP).order_by( + '-created' + ) + return query.all() + + @property + def spectrogram(self): + pass + + spectrograms = self.spectrograms + + assert len(spectrograms) >= 1 + spectrogram = spectrograms[0] # most recently created + + return spectrogram + + @property + def has_compressed_spectrogram(self): + return len(self.compressed_spectrograms) > 0 + + @property + def compressed_spectrograms(self): + from bats_ai.core.models import CompressedSpectrogram + + query = CompressedSpectrogram.objects.filter(acoustic_batch=self).order_by('-created') + return query.all() + + @property + def compressed_spectrogram(self): + pass + + compressed_spectrograms = self.compressed_spectrograms + + assert len(compressed_spectrograms) >= 1 + spectrogram = compressed_spectrograms[0] # most recently created + + return spectrogram diff --git a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py new file mode 100644 index 00000000..7ea9c8b8 --- /dev/null +++ b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py @@ -0,0 +1,112 @@ +from PIL import Image +import cv2 +from django.contrib.postgres.fields import ArrayField +from django.core.files.storage import default_storage +from django.db import models +from django.dispatch import receiver +from django_extensions.db.models import TimeStampedModel +import numpy as np + +from .acoustic_batch import AcousticBatch +from .nabat_spectrogram import NABatSpectrogram + +FREQ_MIN = 5e3 +FREQ_MAX = 120e3 +FREQ_PAD = 2e3 + + +# TimeStampedModel also provides "created" and "modified" fields +class NABatCompressedSpectrogram(TimeStampedModel, models.Model): + acoustic_batch = models.ForeignKey(AcousticBatch, on_delete=models.CASCADE) + spectrogram = models.ForeignKey(NABatSpectrogram, on_delete=models.CASCADE) + image_file = models.FileField() + length = models.IntegerField() + starts = ArrayField(ArrayField(models.IntegerField())) + stops = ArrayField(ArrayField(models.IntegerField())) + widths = ArrayField(ArrayField(models.IntegerField())) + cache_invalidated = models.BooleanField(default=True) + + @property + def image_url(self): + return default_storage.url(self.image_file.name) + + def predict(self): + import json + import os + + import onnx + import onnxruntime as ort + import tqdm + + img = Image.open(self.image_file) + + relative = ('..',) * 4 + asset_path = os.path.abspath(os.path.join(__file__, *relative, 'assets')) + + onnx_filename = os.path.join(asset_path, 'model.mobilenet.onnx') + assert os.path.exists(onnx_filename) + + session = ort.InferenceSession( + onnx_filename, + providers=[ + ( + 'CUDAExecutionProvider', + { + 'cudnn_conv_use_max_workspace': '1', + 'device_id': 0, + 'cudnn_conv_algo_search': 'HEURISTIC', + }, + ), + 'CPUExecutionProvider', + ], + ) + + img = np.array(img) + + h, w, c = img.shape + ratio_y = 224 / h + ratio_x = ratio_y * 0.5 + raw = cv2.resize(img, None, fx=ratio_x, fy=ratio_y, interpolation=cv2.INTER_LANCZOS4) + + h, w, c = raw.shape + if w <= h: + canvas = np.zeros((h, h + 1, 3), dtype=raw.dtype) + canvas[:, :w, :] = raw + raw = canvas + h, w, c = raw.shape + + inputs_ = [] + for index in range(0, w - h, 100): + inputs_.append(raw[:, index : index + h, :]) + inputs_.append(raw[:, -h:, :]) + inputs_ = np.array(inputs_) + + chunksize = 1 + chunks = np.array_split(inputs_, np.arange(chunksize, len(inputs_), chunksize)) + outputs = [] + for chunk in tqdm.tqdm(chunks, desc='Inference'): + outputs_ = session.run( + None, + {'input': chunk}, + ) + outputs.append(outputs_[0]) + outputs = np.vstack(outputs) + outputs = outputs.mean(axis=0) + + model = onnx.load(onnx_filename) + mapping = json.loads(model.metadata_props[0].value) + labels = [mapping['forward'][str(index)] for index in range(len(mapping['forward']))] + + prediction = np.argmax(outputs) + label = labels[prediction] + score = outputs[prediction] + + confs = dict(zip(labels, outputs)) + + return label, score, confs + + +@receiver(models.signals.pre_delete, sender=NABatSpectrogram) +def delete_content(sender, instance, **kwargs): + if instance.image_file: + instance.image_file.delete(save=False) diff --git a/bats_ai/core/models/nabat/nabat_spectrogram.py b/bats_ai/core/models/nabat/nabat_spectrogram.py new file mode 100644 index 00000000..b1efeb46 --- /dev/null +++ b/bats_ai/core/models/nabat/nabat_spectrogram.py @@ -0,0 +1,266 @@ +import base64 +import io +import logging +import math + +from PIL import Image +import cv2 +from django.core.files import File +from django.core.files.storage import default_storage +from django.db import models +from django.db.models.fields.files import FieldFile +from django.dispatch import receiver +from django_extensions.db.models import TimeStampedModel +import librosa +import matplotlib.pyplot as plt +import numpy as np +import tqdm + +from .acoustic_batch import AcousticBatch + +logger = logging.getLogger(__name__) + +FREQ_MIN = 5e3 +FREQ_MAX = 120e3 +FREQ_PAD = 2e3 + +COLORMAP_ALLOWED = [None, 'gist_yarg', 'turbo'] + + +# TimeStampedModel also provides "created" and "modified" fields +class NABatSpectrogram(TimeStampedModel, models.Model): + acoustic_batch = models.ForeignKey(AcousticBatch, on_delete=models.CASCADE) + image_file = models.FileField() + width = models.IntegerField() # pixels + height = models.IntegerField() # pixels + duration = models.IntegerField() # milliseconds + frequency_min = models.IntegerField() # hz + frequency_max = models.IntegerField() # hz + colormap = models.CharField(max_length=20, blank=False, null=True) + + @classmethod + def generate(cls, recording, colormap=None, dpi=520): + """ + Ref: https://matplotlib.org/stable/users/explain/colors/colormaps.html + """ + wav_file = recording.audio_file + try: + if isinstance(wav_file, FieldFile): + sig, sr = librosa.load(io.BytesIO(wav_file.read()), sr=None) + wav_file.name + else: + sig, sr = librosa.load(wav_file, sr=None) + + duration = len(sig) / sr + except Exception as e: + print(e) + return None + + # Helpful aliases + size_mod = 1 + high_res = False + inference = False + + if colormap in ['inference']: + colormap = None + dpi = 300 + size_mod = 0 + inference = True + if colormap in ['none', 'default', 'dark']: + colormap = None + if colormap in ['light']: + colormap = 'gist_yarg' + if colormap in ['heatmap']: + colormap = 'turbo' + high_res = True + + # Supported colormaps + if colormap not in COLORMAP_ALLOWED: + logger.warning(f'Substituted requested {colormap} colormap to default') + logger.warning('See COLORMAP_ALLOWED') + colormap = None + + # Function to take a signal and return a spectrogram. + size = int(0.001 * sr) # 1.0ms resolution + size = 2 ** (math.ceil(math.log(size, 2)) + size_mod) + hop_length = int(size / 4) + + # Short-time Fourier Transform + window = librosa.stft(sig, n_fft=size, hop_length=hop_length, window='hamming') + + # Calculating and processing data for the spectrogram. + window = np.abs(window) ** 2 + window = librosa.power_to_db(window) + + # Denoise spectrogram + # Subtract median frequency + window -= np.median(window, axis=1, keepdims=True) + + # Subtract mean amplitude + window_ = window[window > 0] + thresh = np.median(window_) + window[window <= thresh] = 0 + + bands = librosa.fft_frequencies(sr=sr, n_fft=size) + for index in range(len(bands)): + band_min = bands[index] + band_max = bands[index + 1] if index < len(bands) - 1 else np.inf + if band_max <= FREQ_MIN or FREQ_MAX <= band_min: + window[index, :] = -1 + + window = np.clip(window, 0, None) + + freq_low = int(FREQ_MIN - FREQ_PAD) + freq_high = int(FREQ_MAX + FREQ_PAD) + vmin = window.min() + vmax = window.max() + + chunksize = int(2e3) + arange = np.arange(chunksize, window.shape[1], chunksize) + chunks = np.array_split(window, arange, axis=1) + + imgs = [] + for chunk in tqdm.tqdm(chunks): + h, w = chunk.shape + alpha = 3 + figsize = (int(math.ceil(w / h)) * alpha + 1, alpha) + fig = plt.figure(figsize=figsize, facecolor='black', dpi=dpi) + ax = plt.axes() + plt.margins(0) + + kwargs = { + 'sr': sr, + 'n_fft': size, + 'hop_length': hop_length, + 'x_axis': 's', + 'y_axis': 'fft', + 'ax': ax, + 'vmin': vmin, + 'vmax': vmax, + } + + # Plot + if colormap is None: + librosa.display.specshow(chunk, **kwargs) + else: + librosa.display.specshow(chunk, cmap=colormap, **kwargs) + + ax.set_ylim(freq_low, freq_high) + ax.axis('off') + + buf = io.BytesIO() + fig.savefig(buf, bbox_inches='tight', pad_inches=0) + + fig.clf() + plt.close() + + buf.seek(0) + img = Image.open(buf) + + img = np.array(img) + mask = img[:, :, -1] + flags = np.where(np.sum(mask != 0, axis=0) == 0)[0] + index = flags.min() if len(flags) > 0 else img.shape[1] + img = img[:, :index, :3] + + imgs.append(img) + + if inference: + w_ = int(4.0 * duration * 1e3) + h_ = int(dpi) + else: + w_ = int(8.0 * duration * 1e3) + h_ = 1200 + + img = np.hstack(imgs) + img = cv2.resize(img, (w_, h_), interpolation=cv2.INTER_LANCZOS4) + + if high_res: + img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + + noise = 0.1 + img = img.astype(np.float32) + img -= img.min() + img /= img.max() + img *= 255.0 + img /= 1.0 - noise + img[img < 0] = 0 + img[img > 255] = 255 + img = 255.0 - img # invert + + img = cv2.blur(img, (9, 9)) + img = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LANCZOS4) + img = cv2.blur(img, (9, 9)) + + img -= img.min() + img /= img.max() + img *= 255.0 + + mask = (img > 255 * noise).astype(np.float32) + mask = cv2.blur(mask, (5, 5)) + + img[img < 0] = 0 + img[img > 255] = 255 + img = np.around(img).astype(np.uint8) + img = cv2.applyColorMap(img, cv2.COLORMAP_TURBO) + + img = img.astype(np.float32) + img *= mask.reshape(*mask.shape, 1) + img[img < 0] = 0 + img[img > 255] = 255 + img = np.around(img).astype(np.uint8) + + # cv2.imwrite('temp.png', img) + + img = Image.fromarray(img, 'RGB') + w, h = img.size + + buf = io.BytesIO() + img.save(buf, format='JPEG', quality=80) + buf.seek(0) + + name = f'{recording.pk}_{colormap}_spectrogram.jpg' + image_file = File(buf, name=name) + + spectrogram = cls( + recording_id=recording.pk, + image_file=image_file, + width=w, + height=h, + duration=math.ceil(duration * 1e3), + frequency_min=freq_low, + frequency_max=freq_high, + colormap=colormap, + ) + spectrogram.save() + return spectrogram.pk + + @property + def image_np(self): + return np.array(self.image) + + @property + def image_pil(self): + return self.image + + @property + def image(self): + img = Image.open(self.image_file) + return img + + @property + def base64(self): + img = self.image_file.read() + img_base64 = base64.b64encode(img).decode('utf-8') + + return img_base64 + + @property + def image_url(self): + return default_storage.url(self.image_file.name) + + +@receiver(models.signals.pre_delete, sender=NABatSpectrogram) +def delete_content(sender, instance, **kwargs): + if instance.image_file: + instance.image_file.delete(save=False) diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 00000000..0fc0351c --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +/**/*.json diff --git a/scripts/USGS/naBatInfo.py b/scripts/USGS/naBatInfo.py new file mode 100644 index 00000000..83f3e690 --- /dev/null +++ b/scripts/USGS/naBatInfo.py @@ -0,0 +1,83 @@ +import json + +import click +import requests + +# Global variable for authorization token +AUTH_TOKEN = '' +BASE_URL = 'https://api.sciencebase.gov/nabat-graphql/graphql' +QUERY = """ +query batsAIAcousticInfoByFileBatchId { + acousticFileBatchById(id: \"319479412\") { + batchId + acousticBatchByBatchId { + softwareBySoftwareId { + developer + name + versionNumber + } + classifierByClassifierId { + createdDate + description + name + public + speciesClassifiersByClassifierId { + nodes { + speciesBySpeciesId { + speciesCode + } + } + } + } + surveyEventBySurveyEventId { + createdBy + createdDate + eventGeometryByEventGeometryId { + description + geom { + geojson + } + } + } + createdDate + id + } + acousticFileByFileId { + fileName + recordingTime + s3Verified + sizeBytes + } + manualId + recordingNight + speciesByAutoId { + id + speciesCode + } + speciesByManualId { + id + speciesCode + } + autoId + } +} +""" + + +@click.command() +def fetch_and_save(): + """Fetch data using GraphQL and save to output.json""" + headers = {'Authorization': f'Bearer {AUTH_TOKEN}', 'Content-Type': 'application/json'} + response = requests.post(BASE_URL, json={'query': QUERY}, headers=headers) + + if response.status_code == 200: + data = response.json() + with open('output.json', 'w') as f: + json.dump(data, f, indent=2) + click.echo('Data successfully fetched and saved to output.json') + else: + click.echo(f'Failed to fetch data: {response.status_code}, {response.text}') + + +if __name__ == '__main__': + fetch_and_save() From d8440c2a80c85b13a647b8097ed1cd56bf57e732 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 6 Feb 2025 10:52:34 -0500 Subject: [PATCH 02/43] model updates and reset endpoints --- bats_ai/core/models/nabat/__init__.py | 4 + bats_ai/core/models/nabat/acoustic_batch.py | 2 +- .../models/nabat/acoustic_batch_annotation.py | 25 ++++ bats_ai/core/rest/nabat/__init__.py | 13 ++ .../nabat/nabat_compressed_spectrogram.py | 21 +++ bats_ai/core/rest/nabat/nabat_spectrogram.py | 21 +++ bats_ai/core/views/nabat/__init__.py | 1 + bats_ai/core/views/nabat/acoustic_batch.py | 139 ++++++++++++++++++ bats_ai/core/views/recording.py | 2 +- 9 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 bats_ai/core/models/nabat/acoustic_batch_annotation.py create mode 100644 bats_ai/core/rest/nabat/__init__.py create mode 100644 bats_ai/core/rest/nabat/nabat_compressed_spectrogram.py create mode 100644 bats_ai/core/rest/nabat/nabat_spectrogram.py create mode 100644 bats_ai/core/views/nabat/__init__.py create mode 100644 bats_ai/core/views/nabat/acoustic_batch.py diff --git a/bats_ai/core/models/nabat/__init__.py b/bats_ai/core/models/nabat/__init__.py index 3f1dd7dc..6ec88389 100644 --- a/bats_ai/core/models/nabat/__init__.py +++ b/bats_ai/core/models/nabat/__init__.py @@ -1,7 +1,11 @@ from .acoustic_batch import AcousticBatch +from .acoustic_batch_annotation import AcousticBatchAnnotation +from .nabat_compressed_spectrogram import NABatCompressedSpectrogram from .nabat_spectrogram import NABatSpectrogram __all__ = [ 'NABatSpectrogram', + 'NABatCompressedSpectrogram', 'AcousticBatch', + 'AcousticBatchAnnotation', ] diff --git a/bats_ai/core/models/nabat/acoustic_batch.py b/bats_ai/core/models/nabat/acoustic_batch.py index bbfb2f89..582da62f 100644 --- a/bats_ai/core/models/nabat/acoustic_batch.py +++ b/bats_ai/core/models/nabat/acoustic_batch.py @@ -30,7 +30,7 @@ def __exit__(self, exc_type, exc_value, exc_tb): # TimeStampedModel also provides "created" and "modified" fields class AcousticBatch(TimeStampedModel, models.Model): name = models.CharField(max_length=255) - batch_id = models.BigIntegerField(blank=False, null=False) + batch_id = models.BigIntegerField(blank=False, null=False, unique=True) equipment = models.TextField(blank=True, null=True) comments = models.TextField(blank=True, null=True) recording_location = models.GeometryField(srid=4326, blank=True, null=True) diff --git a/bats_ai/core/models/nabat/acoustic_batch_annotation.py b/bats_ai/core/models/nabat/acoustic_batch_annotation.py new file mode 100644 index 00000000..fc5f753a --- /dev/null +++ b/bats_ai/core/models/nabat/acoustic_batch_annotation.py @@ -0,0 +1,25 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django_extensions.db.models import TimeStampedModel + +from bats_ai.core.models import Species + +from .acoustic_batch import AcousticBatch + + +class AcousticBatchAnnotation(TimeStampedModel, models.Model): + acoustic_batch = models.ForeignKey(AcousticBatch, on_delete=models.CASCADE) + species = models.ManyToManyField(Species) + comments = models.TextField(blank=True, null=True) + model = models.TextField(blank=True, null=True) # AI Model information if inference used + confidence = models.FloatField( + default=1.0, + validators=[ + MinValueValidator(0.0), + MaxValueValidator(1.0), + ], + help_text='A confidence value between 0 and 1.0, default is 1.0.', + ) + additional_data = models.JSONField( + blank=True, null=True, help_text='Additional information about the models/data' + ) diff --git a/bats_ai/core/rest/nabat/__init__.py b/bats_ai/core/rest/nabat/__init__.py new file mode 100644 index 00000000..b5f0edd6 --- /dev/null +++ b/bats_ai/core/rest/nabat/__init__.py @@ -0,0 +1,13 @@ +from rest_framework import routers + +from .nabat_compressed_spectrogram import NaBatCompressedSpectrogramViewSet +from .nabat_spectrogram import NABatSpectrogramViewSet + +__all__ = [ + 'SpectrogramViewSet', + 'CompressedSpectrogramViewSet', +] + +rest = routers.SimpleRouter() +rest.register(r'nabat/spectrograms', NABatSpectrogramViewSet) +rest.register(r'nabat/compressed_spectrograms', NaBatCompressedSpectrogramViewSet) diff --git a/bats_ai/core/rest/nabat/nabat_compressed_spectrogram.py b/bats_ai/core/rest/nabat/nabat_compressed_spectrogram.py new file mode 100644 index 00000000..5882b240 --- /dev/null +++ b/bats_ai/core/rest/nabat/nabat_compressed_spectrogram.py @@ -0,0 +1,21 @@ +from django_large_image.rest import LargeImageFileDetailMixin +from rest_framework import mixins, serializers, viewsets + +from bats_ai.core.models.nabat import NABatCompressedSpectrogram + + +class NABatCompressedSpectrogramSerializer(serializers.ModelSerializer): + class Meta: + model = NABatCompressedSpectrogram + fields = '__all__' + + +class NaBatCompressedSpectrogramViewSet( + mixins.ListModelMixin, + viewsets.GenericViewSet, + LargeImageFileDetailMixin, +): + queryset = NABatCompressedSpectrogram.objects.all() + serializer_class = NABatCompressedSpectrogramSerializer + + FILE_FIELD_NAME = 'image_file' diff --git a/bats_ai/core/rest/nabat/nabat_spectrogram.py b/bats_ai/core/rest/nabat/nabat_spectrogram.py new file mode 100644 index 00000000..65fc8def --- /dev/null +++ b/bats_ai/core/rest/nabat/nabat_spectrogram.py @@ -0,0 +1,21 @@ +from django_large_image.rest import LargeImageFileDetailMixin +from rest_framework import mixins, serializers, viewsets + +from bats_ai.core.models.nabat import NABatSpectrogram + + +class NABatSpectrogramSerializer(serializers.ModelSerializer): + class Meta: + model = NABatSpectrogram + fields = '__all__' + + +class NABatSpectrogramViewSet( + mixins.ListModelMixin, + viewsets.GenericViewSet, + LargeImageFileDetailMixin, +): + queryset = NABatSpectrogram.objects.all() + serializer_class = NABatSpectrogramSerializer + + FILE_FIELD_NAME = 'image_file' diff --git a/bats_ai/core/views/nabat/__init__.py b/bats_ai/core/views/nabat/__init__.py new file mode 100644 index 00000000..a9a2c5b3 --- /dev/null +++ b/bats_ai/core/views/nabat/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/bats_ai/core/views/nabat/acoustic_batch.py b/bats_ai/core/views/nabat/acoustic_batch.py new file mode 100644 index 00000000..a6c1f4dd --- /dev/null +++ b/bats_ai/core/views/nabat/acoustic_batch.py @@ -0,0 +1,139 @@ +import logging + +from django.http import HttpRequest +from ninja import Form, Schema +from ninja.pagination import RouterPaginated + +from bats_ai.core.models import colormap +from bats_ai.core.models.nabat import ( + AcousticBatch, + AcousticBatchAnnotation, + NABatCompressedSpectrogram, +) +from bats_ai.core.views.species import SpeciesSchema + +logger = logging.getLogger(__name__) + + +router = RouterPaginated() + + +class AcousticBatchSchema(Schema): + name: str + batch_id: int + recorded_date: str | None + equipment: str | None + comments: str | None + recording_location: str | None + grts_cell_id: int | None + grts_cell: int | None + + +class AcousticBatchGenerateSchema(Schema): + apiToken: str + batchId: int + + +class AcousticBatchAnnotationSchema(Schema): + species: list[SpeciesSchema] | None + comments: str | None = None + model: str | None = None + owner: str + confidence: float + id: int | None = None + + @classmethod + def from_orm(cls, obj: AcousticBatchAnnotation, **kwargs): + return cls( + species=[SpeciesSchema.from_orm(species) for species in obj.species.all()], + owner=obj.owner.username, + confidence=obj.confidence, + comments=obj.comments, + model=obj.model, + id=obj.pk, + ) + + +@router.post('/') +def generate_acoustic_batch( + request: HttpRequest, + payload: Form[AcousticBatchGenerateSchema], +): + acoustic_batch = AcousticBatch.objects.filter(batch_id=payload.batchId) + if not acoustic_batch.exists(): + # use a task to start downloading the file using the API key and generate the spectrograms + return {'taskId': 'TODO:TASKID'} + + return get_acoustic_batch_spectrogram(request, payload.batchId) + + +@router.get('/') +def get_acoustic_batch_spectrogram(request: HttpRequest, id: int): + try: + acoustic_batch = AcousticBatch.objects.get(pk=id) + except AcousticBatch.DoesNotExist: + return {'error': 'AcousticBatch not found'} + + with colormap(None): + spectrogram = acoustic_batch.spectrogram + + compressed = acoustic_batch.compressed_spectrogram + + spectro_data = { + 'url': spectrogram.image_url, + 'spectroInfo': { + 'spectroId': spectrogram.pk, + 'width': spectrogram.width, + 'height': spectrogram.height, + 'start_time': 0, + 'end_time': spectrogram.duration, + 'low_freq': spectrogram.frequency_min, + 'high_freq': spectrogram.frequency_max, + }, + } + if compressed: + spectro_data['compressed'] = { + 'start_times': compressed.starts, + 'end_times': compressed.stops, + } + + # Pulse and Sequence Annotations may be implemented in the future + spectro_data['annotations'] = [] + spectro_data['temporal'] = [] + return spectro_data + + +@router.get('/{acoustc_batch_id}/recording-annotations') +def get_acoustic_batch_annotation(request: HttpRequest, acoustic_batch_id: int): + fileAnnotations = AcousticBatchAnnotation.objects.filter( + acoustic_batch=acoustic_batch_id + ).order_by('confidence') + output = [ + AcousticBatchAnnotationSchema.from_orm(fileAnnotation).dict() + for fileAnnotation in fileAnnotations + ] + return output + + +@router.post('/{id}/spectrogram/compressed/predict') +def predict_spectrogram_compressed(request: HttpRequest, id: int): + try: + recording = AcousticBatch.objects.get(pk=id) + compressed_spectrogram = NABatCompressedSpectrogram.objects.filter( + acoustic_batch=id + ).first() + except compressed_spectrogram.DoesNotExist: + return {'error': 'Compressed Spectrogram'} + except recording.DoesNotExist: + return {'error': 'Recording does not exist'} + + label, score, confs = compressed_spectrogram.predict() + confidences = [] + confidences = [{'label': key, 'value': float(value)} for key, value in confs.items()] + sorted_confidences = sorted(confidences, key=lambda x: x['value'], reverse=True) + output = { + 'label': label, + 'score': float(score), + 'confidences': sorted_confidences, + } + return output diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 3f0adb7e..0a0b6579 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -841,7 +841,7 @@ def delete_temporal_annotation(request, recording_id: int, id: int): # TODO - this may be modified to use different models in the @router.post('/{id}/spectrogram/compressed/predict') -def precit_spectrogram_compressed(request: HttpRequest, id: int): +def predict_spectrogram_compressed(request: HttpRequest, id: int): try: recording = Recording.objects.get(pk=id) compressed_spectrogram = CompressedSpectrogram.objects.filter(recording=id).first() From 760acc9986bbc52f6bb6d8b2867805b2cdb06229 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 7 Feb 2025 09:29:06 -0500 Subject: [PATCH 03/43] outlining tasks --- bats_ai/core/admin/image.py | 2 +- bats_ai/core/admin/recording.py | 2 +- bats_ai/core/views/recording.py | 2 +- bats_ai/tasks/nabat/tasks.py | 235 +++++++++++++++++++++++++++++++ bats_ai/{core => tasks}/tasks.py | 0 5 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 bats_ai/tasks/nabat/tasks.py rename bats_ai/{core => tasks}/tasks.py (100%) diff --git a/bats_ai/core/admin/image.py b/bats_ai/core/admin/image.py index 2aee474b..2f06989a 100644 --- a/bats_ai/core/admin/image.py +++ b/bats_ai/core/admin/image.py @@ -3,7 +3,7 @@ from django.http import HttpRequest from bats_ai.core.models import Image -from bats_ai.core.tasks import image_compute_checksum +from bats_ai.tasks.tasks import image_compute_checksum @admin.register(Image) diff --git a/bats_ai/core/admin/recording.py b/bats_ai/core/admin/recording.py index 36bec2ec..2da5f82d 100644 --- a/bats_ai/core/admin/recording.py +++ b/bats_ai/core/admin/recording.py @@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe from bats_ai.core.models import Recording -from bats_ai.core.tasks import recording_compute_spectrogram +from bats_ai.tasks.tasks import recording_compute_spectrogram @admin.register(Recording) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 0a0b6579..3e591fd4 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -20,12 +20,12 @@ TemporalAnnotations, colormap, ) -from bats_ai.core.tasks import recording_compute_spectrogram from bats_ai.core.views.species import SpeciesSchema from bats_ai.core.views.temporal_annotations import ( TemporalAnnotationSchema, UpdateTemporalAnnotationSchema, ) +from bats_ai.tasks.tasks import recording_compute_spectrogram logger = logging.getLogger(__name__) diff --git a/bats_ai/tasks/nabat/tasks.py b/bats_ai/tasks/nabat/tasks.py new file mode 100644 index 00000000..27b6d11e --- /dev/null +++ b/bats_ai/tasks/nabat/tasks.py @@ -0,0 +1,235 @@ +import io +import tempfile + +from PIL import Image +from celery import shared_task +import cv2 +from django.core.files import File +import numpy as np +import scipy + +from bats_ai.core.models import Species +from bats_ai.core.models.nabat import ( + AcousticBatch, + AcousticBatchAnnotation, + NABatCompressedSpectrogram, + NABatSpectrogram, +) + + +def generate_compressed(spectrogram: NABatSpectrogram): + img = spectrogram.image_np + + threshold = 0.5 + while True: + canvas = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + canvas = canvas.astype(np.float32) + + is_light = np.median(canvas) > 128.0 + if is_light: + canvas = 255.0 - canvas + + amplitude = canvas.max(axis=0) + amplitude -= amplitude.min() + amplitude /= amplitude.max() + amplitude[amplitude < threshold] = 0.0 + amplitude[amplitude > 0] = 1.0 + amplitude = amplitude.reshape(1, -1) + + canvas -= canvas.min() + canvas /= canvas.max() + canvas *= 255.0 + canvas *= amplitude + canvas = np.around(canvas).astype(np.uint8) + + mask = canvas.max(axis=0) + mask = scipy.signal.medfilt(mask, 3) + mask[0] = 0 + mask[-1] = 0 + + starts = [] + stops = [] + for index in range(1, len(mask) - 1): + value_pre = mask[index - 1] + value = mask[index] + value_post = mask[index + 1] + if value != 0: + if value_pre == 0: + starts.append(index) + if value_post == 0: + stops.append(index) + assert len(starts) == len(stops) + + starts = [val - 40 for val in starts] # 10 ms buffer + stops = [val + 40 for val in stops] # 10 ms buffer + ranges = list(zip(starts, stops)) + + while True: + found = False + merged = [] + index = 0 + while index < len(ranges) - 1: + start1, stop1 = ranges[index] + start2, stop2 = ranges[index + 1] + + start1 = min(max(start1, 0), len(mask)) + start2 = min(max(start2, 0), len(mask)) + stop1 = min(max(stop1, 0), len(mask)) + stop2 = min(max(stop2, 0), len(mask)) + + if stop1 >= start2: + found = True + merged.append((start1, stop2)) + index += 2 + else: + merged.append((start1, stop1)) + index += 1 + if index == len(ranges) - 1: + merged.append((start2, stop2)) + ranges = merged + if not found: + for index in range(1, len(ranges)): + start1, stop1 = ranges[index - 1] + start2, stop2 = ranges[index] + assert start1 < stop1 + assert start2 < stop2 + assert start1 < start2 + assert stop1 < stop2 + assert stop1 < start2 + break + + segments = [] + starts_ = [] + stops_ = [] + domain = img.shape[1] + widths = [] + total_width = 0 + for start, stop in ranges: + segment = img[:, start:stop] + segments.append(segment) + + starts_.append(int(round(spectrogram.duration * (start / domain)))) + stops_.append(int(round(spectrogram.duration * (stop / domain)))) + widths.append(stop - start) + total_width += stop - start + + # buffer = np.zeros((len(img), 20, 3), dtype=img.dtype) + # segments.append(buffer) + # segments = segments[:-1] + + if len(segments) > 0: + break + + threshold -= 0.05 + if threshold < 0: + segments = None + break + + if segments is None: + canvas = img.copy() + else: + canvas = np.hstack(segments) + + canvas = Image.fromarray(canvas, 'RGB') + buf = io.BytesIO() + canvas.save(buf, format='JPEG', quality=80) + buf.seek(0) + + # Use temporary files + with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as temp_file: + temp_file_name = temp_file.name + canvas.save(temp_file_name) + + # Read the temporary file + with open(temp_file_name, 'rb') as f: + temp_file_content = f.read() + + # Wrap the content in BytesIO + buf = io.BytesIO(temp_file_content) + name = f'{spectrogram.pk}_spectrogram_compressed.jpg' + image_file = File(buf, name=name) + + return total_width, image_file, widths, starts_, stops_ + + +@shared_task +def generate_compress_spectrogram(acoustic_batch_id: int, spectrogram_id: int): + acoustic_batch = AcousticBatch.objects.get(pk=acoustic_batch_id) + spectrogram = NABatSpectrogram.objects.get(pk=spectrogram_id) + length, image_file, widths, starts, stops = generate_compressed(spectrogram) + found = NABatCompressedSpectrogram.objects.filter( + acoustic_batch=acoustic_batch_id, spectrogram=spectrogram + ) + if found.exists(): + existing = found.first() + existing.length = length + existing.image_file = image_file + existing.widths = widths + existing.starts = starts + existing.stops = stops + existing.cache_invalidated = False + existing.save() + else: + existing = NABatCompressedSpectrogram.objects.create( + acoustic_batch=acoustic_batch, + spectrogram=spectrogram, + image_file=image_file, + length=length, + widths=widths, + starts=starts, + stops=stops, + cache_invalidated=False, + ) + return existing + + +@shared_task +def acousting_batch_initialize(batch_id: int, api_token: str): + print(batch_id) + # Need to get the information from the server using the api_token and the batch_id + # 1. Use the batch_id and api_token to gather information about the AcousticBatch using graphQL endpoint + # 2. Create the AcousticBatch object, using the batch_id and values from the server + # 3. Use an additional GraphQL query to get the S3 Pre-Signed file URL for the recording object + # 4. Download the files from S3 and use it to convert into a spectrogram that can be saved to the system + # 5. Use the spectrogram to generate a compressed spectrogram + # 6. Use the compressed spectrogram to predict the species + + # Eventual compressed generation + # cmaps = [ + # None, # Default (dark) spectrogram + # 'light', # Light spectrogram + # ] + # spectrogram_id = None + # for cmap in cmaps: + # with colormap(cmap): + # spectrogram_id_temp = NABatSpectrogram.generate(acoustic_batch, cmap) + # if cmap is None: + # spectrogram_id = spectrogram_id_temp + # if spectrogram_id is not None: + # compressed_spectro = generate_compress_spectrogram(spectrogram_id) + # predict(compressed_spectro.pk) + + +@shared_task +def predict(compressed_spectrogram_id: int): + compressed_spectrogram = NABatCompressedSpectrogram.objects.get(pk=compressed_spectrogram_id) + label, score, confs = compressed_spectrogram.predict() + confidences = [{'label': key, 'value': float(value)} for key, value in confs.items()] + sorted_confidences = sorted(confidences, key=lambda x: x['value'], reverse=True) + output = { + 'label': label, + 'score': float(score), + 'confidences': sorted_confidences, + } + species = Species.objects.filter(species_code=label) + + acoustic_batch_annotation = AcousticBatchAnnotation.objects.create( + recording=compressed_spectrogram.recording, + comments='Compressed Spectrogram Generation Prediction', + model='model.mobilenet.onnx', + confidence=output['score'], + additional_data=output, + ) + acoustic_batch_annotation.species.set(species) + acoustic_batch_annotation.save() + return label, score, confs diff --git a/bats_ai/core/tasks.py b/bats_ai/tasks/tasks.py similarity index 100% rename from bats_ai/core/tasks.py rename to bats_ai/tasks/tasks.py From da7d35c4a696fb22aa1b3625eabefca6ecd438d1 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 11 Feb 2025 10:20:27 -0500 Subject: [PATCH 04/43] nabat retrieval logic --- .../core/models/nabat/nabat_spectrogram.py | 205 ----------------- bats_ai/tasks/nabat/nabat_data_retrieval.py | 205 +++++++++++++++++ bats_ai/tasks/nabat/tasks.py | 208 +++++++++++++++--- scripts/USGS/naBatInfo.py | 85 ++++++- 4 files changed, 465 insertions(+), 238 deletions(-) create mode 100644 bats_ai/tasks/nabat/nabat_data_retrieval.py diff --git a/bats_ai/core/models/nabat/nabat_spectrogram.py b/bats_ai/core/models/nabat/nabat_spectrogram.py index b1efeb46..b31c76ef 100644 --- a/bats_ai/core/models/nabat/nabat_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_spectrogram.py @@ -1,20 +1,12 @@ import base64 -import io import logging -import math from PIL import Image -import cv2 -from django.core.files import File from django.core.files.storage import default_storage from django.db import models -from django.db.models.fields.files import FieldFile from django.dispatch import receiver from django_extensions.db.models import TimeStampedModel -import librosa -import matplotlib.pyplot as plt import numpy as np -import tqdm from .acoustic_batch import AcousticBatch @@ -38,203 +30,6 @@ class NABatSpectrogram(TimeStampedModel, models.Model): frequency_max = models.IntegerField() # hz colormap = models.CharField(max_length=20, blank=False, null=True) - @classmethod - def generate(cls, recording, colormap=None, dpi=520): - """ - Ref: https://matplotlib.org/stable/users/explain/colors/colormaps.html - """ - wav_file = recording.audio_file - try: - if isinstance(wav_file, FieldFile): - sig, sr = librosa.load(io.BytesIO(wav_file.read()), sr=None) - wav_file.name - else: - sig, sr = librosa.load(wav_file, sr=None) - - duration = len(sig) / sr - except Exception as e: - print(e) - return None - - # Helpful aliases - size_mod = 1 - high_res = False - inference = False - - if colormap in ['inference']: - colormap = None - dpi = 300 - size_mod = 0 - inference = True - if colormap in ['none', 'default', 'dark']: - colormap = None - if colormap in ['light']: - colormap = 'gist_yarg' - if colormap in ['heatmap']: - colormap = 'turbo' - high_res = True - - # Supported colormaps - if colormap not in COLORMAP_ALLOWED: - logger.warning(f'Substituted requested {colormap} colormap to default') - logger.warning('See COLORMAP_ALLOWED') - colormap = None - - # Function to take a signal and return a spectrogram. - size = int(0.001 * sr) # 1.0ms resolution - size = 2 ** (math.ceil(math.log(size, 2)) + size_mod) - hop_length = int(size / 4) - - # Short-time Fourier Transform - window = librosa.stft(sig, n_fft=size, hop_length=hop_length, window='hamming') - - # Calculating and processing data for the spectrogram. - window = np.abs(window) ** 2 - window = librosa.power_to_db(window) - - # Denoise spectrogram - # Subtract median frequency - window -= np.median(window, axis=1, keepdims=True) - - # Subtract mean amplitude - window_ = window[window > 0] - thresh = np.median(window_) - window[window <= thresh] = 0 - - bands = librosa.fft_frequencies(sr=sr, n_fft=size) - for index in range(len(bands)): - band_min = bands[index] - band_max = bands[index + 1] if index < len(bands) - 1 else np.inf - if band_max <= FREQ_MIN or FREQ_MAX <= band_min: - window[index, :] = -1 - - window = np.clip(window, 0, None) - - freq_low = int(FREQ_MIN - FREQ_PAD) - freq_high = int(FREQ_MAX + FREQ_PAD) - vmin = window.min() - vmax = window.max() - - chunksize = int(2e3) - arange = np.arange(chunksize, window.shape[1], chunksize) - chunks = np.array_split(window, arange, axis=1) - - imgs = [] - for chunk in tqdm.tqdm(chunks): - h, w = chunk.shape - alpha = 3 - figsize = (int(math.ceil(w / h)) * alpha + 1, alpha) - fig = plt.figure(figsize=figsize, facecolor='black', dpi=dpi) - ax = plt.axes() - plt.margins(0) - - kwargs = { - 'sr': sr, - 'n_fft': size, - 'hop_length': hop_length, - 'x_axis': 's', - 'y_axis': 'fft', - 'ax': ax, - 'vmin': vmin, - 'vmax': vmax, - } - - # Plot - if colormap is None: - librosa.display.specshow(chunk, **kwargs) - else: - librosa.display.specshow(chunk, cmap=colormap, **kwargs) - - ax.set_ylim(freq_low, freq_high) - ax.axis('off') - - buf = io.BytesIO() - fig.savefig(buf, bbox_inches='tight', pad_inches=0) - - fig.clf() - plt.close() - - buf.seek(0) - img = Image.open(buf) - - img = np.array(img) - mask = img[:, :, -1] - flags = np.where(np.sum(mask != 0, axis=0) == 0)[0] - index = flags.min() if len(flags) > 0 else img.shape[1] - img = img[:, :index, :3] - - imgs.append(img) - - if inference: - w_ = int(4.0 * duration * 1e3) - h_ = int(dpi) - else: - w_ = int(8.0 * duration * 1e3) - h_ = 1200 - - img = np.hstack(imgs) - img = cv2.resize(img, (w_, h_), interpolation=cv2.INTER_LANCZOS4) - - if high_res: - img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) - - noise = 0.1 - img = img.astype(np.float32) - img -= img.min() - img /= img.max() - img *= 255.0 - img /= 1.0 - noise - img[img < 0] = 0 - img[img > 255] = 255 - img = 255.0 - img # invert - - img = cv2.blur(img, (9, 9)) - img = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LANCZOS4) - img = cv2.blur(img, (9, 9)) - - img -= img.min() - img /= img.max() - img *= 255.0 - - mask = (img > 255 * noise).astype(np.float32) - mask = cv2.blur(mask, (5, 5)) - - img[img < 0] = 0 - img[img > 255] = 255 - img = np.around(img).astype(np.uint8) - img = cv2.applyColorMap(img, cv2.COLORMAP_TURBO) - - img = img.astype(np.float32) - img *= mask.reshape(*mask.shape, 1) - img[img < 0] = 0 - img[img > 255] = 255 - img = np.around(img).astype(np.uint8) - - # cv2.imwrite('temp.png', img) - - img = Image.fromarray(img, 'RGB') - w, h = img.size - - buf = io.BytesIO() - img.save(buf, format='JPEG', quality=80) - buf.seek(0) - - name = f'{recording.pk}_{colormap}_spectrogram.jpg' - image_file = File(buf, name=name) - - spectrogram = cls( - recording_id=recording.pk, - image_file=image_file, - width=w, - height=h, - duration=math.ceil(duration * 1e3), - frequency_min=freq_low, - frequency_max=freq_high, - colormap=colormap, - ) - spectrogram.save() - return spectrogram.pk - @property def image_np(self): return np.array(self.image) diff --git a/bats_ai/tasks/nabat/nabat_data_retrieval.py b/bats_ai/tasks/nabat/nabat_data_retrieval.py new file mode 100644 index 00000000..e4adc6a5 --- /dev/null +++ b/bats_ai/tasks/nabat/nabat_data_retrieval.py @@ -0,0 +1,205 @@ +import logging +import tempfile + +from celery import shared_task +from django.contrib.gis.geos import Point +import requests + +from bats_ai.core.models import Species +from bats_ai.core.models.nabat import AcousticBatch + +from .tasks import generate_compress_spectrogram, generate_spectrogram, predict + +# Set up logger +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +BASE_URL = 'https://api.sciencebase.gov/nabat-graphql/graphql' +PROJECT_ID = 7168 +QUERY = """ +query batsAIAcousticInfoByFileBatchId { + acousticFileBatchById(id: "{batch_id}") { + batchId + acousticBatchByBatchId { + softwareBySoftwareId { + developer + name + versionNumber + } + classifierByClassifierId { + createdDate + description + name + public + speciesClassifiersByClassifierId { + nodes { + speciesBySpeciesId { + speciesCode + } + } + } + } + surveyEventBySurveyEventId { + createdBy + createdDate + eventGeometryByEventGeometryId { + description + geom { + geojson + } + } + } + createdDate + id + } + acousticFileByFileId { + fileName + recordingTime + s3Verified + sizeBytes + } + manualId + recordingNight + speciesByAutoId { + id + speciesCode + } + speciesByManualId { + id + speciesCode + } + autoId + } +} +""" + +PRESIGNED_URL_QUERY = """ +query batsAIAcousticPresignedUrlByBucketKey { + s3FileServiceDownloadFile( + bucket: "nabat-prod-acoustic-recordings", + key: "{key}" + ) { + s3PresignedUrl + success + message + } +} +""" + + +@shared_task +def acousting_batch_initialize(batch_id: int, api_token: str): + headers = {'Authorization': f'Bearer {api_token}', 'Content-Type': 'application/json'} + base_query = QUERY.format(batch_id=batch_id) + response = requests.post(BASE_URL, json={'query': base_query}, headers=headers) + batch_data = {} + try: + file_name = batch_data['data']['acousticFileBatchById']['acousticFileByFileId']['fileName'] + file_key = f'{PROJECT_ID}/{file_name}' + except (KeyError, TypeError) as e: + logger.error(f'Error extracting file information: {e}') + return + presigned_query = PRESIGNED_URL_QUERY.format(key=file_key) + logger.info('Fetching presigned URL...') + response = requests.post(BASE_URL, json={'query': presigned_query}, headers=headers) + + if response.status_code != 200: + logger.error(f'Failed to fetch presigned URL: {response.status_code}, {response.text}') + return + + try: + presigned_data = response.json() + url_info = presigned_data['data']['s3FileServiceDownloadFile'] + presigned_url = url_info['s3PresignedUrl'] + if not url_info['success']: + logger.error(f'Failed to get presigned URL: {url_info["message"]}') + return + except (KeyError, TypeError) as e: + logger.error(f'Error extracting presigned URL: {e}') + return + + logger.info('Presigned URL obtained. Downloading file...') + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file: + file_response = requests.get(presigned_url, stream=True) + if file_response.status_code == 200: + for chunk in file_response.iter_content(chunk_size=8192): + temp_file.write(chunk) + temp_file_path = temp_file.name # This gives the path of the temp file + logger.info(f'File downloaded to temporary file: {temp_file_path}') + + # Now create the AcousticBatch using the response data + logger.info('Creating Acoustic Batch...') + acoustic_batch = create_acoustic_batch_from_response(batch_data) + + # Call generate_spectrogram with the acoustic_batch and the temporary file + logger.info('Generating spectrogram...') + spectrogram = generate_spectrogram(acoustic_batch, open(temp_file_path, 'rb')) + logger.info('Generating compressed spectrogram...') + compressed_spectrogram = generate_compress_spectrogram( + acoustic_batch.pk, spectrogram.pk + ) + logger.info('Running Prediction...') + predict(compressed_spectrogram.pk) + + else: + logger.error(f'Failed to download file: {file_response.status_code}') + except Exception as e: + logger.error(f'Error handling file download or temporary file: {e}') + + +def create_acoustic_batch_from_response(response_data): + try: + # Extract the batch data from the response + acoustic_batch_data = response_data['data']['acousticFileBatchById'] + + # Extract nested data from the response + batch_id = acoustic_batch_data['batchId'] + software_name = acoustic_batch_data['acousticBatchByBatchId']['softwareBySoftwareId'][ + 'name' + ] + software_developer = acoustic_batch_data['acousticBatchByBatchId']['softwareBySoftwareId'][ + 'developer' + ] + software_version = acoustic_batch_data['acousticBatchByBatchId']['softwareBySoftwareId'][ + 'versionNumber' + ] + + # Optional fields + recording_location_data = acoustic_batch_data['acousticBatchByBatchId'][ + 'surveyEventBySurveyEventId' + ]['eventGeometryByEventGeometryId']['geom']['geojson'] + + # Create geometry for the recording location if available + if recording_location_data: + coordinates = recording_location_data.get('coordinates', []) + recording_location = ( + Point(coordinates[0], coordinates[1]) if len(coordinates) == 2 else None + ) + else: + recording_location = None + + # Get the species info + species_code = acoustic_batch_data['acousticFileByFileId']['speciesByAutoId']['speciesCode'] + species = Species.objects.filter(species_code=species_code).first() + + # Create the AcousticBatch instance + acoustic_batch = AcousticBatch.objects.create( + batch_id=batch_id, + name=f'Batch {batch_id}', + software_name=software_name, + software_developer=software_developer, + software_version=software_version, + recording_location=recording_location, + nabat_auto_species=species, + ) + + return acoustic_batch + + except KeyError as e: + print(f'Missing key: {e}') + return None + except Exception as e: + print(f'Error creating AcousticBatch: {e}') + return None diff --git a/bats_ai/tasks/nabat/tasks.py b/bats_ai/tasks/nabat/tasks.py index 27b6d11e..54da3d63 100644 --- a/bats_ai/tasks/nabat/tasks.py +++ b/bats_ai/tasks/nabat/tasks.py @@ -1,10 +1,13 @@ import io +import math import tempfile from PIL import Image from celery import shared_task import cv2 from django.core.files import File +import librosa +import matplotlib.pyplot as plt import numpy as np import scipy @@ -16,6 +19,184 @@ NABatSpectrogram, ) +FREQ_MIN = 5e3 +FREQ_MAX = 120e3 +FREQ_PAD = 2e3 + +COLORMAP_ALLOWED = [None, 'gist_yarg', 'turbo'] + + +def generate_spectrogram(acoustic_batch, file, colormap=None, dpi=520): + try: + sig, sr = librosa.load(file, sr=None) + duration = len(sig) / sr + except Exception as e: + print(f'Error loading file: {e}') + return None + + size_mod = 1 + high_res = False + + if colormap in ['inference']: + colormap = None + dpi = 300 + size_mod = 0 + if colormap in ['none', 'default', 'dark']: + colormap = None + if colormap in ['light']: + colormap = 'gist_yarg' + if colormap in ['heatmap']: + colormap = 'turbo' + high_res = True + + if colormap not in COLORMAP_ALLOWED: + print(f'Substituted requested {colormap} colormap to default') + colormap = None + + size = int(0.001 * sr) # 1.0ms resolution + size = 2 ** (math.ceil(math.log(size, 2)) + size_mod) + hop_length = int(size / 4) + + window = librosa.stft(sig, n_fft=size, hop_length=hop_length, window='hamming') + window = np.abs(window) ** 2 + window = librosa.power_to_db(window) + + window -= np.median(window, axis=1, keepdims=True) + window_ = window[window > 0] + thresh = np.median(window_) + window[window <= thresh] = 0 + + bands = librosa.fft_frequencies(sr=sr, n_fft=size) + for index in range(len(bands)): + band_min = bands[index] + band_max = bands[index + 1] if index < len(bands) - 1 else np.inf + if band_max <= 1000 or 10000 <= band_min: + window[index, :] = -1 + + window = np.clip(window, 0, None) + + freq_low = int(1000 - 50) + freq_high = int(10000 + 50) + vmin = window.min() + vmax = window.max() + + chunksize = int(2e3) + arange = np.arange(chunksize, window.shape[1], chunksize) + chunks = np.array_split(window, arange, axis=1) + + imgs = [] + for chunk in chunks: + h, w = chunk.shape + alpha = 3 + figsize = (int(math.ceil(w / h)) * alpha + 1, alpha) + fig = plt.figure(figsize=figsize, facecolor='black', dpi=dpi) + ax = plt.axes() + plt.margins(0) + + kwargs = { + 'sr': sr, + 'n_fft': size, + 'hop_length': hop_length, + 'x_axis': 's', + 'y_axis': 'fft', + 'ax': ax, + 'vmin': vmin, + 'vmax': vmax, + } + + if colormap is None: + librosa.display.specshow(chunk, **kwargs) + else: + librosa.display.specshow(chunk, cmap=colormap, **kwargs) + + ax.set_ylim(freq_low, freq_high) + ax.axis('off') + + buf = io.BytesIO() + fig.savefig(buf, bbox_inches='tight', pad_inches=0) + + fig.clf() + plt.close() + + buf.seek(0) + img = Image.open(buf) + + img = np.array(img) + mask = img[:, :, -1] + flags = np.where(np.sum(mask != 0, axis=0) == 0)[0] + index = flags.min() if len(flags) > 0 else img.shape[1] + img = img[:, :index, :3] + + imgs.append(img) + + w_ = int(8.0 * duration * 1e3) + h_ = 1200 + + img = np.hstack(imgs) + img = cv2.resize(img, (w_, h_), interpolation=cv2.INTER_LANCZOS4) + + if high_res: + img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + + noise = 0.1 + img = img.astype(np.float32) + img -= img.min() + img /= img.max() + img *= 255.0 + img /= 1.0 - noise + img[img < 0] = 0 + img[img > 255] = 255 + img = 255.0 - img + + img = cv2.blur(img, (9, 9)) + img = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LANCZOS4) + img = cv2.blur(img, (9, 9)) + + img -= img.min() + img /= img.max() + img *= 255.0 + + mask = (img > 255 * noise).astype(np.float32) + mask = cv2.blur(mask, (5, 5)) + + img[img < 0] = 0 + img[img > 255] = 255 + img = np.around(img).astype(np.uint8) + img = cv2.applyColorMap(img, cv2.COLORMAP_TURBO) + + img = img.astype(np.float32) + img *= mask.reshape(*mask.shape, 1) + img[img < 0] = 0 + img[img > 255] = 255 + img = np.around(img).astype(np.uint8) + + img = Image.fromarray(img, 'RGB') + w, h = img.size + + # Save image to temporary file + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') + img.save(temp_file, format='JPEG', quality=80) + temp_file.seek(0) + + # Create new NABatSpectrogram + image_file = File(temp_file, name=f'{acoustic_batch.batch_id}_spectrogram.jpg') + + spectrogram = NABatSpectrogram.objects.create( + acoustic_batch=acoustic_batch, + image_file=image_file, + width=w, + height=h, + duration=math.ceil(duration * 1e3), # duration in ms + frequency_min=freq_low, + frequency_max=freq_high, + colormap=colormap, + ) + + # Clean up temporary file + temp_file.close() + + return spectrogram + def generate_compressed(spectrogram: NABatSpectrogram): img = spectrogram.image_np @@ -183,33 +364,6 @@ def generate_compress_spectrogram(acoustic_batch_id: int, spectrogram_id: int): return existing -@shared_task -def acousting_batch_initialize(batch_id: int, api_token: str): - print(batch_id) - # Need to get the information from the server using the api_token and the batch_id - # 1. Use the batch_id and api_token to gather information about the AcousticBatch using graphQL endpoint - # 2. Create the AcousticBatch object, using the batch_id and values from the server - # 3. Use an additional GraphQL query to get the S3 Pre-Signed file URL for the recording object - # 4. Download the files from S3 and use it to convert into a spectrogram that can be saved to the system - # 5. Use the spectrogram to generate a compressed spectrogram - # 6. Use the compressed spectrogram to predict the species - - # Eventual compressed generation - # cmaps = [ - # None, # Default (dark) spectrogram - # 'light', # Light spectrogram - # ] - # spectrogram_id = None - # for cmap in cmaps: - # with colormap(cmap): - # spectrogram_id_temp = NABatSpectrogram.generate(acoustic_batch, cmap) - # if cmap is None: - # spectrogram_id = spectrogram_id_temp - # if spectrogram_id is not None: - # compressed_spectro = generate_compress_spectrogram(spectrogram_id) - # predict(compressed_spectro.pk) - - @shared_task def predict(compressed_spectrogram_id: int): compressed_spectrogram = NABatCompressedSpectrogram.objects.get(pk=compressed_spectrogram_id) diff --git a/scripts/USGS/naBatInfo.py b/scripts/USGS/naBatInfo.py index 83f3e690..3886c41c 100644 --- a/scripts/USGS/naBatInfo.py +++ b/scripts/USGS/naBatInfo.py @@ -1,14 +1,22 @@ import json +import logging import click import requests +# Set up logger +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + # Global variable for authorization token AUTH_TOKEN = '' BASE_URL = 'https://api.sciencebase.gov/nabat-graphql/graphql' +PROJECT_ID = 7168 + +# GraphQL queries QUERY = """ query batsAIAcousticInfoByFileBatchId { - acousticFileBatchById(id: \"319479412\") { + acousticFileBatchById(id: "319479412") { batchId acousticBatchByBatchId { softwareBySoftwareId { @@ -63,20 +71,85 @@ } """ +PRESIGNED_URL_QUERY = """ +query batsAIAcousticPresignedUrlByBucketKey { + s3FileServiceDownloadFile( + bucket: "nabat-prod-acoustic-recordings", + key: "{key}" + ) { + s3PresignedUrl + success + message + } +} +""" + @click.command() def fetch_and_save(): """Fetch data using GraphQL and save to output.json""" headers = {'Authorization': f'Bearer {AUTH_TOKEN}', 'Content-Type': 'application/json'} + + # Fetch batch data + logger.info('Fetching batch data...') response = requests.post(BASE_URL, json={'query': QUERY}, headers=headers) + batch_data = {} if response.status_code == 200: - data = response.json() - with open('output.json', 'w') as f: - json.dump(data, f, indent=2) - click.echo('Data successfully fetched and saved to output.json') + try: + batch_data = response.json() + with open('output.json', 'w') as f: + json.dump(batch_data, f, indent=2) + logger.info('Data successfully fetched and saved to output.json') + except (KeyError, TypeError, json.JSONDecodeError) as e: + logger.error(f'Error processing batch data: {e}') + return + else: + logger.error(f'Failed to fetch data: {response.status_code}, {response.text}') + return + + # Extract file name and key + try: + file_name = batch_data['data']['acousticFileBatchById']['acousticFileByFileId']['fileName'] + file_key = f'{PROJECT_ID}/{file_name}' + except (KeyError, TypeError) as e: + logger.error(f'Error extracting file information: {e}') + return + + # Fetch presigned URL + presigned_query = PRESIGNED_URL_QUERY.format(key=file_key) + logger.info('Fetching presigned URL...') + response = requests.post(BASE_URL, json={'query': presigned_query}, headers=headers) + + if response.status_code != 200: + logger.error(f'Failed to fetch presigned URL: {response.status_code}, {response.text}') + return + + try: + presigned_data = response.json() + url_info = presigned_data['data']['s3FileServiceDownloadFile'] + presigned_url = url_info['s3PresignedUrl'] + if not url_info['success']: + logger.error(f'Failed to get presigned URL: {url_info["message"]}') + return + except (KeyError, TypeError) as e: + logger.error(f'Error extracting presigned URL: {e}') + return + + logger.info('Presigned URL obtained. Downloading file...') + + # Download the file + file_response = requests.get(presigned_url, stream=True) + if file_response.status_code == 200: + try: + with open(file_name, 'wb') as f: + for chunk in file_response.iter_content(chunk_size=8192): + f.write(chunk) + logger.info(f'File downloaded: {file_name}') + except Exception as e: + logger.error(f'Error saving the file: {e}') else: - click.echo(f'Failed to fetch data: {response.status_code}, {response.text}') + logger.error(f'Failed to download file: {file_response.status_code}') if __name__ == '__main__': From 0574aa4f9e216941a3afdffbf9cbb36b3c40247e Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 11 Feb 2025 13:58:00 -0500 Subject: [PATCH 05/43] NABat ingestion working --- bats_ai/api.py | 2 + bats_ai/core/admin/__init__.py | 11 + bats_ai/core/admin/nabat/admin.py | 70 +++++ ...acousticbatch_nabatspectrogram_and_more.py | 257 ++++++++++++++++++ bats_ai/core/models/nabat/acoustic_batch.py | 20 +- .../models/nabat/acoustic_batch_annotation.py | 4 + .../nabat/nabat_compressed_spectrogram.py | 4 + .../core/models/nabat/nabat_spectrogram.py | 4 + bats_ai/core/views/nabat/__init__.py | 6 +- bats_ai/core/views/nabat/acoustic_batch.py | 5 +- bats_ai/tasks/nabat/nabat_data_retrieval.py | 84 +++--- bats_ai/tasks/nabat/tasks.py | 6 +- scripts/USGS/naBatInfo.py | 69 ++--- 13 files changed, 463 insertions(+), 79 deletions(-) create mode 100644 bats_ai/core/admin/nabat/admin.py create mode 100644 bats_ai/core/migrations/0014_acousticbatch_nabatspectrogram_and_more.py diff --git a/bats_ai/api.py b/bats_ai/api.py index 4a899cc7..03a5fee7 100644 --- a/bats_ai/api.py +++ b/bats_ai/api.py @@ -11,6 +11,7 @@ RecordingRouter, SpeciesRouter, ) +from bats_ai.core.views.nabat import AcouticBatchRouter logger = logging.getLogger(__name__) @@ -37,3 +38,4 @@ def global_auth(request): api.add_router('/guano/', GuanoMetadataRouter) api.add_router('/recording-annotation/', RecordingAnnotationRouter) api.add_router('/configuration/', ConfigurationRouter) +api.add_router('/nabat/acoustic-batch/', AcouticBatchRouter) diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index 52f0de71..6a0cfaca 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -2,6 +2,12 @@ from .compressed_spectrogram import CompressedSpectrogramAdmin from .grts_cells import GRTSCellsAdmin from .image import ImageAdmin +from .nabat.admin import ( + AcousticBatch, + AcousticBatchAnnotation, + NABatCompressedSpectrogram, + NABatSpectrogram, +) from .recording import RecordingAdmin from .recording_annotations import RecordingAnnotationAdmin from .species import SpeciesAdmin @@ -18,4 +24,9 @@ 'GRTSCellsAdmin', 'CompressedSpectrogramAdmin', 'RecordingAnnotationAdmin', + # NABat Models + 'AcousticBatchAnnotation', + 'NABatCompressedSpectrogram', + 'NABatSpectrogram', + 'AcousticBatch', ] diff --git a/bats_ai/core/admin/nabat/admin.py b/bats_ai/core/admin/nabat/admin.py new file mode 100644 index 00000000..fccb7fb3 --- /dev/null +++ b/bats_ai/core/admin/nabat/admin.py @@ -0,0 +1,70 @@ +from django.contrib import admin + +from bats_ai.core.models.nabat import ( + AcousticBatch, + AcousticBatchAnnotation, + NABatCompressedSpectrogram, + NABatSpectrogram, +) + + +# Register models for the NaBat category +@admin.register(AcousticBatchAnnotation) +class AcousticBatchAnnotationAdmin(admin.ModelAdmin): + list_display = ( + 'acoustic_batch', + 'comments', + 'model', + 'confidence', + 'additional_data', + 'species_codes', + ) + search_fields = ('acoustic_batch__name', 'comments', 'model') + list_filter = ('acoustic_batch',) + + @admin.display(description='Species Codes') + def species_codes(self, obj): + # Assuming species have a `species_code` field + return ', '.join([species.species_code for species in obj.species.all()]) + + +@admin.register(NABatSpectrogram) +class NABatSpectrogramAdmin(admin.ModelAdmin): + list_display = ( + 'acoustic_batch', + 'image_file', + 'width', + 'height', + 'duration', + 'frequency_min', + 'frequency_max', + 'colormap', + ) + search_fields = ('acoustic_batch__name', 'colormap') + list_filter = ('acoustic_batch', 'colormap') + + +@admin.register(NABatCompressedSpectrogram) +class NABatCompressedSpectrogramAdmin(admin.ModelAdmin): + list_display = ('acoustic_batch', 'spectrogram', 'length', 'cache_invalidated') + search_fields = ('acoustic_batch__name', 'spectrogram__id') + list_filter = ('acoustic_batch', 'cache_invalidated') + + +class AcousticBatchAdmin(admin.ModelAdmin): + list_display = ( + 'name', + 'batch_id', + 'recorded_date', + 'equipment', + 'comments', + 'recording_location', + 'grts_cell_id', + 'grts_cell', + ) + search_fields = ('name', 'batch_id', 'recording_location') + list_filter = ('name', 'batch_id', 'recording_location') + + +# Register the models under the NaBat category +admin.site.register(AcousticBatch) diff --git a/bats_ai/core/migrations/0014_acousticbatch_nabatspectrogram_and_more.py b/bats_ai/core/migrations/0014_acousticbatch_nabatspectrogram_and_more.py new file mode 100644 index 00000000..c28a5457 --- /dev/null +++ b/bats_ai/core/migrations/0014_acousticbatch_nabatspectrogram_and_more.py @@ -0,0 +1,257 @@ +# Generated by Django 4.1.13 on 2025-02-11 17:49 + +import django.contrib.gis.db.models.fields +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0013_configuration'), + ] + + operations = [ + migrations.CreateModel( + name='AcousticBatch', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ('name', models.CharField(max_length=255)), + ('batch_id', models.BigIntegerField(unique=True)), + ('equipment', models.TextField(blank=True, null=True)), + ('comments', models.TextField(blank=True, null=True)), + ( + 'recording_location', + django.contrib.gis.db.models.fields.GeometryField( + blank=True, null=True, srid=4326 + ), + ), + ('grts_cell_id', models.IntegerField(blank=True, null=True)), + ('grts_cell', models.IntegerField(blank=True, null=True)), + ('public', models.BooleanField(default=False)), + ('software_name', models.TextField(blank=True, null=True)), + ('software_developer', models.TextField(blank=True, null=True)), + ('software_version', models.TextField(blank=True, null=True)), + ('detector', models.TextField(blank=True, null=True)), + ('species_list', models.TextField(blank=True, null=True)), + ('unusual_occurrences', models.TextField(blank=True, null=True)), + ( + 'computed_species', + models.ManyToManyField( + related_name='acousticbatch_computed_species', to='core.species' + ), + ), + ( + 'nabat_auto_species', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='acousticbatch_auto_species', + to='core.species', + ), + ), + ( + 'nabat_manual_species', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='acousticbatch_manual_species', + to='core.species', + ), + ), + ( + 'official_species', + models.ManyToManyField( + related_name='acousticbatch_official_species', to='core.species' + ), + ), + ], + options={ + 'verbose_name': 'NABat Acoustic Batch', + 'verbose_name_plural': 'NABat Acoustic Batches', + }, + ), + migrations.CreateModel( + name='NABatSpectrogram', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ('image_file', models.FileField(upload_to='')), + ('width', models.IntegerField()), + ('height', models.IntegerField()), + ('duration', models.IntegerField()), + ('frequency_min', models.IntegerField()), + ('frequency_max', models.IntegerField()), + ('colormap', models.CharField(max_length=20, null=True)), + ( + 'acoustic_batch', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.acousticbatch' + ), + ), + ], + options={ + 'verbose_name': 'NABat Spectrogram', + 'verbose_name_plural': 'NABat Spectrograms', + }, + ), + migrations.CreateModel( + name='NABatCompressedSpectrogram', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ('image_file', models.FileField(upload_to='')), + ('length', models.IntegerField()), + ( + 'starts', + django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), size=None + ), + size=None, + ), + ), + ( + 'stops', + django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), size=None + ), + size=None, + ), + ), + ( + 'widths', + django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), size=None + ), + size=None, + ), + ), + ('cache_invalidated', models.BooleanField(default=True)), + ( + 'acoustic_batch', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.acousticbatch' + ), + ), + ( + 'spectrogram', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.nabatspectrogram' + ), + ), + ], + options={ + 'verbose_name': 'NABat Compressed Spectrogram', + 'verbose_name_plural': 'NABat Compressed Spectrogram', + }, + ), + migrations.CreateModel( + name='AcousticBatchAnnotation', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ('comments', models.TextField(blank=True, null=True)), + ('model', models.TextField(blank=True, null=True)), + ( + 'confidence', + models.FloatField( + default=1.0, + help_text='A confidence value between 0 and 1.0, default is 1.0.', + validators=[ + django.core.validators.MinValueValidator(0.0), + django.core.validators.MaxValueValidator(1.0), + ], + ), + ), + ( + 'additional_data', + models.JSONField( + blank=True, + help_text='Additional information about the models/data', + null=True, + ), + ), + ( + 'acoustic_batch', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.acousticbatch' + ), + ), + ('species', models.ManyToManyField(to='core.species')), + ], + options={ + 'verbose_name': 'NABat Acoustic Batch Annotation', + 'verbose_name_plural': 'NABat Acoustic Batch Annotations', + }, + ), + ] diff --git a/bats_ai/core/models/nabat/acoustic_batch.py b/bats_ai/core/models/nabat/acoustic_batch.py index 582da62f..e99c2cd9 100644 --- a/bats_ai/core/models/nabat/acoustic_batch.py +++ b/bats_ai/core/models/nabat/acoustic_batch.py @@ -41,15 +41,23 @@ class AcousticBatch(TimeStampedModel, models.Model): software_developer = models.TextField(blank=True, null=True) software_version = models.TextField(blank=True, null=True) detector = models.TextField(blank=True, null=True) - nabat_auto_species = models.ForeignKey(Species, null=True) - nabat_manual_species = models.ForeignKey(Species, null=True) + nabat_auto_species = models.ForeignKey(Species, null=True, on_delete=models.SET_NULL) + nabat_manual_species = models.ForeignKey(Species, null=True, on_delete=models.SET_NULL) species_list = models.TextField(blank=True, null=True) + nabat_auto_species = models.ForeignKey( + Species, null=True, on_delete=models.SET_NULL, related_name='acousticbatch_auto_species' + ) + nabat_manual_species = models.ForeignKey( + Species, null=True, on_delete=models.SET_NULL, related_name='acousticbatch_manual_species' + ) computed_species = models.ManyToManyField( - Species, related_name='recording_computed_species' + Species, related_name='acousticbatch_computed_species' # Changed related name ) # species from a computed sense + official_species = models.ManyToManyField( - Species, related_name='recording_official_species' + Species, related_name='acousticbatch_official_species' # Changed related name ) # species that are detemrined by the owner or from annotations as official species list + unusual_occurrences = models.TextField(blank=True, null=True) @property @@ -97,3 +105,7 @@ def compressed_spectrogram(self): spectrogram = compressed_spectrograms[0] # most recently created return spectrogram + + class Meta: + verbose_name = 'NABat Acoustic Batch' + verbose_name_plural = 'NABat Acoustic Batches' diff --git a/bats_ai/core/models/nabat/acoustic_batch_annotation.py b/bats_ai/core/models/nabat/acoustic_batch_annotation.py index fc5f753a..dfbab9bb 100644 --- a/bats_ai/core/models/nabat/acoustic_batch_annotation.py +++ b/bats_ai/core/models/nabat/acoustic_batch_annotation.py @@ -23,3 +23,7 @@ class AcousticBatchAnnotation(TimeStampedModel, models.Model): additional_data = models.JSONField( blank=True, null=True, help_text='Additional information about the models/data' ) + + class Meta: + verbose_name = 'NABat Acoustic Batch Annotation' + verbose_name_plural = 'NABat Acoustic Batch Annotations' diff --git a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py index 7ea9c8b8..6d9489fa 100644 --- a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py @@ -105,6 +105,10 @@ def predict(self): return label, score, confs + class Meta: + verbose_name = 'NABat Compressed Spectrogram' + verbose_name_plural = 'NABat Compressed Spectrogram' + @receiver(models.signals.pre_delete, sender=NABatSpectrogram) def delete_content(sender, instance, **kwargs): diff --git a/bats_ai/core/models/nabat/nabat_spectrogram.py b/bats_ai/core/models/nabat/nabat_spectrogram.py index b31c76ef..25e57a6a 100644 --- a/bats_ai/core/models/nabat/nabat_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_spectrogram.py @@ -54,6 +54,10 @@ def base64(self): def image_url(self): return default_storage.url(self.image_file.name) + class Meta: + verbose_name = 'NABat Spectrogram' + verbose_name_plural = 'NABat Spectrograms' + @receiver(models.signals.pre_delete, sender=NABatSpectrogram) def delete_content(sender, instance, **kwargs): diff --git a/bats_ai/core/views/nabat/__init__.py b/bats_ai/core/views/nabat/__init__.py index a9a2c5b3..61a2f713 100644 --- a/bats_ai/core/views/nabat/__init__.py +++ b/bats_ai/core/views/nabat/__init__.py @@ -1 +1,5 @@ -__all__ = [] +from .acoustic_batch import router as AcouticBatchRouter + +__all__ = [ + 'AcouticBatchRouter', +] diff --git a/bats_ai/core/views/nabat/acoustic_batch.py b/bats_ai/core/views/nabat/acoustic_batch.py index a6c1f4dd..e761ae05 100644 --- a/bats_ai/core/views/nabat/acoustic_batch.py +++ b/bats_ai/core/views/nabat/acoustic_batch.py @@ -11,6 +11,7 @@ NABatCompressedSpectrogram, ) from bats_ai.core.views.species import SpeciesSchema +from bats_ai.tasks.nabat.nabat_data_retrieval import acousting_batch_initialize logger = logging.getLogger(__name__) @@ -62,8 +63,8 @@ def generate_acoustic_batch( acoustic_batch = AcousticBatch.objects.filter(batch_id=payload.batchId) if not acoustic_batch.exists(): # use a task to start downloading the file using the API key and generate the spectrograms - return {'taskId': 'TODO:TASKID'} - + task = acousting_batch_initialize.delay(payload.batchId, payload.apiToken) + return {'task_id': task.id} return get_acoustic_batch_spectrogram(request, payload.batchId) diff --git a/bats_ai/tasks/nabat/nabat_data_retrieval.py b/bats_ai/tasks/nabat/nabat_data_retrieval.py index e4adc6a5..f63d7ed1 100644 --- a/bats_ai/tasks/nabat/nabat_data_retrieval.py +++ b/bats_ai/tasks/nabat/nabat_data_retrieval.py @@ -1,3 +1,4 @@ +import json import logging import tempfile @@ -12,78 +13,77 @@ # Set up logger logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +logger = logging.getLogger('NABatDataRetrieval') BASE_URL = 'https://api.sciencebase.gov/nabat-graphql/graphql' PROJECT_ID = 7168 QUERY = """ -query batsAIAcousticInfoByFileBatchId { - acousticFileBatchById(id: "{batch_id}") { +query batsAIAcousticInfoByFileBatchId {{ + acousticFileBatchById(id: "{batch_id}") {{ batchId - acousticBatchByBatchId { - softwareBySoftwareId { + acousticBatchByBatchId {{ + softwareBySoftwareId {{ developer name versionNumber - } - classifierByClassifierId { + }} + classifierByClassifierId {{ createdDate description name public - speciesClassifiersByClassifierId { - nodes { - speciesBySpeciesId { + speciesClassifiersByClassifierId {{ + nodes {{ + speciesBySpeciesId {{ speciesCode - } - } - } - } - surveyEventBySurveyEventId { + }} + }} + }} + }} + surveyEventBySurveyEventId {{ createdBy createdDate - eventGeometryByEventGeometryId { + eventGeometryByEventGeometryId {{ description - geom { + geom {{ geojson - } - } - } + }} + }} + }} createdDate id - } - acousticFileByFileId { + }} + acousticFileByFileId {{ fileName recordingTime s3Verified sizeBytes - } + }} manualId recordingNight - speciesByAutoId { + speciesByAutoId {{ id speciesCode - } - speciesByManualId { + }} + speciesByManualId {{ id speciesCode - } + }} autoId - } -} -""" + }} +}}""" PRESIGNED_URL_QUERY = """ -query batsAIAcousticPresignedUrlByBucketKey { +query batsAIAcousticPresignedUrlByBucketKey {{ s3FileServiceDownloadFile( bucket: "nabat-prod-acoustic-recordings", key: "{key}" - ) { + ) {{ s3PresignedUrl success message - } -} + }} +}} """ @@ -93,6 +93,20 @@ def acousting_batch_initialize(batch_id: int, api_token: str): base_query = QUERY.format(batch_id=batch_id) response = requests.post(BASE_URL, json={'query': base_query}, headers=headers) batch_data = {} + + if response.status_code == 200: + try: + batch_data = response.json() + with open('output.json', 'w') as f: + json.dump(batch_data, f, indent=2) + logger.info('Data successfully fetched and saved to output.json') + except (KeyError, TypeError, json.JSONDecodeError) as e: + logger.error(f'Error processing batch data: {e}') + return + else: + logger.error(f'Failed to fetch data: {response.status_code}, {response.text}') + return + try: file_name = batch_data['data']['acousticFileBatchById']['acousticFileByFileId']['fileName'] file_key = f'{PROJECT_ID}/{file_name}' @@ -181,7 +195,7 @@ def create_acoustic_batch_from_response(response_data): recording_location = None # Get the species info - species_code = acoustic_batch_data['acousticFileByFileId']['speciesByAutoId']['speciesCode'] + species_code = acoustic_batch_data['speciesByAutoId']['speciesCode'] species = Species.objects.filter(species_code=species_code).first() # Create the AcousticBatch instance diff --git a/bats_ai/tasks/nabat/tasks.py b/bats_ai/tasks/nabat/tasks.py index 54da3d63..64c05028 100644 --- a/bats_ai/tasks/nabat/tasks.py +++ b/bats_ai/tasks/nabat/tasks.py @@ -70,13 +70,13 @@ def generate_spectrogram(acoustic_batch, file, colormap=None, dpi=520): for index in range(len(bands)): band_min = bands[index] band_max = bands[index + 1] if index < len(bands) - 1 else np.inf - if band_max <= 1000 or 10000 <= band_min: + if band_max <= FREQ_MIN or FREQ_MAX <= band_min: window[index, :] = -1 window = np.clip(window, 0, None) - freq_low = int(1000 - 50) - freq_high = int(10000 + 50) + freq_low = int(FREQ_MIN - FREQ_PAD) + freq_high = int(FREQ_MAX + FREQ_PAD) vmin = window.min() vmax = window.max() diff --git a/scripts/USGS/naBatInfo.py b/scripts/USGS/naBatInfo.py index 3886c41c..bdd9a887 100644 --- a/scripts/USGS/naBatInfo.py +++ b/scripts/USGS/naBatInfo.py @@ -12,76 +12,76 @@ AUTH_TOKEN = '' BASE_URL = 'https://api.sciencebase.gov/nabat-graphql/graphql' PROJECT_ID = 7168 - +BATCH_ID = 319479412 # GraphQL queries QUERY = """ -query batsAIAcousticInfoByFileBatchId { - acousticFileBatchById(id: "319479412") { +query batsAIAcousticInfoByFileBatchId {{ + acousticFileBatchById(id: "{batch_id}") {{ batchId - acousticBatchByBatchId { - softwareBySoftwareId { + acousticBatchByBatchId {{ + softwareBySoftwareId {{ developer name versionNumber - } - classifierByClassifierId { + }} + classifierByClassifierId {{ createdDate description name public - speciesClassifiersByClassifierId { - nodes { - speciesBySpeciesId { + speciesClassifiersByClassifierId {{ + nodes {{ + speciesBySpeciesId {{ speciesCode - } - } - } - } - surveyEventBySurveyEventId { + }} + }} + }} + }} + surveyEventBySurveyEventId {{ createdBy createdDate - eventGeometryByEventGeometryId { + eventGeometryByEventGeometryId {{ description - geom { + geom {{ geojson - } - } - } + }} + }} + }} createdDate id - } - acousticFileByFileId { + }} + acousticFileByFileId {{ fileName recordingTime s3Verified sizeBytes - } + }} manualId recordingNight - speciesByAutoId { + speciesByAutoId {{ id speciesCode - } - speciesByManualId { + }} + speciesByManualId {{ id speciesCode - } + }} autoId - } -} + }} +}} """ PRESIGNED_URL_QUERY = """ -query batsAIAcousticPresignedUrlByBucketKey { +query batsAIAcousticPresignedUrlByBucketKey {{ s3FileServiceDownloadFile( bucket: "nabat-prod-acoustic-recordings", key: "{key}" - ) { + ) {{ s3PresignedUrl success message - } -} + }} +}} """ @@ -92,7 +92,8 @@ def fetch_and_save(): # Fetch batch data logger.info('Fetching batch data...') - response = requests.post(BASE_URL, json={'query': QUERY}, headers=headers) + batch_query = QUERY.format(batch_id=BATCH_ID) + response = requests.post(BASE_URL, json={'query': batch_query}, headers=headers) batch_data = {} if response.status_code == 200: From e83ef4cba650ffee8aa80e89d30a9b4d0d222e37 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 11 Feb 2025 14:16:31 -0500 Subject: [PATCH 06/43] rename acoustic_batch_initialize --- bats_ai/core/views/nabat/acoustic_batch.py | 4 ++-- bats_ai/tasks/nabat/nabat_data_retrieval.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bats_ai/core/views/nabat/acoustic_batch.py b/bats_ai/core/views/nabat/acoustic_batch.py index e761ae05..3967d80a 100644 --- a/bats_ai/core/views/nabat/acoustic_batch.py +++ b/bats_ai/core/views/nabat/acoustic_batch.py @@ -11,7 +11,7 @@ NABatCompressedSpectrogram, ) from bats_ai.core.views.species import SpeciesSchema -from bats_ai.tasks.nabat.nabat_data_retrieval import acousting_batch_initialize +from bats_ai.tasks.nabat.nabat_data_retrieval import acoustic_batch_initialize logger = logging.getLogger(__name__) @@ -63,7 +63,7 @@ def generate_acoustic_batch( acoustic_batch = AcousticBatch.objects.filter(batch_id=payload.batchId) if not acoustic_batch.exists(): # use a task to start downloading the file using the API key and generate the spectrograms - task = acousting_batch_initialize.delay(payload.batchId, payload.apiToken) + task = acoustic_batch_initialize.delay(payload.batchId, payload.apiToken) return {'task_id': task.id} return get_acoustic_batch_spectrogram(request, payload.batchId) diff --git a/bats_ai/tasks/nabat/nabat_data_retrieval.py b/bats_ai/tasks/nabat/nabat_data_retrieval.py index f63d7ed1..85885e93 100644 --- a/bats_ai/tasks/nabat/nabat_data_retrieval.py +++ b/bats_ai/tasks/nabat/nabat_data_retrieval.py @@ -88,7 +88,7 @@ @shared_task -def acousting_batch_initialize(batch_id: int, api_token: str): +def acoustic_batch_initialize(batch_id: int, api_token: str): headers = {'Authorization': f'Bearer {api_token}', 'Content-Type': 'application/json'} base_query = QUERY.format(batch_id=batch_id) response = requests.post(BASE_URL, json={'query': base_query}, headers=headers) From 91be416e573cc9b41ca39df40c32d2f83a808bfb Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 18 Feb 2025 12:05:21 -0500 Subject: [PATCH 07/43] remove flower, add spectrogram viewer script, move spectrogram creation to tasks, update NABat spectro generating process --- bats_ai/api.py | 2 + bats_ai/core/admin/__init__.py | 2 + bats_ai/core/admin/nabat/admin.py | 6 +- bats_ai/core/admin/processing_task.py | 28 ++ .../core/migrations/0015_processingtask.py | 64 +++ bats_ai/core/models/__init__.py | 2 + bats_ai/core/models/compressed_spectrogram.py | 4 - bats_ai/core/models/nabat/acoustic_batch.py | 4 +- bats_ai/core/models/processing_task.py | 31 ++ bats_ai/core/models/spectrogram.py | 215 ---------- bats_ai/core/views/__init__.py | 2 + bats_ai/core/views/nabat/acoustic_batch.py | 27 +- bats_ai/core/views/processing_tasks.py | 51 +++ bats_ai/settings.py | 2 + bats_ai/tasks/nabat/nabat_data_retrieval.py | 69 +++- bats_ai/tasks/tasks.py | 186 ++++++++- client/src/App.vue | 8 +- client/src/api/NABatApi.ts | 28 ++ client/src/api/api.ts | 48 +++ client/src/router/index.ts | 9 + client/src/views/Admin.vue | 4 +- client/src/views/NABatAcousticBatch.vue | 100 +++++ client/src/views/NABatSpectrogram.vue | 387 ++++++++++++++++++ docker-compose.override.yml | 1 - docker-compose.prod.yml | 25 -- docker-compose.yml | 22 - scripts/generateSpectrograms.py | 207 ++++++++++ setup.py | 2 +- 28 files changed, 1239 insertions(+), 297 deletions(-) create mode 100644 bats_ai/core/admin/processing_task.py create mode 100644 bats_ai/core/migrations/0015_processingtask.py create mode 100644 bats_ai/core/models/processing_task.py create mode 100644 bats_ai/core/views/processing_tasks.py create mode 100644 client/src/api/NABatApi.ts create mode 100644 client/src/views/NABatAcousticBatch.vue create mode 100644 client/src/views/NABatSpectrogram.vue create mode 100644 scripts/generateSpectrograms.py diff --git a/bats_ai/api.py b/bats_ai/api.py index 03a5fee7..fe503ae7 100644 --- a/bats_ai/api.py +++ b/bats_ai/api.py @@ -7,6 +7,7 @@ ConfigurationRouter, GRTSCellsRouter, GuanoMetadataRouter, + ProcessingTaskRouter, RecordingAnnotationRouter, RecordingRouter, SpeciesRouter, @@ -38,4 +39,5 @@ def global_auth(request): api.add_router('/guano/', GuanoMetadataRouter) api.add_router('/recording-annotation/', RecordingAnnotationRouter) api.add_router('/configuration/', ConfigurationRouter) +api.add_router('/processing-task/', ProcessingTaskRouter) api.add_router('/nabat/acoustic-batch/', AcouticBatchRouter) diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index 6a0cfaca..713cbba2 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -8,6 +8,7 @@ NABatCompressedSpectrogram, NABatSpectrogram, ) +from .processing_task import ProcessingTaskAdmin from .recording import RecordingAdmin from .recording_annotations import RecordingAnnotationAdmin from .species import SpeciesAdmin @@ -29,4 +30,5 @@ 'NABatCompressedSpectrogram', 'NABatSpectrogram', 'AcousticBatch', + 'ProcessingTaskAdmin', ] diff --git a/bats_ai/core/admin/nabat/admin.py b/bats_ai/core/admin/nabat/admin.py index fccb7fb3..db8010d7 100644 --- a/bats_ai/core/admin/nabat/admin.py +++ b/bats_ai/core/admin/nabat/admin.py @@ -51,11 +51,11 @@ class NABatCompressedSpectrogramAdmin(admin.ModelAdmin): list_filter = ('acoustic_batch', 'cache_invalidated') +@admin.register(AcousticBatch) class AcousticBatchAdmin(admin.ModelAdmin): list_display = ( 'name', 'batch_id', - 'recorded_date', 'equipment', 'comments', 'recording_location', @@ -64,7 +64,3 @@ class AcousticBatchAdmin(admin.ModelAdmin): ) search_fields = ('name', 'batch_id', 'recording_location') list_filter = ('name', 'batch_id', 'recording_location') - - -# Register the models under the NaBat category -admin.site.register(AcousticBatch) diff --git a/bats_ai/core/admin/processing_task.py b/bats_ai/core/admin/processing_task.py new file mode 100644 index 00000000..ca45d234 --- /dev/null +++ b/bats_ai/core/admin/processing_task.py @@ -0,0 +1,28 @@ +from django.contrib import admin + +from bats_ai.core.models import ProcessingTask + + +@admin.register(ProcessingTask) +class ProcessingTaskAdmin(admin.ModelAdmin): + list_display = ('id', 'name', 'status', 'created', 'modified', 'celery_id', 'metadata', 'error') + list_filter = ('status', 'created', 'modified') + search_fields = ('name', 'celery_id', 'metadata', 'error') + ordering = ('-created',) + readonly_fields = ('created', 'modified') + fieldsets = ( + (None, {'fields': ('name', 'status', 'celery_id', 'error')}), + ( + 'Metadata', + { + 'classes': ('collapse',), + 'fields': ('metadata',), + }, + ), + ( + 'Timestamps', + { + 'fields': ('created', 'modified'), + }, + ), + ) diff --git a/bats_ai/core/migrations/0015_processingtask.py b/bats_ai/core/migrations/0015_processingtask.py new file mode 100644 index 00000000..994a90a2 --- /dev/null +++ b/bats_ai/core/migrations/0015_processingtask.py @@ -0,0 +1,64 @@ +# Generated by Django 4.1.13 on 2025-02-18 15:31 + +from django.db import migrations, models +import django_extensions.db.fields + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0014_acousticbatch_nabatspectrogram_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProcessingTask', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ('name', models.CharField(max_length=255)), + ('metadata', models.JSONField(blank=True, null=True)), + ( + 'status', + models.CharField( + blank=True, + choices=[ + ('Complete', 'Complete'), + ('Running', 'Running'), + ('Error', 'Error'), + ('Queued', 'Queued'), + ], + help_text='Processing Status', + max_length=255, + ), + ), + ( + 'celery_id', + models.CharField( + blank=True, help_text='Celery Task Id', max_length=255, unique=True + ), + ), + ('output_metadata', models.JSONField(blank=True, null=True)), + ('error', models.TextField(blank=True, help_text='Error text if an error occurs')), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + ] diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index 16944b1d..2d6e0a45 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -3,6 +3,7 @@ from .configuration import Configuration from .grts_cells import GRTSCells from .image import Image +from .processing_task import ProcessingTask from .recording import Recording, colormap from .recording_annotation import RecordingAnnotation from .recording_annotation_status import RecordingAnnotationStatus @@ -23,4 +24,5 @@ 'CompressedSpectrogram', 'RecordingAnnotation', 'Configuration', + 'ProcessingTask', ] diff --git a/bats_ai/core/models/compressed_spectrogram.py b/bats_ai/core/models/compressed_spectrogram.py index 5dd57c18..d5ca2bad 100644 --- a/bats_ai/core/models/compressed_spectrogram.py +++ b/bats_ai/core/models/compressed_spectrogram.py @@ -10,10 +10,6 @@ from .recording import Recording from .spectrogram import Spectrogram -FREQ_MIN = 5e3 -FREQ_MAX = 120e3 -FREQ_PAD = 2e3 - # TimeStampedModel also provides "created" and "modified" fields class CompressedSpectrogram(TimeStampedModel, models.Model): diff --git a/bats_ai/core/models/nabat/acoustic_batch.py b/bats_ai/core/models/nabat/acoustic_batch.py index e99c2cd9..beefc8ed 100644 --- a/bats_ai/core/models/nabat/acoustic_batch.py +++ b/bats_ai/core/models/nabat/acoustic_batch.py @@ -90,9 +90,9 @@ def has_compressed_spectrogram(self): @property def compressed_spectrograms(self): - from bats_ai.core.models import CompressedSpectrogram + from bats_ai.core.models.nabat import NABatCompressedSpectrogram - query = CompressedSpectrogram.objects.filter(acoustic_batch=self).order_by('-created') + query = NABatCompressedSpectrogram.objects.filter(acoustic_batch=self).order_by('-created') return query.all() @property diff --git a/bats_ai/core/models/processing_task.py b/bats_ai/core/models/processing_task.py new file mode 100644 index 00000000..47213d53 --- /dev/null +++ b/bats_ai/core/models/processing_task.py @@ -0,0 +1,31 @@ +from django.db import models +from django_extensions.db.models import TimeStampedModel + + +class ProcessingTask(TimeStampedModel): + class Status(models.TextChoices): + COMPLETE = 'Complete' + RUNNING = 'Running' + ERROR = 'Error' + QUEUED = 'Queued' + + name = models.CharField(max_length=255) + + metadata = models.JSONField(blank=True, null=True) # description and details about the task + status = models.CharField( + max_length=255, # If we need future states + blank=True, + help_text='Processing Status', + choices=Status.choices, + ) + celery_id = models.CharField( + max_length=255, # If we need future states + blank=True, + unique=True, + help_text='Celery Task Id', + ) + output_metadata = models.JSONField( + blank=True, null=True + ) # description and details about the task output (file_items/layers) + + error = models.TextField(blank=True, help_text='Error text if an error occurs') diff --git a/bats_ai/core/models/spectrogram.py b/bats_ai/core/models/spectrogram.py index eaedefbd..a4c936ad 100644 --- a/bats_ai/core/models/spectrogram.py +++ b/bats_ai/core/models/spectrogram.py @@ -1,33 +1,15 @@ import base64 -import io -import logging -import math from PIL import Image -import cv2 -from django.core.files import File from django.core.files.storage import default_storage from django.db import models -from django.db.models.fields.files import FieldFile from django.dispatch import receiver from django_extensions.db.models import TimeStampedModel -import librosa -import matplotlib.pyplot as plt import numpy as np -import tqdm from .recording import Recording -logger = logging.getLogger(__name__) -FREQ_MIN = 5e3 -FREQ_MAX = 120e3 -FREQ_PAD = 2e3 - -COLORMAP_ALLOWED = [None, 'gist_yarg', 'turbo'] - - -# TimeStampedModel also provides "created" and "modified" fields class Spectrogram(TimeStampedModel, models.Model): recording = models.ForeignKey(Recording, on_delete=models.CASCADE) image_file = models.FileField() @@ -38,203 +20,6 @@ class Spectrogram(TimeStampedModel, models.Model): frequency_max = models.IntegerField() # hz colormap = models.CharField(max_length=20, blank=False, null=True) - @classmethod - def generate(cls, recording, colormap=None, dpi=520): - """ - Ref: https://matplotlib.org/stable/users/explain/colors/colormaps.html - """ - wav_file = recording.audio_file - try: - if isinstance(wav_file, FieldFile): - sig, sr = librosa.load(io.BytesIO(wav_file.read()), sr=None) - wav_file.name - else: - sig, sr = librosa.load(wav_file, sr=None) - - duration = len(sig) / sr - except Exception as e: - print(e) - return None - - # Helpful aliases - size_mod = 1 - high_res = False - inference = False - - if colormap in ['inference']: - colormap = None - dpi = 300 - size_mod = 0 - inference = True - if colormap in ['none', 'default', 'dark']: - colormap = None - if colormap in ['light']: - colormap = 'gist_yarg' - if colormap in ['heatmap']: - colormap = 'turbo' - high_res = True - - # Supported colormaps - if colormap not in COLORMAP_ALLOWED: - logger.warning(f'Substituted requested {colormap} colormap to default') - logger.warning('See COLORMAP_ALLOWED') - colormap = None - - # Function to take a signal and return a spectrogram. - size = int(0.001 * sr) # 1.0ms resolution - size = 2 ** (math.ceil(math.log(size, 2)) + size_mod) - hop_length = int(size / 4) - - # Short-time Fourier Transform - window = librosa.stft(sig, n_fft=size, hop_length=hop_length, window='hamming') - - # Calculating and processing data for the spectrogram. - window = np.abs(window) ** 2 - window = librosa.power_to_db(window) - - # Denoise spectrogram - # Subtract median frequency - window -= np.median(window, axis=1, keepdims=True) - - # Subtract mean amplitude - window_ = window[window > 0] - thresh = np.median(window_) - window[window <= thresh] = 0 - - bands = librosa.fft_frequencies(sr=sr, n_fft=size) - for index in range(len(bands)): - band_min = bands[index] - band_max = bands[index + 1] if index < len(bands) - 1 else np.inf - if band_max <= FREQ_MIN or FREQ_MAX <= band_min: - window[index, :] = -1 - - window = np.clip(window, 0, None) - - freq_low = int(FREQ_MIN - FREQ_PAD) - freq_high = int(FREQ_MAX + FREQ_PAD) - vmin = window.min() - vmax = window.max() - - chunksize = int(2e3) - arange = np.arange(chunksize, window.shape[1], chunksize) - chunks = np.array_split(window, arange, axis=1) - - imgs = [] - for chunk in tqdm.tqdm(chunks): - h, w = chunk.shape - alpha = 3 - figsize = (int(math.ceil(w / h)) * alpha + 1, alpha) - fig = plt.figure(figsize=figsize, facecolor='black', dpi=dpi) - ax = plt.axes() - plt.margins(0) - - kwargs = { - 'sr': sr, - 'n_fft': size, - 'hop_length': hop_length, - 'x_axis': 's', - 'y_axis': 'fft', - 'ax': ax, - 'vmin': vmin, - 'vmax': vmax, - } - - # Plot - if colormap is None: - librosa.display.specshow(chunk, **kwargs) - else: - librosa.display.specshow(chunk, cmap=colormap, **kwargs) - - ax.set_ylim(freq_low, freq_high) - ax.axis('off') - - buf = io.BytesIO() - fig.savefig(buf, bbox_inches='tight', pad_inches=0) - - fig.clf() - plt.close() - - buf.seek(0) - img = Image.open(buf) - - img = np.array(img) - mask = img[:, :, -1] - flags = np.where(np.sum(mask != 0, axis=0) == 0)[0] - index = flags.min() if len(flags) > 0 else img.shape[1] - img = img[:, :index, :3] - - imgs.append(img) - - if inference: - w_ = int(4.0 * duration * 1e3) - h_ = int(dpi) - else: - w_ = int(8.0 * duration * 1e3) - h_ = 1200 - - img = np.hstack(imgs) - img = cv2.resize(img, (w_, h_), interpolation=cv2.INTER_LANCZOS4) - - if high_res: - img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) - - noise = 0.1 - img = img.astype(np.float32) - img -= img.min() - img /= img.max() - img *= 255.0 - img /= 1.0 - noise - img[img < 0] = 0 - img[img > 255] = 255 - img = 255.0 - img # invert - - img = cv2.blur(img, (9, 9)) - img = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LANCZOS4) - img = cv2.blur(img, (9, 9)) - - img -= img.min() - img /= img.max() - img *= 255.0 - - mask = (img > 255 * noise).astype(np.float32) - mask = cv2.blur(mask, (5, 5)) - - img[img < 0] = 0 - img[img > 255] = 255 - img = np.around(img).astype(np.uint8) - img = cv2.applyColorMap(img, cv2.COLORMAP_TURBO) - - img = img.astype(np.float32) - img *= mask.reshape(*mask.shape, 1) - img[img < 0] = 0 - img[img > 255] = 255 - img = np.around(img).astype(np.uint8) - - # cv2.imwrite('temp.png', img) - - img = Image.fromarray(img, 'RGB') - w, h = img.size - - buf = io.BytesIO() - img.save(buf, format='JPEG', quality=80) - buf.seek(0) - - name = f'{recording.pk}_{colormap}_spectrogram.jpg' - image_file = File(buf, name=name) - - spectrogram = cls( - recording_id=recording.pk, - image_file=image_file, - width=w, - height=h, - duration=math.ceil(duration * 1e3), - frequency_min=freq_low, - frequency_max=freq_high, - colormap=colormap, - ) - spectrogram.save() - return spectrogram.pk - @property def image_np(self): return np.array(self.image) diff --git a/bats_ai/core/views/__init__.py b/bats_ai/core/views/__init__.py index 37d82d72..0208428f 100644 --- a/bats_ai/core/views/__init__.py +++ b/bats_ai/core/views/__init__.py @@ -2,6 +2,7 @@ from .configuration import router as ConfigurationRouter from .grts_cells import router as GRTSCellsRouter from .guanometadata import router as GuanoMetadataRouter +from .processing_tasks import router as ProcessingTaskRouter from .recording import router as RecordingRouter from .recording_annotation import router as RecordingAnnotationRouter from .species import router as SpeciesRouter @@ -16,4 +17,5 @@ 'GuanoMetadataRouter', 'RecordingAnnotationRouter', 'ConfigurationRouter', + 'ProcessingTaskRouter', ] diff --git a/bats_ai/core/views/nabat/acoustic_batch.py b/bats_ai/core/views/nabat/acoustic_batch.py index 3967d80a..84305ec1 100644 --- a/bats_ai/core/views/nabat/acoustic_batch.py +++ b/bats_ai/core/views/nabat/acoustic_batch.py @@ -4,7 +4,7 @@ from ninja import Form, Schema from ninja.pagination import RouterPaginated -from bats_ai.core.models import colormap +from bats_ai.core.models import ProcessingTask, colormap from bats_ai.core.models.nabat import ( AcousticBatch, AcousticBatchAnnotation, @@ -60,18 +60,39 @@ def generate_acoustic_batch( request: HttpRequest, payload: Form[AcousticBatchGenerateSchema], ): + existing_task = ProcessingTask.objects.filter( + metadata__batchId=payload.batchId, + status__in=[ProcessingTask.Status.QUEUED, ProcessingTask.Status.RUNNING], + ).first() + + if existing_task: + return { + 'error': 'A task is already processing this batchId.', + 'taskId': existing_task.celery_id, + 'status': existing_task.status, + }, 400 + acoustic_batch = AcousticBatch.objects.filter(batch_id=payload.batchId) if not acoustic_batch.exists(): # use a task to start downloading the file using the API key and generate the spectrograms task = acoustic_batch_initialize.delay(payload.batchId, payload.apiToken) - return {'task_id': task.id} + ProcessingTask.objects.create( + name=f'Processing Acoustic BatchId {payload.batchId}', + status=ProcessingTask.Status.QUEUED, + metadata={ + 'type': 'AcousticBatchProcessing', + 'batchId': payload.batchId, + }, + celery_id=task.id, + ) + return {'taskId': task.id} return get_acoustic_batch_spectrogram(request, payload.batchId) @router.get('/') def get_acoustic_batch_spectrogram(request: HttpRequest, id: int): try: - acoustic_batch = AcousticBatch.objects.get(pk=id) + acoustic_batch = AcousticBatch.objects.get(batch_id=id) except AcousticBatch.DoesNotExist: return {'error': 'AcousticBatch not found'} diff --git a/bats_ai/core/views/processing_tasks.py b/bats_ai/core/views/processing_tasks.py new file mode 100644 index 00000000..1e31da16 --- /dev/null +++ b/bats_ai/core/views/processing_tasks.py @@ -0,0 +1,51 @@ +import logging + +from celery.result import AsyncResult +from django.db import transaction +from django.shortcuts import get_object_or_404 +from ninja import Router + +from bats_ai.core.models import ProcessingTask + +logger = logging.getLogger(__name__) + +# class ProcessingTaskSerializer(serializers.ModelSerializer): +# class Meta: +# model = ProcessingTask +# fields = '__all__' + + +router = Router() + +# @router.get("/filtered", response=List[ProcessingTaskSerializer]) +# def filtered_tasks(request, status: Optional[str] = None): +# if status and status not in ProcessingTask.Status.values: +# return {"error": f"Invalid status value. Allowed values are {ProcessingTask.Status.values}."}, 400 + +# tasks = ProcessingTask.objects.filter(status=status) if status else ProcessingTask.objects.all() +# return ProcessingTaskSerializer(tasks, many=True).data + + +@router.get('/{task_id}/details') +def task_details(request, task_id: str): + task = get_object_or_404(ProcessingTask, celery_id=task_id) + celery_task = AsyncResult(task.celery_id) + celery_data = { + 'state': celery_task.state, + 'status': task.status, + 'info': celery_task.info if not isinstance(celery_task.info, Exception) else None, + 'error': task.error + or (str(celery_task.info) if isinstance(celery_task.info, Exception) else None), + } + return {'task': task.name, 'celery_data': celery_data} + + +@router.post('/{task_id}/cancel') +def cancel_task(request, task_id: str): + task = get_object_or_404(ProcessingTask, pk=task_id) + with transaction.atomic(): + task.delete() + celery_task = AsyncResult(task.celery_id) + if celery_task: + celery_task.revoke(terminate=True) + return {'message': 'Task successfully canceled.'}, 202 diff --git a/bats_ai/settings.py b/bats_ai/settings.py index ef209a06..aec6bf12 100644 --- a/bats_ai/settings.py +++ b/bats_ai/settings.py @@ -24,6 +24,7 @@ class BatsAiMixin(ConfigMixin): FILE_UPLOAD_HANDLERS = [ 'django.core.files.uploadhandler.TemporaryFileUploadHandler', ] + CELERY_RESULT_BACKEND = 'django-db' @staticmethod def mutate_configuration(configuration: ComposedConfiguration) -> None: @@ -36,6 +37,7 @@ def mutate_configuration(configuration: ComposedConfiguration) -> None: configuration.INSTALLED_APPS += [ 'django.contrib.gis', 'django_large_image', + 'django_celery_results', ] configuration.MIDDLEWARE = [ diff --git a/bats_ai/tasks/nabat/nabat_data_retrieval.py b/bats_ai/tasks/nabat/nabat_data_retrieval.py index 85885e93..bebb4730 100644 --- a/bats_ai/tasks/nabat/nabat_data_retrieval.py +++ b/bats_ai/tasks/nabat/nabat_data_retrieval.py @@ -2,11 +2,11 @@ import logging import tempfile -from celery import shared_task from django.contrib.gis.geos import Point import requests -from bats_ai.core.models import Species +from bats_ai.celery import app +from bats_ai.core.models import ProcessingTask, Species from bats_ai.core.models.nabat import AcousticBatch from .tasks import generate_compress_spectrogram, generate_spectrogram, predict @@ -87,11 +87,19 @@ """ -@shared_task -def acoustic_batch_initialize(batch_id: int, api_token: str): +@app.task(bind=True) +def acoustic_batch_initialize(self, batch_id: int, api_token: str): + processing_task = ProcessingTask.objects.filter(celery_id=self.request.id) + processing_task.update(status=ProcessingTask.Status.RUNNING) headers = {'Authorization': f'Bearer {api_token}', 'Content-Type': 'application/json'} base_query = QUERY.format(batch_id=batch_id) - response = requests.post(BASE_URL, json={'query': base_query}, headers=headers) + try: + response = requests.post(BASE_URL, json={'query': base_query}, headers=headers) + except Exception as e: + processing_task.update( + status=ProcessingTask.Status.ERROR, error=f'Error with API Reqeust: {e}' + ) + raise batch_data = {} if response.status_code == 200: @@ -102,9 +110,16 @@ def acoustic_batch_initialize(batch_id: int, api_token: str): logger.info('Data successfully fetched and saved to output.json') except (KeyError, TypeError, json.JSONDecodeError) as e: logger.error(f'Error processing batch data: {e}') - return + processing_task.update( + status=ProcessingTask.Status.ERROR, error=f'Error processing batch data: {e}' + ) + raise else: logger.error(f'Failed to fetch data: {response.status_code}, {response.text}') + processing_task.update( + status=ProcessingTask.Status.ERROR, + error=f'Failed to fetch data: {response.status_code}, {response.text}', + ) return try: @@ -112,14 +127,21 @@ def acoustic_batch_initialize(batch_id: int, api_token: str): file_key = f'{PROJECT_ID}/{file_name}' except (KeyError, TypeError) as e: logger.error(f'Error extracting file information: {e}') - return + processing_task.update( + status=ProcessingTask.Status.ERROR, error=f'Error extracting file information: {e}' + ) + raise presigned_query = PRESIGNED_URL_QUERY.format(key=file_key) logger.info('Fetching presigned URL...') response = requests.post(BASE_URL, json={'query': presigned_query}, headers=headers) if response.status_code != 200: logger.error(f'Failed to fetch presigned URL: {response.status_code}, {response.text}') - return + processing_task.update( + status=ProcessingTask.Status.ERROR, + error=f'Failed to fetch presigned URL: {response.status_code}, {response.text}', + ) + raise try: presigned_data = response.json() @@ -127,10 +149,17 @@ def acoustic_batch_initialize(batch_id: int, api_token: str): presigned_url = url_info['s3PresignedUrl'] if not url_info['success']: logger.error(f'Failed to get presigned URL: {url_info["message"]}') + processing_task.update( + status=ProcessingTask.Status.ERROR, + error=f'Failed to get presigned URL: {url_info["message"]}', + ) return except (KeyError, TypeError) as e: logger.error(f'Error extracting presigned URL: {e}') - return + processing_task.update( + status=ProcessingTask.Status.ERROR, error=f'Error extracting presigned URL: {e}' + ) + raise logger.info('Presigned URL obtained. Downloading file...') @@ -145,7 +174,7 @@ def acoustic_batch_initialize(batch_id: int, api_token: str): # Now create the AcousticBatch using the response data logger.info('Creating Acoustic Batch...') - acoustic_batch = create_acoustic_batch_from_response(batch_data) + acoustic_batch = create_acoustic_batch_from_response(batch_data, batch_id) # Call generate_spectrogram with the acoustic_batch and the temporary file logger.info('Generating spectrogram...') @@ -155,21 +184,35 @@ def acoustic_batch_initialize(batch_id: int, api_token: str): acoustic_batch.pk, spectrogram.pk ) logger.info('Running Prediction...') - predict(compressed_spectrogram.pk) + try: + predict(compressed_spectrogram.pk) + except Exception as e: + logger.error(f'Error Performing Prediction: {e}') + processing_task.update( + status=ProcessingTask.Status.ERROR, + error=f'Error extracting presigned URL: {e}', + ) + raise + processing_task.update(status=ProcessingTask.Status.COMPLETE) else: + processing_task.update( + status=ProcessingTask.Status.ERROR, + error=f'Failed to download file: {file_response.status_code}', + ) logger.error(f'Failed to download file: {file_response.status_code}') except Exception as e: + processing_task.update(status=ProcessingTask.Status.ERROR, error=str(e)) logger.error(f'Error handling file download or temporary file: {e}') + raise -def create_acoustic_batch_from_response(response_data): +def create_acoustic_batch_from_response(response_data, batch_id): try: # Extract the batch data from the response acoustic_batch_data = response_data['data']['acousticFileBatchById'] # Extract nested data from the response - batch_id = acoustic_batch_data['batchId'] software_name = acoustic_batch_data['acousticBatchByBatchId']['softwareBySoftwareId'][ 'name' ] diff --git a/bats_ai/tasks/tasks.py b/bats_ai/tasks/tasks.py index 65fd580a..31a6e997 100644 --- a/bats_ai/tasks/tasks.py +++ b/bats_ai/tasks/tasks.py @@ -1,10 +1,13 @@ import io +import math import tempfile from PIL import Image from celery import shared_task import cv2 from django.core.files import File +import librosa +import matplotlib.pyplot as plt import numpy as np import scipy @@ -18,6 +21,185 @@ colormap, ) +FREQ_MIN = 5e3 +FREQ_MAX = 120e3 +FREQ_PAD = 2e3 + +COLORMAP_ALLOWED = [None, 'gist_yarg', 'turbo'] + + +def generate_spectrogram(recording, file, colormap=None, dpi=520): + try: + sig, sr = librosa.load(file, sr=None) + duration = len(sig) / sr + except Exception as e: + print(f'Error loading file: {e}') + return None + + size_mod = 1 + high_res = False + + if colormap in ['inference']: + colormap = None + dpi = 300 + size_mod = 0 + if colormap in ['none', 'default', 'dark']: + colormap = None + if colormap in ['light']: + colormap = 'gist_yarg' + if colormap in ['heatmap']: + colormap = 'turbo' + high_res = True + + if colormap not in COLORMAP_ALLOWED: + print(f'Substituted requested {colormap} colormap to default') + colormap = None + + size = int(0.001 * sr) # 1.0ms resolution + size = 2 ** (math.ceil(math.log(size, 2)) + size_mod) + hop_length = int(size / 4) + + window = librosa.stft(sig, n_fft=size, hop_length=hop_length, window='hamming') + window = np.abs(window) ** 2 + window = librosa.power_to_db(window) + + window -= np.median(window, axis=1, keepdims=True) + window_ = window[window > 0] + thresh = np.median(window_) + window[window <= thresh] = 0 + + bands = librosa.fft_frequencies(sr=sr, n_fft=size) + for index in range(len(bands)): + band_min = bands[index] + band_max = bands[index + 1] if index < len(bands) - 1 else np.inf + if band_max <= FREQ_MIN or FREQ_MAX <= band_min: + window[index, :] = -1 + + window = np.clip(window, 0, None) + + freq_low = int(FREQ_MIN - FREQ_PAD) + freq_high = int(FREQ_MAX + FREQ_PAD) + vmin = window.min() + vmax = window.max() + + chunksize = int(2e3) + arange = np.arange(chunksize, window.shape[1], chunksize) + chunks = np.array_split(window, arange, axis=1) + + imgs = [] + for chunk in chunks: + h, w = chunk.shape + alpha = 3 + figsize = (int(math.ceil(w / h)) * alpha + 1, alpha) + fig = plt.figure(figsize=figsize, facecolor='black', dpi=dpi) + ax = plt.axes() + plt.margins(0) + + kwargs = { + 'sr': sr, + 'n_fft': size, + 'hop_length': hop_length, + 'x_axis': 's', + 'y_axis': 'fft', + 'ax': ax, + 'vmin': vmin, + 'vmax': vmax, + } + + if colormap is None: + librosa.display.specshow(chunk, **kwargs) + else: + librosa.display.specshow(chunk, cmap=colormap, **kwargs) + + ax.set_ylim(freq_low, freq_high) + ax.axis('off') + + buf = io.BytesIO() + fig.savefig(buf, bbox_inches='tight', pad_inches=0) + + fig.clf() + plt.close() + + buf.seek(0) + img = Image.open(buf) + + img = np.array(img) + mask = img[:, :, -1] + flags = np.where(np.sum(mask != 0, axis=0) == 0)[0] + index = flags.min() if len(flags) > 0 else img.shape[1] + img = img[:, :index, :3] + + imgs.append(img) + + w_ = int(8.0 * duration * 1e3) + h_ = 1200 + + img = np.hstack(imgs) + img = cv2.resize(img, (w_, h_), interpolation=cv2.INTER_LANCZOS4) + + if high_res: + img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + + noise = 0.1 + img = img.astype(np.float32) + img -= img.min() + img /= img.max() + img *= 255.0 + img /= 1.0 - noise + img[img < 0] = 0 + img[img > 255] = 255 + img = 255.0 - img + + img = cv2.blur(img, (9, 9)) + img = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LANCZOS4) + img = cv2.blur(img, (9, 9)) + + img -= img.min() + img /= img.max() + img *= 255.0 + + mask = (img > 255 * noise).astype(np.float32) + mask = cv2.blur(mask, (5, 5)) + + img[img < 0] = 0 + img[img > 255] = 255 + img = np.around(img).astype(np.uint8) + img = cv2.applyColorMap(img, cv2.COLORMAP_TURBO) + + img = img.astype(np.float32) + img *= mask.reshape(*mask.shape, 1) + img[img < 0] = 0 + img[img > 255] = 255 + img = np.around(img).astype(np.uint8) + + img = Image.fromarray(img, 'RGB') + w, h = img.size + + # Save image to temporary file + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') + img.save(temp_file, format='JPEG', quality=80) + temp_file.seek(0) + + # Create new NABatSpectrogram + image_file = File(temp_file, name=f'{recording.id}_spectrogram.jpg') + + spectrogram = Spectrogram.objects.create( + recording=recording, + image_file=image_file, + width=w, + height=h, + duration=math.ceil(duration * 1e3), # duration in ms + frequency_min=freq_low, + frequency_max=freq_high, + colormap=colormap, + ) + + spectrogram.save() + # Clean up temporary file + temp_file.close() + + return spectrogram + def generate_compressed(recording: Recording, spectrogram: Spectrogram): img = spectrogram.image_np @@ -182,9 +364,9 @@ def recording_compute_spectrogram(recording_id: int): spectrogram_id = None for cmap in cmaps: with colormap(cmap): - spectrogram_id_temp = Spectrogram.generate(recording, cmap) + spectrogram_temp = generate_spectrogram(recording, recording.audio_file, cmap) if cmap is None: - spectrogram_id = spectrogram_id_temp + spectrogram_id = spectrogram_temp.pk if spectrogram_id is not None: compressed_spectro = generate_compress_spectrogram(recording_id, spectrogram_id) predict(compressed_spectro.pk) diff --git a/client/src/App.vue b/client/src/App.vue index 4a594d0b..e0e9450e 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -59,7 +59,7 @@ export default defineComponent({ }); const isAdmin = computed(() => configuration.value.is_admin); - + const isNaBat = computed(() => route.path.includes('nabat')); return { oauthClient, containsSpectro, @@ -69,6 +69,7 @@ export default defineComponent({ nextShared, sideTab, isAdmin, + isNaBat, }; }, }); @@ -78,7 +79,7 @@ export default defineComponent({ @@ -108,6 +109,9 @@ export default defineComponent({ Admin +

+ NA Bat Spectrogram Viewer +

('/nabat/acoustic-batch/', formData))).data; + if (isAcousticBatchCompleteResponse(response)) { + return response as AcousticBatchCompleteResponse; + } + return response as Spectrogram; +} + + +export { + postAcousticBatch, +}; \ No newline at end of file diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 9bf12d40..6ba92af2 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -357,6 +357,7 @@ async function getCellfromLocation(latitude: number, longitude: number) { return axiosInstance.get(`/grts/grid_cell_id`, {params: {latitude, longitude}}); } + export interface ConfigurationSettings { display_pulse_annotations: boolean; display_sequence_annotations: boolean; @@ -372,6 +373,49 @@ async function patchConfiguration(config: ConfigurationSettings) { return axiosInstance.patch('/configuration/', {...config }); } +export interface ProcessingTask { + id: number; + created: string; + modified: string; + name: string; + file_items: number[]; + error? : string; + info?: string; + status: 'Complete' | 'Running' | 'Error' | 'Queued'; + metadata: Record & { type?: 'AcousticBatchProcessing' } & { batchId: string }; + output_metadata: Record; +} +export interface ProcessingTaskDetails { + name: string; + celery_data: { + "state": 'PENDING' | 'RECEIVED' | 'STARTED' | 'SUCCESS' | 'FAILURE' | 'RETRY' | 'REVOKED', + "status": ProcessingTask['status'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + info: Record + error:string; + } + +} + +async function getProcessingTasks(): Promise { + return (await axiosInstance.get('/processing-task')).data; + } + + async function getProcessingTaskDetails(taskId: string): Promise { + return (await axiosInstance.get(`/processing-task/${taskId}/details`)).data; + } + + async function getFilteredProcessingTasks( + status: ProcessingTask['status'], + ): Promise { + return (await axiosInstance.get('/processing-task/filtered/', { params: { status } })).data; + } + + async function cancelProcessingTask(taskId: number): Promise<{ detail: string }> { + return (await axiosInstance.post(`/processing-task/${taskId}/cancel/`)).data; + } + + interface GuanoMetadata { nabat_grid_cell_grts_id?: string nabat_latitude?: number @@ -426,4 +470,8 @@ export { deleteFileAnnotation, getConfiguration, patchConfiguration, + getProcessingTasks, + getProcessingTaskDetails, + cancelProcessingTask, + getFilteredProcessingTasks, }; \ No newline at end of file diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 79efad63..6fe9eef4 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -6,6 +6,7 @@ import Login from '../views/Login.vue'; import oauthClient from '../plugins/Oauth'; import Admin from '../views/Admin.vue'; +import NABatAcousticBatch from '../views/NABatAcousticBatch.vue'; function beforeEach( to: RouteLocationNormalized, @@ -48,6 +49,14 @@ function routerInit(){ component: Spectrogram, props: true, }, + { + path: '/nabat/:batchId/', + component: NABatAcousticBatch, + props: (route) => ({ + batchId: parseInt(route.params.batchId as string, 10), + apiToken: route.query.apiToken, + }), + }, ], }); diff --git a/client/src/views/Admin.vue b/client/src/views/Admin.vue index ff3c26b1..6eda7fc4 100644 --- a/client/src/views/Admin.vue +++ b/client/src/views/Admin.vue @@ -20,7 +20,7 @@ import { patchConfiguration } from '../api/api'; // Function to save the settings const saveSettings = async () => { // Mock save function: replace with API call if necessary - const result = await patchConfiguration( { + await patchConfiguration( { display_pulse_annotations: settings.displayPulseAnnotations, display_sequence_annotations: settings.displaySequenceAnnotations }); @@ -60,8 +60,8 @@ import { patchConfiguration } from '../api/api'; Save diff --git a/client/src/views/NABatAcousticBatch.vue b/client/src/views/NABatAcousticBatch.vue new file mode 100644 index 00000000..164e3c3e --- /dev/null +++ b/client/src/views/NABatAcousticBatch.vue @@ -0,0 +1,100 @@ + + + diff --git a/client/src/views/NABatSpectrogram.vue b/client/src/views/NABatSpectrogram.vue new file mode 100644 index 00000000..900093cb --- /dev/null +++ b/client/src/views/NABatSpectrogram.vue @@ -0,0 +1,387 @@ + + + + + diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 15b564bf..70699311 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,4 +1,3 @@ -version: '3' services: django: build: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 234e2a14..0d9859b0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # COMMENTED OUT UNTIL READY TO TEST traefik: @@ -138,29 +136,6 @@ services: volumes: - minio:/data - flower: - env_file: ./dev/.env.prod.docker-compose - build: - context: . - dockerfile: ./dev/django.Dockerfile - command: [ - "celery", - "--app", "bats_ai.celery", - "flower" - ] - tty: false - volumes: - - .:/opt/django-project - networks: - - django-nginx - ports: - - ${DOCKER_FLOWER_PORT-5555}:5555 - depends_on: - - postgres - - rabbitmq - - minio - - celery - volumes: postgres: sourcedb: diff --git a/docker-compose.yml b/docker-compose.yml index bab8bd6d..edff032c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: postgres: image: postgis/postgis:latest @@ -32,27 +31,6 @@ services: volumes: - minio:/data - flower: - build: - context: . - dockerfile: ./dev/django.Dockerfile - command: [ - "celery", - "--app", "bats_ai.celery", - "flower" - ] - tty: false - env_file: ./dev/.env.docker-compose - volumes: - - .:/opt/django-project - ports: - - ${DOCKER_FLOWER_PORT-5555}:5555 - depends_on: - - postgres - - rabbitmq - - minio - - celery - volumes: postgres: sourcedb: diff --git a/scripts/generateSpectrograms.py b/scripts/generateSpectrograms.py new file mode 100644 index 00000000..c2fb5b19 --- /dev/null +++ b/scripts/generateSpectrograms.py @@ -0,0 +1,207 @@ +import io +import json +import math +import os +import traceback + +from PIL import Image +import click +import cv2 +import librosa +import matplotlib.pyplot as plt +import numpy as np +import tqdm + +FREQ_MIN = 5e3 +FREQ_MAX = 120e3 +FREQ_PAD = 2e3 + +COLORMAP_ALLOWED = [None, 'gist_yarg', 'turbo'] + + +def generate_spectrogram(wav_path, output_folder, colormap=None): + try: + sig, sr = librosa.load(wav_path, sr=None) + duration = len(sig) / sr + size_mod = 1 + high_res = False + + if colormap in ['inference']: + colormap = None + size_mod = 0 + elif colormap in ['none', 'default', 'dark']: + colormap = None + elif colormap in ['light']: + colormap = 'gist_yarg' + elif colormap in ['heatmap']: + colormap = 'turbo' + high_res = True + + if colormap not in COLORMAP_ALLOWED: + click.echo(f'Substituted requested {colormap} colormap to default') + colormap = None + + size = int(0.001 * sr) # 1.0ms resolution + size = 2 ** (math.ceil(math.log(size, 2)) + size_mod) + hop_length = int(size / 4) + + window = librosa.stft(sig, n_fft=size, hop_length=hop_length, window='hamming') + window = np.abs(window) ** 2 + window = librosa.power_to_db(window) + + # Denoise spectrogram + window -= np.median(window, axis=1, keepdims=True) + window_ = window[window > 0] + thresh = np.median(window_) + window[window <= thresh] = 0 + + bands = librosa.fft_frequencies(sr=sr, n_fft=size) + for index in range(len(bands)): + band_min = bands[index] + band_max = bands[index + 1] if index < len(bands) - 1 else np.inf + if band_max <= FREQ_MIN or FREQ_MAX <= band_min: + window[index, :] = -1 + + window = np.clip(window, 0, None) + + freq_low = int(FREQ_MIN - FREQ_PAD) + freq_high = int(FREQ_MAX + FREQ_PAD) + vmin = window.min() + vmax = window.max() + + chunksize = int(2e3) + arange = np.arange(chunksize, window.shape[1], chunksize) + chunks = np.array_split(window, arange, axis=1) + + imgs = [] + for chunk in tqdm.tqdm(chunks, desc=f'Processing {os.path.basename(wav_path)}'): + h, w = chunk.shape + alpha = 3 + figsize = (int(math.ceil(w / h)) * alpha + 1, alpha) + fig = plt.figure(figsize=figsize, facecolor='black', dpi=300) + ax = plt.axes() + plt.margins(0) + + kwargs = { + 'sr': sr, + 'n_fft': size, + 'hop_length': hop_length, + 'x_axis': 's', + 'y_axis': 'fft', + 'ax': ax, + 'vmin': vmin, + 'vmax': vmax, + } + + if colormap is None: + librosa.display.specshow(chunk, **kwargs) + else: + librosa.display.specshow(chunk, cmap=colormap, **kwargs) + + ax.set_ylim(freq_low, freq_high) + ax.axis('off') + + buf = io.BytesIO() + fig.savefig(buf, bbox_inches='tight', pad_inches=0) + plt.close(fig) + + buf.seek(0) + img = Image.open(buf) + + img = np.array(img) + mask = img[:, :, -1] + flags = np.where(np.sum(mask != 0, axis=0) == 0)[0] + index = flags.min() if len(flags) > 0 else img.shape[1] + img = img[:, :index, :3] + + imgs.append(img) + + w_ = int(8.0 * duration * 1e3) + h_ = 1200 + + img = np.hstack(imgs) + img = cv2.resize(img, (w_, h_), interpolation=cv2.INTER_LANCZOS4) + + if high_res: + img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + + noise = 0.1 + img = img.astype(np.float32) + img -= img.min() + img /= img.max() + img *= 255.0 + img /= 1.0 - noise + img[img < 0] = 0 + img[img > 255] = 255 + img = 255.0 - img + + img = cv2.blur(img, (9, 9)) + img = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LANCZOS4) + img = cv2.blur(img, (9, 9)) + + img -= img.min() + img /= img.max() + img *= 255.0 + + mask = (img > 255 * noise).astype(np.float32) + mask = cv2.blur(mask, (5, 5)) + + img[img < 0] = 0 + img[img > 255] = 255 + img = np.around(img).astype(np.uint8) + img = cv2.applyColorMap(img, cv2.COLORMAP_TURBO) + + img = img.astype(np.float32) + img *= mask.reshape(*mask.shape, 1) + img[img < 0] = 0 + img[img > 255] = 255 + img = np.around(img).astype(np.uint8) + + img = Image.fromarray(img, 'RGB') + output_path = os.path.join( + output_folder, os.path.splitext(os.path.basename(wav_path))[0] + '.jpg' + ) + img.save(output_path, format='JPEG', quality=80) + + return {'file': wav_path, 'status': 'success', 'output': output_path} + + except Exception as e: + return { + 'file': wav_path, + 'status': 'error', + 'error': str(e), + 'traceback': traceback.format_exc(), + } + + +@click.command() +@click.argument('input_folder', type=click.Path(exists=True)) +@click.argument('output_folder', type=click.Path(), default='./outputs') +@click.option( + '--colormap', + type=str, + default=None, + help='Colormap for spectrograms (default, light, heatmap).', +) +def process_wav_files(input_folder, output_folder, colormap): + os.makedirs(output_folder, exist_ok=True) + results = [] + + for root, _, files in os.walk(input_folder): + for file in files: + if file.lower().endswith('.wav'): + wav_path = os.path.join(root, file) + result = generate_spectrogram(wav_path, output_folder, colormap) + results.append(result) + + results.sort(key=lambda x: x['status'], reverse=True) + + log_file = os.path.join(output_folder, 'conversion_log.json') + with open(log_file, 'w') as f: + json.dump(results, f, indent=4) + + click.echo(f'Processing complete. Log saved to {log_file}') + + +if __name__ == '__main__': + process_wav_files() diff --git a/setup.py b/setup.py index 02f4b0ff..1a3423ca 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,6 @@ 'django-composed-configuration[prod]>=0.20', 'django-s3-file-field[boto3]<1', 'gunicorn', - 'flower', # Spectrogram Generation 'librosa', 'matplotlib', @@ -70,6 +69,7 @@ 'rio-cogeo', # guano metadata 'guano', + 'django_celery_results', ], extras_require={ 'dev': [ From ab9977d2de80ff5a430e5fba3f075a7cef68e413 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 19 Feb 2025 13:15:41 -0500 Subject: [PATCH 08/43] nabat integration --- .../nabat/nabat_compressed_spectrogram.py | 2 +- bats_ai/core/views/nabat/acoustic_batch.py | 83 ++++++++++++++++++- bats_ai/tasks/nabat/nabat_data_retrieval.py | 24 ++++++ bats_ai/tasks/nabat/tasks.py | 2 +- client/src/App.vue | 7 +- client/src/api/NABatApi.ts | 27 +++++- .../src/components/RecordingAnnotations.vue | 17 +++- client/src/components/geoJS/LayerManager.vue | 2 +- .../components/geoJS/layers/legendLayer.ts | 28 ++++--- client/src/router/index.ts | 7 ++ client/src/views/NABatAcousticBatch.vue | 75 +++++++++++------ client/src/views/NABatSpectrogram.vue | 61 +++++++------- 12 files changed, 254 insertions(+), 81 deletions(-) diff --git a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py index 6d9489fa..1dbdcbda 100644 --- a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py @@ -40,7 +40,7 @@ def predict(self): img = Image.open(self.image_file) - relative = ('..',) * 4 + relative = ('..',) * 5 asset_path = os.path.abspath(os.path.join(__file__, *relative, 'assets')) onnx_filename = os.path.join(asset_path, 'model.mobilenet.onnx') diff --git a/bats_ai/core/views/nabat/acoustic_batch.py b/bats_ai/core/views/nabat/acoustic_batch.py index 84305ec1..038308a6 100644 --- a/bats_ai/core/views/nabat/acoustic_batch.py +++ b/bats_ai/core/views/nabat/acoustic_batch.py @@ -39,7 +39,6 @@ class AcousticBatchAnnotationSchema(Schema): species: list[SpeciesSchema] | None comments: str | None = None model: str | None = None - owner: str confidence: float id: int | None = None @@ -47,7 +46,6 @@ class AcousticBatchAnnotationSchema(Schema): def from_orm(cls, obj: AcousticBatchAnnotation, **kwargs): return cls( species=[SpeciesSchema.from_orm(species) for species in obj.species.all()], - owner=obj.owner.username, confidence=obj.confidence, comments=obj.comments, model=obj.model, @@ -86,7 +84,7 @@ def generate_acoustic_batch( celery_id=task.id, ) return {'taskId': task.id} - return get_acoustic_batch_spectrogram(request, payload.batchId) + return {'acousticId': acoustic_batch.first().pk} @router.get('/') @@ -125,7 +123,7 @@ def get_acoustic_batch_spectrogram(request: HttpRequest, id: int): return spectro_data -@router.get('/{acoustc_batch_id}/recording-annotations') +@router.get('/{acoustic_batch_id}/recording-annotations') def get_acoustic_batch_annotation(request: HttpRequest, acoustic_batch_id: int): fileAnnotations = AcousticBatchAnnotation.objects.filter( acoustic_batch=acoustic_batch_id @@ -159,3 +157,80 @@ def predict_spectrogram_compressed(request: HttpRequest, id: int): 'confidences': sorted_confidences, } return output + + +@router.get('/{id}/spectrogram') +def get_spectrogram(request: HttpRequest, id: int): + try: + acoustic_batch = AcousticBatch.objects.get(pk=id) + except AcousticBatch.DoesNotExist: + return {'error': 'Recording not found'} + + with colormap(None): + spectrogram = acoustic_batch.spectrogram + + compressed = acoustic_batch.compressed_spectrogram + + spectro_data = { + 'url': spectrogram.image_url, + 'spectroInfo': { + 'spectroId': spectrogram.pk, + 'width': spectrogram.width, + 'height': spectrogram.height, + 'start_time': 0, + 'end_time': spectrogram.duration, + 'low_freq': spectrogram.frequency_min, + 'high_freq': spectrogram.frequency_max, + }, + } + if compressed: + spectro_data['compressed'] = { + 'start_times': compressed.starts, + 'end_times': compressed.stops, + } + + # Serialize the annotations using AnnotationSchema + annotations_data = [] + temporal_annotations_data = [] + + spectro_data['annotations'] = annotations_data + spectro_data['temporal'] = temporal_annotations_data + return spectro_data + + +@router.get('/{id}/spectrogram/compressed') +def get_spectrogram_compressed(request: HttpRequest, id: int): + try: + acoustic_batch = AcousticBatch.objects.get(pk=id) + compressed_spectrogram = NABatCompressedSpectrogram.objects.filter( + acoustic_batch=id + ).first() + except compressed_spectrogram.DoesNotExist: + return {'error': 'Compressed Spectrogram'} + except acoustic_batch.DoesNotExist: + return {'error': 'Recording does not exist'} + + spectro_data = { + 'url': compressed_spectrogram.image_url, + 'spectroInfo': { + 'spectroId': compressed_spectrogram.pk, + 'width': compressed_spectrogram.spectrogram.width, + 'start_time': 0, + 'end_time': compressed_spectrogram.spectrogram.duration, + 'height': compressed_spectrogram.spectrogram.height, + 'low_freq': compressed_spectrogram.spectrogram.frequency_min, + 'high_freq': compressed_spectrogram.spectrogram.frequency_max, + 'start_times': compressed_spectrogram.starts, + 'end_times': compressed_spectrogram.stops, + 'widths': compressed_spectrogram.widths, + 'compressedWidth': compressed_spectrogram.length, + }, + } + + # Serialize the annotations using AnnotationSchema + annotations_data = [] + temporal_annotations_data = [] + + spectro_data['annotations'] = annotations_data + spectro_data['temporal'] = temporal_annotations_data + return spectro_data diff --git a/bats_ai/tasks/nabat/nabat_data_retrieval.py b/bats_ai/tasks/nabat/nabat_data_retrieval.py index bebb4730..b8432c2f 100644 --- a/bats_ai/tasks/nabat/nabat_data_retrieval.py +++ b/bats_ai/tasks/nabat/nabat_data_retrieval.py @@ -93,6 +93,10 @@ def acoustic_batch_initialize(self, batch_id: int, api_token: str): processing_task.update(status=ProcessingTask.Status.RUNNING) headers = {'Authorization': f'Bearer {api_token}', 'Content-Type': 'application/json'} base_query = QUERY.format(batch_id=batch_id) + self.update_state( + state='Progress', + meta={'description': 'Fetching NAbat Recording Data'}, + ) try: response = requests.post(BASE_URL, json={'query': base_query}, headers=headers) except Exception as e: @@ -133,6 +137,11 @@ def acoustic_batch_initialize(self, batch_id: int, api_token: str): raise presigned_query = PRESIGNED_URL_QUERY.format(key=file_key) logger.info('Fetching presigned URL...') + self.update_state( + state='Progress', + meta={'description': 'Fetching Recording File'}, + ) + response = requests.post(BASE_URL, json={'query': presigned_query}, headers=headers) if response.status_code != 200: @@ -178,12 +187,27 @@ def acoustic_batch_initialize(self, batch_id: int, api_token: str): # Call generate_spectrogram with the acoustic_batch and the temporary file logger.info('Generating spectrogram...') + self.update_state( + state='Progress', + meta={'description': 'Generating Spectrogram'}, + ) + spectrogram = generate_spectrogram(acoustic_batch, open(temp_file_path, 'rb')) logger.info('Generating compressed spectrogram...') + self.update_state( + state='Progress', + meta={'description': 'Generating Compressed Spectrogram'}, + ) + compressed_spectrogram = generate_compress_spectrogram( acoustic_batch.pk, spectrogram.pk ) logger.info('Running Prediction...') + self.update_state( + state='Progress', + meta={'description': 'Running Prediction'}, + ) + try: predict(compressed_spectrogram.pk) except Exception as e: diff --git a/bats_ai/tasks/nabat/tasks.py b/bats_ai/tasks/nabat/tasks.py index 64c05028..676ff03e 100644 --- a/bats_ai/tasks/nabat/tasks.py +++ b/bats_ai/tasks/nabat/tasks.py @@ -378,7 +378,7 @@ def predict(compressed_spectrogram_id: int): species = Species.objects.filter(species_code=label) acoustic_batch_annotation = AcousticBatchAnnotation.objects.create( - recording=compressed_spectrogram.recording, + acoustic_batch=compressed_spectrogram.acoustic_batch, comments='Compressed Spectrogram Generation Prediction', model='model.mobilenet.onnx', confidence=output['score'], diff --git a/client/src/App.vue b/client/src/App.vue index e0e9450e..a5bda9cb 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -59,7 +59,7 @@ export default defineComponent({ }); const isAdmin = computed(() => configuration.value.is_admin); - const isNaBat = computed(() => route.path.includes('nabat')); + const isNaBat = computed(() => (route.path.includes('nabat'))); return { oauthClient, containsSpectro, @@ -109,7 +109,10 @@ export default defineComponent({ Admin -

+

NA Bat Spectrogram Viewer

diff --git a/client/src/api/NABatApi.ts b/client/src/api/NABatApi.ts index 8a4f70ee..0be56826 100644 --- a/client/src/api/NABatApi.ts +++ b/client/src/api/NABatApi.ts @@ -1,11 +1,15 @@ -import { axiosInstance, ProcessingTask, Spectrogram } from "./api"; +import { axiosInstance, FileAnnotation, ProcessingTask, Spectrogram } from "./api"; export interface AcousticBatchCompleteResponse { error?: string; taskId: string; status?: ProcessingTask['status']; } -export type AcousticBatchResponse = AcousticBatchCompleteResponse | Spectrogram; + +export interface AcousticBatchDataResponse { + acousticId: string; +} +export type AcousticBatchResponse = AcousticBatchCompleteResponse | AcousticBatchDataResponse; function isAcousticBatchCompleteResponse(response: AcousticBatchResponse): response is AcousticBatchCompleteResponse { return "taskId" in response; @@ -19,10 +23,27 @@ async function postAcousticBatch(batchId: number, apiToken: string) { if (isAcousticBatchCompleteResponse(response)) { return response as AcousticBatchCompleteResponse; } - return response as Spectrogram; + return response as AcousticBatchDataResponse; +} + +async function getSpectrogram(id: string) { + return axiosInstance.get(`/nabat/acoustic-batch/${id}/spectrogram`); +} + +async function getSpectrogramCompressed(id: string) { + return axiosInstance.get(`/nabat/acoustic-batch/${id}/spectrogram/compressed`); + } +async function getAcousticFileAnnotations(batchId: number) { + return axiosInstance.get(`/nabat/acoustic-batch/${batchId}/recording-annotations`); +} + + export { postAcousticBatch, + getSpectrogram, + getSpectrogramCompressed, + getAcousticFileAnnotations, }; \ No newline at end of file diff --git a/client/src/components/RecordingAnnotations.vue b/client/src/components/RecordingAnnotations.vue index 72a8b4d2..9ba167f0 100644 --- a/client/src/components/RecordingAnnotations.vue +++ b/client/src/components/RecordingAnnotations.vue @@ -3,6 +3,7 @@ import { defineComponent, onMounted, PropType, Ref } from "vue"; import { ref } from "vue"; import { FileAnnotation, getFileAnnotations, putFileAnnotation, Species, UpdateFileAnnotation } from "../api/api"; import RecordingAnnotationEditor from "./RecordingAnnotationEditor.vue"; +import { getAcousticFileAnnotations } from "../api/NABatApi"; export default defineComponent({ name: "AnnotationList", components: { @@ -16,6 +17,10 @@ export default defineComponent({ recordingId: { type: Number, required: true, + }, + type: { + type: String as PropType<'nabat' | null>, + default: () => null, } }, emits: [], @@ -25,11 +30,17 @@ export default defineComponent({ const annotations: Ref = ref([]); const setSelectedId = (annotation: FileAnnotation) => { - selectedAnnotation.value = annotation; + if (!props.type) { + selectedAnnotation.value = annotation; + } }; const loadFileAnnotations = async () => { - annotations.value = (await getFileAnnotations(props.recordingId)).data; + if (props.type === 'nabat') { + annotations.value = (await getAcousticFileAnnotations(props.recordingId)).data; + } else { + annotations.value = (await getFileAnnotations(props.recordingId)).data; + } }; onMounted(() => loadFileAnnotations()); @@ -82,7 +93,7 @@ export default defineComponent({ Annotations - + import { defineComponent, ref, onMounted, onUnmounted, Ref} from 'vue'; import { getProcessingTaskDetails } from '../api/api'; -import { postAcousticBatch } from '../api/NABatApi'; +import { AcousticBatchDataResponse, postAcousticBatch } from '../api/NABatApi'; +import { useRouter } from 'vue-router'; export default defineComponent({ props: { @@ -20,32 +21,39 @@ import { postAcousticBatch } from '../api/NABatApi'; const loading = ref(true); const taskId: Ref = ref(null); let timeoutId: ReturnType | null = null; + const taskInfo = ref(''); + const router = useRouter(); const fetchTaskDetails = async () => { if (taskId.value) { try { const response = await getProcessingTaskDetails(taskId.value); - if (response.celery_data.status === 'Complete') { + if (response.celery_data.status === 'Complete') { loading.value = false; if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; } + taskInfo.value = ''; + await checkAcousticBatch(); return; } else if (response.celery_data.status === 'Error') { loading.value = false; errorMessage.value = response.celery_data.error; + taskInfo.value = ''; return; + } else if (response.celery_data.info?.description) { + taskInfo.value = response.celery_data.info?.description; } } catch (error) { loading.value = false; errorMessage.value = 'Failed to fetch task details'; } } - timeoutId = setTimeout(fetchTaskDetails, 5000); + timeoutId = setTimeout(fetchTaskDetails, 1000); }; - - onMounted(async () => { + + const checkAcousticBatch = async () => { try { const response = await postAcousticBatch(props.batchId, props.apiToken); if ('error' in response && response.error) { @@ -53,17 +61,20 @@ import { postAcousticBatch } from '../api/NABatApi'; errorMessage.value = response.error; } if ('taskId' in response && response?.taskId && !response?.error) { taskId.value = response.taskId; - timeoutId = setTimeout(fetchTaskDetails, 2000); + timeoutId = setTimeout(fetchTaskDetails, 1000); } else { loading.value = false; // Load in new NABatSpectrogramViewer either by route or component - console.log('Data is loaded, please start loading spectrogram'); + const id = (response as AcousticBatchDataResponse).acousticId; + router.push(`/nabat/${id}/spectrogram`); } } catch (error) { errorMessage.value = 'Failed to start processing'; loading.value = false; } - }); + }; + + onMounted(async () => checkAcousticBatch()); onUnmounted(() => { if (timeoutId !== null) { @@ -75,26 +86,40 @@ import { postAcousticBatch } from '../api/NABatApi'; return { errorMessage, loading, + taskInfo, }; }, }); diff --git a/client/src/views/NABatSpectrogram.vue b/client/src/views/NABatSpectrogram.vue index 900093cb..bc121cbd 100644 --- a/client/src/views/NABatSpectrogram.vue +++ b/client/src/views/NABatSpectrogram.vue @@ -8,23 +8,25 @@ import { } from "vue"; import { getSpecies, - getSpectrogram, Species, - getSpectrogramCompressed, } from "../api/api"; +import { + getSpectrogram, + getSpectrogramCompressed, +} from "../api/NABatApi"; import SpectrogramViewer from "../components/SpectrogramViewer.vue"; import { SpectroInfo } from "../components/geoJS/geoJSUtils"; import ThumbnailViewer from "../components/ThumbnailViewer.vue"; -import RecordingList from "../components/RecordingList.vue"; import useState from "../use/useState"; import RecordingInfoDialog from "../components/RecordingInfoDialog.vue"; +import RecordingAnnotations from "../components/RecordingAnnotations.vue"; export default defineComponent({ name: "Spectrogram", components: { SpectrogramViewer, ThumbnailViewer, RecordingInfoDialog, - RecordingList, + RecordingAnnotations, }, props: { id: { @@ -52,6 +54,7 @@ export default defineComponent({ const gridEnabled = ref(false); const recordingInfo = ref(false); + const disabledFeatures = ref(['speciesLabel', 'endpointLabels', 'durationLabels', 'timeLabels']); const loadData = async () => { loadedImage.value = false; const response = compressed.value @@ -79,6 +82,7 @@ export default defineComponent({ if (response.data['compressed'] && spectroInfo.value) { spectroInfo.value.start_times = response.data.compressed.start_times; spectroInfo.value.end_times = response.data.compressed.end_times; + viewCompressedOverlay.value = false; } const speciesResponse = await getSpecies(); speciesList.value = speciesResponse.data; @@ -141,6 +145,8 @@ export default defineComponent({ colorScale, scaledVals, recordingInfo, + // Disabled Featuers not in NABat + disabledFeatures, }; }, }); @@ -198,7 +204,10 @@ export default defineComponent({ - +