Skip to content

Commit fc26508

Browse files
committed
initial exporting annotations task
1 parent 15f6586 commit fc26508

5 files changed

Lines changed: 150 additions & 3 deletions

File tree

bats_ai/core/tasks/export_task.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,22 @@
44
from datetime import timedelta
55
from io import BytesIO, StringIO
66
import json
7+
from urllib.parse import urljoin
78
import zipfile
89

10+
from django.conf import settings
911
from django.contrib.auth.models import User
1012
from django.core.files import File
13+
from django.core.files.storage import default_storage
14+
from django.db.models import Prefetch
1115
from django.utils.timezone import now
1216

1317
from bats_ai.celery import app
1418
from bats_ai.core.models import (
1519
Annotations,
1620
ExportedAnnotationFile,
1721
RecordingAnnotation,
22+
RecordingAnnotationSpecies,
1823
RecordingTag,
1924
SequenceAnnotations,
2025
)
@@ -179,6 +184,90 @@ def export_tag_annotation_summary_task(self, export_id: int):
179184
raise
180185

181186

187+
@app.task(bind=True)
188+
def export_recording_annotation_hierarchy_task(self, export_id: int):
189+
export_record = ExportedAnnotationFile.objects.get(pk=export_id)
190+
try:
191+
recordings_payload = _build_recording_annotations_payload()
192+
193+
buffer = BytesIO()
194+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
195+
zipf.writestr(
196+
"recording_annotations.json",
197+
json.dumps({"recordings": recordings_payload}, indent=2),
198+
)
199+
200+
buffer.seek(0)
201+
filename = f"recording-annotations-{export_id}.zip"
202+
export_record.file.save(filename, File(buffer), save=False)
203+
export_record.download_url = export_record.file.url
204+
export_record.status = "complete"
205+
export_record.expires_at = now() + timedelta(hours=24)
206+
export_record.save()
207+
except Exception:
208+
export_record.status = "failed"
209+
export_record.save()
210+
raise
211+
212+
213+
def _build_recording_annotations_payload():
214+
species_links_prefetch = Prefetch(
215+
"recordingannotationspecies_set",
216+
queryset=RecordingAnnotationSpecies.objects.select_related("species").order_by(
217+
"order"
218+
),
219+
to_attr="ordered_species_links",
220+
)
221+
annotations = (
222+
RecordingAnnotation.objects.select_related("recording", "owner")
223+
.prefetch_related(species_links_prefetch)
224+
.order_by("recording_id", "id")
225+
)
226+
227+
recordings_by_id = {}
228+
for annotation in annotations:
229+
recording = annotation.recording
230+
recording_entry = recordings_by_id.get(recording.id)
231+
if recording_entry is None:
232+
recording_entry = {
233+
"recording_id": recording.id,
234+
"filename": recording.name,
235+
"submitted_annotations": 0,
236+
"unsubmitted_annotations": 0,
237+
"spectrogram_url": urljoin(
238+
settings.BATAI_WEB_URL, f"/recording/{recording.id}/spectrogram"
239+
),
240+
"wav_download_url": (
241+
default_storage.url(recording.audio_file.name) if recording.audio_file else None
242+
),
243+
"annotations": [],
244+
}
245+
recordings_by_id[recording.id] = recording_entry
246+
247+
if annotation.submitted:
248+
recording_entry["submitted_annotations"] += 1
249+
else:
250+
recording_entry["unsubmitted_annotations"] += 1
251+
252+
species_codes = [
253+
species_link.species.species_code
254+
for species_link in annotation.ordered_species_links
255+
]
256+
recording_entry["annotations"].append(
257+
{
258+
"annotation_id": annotation.id,
259+
"user": annotation.owner.username,
260+
"species_codes": species_codes,
261+
"confidence": annotation.confidence,
262+
"additional_data": annotation.additional_data,
263+
"comment": annotation.comments,
264+
"submitted": annotation.submitted,
265+
}
266+
)
267+
268+
return list(recordings_by_id.values())
269+
270+
182271
def _collect_tag_summary_rows():
183272
tag_rows = []
184273
tag_user_rows = []

bats_ai/core/views/configuration.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
from ninja.pagination import RouterPaginated
1010

1111
from bats_ai.core.models import Configuration, ExportedAnnotationFile
12-
from bats_ai.core.tasks.export_task import export_tag_annotation_summary_task
12+
from bats_ai.core.tasks.export_task import (
13+
export_recording_annotation_hierarchy_task,
14+
export_tag_annotation_summary_task,
15+
)
1316

1417
logger = logging.getLogger(__name__)
1518

@@ -99,3 +102,20 @@ def export_tag_summary(request):
99102
)
100103
export_tag_annotation_summary_task.delay(export.id)
101104
return {"exportId": export.id}
105+
106+
107+
@router.post(
108+
"/export-recording-annotations",
109+
response=ExportTagSummaryResponse,
110+
)
111+
def export_recording_annotations(request):
112+
if not request.user.is_authenticated or not request.user.is_superuser:
113+
return JsonResponse({"error": "Permission denied"}, status=403)
114+
115+
export = ExportedAnnotationFile.objects.create(
116+
filters_applied={"type": "recording_annotation_hierarchy"},
117+
status="pending",
118+
expires_at=now() + timedelta(hours=24),
119+
)
120+
export_recording_annotation_hierarchy_task.delay(export.id)
121+
return {"exportId": export.id}

bats_ai/core/views/export_annotation.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,22 @@ def _is_tag_annotation_summary_export(export: ExportedAnnotationFile) -> bool:
2323
)
2424

2525

26+
def _is_recording_annotation_hierarchy_export(
27+
export: ExportedAnnotationFile,
28+
) -> bool:
29+
filters_applied = export.filters_applied
30+
return (
31+
isinstance(filters_applied, dict)
32+
and filters_applied.get("type") == "recording_annotation_hierarchy"
33+
)
34+
35+
2636
def _can_access_export(request, export: ExportedAnnotationFile) -> bool:
2737
# Tag annotation summary exports include user-level aggregate stats,
2838
# so only admins can access them.
29-
if _is_tag_annotation_summary_export(export):
39+
if _is_tag_annotation_summary_export(export) or _is_recording_annotation_hierarchy_export(
40+
export
41+
):
3042
return request.user.is_authenticated and request.user.is_superuser
3143
return True
3244

client/src/api/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,13 @@ async function exportTagSummary(): Promise<ExportTagSummaryResponse> {
787787
return result.data;
788788
}
789789

790+
async function exportRecordingAnnotations(): Promise<ExportTagSummaryResponse> {
791+
const result = await axiosInstance.post<ExportTagSummaryResponse>(
792+
"/configuration/export-recording-annotations",
793+
);
794+
return result.data;
795+
}
796+
790797
export interface VettingDetails {
791798
id: number;
792799
user_id: number;
@@ -911,6 +918,7 @@ export {
911918
getFileAnnotationDetails,
912919
getExportStatus,
913920
exportTagSummary,
921+
exportRecordingAnnotations,
914922
getRecordingTags,
915923
getUnsubmittedNeighbors,
916924
getComputedPulseContour,

client/src/views/Admin.vue

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
<script lang="ts">
22
import { reactive, defineComponent, watch, ref, type Ref } from "vue";
33
import useState from "@use/useState";
4-
import { exportTagSummary, patchConfiguration } from "../api/api";
4+
import {
5+
exportRecordingAnnotations,
6+
exportTagSummary,
7+
patchConfiguration,
8+
} from "../api/api";
59
import NABatAdmin from "./NABat/NABatAdmin.vue";
610
import ColorPickerMenu from "@components/ColorPickerMenu.vue";
711
import ColorSchemeSelect from "@components/ColorSchemeSelect.vue";
@@ -86,6 +90,11 @@ export default defineComponent({
8690
exportId.value = result.exportId;
8791
};
8892
93+
const runRecordingAnnotationsExport = async () => {
94+
const result = await exportRecordingAnnotations();
95+
exportId.value = result.exportId;
96+
};
97+
8998
const clearExport = () => {
9099
exportId.value = null;
91100
};
@@ -99,6 +108,7 @@ export default defineComponent({
99108
defaultColorScheme,
100109
exportId,
101110
runTagSummaryExport,
111+
runRecordingAnnotationsExport,
102112
clearExport,
103113
};
104114
},
@@ -208,6 +218,14 @@ export default defineComponent({
208218
>
209219
Export Tag Annotation Summary
210220
</v-btn>
221+
<v-btn
222+
color="secondary"
223+
variant="outlined"
224+
class="mx-2"
225+
@click="runRecordingAnnotationsExport"
226+
>
227+
Export Recording Annotations
228+
</v-btn>
211229
</v-row>
212230
</v-card-actions>
213231
<Exporting

0 commit comments

Comments
 (0)