diff --git a/cvat/apps/engine/static/engine/js/annotationUI.js b/cvat/apps/engine/static/engine/js/annotationUI.js index 3bd09d35..f0c5fab4 100644 --- a/cvat/apps/engine/static/engine/js/annotationUI.js +++ b/cvat/apps/engine/static/engine/js/annotationUI.js @@ -85,6 +85,13 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { let shapeCreatorController = new ShapeCreatorController(shapeCreatorModel); let shapeCreatorView = new ShapeCreatorView(shapeCreatorModel, shapeCreatorController); + let polyshapeEditorModel = new PolyshapeEditorModel(); + let polyshapeEditorController = new PolyshapeEditorController(polyshapeEditorModel); + let polyshapeEditorView = new PolyshapeEditorView(polyshapeEditorModel, polyshapeEditorController); + + // Add static member for class. It will be used by all polyshapes. + PolyShapeView.editor = polyshapeEditorModel; + let shapeMergerModel = new ShapeMergerModel(shapeCollectionModel); let shapeMergerController = new ShapeMergerController(shapeMergerModel); new ShapeMergerView(shapeMergerModel, shapeMergerController); @@ -129,6 +136,7 @@ function buildAnnotationUI(job, shapeData, loadJobEvent) { playerModel.subscribe(shapeCreatorView); playerModel.subscribe(shapeBufferView); playerModel.subscribe(shapeGrouperView); + playerModel.subscribe(polyshapeEditorView); playerModel.shift(0); let shortkeys = window.cvat.config.shortkeys; @@ -459,13 +467,15 @@ function setupMenu(job, shapeCollectionModel, annotationParser, aamModel, player }); $('#removeAnnotationButton').on('click', () => { - hide(); - confirm('Do you want to remove all annotations? The action cannot be undone!', - () => { - historyModel.empty(); - shapeCollectionModel.empty(); - } - ); + if (!window.cvat.mode) { + hide(); + confirm('Do you want to remove all annotations? The action cannot be undone!', + () => { + historyModel.empty(); + shapeCollectionModel.empty(); + } + ); + } }); $('#saveButton').on('click', () => { diff --git a/cvat/apps/engine/static/engine/js/polyshapeEditor.js b/cvat/apps/engine/static/engine/js/polyshapeEditor.js new file mode 100644 index 00000000..2f5c61fb --- /dev/null +++ b/cvat/apps/engine/static/engine/js/polyshapeEditor.js @@ -0,0 +1,395 @@ +/* + * Copyright (C) 2018 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/* exported PolyshapeEditorModel PolyshapeEditorController PolyshapeEditorView */ + +"use strict"; + +class PolyshapeEditorModel extends Listener { + constructor() { + super("onPolyshapeEditorUpdate", () => this); + + this._modeName = 'poly_editing'; + this._active = false; + this._data = { + points: null, + color: null, + start: null, + oncomplete: null, + type: null, + event: null, + }; + } + + edit(type, points, color, start, event, oncomplete) { + if (!this._active && !window.cvat.mode) { + window.cvat.mode = this._modeName; + this._active = true; + this._data.points = points; + this._data.color = color; + this._data.start = start; + this._data.oncomplete = oncomplete; + this._data.type = type; + this._data.event = event; + this.notify(); + } + else if (this._active) { + throw Error('Polyshape has been being edited already'); + } + } + + finish(points) { + if (this._active && this._data.oncomplete) { + this._data.oncomplete(points); + } + + this.cancel(); + } + + cancel() { + if (this._active) { + this._active = false; + if (window.cvat.mode === this._modeName) { + window.cvat.mode = null; + } + + this._data.points = null; + this._data.color = null; + this._data.start = null; + this._data.oncomplete = null; + this._data.type = null; + this._data.event = null; + this.notify(); + } + } + + get active() { + return this._active; + } + + get data() { + return this._data; + } +} + + +class PolyshapeEditorController { + constructor(model) { + this._model = model; + } + + finish(points) { + this._model.finish(points); + } + + cancel() { + this._model.cancel(); + } +} + + +class PolyshapeEditorView { + constructor(model, controller) { + this._controller = controller; + this._data = null; + + this._frameContent = SVG.adopt($('#frameContent')[0]); + this._originalShapePointsGroup = null; + this._originalShapePoints = []; + this._originalShape = null; + this._correctLine = null; + + this._scale = window.cvat.player.geometry.scale; + this._frame = window.cvat.player.frames.current; + + model.subscribe(this); + } + + _rescaleDrawPoints() { + let scale = this._scale; + $('.svg_draw_point').each(function() { + this.instance.radius(POINT_RADIUS / (2 * scale)).attr('stroke-width', STROKE_WIDTH / (2 * scale)); + }); + } + + // After this method start element will be in begin of the array. + // Array will consist only range elements from start to stop + _resortPoints(points, start, stop) { + let sorted = []; + + if (points.indexOf(start) === -1 || points.indexOf(stop) === -1) { + throw Error('Point array must consist both start and stop elements'); + } + + let idx = points.indexOf(start) + 1; + let condition = true; // constant condition is eslint error + while (condition) { + if (idx >= points.length) idx = 0; + if (points[idx] === stop) condition = false; + else sorted.push(points[idx++]); + } + + return sorted; + } + + // Method represents array like circle list and find shortest way from source to target + // It returns integer number - distance from source to target. + // It can be negative if shortest way is anti clockwise + _findMinCircleDistance(array, source, target) { + let clockwise_distance = 0; + let anti_clockwise_distance = 0; + + let source_idx = array.indexOf(source); + let target_idx = array.indexOf(target); + + if (source_idx === -1 || target_idx == -1) { + throw Error('Array should consist both elements'); + } + + let idx = source_idx; + while (array[idx++] != target) { + clockwise_distance ++; + if (idx >= array.length) idx = 0; + } + + idx = source_idx; + while (array[idx--] != target) { + anti_clockwise_distance ++; + if (idx < 0) idx = array.length - 1; + } + + let offset = Math.min(clockwise_distance, anti_clockwise_distance); + if (anti_clockwise_distance < clockwise_distance) { + offset = -offset; + } + + return offset; + } + + _startEdit() { + this._frame = window.cvat.player.frames.current; + let strokeWidth = this._data.type === 'points' ? 0 : STROKE_WIDTH / this._scale; + + // Draw copy of original shape + if (this._data.type === 'polygon') { + this._originalShape = this._frameContent.polygon(this._data.points); + } + else { + this._originalShape = this._frameContent.polyline(this._data.points); + } + + this._originalShape.attr({ + 'stroke-width': strokeWidth, + 'stroke': 'white', + 'fill': 'none', + }); + + // Create the correct line + this._correctLine = this._frameContent.polyline().draw({snapToGrid: 0.1}).attr({ + 'stroke-width': strokeWidth / 2, + 'fill': 'none', + 'stroke': 'red', + }).on('mouseover', () => false); + + + // Add points to original shape + let pointRadius = POINT_RADIUS / this._scale; + this._originalShapePointsGroup = this._frameContent.group(); + for (let point of PolyShapeModel.convertStringToNumberArray(this._data.points)) { + let uiPoint = this._originalShapePointsGroup.circle(pointRadius * 2) + .move(point.x - pointRadius, point.y - pointRadius) + .attr({ + 'stroke-width': strokeWidth, + 'stroke': 'black', + 'fill': 'white', + 'z_order': Number.MAX_SAFE_INTEGER, + }); + this._originalShapePoints.push(uiPoint); + } + + + let prevPoint = { + x: this._data.event.clientX, + y: this._data.event.clientY + }; + + this._correctLine.draw('point', this._data.event); + this._rescaleDrawPoints(); + this._frameContent.on('mousemove.polyshapeEditor', (e) => { + if (e.shiftKey && this._data.type != 'points') { + let delta = Math.sqrt(Math.pow(e.clientX - prevPoint.x, 2) + Math.pow(e.clientY - prevPoint.y, 2)); + let deltaTreshold = 15; + if (delta > deltaTreshold) { + this._correctLine.draw('point', e); + prevPoint = { + x: e.clientX, + y: e.clientY + }; + } + } + }); + + this._frameContent.on('contextmenu.polyshapeEditor', (e) => { + if (PolyShapeModel.convertStringToNumberArray(this._correctLine.attr('points')).length > 2) { + this._correctLine.draw('undo'); + } + else { + // Finish without points argument is just cancel + this._controller.finish(); + } + e.preventDefault(); + e.stopPropagation(); + }); + + this._correctLine.on('drawpoint', (e) => { + prevPoint = { + x: e.detail.event.clientX, + y: e.detail.event.clientY + }; + this._rescaleDrawPoints(); + }); + + this._correctLine.on('drawstart', () => this._rescaleDrawPoints()); + + + for (let instance of this._originalShapePoints) { + instance.on('mouseover', () => { + instance.attr('stroke-width', STROKE_WIDTH * 2 / this._scale); + }).on('mouseout', () => { + instance.attr('stroke-width', STROKE_WIDTH / this._scale); + }).on('mousedown', (e) => { + if (e.which != 1) return; + let currentPoints = PolyShapeModel.convertStringToNumberArray(this._data.points); + let correctPoints = PolyShapeModel.convertStringToNumberArray(this._correctLine.attr('points')); + let resultPoints = []; + + if (this._data.type === 'polygon') { + let startPtIdx = this._data.start; + let stopPtIdx = $(instance.node).index(); + let offset = this._findMinCircleDistance(currentPoints, currentPoints[startPtIdx], currentPoints[stopPtIdx]); + + if (!offset) { + currentPoints = this._resortPoints(currentPoints, currentPoints[startPtIdx], currentPoints[stopPtIdx]); + resultPoints.push(...correctPoints.slice(0, -2)); + resultPoints.push(...currentPoints); + } + else { + resultPoints.push(...correctPoints); + if (offset < 0) { + resultPoints = resultPoints.reverse(); + currentPoints = this._resortPoints(currentPoints, currentPoints[startPtIdx], currentPoints[stopPtIdx]); + } + else { + currentPoints = this._resortPoints(currentPoints, currentPoints[stopPtIdx], currentPoints[startPtIdx]); + } + + resultPoints.push(...currentPoints); + } + } + else if (this._data.type === 'polyline') { + let startPtIdx = this._data.start; + let stopPtIdx = $(instance.node).index(); + + if (startPtIdx === stopPtIdx) { + resultPoints.push(...correctPoints.slice(1, -1).reverse()); + resultPoints.push(...currentPoints); + } + else { + if (startPtIdx > stopPtIdx) { + if (startPtIdx < currentPoints.length - 1) { + resultPoints.push(...currentPoints.slice(startPtIdx + 1).reverse()); + } + resultPoints.push(...correctPoints.slice(0, -1)); + if (stopPtIdx > 0) { + resultPoints.push(...currentPoints.slice(0, stopPtIdx).reverse()); + } + } + else { + if (startPtIdx > 0) { + resultPoints.push(...currentPoints.slice(0, startPtIdx)); + } + resultPoints.push(...correctPoints.slice(0, -1)); + if (stopPtIdx < currentPoints.length) { + resultPoints.push(...currentPoints.slice(stopPtIdx + 1)); + } + } + } + } + else { + resultPoints.push(...currentPoints); + resultPoints.push(...correctPoints.slice(1, -1).reverse()); + } + + this._correctLine.draw('cancel'); + this._controller.finish(PolyShapeModel.convertNumberArrayToString(resultPoints)); + }); + } + } + + _endEdit() { + for (let uiPoint of this._originalShapePoints) { + uiPoint.off(); + uiPoint.remove(); + } + + this._originalShapePoints = []; + this._originalShapePointsGroup.remove(); + this._originalShapePointsGroup = null; + this._originalShape.remove(); + this._originalShape = null; + this._correctLine.off('drawstart'); + this._correctLine.off('drawpoint'); + this._correctLine.draw('cancel'); + this._correctLine.remove(); + this._correctLine = null; + this._data = null; + + this._frameContent.off('mousemove.polyshapeEditor'); + this._frameContent.off('mousedown.polyshapeEditor'); + this._frameContent.off('contextmenu.polyshapeEditor'); + } + + + onPolyshapeEditorUpdate(model) { + if (model.active && !this._data) { + this._data = model.data; + this._startEdit(); + } + else if (!model.active) { + this._endEdit(); + } + } + + onPlayerUpdate(player) { + let scale = player.geometry.scale; + if (this._scale != scale) { + this._scale = scale; + + let strokeWidth = this._data && this._data.type === 'points' ? 0 : STROKE_WIDTH / this._scale; + let pointRadius = POINT_RADIUS / this._scale; + + if (this._originalShape) { + this._originalShape.attr('stroke-width', strokeWidth); + } + + if (this._correctLine) { + this._correctLine.attr('stroke-width', strokeWidth / 2); + } + + for (let uiPoint of this._originalShapePoints) { + uiPoint.attr('stroke-width', strokeWidth); + uiPoint.radius(pointRadius); + } + + this._rescaleDrawPoints(); + } + + // Abort if frame have been changed + if (player.frames.current != this._frame && this._data) { + this._controller.cancel(); + } + } +} diff --git a/cvat/apps/engine/static/engine/js/shapeCollection.js b/cvat/apps/engine/static/engine/js/shapeCollection.js index bdb08126..67cbc9ee 100644 --- a/cvat/apps/engine/static/engine/js/shapeCollection.js +++ b/cvat/apps/engine/static/engine/js/shapeCollection.js @@ -725,13 +725,6 @@ class ShapeCollectionModel extends Listener { } } - clonePointForActiveShape(idx, direction, insertPoint) { - if (this._activeShape && !this._activeShape.lock) { - return this._activeShape.clonePoint(idx, direction, insertPoint); - } - else return null; - } - split() { if (this._activeShape) { if (!this._activeShape.lock && this._activeShape.type.split('_')[0] === 'interpolation') { @@ -968,10 +961,6 @@ class ShapeCollectionController { this._model.removePointFromActiveShape(idx); } - clonePointForActiveShape(idx, direction, insertPoint) { - return this._model.clonePointForActiveShape(idx, direction, insertPoint); - } - splitForActive() { this._model.split(); } @@ -1164,44 +1153,6 @@ class ShapeCollectionView { case "remove_point": this._controller.removePointFromActiveShape(idx); break; - case "clone_point_before": - this._controller.clonePointForActiveShape(idx, 'before', true); - break; - case "clone_point_after": - this._controller.clonePointForActiveShape(idx, 'after', true); - break; - } - }); - - $('#pointContextMenu').mouseout(() => { - $(this._frameContent.node).find('.tmp_inserted_point').remove(); - }); - - $('#pointContextMenu li').mouseover((e) => { - $(this._frameContent.node).find('.tmp_inserted_point').remove(); - let menu = $('#pointContextMenu'); - let idx = +menu.attr('point_idx'); - let point = null; - - switch($(e.target).attr("action")) { - case "clone_point_before": - point = this._controller.clonePointForActiveShape(idx, 'before', false); - if (point) { - this._frameContent.circle(POINT_RADIUS * 2 / this._scale).center(point.x, point.y) - .addClass('tmp_inserted_point tempMarker').fill('white').stroke('black').attr({ - 'stroke-width': STROKE_WIDTH / this._scale - }); - } - break; - case "clone_point_after": - point = this._controller.clonePointForActiveShape(idx, 'after', false); - if (point) { - this._frameContent.circle(POINT_RADIUS * 2 / this._scale).center(point.x, point.y) - .addClass('tmp_inserted_point tempMarker').fill('white').stroke('black').attr({ - 'stroke-width': STROKE_WIDTH / this._scale - }); - } - break; } }); diff --git a/cvat/apps/engine/static/engine/js/shapeCreator.js b/cvat/apps/engine/static/engine/js/shapeCreator.js index dc797fc2..eebcb878 100644 --- a/cvat/apps/engine/static/engine/js/shapeCreator.js +++ b/cvat/apps/engine/static/engine/js/shapeCreator.js @@ -265,6 +265,10 @@ class ShapeCreatorView { _createPolyEvents() { // If number of points for poly shape specified, use it. // Dicrement number on draw new point events. Drawstart trigger when create first point + let lastPoint = { + x: null, + y: null, + } if (this._polyShapeSize) { let size = this._polyShapeSize; @@ -287,14 +291,50 @@ class ShapeCreatorView { // Callbacks for point scale this._drawInstance.on('drawstart', this._rescaleDrawPoints.bind(this)); this._drawInstance.on('drawpoint', this._rescaleDrawPoints.bind(this)); + + this._drawInstance.on('drawstart', (e) => { + lastPoint = { + x: e.detail.event.clientX, + y: e.detail.event.clientY, + } + }); + + this._drawInstance.on('drawpoint', (e) => { + lastPoint = { + x: e.detail.event.clientX, + y: e.detail.event.clientY, + } + }); + this._frameContent.on('mousedown.shapeCreator', (e) => { if (e.which === 3) { this._drawInstance.draw('undo'); } }); + + this._frameContent.on('mousemove.shapeCreator', (e) => { + if (e.shiftKey && this._type != 'points') { + if (lastPoint.x === null || lastPoint.y === null) { + this._drawInstance.draw('point', e); + } + else { + let delta = Math.sqrt(Math.pow(e.clientX - lastPoint.x, 2) + Math.pow(e.clientY - lastPoint.y, 2)); + let deltaTreshold = 15; + if (delta > deltaTreshold) { + this._drawInstance.draw('point', e); + lastPoint = { + x: e.clientX, + y: e.clientY + }; + } + } + } + }); + this._drawInstance.on('drawstop', () => { this._frameContent.off('mousedown.shapeCreator'); + this._frameContent.off('mousemove.shapeCreator'); }); // Also we need callback on drawdone event for get points this._drawInstance.on('drawdone', function(e) { diff --git a/cvat/apps/engine/static/engine/js/shapes.js b/cvat/apps/engine/static/engine/js/shapes.js index e58d52f1..20392394 100644 --- a/cvat/apps/engine/static/engine/js/shapes.js +++ b/cvat/apps/engine/static/engine/js/shapes.js @@ -804,10 +804,6 @@ class BoxModel extends ShapeModel { // nothing do } - clonePoint() { - // nothing do - } - static importPositions(positions) { let imported = {}; if (this._type === 'interpolation_box') { @@ -1011,40 +1007,6 @@ class PolyShapeModel extends ShapeModel { } } - clonePoint(idx, direction, inserPoint) { - let frame = window.cvat.player.frames.current; - let position = this._interpolatePosition(frame); - let points = PolyShapeModel.convertStringToNumberArray(position.points); - - let otherIdx = null; - if (direction === 'before') { - otherIdx = idx - 1 >= 0 ? idx - 1: points.length - 1; - } - else { - otherIdx = idx + 1 in points ? idx + 1: 0; - } - let curP = points[idx]; - let otherP = points[otherIdx]; - let newP = { - x: curP.x + (otherP.x - curP.x) / 2, - y: curP.y + (otherP.y - curP.y) / 2, - }; - - if (direction === 'before') { - points.splice(idx, 0, newP); - } - else { - points.splice(idx + 1, 0, newP); - } - - if (inserPoint) { - position.points = PolyShapeModel.convertNumberArrayToString(points); - this.updatePosition(frame, position); - } - - return newP; - } - static convertStringToNumberArray(serializedPoints) { let pointArray = []; for (let pair of serializedPoints.split(' ')) { @@ -2868,9 +2830,11 @@ class PolyShapeView extends ShapeView { if (this._flags.editable) { for (let point of $('.svg_select_points')) { point = $(point); + point.on('contextmenu.contextMenu', (e) => { this._shapeContextMenu.hide(100); this._pointContextMenu.attr('point_idx', point.index()); + this._pointContextMenu.attr('dom_point_id', point.attr('id')); this._pointContextMenu.finish().show(100).offset({ top: e.pageY - 20, @@ -2878,6 +2842,38 @@ class PolyShapeView extends ShapeView { }); e.preventDefault(); + }); + + point.on('dblclick.polyshapeEditor', (e) => { + if (e.shiftKey) { + if (!window.cvat.mode) { + // Get index before detach shape from DOM + let index = point.index(); + + // Make non active view and detach shape from DOM + this._makeNotEditable(); + this._deselect(); + if (this._controller.hiddenText) { + this._hideShapeText(); + } + this._uis.shape.addClass('hidden'); + + // Run edit mode + PolyShapeView.editor.edit(this._controller.type.split('_')[1], + this._uis.shape.attr('points'), this._color, index, e, + (points) => { + this._uis.shape.removeClass('hidden'); + if (points) { + this._uis.shape.attr('points', points); + this._controller.updatePosition(window.cvat.player.frames.current, this._buildPosition()); + } + } + ); + } + } + else { + this._controller.model().removePoint(point.index()); + } e.stopPropagation(); }); } @@ -2888,6 +2884,7 @@ class PolyShapeView extends ShapeView { _makeNotEditable() { for (let point of $('.svg_select_points')) { $(point).off('contextmenu.contextMenu'); + $(point).off('dblclick.polyshapeEditor'); } ShapeView.prototype._makeNotEditable.call(this); } diff --git a/cvat/apps/engine/templates/engine/annotation.html b/cvat/apps/engine/templates/engine/annotation.html index 2b12d634..33dea2ad 100644 --- a/cvat/apps/engine/templates/engine/annotation.html +++ b/cvat/apps/engine/templates/engine/annotation.html @@ -41,6 +41,7 @@ + @@ -87,8 +88,6 @@
diff --git a/tests/eslintrc.conf.js b/tests/eslintrc.conf.js index e54e0548..7771a024 100644 --- a/tests/eslintrc.conf.js +++ b/tests/eslintrc.conf.js @@ -73,6 +73,7 @@ module.exports = { 'ShapeMergerView': true, // from shapes.js 'PolyShapeModel': true, + 'PolyShapeView': true, 'buildShapeModel': true, 'buildShapeController': true, 'buildShapeView': true, @@ -116,5 +117,9 @@ module.exports = { 'HistoryModel': true, 'HistoryController': true, 'HistoryView': true, + // from polyshapeEditor.js + 'PolyshapeEditorModel': true, + 'PolyshapeEditorController': true, + 'PolyshapeEditorView': true, }, };