diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index faff64a..f77aae0 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -48,6 +48,16 @@ class AnnotationSchema(Schema): id: int +class UpdateAnnotationsSchema(Schema): + start_time: int | None + end_time: int | None + low_freq: int | None + high_freq: int | None + species: list[SpeciesSchema] | None + comments: str | None + id: int | None + + def get_user(request: HttpRequest): auth_header = request.headers.get('Authorization', None) if auth_header is not None: @@ -179,7 +189,7 @@ def patch_annotation( request, recording_id: int, id: int, - annotation: AnnotationSchema, + annotation: UpdateAnnotationsSchema, species_ids: list[int], ): user_id = get_user(request) @@ -193,11 +203,16 @@ def patch_annotation( return {'error': 'Annotation not found'} # Update annotation details - annotation_instance.start_time = annotation.start_time - annotation_instance.end_time = annotation.end_time - annotation_instance.low_freq = annotation.low_freq - annotation_instance.high_freq = annotation.high_freq - annotation_instance.comments = annotation.comments + if annotation.start_time: + annotation_instance.start_time = annotation.start_time + if annotation.end_time: + annotation_instance.end_time = annotation.end_time + if annotation.low_freq: + annotation_instance.low_freq = annotation.low_freq + if annotation.high_freq: + annotation_instance.high_freq = annotation.high_freq + if annotation.comments: + annotation_instance.comments = annotation.comments annotation_instance.save() # Clear existing species associations diff --git a/client/package-lock.json b/client/package-lock.json index 37a77c2..d05e0be 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -16,6 +16,7 @@ "d3": "^7.8.5", "django-s3-file-field": "^1.0.0", "geojs": "^1.11.1", + "lodash": "^4.17.21", "vue": "^3.3.1", "vue-router": "^4.0.12", "vuetify": "^3.3.12" @@ -23,6 +24,7 @@ "devDependencies": { "@types/geojson": "^7946.0.13", "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.202", "@vitejs/plugin-vue": "^2.2.0", "@vue/eslint-config-typescript": "^10.0.0", "@vuetify/vite-plugin": "^1.0.0-alpha.10", @@ -3065,6 +3067,12 @@ "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "devOptional": true }, + "node_modules/@types/lodash": { + "version": "4.14.202", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", + "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", + "dev": true + }, "node_modules/@types/node": { "version": "17.0.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", diff --git a/client/package.json b/client/package.json index a6275bd..652ce37 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "d3": "^7.8.5", "django-s3-file-field": "^1.0.0", "geojs": "^1.11.1", + "lodash": "^4.17.21", "vue": "^3.3.1", "vue-router": "^4.0.12", "vuetify": "^3.3.12" @@ -28,6 +29,7 @@ "devDependencies": { "@types/geojson": "^7946.0.13", "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.202", "@vitejs/plugin-vue": "^2.2.0", "@vue/eslint-config-typescript": "^10.0.0", "@vuetify/vite-plugin": "^1.0.0-alpha.10", diff --git a/client/src/api/api.ts b/client/src/api/api.ts index cbff784..19662e3 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -42,6 +42,7 @@ export interface Species { genus: string; common_name: string; species_code_6?: string; + id: number; } export interface SpectrogramAnnotation { @@ -50,8 +51,21 @@ export interface SpectrogramAnnotation { low_freq: number; high_freq: number; id: number; + editing?: boolean; + species?: Species[]; + comments?: string; } +export interface UpdateSpectrogramAnnotation { + start_time?: number; + end_time?: number; + low_freq?: number; + high_freq?: number; + id?: number; + editing?: boolean; + species?: Species[]; + comments?: string; +} export interface Spectrogram { 'base64_spectrogram': string; @@ -106,9 +120,34 @@ async function getSpectrogram(id: string) { return axiosInstance.get(`/recording/${id}/spectrogram`); } +async function getAnnotations(recordingId: string) { + return axiosInstance.get(`/recording/${recordingId}/annotations`); + +} + +async function getSpecies() { + return axiosInstance.get('/species/'); +} + +async function patchAnnotation(recordingId: string, annotationId: number, annotation: UpdateSpectrogramAnnotation, speciesList: number[] = []) { + return axiosInstance.patch(`/recording/${recordingId}/annotations/${annotationId}`, { annotation, species_ids: speciesList}); +} + +async function putAnnotation(recordingId: string, annotation: UpdateSpectrogramAnnotation, speciesList: number[] = []) { + return axiosInstance.put<{message: string, id: number}>(`/recording/${recordingId}/annotations`, { annotation, species_ids: speciesList}); +} + +async function deleteAnnotation(recordingId: string, annotationId: number) { + return axiosInstance.delete(`/recording/${recordingId}/annotations/${annotationId}`); +} export { uploadRecordingFile, getRecordings, getSpectrogram, + getSpecies, + getAnnotations, + patchAnnotation, + putAnnotation, + deleteAnnotation }; \ No newline at end of file diff --git a/client/src/components/AnnotationEditor.vue b/client/src/components/AnnotationEditor.vue new file mode 100644 index 0000000..1245061 --- /dev/null +++ b/client/src/components/AnnotationEditor.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/client/src/components/AnnotationList.vue b/client/src/components/AnnotationList.vue new file mode 100644 index 0000000..47dbdf8 --- /dev/null +++ b/client/src/components/AnnotationList.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/client/src/components/SpectrogramViewer.vue b/client/src/components/SpectrogramViewer.vue index e07a896..807046b 100644 --- a/client/src/components/SpectrogramViewer.vue +++ b/client/src/components/SpectrogramViewer.vue @@ -1,7 +1,7 @@ \ No newline at end of file + diff --git a/client/src/components/geoJS/geoJSUtils.ts b/client/src/components/geoJS/geoJSUtils.ts index 3af9073..048fa70 100644 --- a/client/src/components/geoJS/geoJSUtils.ts +++ b/client/src/components/geoJS/geoJSUtils.ts @@ -188,10 +188,10 @@ function geojsonToSpectro(geojson: GeoJSON.Feature, spectroInfo const coords = geojson.geometry.coordinates[0]; const widthScale = spectroInfo.width / (spectroInfo.end_time - spectroInfo.start_time); const heightScale = spectroInfo.height / (spectroInfo.high_freq - spectroInfo.low_freq); - const start_time = coords[1][0] / widthScale; - const end_time = coords[3][0] / widthScale; - const low_freq = coords[1][1] / heightScale; - const high_freq = coords[3][1] / heightScale; + const start_time = Math.round(coords[1][0] / widthScale); + const end_time = Math.round(coords[3][0] / widthScale); + const low_freq = Math.round(spectroInfo.high_freq - (coords[1][1]) / heightScale); + const high_freq = Math.round(spectroInfo.high_freq - (coords[3][1]) / heightScale); return { start_time, end_time, diff --git a/client/src/components/geoJS/layers/editAnnotationLayer.ts b/client/src/components/geoJS/layers/editAnnotationLayer.ts index b9fad24..da6216f 100644 --- a/client/src/components/geoJS/layers/editAnnotationLayer.ts +++ b/client/src/components/geoJS/layers/editAnnotationLayer.ts @@ -6,9 +6,6 @@ import { LayerStyle } from "./types"; import { GeoJSON } from "geojson"; export type EditAnnotationTypes = "rectangle"; -interface EditAnnotationLayerParams { - type: EditAnnotationTypes; -} interface RectGeoJSData { id: number; @@ -92,11 +89,11 @@ export default class EditAnnotationLayer { ) { (this.geoViewerRef = geoViewerRef), (this.event = event); this.type = "rectangle"; - this.style = { - strokeColor: 'black', - strokeWidth: 1.0, - antialiasing: 0, - }; + this.style = { + strokeColor: "black", + strokeWidth: 1.0, + antialiasing: 0, + }; this.formattedData = []; this.spectroInfo = spectroInfo; this.skipNextExternalUpdate = false; @@ -199,12 +196,10 @@ export default class EditAnnotationLayer { // triggers a mouse up while editing to make it seem like a point was placed window.setTimeout( () => - this.geoViewerRef - .interactor() - .simulateEvent("mouseup", { - map: { x: e.mouse.geo.x, y: e.mouse.geo.y }, - button: "left", - }), + this.geoViewerRef.interactor().simulateEvent("mouseup", { + map: { x: e.mouse.geo.x, y: e.mouse.geo.y }, + button: "left", + }), 0 ); } else if (this.shapeInProgress) { @@ -319,7 +314,7 @@ export default class EditAnnotationLayer { } /** overrides default function to disable and clear anotations before drawing again */ - async changeData(frameData: SpectrogramAnnotation) { + async changeData(frameData: SpectrogramAnnotation | null) { if (this.skipNextExternalUpdate === false) { // disable resets things before we load a new/different shape or mode this.disable(); @@ -344,25 +339,40 @@ export default class EditAnnotationLayer { * * @param frameData a single FrameDataTrack Array that is the editing item */ - formatData(annotationData: SpectrogramAnnotation) { + formatData(annotationData: SpectrogramAnnotation | null) { this.selectedHandleIndex = -1; this.hoverHandleIndex = -1; this.event("update:selectedIndex", { selectedIndex: this.selectedHandleIndex, selectedKey: this.selectedKey, }); - const geoJSONData = spectroToGeoJSon(annotationData, this.spectroInfo); - const geojsonFeature: GeoJSON.Feature = { - type: "Feature", - geometry: geoJSONData, - properties: { - annotationType: typeMapper.get(this.type), - }, - }; - this.featureLayer.geojson(geojsonFeature); - const annotation = this.applyStylesToAnnotations(); - this.setMode("rectangle", annotation); - this.formattedData = [geojsonFeature]; + if (annotationData) { + const geoJSONData = spectroToGeoJSon(annotationData, this.spectroInfo); + const geojsonFeature: GeoJSON.Feature = { + type: "Feature", + geometry: geoJSONData, + properties: { + annotationType: typeMapper.get(this.type), + }, + }; + this.featureLayer.geojson(geojsonFeature); + const annotation = this.applyStylesToAnnotations(); + this.setMode("rectangle", annotation); + this.formattedData = [geojsonFeature]; + return; + } else { + this.setMode(this.type); + } + if (typeof this.type !== "string") { + throw new Error( + `editing props needs to be a string of value + ${geo.listAnnotations().join(", ")} + when geojson prop is not set` + ); + } else { + // point or rectangle mode for the editor + this.setMode(this.type); + } } /** @@ -388,6 +398,7 @@ export default class EditAnnotationLayer { this.event("update:geojson", { status: "editing", creating: this.getMode() === "creation", + geoJSON: geoJSONData[0], type: this.type, selectedKey: this.selectedKey, }); @@ -415,10 +426,12 @@ export default class EditAnnotationLayer { ); // The corners need to update for the indexes to update // coordinates are in a different system than display - const coords = (newGeojson.geometry.coordinates[0] as GeoJSON.Position[]).map((coord) => ({ - x: coord[0], - y: coord[1], - })); + const coords = (newGeojson.geometry.coordinates[0] as GeoJSON.Position[]).map( + (coord) => ({ + x: coord[0], + y: coord[1], + }) + ); // only use the 4 coords instead of 5 const remapped = this.geoViewerRef.worldToGcs(coords.splice(0, 4)); e.annotation.options("corners", remapped); @@ -446,7 +459,7 @@ export default class EditAnnotationLayer { type: this.type, selectedKey: this.selectedKey, }); - } + } } } } @@ -476,7 +489,7 @@ export default class EditAnnotationLayer { fill: false, }, fill: false, - strokeColor: "red", + strokeColor: "cyan", }; } diff --git a/client/src/components/geoJS/layers/rectangleLayer.ts b/client/src/components/geoJS/layers/rectangleLayer.ts index 783062f..e98a16e 100644 --- a/client/src/components/geoJS/layers/rectangleLayer.ts +++ b/client/src/components/geoJS/layers/rectangleLayer.ts @@ -1,161 +1,164 @@ /* eslint-disable class-methods-use-this */ -import geo, { GeoEvent } from 'geojs'; -import { SpectroInfo, spectroToGeoJSon } from '../geoJSUtils'; -import { SpectrogramAnnotation } from '../../../api/api'; -import { LayerStyle } from './types'; +import geo, { GeoEvent } from "geojs"; +import { SpectroInfo, spectroToGeoJSon } from "../geoJSUtils"; +import { SpectrogramAnnotation } from "../../../api/api"; +import { LayerStyle } from "./types"; -interface RectGeoJSData{ +interface RectGeoJSData { id: number; selected: boolean; - editing: boolean | string; + editing?: boolean; polygon: GeoJSON.Polygon; } -export default class RectangleLayer{ - formattedData: RectGeoJSData[]; +export default class RectangleLayer { + formattedData: RectGeoJSData[]; - drawingOther: boolean; //drawing another type of annotation at the same time? + drawingOther: boolean; //drawing another type of annotation at the same time? - hoverOn: boolean; //to turn over annnotations on - // eslint-disable-next-line @typescript-eslint/no-explicit-any - featureLayer: any; + hoverOn: boolean; //to turn over annnotations on + // eslint-disable-next-line @typescript-eslint/no-explicit-any + featureLayer: any; - selectedIndex: number[]; // sparse array + selectedIndex: number[]; // sparse array - // eslint-disable-next-line @typescript-eslint/no-explicit-any - geoViewerRef: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geoViewerRef: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - event: (name: string, data: any) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: (name: string, data: any) => void; - spectroInfo: SpectroInfo; + spectroInfo: SpectroInfo; - style: LayerStyle; + style: LayerStyle; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geoViewerRef: any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(geoViewerRef: any, event: (name: string, data: any) => void, spectroInfo: SpectroInfo) { + event: (name: string, data: any) => void, + spectroInfo: SpectroInfo + ) { this.geoViewerRef = geoViewerRef; - this.drawingOther = false; - this.spectroInfo = spectroInfo; - this.formattedData = []; - this.hoverOn = false; - this.selectedIndex = []; - this.event = event; - //Only initialize once, prevents recreating Layer each edit - const layer = this.geoViewerRef.createLayer('feature', { - features: ['polygon'], - }); - this.featureLayer = layer - .createFeature('polygon', { selectionAPI: true }) - .geoOn(geo.event.feature.mouseclick, (e: GeoEvent) => { + this.drawingOther = false; + this.spectroInfo = spectroInfo; + this.formattedData = []; + this.hoverOn = false; + this.selectedIndex = []; + this.event = event; + //Only initialize once, prevents recreating Layer each edit + const layer = this.geoViewerRef.createLayer("feature", { + features: ["polygon"], + }); + this.featureLayer = layer + .createFeature("polygon", { selectionAPI: true }) + .geoOn(geo.event.feature.mouseclick, (e: GeoEvent) => { /** * Handle clicking on individual annotations, if DrawingOther is true we use the * Rectangle type if only the polygon is visible we use the polygon bounds * */ - 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 }); - } - } 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.mouse.buttonsDown.left) { + if (!e.data.editing || (e.data.editing && !e.data.selected)) { + 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 }); } - }); - this.featureLayer.geoOn( - geo.event.feature.mouseclick_order, - this.featureLayer.mouseOverOrderClosestBorder, - ); - this.featureLayer.geoOn(geo.event.mouseclick, (e: GeoEvent) => { - // If we aren't clicking on an annotation we can deselect the current track - if (this.featureLayer.pointSearch(e.geo).found.length === 0) { - this.event('annotation-clicked', { id: null, edit: false }); } }); - this.style = this.createStyle(); - } - - hoverAnnotations(e: GeoEvent) { - const { found } = this.featureLayer.pointSearch(e.mouse.geo); - this.event('annotation-hover', {id: found, pos: e.mouse.geo}); - } - - setHoverAnnotations(val: boolean) { - if (!this.hoverOn && val) { - this.featureLayer.geoOn( - geo.event.feature.mouseover, - (e: GeoEvent) => this.hoverAnnotations(e), - ); - this.featureLayer.geoOn( - geo.event.feature.mouseoff, - (e: GeoEvent) => this.hoverAnnotations(e), - ); - this.hoverOn = true; - } else if (this.hoverOn && !val) { - this.featureLayer.geoOff(geo.event.feature.mouseover); - this.featureLayer.geoOff(geo.event.feature.mouseoff); - this.hoverOn = false; + this.featureLayer.geoOn( + geo.event.feature.mouseclick_order, + this.featureLayer.mouseOverOrderClosestBorder + ); + this.featureLayer.geoOn(geo.event.mouseclick, (e: GeoEvent) => { + // If we aren't clicking on an annotation we can deselect the current track + if (this.featureLayer.pointSearch(e.geo).found.length === 0) { + this.event("annotation-cleared", { id: null, edit: false }); } + }); + this.style = this.createStyle(); + } + + hoverAnnotations(e: GeoEvent) { + const { found } = this.featureLayer.pointSearch(e.mouse.geo); + this.event("annotation-hover", { id: found, pos: e.mouse.geo }); + } + + setHoverAnnotations(val: boolean) { + if (!this.hoverOn && val) { + this.featureLayer.geoOn(geo.event.feature.mouseover, (e: GeoEvent) => + this.hoverAnnotations(e) + ); + this.featureLayer.geoOn(geo.event.feature.mouseoff, (e: GeoEvent) => + this.hoverAnnotations(e) + ); + this.hoverOn = true; + } else if (this.hoverOn && !val) { + this.featureLayer.geoOff(geo.event.feature.mouseover); + this.featureLayer.geoOff(geo.event.feature.mouseoff); + this.hoverOn = false; } + } - /** + /** * Used to set the drawingOther parameter used to change styling if other types are drawn * and also handle selection clicking between different types * @param val - determines if we are drawing other types of annotations */ - setDrawingOther(val: boolean) { - this.drawingOther = val; - } + setDrawingOther(val: boolean) { + this.drawingOther = val; + } - - formatData(annotationData: SpectrogramAnnotation[]) { - const arr: RectGeoJSData[] = []; - annotationData.forEach((annotation: SpectrogramAnnotation, ) => { + formatData(annotationData: SpectrogramAnnotation[], selectedIndex: number | null) { + const arr: RectGeoJSData[] = []; + annotationData.forEach((annotation: SpectrogramAnnotation) => { const polygon = spectroToGeoJSon(annotation, this.spectroInfo); const newAnnotation: RectGeoJSData = { - id: annotation.id, - selected: false, - editing: false, - polygon, - }; + id: annotation.id, + selected: annotation.id === selectedIndex, + editing: annotation.editing, + polygon, + }; arr.push(newAnnotation); - - }); - this.formattedData = arr; - } - - redraw() { - // add some styles - this.featureLayer - .data(this.formattedData) - .polygon((d: RectGeoJSData) => d.polygon.coordinates[0]) - .style(this.createStyle()) - .draw(); - } - - disable() { - this.featureLayer - .data([]) - .draw(); - } - - createStyle(): LayerStyle { - return { - ...{ - strokeColor: 'black', - strokeWidth: 4.0, - antialiasing: 0, - stroke: true, - uniformPolygon: true, - fill: false, - }, - // Style conversion to get array objects to work in geoJS - position: (point) => { - return ({ x: point[0], y: point[1] }); - }, - strokeColor: (_point, _index, data) => { - - return 'red'; - }, }; + }); + this.formattedData = arr; + } + + redraw() { + // add some styles + this.featureLayer + .data(this.formattedData) + .polygon((d: RectGeoJSData) => d.polygon.coordinates[0]) + .style(this.createStyle()) + .draw(); + } + + disable() { + this.featureLayer.data([]).draw(); + } + + createStyle(): LayerStyle { + return { + ...{ + strokeColor: "black", + strokeWidth: 4.0, + antialiasing: 0, + stroke: true, + uniformPolygon: true, + fill: false, + }, + // Style conversion to get array objects to work in geoJS + position: (point) => { + return { x: point[0], y: point[1] }; + }, + strokeColor: (_point, _index, data) => { + if (data.selected) { + return "cyan"; } + return "red"; + }, + }; + } } diff --git a/client/src/use/useState.ts b/client/src/use/useState.ts new file mode 100644 index 0000000..ac9ca6d --- /dev/null +++ b/client/src/use/useState.ts @@ -0,0 +1,15 @@ +import { ref, Ref } from 'vue'; + +const annotationState: Ref = ref(''); + +type AnnotationState = '' | 'editing' | 'creating'; +export default function useState() { + const setAnnotationState = (state: AnnotationState) => { + annotationState.value = state; + }; + return { + annotationState, + setAnnotationState, + }; +} + diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index d8dc192..1b3b0b9 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -1,13 +1,17 @@ diff --git a/client/yarn.lock b/client/yarn.lock index 093b34c..8700ec4 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1717,6 +1717,11 @@ resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz" integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== +"@types/lodash@^4.14.202": + version "4.14.202" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz" + integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ== + "@types/node@*": version "17.0.21" resolved "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz"