Skip to content

Commit

Permalink
Particular support of annotation in client (#352)
Browse files Browse the repository at this point in the history
* Adopted build annotation UI for rest API
* Shapes could be imported from server
* Export of shapes
* Import/export
* Fixed id in undo/redo
* Initialized annotation server
* Annotation saver fixed
* Fixed incremental saving
* Created objects are appended into collection after saving
* Added objecthash
* Some delay after latest save
* Removed extra code
* Simplified condition
  • Loading branch information
bsekachev authored and nmanovic committed Mar 18, 2019
1 parent cd7a409 commit 5ea78d8
Show file tree
Hide file tree
Showing 18 changed files with 635 additions and 632 deletions.
5 changes: 2 additions & 3 deletions cvat/apps/dashboard/static/dashboard/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
/* global
AnnotationParser:false
userConfirm:false
ConstIdGenerator:false
createExportContainer:false
dumpAnnotationRequest: false
dumpAnnotation:false
Expand Down Expand Up @@ -93,7 +92,7 @@ class TaskView {
stop: this._size,
flipped: this._flipped,
image_meta_data: imageMetaCache,
}, new LabelsInfo({labels: {}, attributes: {}}).restConstructor(labelsCopy), new ConstIdGenerator(-1));
}, new LabelsInfo(labelsCopy));

function asyncParse() {
let parsed = null;
Expand All @@ -114,7 +113,7 @@ class TaskView {
success: function() {
asyncSaveChunk(0);
},
error: function(response) {
error: function(errorData) {
const message = `Could not remove current annotation. Code: ${errorData.status}. ` +
`Message: ${errorData.responseText || errorData.statusText}`;
showMessage(message);
Expand Down
2 changes: 2 additions & 0 deletions cvat/apps/engine/static/engine/js/3rdparty/object_hash.js

Large diffs are not rendered by default.

6 changes: 1 addition & 5 deletions cvat/apps/engine/static/engine/js/annotationParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,13 @@
"use strict";

class AnnotationParser {
constructor(job, labelsInfo, idGenerator) {
constructor(job, labelsInfo) {
this._parser = new DOMParser();
this._startFrame = job.start;
this._stopFrame = job.stop;
this._flipped = job.flipped;
this._im_meta = job.image_meta_data;
this._labelsInfo = labelsInfo;
this._idGen = idGenerator;
}

_xmlParseError(parsedXML) {
Expand Down Expand Up @@ -230,7 +229,6 @@ class AnnotationParser {
ybr: ybr,
z_order: z_order,
attributes: attributeList,
id: this._idGen.next(),
});
}
else {
Expand All @@ -243,7 +241,6 @@ class AnnotationParser {
occluded: occluded,
z_order: z_order,
attributes: attributeList,
id: this._idGen.next(),
});
}
}
Expand Down Expand Up @@ -316,7 +313,6 @@ class AnnotationParser {
frame: +parsed[type][0].getAttribute('frame'),
attributes: [],
shapes: [],
id: this._idGen.next(),
};

for (let shape of parsed[type]) {
Expand Down
312 changes: 312 additions & 0 deletions cvat/apps/engine/static/engine/js/annotationSaver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
/* exported buildAnnotationSaver */

/* global
showOverlay:false
showMessage:false
*/

class AnnotationSaverModel extends Listener {
constructor(initialData, shapeCollection) {
super('onAnnotationSaverUpdate', () => this._state);

this._state = {
status: null,
message: null,
};

this._version = initialData.version;
this._shapeCollection = shapeCollection;
this._initialObjects = [];
this._hash = objectHash(shapeCollection.export());

for (let shape of initialData.shapes) {
this._initialObjects[shape.id] = shape;
}

for (let track of initialData.tracks) {
this._initialObjects[track.id] = track;
}
}

async _request(data, action) {
return new Promise((resolve, reject) => {
$.ajax({
url: `/api/v1/jobs/${window.cvat.job.id}/annotations?action=${action}`,
type: 'PATCH',
data: JSON.stringify(data),
contentType: 'application/json',
}).done((data) => {
resolve(data);
}).fail((errorData) => {
const message = `Could not make ${action} annotation. Code: ${errorData.status}. ` +
`Message: ${errorData.responseText || errorData.statusText}`;
reject(new Error(message));
});
});
}

async _create(created) {
return this._request(created, 'create');
}

async _update(updated) {
return this._request(updated, 'update');
}

async _delete(deleted) {
return this._request(deleted, 'delete');
}

async _logs() {
Logger.addEvent(Logger.EventType.saveJob);
const totalStat = this._shapeCollection.collectStatistic()[1];
Logger.addEvent(Logger.EventType.sendTaskInfo, {
'track count': totalStat.boxes.annotation + totalStat.boxes.interpolation +
totalStat.polygons.annotation + totalStat.polygons.interpolation +
totalStat.polylines.annotation + totalStat.polylines.interpolation +
totalStat.points.annotation + totalStat.points.interpolation,
'frame count': window.cvat.player.frames.stop - window.cvat.player.frames.start + 1,
'object count': totalStat.total,
'box count': totalStat.boxes.annotation + totalStat.boxes.interpolation,
'polygon count': totalStat.polygons.annotation + totalStat.polygons.interpolation,
'polyline count': totalStat.polylines.annotation + totalStat.polylines.interpolation,
'points count': totalStat.points.annotation + totalStat.points.interpolation,
});

const annotationLogs = Logger.getLogs();

// TODO: Save logs
}

_split(exported) {
const exportedIDs = Array.from(exported.shapes, (shape) => +shape.id)
.concat(Array.from(exported.tracks, (track) => +track.id));

const created = {
version: this._version,
shapes: [],
tracks: [],
tags: [],
}

const updated = {
version: this._version + 1,
shapes: [],
tracks: [],
tags: [],
}

const deleted = {
version: this._version + 2,
shapes: [],
tracks: [],
tags: [],
}

// Compare initial state objects and export state objects
// in order to get updated and created objects
for (let obj of exported.shapes.concat(exported.tracks)) {
if (obj.id in this._initialObjects) {
const exportedHash = objectHash(obj);
const initialSash = objectHash(this._initialObjects[obj.id]);
if (exportedHash != initialSash) {
const target = 'shapes' in obj ? updated.tracks : updated.shapes;
target.push(obj);
}
} else if (typeof(obj.id) === 'undefined') {
const target = 'shapes' in obj ? created.tracks : created.shapes;
target.push(obj);
} else {
throw Error(`Bad object ID found: ${obj.id}. `
+ 'It is not contained in initial state and have server ID');
}
}

// Compare initial state indexes and export state indexes
// in order to get removed objects
for (let shapeID in this._initialObjects) {
if (!exportedIDs.includes(+shapeID)) {
const initialShape = this._initialObjects[shapeID];
const target = 'shapes' in initialShape ? deleted.tracks : deleted.shapes;
target.push(initialShape);
}
}

return [created, updated, deleted];
}

notify(status, message = null) {
this._state.status = status;
this._state.message = message;
Listener.prototype.notify.call(this);
}

hasUnsavedChanges() {
return objectHash(this._shapeCollection.export()) != this._hash;
}

async save() {
this.notify('saveStart');
try {
const exported = this._shapeCollection.export();
const [created, updated, deleted] = this._split(exported);

this.notify('saveCreated');
const savedCreatedObjects = await this._create(created);
this._shapeCollection.cleanupClientObjects();
this._shapeCollection.import(savedCreatedObjects).update();
for (let object of savedCreatedObjects.shapes.concat(savedCreatedObjects.tracks)) {
this._initialObjects[object.id] = object;
}

this.notify('saveUpdated');
const savedUpdatedObjects = await this._update(updated);
for (let object of savedUpdatedObjects.shapes.concat(savedUpdatedObjects.tracks)) {
if (object.id in this._initialObjects) {
this._initialObjects[object.id] = object;
}
}

this.notify('saveDeleted');
const savedDeletedObjects = await this._delete(deleted);
for (let object of savedDeletedObjects.shapes.concat(savedDeletedObjects.tracks)) {
if (object.id in this._initialObjects) {
delete this._initialObjects[object.id];
}
}

this._version = savedDeletedObjects.version;
this._hash = objectHash(this._shapeCollection.export());
this.notify('saveDone');
} catch (error) {
this.notify('saveUnlocked');
this.notify('saveError', error);
this._state = {
status: null,
message: null,
}
throw Error(error);
}

setTimeout(() => {
this.notify('saveUnlocked');
this._state = {
status: null,
message: null,
}
}, 15000);
}

get state() {
return JSON.parse(JSON.stringify(this._state));
}
}


class AnnotationSaverController {
constructor(model) {
this._model = model;
this._autoSaveInterval = null;

const shortkeys = window.cvat.config.shortkeys;
Mousetrap.bind(shortkeys["save_work"].value, () => {
this.save();
return false;
}, 'keydown');
}

autoSave(enabled, time) {
if (this._autoSaveInterval) {
clearInterval(this._autoSaveInterval);
this._autoSaveInterval = null;
}

if (enabled) {
this._autoSaveInterval = setInterval(() => {
this.save();
}, time * 1000 * 60);
}
}

hasUnsavedChanges() {
return this._model.hasUnsavedChanges();
}

save() {
if (this._model.state.status === null) {
this._model.save();
}
}
}


class AnnotationSaverView {
constructor(model, controller) {
model.subscribe(this);

this._controller = controller;
this._overlay = null;

const shortkeys = window.cvat.config.shortkeys;
const saveHelp = `${shortkeys['save_work'].view_value} - ${shortkeys['save_work'].description}`;

this._saveButton = $('#saveButton').on('click', () => {
this._controller.save();
}).attr('title', saveHelp);

this._autoSaveBox = $('#autoSaveBox').on('change', (e) => {
const enabled = e.target.checked;
const time = +this._autoSaveTime.prop('value');
this._controller.autoSave(enabled, time);
});

this._autoSaveTime = $('#autoSaveTime').on('change', (e) => {
e.target.value = Math.clamp(+e.target.value, +e.target.min, +e.target.max);
this._autoSaveBox.trigger('change');
});

window.onbeforeunload = (e) => {
if (this._controller.hasUnsavedChanges()) {
let message = "You have unsaved changes. Leave this page?";
e.returnValue = message;
return message;
}
return;
};
}

onAnnotationSaverUpdate(state) {
if (state.status === 'saveStart') {
this._overlay = showOverlay('Annotations are being saved..');
this._saveButton.prop('disabled', true).text('Saving..');
} else if (state.status === 'saveDone') {
this._saveButton.text('Successful save');
this._overlay.remove();
} else if (state.status === 'saveError') {
this._saveButton.prop('disabled', false).text('Save Work');
const message = `Couldn't to save the job. Errors occured: ${state.message}. `
+ 'Please report the problem to support team immediately.';
showMessage(message);
this._overlay.remove();
} else if (state.status === 'saveCreated') {
this._overlay.setMessage(`${this._overlay.getMessage()}` + '<br /> - Created objects are being saved..');
} else if (state.status === 'saveUpdated') {
this._overlay.setMessage(`${this._overlay.getMessage()}` + '<br /> - Updated objects are being saved..');
} else if (state.status === 'saveDeleted') {
this._overlay.setMessage(`${this._overlay.getMessage()}` + '<br /> - Deleted objects are being saved..');
} else if (state.status === 'saveUnlocked') {
this._saveButton.prop('disabled', false).text('Save Work');
} else {
const message = `Unknown state has been reached during annotation saving: ${state.status} `
+ 'Please report the problem to support team immediately.';
showMessage(message);
}
}
}


function buildAnnotationSaver(initialData, shapeCollection) {
const model = new AnnotationSaverModel(initialData, shapeCollection);
const controller = new AnnotationSaverController(model);
new AnnotationSaverView(model, controller);
}
Loading

0 comments on commit 5ea78d8

Please sign in to comment.