diff --git a/cvatjs/.eslintrc.js b/cvatjs/.eslintrc.js index 6989c97e..7a2e5f89 100644 --- a/cvatjs/.eslintrc.js +++ b/cvatjs/.eslintrc.js @@ -49,5 +49,6 @@ "no-useless-constructor": 0, "func-names": [0], "valid-typeof": [0], + "no-console": [0], // this rule deprecates console.log, console.warn etc. because "it is not good in production code" }, }; diff --git a/cvatjs/src/annotations-collection.js b/cvatjs/src/annotations-collection.js index b6088644..4251214c 100644 --- a/cvatjs/src/annotations-collection.js +++ b/cvatjs/src/annotations-collection.js @@ -47,11 +47,15 @@ return labelAccumulator; }, {}); - this.empty(); + this.shapes = {}; // key is frame + this.tags = {}; // key is frame + this.tracks = []; + this.objects = {}; // key is client id + this.count = 0; + this.flush = false; } import(data) { - this.empty(); const injection = { labels: this.labels, }; @@ -142,16 +146,21 @@ this.objects[clientID] = trackModel; } } + + return this; } export() { const data = { - tracks: Object.values(this.tracks).reduce((accumulator, value) => { + tracks: this.tracks.map(track => track.toJSON()), + shapes: Object.values(this.shapes).reduce((accumulator, value) => { accumulator.push(...value); return accumulator; - }, []).map(track => track.toJSON()), - shapes: this.shapes.map(shape => shape.toJSON()), - tags: this.shapes.map(tag => tag.toJSON()), + }, []).map(shape => shape.toJSON()), + tags: Object.values(this.tags).reduce((accumulator, value) => { + accumulator.push(...value); + return accumulator; + }, []).map(tag => tag.toJSON()), }; return data; @@ -163,6 +172,8 @@ this.tracks = []; this.objects = {}; // by id this.count = 0; + + this.flush = true; } get(frame) { diff --git a/cvatjs/src/annotations-objects.js b/cvatjs/src/annotations-objects.js index 6f334a68..e145fb73 100644 --- a/cvatjs/src/annotations-objects.js +++ b/cvatjs/src/annotations-objects.js @@ -105,6 +105,7 @@ // Method is used to export data to the server toJSON() { return { + clientID: this.clientID, occluded: this.occluded, z_order: this.zOrder, points: [...this.points], @@ -264,9 +265,11 @@ // Method is used to export data to the server toJSON() { return { - occluded: this.occluded, - z_order: this.zOrder, - points: [...this.points], + clientID: this.clientID, + id: this.serverID, + frame: this.frame, + label_id: this.label.id, + group: this.group, attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { attributeAccumulator.push({ spec_id: attrId, @@ -275,19 +278,14 @@ return attributeAccumulator; }, []), - - id: this.serverID, - frame: this.frame, - label_id: this.label.id, - group: this.group, shapes: Object.keys(this.shapes).reduce((shapesAccumulator, frame) => { shapesAccumulator.push({ - type: this.type, + type: this.shape, occluded: this.shapes[frame].occluded, z_order: this.shapes[frame].zOrder, points: [...this.shapes[frame].points], - outside: [...this.shapes[frame].outside], - attributes: Object.keys(...this.shapes[frame].attributes) + outside: this.shapes[frame].outside, + attributes: Object.keys(this.shapes[frame].attributes) .reduce((attributeAccumulator, attrId) => { attributeAccumulator.push({ spec_id: attrId, @@ -607,6 +605,7 @@ // Method is used to export data to the server toJSON() { return { + clientID: this.clientID, id: this.serverID, frame: this.frame, label_id: this.label.id, diff --git a/cvatjs/src/annotations-saver.js b/cvatjs/src/annotations-saver.js new file mode 100644 index 00000000..e8db605f --- /dev/null +++ b/cvatjs/src/annotations-saver.js @@ -0,0 +1,264 @@ +/* +* Copyright (C) 2018 Intel Corporation +* SPDX-License-Identifier: MIT +*/ + +/* global + require:false +*/ + +(() => { + const serverProxy = require('./server-proxy'); + + class AnnotationsSaver { + constructor(version, collection, session) { + this.session = session.constructor.name.toLowerCase(); + this.id = session.id; + this.version = version; + this.collection = collection; + this.initialObjects = []; + this.hash = this._getHash(); + + // We need use data from export instead of initialData + // Otherwise we have differ keys order and JSON comparison code incorrect + const exported = this.collection.export(); + for (const shape of exported.shapes) { + this.initialObjects[shape.id] = shape; + } + + for (const track of exported.tracks) { + this.initialObjects[track.id] = track; + } + + for (const tag of exported.tags) { + this.initialObjects[tag.id] = tag; + } + } + + _getHash() { + const exported = this.collection.export(); + return JSON.stringify(exported); + } + + async _request(data, action) { + const result = await serverProxy.annotations.updateAnnotations( + this.session, + this.id, + data, + action, + ); + + return result; + } + + async _put(data) { + const result = await this._request(data, 'put'); + return result; + } + + async _create(created) { + const result = await this._request(created, 'create'); + return result; + } + + async _update(updated) { + const result = await this._request(updated, 'update'); + return result; + } + + async _delete(deleted) { + const result = await this._request(deleted, 'delete'); + return result; + } + + _split(exported) { + const splitted = { + created: { + shapes: [], + tracks: [], + tags: [], + }, + updated: { + shapes: [], + tracks: [], + tags: [], + }, + deleted: { + shapes: [], + tracks: [], + tags: [], + }, + }; + + // Find created and updated objects + for (const type of Object.keys(exported)) { + for (const object of exported[type]) { + if (object.id in this.initialObjects) { + const exportedHash = JSON.stringify(object); + const initialHash = JSON.stringify(this.initialObjects[object.id]); + if (exportedHash !== initialHash) { + splitted.updated[type].push(object); + } + } else if (typeof (object.id) === 'undefined') { + splitted.created[type].push(object); + } else { + throw window.cvat.exceptions.ScriptingError( + `Id of object is defined "${object.id}"` + + 'but it absents in initial state', + ); + } + } + } + + // Now find deleted objects + const indexes = exported.tracks.concat(exported.shapes) + .concat(exported.tags).map(object => object.id); + + for (const id of Object.keys(this.initialObjects)) { + if (!indexes.includes(+id)) { + const object = this.initialObjects[id]; + let type = null; + if ('shapes' in object) { + type = 'tracks'; + } else if ('points' in object) { + type = 'shapes'; + } else { + type = 'tags'; + } + splitted.deleted[type].push(object); + } + } + + return splitted; + } + + _updateCreatedObjects(saved, indexes) { + const savedLength = saved.tracks.length + + saved.shapes.length + saved.tags.length; + + const indexesLength = indexes.tracks.length + + indexes.shapes.length + indexes.tags.length; + + if (indexesLength !== savedLength) { + throw window.cvat.exception.ScriptingError( + 'Number of indexes is differed by number of saved objects' + + `${indexesLength} vs ${savedLength}`, + ); + } + + // Updated IDs of created objects + for (const type of Object.keys(indexes)) { + for (let i = 0; i < indexes[type].length; i++) { + const clientID = indexes[type][i]; + this.collection.objects[clientID].serverID = saved[type][i].id; + } + } + } + + _receiveIndexes(exported) { + // Receive client indexes before saving + const indexes = { + tracks: exported.tracks.map(track => track.clientID), + shapes: exported.shapes.map(shape => shape.clientID), + tags: exported.tags.map(tag => tag.clientID), + }; + + // Remove them from the request body + exported.tracks.concat(exported.shapes).concat(exported.tags) + .map((value) => { + delete value.clientID; + return value; + }); + + return indexes; + } + + async save(onUpdate) { + if (typeof onUpdate !== 'function') { + onUpdate = (message) => { + console.log(message); + }; + } + + try { + const exported = this.collection.export(); + const { flush } = this.collection; + if (flush) { + onUpdate('New objects are being saved..'); + const indexes = this._receiveIndexes(exported); + const savedData = await this._put(Object.assign({}, exported, { + version: this.version, + })); + this.version = savedData.version; + this.collection.flush = false; + + onUpdate('Saved objects are being updated in the client'); + this._updateCreatedObjects(savedData, indexes); + + onUpdate('Initial state is being updated'); + for (const object of savedData.shapes + .concat(savedData.tracks).concat(savedData.tags)) { + this.initialObjects[object.id] = object; + } + } else { + const { + created, + updated, + deleted, + } = this._split(exported); + + onUpdate('New objects are being saved..'); + const indexes = this._receiveIndexes(created); + const createdData = await this._create(Object.assign({}, created, { + version: this.version, + })); + this.version = createdData.version; + + onUpdate('Saved objects are being updated in the client'); + this._updateCreatedObjects(createdData, indexes); + + onUpdate('Initial state is being updated'); + for (const object of createdData.shapes + .concat(createdData.tracks).concat(createdData.tags)) { + this.initialObjects[object.id] = object; + } + + onUpdate('Changed objects are being saved..'); + this._receiveIndexes(updated); + const updatedData = await this._update(Object.assign({}, updated, { + version: this.version, + })); + this.version = createdData.version; + + onUpdate('Initial state is being updated'); + for (const object of updatedData.shapes + .concat(updatedData.tracks).concat(updatedData.tags)) { + this.initialObjects[object.id] = object; + } + + onUpdate('Changed objects are being saved..'); + this._receiveIndexes(deleted); + const deletedData = await this._delete(Object.assign({}, deleted, { + version: this.version, + })); + this._version = deletedData.version; + + onUpdate('Initial state is being updated'); + for (const object of deletedData.shapes + .concat(deletedData.tracks).concat(deletedData.tags)) { + delete this.initialObjects[object.id]; + } + } + } catch (error) { + onUpdate(`Can not save annotations: ${error.message}`); + throw error; + } + } + + hasUnsavedChanges() { + return this._getHash() !== this._hash; + } + } + + module.exports = AnnotationsSaver; +})(); diff --git a/cvatjs/src/annotations.js b/cvatjs/src/annotations.js index 627072e4..181eabdf 100644 --- a/cvatjs/src/annotations.js +++ b/cvatjs/src/annotations.js @@ -10,6 +10,7 @@ (() => { const serverProxy = require('./server-proxy'); const Collection = require('./annotations-collection'); + const AnnotationsSaver = require('./annotations-saver'); const jobCache = {}; const taskCache = {}; @@ -17,25 +18,53 @@ async function getJobAnnotations(job, frame, filter) { if (!(job.id in jobCache)) { const rawAnnotations = await serverProxy.annotations.getJobAnnotations(job.id); - jobCache[job.id] = new Collection(job.task.labels); - jobCache[job.id].import(rawAnnotations); + const collection = new Collection(job.task.labels).import(rawAnnotations); + const saver = new AnnotationsSaver(rawAnnotations.version, collection, job); + + jobCache[job.id] = { + collection, + saver, + }; } - return jobCache[job.id].get(frame, filter); + return jobCache[job.id].collection.get(frame, filter); } async function getTaskAnnotations(task, frame, filter) { if (!(task.id in jobCache)) { const rawAnnotations = await serverProxy.annotations.getTaskAnnotations(task.id); - taskCache[task.id] = new Collection(task.labels); - taskCache[task.id].import(rawAnnotations); + const collection = new Collection(task.labels).import(rawAnnotations); + const saver = new AnnotationsSaver(rawAnnotations.version, collection, task); + + taskCache[task.id] = { + collection, + saver, + }; + } + + return taskCache[task.id].collection.get(frame, filter); + } + + async function saveJobAnnotations(job, onUpdate) { + if (job.id in jobCache) { + await jobCache[job.id].saver.save(onUpdate); + } + + // If a collection wasn't uploaded, than it wasn't changed, finally we shouldn't save it + } + + async function saveTaskAnnotations(task, onUpdate) { + if (task.id in taskCache) { + await taskCache[task.id].saver.save(onUpdate); } - return taskCache[task.id].get(frame, filter); + // If a collection wasn't uploaded, than it wasn't changed, finally we shouldn't save it } module.exports = { getJobAnnotations, getTaskAnnotations, + saveJobAnnotations, + saveTaskAnnotations, }; })(); diff --git a/cvatjs/src/server-proxy.js b/cvatjs/src/server-proxy.js index df983621..b74aa1ee 100644 --- a/cvatjs/src/server-proxy.js +++ b/cvatjs/src/server-proxy.js @@ -498,6 +498,38 @@ return response.data; } + // Session is 'task' or 'job' + async function updateAnnotations(session, id, data, action) { + const { backendAPI } = window.cvat.config; + let requestFunc = null; + let url = null; + if (action.toUpperCase() === 'PUT') { + requestFunc = Axios.put.bind(Axios); + url = `${backendAPI}/${session}s/${id}/annotations`; + } else { + requestFunc = Axios.patch.bind(Axios); + url = `${backendAPI}/${session}s/${id}/annotations?action=${action}`; + } + + let response = null; + try { + response = await requestFunc(url, JSON.stringify(data), { + proxy: window.cvat.config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + const code = errorData.response ? errorData.response.status : errorData.code; + throw new window.cvat.exceptions.ServerError( + `Could not updated annotations for the ${session} ${id} on the server`, + code, + ); + } + + return response.data; + } + // Set csrftoken header from browser cookies if it exists // NodeJS env returns 'undefined' // So in NodeJS we need login after each run @@ -556,6 +588,7 @@ value: Object.freeze({ getTaskAnnotations, getJobAnnotations, + updateAnnotations, }), writable: false, }, diff --git a/cvatjs/src/session.js b/cvatjs/src/session.js index 252f39d0..c4003634 100644 --- a/cvatjs/src/session.js +++ b/cvatjs/src/session.js @@ -14,6 +14,8 @@ const { getJobAnnotations, getTaskAnnotations, + saveJobAnnotations, + saveTaskAnnotations, } = require('./annotations'); function buildDublicatedAPI() { @@ -93,9 +95,9 @@ .apiWrapper.call(this, logs.value.put, logType, details); return result; }, - async save() { + async save(onUpdate) { const result = await PluginRegistry - .apiWrapper.call(this, logs.value.save); + .apiWrapper.call(this, logs.value.save, onUpdate); return result; }, }, @@ -169,13 +171,16 @@ * @throws {module:API.cvat.exceptions.ArgumentError} */ /** - * Save annotation changes on a server + * Save all changes in annotations on a server * @method save * @memberof Session.annotations * @throws {module:API.cvat.exceptions.PluginError} * @throws {module:API.cvat.exceptions.ServerError} * @instance * @async + * @param {function} [onUpdate] saving can be long. + * This callback can be used to notify a user about current progress + * Its argument is a text string */ /** * Remove all annotations from a session @@ -508,6 +513,7 @@ this.frames.get.implementation = this.frames.get.implementation.bind(this); this.annotations.get.implementation = this.annotations.get.implementation.bind(this); + this.annotations.save.implementation = this.annotations.save.implementation.bind(this); } /** @@ -576,6 +582,10 @@ return annotationsData; }; + Job.prototype.annotations.save.implementation = async function (onUpdate) { + await saveJobAnnotations(this, onUpdate); + }; + /** * Class representing a task * @memberof module:API.cvat.classes @@ -979,6 +989,7 @@ this.frames.get.implementation = this.frames.get.implementation.bind(this); this.annotations.get.implementation = this.annotations.get.implementation.bind(this); + this.annotations.save.implementation = this.annotations.save.implementation.bind(this); } /** @@ -1097,6 +1108,10 @@ return annotationsData; }; + Task.prototype.annotations.save.implementation = async function (onUpdate) { + await saveTaskAnnotations(this, onUpdate); + }; + module.exports = { Job, Task,