From 7a85e983264bdcfbd636a05e9198a594719fe95a Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 31 Jan 2024 09:47:16 -0500 Subject: [PATCH] visualization of other user's annotations --- bats_ai/core/views/recording.py | 66 ++++++++- client/src/api/api.ts | 15 +++ client/src/components/SpectrogramViewer.vue | 97 ++++++++------ client/src/components/ThumbnailViewer.vue | 7 +- client/src/components/geoJS/LayerManager.vue | 125 +++++++++++------- .../components/geoJS/layers/rectangleLayer.ts | 58 +++++--- client/src/use/useState.ts | 72 ++++++---- client/src/views/Spectrogram.vue | 76 +++++++++-- client/yarn.lock | 11 +- 9 files changed, 370 insertions(+), 157 deletions(-) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 65e3c2f..2c9432a 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -44,7 +44,21 @@ class AnnotationSchema(Schema): high_freq: int species: list[SpeciesSchema] comments: str - id: int + id: int = None + 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, + low_freq=obj.low_freq, + high_freq=obj.high_freq, + species=[SpeciesSchema.from_orm(species) for species in obj.species.all()], + comments=obj.comments, + id=obj.id, + owner_email=owner_email, # Include owner_email in the schema + ) class UpdateAnnotationsSchema(Schema): @@ -169,12 +183,14 @@ def get_spectrogram(request: HttpRequest, id: int): ] spectro_data['otherUsers'] = other_users + spectro_data['currentUser'] = request.user.email annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) # Serialize the annotations using AnnotationSchema annotations_data = [ - AnnotationSchema.from_orm(annotation).dict() for annotation in annotations_qs + AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs ] spectro_data['annotations'] = annotations_data return spectro_data @@ -222,12 +238,14 @@ def get_spectrogram_compressed(request: HttpRequest, id: int): ] spectro_data['otherUsers'] = other_users + spectro_data['currentUser'] = request.user.email annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) # Serialize the annotations using AnnotationSchema annotations_data = [ - AnnotationSchema.from_orm(annotation).dict() for annotation in annotations_qs + AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs ] spectro_data['annotations'] = annotations_data return spectro_data @@ -245,7 +263,8 @@ def get_annotations(request: HttpRequest, id: int): # Serialize the annotations using AnnotationSchema annotations_data = [ - AnnotationSchema.from_orm(annotation).dict() for annotation in annotations_qs + AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs ] return annotations_data @@ -258,8 +277,45 @@ def get_annotations(request: HttpRequest, id: int): return {'error': 'Recording not found'} +@router.get('/{id}/annotations/other_users') +def get_other_user_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 other users + annotations_qs = Annotations.objects.filter(recording=recording).exclude( + owner=request.user + ) + + # Create a dictionary to store annotations for each user + annotations_by_user = {} + + # Serialize the annotations using AnnotationSchema + for annotation in annotations_qs: + user_email = annotation.owner.email + + # If user_email is not already a key in the dictionary, initialize it with an empty list + annotations_by_user.setdefault(user_email, []) + + # Append the annotation to the list for the corresponding user_email + annotations_by_user[user_email].append( + AnnotationSchema.from_orm(annotation, owner_email=user_email).dict() + ) + + return annotations_by_user + 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.get('/{id}/annotations/user/{userId}') -def get__other_user_annotations(request: HttpRequest, id: int, userId: int): +def get_user_annotations(request: HttpRequest, id: int, userId: int): try: recording = Recording.objects.get(pk=id) diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 9ad263f..1c0e5fe 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -57,6 +57,7 @@ export interface SpectrogramAnnotation { editing?: boolean; species?: Species[]; comments?: string; + owner_email: string; } export interface UpdateSpectrogramAnnotation { @@ -70,15 +71,24 @@ export interface UpdateSpectrogramAnnotation { comments?: string; } +export interface UserInfo { + username: string; + email: string; + id: number; +} export interface Spectrogram { 'base64_spectrogram': string; url?: string; filename?: string; annotations?: SpectrogramAnnotation[]; spectroInfo?: SpectroInfo; + currentUser?: string; + otherUsers?: UserInfo[]; } +export type OtherUserAnnotations = Record; + interface PaginatedNinjaResponse { count: number, items: T[], @@ -167,12 +177,17 @@ async function deleteAnnotation(recordingId: string, annotationId: number) { return axiosInstance.delete(`/recording/${recordingId}/annotations/${annotationId}`); } +async function getOtherUserAnnotations(recordingId: string) { + return axiosInstance.get(`/recording/${recordingId}/annotations/other_users`); +} + export { uploadRecordingFile, getRecordings, patchRecording, getSpectrogram, getSpectrogramCompressed, + getOtherUserAnnotations, getSpecies, getAnnotations, patchAnnotation, diff --git a/client/src/components/SpectrogramViewer.vue b/client/src/components/SpectrogramViewer.vue index 562852e..57e0e76 100644 --- a/client/src/components/SpectrogramViewer.vue +++ b/client/src/components/SpectrogramViewer.vue @@ -1,7 +1,12 @@ -./layers/timeLalyer \ No newline at end of file +./layers/timeLalyer diff --git a/client/src/components/geoJS/layers/rectangleLayer.ts b/client/src/components/geoJS/layers/rectangleLayer.ts index b6a2ff7..86734f3 100644 --- a/client/src/components/geoJS/layers/rectangleLayer.ts +++ b/client/src/components/geoJS/layers/rectangleLayer.ts @@ -9,6 +9,8 @@ interface RectGeoJSData { selected: boolean; editing?: boolean; polygon: GeoJSON.Polygon; + color?: string; + owned: boolean; // if the annotation is user owned } export default class RectangleLayer { @@ -60,11 +62,15 @@ export default class RectangleLayer { * */ if (e.mouse.buttonsDown.left) { if (!e.data.editing || (e.data.editing && !e.data.selected)) { - this.event("annotation-clicked", { id: e.data.id, edit: false }); + if (e.data.owned) { + this.event("annotation-clicked", { id: e.data.id, edit: false }); + } } } else if (e.mouse.buttonsDown.right) { if (!e.data.editing || (e.data.editing && !e.data.selected)) { - this.event("annotation-right-clicked", { id: e.data.id, edit: true }); + if (e.data.owned) { + this.event("annotation-right-clicked", { id: e.data.id, edit: true }); + } } } }); @@ -111,26 +117,35 @@ export default class RectangleLayer { this.drawingOther = val; } - formatData(annotationData: SpectrogramAnnotation[], selectedIndex: number | null) { + formatData( + annotationData: SpectrogramAnnotation[], + selectedIndex: number | null, + currentUser: string, + colorScale?: d3.ScaleOrdinal + ) { const arr: RectGeoJSData[] = []; annotationData.forEach((annotation: SpectrogramAnnotation) => { - const polygon = spectroToGeoJSon(annotation, this.spectroInfo); - const [xmin, ymin] = polygon.coordinates[0][0]; - const [xmax, ymax] = polygon.coordinates[0][2]; - // For the compressed view we need to filter out default or NaN numbers - if (Number.isNaN(xmax) || Number.isNaN(xmin) || Number.isNaN(ymax) || Number.isNaN(ymin)) { - return; - } - if (xmax === -1 && ymin === -1 && ymax === -1 && xmin === -1) { - return; - } - const newAnnotation: RectGeoJSData = { - id: annotation.id, - selected: annotation.id === selectedIndex, - editing: annotation.editing, - polygon, - }; - arr.push(newAnnotation); + const polygon = spectroToGeoJSon(annotation, this.spectroInfo); + const [xmin, ymin] = polygon.coordinates[0][0]; + const [xmax, ymax] = polygon.coordinates[0][2]; + // For the compressed view we need to filter out default or NaN numbers + if (Number.isNaN(xmax) || Number.isNaN(xmin) || Number.isNaN(ymax) || Number.isNaN(ymin)) { + return; + } + if (xmax === -1 && ymin === -1 && ymax === -1 && xmin === -1) { + return; + } + const newAnnotation: RectGeoJSData = { + id: annotation.id, + selected: annotation.id === selectedIndex, + editing: annotation.editing, + polygon, + owned: annotation.owner_email === currentUser, + }; + if (colorScale && annotation.owner_email !== currentUser) { + newAnnotation.color = colorScale(annotation.owner_email); + } + arr.push(newAnnotation); }); this.formattedData = arr; } @@ -166,6 +181,9 @@ export default class RectangleLayer { if (data.selected) { return "cyan"; } + if (data.color) { + return data.color; + } return "red"; }, }; diff --git a/client/src/use/useState.ts b/client/src/use/useState.ts index 698a91c..888ba4e 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -1,32 +1,50 @@ -import { ref, Ref } from 'vue'; -import { cloneDeep } from 'lodash'; +import { ref, Ref } from "vue"; +import { cloneDeep } from "lodash"; +import * as d3 from "d3"; -const annotationState: Ref = ref(''); -type LayersVis = 'time' | 'freq' | 'species' |'grid'; +const annotationState: Ref = ref(""); +type LayersVis = "time" | "freq" | "species" | "grid"; const layerVisibility: Ref = ref([]); +const colorScale: Ref | undefined> = ref(); +const selectedUsers: Ref = ref([]); +const currentUser: Ref = ref(''); -type AnnotationState = '' | 'editing' | 'creating'; +type AnnotationState = "" | "editing" | "creating"; export default function useState() { - const setAnnotationState = (state: AnnotationState) => { - annotationState.value = state; - }; - function toggleLayerVisibility(value: LayersVis) { - const index = layerVisibility.value.indexOf(value); - const clone = cloneDeep(layerVisibility.value); - if (index === -1) { - // If the value is not present, add it - clone.push(value); - } else { - // If the value is present, remove it - clone.splice(index, 1); - } - layerVisibility.value = clone; - } - return { - annotationState, - setAnnotationState, - toggleLayerVisibility, - layerVisibility, - }; -} + const setAnnotationState = (state: AnnotationState) => { + annotationState.value = state; + }; + function toggleLayerVisibility(value: LayersVis) { + const index = layerVisibility.value.indexOf(value); + const clone = cloneDeep(layerVisibility.value); + if (index === -1) { + // If the value is not present, add it + clone.push(value); + } else { + // If the value is present, remove it + clone.splice(index, 1); + } + layerVisibility.value = clone; + } + const setSelectedUsers = (newUsers: string[]) => { + selectedUsers.value = newUsers; + }; + + function createColorScale(userEmails: string[]) { + colorScale.value = d3.scaleOrdinal() + .domain(userEmails) + .range(d3.schemeCategory10.filter(color => color !== 'red' && color !== 'cyan' && color !== 'yellow')); + } + return { + annotationState, + setAnnotationState, + toggleLayerVisibility, + layerVisibility, + createColorScale, + colorScale, + setSelectedUsers, + selectedUsers, + currentUser, + }; +} diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index bc72ccc..64afe5f 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -1,5 +1,5 @@