diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 3cfd9d88c..b6630e875 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -2,19 +2,12 @@ import { provide } from '@vue/composition-api'; import { use } from 'vue-media-annotator/provides'; import { TrackData, TrackId } from 'vue-media-annotator/track'; +import { Attribute } from 'vue-media-annotator/use/useAttributes'; import { CustomStyle } from 'vue-media-annotator/use/useStyling'; type DatasetType = 'image-sequence' | 'video'; type MultiTrackRecord = Record; -interface Attribute { - belongs: 'track' | 'detection'; - datatype: 'text' | 'number' | 'boolean'; - values?: string[]; - name: string; - _id: string; -} - interface Pipe { name: string; pipe: string; @@ -38,6 +31,11 @@ interface SaveDetectionsArgs { upsert: TrackData[]; } +interface SaveAttributeArgs { + delete: string[]; + upsert: Attribute[]; +} + interface FrameImage { url: string; filename: string; @@ -49,7 +47,6 @@ interface FrameImage { interface DatasetMetaMutable { customTypeStyling?: Record; confidenceFilters?: Record; - attributes?: Record; } interface DatasetMeta extends DatasetMetaMutable { @@ -60,19 +57,10 @@ interface DatasetMeta extends DatasetMetaMutable { fps: Readonly; // this will become mutable in the future. name: string; createdAt: string; + attributes?: Record; } interface Api { - /** - * TODO: Modification to use loadMetadata as well as saving - * utilizing upsert/delete for the metaData. This requires having - * useAttributes to manage attributes locally and then save to backend - * @deprecated soon attributes will come from loadMetadata() - */ - getAttributes(datasetId: string): Promise; - setAttribute(datasetId: string, { addNew, data }: - {addNew?: boolean; data: Attribute}): Promise; - deleteAttribute(datasetId: string, data: Attribute): Promise; getPipelineList(): Promise; runPipeline(itemId: string, pipeline: Pipe): Promise; @@ -85,6 +73,7 @@ interface Api { saveDetections(datasetId: string, args: SaveDetectionsArgs): Promise; saveMetadata(datasetId: string, metadata: DatasetMetaMutable): Promise; + saveAttributes(datasetId: string, args: SaveAttributeArgs): Promise; } const ApiSymbol = Symbol('api'); @@ -109,7 +98,6 @@ export { export type { Api, - Attribute, DatasetMeta, DatasetMetaMutable, DatasetType, @@ -118,5 +106,6 @@ export type { Pipe, Pipelines, SaveDetectionsArgs, + SaveAttributeArgs, TrainingConfigs, }; diff --git a/client/dive-common/components/AttributeEditor.vue b/client/dive-common/components/AttributeEditor.vue index 98eab19d1..79c636014 100644 --- a/client/dive-common/components/AttributeEditor.vue +++ b/client/dive-common/components/AttributeEditor.vue @@ -2,8 +2,8 @@ import { computed, defineComponent, PropType, Ref, ref, } from '@vue/composition-api'; +import { Attribute } from 'vue-media-annotator/use/useAttributes'; -import { Attribute } from 'dive-common/apispec'; export default defineComponent({ name: 'AttributeSettings', @@ -22,7 +22,7 @@ export default defineComponent({ const belongs: Ref = ref(props.selectedAttribute.belongs); const datatype: Ref = ref(props.selectedAttribute.datatype); let values: string[] = props.selectedAttribute.values ? props.selectedAttribute.values : []; - let addNew = !props.selectedAttribute._id.length; + let addNew = !props.selectedAttribute.key.length; const form: Ref = ref(null); @@ -58,15 +58,19 @@ export default defineComponent({ return; } - const content = { + const data = { name: name.value, belongs: belongs.value, datatype: datatype.value, values: datatype.value === 'text' && values ? values : [], - _id: props.selectedAttribute._id, + key: `${belongs.value}_${name.value}`, }; - emit('save', { addNew, data: content }); + if (addNew) { + emit('save', { data }); + } else { + emit('save', { data, oldAttribute: props.selectedAttribute }); + } } async function deleteAttribute() { @@ -151,7 +155,7 @@ export default defineComponent({ Delete diff --git a/client/dive-common/components/AttributeSettings.vue b/client/dive-common/components/AttributeSettings.vue deleted file mode 100644 index ca47f8b4d..000000000 --- a/client/dive-common/components/AttributeSettings.vue +++ /dev/null @@ -1,253 +0,0 @@ - - - - - diff --git a/client/dive-common/components/AttributesSubsection.vue b/client/dive-common/components/AttributesSubsection.vue index 1f8c10508..31a86fe8f 100644 --- a/client/dive-common/components/AttributesSubsection.vue +++ b/client/dive-common/components/AttributesSubsection.vue @@ -5,13 +5,13 @@ import { PropType, computed, } from '@vue/composition-api'; -import { Attribute } from 'dive-common/apispec'; import { useSelectedTrackId, useFrame, useTrackMap, } from 'vue-media-annotator/provides'; import { getTrack } from 'vue-media-annotator/use/useTrackStore'; +import { Attribute } from 'vue-media-annotator/use/useAttributes'; import AttributeInput from 'dive-common/components/AttributeInput.vue'; import PanelSubsection from 'dive-common/components/PanelSubsection.vue'; diff --git a/client/dive-common/components/TrackDetailsPanel.vue b/client/dive-common/components/TrackDetailsPanel.vue index 7a7401fc3..f21c87847 100644 --- a/client/dive-common/components/TrackDetailsPanel.vue +++ b/client/dive-common/components/TrackDetailsPanel.vue @@ -2,7 +2,6 @@ import { computed, defineComponent, - onBeforeMount, Ref, ref, } from '@vue/composition-api'; @@ -15,12 +14,12 @@ import { useAllTypes, useHandler, useTrackMap, - useDatasetId, + useAttributes, } from 'vue-media-annotator/provides'; import { getTrack } from 'vue-media-annotator/use/useTrackStore'; +import { Attribute } from 'vue-media-annotator/use/useAttributes'; import TrackItem from 'vue-media-annotator/components/TrackItem.vue'; -import { useApi, Attribute } from 'dive-common/apispec'; import AttributeInput from 'dive-common/components/AttributeInput.vue'; import AttributeEditor from 'dive-common/components/AttributeEditor.vue'; import AttributeSubsection from 'dive-common/components/AttributesSubsection.vue'; @@ -50,14 +49,13 @@ export default defineComponent({ }, }, setup(props) { - const attributes = ref([] as Attribute[]); + const attributes = useAttributes(); const editingAttribute: Ref = ref(null); const editingError: Ref = ref(null); const editingModeRef = useEditingMode(); const typeStylingRef = useTypeStyling(); const allTypesRef = useAllTypes(); const trackMap = useTrackMap(); - const datasetId = useDatasetId(); const { trackSelectNext, trackSplit, removeTrack } = useHandler(); //Edit/Set single value by clicking @@ -66,7 +64,7 @@ export default defineComponent({ const frameRef = useFrame(); const selectedTrackIdRef = useSelectedTrackId(); - const { getAttributes, setAttribute, deleteAttribute } = useApi(); + const { setAttribute, deleteAttribute } = useHandler(); const selectedTrack = computed(() => { if (selectedTrackIdRef.value !== null) { return getTrack(trackMap, selectedTrackIdRef.value); @@ -97,7 +95,6 @@ export default defineComponent({ async function closeEditor() { editingAttribute.value = null; editingError.value = null; - attributes.value = await getAttributes(datasetId.value); } function addAttribute(type: 'Track' | 'Detection') { @@ -107,27 +104,26 @@ export default defineComponent({ belongs, datatype: 'text', name: `New${type}Attribute`, - _id: '', + key: '', }; } function editAttribute(attribute: Attribute) { editingAttribute.value = attribute; } - async function saveAttribtueHandler(saveData: { - addNew: boolean | undefined; + async function saveAttributeHandler({ data, oldAttribute }: { + oldAttribute?: Attribute; data: Attribute; }) { editingError.value = null; - if (attributes.value.some((attribute) => ( - attribute.name === saveData.data.name - && attribute.belongs === saveData.data.belongs - && attribute._id !== saveData.data._id))) { + if (!oldAttribute && attributes.value.some((attribute) => ( + attribute.name === data.name + && attribute.belongs === data.belongs))) { editingError.value = 'Attribute with that name exists'; return; } try { - await setAttribute(datasetId.value, saveData); + await setAttribute({ data, oldAttribute }); } catch (err) { editingError.value = err.message; } @@ -138,7 +134,7 @@ export default defineComponent({ async function deleteAttributeHandler(data: Attribute) { editingError.value = null; try { - await deleteAttribute(datasetId.value, data); + await deleteAttribute({ data }); } catch (err) { editingError.value = err.message; } @@ -181,17 +177,13 @@ export default defineComponent({ ]; }); - onBeforeMount(async () => { - attributes.value = await getAttributes(datasetId.value); - }); - return { selectedTrackIdRef, /* Attributes */ attributes, /* Editing */ editingAttribute, - saveAttribtueHandler, + saveAttributeHandler, deleteAttributeHandler, editingError, editIndividual, @@ -281,7 +273,7 @@ export default defineComponent({ :selected-attribute="editingAttribute" :error="editingError" @close="closeEditor" - @save="saveAttribtueHandler" + @save="saveAttributeHandler" @delete="deleteAttributeHandler" /> diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 3d3b7ccab..cb5b2917d 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -7,6 +7,7 @@ import type { Vue } from 'vue/types/vue'; /* VUE MEDIA ANNOTATOR */ import Track, { TrackId } from 'vue-media-annotator/track'; import { + useAttributes, useLineChart, useStyling, useTrackFilters, @@ -125,6 +126,13 @@ export default defineComponent({ getTypeStyles, } = useStyling({ markChangesPending }); + const { + attributesList: attributes, + loadAttributes, + setAttribute, + deleteAttribute, + } = useAttributes({ markChangesPending }); + const { trackMap, sortedTracks, @@ -287,10 +295,13 @@ export default defineComponent({ updateTypeStyle, removeTypeTracks, deleteType, + setAttribute, + deleteAttribute, }; provideAnnotator( { + attributes, allTypes, datasetId: ref(props.id), usedTypes, @@ -318,6 +329,9 @@ export default defineComponent({ if (meta.customTypeStyling) { importTypes(Object.keys(meta.customTypeStyling), false); } + if (meta.attributes) { + loadAttributes(meta.attributes); + } populateConfidenceFilters(meta.confidenceFilters); datasetName.value = meta.name; fps.value = meta.fps; diff --git a/client/dive-common/use/useSave.ts b/client/dive-common/use/useSave.ts index d04ec71e9..e08f7081a 100644 --- a/client/dive-common/use/useSave.ts +++ b/client/dive-common/use/useSave.ts @@ -1,15 +1,35 @@ import { ref, Ref } from '@vue/composition-api'; -import Track, { TrackId } from 'vue-media-annotator/track'; + +import Track, { TrackId, isTrack } from 'vue-media-annotator/track'; +import { Attribute, isAttribute } from 'vue-media-annotator/use/useAttributes'; + import { useApi, DatasetMetaMutable } from 'dive-common/apispec'; +function _updatePendingChangeMap( + key: K, value: V, + action: 'upsert' | 'delete', + upsert: Map, + del: Set, +) { + if (action === 'delete') { + del.add(key); + upsert.delete(key); + } else if (action === 'upsert') { + del.delete(key); + upsert.set(key, value); + } +} + export default function useSave(datasetId: Ref>) { const pendingSaveCount = ref(0); const pendingChangeMap = { upsert: new Map(), delete: new Set(), + attributeUpsert: new Map(), + attributeDelete: new Set(), meta: 0, }; - const { saveDetections, saveMetadata } = useApi(); + const { saveDetections, saveMetadata, saveAttributes } = useApi(); async function save( datasetMeta?: DatasetMetaMutable, @@ -29,24 +49,40 @@ export default function useSave(datasetId: Ref>) { pendingChangeMap.meta = 0; })); } + if (pendingChangeMap.attributeUpsert.size || pendingChangeMap.attributeDelete.size) { + promiseList.push(saveAttributes(datasetId.value, { + upsert: Array.from(pendingChangeMap.attributeUpsert).map((pair) => pair[1]), + delete: Array.from(pendingChangeMap.attributeDelete), + }).then(() => { + pendingChangeMap.attributeUpsert.clear(); + pendingChangeMap.attributeDelete.clear(); + })); + } await Promise.all(promiseList); pendingSaveCount.value = 0; } function markChangesPending( - type: 'upsert' | 'delete' | 'meta' = 'meta', - track?: Track, + { + action, + data, + }: { + action: 'upsert' | 'delete' | 'meta'; + data?: Track | Attribute; + } = { action: 'meta' }, ) { - if (type === 'delete' && track !== undefined) { - pendingChangeMap.delete.add(track.trackId); - pendingChangeMap.upsert.delete(track.trackId); - } else if (type === 'upsert' && track !== undefined) { - pendingChangeMap.delete.delete(track.trackId); - pendingChangeMap.upsert.set(track.trackId, track); - } else if (type === 'meta') { + if (action === 'meta') { pendingChangeMap.meta += 1; + } else if (isTrack(data)) { + _updatePendingChangeMap( + data.trackId, data, action, pendingChangeMap.upsert, pendingChangeMap.delete, + ); + } else if (isAttribute(data)) { + _updatePendingChangeMap( + data.key, data, action, pendingChangeMap.attributeUpsert, pendingChangeMap.attributeDelete, + ); } else { - throw new Error('Arguments inconsistent with pending change type'); + throw new Error(`Arguments inconsistent with pending change type: ${action} cannot be performed on ${data}`); } pendingSaveCount.value += 1; } diff --git a/client/platform/desktop/backend/native/attributeProcessor.ts b/client/platform/desktop/backend/native/attributeProcessor.ts index d226c72f2..3243b4c22 100644 --- a/client/platform/desktop/backend/native/attributeProcessor.ts +++ b/client/platform/desktop/backend/native/attributeProcessor.ts @@ -1,6 +1,6 @@ -import { Attributes } from 'platform/desktop/constants'; import { MultiTrackRecord } from 'dive-common/apispec'; import { StringKeyObject, TrackData } from 'vue-media-annotator/track'; +import { Attributes } from 'vue-media-annotator/use/useAttributes'; /** @@ -21,7 +21,7 @@ function processTrackAttributes(tracks: TrackData[]): belongs: type, datatype: 'text', name: key, - _id: `${type}_${key}`, + key: `${type}_${key}`, }; testVals[`${type}_${key}`] = { }; testVals[`${type}_${key}`][valstring] = 1; diff --git a/client/platform/desktop/backend/native/common.spec.ts b/client/platform/desktop/backend/native/common.spec.ts index b522bfcfa..a1a6c2e43 100644 --- a/client/platform/desktop/backend/native/common.spec.ts +++ b/client/platform/desktop/backend/native/common.spec.ts @@ -10,7 +10,6 @@ import type { DesktopJobUpdate, DesktopJobUpdater, JsonMeta, RunTraining, Settings, } from 'platform/desktop/constants'; -import { Attribute } from 'dive-common/apispec'; import * as common from './common'; const pipelines = { @@ -218,14 +217,14 @@ mockfs({ datatype: 'text', values: ['value1', 'value2', 'value3'], name: 'attribute1', - _id: 'track_attribute1', + key: 'track_attribute1', }, // eslint-disable-next-line @typescript-eslint/camelcase detection_attribute1: { belongs: 'detection', datatype: 'number', name: 'attribute1', - _id: 'detection_attribute1', + key: 'detection_attribute1', }, }, }), @@ -382,65 +381,6 @@ describe('native.common', () => { expect(pipes.generate.pipes).toHaveLength(4); expect(pipes.trained.pipes).toHaveLength(1); }); - - it('getAtributes', async () => { - const meta = await common.getAttributes(settings, 'metaAttributesID'); - //Should return an array of data items - expect(meta.length).toBe(2); - expect(meta[0].values).toEqual(['value1', 'value2', 'value3']); - expect(meta[1].datatype).toBe('number'); - }); - - it('addAttribute', async () => { - const templateAttribute: Attribute = { - name: 'newAttribute', datatype: 'boolean', belongs: 'track', _id: '', - }; - await common.setAttribute(settings, 'metaAttributesID', { - data: templateAttribute, - }); - //Should return an array of data items - const meta = await common.getAttributes(settings, 'metaAttributesID'); - const newAttribute = meta.find((item) => item.name === 'newAttribute'); - expect(meta.length).toBe(3); - expect(newAttribute).toEqual(templateAttribute); - }); - - it('updateAttribute', async () => { - const templateAttribute: Attribute = { - name: 'newAttributeName', datatype: 'boolean', belongs: 'track', _id: 'track_attribute1', - }; - await common.setAttribute(settings, 'metaAttributesID', { - data: templateAttribute, - }); - //Should return an array of data items - const meta = await common.getAttributes(settings, 'metaAttributesID'); - const updatedAttribute = meta.find((item) => item._id === 'track_attribute1'); - expect(meta.length).toBe(3); - expect(updatedAttribute).toEqual(templateAttribute); - }); - - it('deleteAttribute', async () => { - const deleteAttribute: Attribute = { - name: 'attribute1', datatype: 'text', belongs: 'track', _id: 'track_attribute1', - }; - await common.deleteAttribute(settings, 'metaAttributesID', { data: deleteAttribute }); - const meta = await common.getAttributes(settings, 'metaAttributesID'); - //Should return an array of data items - expect(meta.length).toBe(2); - expect(meta[0].datatype).toBe('number'); - }); - - it('initial attribute creation', async () => { - const templateAttribute: Attribute = { - name: 'newAttribute', datatype: 'boolean', belongs: 'track', _id: '', - }; - await common.setAttribute(settings, 'projectid1VideoGood', { - data: templateAttribute, - }); - const meta = await common.getAttributes(settings, 'projectid1VideoGood'); - expect(meta.length).toBe(1); - expect(meta[0]).toEqual(templateAttribute); - }); }); afterAll(() => { diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts index b5703625b..fae210fba 100644 --- a/client/platform/desktop/backend/native/common.ts +++ b/client/platform/desktop/backend/native/common.ts @@ -10,15 +10,16 @@ import moment from 'moment'; import lockfile from 'proper-lockfile'; import { DatasetType, MultiTrackRecord, Pipelines, SaveDetectionsArgs, - FrameImage, DatasetMetaMutable, TrainingConfigs, Attribute, + FrameImage, DatasetMetaMutable, TrainingConfigs, SaveAttributeArgs, } from 'dive-common/apispec'; import * as viameSerializers from 'platform/desktop/backend/serializers/viame'; import { websafeImageTypes, websafeVideoTypes, otherImageTypes, otherVideoTypes, JsonMeta, Settings, JsonMetaCurrentVersion, DesktopMetadata, DesktopJobUpdater, - ConvertMedia, RunTraining, Attributes, ExportDatasetArgs, + ConvertMedia, RunTraining, ExportDatasetArgs, } from 'platform/desktop/constants'; +import { Attribute, Attributes } from 'vue-media-annotator/use/useAttributes'; import processTrackAttributes from './attributeProcessor'; import { cleanString, makeid } from './utils'; @@ -350,7 +351,8 @@ async function _saveAsJson(absPath: string, data: unknown) { await fs.writeFile(absPath, serialized); } -async function saveMetadata(settings: Settings, datasetId: string, args: DatasetMetaMutable) { +async function saveMetadata(settings: Settings, datasetId: string, + args: DatasetMetaMutable & { attributes?: Record}) { const projectDirInfo = await getValidatedProjectDir(settings, datasetId); const release = await _acquireLock(projectDirInfo.basePath, projectDirInfo.metaFileAbsPath, 'meta'); const existing = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); @@ -363,48 +365,31 @@ async function saveMetadata(settings: Settings, datasetId: string, args: Dataset if (args.attributes) { existing.attributes = args.attributes; } + await _saveAsJson(projectDirInfo.metaFileAbsPath, existing); await release(); } -async function getAttributes(settings: Settings, datasetId: string): - Promise { - const projectDirData = await getValidatedProjectDir(settings, datasetId); - const projectMetaData = await loadJsonMetadata(projectDirData.metaFileAbsPath); - if (projectMetaData.attributes) { - return Object.values(projectMetaData.attributes); - } - return []; -} -async function setAttribute(settings: Settings, datasetId: string, { data }: - {data: Attribute }) { +async function saveAttributes(settings: Settings, datasetId: string, args: SaveAttributeArgs) { const projectDirData = await getValidatedProjectDir(settings, datasetId); const projectMetaData = await loadJsonMetadata(projectDirData.metaFileAbsPath); if (!projectMetaData.attributes) { projectMetaData.attributes = {}; } - // Reassign _id based on name if it is a new item - if (data._id === '') { - // eslint-disable-next-line no-param-reassign - data._id = `${data.belongs}_${data.name}`; - } - projectMetaData.attributes[data._id] = data; + args.delete.forEach((attributeId) => { + if (projectMetaData.attributes && projectMetaData.attributes[attributeId]) { + delete projectMetaData.attributes[attributeId]; + } + }); + args.upsert.forEach((attribute) => { + if (projectMetaData.attributes) { + projectMetaData.attributes[attribute.key] = attribute; + } + }); await saveMetadata(settings, datasetId, projectMetaData); } -async function deleteAttribute(settings: Settings, datasetId: string, { data }: - { data: Attribute }) { - const projectDirData = await getValidatedProjectDir(settings, datasetId); - const projectMetaData = await loadJsonMetadata(projectDirData.metaFileAbsPath); - if (!projectMetaData.attributes) { - return; - } - if (projectMetaData.attributes[data._id]) { - delete projectMetaData.attributes[data._id]; - } - await saveMetadata(settings, datasetId, projectMetaData); -} /** * processOtherAnnotationFiles imports data from external annotation formats @@ -731,7 +716,5 @@ export { saveMetadata, completeConversion, processTrainedPipeline, - getAttributes, - setAttribute, - deleteAttribute, + saveAttributes, }; diff --git a/client/platform/desktop/backend/server.ts b/client/platform/desktop/backend/server.ts index 78c2119d2..a15a72c44 100644 --- a/client/platform/desktop/backend/server.ts +++ b/client/platform/desktop/backend/server.ts @@ -8,7 +8,7 @@ import express from 'express'; import bodyparser from 'body-parser'; import rangeParser from 'range-parser'; import fs from 'fs-extra'; -import { Attribute, SaveDetectionsArgs } from 'dive-common/apispec'; +import { SaveAttributeArgs, SaveDetectionsArgs } from 'dive-common/apispec'; import settings from './state/settings'; import * as common from './native/common'; @@ -64,40 +64,19 @@ apirouter.post('/dataset/:id/meta', async (req, res, next) => { } }); -/* LOAD Attributes */ -apirouter.get('/dataset/:id/attribute', async (req, res, next) => { +/* SAVE attributes */ +apirouter.post('/dataset/:id/attributes', async (req, res, next) => { try { - const ds = await common.getAttributes(settings.get(), req.params.id); - res.json(ds); - } catch (err) { - err.status = 500; - next(err); - } -}); - -/* ADD/Update Attribute */ -apirouter.post('/dataset/:id/attribute', async (req, res, next) => { - try { - const args = req.body as {addNew?: boolean; data: Attribute }; - await common.setAttribute(settings.get(), req.params.id, args); + const args = req.body as SaveAttributeArgs; + await common.saveAttributes(settings.get(), req.params.id, args); res.status(200).send('done'); } catch (err) { err.status = 500; next(err); } + return null; }); -/* Delete Attribute */ -apirouter.delete('/dataset/:id/attribute', async (req, res, next) => { - try { - const args = { data: req.body } as { data: Attribute }; - await common.deleteAttribute(settings.get(), req.params.id, args); - res.status(200).send('done'); - } catch (err) { - err.status = 500; - next(err); - } -}); /* SAVE detections */ apirouter.post('/dataset/:id/detections', async (req, res, next) => { diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 65f0ee847..0c6a48f87 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -1,7 +1,7 @@ import type { - Attribute, DatasetMeta, DatasetMetaMutable, DatasetType, Pipe, } from 'dive-common/apispec'; +import { Attribute } from 'vue-media-annotator/use/useAttributes'; export const websafeVideoTypes = [ 'video/mp4', @@ -53,7 +53,6 @@ export interface Settings { dataPath: string; } -export type Attributes = Record; /** * JsonMeta is a SUBSET of DatasetMeta contained within @@ -101,6 +100,9 @@ export interface JsonMeta extends DatasetMetaMutable { // If the dataset required transcoding, specify the job // key that ran transcoding transcodingJobKey?: string; + + //Attributes are not datasetMetaMutable and are stored separate + attributes?: Record; } export type DesktopMetadata = DatasetMeta & JsonMeta; diff --git a/client/platform/desktop/frontend/api.ts b/client/platform/desktop/frontend/api.ts index d61d135ed..cbe126a96 100644 --- a/client/platform/desktop/frontend/api.ts +++ b/client/platform/desktop/frontend/api.ts @@ -5,8 +5,8 @@ import axios, { AxiosInstance } from 'axios'; import { ipcRenderer, remote } from 'electron'; import type { - Attribute, DatasetMetaMutable, DatasetType, - Pipe, Pipelines, SaveDetectionsArgs, TrainingConfigs, + DatasetMetaMutable, DatasetType, + Pipe, Pipelines, SaveAttributeArgs, SaveDetectionsArgs, TrainingConfigs, } from 'dive-common/apispec'; import { @@ -124,35 +124,22 @@ async function saveDetections(id: string, args: SaveDetectionsArgs) { return client.post(`dataset/${id}/detections`, args); } -async function getAttributes(datasetId: string) { - const client = await getClient(); - const { data } = await client.get(`dataset/${datasetId}/attribute`); - return data; -} - -async function setAttribute(datasetId: string, { addNew, data }: - {addNew?: boolean; data: Attribute}) { - const client = await getClient(); - return client.post(`dataset/${datasetId}/attribute`, { addNew, data }); -} -async function deleteAttribute(datasetId: string, data: Attribute) { +async function saveAttributes(id: string, args: SaveAttributeArgs) { const client = await getClient(); - return client.delete(`dataset/${datasetId}/attribute`, { data }); + return client.post(`dataset/${id}/attributes`, args); } export { /* Standard Specification APIs */ loadMetadata, - getAttributes, - setAttribute, - deleteAttribute, getPipelineList, runPipeline, getTrainingConfigurations, runTraining, saveMetadata, saveDetections, + saveAttributes, /* Nonstandard APIs */ exportDataset, importMedia, diff --git a/client/platform/web-girder/App.vue b/client/platform/web-girder/App.vue index 9ad54ed23..241bc8766 100644 --- a/client/platform/web-girder/App.vue +++ b/client/platform/web-girder/App.vue @@ -9,14 +9,12 @@ import { defineComponent } from '@vue/composition-api'; import { provideApi } from 'dive-common/apispec'; import type { GirderMetadata } from './constants'; import { - getAttributes, - setAttribute, - deleteAttribute, getPipelineList, runPipeline, getTrainingConfigurations, runTraining, saveMetadata, + saveAttributes, } from './api/viame.service'; import { loadDetections, @@ -32,9 +30,6 @@ export default defineComponent({ } provideApi({ - getAttributes, - setAttribute, - deleteAttribute, getPipelineList, runPipeline, getTrainingConfigurations, @@ -43,6 +38,7 @@ export default defineComponent({ saveDetections, loadMetadata, saveMetadata, + saveAttributes, }); }, }); diff --git a/client/platform/web-girder/api/viame.service.ts b/client/platform/web-girder/api/viame.service.ts index 5bc2554f5..a22c31aeb 100644 --- a/client/platform/web-girder/api/viame.service.ts +++ b/client/platform/web-girder/api/viame.service.ts @@ -1,7 +1,7 @@ import { GirderModel } from '@girder/components/src'; import { - Attribute, Pipe, Pipelines, TrainingConfigs, + Pipe, Pipelines, SaveAttributeArgs, TrainingConfigs, } from 'dive-common/apispec'; import girderRest from '../plugins/girder'; @@ -64,29 +64,6 @@ function deleteResources(resources: Array) { headers: { 'X-HTTP-Method-Override': 'DELETE' }, }); } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function getAttributes(datasetId = ''): Promise { - const { data } = await girderRest.get('/viame/attribute'); - return data as Attribute[]; -} -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function setAttribute(datasetId = '', { addNew, data }: - {addNew: boolean | undefined; data: Attribute}): Promise { - if (addNew) { - return girderRest.post('/viame/attribute', data); - } - return girderRest.put( - `/viame/attribute/${data._id}`, - data, - ); -} -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function deleteAttribute(datasetId = '', data: Attribute): Promise { - return girderRest.delete( - `/viame/attribute/${data._id}`, - ); -} - async function getPipelineList() { const { data } = await girderRest.get('viame/pipelines'); @@ -118,6 +95,15 @@ function saveMetadata(folderId: string, metadata: object) { ); } +function saveAttributes(folderId: string, args: SaveAttributeArgs) { + return girderRest.put('/viame/attributes', { + upsert: args.upsert, + delete: args.delete, + }, { + params: { folderId }, + }); +} + function postProcess(folderId: string) { return girderRest.post(`viame/postprocess/${folderId}`); } @@ -138,9 +124,6 @@ async function getValidWebImages(folderId: string) { export { getBrandData, deleteResources, - getAttributes, - setAttribute, - deleteAttribute, getPipelineList, makeViameFolder, postProcess, @@ -148,6 +131,7 @@ export { getTrainingConfigurations, runTraining, saveMetadata, + saveAttributes, validateUploadGroup, getValidWebImages, }; diff --git a/client/platform/web-girder/api/viameDetection.service.ts b/client/platform/web-girder/api/viameDetection.service.ts index be478dbc8..a531a3f78 100644 --- a/client/platform/web-girder/api/viameDetection.service.ts +++ b/client/platform/web-girder/api/viameDetection.service.ts @@ -1,4 +1,4 @@ -import { SaveDetectionsArgs } from 'dive-common/apispec'; +import { SaveAttributeArgs, SaveDetectionsArgs } from 'dive-common/apispec'; import { TrackData } from 'vue-media-annotator/track'; import girderRest from '../plugins/girder'; @@ -39,6 +39,16 @@ async function saveDetections(folderId: string, args: SaveDetectionsArgs) { }); } +async function saveAttributes(folderId: string, args: SaveAttributeArgs) { + return girderRest.put('viame_attribute', { + upsert: args.upsert, + delete: args.delete, + }, { + params: { folderId }, + }); +} + + interface ClipMetaResponse { videoUrl: string; } @@ -55,4 +65,5 @@ export { getExportUrls, loadDetections, saveDetections, + saveAttributes, }; diff --git a/client/platform/web-girder/router.js b/client/platform/web-girder/router.js index 04e23ac45..547604dfc 100644 --- a/client/platform/web-girder/router.js +++ b/client/platform/web-girder/router.js @@ -6,7 +6,7 @@ import Home from './views/Home.vue'; import Jobs from './views/Jobs.vue'; import Login from './views/Login.vue'; import RouterPage from './views/RouterPage.vue'; -import Settings from './views/Settings.vue'; +// import Settings from './views/Settings.vue'; // Remove for now import ViewerLoader from './views/ViewerLoader.vue'; Vue.use(Router); @@ -38,12 +38,15 @@ const router = new Router({ name: 'router_base', component: RouterPage, children: [ + /* + * Deprecated and removed for now { path: 'settings', name: 'settings', component: Settings, beforeEnter, }, + */ { path: 'jobs', name: 'jobs', diff --git a/client/platform/web-girder/views/NavigationBar.vue b/client/platform/web-girder/views/NavigationBar.vue index 677137abf..4a578467f 100644 --- a/client/platform/web-girder/views/NavigationBar.vue +++ b/client/platform/web-girder/views/NavigationBar.vue @@ -53,9 +53,6 @@ export default { Datamdi-database - - Settingsmdi-settings - diff --git a/client/platform/web-girder/views/Settings.vue b/client/platform/web-girder/views/Settings.vue index b614a115f..957fd7337 100644 --- a/client/platform/web-girder/views/Settings.vue +++ b/client/platform/web-girder/views/Settings.vue @@ -1,15 +1,17 @@ diff --git a/client/src/provides.ts b/client/src/provides.ts index e71f33eda..189b8c006 100644 --- a/client/src/provides.ts +++ b/client/src/provides.ts @@ -8,6 +8,7 @@ import { EditAnnotationTypes } from './layers/EditAnnotationLayer'; import Track, { TrackId } from './track'; import { VisibleAnnotationTypes } from './layers'; import { RectBounds } from './utils'; +import { Attribute } from './use/useAttributes'; import { TrackWithContext } from './use/useTrackFilters'; /** @@ -15,6 +16,9 @@ import { TrackWithContext } from './use/useTrackFilters'; * but should never overwrite or delete the injected object. */ +const AttributesSymbol = Symbol('attributes'); +type AttributesType = Readonly>; + const AllTypesSymbol = Symbol('allTypes'); type AllTypesType = Readonly>; @@ -121,9 +125,15 @@ export interface Handler { opacity?: number; fill?: boolean; }): void; + /* set an Attribute in the metaData */ + setAttribute({ data, oldAttribute }: + {data: Attribute; oldAttribute?: Attribute }, updateAllTracks?: boolean): void; + /* delete an Attribute in the metaData */ + deleteAttribute({ data }: {data: Attribute}, removeFromTracks?: boolean): void; } const HandlerSymbol = Symbol('handler'); + /** * Make a trivial noop handler. Useful if you only intend to * override some small number of values. @@ -150,6 +160,8 @@ function dummyHandler(handle: (name: string, args: unknown[]) => void): Handler deleteType(...args) { handle('deleteType', args); }, updateTypeName(...args) { handle('updateTypeName', args); }, updateTypeStyle(...args) { handle('updateTypeStyle', args); }, + setAttribute(...args) { handle('setAttribute', args); }, + deleteAttribute(...args) { handle('deleteAttribute', args); }, }; } @@ -161,6 +173,7 @@ function dummyHandler(handle: (name: string, args: unknown[]) => void): Handler * you will need to construct on your own. */ export interface State { + attributes: AttributesType; allTypes: AllTypesType; datasetId: DatasetIdType; usedTypes: UsedTypesType; @@ -191,6 +204,7 @@ function dummyState(): State { fill: false, }; return { + attributes: ref([]), allTypes: ref([]), datasetId: ref(''), usedTypes: ref([]), @@ -228,6 +242,7 @@ function dummyState(): State { * @param {Hander} handler */ function provideAnnotator(state: State, handler: Handler) { + provide(AttributesSymbol, state.attributes); provide(AllTypesSymbol, state.allTypes); provide(DatasetIdSymbol, state.datasetId); provide(UsedTypesSymbol, state.usedTypes); @@ -259,6 +274,10 @@ function use(s: symbol) { return v; } +function useAttributes() { + return use(AttributesSymbol); +} + function useAllTypes() { return use(AllTypesSymbol); } @@ -330,6 +349,7 @@ export { dummyState, provideAnnotator, use, + useAttributes, useAllTypes, useDatasetId, useUsedTypes, diff --git a/client/src/track.ts b/client/src/track.ts index 3379ff193..ab470adce 100644 --- a/client/src/track.ts +++ b/client/src/track.ts @@ -51,6 +51,17 @@ interface TrackParams { attributes?: StringKeyObject; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isTrack(obj: any): obj is Track { + return ( + (typeof obj === 'object') + && (obj.trackId !== undefined) + && (typeof obj.features === 'object') + && (typeof obj.begin === 'number') + && (typeof obj.end === 'number') + ); +} + /** * Track manages the state of a track, its * frame data, and all metadata. diff --git a/client/src/use/index.ts b/client/src/use/index.ts index cfd3cf119..8bbba1ec6 100644 --- a/client/src/use/index.ts +++ b/client/src/use/index.ts @@ -1,3 +1,4 @@ +import useAttributes from './useAttributes'; import useEventChart from './useEventChart'; import useLineChart from './useLineChart'; import useStyling from './useStyling'; @@ -6,6 +7,7 @@ import useTrackSelectionControls from './useTrackSelectionControls'; import useTrackStore from './useTrackStore'; export { + useAttributes, useEventChart, useLineChart, useStyling, diff --git a/client/src/use/useAttributes.ts b/client/src/use/useAttributes.ts new file mode 100644 index 000000000..235bb701b --- /dev/null +++ b/client/src/use/useAttributes.ts @@ -0,0 +1,84 @@ + +import { + ref, Ref, computed, set as VueSet, del as VueDel, +} from '@vue/composition-api'; + +export interface Attribute { + belongs: 'track' | 'detection'; + datatype: 'text' | 'number' | 'boolean'; + values?: string[]; + name: string; + key: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isAttribute(obj: any): obj is Attribute { + return ( + (typeof obj === 'object') + && (typeof obj.belongs === 'string') + && (typeof obj.datatype === 'string') + && (typeof obj.key === 'string') + && (typeof obj.name === 'string') + ); +} + +export type Attributes = Record; + +/** + * Modified markChangesPending for attributes specifically + */ +interface UseAttributesParams { + markChangesPending: ( + { + action, + data, + }: { + action: 'upsert' | 'delete'; + data: Attribute; + } + ) => void; +} + +export default function UseAttributes({ markChangesPending }: UseAttributesParams) { + const attributes: Ref> = ref({}); + + + function loadAttributes(metadataAttributes: Record) { + attributes.value = metadataAttributes; + } + + const attributesList = computed(() => Object.values(attributes.value)); + + function setAttribute({ data, oldAttribute }: + {data: Attribute; oldAttribute?: Attribute }, updateAllTracks = false) { + if (oldAttribute && data.key !== oldAttribute.key) { + // Name change should delete the old attribute and create a new one with the updated id + VueDel(attributes.value, oldAttribute.key); + markChangesPending({ action: 'delete', data: oldAttribute }); + // Create a new attribute to replace it + } + if (updateAllTracks && oldAttribute) { + // TODO: Lengthy track/detection attribute updating function + } + VueSet(attributes.value, data.key, data); + markChangesPending({ action: 'upsert', data: attributes.value[data.key] }); + } + + + function deleteAttribute({ data }: {data: Attribute}, removeFromTracks = false) { + if (attributes.value[data.key] !== undefined) { + markChangesPending({ action: 'delete', data: attributes.value[data.key] }); + VueDel(attributes.value, data.key); + } + if (removeFromTracks) { + // TODO: Lengthty track/detection attribute deletion function + } + } + + return { + loadAttributes, + attributesList, + setAttribute, + deleteAttribute, + }; +} diff --git a/client/src/use/useTrackStore.ts b/client/src/use/useTrackStore.ts index cf9329780..ad6bee60e 100644 --- a/client/src/use/useTrackStore.ts +++ b/client/src/use/useTrackStore.ts @@ -3,7 +3,15 @@ import IntervalTree from '@flatten-js/interval-tree'; import Track, { TrackId } from '../track'; interface UseTrackStoreParams { - markChangesPending: (type: 'upsert' | 'delete', track: Track) => void; + markChangesPending: ( + { + action, + data, + }: + { + action: 'upsert' | 'delete'; + data: Track; + }) => void; } export function getTrack( @@ -64,7 +72,7 @@ export default function useTrackStore({ markChangesPending }: UseTrackStoreParam intervalTree.insert([track.begin, track.end], track.trackId.toString()); } canary.value += 1; - markChangesPending('upsert', track); + markChangesPending({ action: 'upsert', data: track }); } function insertTrack(track: Track, afterId?: TrackId) { @@ -87,7 +95,7 @@ export default function useTrackStore({ markChangesPending }: UseTrackStoreParam confidencePairs: [[defaultType, 1]], }); insertTrack(track, afterId); - markChangesPending('upsert', track); + markChangesPending({ action: 'upsert', data: track }); return track; } @@ -107,7 +115,7 @@ export default function useTrackStore({ markChangesPending }: UseTrackStoreParam throw new Error(`TrackId ${trackId} not found in trackIds.`); } trackIds.value.splice(listIndex, 1); - markChangesPending('delete', track); + markChangesPending({ action: 'delete', data: track }); } /* diff --git a/server/dive_server/serializers/models.py b/server/dive_server/serializers/models.py index f8b7171a4..7b91f3b52 100644 --- a/server/dive_server/serializers/models.py +++ b/server/dive_server/serializers/models.py @@ -1,6 +1,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from pydantic import BaseModel, Field +from typing_extensions import Literal class GeoJSONGeometry(BaseModel): @@ -52,6 +53,14 @@ def exceeds_thresholds(self, thresholds: Dict[str, float]) -> bool: ) +class Attribute(BaseModel): + belongs: Literal['track', 'detection'] + datatype: Literal['text', 'number', 'boolean'] + values: Optional[List[str]] + name: str + key: str + + # interpolate all features [a, b) def interpolate(a: Feature, b: Feature) -> List[Feature]: if a.interpolate is False: diff --git a/server/dive_server/viame.py b/server/dive_server/viame.py index 9cd53b0b4..6064e4c1e 100644 --- a/server/dive_server/viame.py +++ b/server/dive_server/viame.py @@ -17,6 +17,7 @@ from .model.attribute import Attribute from .pipelines import load_pipelines, load_static_pipelines from .serializers import meva as meva_serializer +from .serializers import models from .training import ( csv_detection_file, load_training_configurations, @@ -49,11 +50,8 @@ def __init__(self): self.route("GET", ("training_configs",), self.get_training_configs) self.route("POST", ("train",), self.run_training) self.route("POST", ("postprocess", ":id"), self.postprocess) - self.route("POST", ("attribute",), self.create_attribute) - self.route("GET", ("attribute",), self.get_attributes) - self.route("PUT", ("attribute", ":id"), self.update_attribute) + self.route("PUT", ("attributes",), self.save_attributes) self.route("POST", ("validate_files",), self.validate_files) - self.route("DELETE", ("attribute", ":id"), self.delete_attribute) self.route("GET", ("valid_images",), self.get_valid_images) @access.public @@ -376,37 +374,47 @@ def postprocess(self, folder, skipJobs): return folder - @access.user - @autoDescribeRoute( - Description("").jsonParam("data", "", requireObject=True, paramType="body") - ) - def create_attribute(self, data, params): - attribute = Attribute().create( - data["name"], data["belongs"], data["datatype"], data["values"] - ) - return attribute - - @access.user - @autoDescribeRoute(Description("")) - def get_attributes(self): - return Attribute().find() - @access.user @autoDescribeRoute( Description("") - .modelParam("id", model=Attribute, required=True) - .jsonParam("data", "", requireObject=True, paramType="body") + .modelParam( + "folderId", + description="folder id of a clip", + model=Folder, + paramType="query", + required=True, + level=AccessType.WRITE, + ) + .jsonParam( + "attributes", + "upsert and delete attributes", + paramType="body", + requireObject=True, + ) ) - def update_attribute(self, data, attribute, params): - if "_id" in data: - del data["_id"] - attribute.update(data) - return Attribute().save(attribute) + def save_attributes(self, folder, attributes): + upsert = attributes.get('upsert', []) + delete = attributes.get('delete', []) + attributes_dict = {} + if 'attributes' in folder['meta']: + attributes_dict = folder['meta']['attributes'] + for attribute_id in delete: + attributes_dict.pop(str(attribute_id), None) + for attribute in upsert: + validated: models.Attribute = models.Attribute(**attribute) + attributes_dict[str(validated.key)] = validated.dict(exclude_none=True) + + upserted_len = len(upsert) + deleted_len = len(delete) + + if upserted_len or deleted_len: + folder['meta']['attributes'] = attributes_dict + Folder().save(folder) - @access.user - @autoDescribeRoute(Description("").modelParam("id", model=Attribute, required=True)) - def delete_attribute(self, attribute, params): - return Attribute().remove(attribute) + return { + "updated": upserted_len, + "deleted": deleted_len, + } @access.user @autoDescribeRoute(