Skip to content

Commit 2aa32ef

Browse files
authored
Add File Level Annotations (#111)
* adding recording annotation model * Add recording annnotation and migrations * client-side updates * adding recording annotation interface * basic adding/editing user based file level annotations * adding file annotation to main recording view * linting client * change apt-fast to apt-get * revert apt-fast * convert to sudo apt-get * pin to ubuntu 22.04
1 parent 6b974cf commit 2aa32ef

21 files changed

+1200
-5177
lines changed

.github/workflows/ci.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ env:
1515
jobs:
1616
lint-python:
1717
name: Lint Python
18-
runs-on: ubuntu-latest
18+
runs-on: ubuntu-22.04
1919
steps:
2020
- name: Checkout repository
2121
uses: actions/checkout@v4
@@ -55,7 +55,7 @@ jobs:
5555
working-directory: client
5656
test-django:
5757
name: Test Django [${{ matrix.tox-env }}]
58-
runs-on: ubuntu-latest
58+
runs-on: ubuntu-22.04
5959
strategy:
6060
fail-fast: false
6161
matrix:
@@ -92,7 +92,7 @@ jobs:
9292
- name: Update Package References
9393
run: sudo apt-get update
9494
- name: Install system dependencies
95-
run: apt-fast install --no-install-recommends --yes
95+
run: sudo apt-get install --no-install-recommends --yes
9696
libgdal30
9797
libproj22
9898
python3-cachecontrol
@@ -110,7 +110,7 @@ jobs:
110110
working-directory: bats_ai
111111
test-vue:
112112
name: Test [vue]
113-
runs-on: ubuntu-latest
113+
runs-on: ubuntu-22.04
114114
steps:
115115
- name: Checkout repository
116116
uses: actions/checkout@v4

bats_ai/api.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
from ninja import NinjaAPI
44
from oauth2_provider.models import AccessToken
55

6-
from bats_ai.core.views import GRTSCellsRouter, GuanoMetadataRouter, RecordingRouter, SpeciesRouter
6+
from bats_ai.core.views import (
7+
GRTSCellsRouter,
8+
GuanoMetadataRouter,
9+
RecordingAnnotationRouter,
10+
RecordingRouter,
11+
SpeciesRouter,
12+
)
713

814
logger = logging.getLogger(__name__)
915

@@ -28,3 +34,4 @@ def global_auth(request):
2834
api.add_router('/species/', SpeciesRouter)
2935
api.add_router('/grts/', GRTSCellsRouter)
3036
api.add_router('/guano/', GuanoMetadataRouter)
37+
api.add_router('/recording-annotation/', RecordingAnnotationRouter)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Generated by Django 4.1.13 on 2024-12-09 15:26
2+
3+
from django.conf import settings
4+
import django.core.validators
5+
from django.db import migrations, models
6+
import django.db.models.deletion
7+
import django.utils.timezone
8+
import django_extensions.db.fields
9+
10+
11+
class Migration(migrations.Migration):
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
('core', '0010_compressedspectrogram'),
15+
]
16+
17+
operations = [
18+
migrations.AlterModelOptions(
19+
name='annotations',
20+
options={'get_latest_by': 'modified'},
21+
),
22+
migrations.AddField(
23+
model_name='annotations',
24+
name='confidence',
25+
field=models.FloatField(
26+
default=1.0,
27+
help_text='A confidence value between 0 and 1.0, default is 1.0.',
28+
validators=[
29+
django.core.validators.MinValueValidator(0.0),
30+
django.core.validators.MaxValueValidator(1.0),
31+
],
32+
),
33+
),
34+
migrations.AddField(
35+
model_name='annotations',
36+
name='created',
37+
field=django_extensions.db.fields.CreationDateTimeField(
38+
auto_now_add=True, default=django.utils.timezone.now, verbose_name='created'
39+
),
40+
preserve_default=False,
41+
),
42+
migrations.AddField(
43+
model_name='annotations',
44+
name='model',
45+
field=models.TextField(blank=True, null=True),
46+
),
47+
migrations.AddField(
48+
model_name='annotations',
49+
name='modified',
50+
field=django_extensions.db.fields.ModificationDateTimeField(
51+
auto_now=True, verbose_name='modified'
52+
),
53+
),
54+
migrations.CreateModel(
55+
name='RecordingAnnotation',
56+
fields=[
57+
(
58+
'id',
59+
models.BigAutoField(
60+
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
61+
),
62+
),
63+
(
64+
'created',
65+
django_extensions.db.fields.CreationDateTimeField(
66+
auto_now_add=True, verbose_name='created'
67+
),
68+
),
69+
(
70+
'modified',
71+
django_extensions.db.fields.ModificationDateTimeField(
72+
auto_now=True, verbose_name='modified'
73+
),
74+
),
75+
('comments', models.TextField(blank=True, null=True)),
76+
('model', models.TextField(blank=True, null=True)),
77+
(
78+
'confidence',
79+
models.FloatField(
80+
default=1.0,
81+
help_text='A confidence value between 0 and 1.0, default is 1.0.',
82+
validators=[
83+
django.core.validators.MinValueValidator(0.0),
84+
django.core.validators.MaxValueValidator(1.0),
85+
],
86+
),
87+
),
88+
(
89+
'owner',
90+
models.ForeignKey(
91+
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
92+
),
93+
),
94+
(
95+
'recording',
96+
models.ForeignKey(
97+
on_delete=django.db.models.deletion.CASCADE, to='core.recording'
98+
),
99+
),
100+
('species', models.ManyToManyField(to='core.species')),
101+
],
102+
options={
103+
'get_latest_by': 'modified',
104+
'abstract': False,
105+
},
106+
),
107+
]

bats_ai/core/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .grts_cells import GRTSCells
44
from .image import Image
55
from .recording import Recording, colormap
6+
from .recording_annotation import RecordingAnnotation
67
from .recording_annotation_status import RecordingAnnotationStatus
78
from .species import Species
89
from .spectrogram import Spectrogram
@@ -19,4 +20,5 @@
1920
'GRTSCells',
2021
'colormap',
2122
'CompressedSpectrogram',
23+
'RecordingAnnotation',
2224
]

bats_ai/core/models/annotations.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from django.contrib.auth.models import User
2+
from django.core.validators import MaxValueValidator, MinValueValidator
23
from django.db import models
4+
from django_extensions.db.models import TimeStampedModel
35

46
from .recording import Recording
57
from .species import Species
68

79

8-
class Annotations(models.Model):
10+
class Annotations(TimeStampedModel, models.Model):
911
recording = models.ForeignKey(Recording, on_delete=models.CASCADE)
1012
owner = models.ForeignKey(User, on_delete=models.CASCADE)
1113
start_time = models.IntegerField(blank=True, null=True)
@@ -15,3 +17,12 @@ class Annotations(models.Model):
1517
type = models.TextField(blank=True, null=True)
1618
species = models.ManyToManyField(Species)
1719
comments = models.TextField(blank=True, null=True)
20+
model = models.TextField(blank=True, null=True) # AI Model information if inference used
21+
confidence = models.FloatField(
22+
default=1.0,
23+
validators=[
24+
MinValueValidator(0.0),
25+
MaxValueValidator(1.0),
26+
],
27+
help_text='A confidence value between 0 and 1.0, default is 1.0.',
28+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django.contrib.auth.models import User
2+
from django.core.validators import MaxValueValidator, MinValueValidator
3+
from django.db import models
4+
from django_extensions.db.models import TimeStampedModel
5+
6+
from .recording import Recording
7+
from .species import Species
8+
9+
10+
class RecordingAnnotation(TimeStampedModel, models.Model):
11+
recording = models.ForeignKey(Recording, on_delete=models.CASCADE)
12+
owner = models.ForeignKey(User, on_delete=models.CASCADE)
13+
species = models.ManyToManyField(Species)
14+
comments = models.TextField(blank=True, null=True)
15+
model = models.TextField(blank=True, null=True) # AI Model information if inference used
16+
confidence = models.FloatField(
17+
default=1.0,
18+
validators=[
19+
MinValueValidator(0.0),
20+
MaxValueValidator(1.0),
21+
],
22+
help_text='A confidence value between 0 and 1.0, default is 1.0.',
23+
)

bats_ai/core/views/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .grts_cells import router as GRTSCellsRouter
33
from .guanometadata import router as GuanoMetadataRouter
44
from .recording import router as RecordingRouter
5+
from .recording_annotation import router as RecordingAnnotationRouter
56
from .species import router as SpeciesRouter
67
from .temporal_annotations import router as TemporalAnnotationRouter
78

@@ -12,4 +13,5 @@
1213
'TemporalAnnotationRouter',
1314
'GRTSCellsRouter',
1415
'GuanoMetadataRouter',
16+
'RecordingAnnotationRouter',
1517
]

bats_ai/core/views/recording.py

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Annotations,
1616
CompressedSpectrogram,
1717
Recording,
18+
RecordingAnnotation,
1819
Species,
1920
TemporalAnnotations,
2021
colormap,
@@ -61,6 +62,26 @@ class RecordingUploadSchema(Schema):
6162
unusual_occurrences: str = None
6263

6364

65+
class RecordingAnnotationSchema(Schema):
66+
species: list[SpeciesSchema] | None
67+
comments: str | None = None
68+
model: str | None = None
69+
owner: str
70+
confidence: float
71+
id: int | None = None
72+
73+
@classmethod
74+
def from_orm(cls, obj: RecordingAnnotation, **kwargs):
75+
return cls(
76+
species=[SpeciesSchema.from_orm(species) for species in obj.species.all()],
77+
owner=obj.owner.username,
78+
confidence=obj.confidence,
79+
comments=obj.comments,
80+
model=obj.model,
81+
id=obj.pk,
82+
)
83+
84+
6485
class AnnotationSchema(Schema):
6586
start_time: int
6687
end_time: int
@@ -73,7 +94,7 @@ class AnnotationSchema(Schema):
7394
owner_email: str = None
7495

7596
@classmethod
76-
def from_orm(cls, obj, owner_email=None, **kwargs):
97+
def from_orm(cls, obj: Annotations, owner_email=None, **kwargs):
7798
return cls(
7899
start_time=obj.start_time,
79100
end_time=obj.end_time,
@@ -215,6 +236,11 @@ def get_recordings(request: HttpRequest, public: bool | None = None):
215236
# TODO with larger dataset it may be better to do this in a queryset instead of python
216237
for recording in recordings:
217238
user = User.objects.get(id=recording['owner_id'])
239+
fileAnnotations = RecordingAnnotation.objects.filter(recording=recording['id'])
240+
recording['fileAnnotations'] = [
241+
RecordingAnnotationSchema.from_orm(fileAnnotation).dict()
242+
for fileAnnotation in fileAnnotations
243+
]
218244
recording['owner_username'] = user.username
219245
recording['audio_file_presigned_url'] = default_storage.url(recording['audio_file'])
220246
recording['hasSpectrogram'] = Recording.objects.get(id=recording['id']).has_spectrogram
@@ -227,9 +253,12 @@ def get_recordings(request: HttpRequest, public: bool | None = None):
227253
.count()
228254
)
229255
recording['userAnnotations'] = unique_users_with_annotations
230-
user_has_annotations = Annotations.objects.filter(
231-
recording_id=recording['id'], owner=request.user
232-
).exists()
256+
user_has_annotations = (
257+
Annotations.objects.filter(recording_id=recording['id'], owner=request.user).exists()
258+
or RecordingAnnotation.objects.filter(
259+
recording_id=recording['id'], owner=request.user
260+
).exists()
261+
)
233262
recording['userMadeAnnotations'] = user_has_annotations
234263

235264
return list(recordings)
@@ -249,17 +278,38 @@ def get_recording(request: HttpRequest, id: int):
249278
recording['hasSpectrogram'] = Recording.objects.get(id=recording['id']).has_spectrogram
250279
if recording['recording_location']:
251280
recording['recording_location'] = json.loads(recording['recording_location'].json)
252-
unique_users_with_annotations = (
281+
annotation_owners = (
253282
Annotations.objects.filter(recording_id=recording['id'])
254-
.values('owner')
283+
.values_list('owner', flat=True)
255284
.distinct()
256-
.count()
285+
)
286+
recording_annotation_owners = (
287+
RecordingAnnotation.objects.filter(recording_id=recording['id'])
288+
.values_list('owner', flat=True)
289+
.distinct()
290+
)
291+
292+
# Combine the sets of owners and count unique entries
293+
unique_users_with_annotations = len(
294+
set(annotation_owners).union(set(recording_annotation_owners))
257295
)
258296
recording['userAnnotations'] = unique_users_with_annotations
259-
user_has_annotations = Annotations.objects.filter(
260-
recording_id=recording['id'], owner=request.user
261-
).exists()
297+
user_has_annotations = (
298+
Annotations.objects.filter(
299+
recording_id=recording['id'], owner=request.user
300+
).exists()
301+
or RecordingAnnotation.objects.filter(
302+
recording_id=recording['id'], owner=request.user
303+
).exists()
304+
)
262305
recording['userMadeAnnotations'] = user_has_annotations
306+
fileAnnotations = RecordingAnnotation.objects.filter(recording=id).order_by(
307+
'confidence'
308+
)
309+
recording['fileAnnotations'] = [
310+
RecordingAnnotationSchema.from_orm(fileAnnotation).dict()
311+
for fileAnnotation in fileAnnotations
312+
]
263313

264314
return recording
265315
else:
@@ -268,6 +318,18 @@ def get_recording(request: HttpRequest, id: int):
268318
return {'error': 'Recording not found'}
269319

270320

321+
@router.get('/{recording_id}/recording-annotations')
322+
def get_recording_annotations(request: HttpRequest, recording_id: int):
323+
fileAnnotations = RecordingAnnotation.objects.filter(recording=recording_id).order_by(
324+
'confidence'
325+
)
326+
output = [
327+
RecordingAnnotationSchema.from_orm(fileAnnotation).dict()
328+
for fileAnnotation in fileAnnotations
329+
]
330+
return output
331+
332+
271333
@router.get('/{id}/spectrogram')
272334
def get_spectrogram(request: HttpRequest, id: int):
273335
try:

0 commit comments

Comments
 (0)