From 205f74aba164f78d88b3f297470c888fd2fb4f77 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 6 Feb 2024 13:58:21 -0500 Subject: [PATCH 01/47] temporal annotation models and endpoints --- .gitignore | 2 + bats_ai/core/models/__init__.py | 2 + bats_ai/core/models/temporal_annotations.py | 13 +++ bats_ai/core/views/__init__.py | 4 + bats_ai/core/views/annotations.py | 34 ++++++-- bats_ai/core/views/recording.py | 89 ++++++++++++++++++++- bats_ai/core/views/temporal_annotations.py | 45 +++++++++++ 7 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 bats_ai/core/models/temporal_annotations.py create mode 100644 bats_ai/core/views/temporal_annotations.py diff --git a/.gitignore b/.gitignore index e43b0f9..783b798 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +/**/*.shp +/**/*.shx diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index 3dfd89d..cc8bf05 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -4,6 +4,7 @@ from .recording_annotation_status import RecordingAnnotationStatus from .species import Species from .spectrogram import Spectrogram +from .temporal_annotations import TemporalAnnotations __all__ = [ 'Annotations', @@ -12,4 +13,5 @@ 'RecordingAnnotationStatus', 'Species', 'Spectrogram', + 'TemporalAnnotations', ] diff --git a/bats_ai/core/models/temporal_annotations.py b/bats_ai/core/models/temporal_annotations.py new file mode 100644 index 0000000..1f5207e --- /dev/null +++ b/bats_ai/core/models/temporal_annotations.py @@ -0,0 +1,13 @@ +from django.contrib.auth.models import User +from django.db import models + +from .recording import Recording + + +class TemporalAnnotations(models.Model): + recording = models.ForeignKey(Recording, on_delete=models.CASCADE) + owner = models.ForeignKey(User, on_delete=models.CASCADE) + start_time = models.IntegerField(blank=True, null=True) + end_time = models.IntegerField(blank=True, null=True) + type = models.TextField(blank=True, null=True) + comments = models.TextField(blank=True, null=True) diff --git a/bats_ai/core/views/__init__.py b/bats_ai/core/views/__init__.py index 6a0e84a..ddf5efb 100644 --- a/bats_ai/core/views/__init__.py +++ b/bats_ai/core/views/__init__.py @@ -1,7 +1,11 @@ +from .annotations import router as AnnotationRouter from .recording import router as RecordingRouter from .species import router as SpeciesRouter +from .temporal_annotations import router as TemporalAnnotationRouter __all__ = [ 'RecordingRouter', 'SpeciesRouter', + 'AnnotationRouter', + 'TemporalAnnotationRouter', ] diff --git a/bats_ai/core/views/annotations.py b/bats_ai/core/views/annotations.py index 68c9445..e83fd25 100644 --- a/bats_ai/core/views/annotations.py +++ b/bats_ai/core/views/annotations.py @@ -2,9 +2,9 @@ from django.http import HttpRequest from ninja import Schema -from ninja.errors import HttpError from ninja.pagination import RouterPaginated -from oauth2_provider.models import AccessToken + +from bats_ai.core.models import Annotations, Recording logger = logging.getLogger(__name__) @@ -23,10 +23,28 @@ class AnnotationSchema(Schema): comments: str -def get_owner_id(request: HttpRequest): - token = request.headers.get('Authorization').replace('Bearer ', '') - token_found = AccessToken.objects.get(token=token) - if not token_found: - raise HttpError(401, 'Authentication credentials were not provided.') +@router.get('/{id}') +def get_annotation(request: HttpRequest, id: int): + try: + annotation = Annotations.objects.get(pk=id) + recording = annotation.recording + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) + + # Serialize the annotations using AnnotationSchema + annotations_data = [ + AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs + ] + + return annotations_data + else: + return { + 'error': 'Permission denied. You do not own this annotation, or the associated recording is not public.' + } - return token_found.user.pk + except Recording.DoesNotExist: + return {'error': 'Recording not found'} diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 951c042..5b14cce 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -8,8 +8,9 @@ from ninja.files import UploadedFile from ninja.pagination import RouterPaginated -from bats_ai.core.models import Annotations, Recording, Species +from bats_ai.core.models import Annotations, Recording, Species, TemporalAnnotations from bats_ai.core.views.species import SpeciesSchema +from bats_ai.core.views.temporal_annotations import TemporalAnnotationSchema logger = logging.getLogger(__name__) @@ -461,3 +462,89 @@ def delete_annotation(request, recording_id: int, id: int): return {'error': 'Recording not found'} except Annotations.DoesNotExist: return {'error': 'Annotation not found'} + + +# TEMPORAL ANNOTATIONS + + +@router.get('recording/{id}/temporal-annotations') +def get_temporal_annotations(request: HttpRequest, id: int): + try: + recording = Recording.objects.get(pk=id) + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = TemporalAnnotations.objects.filter( + recording=recording, owner=request.user + ) + + # Serialize the annotations using AnnotationSchema + annotations_data = [ + TemporalAnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs + ] + + return annotations_data + else: + return { + 'error': 'Permission denied. You do not own this recording, and it is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} + + +@router.put('recording/{id}/temporal-annotations') +def put_temporal_annotation( + request, + id: int, + annotation: TemporalAnnotationSchema, +): + try: + recording = Recording.objects.get(pk=id) + if recording.owner == request.user or recording.public: + # Create a new annotation + new_annotation = TemporalAnnotations.objects.create( + recording=recording, + owner=request.user, + start_time=annotation.start_time, + end_time=annotation.end_time, + type=annotation.type, + comments=annotation.comments, + ) + + return {'message': 'Annotation added successfully', 'id': new_annotation.pk} + else: + return { + 'error': 'Permission denied. You do not own this recording, and it is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} + + +@router.delete('/{recording_id}/temporal-annotations/{id}') +def delete_temporal_annotation(request, recording_id: int, id: int): + try: + recording = Recording.objects.get(pk=recording_id) + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + annotation_instance = TemporalAnnotations.objects.get( + pk=id, recording=recording, owner=request.user + ) + + # Delete the annotation + annotation_instance.delete() + + return {'message': 'Annotation deleted successfully'} + else: + return { + 'error': 'Permission denied. You do not own this recording, and it is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} + except Annotations.DoesNotExist: + return {'error': 'Annotation not found'} diff --git a/bats_ai/core/views/temporal_annotations.py b/bats_ai/core/views/temporal_annotations.py new file mode 100644 index 0000000..e961363 --- /dev/null +++ b/bats_ai/core/views/temporal_annotations.py @@ -0,0 +1,45 @@ +from django.http import HttpRequest +from ninja import Schema +from ninja.pagination import RouterPaginated + +from bats_ai.core.models import Annotations, Recording, TemporalAnnotations + +router = RouterPaginated() + + +class TemporalAnnotationSchema(Schema): + recording: int # Foreign Key to index + owner_username: str + start_time: int + end_time: int + type: str + comments: str + + +@router.get('/{id}') +def get_temporal_annotation(request: HttpRequest, id: int): + try: + annotation = Annotations.objects.get(pk=id) + recording = annotation.recording + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = TemporalAnnotations.objects.filter( + recording=recording, owner=request.user + ) + + # Serialize the annotations using AnnotationSchema + annotations_data = [ + TemporalAnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs + ] + + return annotations_data + else: + return { + 'error': 'Permission denied. You do not own this annotation, or the associated recording is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} From e69021818c5ead3274ab5bfc281b5477d8b81785 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 7 Feb 2024 08:22:32 -0500 Subject: [PATCH 02/47] migrations for temporal endpoints --- .../migrations/0006_temporalannotations.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 bats_ai/core/migrations/0006_temporalannotations.py diff --git a/bats_ai/core/migrations/0006_temporalannotations.py b/bats_ai/core/migrations/0006_temporalannotations.py new file mode 100644 index 0000000..fa4ea5b --- /dev/null +++ b/bats_ai/core/migrations/0006_temporalannotations.py @@ -0,0 +1,28 @@ +# Generated by Django 4.1.13 on 2024-02-07 13:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0005_recording_public'), + ] + + operations = [ + migrations.CreateModel( + name='TemporalAnnotations', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.IntegerField(blank=True, null=True)), + ('end_time', models.IntegerField(blank=True, null=True)), + ('type', models.TextField(blank=True, null=True)), + ('comments', models.TextField(blank=True, null=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.recording')), + ], + ), + ] From db68837b17c02b6173b16b361024969c65156ec6 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 7 Feb 2024 08:23:49 -0500 Subject: [PATCH 03/47] updating migrations --- ...006_temporalannotations.py => 0007_temporalannotations.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename bats_ai/core/migrations/{0006_temporalannotations.py => 0007_temporalannotations.py} (90%) diff --git a/bats_ai/core/migrations/0006_temporalannotations.py b/bats_ai/core/migrations/0007_temporalannotations.py similarity index 90% rename from bats_ai/core/migrations/0006_temporalannotations.py rename to bats_ai/core/migrations/0007_temporalannotations.py index fa4ea5b..0351928 100644 --- a/bats_ai/core/migrations/0006_temporalannotations.py +++ b/bats_ai/core/migrations/0007_temporalannotations.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.13 on 2024-02-07 13:22 +# Generated by Django 4.1.13 on 2024-02-07 13:23 from django.conf import settings from django.db import migrations, models @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('core', '0005_recording_public'), + ('core', '0006_alter_recording_recording_location'), ] operations = [ From e5e4a63f0269f3d8dc1e03f5de340c57012488f6 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 7 Feb 2024 11:30:35 -0500 Subject: [PATCH 04/47] basics of rendering temporal annotations --- bats_ai/core/admin/__init__.py | 2 + bats_ai/core/admin/temporal_annotations.py | 18 ++ .../migrations/0007_temporalannotations.py | 22 +- bats_ai/core/views/recording.py | 9 + bats_ai/core/views/temporal_annotations.py | 14 +- client/src/App.vue | 8 +- client/src/api/api.ts | 12 ++ client/src/components/SpectrogramViewer.vue | 6 + client/src/components/TemporalList.vue | 114 +++++++++++ client/src/components/UploadRecording.vue | 2 +- client/src/components/geoJS/LayerManager.vue | 35 +++- client/src/components/geoJS/geoJSUtils.ts | 93 ++++++++- .../components/geoJS/layers/rectangleLayer.ts | 11 - .../components/geoJS/layers/temporalLayer.ts | 193 ++++++++++++++++++ client/src/router/index.ts | 14 +- client/src/use/useState.ts | 2 +- client/src/views/Login.vue | 17 ++ client/src/views/Recordings.vue | 2 +- client/src/views/Spectrogram.vue | 6 + 19 files changed, 547 insertions(+), 33 deletions(-) create mode 100644 bats_ai/core/admin/temporal_annotations.py create mode 100644 client/src/components/TemporalList.vue create mode 100644 client/src/components/geoJS/layers/temporalLayer.ts create mode 100644 client/src/views/Login.vue diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index 68eb9b8..c405338 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -2,10 +2,12 @@ from .image import ImageAdmin from .recording import RecordingAdmin from .spectrogram import SpectrogramAdmin +from .temporal_annotations import TemporalAnnotationsAdmin __all__ = [ 'AnnotationsAdmin', 'ImageAdmin', 'RecordingAdmin', 'SpectrogramAdmin', + 'TemporalAnnotationsAdmin', ] diff --git a/bats_ai/core/admin/temporal_annotations.py b/bats_ai/core/admin/temporal_annotations.py new file mode 100644 index 0000000..9415855 --- /dev/null +++ b/bats_ai/core/admin/temporal_annotations.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from bats_ai.core.models import TemporalAnnotations + + +@admin.register(TemporalAnnotations) +class TemporalAnnotationsAdmin(admin.ModelAdmin): + list_display = [ + 'pk', + 'recording', + 'owner', + 'start_time', + 'end_time', + 'type', + 'comments', + ] + list_select_related = True + autocomplete_fields = ['owner'] diff --git a/bats_ai/core/migrations/0007_temporalannotations.py b/bats_ai/core/migrations/0007_temporalannotations.py index 0351928..5fbf65f 100644 --- a/bats_ai/core/migrations/0007_temporalannotations.py +++ b/bats_ai/core/migrations/0007_temporalannotations.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('core', '0006_alter_recording_recording_location'), @@ -16,13 +15,28 @@ class Migration(migrations.Migration): migrations.CreateModel( name='TemporalAnnotations', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), ('start_time', models.IntegerField(blank=True, null=True)), ('end_time', models.IntegerField(blank=True, null=True)), ('type', models.TextField(blank=True, null=True)), ('comments', models.TextField(blank=True, null=True)), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.recording')), + ( + 'owner', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ( + 'recording', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.recording' + ), + ), ], ), ] diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 1aa48ba..84c653d 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -203,13 +203,22 @@ def get_spectrogram(request: HttpRequest, id: int): spectro_data['currentUser'] = request.user.email annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) + temporal_annotations_qs = TemporalAnnotations.objects.filter( + recording=recording, owner=request.user + ) # Serialize the annotations using AnnotationSchema annotations_data = [ AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() for annotation in annotations_qs ] + temporal_annotations_data = [ + TemporalAnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in temporal_annotations_qs + ] + spectro_data['annotations'] = annotations_data + spectro_data['temporal'] = temporal_annotations_data return spectro_data diff --git a/bats_ai/core/views/temporal_annotations.py b/bats_ai/core/views/temporal_annotations.py index e961363..10785b8 100644 --- a/bats_ai/core/views/temporal_annotations.py +++ b/bats_ai/core/views/temporal_annotations.py @@ -8,12 +8,22 @@ class TemporalAnnotationSchema(Schema): - recording: int # Foreign Key to index - owner_username: str start_time: int end_time: int type: str comments: str + owner_email: str = None + + @classmethod + def from_orm(cls, obj, owner_email=None, **kwargs): + return cls( + start_time=obj.start_time, + end_time=obj.end_time, + type=obj.type, + comments=obj.comments, + id=obj.id, + owner_email=owner_email, # Include owner_email in the schema + ) @router.get('/{id}') diff --git a/client/src/App.vue b/client/src/App.vue index c31c389..43b0aa9 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,18 +1,21 @@ + + + + diff --git a/client/src/components/UploadRecording.vue b/client/src/components/UploadRecording.vue index 8f71304..75bf020 100644 --- a/client/src/components/UploadRecording.vue +++ b/client/src/components/UploadRecording.vue @@ -291,7 +291,7 @@ export default defineComponent({ diff --git a/client/src/components/geoJS/LayerManager.vue b/client/src/components/geoJS/LayerManager.vue index 8a1ad46..1c78bf5 100644 --- a/client/src/components/geoJS/LayerManager.vue +++ b/client/src/components/geoJS/LayerManager.vue @@ -1,9 +1,10 @@ + + diff --git a/client/src/views/Recordings.vue b/client/src/views/Recordings.vue index 37bc374..944d9cb 100644 --- a/client/src/views/Recordings.vue +++ b/client/src/views/Recordings.vue @@ -119,7 +119,7 @@ export default defineComponent({ id: item.id, }; if (item.recording_location) { - const [ lat, lon ] = item.recording_location.coordinates; + const [ lon, lat ] = item.recording_location.coordinates; editingRecording.value['location'] = {lat, lon}; } uploadDialog.value = true; diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index 3a7b421..f6635b7 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -9,6 +9,7 @@ import { getSpectrogramCompressed, OtherUserAnnotations, getOtherUserAnnotations, + SpectrogramTemporalAnnotation, } from "../api/api"; import SpectrogramViewer from "../components/SpectrogramViewer.vue"; import { SpectroInfo } from "../components/geoJS/geoJSUtils"; @@ -43,6 +44,7 @@ export default defineComponent({ const image: Ref = ref(new Image()); const spectroInfo: Ref = ref(); const annotations: Ref = ref([]); + const temporalAnnotations: Ref = ref([]); const otherUserAnnotations: Ref = ref({}); const selectedId: Ref = ref(null); const selectedUsers: Ref = ref([]); @@ -84,6 +86,7 @@ export default defineComponent({ image.value.src = `data:image/png;base64,${response.data["base64_spectrogram"]}`; spectroInfo.value = response.data["spectroInfo"]; annotations.value = response.data["annotations"]?.sort((a, b) => a.start_time - b.start_time); + temporalAnnotations.value = response.data["temporal"]?.sort((a, b) => a.start_time - b.start_time); if (response.data.currentUser) { currentUser.value = response.data.currentUser; } @@ -175,6 +178,7 @@ export default defineComponent({ freqRef, // Other user selection otherUserAnnotations, + temporalAnnotations, otherUsers, selectedUsers, deleteChip, @@ -319,6 +323,7 @@ export default defineComponent({ :spectro-info="spectroInfo" :recording-id="id" :annotations="annotations" + :temporal-annotations="temporalAnnotations" :other-user-annotations="otherUserAnnotations" :selected-id="selectedId" :grid="gridEnabled" @@ -334,6 +339,7 @@ export default defineComponent({ :spectro-info="spectroInfo" :recording-id="id" :annotations="annotations" + :temporal-annotations="temporalAnnotations" :other-user-annotations="otherUserAnnotations" :selected-id="selectedId" :parent-geo-viewer-ref="parentGeoViewerRef" From 3a81aea28e8b9fe73a0f0c23d7b670b67d5ea703 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 7 Feb 2024 11:41:26 -0500 Subject: [PATCH 05/47] linting --- client/src/components/geoJS/LayerManager.vue | 5 +++-- client/src/components/geoJS/geoJSUtils.ts | 2 -- client/src/views/Login.vue | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/client/src/components/geoJS/LayerManager.vue b/client/src/components/geoJS/LayerManager.vue index 1c78bf5..0abe102 100644 --- a/client/src/components/geoJS/LayerManager.vue +++ b/client/src/components/geoJS/LayerManager.vue @@ -72,8 +72,9 @@ export default defineComponent({ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any const temporalEvent = (type: string, data: any) => { - console.log(`${type}`); - console.log(data); + if (type === "annotation-clicked") { + // click temporal annotation + } }; // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any diff --git a/client/src/components/geoJS/geoJSUtils.ts b/client/src/components/geoJS/geoJSUtils.ts index 5ebed6e..8e98d94 100644 --- a/client/src/components/geoJS/geoJSUtils.ts +++ b/client/src/components/geoJS/geoJSUtils.ts @@ -191,7 +191,6 @@ function spectroTemporalToGeoJSon( //scale pixels to time and frequency ranges if (spectroInfo.start_times === undefined || spectroInfo.end_times === undefined) { const widthScale = spectroInfo.width / (spectroInfo.end_time - spectroInfo.start_time); - const heightScale = spectroInfo.height / (spectroInfo.high_freq - spectroInfo.low_freq); // Now we remap our annotation to pixel coordinates const start_time = annotation.start_time * widthScale; const end_time = annotation.end_time * widthScale; @@ -232,7 +231,6 @@ function spectroTemporalToGeoJSon( for (let i = 0; i < foundIndex; i += 1) { pixelAdd += (end_times[i] - start_times[i]) * widthScale; } - const heightScale = spectroInfo.height / (spectroInfo.high_freq - spectroInfo.low_freq); // Now we remap our annotation to pixel coordinates const start_time = pixelAdd + (annotation.start_time - start_times[foundIndex]) * widthScale; const end_time = pixelAdd + (annotation.end_time - start_times[foundIndex]) * widthScale; diff --git a/client/src/views/Login.vue b/client/src/views/Login.vue index 0a10cb3..3de239a 100644 --- a/client/src/views/Login.vue +++ b/client/src/views/Login.vue @@ -1,6 +1,5 @@