diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 100f35bf..4efeceff 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -45,9 +45,11 @@ to `.env` and change the default passwords for fields to collect the static files 6. Run `docker compose -f docker-compose.prod.yml up` to start the server add `-d` for a silent version to run in the background -7. Change the ID in the `./client/env.production` to a custom ID - this will +7. **OPTIONAL** Change the ID in the `./client/env.production` to a custom ID - this will probably require a `docker compose -f docker-compose.prod.yml build` \ - to build the app afterwards + to build the app afterwards. This Id is used to indetify the application and + isn't required to be changed especially if the building of the client is done + outside of deployment. 8. After creating the basic application log into the django admin `batdetectai.kitware.com/admin` and change the ApplicationId to the ID in the `./client.env.production` 9. Test logging in/out and uploading data to the server. diff --git a/bats_ai/api.py b/bats_ai/api.py index 4a899cc7..4c0120af 100644 --- a/bats_ai/api.py +++ b/bats_ai/api.py @@ -7,10 +7,12 @@ ConfigurationRouter, GRTSCellsRouter, GuanoMetadataRouter, + ProcessingTaskRouter, RecordingAnnotationRouter, RecordingRouter, SpeciesRouter, ) +from bats_ai.core.views.nabat import NABatConfigurationRouter, NABatRecordingRouter logger = logging.getLogger(__name__) @@ -37,3 +39,6 @@ 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/recording/', NABatRecordingRouter) +api.add_router('/nabat/configuration/', NABatConfigurationRouter) diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index 52f0de71..a2c4419a 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -1,7 +1,15 @@ from .annotations import AnnotationsAdmin from .compressed_spectrogram import CompressedSpectrogramAdmin +from .configuration import ConfigurationAdmin from .grts_cells import GRTSCellsAdmin from .image import ImageAdmin +from .nabat.admin import ( + NABatCompressedSpectrogramAdmin, + NABatRecordingAdmin, + NABatRecordingAnnotationAdmin, + NABatSpectrogramAdmin, +) +from .processing_task import ProcessingTaskAdmin from .recording import RecordingAdmin from .recording_annotations import RecordingAnnotationAdmin from .species import SpeciesAdmin @@ -18,4 +26,11 @@ 'GRTSCellsAdmin', 'CompressedSpectrogramAdmin', 'RecordingAnnotationAdmin', + 'ProcessingTaskAdmin', + 'ConfigurationAdmin', + # NABat Models + 'NABatRecordingAnnotationAdmin', + 'NABatCompressedSpectrogramAdmin', + 'NABatSpectrogramAdmin', + 'NABatRecordingAdmin', ] diff --git a/bats_ai/core/admin/configuration.py b/bats_ai/core/admin/configuration.py new file mode 100644 index 00000000..969ede25 --- /dev/null +++ b/bats_ai/core/admin/configuration.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from bats_ai.core.models.configuration import Configuration + + +@admin.register(Configuration) +class ConfigurationAdmin(admin.ModelAdmin): + list_display = ( + 'display_pulse_annotations', + 'display_sequence_annotations', + 'run_inference_on_upload', + 'spectrogram_x_stretch', + 'spectrogram_view', + ) + + def has_add_permission(self, request): + # Allow add only if there is no Configuration instance + if Configuration.objects.exists(): + return False + return super().has_add_permission(request) + + def has_delete_permission(self, request, obj=None): + # Prevent deleting the Configuration through the admin + return False 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/nabat/admin.py b/bats_ai/core/admin/nabat/admin.py new file mode 100644 index 00000000..f05e50ba --- /dev/null +++ b/bats_ai/core/admin/nabat/admin.py @@ -0,0 +1,66 @@ +from django.contrib import admin + +from bats_ai.core.models.nabat import ( + NABatCompressedSpectrogram, + NABatRecording, + NABatRecordingAnnotation, + NABatSpectrogram, +) + + +# Register models for the NaBat category +@admin.register(NABatRecordingAnnotation) +class NABatRecordingAnnotationAdmin(admin.ModelAdmin): + list_display = ( + 'nabat_recording', + 'comments', + 'model', + 'confidence', + 'additional_data', + 'species_codes', + ) + search_fields = ('nabat_recording_name', 'comments', 'model') + list_filter = ('nabat_recording',) + + @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 = ( + 'nabat_recording', + 'image_file', + 'width', + 'height', + 'duration', + 'frequency_min', + 'frequency_max', + 'colormap', + ) + search_fields = ('nabat_recording__name', 'colormap') + list_filter = ('nabat_recording', 'colormap') + + +@admin.register(NABatCompressedSpectrogram) +class NABatCompressedSpectrogramAdmin(admin.ModelAdmin): + list_display = ('nabat_recording', 'spectrogram', 'length', 'cache_invalidated') + search_fields = ('nabat_recording__name', 'spectrogram__id') + list_filter = ('nabat_recording', 'cache_invalidated') + + +@admin.register(NABatRecording) +class NABatRecordingAdmin(admin.ModelAdmin): + list_display = ( + 'name', + 'recording_id', + 'equipment', + 'comments', + 'recording_location', + 'grts_cell_id', + 'grts_cell', + ) + search_fields = ('name', 'recording_id', 'recording_location') + list_filter = ('name', 'recording_id', 'recording_location') 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/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/management/commands/makeclient.py b/bats_ai/core/management/commands/makeclient.py index 19ca6c94..3a5edfa7 100644 --- a/bats_ai/core/management/commands/makeclient.py +++ b/bats_ai/core/management/commands/makeclient.py @@ -1,8 +1,10 @@ +import os + from django.contrib.auth.models import User import djclick as click from oauth2_provider.models import Application -CLIENT_ID = 'HSJWFZ2cIpWQOvNyCXyStV9hiOd7DfWeBOCzo4pP' +CLIENT_ID = os.environ.get('APPLICATION_CLIENT_ID', 'HSJWFZ2cIpWQOvNyCXyStV9hiOd7DfWeBOCzo4pP') # create django oauth toolkit appliction (client) @@ -12,10 +14,23 @@ required=True, help='superuser username for application creator', ) -@click.option('--uri', type=click.STRING, required=True, help='redirect uri for application') +@click.option( + '--uri', + type=click.STRING, + default='http://localhost:3000/', + required=False, + help='redirect uri for application', +) +@click.option( + '--clientid', + type=click.STRING, + default=CLIENT_ID, + required=False, + help='clientID used in the application', +) @click.command() -def command(username, uri): - if Application.objects.filter(client_id=CLIENT_ID).exists(): +def command(username, uri, clientid): + if Application.objects.filter(client_id=clientid).exists(): click.echo('The client already exists. You can administer it from the admin console.') return @@ -29,7 +44,7 @@ def command(username, uri): if user: application = Application( name='batsai-client', - client_id=CLIENT_ID, + client_id=clientid, client_secret='', client_type='public', redirect_uris=uri, diff --git a/bats_ai/core/migrations/0015_nabatrecording_processingtask_nabatspectrogram_and_more.py b/bats_ai/core/migrations/0015_nabatrecording_processingtask_nabatspectrogram_and_more.py new file mode 100644 index 00000000..96d4f716 --- /dev/null +++ b/bats_ai/core/migrations/0015_nabatrecording_processingtask_nabatspectrogram_and_more.py @@ -0,0 +1,326 @@ +# Generated by Django 4.1.13 on 2025-04-07 17:26 + +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', '0014_configuration_run_inference_on_upload_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='NABatRecording', + 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)), + ('recording_id', models.BigIntegerField(unique=True)), + ('survey_event_id', models.BigIntegerField()), + ('acoustic_batch_id', models.BigIntegerField(blank=True, null=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='nabatrecording_computed_species', to='core.species' + ), + ), + ( + 'nabat_auto_species', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='nabatrecording_auto_species', + to='core.species', + ), + ), + ( + 'nabat_manual_species', + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='nabatrecording_manual_species', + to='core.species', + ), + ), + ( + 'official_species', + models.ManyToManyField( + related_name='nabatrecording_official_species', to='core.species' + ), + ), + ], + options={ + 'verbose_name': 'NABat Recording', + 'verbose_name_plural': 'NABat Recording', + }, + ), + 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, + }, + ), + 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)), + ( + 'nabat_recording', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.nabatrecording' + ), + ), + ], + options={ + 'verbose_name': 'NABat Spectrogram', + 'verbose_name_plural': 'NABat Spectrograms', + }, + ), + migrations.CreateModel( + name='NABatRecordingAnnotation', + 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)), + ( + 'user_id', + models.UUIDField( + blank=True, + help_text='User ID of the person who created the annotation', + null=True, + ), + ), + ( + 'user_email', + models.TextField( + blank=True, + help_text='User ID of the person who created the annotation', + 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, + ), + ), + ( + 'nabat_recording', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.nabatrecording' + ), + ), + ('species', models.ManyToManyField(to='core.species')), + ], + options={ + 'verbose_name': 'NABat Recording Annotation', + 'verbose_name_plural': 'NABat Recording Annotations', + }, + ), + 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)), + ( + 'nabat_recording', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.nabatrecording' + ), + ), + ( + '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', + }, + ), + ] diff --git a/bats_ai/core/migrations/0016_alter_species_common_name_alter_species_family_and_more.py b/bats_ai/core/migrations/0016_alter_species_common_name_alter_species_family_and_more.py new file mode 100644 index 00000000..5a908738 --- /dev/null +++ b/bats_ai/core/migrations/0016_alter_species_common_name_alter_species_family_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.1.13 on 2025-04-11 15:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0015_nabatrecording_processingtask_nabatspectrogram_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='species', + name='common_name', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='species', + name='family', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='species', + name='genus', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='species', + name='species', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='species', + name='species_code', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='species', + name='species_code_6', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index 16944b1d..32d9a02e 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, ProcessingTaskType from .recording import Recording, colormap from .recording_annotation import RecordingAnnotation from .recording_annotation_status import RecordingAnnotationStatus @@ -23,4 +24,6 @@ 'CompressedSpectrogram', 'RecordingAnnotation', 'Configuration', + 'ProcessingTask', + 'ProcessingTaskType', ] 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/__init__.py b/bats_ai/core/models/nabat/__init__.py new file mode 100644 index 00000000..bf8a0999 --- /dev/null +++ b/bats_ai/core/models/nabat/__init__.py @@ -0,0 +1,11 @@ +from .nabat_compressed_spectrogram import NABatCompressedSpectrogram +from .nabat_recording import NABatRecording +from .nabat_recording_annotation import NABatRecordingAnnotation +from .nabat_spectrogram import NABatSpectrogram + +__all__ = [ + 'NABatSpectrogram', + 'NABatCompressedSpectrogram', + 'NABatRecording', + 'NABatRecordingAnnotation', +] 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..e33acb1b --- /dev/null +++ b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py @@ -0,0 +1,34 @@ +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 + +from .nabat_recording import NABatRecording +from .nabat_spectrogram import NABatSpectrogram + + +# TimeStampedModel also provides "created" and "modified" fields +class NABatCompressedSpectrogram(TimeStampedModel, models.Model): + nabat_recording = models.ForeignKey(NABatRecording, 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) + + 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): + if instance.image_file: + instance.image_file.delete(save=False) diff --git a/bats_ai/core/models/nabat/nabat_recording.py b/bats_ai/core/models/nabat/nabat_recording.py new file mode 100644 index 00000000..4d3910ab --- /dev/null +++ b/bats_ai/core/models/nabat/nabat_recording.py @@ -0,0 +1,113 @@ +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 NABatRecording(TimeStampedModel, models.Model): + name = models.CharField(max_length=255) + recording_id = models.BigIntegerField(blank=False, null=False, unique=True) + survey_event_id = models.BigIntegerField(blank=False, null=False) + acoustic_batch_id = models.BigIntegerField(blank=True, null=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) + 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, 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='nabatrecording_auto_species' + ) + nabat_manual_species = models.ForeignKey( + Species, null=True, on_delete=models.SET_NULL, related_name='nabatrecording_manual_species' + ) + computed_species = models.ManyToManyField( + Species, related_name='nabatrecording_computed_species' # Changed related name + ) # species from a computed sense + + official_species = models.ManyToManyField( + Species, related_name='nabatrecording_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 + 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(nabat_recording=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.nabat import NABatCompressedSpectrogram + + query = NABatCompressedSpectrogram.objects.filter(nabat_recording=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 + + class Meta: + verbose_name = 'NABat Recording' + verbose_name_plural = 'NABat Recording' diff --git a/bats_ai/core/models/nabat/nabat_recording_annotation.py b/bats_ai/core/models/nabat/nabat_recording_annotation.py new file mode 100644 index 00000000..6cc45653 --- /dev/null +++ b/bats_ai/core/models/nabat/nabat_recording_annotation.py @@ -0,0 +1,39 @@ +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 .nabat_recording import NABatRecording + + +class NABatRecordingAnnotation(TimeStampedModel, models.Model): + nabat_recording = models.ForeignKey(NABatRecording, on_delete=models.CASCADE) + species = models.ManyToManyField(Species) + comments = models.TextField(blank=True, null=True) + user_id = models.UUIDField( + blank=True, + null=True, + help_text='User ID of the person who created the annotation', + ) + user_email = models.TextField( + blank=True, + null=True, + help_text='User ID of the person who created the annotation', + ) + 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' + ) + + class Meta: + verbose_name = 'NABat Recording Annotation' + verbose_name_plural = 'NABat Recording Annotations' 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..c9f9a594 --- /dev/null +++ b/bats_ai/core/models/nabat/nabat_spectrogram.py @@ -0,0 +1,59 @@ +import base64 +import logging + +from PIL import Image +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 .nabat_recording import NABatRecording + +logger = logging.getLogger(__name__) + + +# TimeStampedModel also provides "created" and "modified" fields +class NABatSpectrogram(TimeStampedModel, models.Model): + nabat_recording = models.ForeignKey(NABatRecording, 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) + + @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) + + class Meta: + verbose_name = 'NABat Spectrogram' + verbose_name_plural = 'NABat Spectrograms' + + +@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/processing_task.py b/bats_ai/core/models/processing_task.py new file mode 100644 index 00000000..5a3d8b17 --- /dev/null +++ b/bats_ai/core/models/processing_task.py @@ -0,0 +1,38 @@ +from enum import Enum + +from django.db import models +from django_extensions.db.models import TimeStampedModel + + +class ProcessingTaskType(Enum): + UPDATING_SPECIES = 'Updating Species' + NABAT_RECORDING_PROCESSING = 'NABatRecordingProcessing' + + +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/species.py b/bats_ai/core/models/species.py index 346483ab..97011cc6 100644 --- a/bats_ai/core/models/species.py +++ b/bats_ai/core/models/species.py @@ -2,9 +2,9 @@ class Species(models.Model): - species_code = models.CharField(max_length=10, blank=True, null=True) - family = models.CharField(max_length=50, blank=True, null=True) - genus = models.CharField(max_length=50, blank=True, null=True) - species = models.CharField(max_length=100, blank=True, null=True) - common_name = models.CharField(max_length=100, blank=True, null=True) - species_code_6 = models.CharField(max_length=10, blank=True, null=True) + species_code = models.CharField(max_length=255, blank=True, null=True) + family = models.CharField(max_length=255, blank=True, null=True) + genus = models.CharField(max_length=255, blank=True, null=True) + species = models.CharField(max_length=255, blank=True, null=True) + common_name = models.CharField(max_length=255, blank=True, null=True) + species_code_6 = models.CharField(max_length=255, blank=True, null=True) 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/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/__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/__init__.py b/bats_ai/core/views/nabat/__init__.py new file mode 100644 index 00000000..5f637932 --- /dev/null +++ b/bats_ai/core/views/nabat/__init__.py @@ -0,0 +1,7 @@ +from .nabat_configuration import router as NABatConfigurationRouter +from .nabat_recording import router as NABatRecordingRouter + +__all__ = [ + 'NABatRecordingRouter', + 'NABatConfigurationRouter', +] diff --git a/bats_ai/core/views/nabat/nabat_configuration.py b/bats_ai/core/views/nabat/nabat_configuration.py new file mode 100644 index 00000000..dbfc26aa --- /dev/null +++ b/bats_ai/core/views/nabat/nabat_configuration.py @@ -0,0 +1,45 @@ +import logging + +from django.http import HttpRequest, JsonResponse +from ninja.pagination import Router + +from bats_ai.core.models import ProcessingTask, ProcessingTaskType +from bats_ai.tasks.nabat.nabat_update_species import update_nabat_species + +logger = logging.getLogger(__name__) + +router = Router() + + +@router.post('/update-species') +def update_species_list( + request: HttpRequest, +): + if not request.user.is_authenticated or not request.user.is_superuser: + return JsonResponse({'error': 'Permission denied'}, status=403) + existing_task = ProcessingTask.objects.filter( + metadata__type=ProcessingTaskType.UPDATING_SPECIES.value, + status__in=[ProcessingTask.Status.QUEUED, ProcessingTask.Status.RUNNING], + ).first() + + if existing_task: + return JsonResponse( + { + 'error': 'A task is already updating the Species List.', + 'taskId': existing_task.celery_id, + 'status': existing_task.status, + }, + status=409, + ) + + # use a task to start downloading the file using the API key and generate the spectrograms + task = update_nabat_species.delay() + ProcessingTask.objects.create( + name='Updating Species List', + status=ProcessingTask.Status.QUEUED, + metadata={ + 'type': ProcessingTaskType.UPDATING_SPECIES.value, + }, + celery_id=task.id, + ) + return {'taskId': task.id} diff --git a/bats_ai/core/views/nabat/nabat_recording.py b/bats_ai/core/views/nabat/nabat_recording.py new file mode 100644 index 00000000..bf6c7c57 --- /dev/null +++ b/bats_ai/core/views/nabat/nabat_recording.py @@ -0,0 +1,555 @@ +import base64 +import json +import logging +import os + +from django.db.models import Q +from django.http import HttpRequest, JsonResponse +from ninja import Form, Schema +from ninja.pagination import RouterPaginated +import requests + +from bats_ai.core.models import ProcessingTask, ProcessingTaskType, Species, colormap +from bats_ai.core.models.nabat import ( + NABatCompressedSpectrogram, + NABatRecording, + NABatRecordingAnnotation, +) +from bats_ai.core.views.species import SpeciesSchema +from bats_ai.tasks.nabat.nabat_data_retrieval import nabat_recording_initialize +from bats_ai.tasks.tasks import predict_compressed + +logger = logging.getLogger(__name__) + + +router = RouterPaginated() +SOFTWARE_ID = 81 +BASE_URL = os.environ.get('NABAT_API_URL', 'https://api.sciencebase.gov/nabat-graphql/graphql') +QUERY = """ +query fetchAcousticAndSurveyEventInfo { + presignedUrlFromAcousticFile(acousticFileId: "%(acoustic_file_id)s") { + s3PresignedUrl + } +} +""" + +UPDATE_QUERY = """ +mutation UpdateQuery{ +updateAcousticFileVet ( + surveyEventId: %(survey_event_id)d + acousticFileId: %(acoustic_file_id)d + softwareId: %(software_id)d + speciesId: %(species_id)d, + ) { + acousticFileBatchId + } +} +""" + + +def decode_jwt(token): + # Split the token into parts + parts = token.split('.') + if len(parts) != 3: + raise ValueError('Invalid JWT token format') + + # JWT uses base64url encoding, so need to fix padding + payload = parts[1] + padding = '=' * (4 - (len(payload) % 4)) # Fix padding if needed + payload += padding + + # Decode the payload + decoded_bytes = base64.urlsafe_b64decode(payload) + decoded_str = decoded_bytes.decode('utf-8') + + # Parse JSON + return json.loads(decoded_str) + + +class NABatRecordingSchema(Schema): + name: str + recording_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 NABatRecordingGenerateSchema(Schema): + apiToken: str + recordingId: int + surveyEventId: int + + +@router.post('/', auth=None) +def generate_nabat_recording( + request: HttpRequest, + payload: Form[NABatRecordingGenerateSchema], +): + existing_task = ProcessingTask.objects.filter( + metadata__recordingId=payload.recordingId, + status__in=[ProcessingTask.Status.QUEUED, ProcessingTask.Status.RUNNING], + ).first() + + if existing_task: + return JsonResponse( + { + 'error': 'A task is already processing this recordingId.', + 'taskId': existing_task.celery_id, + 'status': existing_task.status, + }, + status=409, + ) + + nabat_recording = NABatRecording.objects.filter(recording_id=payload.recordingId) + if not nabat_recording.exists(): + # use a task to start downloading the file using the API key and generate the spectrograms + task = nabat_recording_initialize.delay( + payload.recordingId, payload.surveyEventId, payload.apiToken + ) + ProcessingTask.objects.create( + name=f'Processing Recording {payload.recordingId}', + status=ProcessingTask.Status.QUEUED, + metadata={ + 'type': ProcessingTaskType.NABAT_RECORDING_PROCESSING.value, + 'recordingId': payload.recordingId, + }, + celery_id=task.id, + ) + return {'taskId': task.id} + # we want to check the apiToken and make sure the user has access to the file before returning it + api_token = payload.apiToken + recording_id = payload.recordingId + headers = {'Authorization': f'Bearer {api_token}', 'Content-Type': 'application/json'} + batch_query = QUERY % { + 'acoustic_file_id': recording_id, + } + try: + response = requests.post(BASE_URL, json={'query': batch_query}, headers=headers) + logger.info(response.json()) + except Exception: + return JsonResponse(response.json(), status=500) + + if response.status_code == 200: + try: + batch_data = response.json() + logger.info(batch_data) + if batch_data['data']['presignedUrlFromAcousticFile'] is None: + return JsonResponse({'error': 'Recording not found or access denied'}, status=404) + else: + return {'recordingId': nabat_recording.first().pk} + except (KeyError, TypeError, json.JSONDecodeError) as e: + logger.error(f'Error processing batch data: {e}') + return JsonResponse({'error': f'Error with API Request: {e}'}, status=500) + else: + logger.error(f'Failed to fetch data: {response.status_code}, {response.text}') + return JsonResponse(response.json(), status=500) + + +@router.get('/', auth=None) +def get_nabat_recording_spectrogram(request: HttpRequest, id: int, apiToken: str): + try: + nabat_recording = NABatRecording.objects.get(pk=id) + except NABatRecording.DoesNotExist: + return JsonResponse({'error': 'Recording does not exist'}, status=404) + + headers = {'Authorization': f'Bearer {apiToken}', 'Content-Type': 'application/json'} + batch_query = QUERY % { + 'acoustic_file_id': nabat_recording.recording_id, + } + try: + response = requests.post(BASE_URL, json={'query': batch_query}, headers=headers) + except Exception as e: + logger.error(f'API Request Failed: {e}') + return JsonResponse({'error': 'Failed to connect to NABat API'}, status=500) + + if response.status_code != 200: + logger.error(f'Failed NABat API Query: {response.status_code} - {response.text}') + return JsonResponse({'error': 'Failed to verify recording access'}, status=500) + + try: + batch_data = response.json() + if batch_data['data']['presignedUrlFromAcousticFile'] is None: + return JsonResponse({'error': 'Recording not found or access denied'}, status=404) + except (KeyError, TypeError, json.JSONDecodeError) as e: + logger.error(f'Error parsing NABat API response: {e}') + return JsonResponse({'error': 'Invalid response from NABat API'}, status=500) + + with colormap(None): + spectrogram = nabat_recording.spectrogram + + compressed = nabat_recording.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, + } + + spectro_data['annotations'] = [] + spectro_data['temporal'] = [] + return spectro_data + + +@router.post('/{id}/spectrogram/compressed/predict', auth=None) +def predict_spectrogram_compressed(request: HttpRequest, id: int): + try: + recording = NABatRecording.objects.get(pk=id) + compressed_spectrogram = NABatCompressedSpectrogram.objects.filter( + nabat_recording=id + ).first() + except compressed_spectrogram.DoesNotExist: + return {'error': 'Compressed Spectrogram'} + except recording.DoesNotExist: + return {'error': 'Recording does not exist'} + + label, score, confs = predict_compressed(compressed_spectrogram.image_file) + 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 + + +@router.get('/{id}/spectrogram', auth=None) +def get_spectrogram(request: HttpRequest, id: int): + try: + nabat_recording = NABatRecording.objects.get(pk=id) + except NABatRecording.DoesNotExist: + return {'error': 'Recording not found'} + + with colormap(None): + spectrogram = nabat_recording.spectrogram + + compressed = nabat_recording.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', auth=None) +def get_spectrogram_compressed(request: HttpRequest, id: int, apiToken: str): + try: + nabat_recording = NABatRecording.objects.get(pk=id) + except NABatRecording.DoesNotExist: + return JsonResponse({'error': 'Recording does not exist'}, status=404) + + headers = {'Authorization': f'Bearer {apiToken}', 'Content-Type': 'application/json'} + batch_query = QUERY % { + 'acoustic_file_id': nabat_recording.recording_id, + } + + try: + response = requests.post(BASE_URL, json={'query': batch_query}, headers=headers) + except Exception as e: + logger.error(f'API request failed: {e}') + return JsonResponse({'error': 'Failed to verify API token'}, status=500) + + if response.status_code != 200: + logger.error(f'Failed API auth check: {response.status_code} - {response.text}') + return JsonResponse(response.json(), status=response.status_code) + + try: + batch_data = response.json() + if batch_data['data']['presignedUrlFromAcousticFile'] is None: + return JsonResponse({'error': 'Recording not found or access denied'}, status=404) + except (KeyError, TypeError, json.JSONDecodeError) as e: + logger.error(f'Error processing batch data: {e}') + return JsonResponse({'error': 'Error processing API response'}, status=500) + + # --- passed authorization check --- + + compressed_spectrogram = NABatCompressedSpectrogram.objects.filter(nabat_recording=id).first() + + if not compressed_spectrogram: + return JsonResponse({'error': 'Compressed Spectrogram not found'}, status=404) + + 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, + }, + 'annotations': [], + 'temporal': [], + } + + return spectro_data + + +class NABatRecordingAnnotationSchema(Schema): + species: list[SpeciesSchema] | None + comments: str | None = None + model: str | None = None + owner: str | None = None + confidence: float + id: int | None = None + hasDetails: bool + + @classmethod + def from_orm(cls, obj: NABatRecordingAnnotation, **kwargs): + return cls( + species=[SpeciesSchema.from_orm(species) for species in obj.species.all()], + owner=obj.user_email, + confidence=obj.confidence, + comments=obj.comments, + model=obj.model, + id=obj.pk, + hasDetails=obj.additional_data is not None, + ) + + +class NABatRecordingAnnotationDetailsSchema(Schema): + species: list[SpeciesSchema] | None + comments: str | None = None + model: str | None = None + owner: str | None = None + confidence: float + id: int | None = None + details: dict + hasDetails: bool + + @classmethod + def from_orm(cls, obj: NABatRecordingAnnotation, **kwargs): + return cls( + species=[SpeciesSchema.from_orm(species) for species in obj.species.all()], + owner=obj.user_email, + confidence=obj.confidence, + comments=obj.comments, + model=obj.model, + hasDetails=obj.additional_data is not None, + details=obj.additional_data, + id=obj.pk, + ) + + +class NABatCreateRecordingAnnotationSchema(Schema): + recordingId: int + species: list[int] + comments: str = None + model: str = None + confidence: float + apiToken: str + + +@router.get('/{nabat_recording_id}/recording-annotations', auth=None) +def get_nabat_recording_annotation( + request: HttpRequest, + nabat_recording_id: int, + apiToken: str | None = None, +): + token_data = decode_jwt(apiToken) + user_email = token_data['email'] + + fileAnnotations = NABatRecordingAnnotation.objects.filter(nabat_recording=nabat_recording_id) + + if user_email: + fileAnnotations = fileAnnotations.filter( + Q(user_email=user_email) | Q(user_email__isnull=True) + ) + + fileAnnotations = fileAnnotations.order_by('confidence') + + output = [ + NABatRecordingAnnotationSchema.from_orm(fileAnnotation).dict() + for fileAnnotation in fileAnnotations + ] + return output + + +@router.get('recording-annotation/{id}', auth=None, response=NABatRecordingAnnotationSchema) +def get_recording_annotation(request: HttpRequest, id: int, apiToken: str): + token_data = decode_jwt(apiToken) + user_email = token_data['email'] + try: + annotation = NABatRecordingAnnotation.objects.get(pk=id) + + if user_email: + annotation = annotation.filter(Q(user_email=user_email) | Q(user_email__isnull=True)) + + return NABatRecordingAnnotationSchema.from_orm(annotation).dict() + except NABatRecordingAnnotation.DoesNotExist: + return JsonResponse({'error': 'Recording annotation not found.'}, 404) + + +@router.get( + 'recording-annotation/{id}/details', auth=None, response=NABatRecordingAnnotationDetailsSchema +) +def get_recording_annotation_details(request: HttpRequest, id: int, apiToken: str): + token_data = decode_jwt(apiToken) + user_email = token_data['email'] + try: + annotation = NABatRecordingAnnotation.objects.get( + Q(pk=id) & (Q(user_email=user_email) | Q(user_email__isnull=True)) + ) + + return NABatRecordingAnnotationDetailsSchema.from_orm(annotation).dict() + except NABatRecordingAnnotation.DoesNotExist: + return JsonResponse({'error': 'Recording annotation not found.'}, 404) + + +@router.put('recording-annotation', auth=None, response={200: str}) +def create_recording_annotation(request: HttpRequest, data: NABatCreateRecordingAnnotationSchema): + token_data = decode_jwt(data.apiToken) + user_id = token_data['sub'] + user_email = token_data['email'] + + try: + recording = NABatRecording.objects.get(pk=data.recordingId) + + # Create the recording annotation + annotation = NABatRecordingAnnotation.objects.create( + nabat_recording=recording, + user_email=user_email, + user_id=user_id, + comments=data.comments, + model=data.model, + confidence=data.confidence, + ) + + # Add species + for species_id in data.species: + species = Species.objects.get(pk=species_id) + annotation.species.add(species) + if len(data.species) > 0: + species_id = data.species[0] + headers = { + 'Authorization': f'Bearer {data.apiToken}', + 'Content-Type': 'application/json', + } + batch_query = UPDATE_QUERY % { + 'survey_event_id': recording.survey_event_id, + 'software_id': SOFTWARE_ID, + 'acoustic_file_id': recording.recording_id, + 'species_id': species_id, + } + try: + response = requests.post(BASE_URL, json={'query': batch_query}, headers=headers) + logger.info(response.json()) + except Exception as e: + logger.error(f'API Request Failed: {e}') + return JsonResponse({'error': 'Failed to connect to NABat API'}, status=500) + + return 'Recording annotation created successfully.' + except NABatRecording.DoesNotExist: + return JsonResponse({'error': 'Recording not found.'}, 404) + except Species.DoesNotExist: + return JsonResponse({'error': 'One or more species IDs not found.'}, 404) + + +@router.patch('recording-annotation/{id}', auth=None, response={200: str}) +def update_recording_annotation( + request: HttpRequest, id: int, data: NABatCreateRecordingAnnotationSchema +): + token_data = decode_jwt(data.apiToken) + user_email = token_data['email'] + + try: + annotation = NABatRecordingAnnotation.objects.get(pk=id, user_email=user_email) + # Check permission + + # Update fields if provided + if data.comments is not None: + annotation.comments = data.comments + if data.model is not None: + annotation.model = data.model + if data.confidence is not None: + annotation.confidence = data.confidence + if data.species is not None: + annotation.species.clear() # Clear existing species + for species_id in data.species: + species = Species.objects.get(pk=species_id) + annotation.species.add(species) + + if len(data.species) > 0: + species_id = data.species[0] + headers = { + 'Authorization': f'Bearer {data.apiToken}', + 'Content-Type': 'application/json', + } + batch_query = UPDATE_QUERY % { + 'survey_event_id': annotation.nabat_recording.survey_event_id, + 'software_id': SOFTWARE_ID, + 'acoustic_file_id': annotation.nabat_recording.recording_id, + 'species_id': species_id, + } + try: + response = requests.post(BASE_URL, json={'query': batch_query}, headers=headers) + json_response = response.json() + if json_response.get('errors'): + logger.error(f'API Error: {json_response["errors"]}') + return JsonResponse(json_response, status=500) + except Exception as e: + logger.error(f'API Request Failed: {e}') + return JsonResponse({'error': 'Failed to connect to NABat API'}, status=500) + + annotation.save() + return 'Recording annotation updated successfully.' + except NABatRecordingAnnotation.DoesNotExist: + return JsonResponse({'error': 'Recording not found.'}, 404) + except Species.DoesNotExist: + return JsonResponse({'error': 'One or more species IDs not found.'}, 404) + + +@router.delete('recording-annotation/{id}', auth=None, response={200: str}) +def delete_recording_annotation(request: HttpRequest, id: int, apiToken: str): + token_data = decode_jwt(apiToken) + user_email = token_data['email'] + try: + annotation = NABatRecordingAnnotation.objects.get(pk=id, user_email=user_email) + + # Check permission + annotation.delete() + return 'Recording annotation deleted successfully.' + except NABatRecordingAnnotation.DoesNotExist: + return JsonResponse({'error': 'Recording not found for this user.'}, 404) diff --git a/bats_ai/core/views/processing_tasks.py b/bats_ai/core/views/processing_tasks.py new file mode 100644 index 00000000..7227b0dc --- /dev/null +++ b/bats_ai/core/views/processing_tasks.py @@ -0,0 +1,37 @@ +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__) + +router = Router() + + +@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/core/views/recording.py b/bats_ai/core/views/recording.py index 42757801..2cac2279 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 predict_compressed, recording_compute_spectrogram logger = logging.getLogger(__name__) @@ -843,7 +843,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() @@ -852,7 +852,7 @@ def precit_spectrogram_compressed(request: HttpRequest, id: int): except recording.DoesNotExist: return {'error': 'Recording does not exist'} - label, score, confs = compressed_spectrogram.predict() + label, score, confs = predict_compressed(compressed_spectrogram.image_file) confidences = [] confidences = [{'label': key, 'value': float(value)} for key, value in confs.items()] sorted_confidences = sorted(confidences, key=lambda x: x['value'], reverse=True) diff --git a/bats_ai/core/views/species.py b/bats_ai/core/views/species.py index fe45fd83..a72ccfeb 100644 --- a/bats_ai/core/views/species.py +++ b/bats_ai/core/views/species.py @@ -22,7 +22,7 @@ class SpeciesSchema(Schema): pk: int = None -@router.get('/') +@router.get('/', auth=None) def get_species(request: HttpRequest): species = Species.objects.values() diff --git a/bats_ai/settings.py b/bats_ai/settings.py index a1824c94..ac7aa055 100644 --- a/bats_ai/settings.py +++ b/bats_ai/settings.py @@ -25,6 +25,7 @@ class BatsAiMixin(ConfigMixin): FILE_UPLOAD_HANDLERS = [ 'django.core.files.uploadhandler.TemporaryFileUploadHandler', ] + CELERY_RESULT_BACKEND = 'django-db' @staticmethod def mutate_configuration(configuration: ComposedConfiguration) -> None: @@ -37,6 +38,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 new file mode 100644 index 00000000..aa437f76 --- /dev/null +++ b/bats_ai/tasks/nabat/nabat_data_retrieval.py @@ -0,0 +1,274 @@ +import json +import logging +import os +import tempfile + +from django.contrib.gis.geos import Point +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import Q +import requests + +from bats_ai.celery import app +from bats_ai.core.models import Configuration, ProcessingTask, ProcessingTaskType, Species +from bats_ai.core.models.nabat import NABatRecording, NABatRecordingAnnotation + +from .tasks import generate_compress_spectrogram, generate_spectrogram, predict + +# Set up logger +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('NABatDataRetrieval') + +BASE_URL = os.environ.get('NABAT_API_URL', 'https://api.sciencebase.gov/nabat-graphql/graphql') +SOFTWARE_ID = 81 +QUERY = """ +query fetchAcousticAndSurveyEventInfo { + presignedUrlFromAcousticFile(acousticFileId: "%(acoustic_file_id)s") { + s3PresignedUrl + } + surveyEventById(id: "%(survey_event_id)d") { + createdBy + createdDate + eventGeometryByEventGeometryId { + description + geom { + geojson + } + } + acousticBatchesBySurveyEventId(filter: {softwareId: {equalTo:%(software_id)d}}) { + nodes { + id + acousticFileBatchesByBatchId(filter: {fileId: {equalTo: "%(acoustic_file_id)s"}}) { + nodes { + autoId + manualId + vetter + speciesByManualId { + speciesCode + } + } + } + } + } + } + acousticFileById(id: "%(acoustic_file_id)d") { + fileName + recordingTime + s3Verified + sizeBytes + } +} +""" + + +def get_or_create_processing_task(recording_id, request_id): + """ + Fetches or creates a ProcessingTask with the given metadata and status filters. + Uses `get` with try-except block to handle object retrieval. + + Args: + recording_id (int): The recording ID for the task metadata. + request_id (str): The Celery request ID to store in the task. + + Returns: + tuple: A tuple with the ProcessingTask instance and a boolean indicating if it was created. + """ + # Define the metadata filter with specific keys in the JSON metadata field + metadata_filter = { + 'type': ProcessingTaskType.NABAT_RECORDING_PROCESSING.value, + 'recordingId': recording_id, + } + + # Try to get an existing task or handle the case where it's not found + try: + # Attempt to get a task based on the metadata filter and status + processing_task = ProcessingTask.objects.get( + Q(metadata__contains=metadata_filter) + & Q(status__in=[ProcessingTask.Status.QUEUED, ProcessingTask.Status.RUNNING]) + & Q(celery_id=request_id) + ) + # If task is found, return the task with False (not created) + return processing_task, False + + except ObjectDoesNotExist: + # If task does not exist, create a new one with the given defaults + processing_task = ProcessingTask.objects.create( + metadata=metadata_filter, + status=ProcessingTask.Status.QUEUED, # Default status if creating a new task + celery_id=request_id, + ) + # Return the newly created task and True (created) + return processing_task, True + + except MultipleObjectsReturned: + # If multiple tasks are found, raise an exception (shouldn't happen if data is correct) + raise Exception('Multiple tasks found with the same metadata and status filter.') + + +@app.task(bind=True) +def nabat_recording_initialize(self, recording_id: int, survey_event_id: int, api_token: str): + processing_task, _created = get_or_create_processing_task(recording_id, self.request.id) + + processing_task.status = ProcessingTask.Status.RUNNING + processing_task.save() + headers = {'Authorization': f'Bearer {api_token}', 'Content-Type': 'application/json'} + batch_query = QUERY % { + 'acoustic_file_id': recording_id, + 'survey_event_id': survey_event_id, + 'software_id': SOFTWARE_ID, + } + self.update_state( + state='Progress', + meta={'description': 'Fetching NAbat Recording Data'}, + ) + try: + response = requests.post( + BASE_URL, + json={'query': batch_query}, + headers=headers, + ) + except Exception as e: + processing_task.status = ProcessingTask.Status.ERROR + processing_task.error = f'Error with API Request: {e}' + processing_task.save() + raise + batch_data = {} + + if response.status_code == 200: + try: + batch_data = response.json() + except (KeyError, TypeError, json.JSONDecodeError) as e: + logger.error(f'Error processing batch data: {e}') + processing_task.status = ProcessingTask.Status.ERROR + processing_task.error = f'Error processing batch data: {e}' + processing_task.save() + raise + else: + logger.error(f'Failed to fetch data: {response.status_code}, {response.text}') + processing_task.status = ProcessingTask.Status.ERROR + processing_task.error = f'Failed to fetch data: {response.status_code}, {response.text}' + processing_task.save() + return + + self.update_state( + state='Progress', + meta={'description': 'Fetching Recording File'}, + ) + + logger.info('Presigned URL obtained. Downloading file...') + + presigned_url = batch_data['data']['presignedUrlFromAcousticFile']['s3PresignedUrl'] + + 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 NABatRecording using the response data + logger.info('Creating NA Bat Recording...') + nabat_recording = create_nabat_recording_from_response( + batch_data, recording_id, survey_event_id + ) + + # Call generate_spectrogram with the nabat_recording and the temporary file + logger.info('Generating spectrogram...') + self.update_state( + state='Progress', + meta={'description': 'Generating Spectrogram'}, + ) + + spectrogram = generate_spectrogram(nabat_recording, 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( + nabat_recording.pk, spectrogram.pk + ) + logger.info('Running Prediction...') + self.update_state( + state='Progress', + meta={'description': 'Running Prediction'}, + ) + + try: + config = Configuration.objects.first() + if config and config.run_inference_on_upload: + predict(compressed_spectrogram.pk) + except Exception as e: + logger.error(f'Error Performing Prediction: {e}') + processing_task.status = ProcessingTask.Status.ERROR + processing_task.error = f'Error extracting presigned URL: {e}' + processing_task.save() + raise + processing_task.status = ProcessingTask.Status.COMPLETE + processing_task.save() + + else: + processing_task.status = ProcessingTask.Status.ERROR + processing_task.error = f'Failed to download file: {file_response.status_code}' + processing_task.save() + logger.error(f'Failed to download file: {file_response.status_code}') + except Exception as e: + processing_task.status = ProcessingTask.Status.ERROR + processing_task.error = str(e) + processing_task.save() + raise + + +def create_nabat_recording_from_response(response_data, recording_id, survey_event_id): + try: + # Extract the batch data from the response + nabat_recording_data = response_data['data'] + + # Optional fields + recording_location_data = nabat_recording_data['surveyEventById'][ + 'eventGeometryByEventGeometryId' + ]['geom']['geojson'] + file_name = nabat_recording_data['acousticFileById']['fileName'] + + # 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 + + # Create the NABatRecording instance + nabat_recording = NABatRecording.objects.create( + recording_id=recording_id, + survey_event_id=survey_event_id, + name=file_name, + recording_location=recording_location, + ) + + acoustic_batches_nodes = nabat_recording_data['surveyEventById'][ + 'acousticBatchesBySurveyEventId' + ]['nodes'] + if len(acoustic_batches_nodes) > 0: + batch_data = acoustic_batches_nodes[0]['acousticFileBatchesByBatchId']['nodes'] + for node in batch_data: + species_id = node.get('manualId', False) + if species_id is not False: + annotation = NABatRecordingAnnotation.objects.create( + nabat_recording=nabat_recording, + user_email=node['vetter'], + ) + species = Species.objects.get(pk=species_id) + annotation.species.add(species) + + return nabat_recording + + except KeyError as e: + logger.error(f'Missing key: {e}') + return None + except Exception as e: + logger.error(f'Error creating NABatRecording: {e}') + return None diff --git a/bats_ai/tasks/nabat/nabat_update_species.py b/bats_ai/tasks/nabat/nabat_update_species.py new file mode 100644 index 00000000..8527e77f --- /dev/null +++ b/bats_ai/tasks/nabat/nabat_update_species.py @@ -0,0 +1,115 @@ +import logging +import os + +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +import requests + +from bats_ai.celery import app +from bats_ai.core.models import ProcessingTask, ProcessingTaskType, Species + +# Set up logger +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('NABatGetSpecies') + +BASE_URL = os.environ.get('NABAT_API_URL', 'https://api.sciencebase.gov/nabat-graphql/graphql') +QUERY = """ +query GetAllSpeciesOptions { + allSpecies { + nodes { + id + species + speciesCode + speciesCode6 + genus + family + commonName + } + } +} +""" + + +def get_or_create_processing_task(request_id): + """ + Fetches or creates a ProcessingTask with the given metadata and status filters. + + Args: + request_id (str): The Celery request ID to store in the task. + + Returns: + tuple: A tuple with the ProcessingTask instance and a boolean indicating if it was created. + """ + metadata_filter = { + 'type': ProcessingTaskType.UPDATING_SPECIES.value, + } + + # Try to get an existing task or handle the case where it's not found + try: + # Attempt to get a task based on the metadata filter and status + processing_task = ProcessingTask.objects.get( + metadata__contains=metadata_filter, + status__in=[ProcessingTask.Status.QUEUED, ProcessingTask.Status.RUNNING], + ) + # If task is found, return the task with False (not created) + return processing_task, False + + except ObjectDoesNotExist: + # If task does not exist, create a new one with the given defaults + processing_task = ProcessingTask.objects.create( + metadata={'type': ProcessingTaskType.UPDATING_SPECIES.value}, + status=ProcessingTask.Status.QUEUED, # Default status if creating a new task + celery_id=request_id, + ) + # Return the newly created task and True (created) + return processing_task, True + + except MultipleObjectsReturned: + # If multiple tasks are found, raise an exception (shouldn't happen if data is correct) + raise Exception('Multiple tasks found with the same metadata and status filter.') + + +@app.task(bind=True) +def update_nabat_species(self): + processing_task, _created = get_or_create_processing_task(self.request.id) + processing_task.status = ProcessingTask.Status.RUNNING + processing_task.save() + + try: + response = requests.post(BASE_URL, json={'query': QUERY}) + response.raise_for_status() + except Exception as e: + processing_task.status = ProcessingTask.Status.ERROR + processing_task.error = f'Error with API request: {e}' + processing_task.save() + raise + + try: + data = response.json() + species_list = data.get('data', {}).get('allSpecies', {}).get('nodes', []) + + for species_data in species_list: + species_id = species_data.get('id') + if species_id is None: + logger.warning(f'Species without an ID encountered: {species_data}') + continue + + Species.objects.update_or_create( + id=species_id, # force the pk to match the external ID + defaults={ + 'species_code': species_data.get('speciesCode'), + 'species_code_6': species_data.get('speciesCode6'), + 'genus': species_data.get('genus'), + 'family': species_data.get('family'), + 'species': species_data.get('species'), + 'common_name': species_data.get('commonName'), + }, + ) + + processing_task.status = ProcessingTask.Status.COMPLETE + processing_task.save() + logger.info(f'Successfully updated {len(species_list)} species.') + except Exception as e: + processing_task.status = ProcessingTask.Status.ERROR + processing_task.error = f'Error processing species data: {e}' + processing_task.save() + raise diff --git a/bats_ai/tasks/nabat/tasks.py b/bats_ai/tasks/nabat/tasks.py new file mode 100644 index 00000000..a8626130 --- /dev/null +++ b/bats_ai/tasks/nabat/tasks.py @@ -0,0 +1,390 @@ +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 + +from bats_ai.core.models import Species +from bats_ai.core.models.nabat import ( + NABatCompressedSpectrogram, + NABatRecording, + NABatRecordingAnnotation, + NABatSpectrogram, +) +from bats_ai.tasks.tasks import predict_compressed + +FREQ_MIN = 5e3 +FREQ_MAX = 120e3 +FREQ_PAD = 2e3 + +COLORMAP_ALLOWED = [None, 'gist_yarg', 'turbo'] + + +def generate_spectrogram(nabat_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'{nabat_recording.recording_id}_spectrogram.jpg') + + spectrogram = NABatSpectrogram.objects.create( + nabat_recording=nabat_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, + ) + + # Clean up temporary file + temp_file.close() + + return spectrogram + + +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(nabat_recording_id: int, spectrogram_id: int): + nabat_recording = NABatRecording.objects.get(pk=nabat_recording_id) + spectrogram = NABatSpectrogram.objects.get(pk=spectrogram_id) + length, image_file, widths, starts, stops = generate_compressed(spectrogram) + found = NABatCompressedSpectrogram.objects.filter( + nabat_recording=nabat_recording_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( + nabat_recording=nabat_recording, + spectrogram=spectrogram, + image_file=image_file, + length=length, + widths=widths, + starts=starts, + stops=stops, + cache_invalidated=False, + ) + return existing + + +@shared_task +def predict(compressed_spectrogram_id: int): + compressed_spectrogram = NABatCompressedSpectrogram.objects.get(pk=compressed_spectrogram_id) + label, score, confs = predict_compressed(compressed_spectrogram.image_file) + 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) + + nabat_recording_annotation = NABatRecordingAnnotation.objects.create( + nabat_recording=compressed_spectrogram.nabat_recording, + comments='Compressed Spectrogram Generation Prediction', + model='model.mobilenet.onnx', + confidence=output['score'], + additional_data=output, + ) + nabat_recording_annotation.species.set(species) + nabat_recording_annotation.save() + return label, score, confs diff --git a/bats_ai/core/tasks.py b/bats_ai/tasks/tasks.py similarity index 51% rename from bats_ai/core/tasks.py rename to bats_ai/tasks/tasks.py index 03216ddc..0769d29d 100644 --- a/bats_ai/core/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 @@ -19,6 +22,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 @@ -183,13 +365,13 @@ 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) config = Configuration.objects.first() - if not config or not config.run_inference_on_upload: + if config and config.run_inference_on_upload: predict(compressed_spectro.pk) @@ -225,7 +407,7 @@ def generate_compress_spectrogram(recording_id: int, spectrogram_id: int): @shared_task def predict(compressed_spectrogram_id: int): compressed_spectrogram = CompressedSpectrogram.objects.get(pk=compressed_spectrogram_id) - label, score, confs = compressed_spectrogram.predict() + label, score, confs = predict_compressed(compressed_spectrogram.image_file) confidences = [{'label': key, 'value': float(value)} for key, value in confs.items()] sorted_confidences = sorted(confidences, key=lambda x: x['value'], reverse=True) output = { @@ -246,3 +428,79 @@ def predict(compressed_spectrogram_id: int): recording_annotation.species.set(species) recording_annotation.save() return label, score, confs + + +def predict_compressed(image_file): + import json + import os + + import onnx + import onnxruntime as ort + import tqdm + + img = Image.open(image_file) + + relative = ('..',) * 3 + 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 diff --git a/client/package-lock.json b/client/package-lock.json index 3aece158..91348c3d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,11 +1,11 @@ { - "name": "vue-project-template", + "name": "bat-ai-client", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vue-project-template", + "name": "bat-ai-client", "version": "0.1.0", "dependencies": { "@girder/oauth-client": "^0.8.0", diff --git a/client/src/App.vue b/client/src/App.vue index 4a594d0b..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'))); return { oauthClient, containsSpectro, @@ -69,6 +69,7 @@ export default defineComponent({ nextShared, sideTab, isAdmin, + isNaBat, }; }, }); @@ -78,7 +79,7 @@ export default defineComponent({ @@ -108,6 +109,12 @@ export default defineComponent({ Admin +

+ NA Bat Spectrogram Viewer +

('/nabat/recording/', formData))).data; + if (isNABatRecordingCompleteResponse(response)) { + return response as NABatRecordingCompleteResponse; + } + return response as NABatRecordingDataResponse; +} + +async function getNABatSpectrogram(id: string, apiToken: string) { + return axiosInstance.get(`/nabat/recording/${id}/spectrogram?apiToken=${apiToken}`); +} + +async function getNABatSpectrogramCompressed(id: string, apiToken: string) { + return axiosInstance.get(`/nabat/recording/${id}/spectrogram/compressed?apiToken=${apiToken}`); + +} + +async function getNABatRecordingFileAnnotations(recordingId: number, apiToken?: string) { + return axiosInstance.get(`/nabat/recording/${recordingId}/recording-annotations`, { params: { apiToken } }); +} + + +async function getNABatFileAnnotations(recordingId: number) { + return axiosInstance.get(`recording/${recordingId}/recording-annotations`); +} + +async function getNABatFileAnnotationDetails(recordingId: number, apiToken?: string) { + return axiosInstance.get<(FileAnnotation & {details: FileAnnotationDetails })>(`nabat/recording/recording-annotation/${recordingId}/details`, { params: { apiToken } }); +} + +async function putNABatFileAnnotation(fileAnnotation: UpdateFileAnnotation & { apiToken?: string}) { + return axiosInstance.put<{ message: string, id: number }>(`nabat/recording/recording-annotation`, { ...fileAnnotation }); +} + +async function patchNABatFileAnnotation(fileAnnotationId: number, fileAnnotation: UpdateFileAnnotation & { apiToken?: string}) { + return axiosInstance.patch<{ message: string, id: number }>(`nabat/recording/recording-annotation/${fileAnnotationId}`, { ...fileAnnotation }); +} + +async function deleteNABatFileAnnotation(fileAnnotationId: number, apiToken?: string) { + return axiosInstance.delete<{ message: string, id: number }>(`nabat/recording/recording-annotation/${fileAnnotationId}`, { params: { apiToken } }); +} + + + +export { + postNABatRecording, + getNABatSpectrogram, + getNABatSpectrogramCompressed, + getNABatRecordingFileAnnotations, + getNABatFileAnnotations, + getNABatFileAnnotationDetails, + putNABatFileAnnotation, + patchNABatFileAnnotation, + deleteNABatFileAnnotation, +}; \ No newline at end of file diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 0539b1b0..603c749b 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -36,17 +36,6 @@ export interface Recording { unusual_occurrences?: string; } -export interface AcousticFiles { - id: number, - recording_time: string; - recording_location: string | null; - file_name: string | null; - s3_verified: boolean | null; - length_ms: number | null; - size_bytes: number | null; - survey_event: null; -} - export interface Species { species_code: string; family: string; @@ -361,13 +350,14 @@ 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; run_inference_on_upload: boolean; spectrogram_x_stretch: number; spectrogram_view: 'compressed' | 'uncompressed'; - is_admin: boolean; + is_admin?: boolean; } export type Configuration = ConfigurationSettings & { is_admin: boolean }; @@ -379,6 +369,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?: 'NABatRecordingProcessing' } & { recordingId: 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 @@ -406,6 +439,12 @@ async function getGuanoMetadata(file: File): Promise { } +async function adminNaBatUpdateSpecies(apiToken: string) { + return axiosInstance.post<{ taskId: string }>('/nabat/configuration/update-species', { params: { apiToken } }); + + } + + export { uploadRecordingFile, getRecordings, @@ -433,5 +472,10 @@ export { deleteFileAnnotation, getConfiguration, patchConfiguration, + getProcessingTasks, + getProcessingTaskDetails, + cancelProcessingTask, + getFilteredProcessingTasks, getFileAnnotationDetails, + adminNaBatUpdateSpecies, }; diff --git a/client/src/components/AnnotationEditor.vue b/client/src/components/AnnotationEditor.vue index f516fae1..368fedec 100644 --- a/client/src/components/AnnotationEditor.vue +++ b/client/src/components/AnnotationEditor.vue @@ -93,7 +93,8 @@ export default defineComponent({ type, selectedType, updateAnnotation, - deleteAnno + deleteAnnotation, + deleteAnno, }; }, }); diff --git a/client/src/components/RecordingAnnotationDetails.vue b/client/src/components/RecordingAnnotationDetails.vue index 49115a25..805334a7 100644 --- a/client/src/components/RecordingAnnotationDetails.vue +++ b/client/src/components/RecordingAnnotationDetails.vue @@ -1,6 +1,7 @@ + + + + diff --git a/client/src/views/NABatRecording.vue b/client/src/views/NABatRecording.vue new file mode 100644 index 00000000..da705d8d --- /dev/null +++ b/client/src/views/NABatRecording.vue @@ -0,0 +1,154 @@ + + + diff --git a/client/src/views/NABatSpectrogram.vue b/client/src/views/NABatSpectrogram.vue new file mode 100644 index 00000000..f0e02a56 --- /dev/null +++ b/client/src/views/NABatSpectrogram.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/dev/.env.docker-compose b/dev/.env.docker-compose index 701f4cec..316abbcd 100644 --- a/dev/.env.docker-compose +++ b/dev/.env.docker-compose @@ -7,8 +7,9 @@ DJANGO_MINIO_STORAGE_ENDPOINT=minio:9000 DJANGO_MINIO_STORAGE_ACCESS_KEY=minioAccessKey DJANGO_MINIO_STORAGE_SECRET_KEY=minioSecretKey DJANGO_STORAGE_BUCKET_NAME=django-storage -DJANGO_MINIO_STORAGE_ENDPOINT=minio:9000 DJANGO_CORS_ORIGIN_WHITELIST=http://localhost:3000 DOCKER_POSTGRES_PORT=5432 DOCKER_MINIO_PORT=9000 DOCKER_MINIO_CONSOLE_PORT=9001 +APPLICATION_CLIENT_ID=HSJWFZ2cIpWQOvNyCXyStV9hiOd7DfWeBOCzo4pP # Application Identification +NABAT_API_URL=https://api.sciencebase.gov/nabat-graphql/graphql diff --git a/dev/.env.docker-compose-native b/dev/.env.docker-compose-native index 7c57700a..e8636c61 100644 --- a/dev/.env.docker-compose-native +++ b/dev/.env.docker-compose-native @@ -5,3 +5,5 @@ DJANGO_MINIO_STORAGE_ENDPOINT=localhost:9000 DJANGO_MINIO_STORAGE_ACCESS_KEY=minioAccessKey DJANGO_MINIO_STORAGE_SECRET_KEY=minioSecretKey DJANGO_STORAGE_BUCKET_NAME=django-storage +APPLICATION_CLIENT_ID=HSJWFZ2cIpWQOvNyCXyStV9hiOd7DfWeBOCzo4pP # Application Identification +NABAT_API_URL=https://api.sciencebase.gov/nabat-graphql/graphql diff --git a/dev/.env.prod.docker-compose.template b/dev/.env.prod.docker-compose.template index ada664b1..d185a113 100644 --- a/dev/.env.prod.docker-compose.template +++ b/dev/.env.prod.docker-compose.template @@ -14,3 +14,5 @@ ACME_EMAIL=Bryon.Lewis@kitware.com DOCKER_POSTGRES_PORT=5432 DOCKER_MINIO_PORT=9000 DOCKER_MINIO_CONSOLE_PORT=9001 +APPLICATION_CLIENT_ID=HSJWFZ2cIpWQOvNyCXyStV9hiOd7DfWeBOCzo4pP # Application Identification +NABAT_API_URL=https://api.sciencebase.gov/nabat-graphql/graphql diff --git a/dev/.env.prod.s3.template b/dev/.env.prod.s3.template index 282ee681..332f7355 100644 --- a/dev/.env.prod.s3.template +++ b/dev/.env.prod.s3.template @@ -11,3 +11,5 @@ AWS_DEFAULT_REGION= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= DJANGO_STORAGE_BUCKET_NAME= +APPLICATION_CLIENT_ID=HSJWFZ2cIpWQOvNyCXyStV9hiOd7DfWeBOCzo4pP # Application Identification +NABAT_API_URL=https://api.sciencebase.gov/nabat-graphql/graphql diff --git a/dev/django.Dockerfile b/dev/django.Dockerfile index 7861630d..941bea08 100644 --- a/dev/django.Dockerfile +++ b/dev/django.Dockerfile @@ -1,8 +1,8 @@ -FROM python:3.10-slim +FROM python:3.10-slim-bookworm -ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED 1 +ENV PYTHONUNBUFFERED=1 # Install system libraries for Python packages: # * psycopg2 @@ -17,6 +17,7 @@ RUN set -ex \ libgdal-dev \ libpq-dev \ libsndfile1-dev \ + ca-certificates \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -26,6 +27,13 @@ RUN set -ex \ # and all package modules are importable. COPY ./setup.py /opt/django-project/setup.py +# TODO: TEMPORARY FOR SSL VERIFICATION +COPY ./dev/sciencebase-fullchain.crt /usr/local/share/ca-certificates/sciencebase-fullchain.crt +RUN update-ca-certificates + +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + # Use a directory name which will never be an import name, as isort considers this as first-party. WORKDIR /opt/django-project # hadolint ignore=DL3013 diff --git a/dev/sciencebase-fullchain.crt b/dev/sciencebase-fullchain.crt new file mode 100644 index 00000000..34746350 --- /dev/null +++ b/dev/sciencebase-fullchain.crt @@ -0,0 +1,131 @@ +-----BEGIN CERTIFICATE----- +MIIHCDCCBfCgAwIBAgIQAiA0JXynGVRxqKRYc9sFKzANBgkqhkiG9w0BAQsFADBZ +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE +aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjUw +NDIyMDAwMDAwWhcNMjYwNDI5MjM1OTU5WjBuMQswCQYDVQQGEwJVUzERMA8GA1UE +CBMIVmlyZ2luaWExDzANBgNVBAcTBlJlc3RvbjEfMB0GA1UEChMWVS5TLiBHZW9s +b2dpY2FsIFN1cnZleTEaMBgGA1UEAwwRKi5zY2llbmNlYmFzZS5nb3YwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCjTjaxZoQ2CpKuw6ZL1x0qDKDCDBu6 +wDxKsdW8ObZDU0ibWPqWIqHvcUVALEe8Ki9WjMMDHkodYcgIJVxcOeT1DPZFyKGu +5eplLG/J9UYpkp5yrQCwx0WTQap9xJUAUbtWDhYGE3pk1KcMCBPvuec6Va9Ok6YO +/bKlYhxcww0A7cCERZO3QMKqFnAIXbQZtTSGBZDSrbDiTLdKQVITJKwZ+kWCfaWd +BEVE+gxk5tAx/sx+jx1N28A9TSwTJhcREJP9MSY0fS5zQaOQg9Y+pux9R95o2tqK +khI819bhwi4qS/Pg8tGsLgTen3Ss9L62Ec02qint3Iu1N/w79kXAkJ/FAgMBAAGj +ggO1MIIDsTAfBgNVHSMEGDAWgBR0hYDAZsffN97PvSk3qgMdvu3NFzAdBgNVHQ4E +FgQU9x45UrXEcuOJGekX88bxq/PYurswQgYDVR0RBDswOYIRKi5zY2llbmNlYmFz +ZS5nb3aCD3NjaWVuY2ViYXNlLmdvdoITd3d3LnNjaWVuY2ViYXNlLmdvdjA+BgNV +HSAENzA1MDMGBmeBDAECAjApMCcGCCsGAQUFBwIBFhtodHRwOi8vd3d3LmRpZ2lj +ZXJ0LmNvbS9DUFMwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMB +BggrBgEFBQcDAjCBnwYDVR0fBIGXMIGUMEigRqBEhkJodHRwOi8vY3JsMy5kaWdp +Y2VydC5jb20vRGlnaUNlcnRHbG9iYWxHMlRMU1JTQVNIQTI1NjIwMjBDQTEtMS5j +cmwwSKBGoESGQmh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2Jh +bEcyVExTUlNBU0hBMjU2MjAyMENBMS0xLmNybDCBhwYIKwYBBQUHAQEEezB5MCQG +CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wUQYIKwYBBQUHMAKG +RWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbEcyVExT +UlNBU0hBMjU2MjAyMENBMS0xLmNydDAMBgNVHRMBAf8EAjAAMIIBgAYKKwYBBAHW +eQIEAgSCAXAEggFsAWoAdgAOV5S8866pPjMbLJkHs/eQ35vCPXEyJd0hqSWsYcVO +IQAAAZZehMPFAAAEAwBHMEUCIQCAws0iP+LqXwNfsTaoDV93ZnUwIxkrLRD7t+Lj +KofTHAIgZQPCZ0DV/L/NCwTJW2KpadtNpMaKgUfDA/DHeUxs8S4AdwBJnJtp3h18 +7Pw23s2HZKa4W68Kh4AZ0VVS++nrKd34wwAAAZZehMQXAAAEAwBIMEYCIQDyj7st +DPFeZ72beKt5RXju0eTXVo3cMkTACQyTKzZUJgIhAPe3BMwUEaWFxi3GlHj3gj62 +K4xcG/2STrhLhdlk2KwTAHcAyzj3FYl8hKFEX1vB3fvJbvKaWc1HCmkFhbDLFMMU +WOcAAAGWXoTD2AAABAMASDBGAiEA4/fh9bzbkIBLAQoWxXrUrp0dBnCg4UDdxCpx +HoCXAQkCIQDQ8WbiVOJWnGuA7cgUBwkE9g4xBamToGj89y7k1kCU9zANBgkqhkiG +9w0BAQsFAAOCAQEAYSVSfY7ucgGicmooGl4yUOn0EtupPXTVRd+pk5d2zTpv1tcS +paWDKsIYM/Y7b0oLRDNS9yCS5GmUjI1JflxdAr8RhgEjwRq3JmUIcE5m+Id+Bs5g +rWXxO+MtzApbz31PdyDdAR0JcJ0mOZN8Di13ystwG+wjp5SIak/+xTavsfriO0yJ +hV7VkpDNCE1YgVCGAJr6ClD9GkCJv1zH+n1L+j0q1saMylNeIGYPmkEtjL0h926Q +ep+CUl0FAWoiie2q/DyogdfSugqY7Gv7VRkSRaaqWjzlXHl+AacxXDSK+OYsrw35 +jxjXDUFritXdfAgdLZJrEvzsdcjsOOQ93QMhYg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIHCDCCBfCgAwIBAgIQAiA0JXynGVRxqKRYc9sFKzANBgkqhkiG9w0BAQsFADBZ +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE +aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjUw +NDIyMDAwMDAwWhcNMjYwNDI5MjM1OTU5WjBuMQswCQYDVQQGEwJVUzERMA8GA1UE +CBMIVmlyZ2luaWExDzANBgNVBAcTBlJlc3RvbjEfMB0GA1UEChMWVS5TLiBHZW9s +b2dpY2FsIFN1cnZleTEaMBgGA1UEAwwRKi5zY2llbmNlYmFzZS5nb3YwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCjTjaxZoQ2CpKuw6ZL1x0qDKDCDBu6 +wDxKsdW8ObZDU0ibWPqWIqHvcUVALEe8Ki9WjMMDHkodYcgIJVxcOeT1DPZFyKGu +5eplLG/J9UYpkp5yrQCwx0WTQap9xJUAUbtWDhYGE3pk1KcMCBPvuec6Va9Ok6YO +/bKlYhxcww0A7cCERZO3QMKqFnAIXbQZtTSGBZDSrbDiTLdKQVITJKwZ+kWCfaWd +BEVE+gxk5tAx/sx+jx1N28A9TSwTJhcREJP9MSY0fS5zQaOQg9Y+pux9R95o2tqK +khI819bhwi4qS/Pg8tGsLgTen3Ss9L62Ec02qint3Iu1N/w79kXAkJ/FAgMBAAGj +ggO1MIIDsTAfBgNVHSMEGDAWgBR0hYDAZsffN97PvSk3qgMdvu3NFzAdBgNVHQ4E +FgQU9x45UrXEcuOJGekX88bxq/PYurswQgYDVR0RBDswOYIRKi5zY2llbmNlYmFz +ZS5nb3aCD3NjaWVuY2ViYXNlLmdvdoITd3d3LnNjaWVuY2ViYXNlLmdvdjA+BgNV +HSAENzA1MDMGBmeBDAECAjApMCcGCCsGAQUFBwIBFhtodHRwOi8vd3d3LmRpZ2lj +ZXJ0LmNvbS9DUFMwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMB +BggrBgEFBQcDAjCBnwYDVR0fBIGXMIGUMEigRqBEhkJodHRwOi8vY3JsMy5kaWdp +Y2VydC5jb20vRGlnaUNlcnRHbG9iYWxHMlRMU1JTQVNIQTI1NjIwMjBDQTEtMS5j +cmwwSKBGoESGQmh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2Jh +bEcyVExTUlNBU0hBMjU2MjAyMENBMS0xLmNybDCBhwYIKwYBBQUHAQEEezB5MCQG +CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wUQYIKwYBBQUHMAKG +RWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbEcyVExT +UlNBU0hBMjU2MjAyMENBMS0xLmNydDAMBgNVHRMBAf8EAjAAMIIBgAYKKwYBBAHW +eQIEAgSCAXAEggFsAWoAdgAOV5S8866pPjMbLJkHs/eQ35vCPXEyJd0hqSWsYcVO +IQAAAZZehMPFAAAEAwBHMEUCIQCAws0iP+LqXwNfsTaoDV93ZnUwIxkrLRD7t+Lj +KofTHAIgZQPCZ0DV/L/NCwTJW2KpadtNpMaKgUfDA/DHeUxs8S4AdwBJnJtp3h18 +7Pw23s2HZKa4W68Kh4AZ0VVS++nrKd34wwAAAZZehMQXAAAEAwBIMEYCIQDyj7st +DPFeZ72beKt5RXju0eTXVo3cMkTACQyTKzZUJgIhAPe3BMwUEaWFxi3GlHj3gj62 +K4xcG/2STrhLhdlk2KwTAHcAyzj3FYl8hKFEX1vB3fvJbvKaWc1HCmkFhbDLFMMU +WOcAAAGWXoTD2AAABAMASDBGAiEA4/fh9bzbkIBLAQoWxXrUrp0dBnCg4UDdxCpx +HoCXAQkCIQDQ8WbiVOJWnGuA7cgUBwkE9g4xBamToGj89y7k1kCU9zANBgkqhkiG +9w0BAQsFAAOCAQEAYSVSfY7ucgGicmooGl4yUOn0EtupPXTVRd+pk5d2zTpv1tcS +paWDKsIYM/Y7b0oLRDNS9yCS5GmUjI1JflxdAr8RhgEjwRq3JmUIcE5m+Id+Bs5g +rWXxO+MtzApbz31PdyDdAR0JcJ0mOZN8Di13ystwG+wjp5SIak/+xTavsfriO0yJ +hV7VkpDNCE1YgVCGAJr6ClD9GkCJv1zH+n1L+j0q1saMylNeIGYPmkEtjL0h926Q +ep+CUl0FAWoiie2q/DyogdfSugqY7Gv7VRkSRaaqWjzlXHl+AacxXDSK+OYsrw35 +jxjXDUFritXdfAgdLZJrEvzsdcjsOOQ93QMhYg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIE9DCCA9ygAwIBAgIQCF+UwC2Fe+jMFP9T7aI+KjANBgkqhkiG9w0BAQsFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0yMDA5MjQwMDAwMDBaFw0zMDA5MjMyMzU5NTlaMFkxCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxMzAxBgNVBAMTKkRpZ2lDZXJ0IEdsb2Jh +bCBHMiBUTFMgUlNBIFNIQTI1NiAyMDIwIENBMTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAMz3EGJPprtjb+2QUlbFbSd7ehJWivH0+dbn4Y+9lavyYEEV +cNsSAPonCrVXOFt9slGTcZUOakGUWzUb+nv6u8W+JDD+Vu/E832X4xT1FE3LpxDy +FuqrIvAxIhFhaZAmunjZlx/jfWardUSVc8is/+9dCopZQ+GssjoP80j812s3wWPc +3kbW20X+fSP9kOhRBx5Ro1/tSUZUfyyIxfQTnJcVPAPooTncaQwywa8WV0yUR0J8 +osicfebUTVSvQpmowQTCd5zWSOTOEeAqgJnwQ3DPP3Zr0UxJqyRewg2C/Uaoq2yT +zGJSQnWS+Jr6Xl6ysGHlHx+5fwmY6D36g39HaaECAwEAAaOCAa4wggGqMB0GA1Ud +DgQWBBR0hYDAZsffN97PvSk3qgMdvu3NFzAfBgNVHSMEGDAWgBROIlQgGJXm427m +D/r6uRLtBhePOTAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwEG +CCsGAQUFBwMCMBIGA1UdEwEB/wQIMAYBAf8CAQAwdgYIKwYBBQUHAQEEajBoMCQG +CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQAYIKwYBBQUHMAKG +NGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RH +Mi5jcnQwewYDVR0fBHQwcjA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQuY29t +L0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDA3oDWgM4YxaHR0cDovL2NybDQuZGln +aWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDAwBgNVHSAEKTAnMAcG +BWeBDAEBMAgGBmeBDAECATAIBgZngQwBAgIwCAYGZ4EMAQIDMA0GCSqGSIb3DQEB +CwUAA4IBAQB1i8A8W+//cFxrivUh76wx5kM9gK/XVakew44YbHnT96xC34+IxZ20 +dfPJCP2K/lHz8p0gGgQ1zvi2QXmv/8yWXpTTmh1wLqIxi/ulzH9W3xc3l7/BjUOG +q4xmfrnti/EPjLXUVa9ciZ7gpyptsqNjMhg7y961n4OzEQGsIA2QlxK3KZw1tdeR +Du9Ab21cO72h2fviyy52QNI6uyy/FgvqvQNbTpg6Ku0FUAcVkzxzOZGUWkgOxtNK +Aa9mObm9QjQc2wgD80D8EuiuPKuK1ftyeWSm4w5VsTuVP61gM2eKrLanXPDtWlIb +1GHhJRLmB7WqlLLwKPZhJl5VHPgB63dx +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI +2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx +1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ +q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz +tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ +vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV +5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY +1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4 +NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG +Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91 +8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe +pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl +MrY= +-----END CERTIFICATE----- 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 04192166..30e4e443 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: diff --git a/docker-compose.yml b/docker-compose.yml index a3b71ada..1193c0e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: postgres: image: postgis/postgis:latest 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..7e78b648 --- /dev/null +++ b/scripts/USGS/naBatInfo.py @@ -0,0 +1,132 @@ +import base64 +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' +SURVEY_ID = 591184 +SURVEY_EVENT_ID = 4768736 +ACOUSTIC_FILE_ID = 190255936 +PROJECT_ID = 7168 +BATCH_ID = 319479412 +SOFTWARE_ID = 81 +# GraphQL queries +QUERY = """ +query fetchAcousticAndSurveyEventInfo { + presignedUrlFromAcousticFile(acousticFileId: "%(acoustic_file_id)s") { + s3PresignedUrl + } + surveyEventById(id: "%(survey_event_id)d") { + createdBy + createdDate + eventGeometryByEventGeometryId { + description + geom { + geojson + } + } + acousticBatchesBySurveyEventId(filter: {softwareId: {equalTo:%(software_id)d}}) { + nodes { + id + acousticFileBatchesByBatchId(filter: {fileId: {equalTo: "%(acoustic_file_id)s"}}) { + nodes { + autoId + manualId + vetter + speciesByManualId { + speciesCode + } + } + } + } + } + } + acousticFileById(id: "%(acoustic_file_id)d") { + fileName + recordingTime + s3Verified + sizeBytes + } +} +""" + + +def decode_jwt(token): + # Split the token into parts + parts = token.split('.') + if len(parts) != 3: + raise ValueError('Invalid JWT token format') + + # JWT uses base64url encoding, so need to fix padding + payload = parts[1] + padding = '=' * (4 - (len(payload) % 4)) # Fix padding if needed + payload += padding + + # Decode the payload + decoded_bytes = base64.urlsafe_b64decode(payload) + decoded_str = decoded_bytes.decode('utf-8') + + # Parse JSON + return json.loads(decoded_str) + + +@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...') + batch_query = QUERY % { + 'acoustic_file_id': ACOUSTIC_FILE_ID, + 'survey_event_id': SURVEY_EVENT_ID, + 'software_id': SOFTWARE_ID, + } + response = requests.post(BASE_URL, json={'query': batch_query}, headers=headers) + batch_data = {} + + print(response.text) + + if response.status_code == 200: + try: + batch_data = response.json() + batch_data['jwt'] = decode_jwt(AUTH_TOKEN) + 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 + file_name = batch_data['data']['acousticFileById']['fileName'] + + # Fetch presigned URL + presigned_url = batch_data['data']['presignedUrlFromAcousticFile']['s3PresignedUrl'] + # 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: + logger.error(f'Failed to download file: {file_response.status_code}') + + +if __name__ == '__main__': + fetch_and_save() diff --git a/scripts/USGS/naBatSpecies.py b/scripts/USGS/naBatSpecies.py new file mode 100644 index 00000000..2a1411f5 --- /dev/null +++ b/scripts/USGS/naBatSpecies.py @@ -0,0 +1,78 @@ +import base64 +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' +# GraphQL queries +QUERY = """ +query GetAllSpeciesOptions { + allSpecies { + nodes { + id + species + speciesCode + speciesCode6 + genus + family + commonName + } + } +} +""" + + +def decode_jwt(token): + # Split the token into parts + parts = token.split('.') + if len(parts) != 3: + raise ValueError('Invalid JWT token format') + + # JWT uses base64url encoding, so need to fix padding + payload = parts[1] + padding = '=' * (4 - (len(payload) % 4)) # Fix padding if needed + payload += padding + + # Decode the payload + decoded_bytes = base64.urlsafe_b64decode(payload) + decoded_str = decoded_bytes.decode('utf-8') + + # Parse JSON + return json.loads(decoded_str) + + +@click.command() +def fetch_and_save(): + """Fetch data using GraphQL and save to species.json""" + headers = {} + # Fetch batch data + logger.info('Fetching batch data...') + response = requests.post(BASE_URL, json={'query': QUERY}, headers=headers) + batch_data = {} + + print(response.text) + + if response.status_code == 200: + try: + batch_data = response.json() + with open('species.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 + + +if __name__ == '__main__': + fetch_and_save() diff --git a/scripts/USGS/sampleUrl.txt b/scripts/USGS/sampleUrl.txt new file mode 100644 index 00000000..94c632a9 --- /dev/null +++ b/scripts/USGS/sampleUrl.txt @@ -0,0 +1 @@ +http://localhost:3000/nabat/190255936/?surveyEventId=4768736&apiToken='CHANGE TO YOUR API TOKEN' 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 68700e05..06925932 100644 --- a/setup.py +++ b/setup.py @@ -70,6 +70,7 @@ 'rio-cogeo', # guano metadata 'guano', + 'django_celery_results', ], extras_require={ 'dev': [