diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c654efac8a..488c67b1bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Built-in search for labels when create an object or change a label () - Better validation of labels and attributes in raw viewer () - ClamAV antivirus integration () +- Polygon and polylines interpolation () +- Ability to redraw shape from scratch (Shift + N) for an activated shape () +- Highlights for the first point of a polygon/polyline and direction () +- Ability to change orientation for poylgons/polylines in context menu () +- Ability to set the first point for polygons in points context menu () ### Changed - Removed information about e-mail from the basic user information () - Update https install manual. Makes it easier and more robust. Includes automatic renewing of lets encrypt certificates. - Implemented import and export of annotations with relative image paths () +- Using only single click to start editing or remove a point () ### Deprecated - diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md index a538a9e7040..808aa5ac52d 100644 --- a/cvat-canvas/README.md +++ b/cvat-canvas/README.md @@ -117,6 +117,7 @@ Canvas itself handles: mode(): Mode; cancel(): void; configure(configuration: Configuration): void; + isAbleToChangeFrame(): boolean; } ``` @@ -188,8 +189,7 @@ Standard JS events are used. | | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS | |--------------|------|-------|-------|------|-------|------|------|--------|-------------|-------------| -| html() | + | + | + | + | + | + | + | + | + | + | -| setup() | + | + | + | + | + | +/- | +/- | +/- | + | + | +| setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + | | activate() | + | - | - | - | - | - | - | - | - | - | | rotate() | + | + | + | + | + | + | + | + | + | + | | focus() | + | + | + | + | + | + | + | + | + | + | @@ -208,3 +208,6 @@ Standard JS events are used. | setZLayer() | + | + | + | + | + | + | + | + | + | + | You can call setup() during editing, dragging, and resizing only to update objects, not to change a frame. +You can change frame during draw only when you do not redraw an existing object + +Other methods do not change state and can be used everytime. diff --git a/cvat-canvas/package-lock.json b/cvat-canvas/package-lock.json index e42242226b1..0805ead9b1a 100644 --- a/cvat-canvas/package-lock.json +++ b/cvat-canvas/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index 73a7c98a391..38a0c145eb7 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "1.1.1", + "version": "1.2.0", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", "scripts": { diff --git a/cvat-canvas/src/scss/canvas.scss b/cvat-canvas/src/scss/canvas.scss index b7d6c56d12c..5e1e24ab2ca 100644 --- a/cvat-canvas/src/scss/canvas.scss +++ b/cvat-canvas/src/scss/canvas.scss @@ -151,6 +151,25 @@ polyline.cvat_canvas_shape_splitting { cursor: move; } +.cvat_canvas_first_poly_point { + fill: lightgray; +} + +.cvat_canvas_poly_direction { + fill: lightgray; + stroke: black; + + &:hover { + fill: black; + stroke: lightgray; + } + + &:active { + fill: lightgray; + stroke: black; + } +} + #cvat_canvas_wrapper { width: calc(100% - 10px); height: calc(100% - 10px); diff --git a/cvat-canvas/src/typescript/canvas.ts b/cvat-canvas/src/typescript/canvas.ts index 61f3b0c8ca5..e0db5e5a175 100644 --- a/cvat-canvas/src/typescript/canvas.ts +++ b/cvat-canvas/src/typescript/canvas.ts @@ -58,6 +58,7 @@ interface Canvas { mode(): Mode; cancel(): void; configure(configuration: Configuration): void; + isAbleToChangeFrame(): boolean; } class CanvasImpl implements Canvas { @@ -153,6 +154,10 @@ class CanvasImpl implements Canvas { public configure(configuration: Configuration): void { this.model.configure(configuration); } + + public isAbleToChangeFrame(): boolean { + return this.model.isAbleToChangeFrame(); + } } export { diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index 55873e4aa37..e2069efe27c 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -66,6 +66,7 @@ export interface DrawData { numberOfPoints?: number; initialState?: any; crosshair?: boolean; + redraw?: number; } export interface EditData { @@ -169,6 +170,7 @@ export interface CanvasModel { dragCanvas(enable: boolean): void; zoomCanvas(enable: boolean): void; + isAbleToChangeFrame(): boolean; configure(configuration: Configuration): void; cancel(): void; } @@ -382,10 +384,17 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { } if (this.data.mode !== Mode.IDLE && clientID !== null) { - // Exception or just return? throw Error(`Canvas is busy. Action: ${this.data.mode}`); } + if (typeof (clientID) === 'number') { + const [state] = this.data.objects + .filter((_state: any): boolean => _state.clientID === clientID); + if (!['rectangle', 'polygon', 'polyline', 'points', 'cuboid'].includes(state.shapeType)) { + return; + } + } + this.data.activeElement = { clientID, attributeID, @@ -465,10 +474,24 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { } } - this.data.drawData = { ...drawData }; - if (this.data.drawData.initialState) { - this.data.drawData.shapeType = this.data.drawData.initialState.shapeType; + if (typeof (drawData.redraw) === 'number') { + const clientID = drawData.redraw; + const [state] = this.data.objects + .filter((_state: any): boolean => _state.clientID === clientID); + + if (state) { + this.data.drawData = { ...drawData }; + this.data.drawData.shapeType = state.shapeType; + } else { + return; + } + } else { + this.data.drawData = { ...drawData }; + if (this.data.drawData.initialState) { + this.data.drawData.shapeType = this.data.drawData.initialState.shapeType; + } } + this.notify(UpdateReasons.DRAW); } @@ -548,6 +571,13 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.notify(UpdateReasons.CONFIG_UPDATED); } + public isAbleToChangeFrame(): boolean { + const isUnable = [Mode.DRAG, Mode.EDIT, Mode.RESIZE].includes(this.data.mode) + || (this.data.mode === Mode.DRAW && typeof (this.data.drawData.redraw) === 'number'); + + return !isUnable; + } + public cancel(): void { this.notify(UpdateReasons.CANCEL); } diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 2220eff9c63..8c2fe940c7f 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -21,8 +21,11 @@ import consts from './consts'; import { translateToSVG, translateFromSVG, - pointsToArray, + pointsToNumberArray, + parsePoints, displayShapeSize, + scalarProduct, + vectorLength, ShapeSizeElement, DrawnState, } from './shared'; @@ -71,6 +74,9 @@ export class CanvasViewImpl implements CanvasView, Listener { private autoborderHandler: AutoborderHandler; private activeElement: ActiveElement; private configuration: Configuration; + private serviceFlags: { + drawHidden: Record; + }; private set mode(value: Mode) { this.controller.mode = value; @@ -80,8 +86,75 @@ export class CanvasViewImpl implements CanvasView, Listener { return this.controller.mode; } + private isServiceHidden(clientID: number): boolean { + return this.serviceFlags.drawHidden[clientID] || false; + } + + private setupServiceHidden(clientID: number, value: boolean): void { + this.serviceFlags.drawHidden[clientID] = value; + const shape = this.svgShapes[clientID]; + const text = this.svgTexts[clientID]; + const state = this.drawnStates[clientID]; + + if (value) { + if (shape) { + (state.shapeType === 'points' ? shape.remember('_selectHandler').nested : shape) + .style('display', 'none'); + } + + if (text) { + text.addClass('cvat_canvas_hidden'); + } + } else { + delete this.serviceFlags.drawHidden[clientID]; + + if (state) { + if (!state.outside && !state.hidden) { + if (shape) { + (state.shapeType === 'points' ? shape.remember('_selectHandler').nested : shape) + .style('display', ''); + } + + if (text) { + text.removeClass('cvat_canvas_hidden'); + this.updateTextPosition( + text, + shape, + ); + } + } + } + } + } + private onDrawDone(data: object | null, duration: number, continueDraw?: boolean): void { + const hiddenBecauseOfDraw = Object.keys(this.serviceFlags.drawHidden) + .map((_clientID): number => +_clientID); + if (hiddenBecauseOfDraw.length) { + for (const hidden of hiddenBecauseOfDraw) { + this.setupServiceHidden(hidden, false); + } + } + if (data) { + const { clientID, points } = data as any; + if (typeof (clientID) === 'number') { + const event: CustomEvent = new CustomEvent('canvas.canceled', { + bubbles: false, + cancelable: true, + }); + + this.canvas.dispatchEvent(event); + + const [state] = this.controller.objects + .filter((_state: any): boolean => ( + _state.clientID === clientID + )); + + this.onEditDone(state, points); + return; + } + const { zLayer } = this.controller; const event: CustomEvent = new CustomEvent('canvas.drawn', { bubbles: false, @@ -323,6 +396,15 @@ export class CanvasViewImpl implements CanvasView, Listener { ); } + for (const element of + window.document.getElementsByClassName('cvat_canvas_poly_direction')) { + const angle = (element as any).instance.data('angle'); + + (element as any).instance.style({ + transform: `scale(${1 / this.geometry.scale}) rotate(${angle}deg)`, + }); + } + for (const element of window.document.getElementsByClassName('cvat_canvas_selected_point')) { const previousWidth = element.getAttribute('stroke-width') as string; @@ -425,13 +507,88 @@ export class CanvasViewImpl implements CanvasView, Listener { } } + private hideDirection(shape: SVG.Polygon | SVG.PolyLine): void { + /* eslint class-methods-use-this: 0 */ + const handler = shape.remember('_selectHandler'); + if (!handler || !handler.nested) return; + const nested = handler.nested as SVG.Parent; + if (nested.children().length) { + nested.children()[0].removeClass('cvat_canvas_first_poly_point'); + } + + const node = nested.node as SVG.LinkedHTMLElement; + const directions = node.getElementsByClassName('cvat_canvas_poly_direction'); + for (const direction of directions) { + const { instance } = (direction as any); + instance.off('click'); + instance.remove(); + } + } + + private showDirection(state: any, shape: SVG.Polygon | SVG.PolyLine): void { + const path = consts.ARROW_PATH; + + const points = parsePoints(state.points); + const handler = shape.remember('_selectHandler'); + + if (!handler || !handler.nested) return; + const firstCircle = handler.nested.children()[0]; + const secondCircle = handler.nested.children()[1]; + firstCircle.addClass('cvat_canvas_first_poly_point'); + + const [cx, cy] = [ + (secondCircle.cx() + firstCircle.cx()) / 2, + (secondCircle.cy() + firstCircle.cy()) / 2, + ]; + const [firstPoint, secondPoint] = points.slice(0, 2); + const xAxis = { i: 1, j: 0 }; + const baseVector = { i: secondPoint.x - firstPoint.x, j: secondPoint.y - firstPoint.y }; + const baseVectorLength = vectorLength(baseVector); + let cosinus = 0; + + if (baseVectorLength !== 0) { + // two points have the same coordinates + cosinus = scalarProduct(xAxis, baseVector) + / (vectorLength(xAxis) * baseVectorLength); + } + const angle = Math.acos(cosinus) * (Math.sign(baseVector.j) || 1) * 180 / Math.PI; + + const pathElement = handler.nested.path(path).fill('white') + .stroke({ + width: 1, + color: 'black', + }).addClass('cvat_canvas_poly_direction').style({ + 'transform-origin': `${cx}px ${cy}px`, + transform: `scale(${1 / this.geometry.scale}) rotate(${angle}deg)`, + }).move(cx, cy); + + pathElement.on('click', (e: MouseEvent): void => { + if (e.button === 0) { + e.stopPropagation(); + if (state.shapeType === 'polygon') { + const reversedPoints = [points[0], ...points.slice(1).reverse()]; + this.onEditDone(state, pointsToNumberArray(reversedPoints)); + } else { + const reversedPoints = points.reverse(); + this.onEditDone(state, pointsToNumberArray(reversedPoints)); + } + } + }); + + pathElement.data('angle', angle); + pathElement.dmove(-pathElement.width() / 2, -pathElement.height() / 2); + } + private selectize(value: boolean, shape: SVG.Element): void { const self = this; const { offset } = this.controller.geometry; const translate = (points: number[]): number[] => points .map((coord: number): number => coord - offset); - function dblClickHandler(e: MouseEvent): void { + function mousedownHandler(e: MouseEvent): void { + if (e.button !== 0) return; + e.preventDefault(); + const pointID = Array.prototype.indexOf .call(((e.target as HTMLElement).parentElement as HTMLElement).children, e.target); @@ -440,45 +597,52 @@ export class CanvasViewImpl implements CanvasView, Listener { .filter((_state: any): boolean => ( _state.clientID === self.activeElement.clientID )); - if (state.shapeType === 'rectangle') { - e.preventDefault(); - return; + + if (['polygon', 'polyline', 'points'].includes(state.shapeType)) { + if (e.ctrlKey) { + const { points } = state; + self.onEditDone( + state, + points.slice(0, pointID * 2).concat(points.slice(pointID * 2 + 2)), + ); + } else if (e.shiftKey) { + self.canvas.dispatchEvent(new CustomEvent('canvas.editstart', { + bubbles: false, + cancelable: true, + })); + + self.mode = Mode.EDIT; + self.deactivate(); + self.editHandler.edit({ + enabled: true, + state, + pointID, + }); + } } + } + } + + function dblClickHandler(e: MouseEvent): void { + e.preventDefault(); + + if (self.activeElement.clientID !== null) { + const [state] = self.controller.objects + .filter((_state: any): boolean => ( + _state.clientID === self.activeElement.clientID + )); + if (state.shapeType === 'cuboid') { if (e.shiftKey) { - const points = translate(pointsToArray((e.target as any) + const points = translate(pointsToNumberArray((e.target as any) .parentElement.parentElement.instance.attr('points'))); self.onEditDone( state, points, ); - e.preventDefault(); - return; } } - if (e.ctrlKey) { - const { points } = state; - self.onEditDone( - state, - points.slice(0, pointID * 2).concat(points.slice(pointID * 2 + 2)), - ); - } else if (e.shiftKey) { - self.canvas.dispatchEvent(new CustomEvent('canvas.editstart', { - bubbles: false, - cancelable: true, - })); - - self.mode = Mode.EDIT; - self.deactivate(); - self.editHandler.edit({ - enabled: true, - state, - pointID, - }); - } } - - e.preventDefault(); } function contextMenuHandler(e: MouseEvent): void { @@ -524,6 +688,7 @@ export class CanvasViewImpl implements CanvasView, Listener { }); circle.on('dblclick', dblClickHandler); + circle.on('mousedown', mousedownHandler); circle.on('contextmenu', contextMenuHandler); circle.addClass('cvat_canvas_selected_point'); }); @@ -534,6 +699,7 @@ export class CanvasViewImpl implements CanvasView, Listener { }); circle.off('dblclick', dblClickHandler); + circle.off('mousedown', mousedownHandler); circle.off('contextmenu', contextMenuHandler); circle.removeClass('cvat_canvas_selected_point'); }); @@ -565,6 +731,9 @@ export class CanvasViewImpl implements CanvasView, Listener { }; this.configuration = model.configuration; this.mode = Mode.IDLE; + this.serviceFlags = { + drawHidden: {}, + }; // Create HTML elements this.loadingAnimation = window.document @@ -864,6 +1033,9 @@ export class CanvasViewImpl implements CanvasView, Listener { if (data.enabled && this.mode === Mode.IDLE) { this.canvas.style.cursor = 'crosshair'; this.mode = Mode.DRAW; + if (typeof (data.redraw) === 'number') { + this.setupServiceHidden(data.redraw, true); + } this.drawHandler.draw(data, this.geometry); } else { this.canvas.style.cursor = ''; @@ -1045,7 +1217,8 @@ export class CanvasViewImpl implements CanvasView, Listener { const drawnState = this.drawnStates[clientID]; const shape = this.svgShapes[state.clientID]; const text = this.svgTexts[state.clientID]; - const isInvisible = state.hidden || state.outside; + const isInvisible = state.hidden || state.outside + || this.isServiceHidden(state.clientID); if (drawnState.hidden !== state.hidden || drawnState.outside !== state.outside) { if (isInvisible) { @@ -1147,59 +1320,57 @@ export class CanvasViewImpl implements CanvasView, Listener { const { displayAllText } = this.configuration; for (const state of states) { - if (state.objectType === 'tag') { - this.addTag(state); + const points: number[] = (state.points as number[]); + const translatedPoints: number[] = translate(points); + + // TODO: Use enums after typification cvat-core + if (state.shapeType === 'rectangle') { + this.svgShapes[state.clientID] = this + .addRect(translatedPoints, state); } else { - const points: number[] = (state.points as number[]); - const translatedPoints: number[] = translate(points); + const stringified = translatedPoints.reduce( + (acc: string, val: number, idx: number): string => { + if (idx % 2) { + return `${acc}${val} `; + } - // TODO: Use enums after typification cvat-core - if (state.shapeType === 'rectangle') { + return `${acc}${val},`; + }, '', + ); + + if (state.shapeType === 'polygon') { + this.svgShapes[state.clientID] = this + .addPolygon(stringified, state); + } else if (state.shapeType === 'polyline') { this.svgShapes[state.clientID] = this - .addRect(translatedPoints, state); + .addPolyline(stringified, state); + } else if (state.shapeType === 'points') { + this.svgShapes[state.clientID] = this + .addPoints(stringified, state); + } else if (state.shapeType === 'cuboid') { + this.svgShapes[state.clientID] = this + .addCuboid(stringified, state); } else { - const stringified = translatedPoints.reduce( - (acc: string, val: number, idx: number): string => { - if (idx % 2) { - return `${acc}${val} `; - } - - return `${acc}${val},`; - }, '', - ); - - if (state.shapeType === 'polygon') { - this.svgShapes[state.clientID] = this - .addPolygon(stringified, state); - } else if (state.shapeType === 'polyline') { - this.svgShapes[state.clientID] = this - .addPolyline(stringified, state); - } else if (state.shapeType === 'points') { - this.svgShapes[state.clientID] = this - .addPoints(stringified, state); - } else if (state.shapeType === 'cuboid') { - this.svgShapes[state.clientID] = this - .addCuboid(stringified, state); - } + continue; } + } - this.svgShapes[state.clientID].on('click.canvas', (): void => { - this.canvas.dispatchEvent(new CustomEvent('canvas.clicked', { - bubbles: false, - cancelable: true, - detail: { - state, - }, - })); - }); + this.svgShapes[state.clientID].on('click.canvas', (): void => { + this.canvas.dispatchEvent(new CustomEvent('canvas.clicked', { + bubbles: false, + cancelable: true, + detail: { + state, + }, + })); + }); - if (displayAllText) { - this.svgTexts[state.clientID] = this.addText(state); - this.updateTextPosition( - this.svgTexts[state.clientID], - this.svgShapes[state.clientID], - ); - } + if (displayAllText) { + this.svgTexts[state.clientID] = this.addText(state); + this.updateTextPosition( + this.svgTexts[state.clientID], + this.svgShapes[state.clientID], + ); } this.saveState(state); @@ -1327,16 +1498,6 @@ export class CanvasViewImpl implements CanvasView, Listener { const shape = this.svgShapes[clientID]; - let text = this.svgTexts[clientID]; - if (!text) { - text = this.addText(state); - this.svgTexts[state.clientID] = text; - this.updateTextPosition( - text, - shape, - ); - } - if (state.lock) { return; } @@ -1354,29 +1515,39 @@ export class CanvasViewImpl implements CanvasView, Listener { (shape as any).attr('projections', true); } + let text = this.svgTexts[clientID]; + if (!text) { + text = this.addText(state); + this.svgTexts[state.clientID] = text; + } + + const hideText = (): void => { + if (text) { + text.addClass('cvat_canvas_hidden'); + } + }; + + const showText = (): void => { + if (text) { + text.removeClass('cvat_canvas_hidden'); + this.updateTextPosition(text, shape); + } + }; + if (!state.pinned) { shape.addClass('cvat_canvas_shape_draggable'); (shape as any).draggable().on('dragstart', (): void => { this.mode = Mode.DRAG; - if (text) { - text.addClass('cvat_canvas_hidden'); - } + hideText(); }).on('dragend', (e: CustomEvent): void => { - if (text) { - text.removeClass('cvat_canvas_hidden'); - this.updateTextPosition( - text, - shape, - ); - } - + showText(); this.mode = Mode.IDLE; const p1 = e.detail.handler.startPoints.point; const p2 = e.detail.p; const delta = 1; const { offset } = this.controller.geometry; if (Math.sqrt(((p1.x - p2.x) ** 2) + ((p1.y - p2.y) ** 2)) >= delta) { - const points = pointsToArray( + const points = pointsToNumberArray( shape.attr('points') || `${shape.attr('x')},${shape.attr('y')} ` + `${shape.attr('x') + shape.attr('width')},` + `${shape.attr('y') + shape.attr('height')}`, @@ -1399,19 +1570,32 @@ export class CanvasViewImpl implements CanvasView, Listener { this.selectize(true, shape); } + const showDirection = (): void => { + if (['polygon', 'polyline'].includes(state.shapeType)) { + this.showDirection(state, shape as SVG.Polygon | SVG.PolyLine); + } + }; + + const hideDirection = (): void => { + if (['polygon', 'polyline'].includes(state.shapeType)) { + this.hideDirection(shape as SVG.Polygon | SVG.PolyLine); + } + }; + + showDirection(); + let shapeSizeElement: ShapeSizeElement | null = null; let resized = false; (shape as any).resize({ snapToGrid: 0.1, }).on('resizestart', (): void => { this.mode = Mode.RESIZE; + resized = false; + hideDirection(); + hideText(); if (state.shapeType === 'rectangle') { shapeSizeElement = displayShapeSize(this.adoptedContent, this.adoptedText); } - resized = false; - if (text) { - text.addClass('cvat_canvas_hidden'); - } }).on('resizing', (): void => { resized = true; if (shapeSizeElement) { @@ -1422,20 +1606,15 @@ export class CanvasViewImpl implements CanvasView, Listener { shapeSizeElement.rm(); } - if (text) { - text.removeClass('cvat_canvas_hidden'); - this.updateTextPosition( - text, - shape, - ); - } + showDirection(); + showText(); this.mode = Mode.IDLE; if (resized) { const { offset } = this.controller.geometry; - const points = pointsToArray( + const points = pointsToNumberArray( shape.attr('points') || `${shape.attr('x')},${shape.attr('y')} ` + `${shape.attr('x') + shape.attr('width')},` + `${shape.attr('y') + shape.attr('height')}`, @@ -1453,6 +1632,7 @@ export class CanvasViewImpl implements CanvasView, Listener { } }); + this.updateTextPosition(text, shape); this.canvas.dispatchEvent(new CustomEvent('canvas.activated', { bubbles: false, cancelable: true, @@ -1570,8 +1750,8 @@ export class CanvasViewImpl implements CanvasView, Listener { rect.addClass('cvat_canvas_shape_occluded'); } - if (state.hidden || state.outside) { - rect.style('display', 'none'); + if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) { + rect.addClass('cvat_canvas_hidden'); } return rect; @@ -1593,8 +1773,8 @@ export class CanvasViewImpl implements CanvasView, Listener { polygon.addClass('cvat_canvas_shape_occluded'); } - if (state.hidden || state.outside) { - polygon.style('display', 'none'); + if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) { + polygon.addClass('cvat_canvas_hidden'); } return polygon; @@ -1616,8 +1796,8 @@ export class CanvasViewImpl implements CanvasView, Listener { polyline.addClass('cvat_canvas_shape_occluded'); } - if (state.hidden || state.outside) { - polyline.style('display', 'none'); + if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) { + polyline.addClass('cvat_canvas_hidden'); } return polyline; @@ -1640,8 +1820,8 @@ export class CanvasViewImpl implements CanvasView, Listener { cube.addClass('cvat_canvas_shape_occluded'); } - if (state.hidden || state.outside) { - cube.style('display', 'none'); + if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) { + cube.addClass('cvat_canvas_hidden'); } return cube; @@ -1684,8 +1864,8 @@ export class CanvasViewImpl implements CanvasView, Listener { const group = this.setupPoints(shape, state); - if (state.hidden || state.outside) { - group.style('display', 'none'); + if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) { + group.addClass('cvat_canvas_hidden'); } shape.remove = (): SVG.PolyLine => { @@ -1696,9 +1876,4 @@ export class CanvasViewImpl implements CanvasView, Listener { return shape; } - - /* eslint-disable-next-line */ - private addTag(state: any): void { - console.log(state); - } } diff --git a/cvat-canvas/src/typescript/consts.ts b/cvat-canvas/src/typescript/consts.ts index aa92a9f2a44..b9110b399c2 100644 --- a/cvat-canvas/src/typescript/consts.ts +++ b/cvat-canvas/src/typescript/consts.ts @@ -14,6 +14,8 @@ const MIN_EDGE_LENGTH = 3; const CUBOID_ACTIVE_EDGE_STROKE_WIDTH = 2.5; const CUBOID_UNACTIVE_EDGE_STROKE_WIDTH = 1.75; const UNDEFINED_ATTRIBUTE_VALUE = '__undefined__'; +const ARROW_PATH = 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 ' + + '0 0-.012.59L4.2 6.72.096 12.192a.479.479 0 0 0 .585.724l12.48-5.76a.48.48 0 0 0 0-.872z'; export default { BASE_STROKE_WIDTH, @@ -28,4 +30,5 @@ export default { CUBOID_ACTIVE_EDGE_STROKE_WIDTH, CUBOID_UNACTIVE_EDGE_STROKE_WIDTH, UNDEFINED_ATTRIBUTE_VALUE, + ARROW_PATH, }; diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index 5a52baddb00..21caaabc752 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -11,8 +11,8 @@ import { translateToSVG, displayShapeSize, ShapeSizeElement, - pointsToString, - pointsToArray, + stringifyPoints, + pointsToNumberArray, BBox, Box, } from './shared'; @@ -264,12 +264,13 @@ export class DrawHandlerImpl implements DrawHandler { this.drawInstance.on('drawstop', (e: Event): void => { const bbox = (e.target as SVGRectElement).getBBox(); const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(bbox); - const { shapeType } = this.drawData; + const { shapeType, redraw: clientID } = this.drawData; this.release(); if (this.canceled) return; if ((xbr - xtl) * (ybr - ytl) >= consts.AREA_THRESHOLD) { this.onDrawDone({ + clientID, shapeType, points: [xtl, ytl, xbr, ybr], }, Date.now() - this.startTimestamp); @@ -298,12 +299,13 @@ export class DrawHandlerImpl implements DrawHandler { if (numberOfPoints === 4) { const bbox = (e.target as SVGPolylineElement).getBBox(); const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(bbox); - const { shapeType } = this.drawData; + const { shapeType, redraw: clientID } = this.drawData; this.cancel(); if ((xbr - xtl) * (ybr - ytl) >= consts.AREA_THRESHOLD) { this.onDrawDone({ shapeType, + clientID, points: [xtl, ytl, xbr, ybr], }, Date.now() - this.startTimestamp); } @@ -356,6 +358,7 @@ export class DrawHandlerImpl implements DrawHandler { if (lastDrawnPoint.x === null || lastDrawnPoint.y === null) { this.drawInstance.draw('point', e); } else { + this.drawInstance.draw('update', e); const deltaTreshold = 15; const delta = Math.sqrt( ((e.clientX - lastDrawnPoint.x) ** 2) @@ -379,8 +382,8 @@ export class DrawHandlerImpl implements DrawHandler { }); this.drawInstance.on('drawdone', (e: CustomEvent): void => { - const targetPoints = pointsToArray((e.target as SVGElement).getAttribute('points')); - const { shapeType } = this.drawData; + const targetPoints = pointsToNumberArray((e.target as SVGElement).getAttribute('points')); + const { shapeType, redraw: clientID } = this.drawData; const { points, box } = shapeType === 'cuboid' ? this.getFinalCuboidCoordinates(targetPoints) : this.getFinalPolyshapeCoordinates(targetPoints); this.release(); @@ -390,6 +393,7 @@ export class DrawHandlerImpl implements DrawHandler { && ((box.xbr - box.xtl) * (box.ybr - box.ytl) >= consts.AREA_THRESHOLD) && points.length >= 3 * 2) { this.onDrawDone({ + clientID, shapeType, points, }, Date.now() - this.startTimestamp); @@ -398,12 +402,14 @@ export class DrawHandlerImpl implements DrawHandler { || (box.ybr - box.ytl) >= consts.SIZE_THRESHOLD) && points.length >= 2 * 2) { this.onDrawDone({ + clientID, shapeType, points, }, Date.now() - this.startTimestamp); } else if (shapeType === 'points' && (e.target as any).getAttribute('points') !== '0,0') { this.onDrawDone({ + clientID, shapeType, points, }, Date.now() - this.startTimestamp); @@ -411,6 +417,7 @@ export class DrawHandlerImpl implements DrawHandler { } else if (shapeType === 'cuboid' && points.length === 4 * 2) { this.onDrawDone({ + clientID, shapeType, points: cuboidFrom4Points(points), }, Date.now() - this.startTimestamp); @@ -673,7 +680,7 @@ export class DrawHandlerImpl implements DrawHandler { } else { const points = this.drawData.initialState.points .map((coord: number): number => coord + offset); - const stringifiedPoints = pointsToString(points); + const stringifiedPoints = stringifyPoints(points); if (this.drawData.shapeType === 'polygon') { this.pastePolygon(stringifiedPoints); diff --git a/cvat-canvas/src/typescript/editHandler.ts b/cvat-canvas/src/typescript/editHandler.ts index 74446096b54..2ced96d5e57 100644 --- a/cvat-canvas/src/typescript/editHandler.ts +++ b/cvat-canvas/src/typescript/editHandler.ts @@ -6,7 +6,7 @@ import * as SVG from 'svg.js'; import 'svg.select.js'; import consts from './consts'; -import { translateFromSVG, pointsToArray } from './shared'; +import { translateFromSVG, pointsToNumberArray } from './shared'; import { EditData, Geometry, Configuration } from './canvasModel'; import { AutoborderHandler } from './autoborderHandler'; @@ -28,6 +28,38 @@ export class EditHandlerImpl implements EditHandler { private clones: SVG.Polygon[]; private autobordersEnabled: boolean; + private setupTrailingPoint(circle: SVG.Circle): void { + const head = this.editedShape.attr('points').split(' ').slice(0, this.editData.pointID).join(' '); + circle.on('mouseenter', (): void => { + circle.attr({ + 'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale, + }); + }); + + circle.on('mouseleave', (): void => { + circle.attr({ + 'stroke-width': consts.POINTS_STROKE_WIDTH / this.geometry.scale, + }); + }); + + const minimumPoints = 2; + circle.on('mousedown', (e: MouseEvent): void => { + if (e.button !== 0) return; + const { offset } = this.geometry; + const stringifiedPoints = `${head} ${this.editLine.node.getAttribute('points').slice(0, -2)}`; + const points = pointsToNumberArray(stringifiedPoints).slice(0, -2) + .map((coord: number): number => coord - offset); + + if (points.length >= minimumPoints * 2) { + const { state } = this.editData; + this.edit({ + enabled: false, + }); + this.onEditDone(state, points); + } + }); + } + private startEdit(): void { // get started coordinates const [clientX, clientY] = translateFromSVG( @@ -72,6 +104,14 @@ export class EditHandlerImpl implements EditHandler { }); this.editLine = (this.canvas as any).polyline(); + + if (this.editData.state.shapeType === 'polyline') { + (this.editLine as any).on('drawpoint', (e: CustomEvent): void => { + const circle = (e.target as any).instance.remember('_paintHandler').set.last(); + if (circle) this.setupTrailingPoint(circle); + }); + } + (this.editLine as any).addClass('cvat_canvas_shape_drawing').style({ 'pointer-events': 'none', 'fill-opacity': 0, @@ -110,7 +150,7 @@ export class EditHandlerImpl implements EditHandler { private selectPolygon(shape: SVG.Polygon): void { const { offset } = this.geometry; - const points = pointsToArray(shape.attr('points')) + const points = pointsToNumberArray(shape.attr('points')) .map((coord: number): number => coord - offset); const { state } = this.editData; @@ -149,9 +189,8 @@ export class EditHandlerImpl implements EditHandler { .concat(linePoints) .concat(oldPoints.slice(stop + 1)); - linePoints.reverse(); - const secondPart = oldPoints.slice(start + 1, stop) - .concat(linePoints); + const secondPart = oldPoints.slice(start, stop) + .concat(linePoints.slice(1).reverse()); if (firstPart.length < 3 || secondPart.length < 3) { this.cancel(); @@ -198,7 +237,7 @@ export class EditHandlerImpl implements EditHandler { points = oldPoints.concat(linePoints.slice(0, -1)); } - points = pointsToArray(points.join(' ')) + points = pointsToNumberArray(points.join(' ')) .map((coord: number): number => coord - offset); const { state } = this.editData; diff --git a/cvat-canvas/src/typescript/shared.ts b/cvat-canvas/src/typescript/shared.ts index 0a06c72d80a..2d985e63977 100644 --- a/cvat-canvas/src/typescript/shared.ts +++ b/cvat-canvas/src/typescript/shared.ts @@ -29,6 +29,12 @@ interface Point { x: number; y: number; } + +interface Vector2D { + i: number; + j: number; +} + export interface DrawnState { clientID: number; outside?: boolean; @@ -76,21 +82,6 @@ export function translateToSVG(svg: SVGSVGElement, points: number[]): number[] { return output; } -export function pointsToString(points: number[]): string { - return points.reduce((acc, val, idx): string => { - if (idx % 2) { - return `${acc},${val}`; - } - - return `${acc} ${val}`.trim(); - }, ''); -} - -export function pointsToArray(points: string): number[] { - return points.trim().split(/[,\s]+/g) - .map((coord: string): number => +coord); -} - export function displayShapeSize( shapesContainer: SVG.Container, textContainer: SVG.Container, @@ -120,25 +111,59 @@ export function displayShapeSize( return shapeSize; } -export function convertToArray(points: Point[]): number[][] { - const arr: number[][] = []; - points.forEach((point: Point): void => { - arr.push([point.x, point.y]); - }); - return arr; +export function pointsToNumberArray(points: string | Point[]): number[] { + if (Array.isArray(points)) { + return points.reduce((acc: number[], point: Point): number[] => { + acc.push(point.x, point.y); + return acc; + }, []); + } + + return points.trim().split(/[,\s]+/g) + .map((coord: string): number => +coord); } -export function parsePoints(stringified: string): Point[] { - return stringified.trim().split(/\s/).map((point: string): Point => { +export function parsePoints(source: string | number[]): Point[] { + if (Array.isArray(source)) { + return source.reduce((acc: Point[], _: number, index: number): Point[] => { + if (index % 2) { + acc.push({ + x: source[index - 1], + y: source[index], + }); + } + + return acc; + }, []); + } + + return source.trim().split(/\s/).map((point: string): Point => { const [x, y] = point.split(',').map((coord: string): number => +coord); return { x, y }; }); } -export function stringifyPoints(points: Point[]): string { +export function stringifyPoints(points: (Point | number)[]): string { + if (typeof (points[0]) === 'number') { + return points.reduce((acc: string, val: number, idx: number): string => { + if (idx % 2) { + return `${acc},${val}`; + } + + return `${acc} ${val}`.trim(); + }, ''); + } return points.map((point: Point): string => `${point.x},${point.y}`).join(' '); } export function clamp(x: number, min: number, max: number): number { return Math.min(Math.max(x, min), max); } + +export function scalarProduct(a: Vector2D, b: Vector2D): number { + return a.i * b.i + a.j * b.j; +} + +export function vectorLength(vector: Vector2D): number { + return Math.sqrt((vector.i ** 2) + (vector.j ** 2)); +} diff --git a/cvat-canvas/src/typescript/svg.patch.ts b/cvat-canvas/src/typescript/svg.patch.ts index f571accb4f3..3d88c7daed3 100644 --- a/cvat-canvas/src/typescript/svg.patch.ts +++ b/cvat-canvas/src/typescript/svg.patch.ts @@ -17,7 +17,7 @@ import { Orientation, Edge, } from './cuboid'; -import { parsePoints, stringifyPoints, clamp } from './shared'; +import { parsePoints, clamp } from './shared'; // Update constructor const originalDraw = SVG.Element.prototype.draw; @@ -174,7 +174,8 @@ SVG.Element.prototype.resize = function constructor(...args: any): any { originalResize.call(this, ...args); handler = this.remember('_resizeHandler'); handler.resize = function(e: any) { - if (e.detail.event.button === 0) { + const { event } = e.detail; + if (event.button === 0 && !event.shiftKey && !event.ctrlKey) { return handler.constructor.prototype.resize.call(this, e); } } diff --git a/cvat-core/docs/Interpolation.pdf b/cvat-core/docs/Interpolation.pdf new file mode 100644 index 00000000000..51e7d5ea383 Binary files /dev/null and b/cvat-core/docs/Interpolation.pdf differ diff --git a/cvat-core/package.json b/cvat-core/package.json index b347fcb15b1..5c62139ad00 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "2.1.1", + "version": "3.0.0", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "babel.config.js", "scripts": { diff --git a/cvat-core/src/annotations-objects.js b/cvat-core/src/annotations-objects.js index 20d2e222ba9..c5d0e035d5a 100644 --- a/cvat-core/src/annotations-objects.js +++ b/cvat-core/src/annotations-objects.js @@ -333,7 +333,9 @@ const { width, height } = this.frameMeta[frame]; fittedPoints = fitPoints(this.shapeType, data.points, width, height); - if ((!checkShapeArea(this.shapeType, fittedPoints)) || checkOutside(fittedPoints, width, height)) { + if ((!checkShapeArea(this.shapeType, fittedPoints)) + || checkOutside(fittedPoints, width, height) + ) { fittedPoints = []; } } @@ -1534,13 +1536,12 @@ } interpolatePosition(leftPosition, rightPosition, offset) { - const positionOffset = leftPosition.points.map((point, index) => ( rightPosition.points[index] - point - )) + )); return { - points: leftPosition.points.map((point ,index) => ( + points: leftPosition.points.map((point, index) => ( point + positionOffset[index] * offset )), occluded: leftPosition.occluded, @@ -1556,385 +1557,274 @@ } interpolatePosition(leftPosition, rightPosition, offset) { - function findBox(points) { - let xmin = Number.MAX_SAFE_INTEGER; - let ymin = Number.MAX_SAFE_INTEGER; - let xmax = Number.MIN_SAFE_INTEGER; - let ymax = Number.MIN_SAFE_INTEGER; - - for (let i = 0; i < points.length; i += 2) { - if (points[i] < xmin) xmin = points[i]; - if (points[i + 1] < ymin) ymin = points[i + 1]; - if (points[i] > xmax) xmax = points[i]; - if (points[i + 1] > ymax) ymax = points[i + 1]; - } - + if (offset === 0) { return { - xmin, - ymin, - xmax, - ymax, + points: [...leftPosition.points], + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, }; } - function normalize(points, box) { - const normalized = []; - const width = box.xmax - box.xmin; - const height = box.ymax - box.ymin; + function toArray(points) { + return points.reduce((acc, val) => { + acc.push(val.x, val.y); + return acc; + }, []); + } - for (let i = 0; i < points.length; i += 2) { - normalized.push( - (points[i] - box.xmin) / width, - (points[i + 1] - box.ymin) / height, - ); - } + function toPoints(array) { + return array.reduce((acc, _, index) => { + if (index % 2) { + acc.push({ + x: array[index - 1], + y: array[index], + }); + } + + return acc; + }, []); + } - return normalized; + function curveLength(points) { + return points.slice(1).reduce((acc, _, index) => { + const dx = points[index + 1].x - points[index].x; + const dy = points[index + 1].y - points[index].y; + return acc + Math.sqrt(dx ** 2 + dy ** 2); + }, 0); } - function denormalize(points, box) { - const denormalized = []; - const width = box.xmax - box.xmin; - const height = box.ymax - box.ymin; + function curveToOffsetVec(points, length) { + const offsetVector = [0]; // with initial value + let accumulatedLength = 0; - for (let i = 0; i < points.length; i += 2) { - denormalized.push( - points[i] * width + box.xmin, - points[i + 1] * height + box.ymin, - ); - } + points.slice(1).forEach((_, index) => { + const dx = points[index + 1].x - points[index].x; + const dy = points[index + 1].y - points[index].y; + accumulatedLength += Math.sqrt(dx ** 2 + dy ** 2); + offsetVector.push(accumulatedLength / length); + }); - return denormalized; + return offsetVector; } - function toPoints(array) { - const points = []; - for (let i = 0; i < array.length; i += 2) { - points.push({ - x: array[i], - y: array[i + 1], - }); + function findNearestPair(value, curve) { + let minimum = [0, Math.abs(value - curve[0])]; + for (let i = 1; i < curve.length; i++) { + const distance = Math.abs(value - curve[i]); + if (distance < minimum[1]) { + minimum = [i, distance]; + } } - return points; + return minimum[0]; } - function toArray(points) { - const array = []; - for (const point of points) { - array.push(point.x, point.y); + function matchLeftRight(leftCurve, rightCurve) { + const matching = {}; + for (let i = 0; i < leftCurve.length; i++) { + matching[i] = [findNearestPair(leftCurve[i], rightCurve)]; } - return array; + return matching; } - function computeDistances(source, target) { - const distances = {}; - for (let i = 0; i < source.length; i++) { - distances[i] = distances[i] || {}; - for (let j = 0; j < target.length; j++) { - const dx = source[i].x - target[j].x; - const dy = source[i].y - target[j].y; + function matchRightLeft(leftCurve, rightCurve, leftRightMatching) { + const matchedRightPoints = Object.values(leftRightMatching); + const unmatchedRightPoints = rightCurve.map((_, index) => index) + .filter((index) => !matchedRightPoints.includes(index)); + const updatedMatching = { ...leftRightMatching }; - distances[i][j] = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); - } + for (const rightPoint of unmatchedRightPoints) { + const leftPoint = findNearestPair(rightCurve[rightPoint], leftCurve); + updatedMatching[leftPoint].push(rightPoint); } - return distances; + for (const key of Object.keys(updatedMatching)) { + const sortedRightIndexes = updatedMatching[key] + .sort((a, b) => a - b); + updatedMatching[key] = sortedRightIndexes; + } + + return updatedMatching; } - function truncateByThreshold(mapping, threshold) { - for (const key of Object.keys(mapping)) { - if (mapping[key].distance > threshold) { - delete mapping[key]; + function reduceInterpolation(interpolatedPoints, matching, leftPoints, rightPoints) { + function averagePoint(points) { + let sumX = 0; + let sumY = 0; + for (const point of points) { + sumX += point.x; + sumY += point.y; } - } - } - // https://en.wikipedia.org/wiki/Stable_marriage_problem - // TODO: One of important part of the algorithm is to correctly match - // "corner" points. Thus it is possible for each of such point calculate - // a descriptor (d) and use (x, y, d) to calculate the distance. One more - // idea is to be sure that order or matched points is preserved. For example, - // if p1 matches q1 and p2 matches q2 and between p1 and p2 we don't have any - // points thus we should not have points between q1 and q2 as well. - function stableMarriageProblem(men, women, distances) { - const menPreferences = {}; - for (const man of men) { - menPreferences[man] = women.concat() - .sort((w1, w2) => distances[man][w1] - distances[man][w2]); + return { + x: sumX / points.length, + y: sumY / points.length, + }; } - // Start alghoritm with max N^2 complexity - const womenMaybe = {}; // id woman:id man,distance - const menBusy = {}; // id man:boolean - let prefIndex = 0; - - // While there is at least one free man - while (Object.values(menBusy).length !== men.length) { - // Every man makes offer to the best woman - for (const man of men) { - // The man have already found a woman - if (menBusy[man]) { - continue; - } + function computeDistance(point1, point2) { + return Math.sqrt( + ((point1.x - point2.x) ** 2) + ((point1.y - point2.y) ** 2), + ); + } - const woman = menPreferences[man][prefIndex]; - const distance = distances[man][woman]; + function minimizeSegment(baseLength, N, startInterpolated, stopInterpolated) { + const threshold = baseLength / (2 * N); + const minimized = [interpolatedPoints[startInterpolated]]; + let latestPushed = startInterpolated; + for (let i = startInterpolated + 1; i < stopInterpolated; i++) { + const distance = computeDistance( + interpolatedPoints[latestPushed], interpolatedPoints[i], + ); - // A women chooses the best offer and says "maybe" - if (woman in womenMaybe && womenMaybe[woman].distance > distance) { - // A woman got better offer - const prevChoice = womenMaybe[woman].value; - delete womenMaybe[woman]; - delete menBusy[prevChoice]; + if (distance >= threshold) { + minimized.push(interpolatedPoints[i]); + latestPushed = i; } + } - if (!(woman in womenMaybe)) { - womenMaybe[woman] = { - value: man, - distance, - }; + minimized.push(interpolatedPoints[stopInterpolated]); + + if (minimized.length === 2) { + const distance = computeDistance( + interpolatedPoints[startInterpolated], + interpolatedPoints[stopInterpolated], + ); - menBusy[man] = true; + if (distance < threshold) { + return [averagePoint(minimized)]; } } - prefIndex++; + return minimized; } - const result = {}; - for (const woman of Object.keys(womenMaybe)) { - result[womenMaybe[woman].value] = { - value: woman, - distance: womenMaybe[woman].distance, - }; + const reduced = []; + const interpolatedIndexes = {}; + let accumulated = 0; + for (let i = 0; i < leftPoints.length; i++) { + // eslint-disable-next-line + interpolatedIndexes[i] = matching[i].map(() => accumulated++); } - return result; - } + function leftSegment(start, stop) { + const startInterpolated = interpolatedIndexes[start][0]; + const stopInterpolated = interpolatedIndexes[stop][0]; - function getMapping(source, target) { - function sumEdges(points) { - let result = 0; - for (let i = 1; i < points.length; i += 2) { - const distance = Math.sqrt(Math.pow(points[i].x - points[i - 1].x, 2) - + Math.pow(points[i].y - points[i - 1].y, 2)); - result += distance; + if (startInterpolated === stopInterpolated) { + reduced.push(interpolatedPoints[startInterpolated]); + return; } - // Corner case when work with one point - // Mapping in this case can't be wrong - if (!result) { - return Number.MAX_SAFE_INTEGER; - } + const baseLength = curveLength(leftPoints.slice(start, stop + 1)); + const N = stop - start + 1; - return result; + reduced.push( + ...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated), + ); } - function computeDeviation(points, average) { - let result = 0; - for (let i = 1; i < points.length; i += 2) { - const distance = Math.sqrt(Math.pow(points[i].x - points[i - 1].x, 2) - + Math.pow(points[i].y - points[i - 1].y, 2)); - result += Math.pow(distance - average, 2); - } - - return result; - } + function rightSegment(leftPoint) { + const start = matching[leftPoint][0]; + const [stop] = matching[leftPoint].slice(-1); + const startInterpolated = interpolatedIndexes[leftPoint][0]; + const [stopInterpolated] = interpolatedIndexes[leftPoint].slice(-1); + const baseLength = curveLength(rightPoints.slice(start, stop + 1)); + const N = stop - start + 1; - const processedSource = []; - const processedTarget = []; - - const distances = computeDistances(source, target); - const mapping = stableMarriageProblem(Array.from(source.keys()), - Array.from(target.keys()), distances); - - const average = (sumEdges(target) - + sumEdges(source)) / (target.length + source.length); - const meanSquareDeviation = Math.sqrt((computeDeviation(source, average) - + computeDeviation(target, average)) / (source.length + target.length)); - const threshold = average + 3 * meanSquareDeviation; // 3 sigma rule - truncateByThreshold(mapping, threshold); - for (const key of Object.keys(mapping)) { - mapping[key] = mapping[key].value; + reduced.push( + ...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated), + ); } - // const receivingOrder = Object.keys(mapping).map(x => +x).sort((a,b) => a - b); - const receivingOrder = this.appendMapping(mapping, source, target); + let previousOpened = null; + for (let i = 0; i < leftPoints.length; i++) { + if (matching[i].length === 1) { + // check if left segment is opened + if (previousOpened !== null) { + // check if we should continue the left segment + if (matching[i][0] === matching[previousOpened][0]) { + continue; + } else { + // left segment found + const start = previousOpened; + const stop = i - 1; + leftSegment(start, stop); + + // start next left segment + previousOpened = i; + } + } else { + // start next left segment + previousOpened = i; + } + } else { + // check if left segment is opened + if (previousOpened !== null) { + // left segment found + const start = previousOpened; + const stop = i - 1; + leftSegment(start, stop); + + previousOpened = null; + } - for (const pointIdx of receivingOrder) { - processedSource.push(source[pointIdx]); - processedTarget.push(target[mapping[pointIdx]]); + // right segment found + rightSegment(i); + } } - return [processedSource, processedTarget]; - } + // check if there is an opened segment + if (previousOpened !== null) { + leftSegment(previousOpened, leftPoints.length - 1); + } - if (offset === 0) { - return { - points: [...leftPosition.points], - occluded: leftPosition.occluded, - outside: leftPosition.outside, - zOrder: leftPosition.zOrder, - }; + return reduced; } - let leftBox = findBox(leftPosition.points); - let rightBox = findBox(rightPosition.points); - - // Sometimes (if shape has one point or shape is line), - // We can get box with zero area - // Next computation will be with NaN in this case - // We have to prevent it - const delta = 1; - if (leftBox.xmax - leftBox.xmin < delta || rightBox.ymax - rightBox.ymin < delta) { - leftBox = { - xmin: 0, - xmax: 1024, // TODO: Get actual image size - ymin: 0, - ymax: 768, - }; - - rightBox = leftBox; - } + // the algorithm below is based on fact that both left and right + // polyshapes have the same start point and the same draw direction + const leftPoints = toPoints(leftPosition.points); + const rightPoints = toPoints(rightPosition.points); + const leftOffsetVec = curveToOffsetVec(leftPoints, curveLength(leftPoints)); + const rightOffsetVec = curveToOffsetVec(rightPoints, curveLength(rightPoints)); - const leftPoints = toPoints(normalize(leftPosition.points, leftBox)); - const rightPoints = toPoints(normalize(rightPosition.points, rightBox)); + const matching = matchLeftRight(leftOffsetVec, rightOffsetVec); + const completedMatching = matchRightLeft( + leftOffsetVec, rightOffsetVec, matching, + ); - let newLeftPoints = []; - let newRightPoints = []; - if (leftPoints.length > rightPoints.length) { - const [ - processedRight, - processedLeft, - ] = getMapping.call(this, rightPoints, leftPoints); - newLeftPoints = processedLeft; - newRightPoints = processedRight; - } else { - const [ - processedLeft, - processedRight, - ] = getMapping.call(this, leftPoints, rightPoints); - newLeftPoints = processedLeft; - newRightPoints = processedRight; - } + const interpolatedPoints = Object.keys(completedMatching) + .map((leftPointIdx) => +leftPointIdx).sort((a, b) => a - b) + .reduce((acc, leftPointIdx) => { + const leftPoint = leftPoints[leftPointIdx]; + for (const rightPointIdx of completedMatching[leftPointIdx]) { + const rightPoint = rightPoints[rightPointIdx]; + acc.push({ + x: leftPoint.x + (rightPoint.x - leftPoint.x) * offset, + y: leftPoint.y + (rightPoint.y - leftPoint.y) * offset, + }); + } - const absoluteLeftPoints = denormalize(toArray(newLeftPoints), leftBox); - const absoluteRightPoints = denormalize(toArray(newRightPoints), rightBox); + return acc; + }, []); - const interpolation = []; - for (let i = 0; i < absoluteLeftPoints.length; i++) { - interpolation.push(absoluteLeftPoints[i] + ( - absoluteRightPoints[i] - absoluteLeftPoints[i]) * offset); - } + const reducedPoints = reduceInterpolation( + interpolatedPoints, + completedMatching, + leftPoints, + rightPoints, + ); return { - points: interpolation, + points: toArray(reducedPoints), occluded: leftPosition.occluded, outside: leftPosition.outside, zOrder: leftPosition.zOrder, }; } - - // mapping is predicted order of points sourse_idx:target_idx - // some points from source and target can absent in mapping - // source, target - arrays of points. Target array size >= sourse array size - appendMapping(mapping, source, target) { - const targetMatched = Object.values(mapping).map((x) => +x); - const sourceMatched = Object.keys(mapping).map((x) => +x); - const orderForReceive = []; - - function findNeighbors(point) { - let prev = point; - let next = point; - - if (!targetMatched.length) { - // Prevent infinity loop - throw new ScriptingError('Interpolation mapping is empty'); - } - - while (!targetMatched.includes(prev)) { - prev--; - if (prev < 0) { - prev = target.length - 1; - } - } - - while (!targetMatched.includes(next)) { - next++; - if (next >= target.length) { - next = 0; - } - } - - return [prev, next]; - } - - function computeOffset(point, prev, next) { - const pathPoints = []; - - while (prev !== next) { - pathPoints.push(target[prev]); - prev++; - if (prev >= target.length) { - prev = 0; - } - } - pathPoints.push(target[next]); - - let curveLength = 0; - let offset = 0; - let iCrossed = false; - for (let k = 1; k < pathPoints.length; k++) { - const p1 = pathPoints[k]; - const p2 = pathPoints[k - 1]; - const distance = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)); - - if (!iCrossed) { - offset += distance; - } - curveLength += distance; - if (target[point] === pathPoints[k]) { - iCrossed = true; - } - } - - if (!curveLength) { - return 0; - } - - return offset / curveLength; - } - - for (let i = 0; i < target.length; i++) { - const index = targetMatched.indexOf(i); - if (index === -1) { - // We have to find a neighbours which have been mapped - const [prev, next] = findNeighbors(i); - - // Now compute edge offset - const offset = computeOffset(i, prev, next); - - // Get point between two neighbors points - const prevPoint = target[prev]; - const nextPoint = target[next]; - const autoPoint = { - x: prevPoint.x + (nextPoint.x - prevPoint.x) * offset, - y: prevPoint.y + (nextPoint.y - prevPoint.y) * offset, - }; - - // Put it into matched - source.push(autoPoint); - mapping[source.length - 1] = i; - orderForReceive.push(source.length - 1); - } else { - orderForReceive.push(sourceMatched[index]); - } - } - - return orderForReceive; - } } class PolygonTrack extends PolyTrack { @@ -1945,6 +1835,26 @@ checkNumberOfPoints(this.shapeType, shape.points); } } + + interpolatePosition(leftPosition, rightPosition, offset) { + const copyLeft = { + ...leftPosition, + points: [...leftPosition.points, leftPosition.points[0], leftPosition.points[1]], + }; + + const copyRight = { + ...rightPosition, + points: [...rightPosition.points, rightPosition.points[0], rightPosition.points[1]], + }; + + const result = PolyTrack.prototype.interpolatePosition + .call(this, copyLeft, copyRight, offset); + + return { + ...result, + points: result.points.slice(0, -2), + }; + } } class PolylineTrack extends PolyTrack { @@ -1965,6 +1875,27 @@ checkNumberOfPoints(this.shapeType, shape.points); } } + + interpolatePosition(leftPosition, rightPosition, offset) { + // interpolate only when one point in both left and right positions + if (leftPosition.points.length === 2 && rightPosition.points.length === 2) { + return { + points: leftPosition.points.map( + (value, index) => value + (rightPosition.points[index] - value) * offset, + ), + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, + }; + } + + return { + points: [...leftPosition.points], + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, + }; + } } class CuboidTrack extends Track { @@ -1978,13 +1909,12 @@ } interpolatePosition(leftPosition, rightPosition, offset) { - const positionOffset = leftPosition.points.map((point, index) => ( rightPosition.points[index] - point - )) + )); return { - points: leftPosition.points.map((point ,index) => ( + points: leftPosition.points.map((point, index) => ( point + positionOffset[index] * offset )), occluded: leftPosition.occluded, diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index ae7685c57d4..f521fbcedc4 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.3.2", + "version": "1.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-ui/package.json b/cvat-ui/package.json index e94c9f289b7..0db6a04d12e 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.3.2", + "version": "1.4.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index f6a46224f3c..b669cabf507 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -1487,3 +1487,51 @@ export function repeatDrawShapeAsync(): ThunkAction, {}, {}, AnyAc } }; } + +export function redrawShapeAsync(): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + const { + annotations: { + activatedStateID, + states, + }, + canvas: { + instance: canvasInstance, + }, + } = getStore().getState().annotation; + + if (activatedStateID !== null) { + const [state] = states + .filter((_state: any): boolean => _state.clientID === activatedStateID); + if (state && state.objectType !== ObjectType.TAG) { + let activeControl = ActiveControl.CURSOR; + if (state.shapeType === ShapeType.RECTANGLE) { + activeControl = ActiveControl.DRAW_RECTANGLE; + } else if (state.shapeType === ShapeType.POINTS) { + activeControl = ActiveControl.DRAW_POINTS; + } else if (state.shapeType === ShapeType.POLYGON) { + activeControl = ActiveControl.DRAW_POLYGON; + } else if (state.shapeType === ShapeType.POLYLINE) { + activeControl = ActiveControl.DRAW_POLYLINE; + } else if (state.shapeType === ShapeType.CUBOID) { + activeControl = ActiveControl.DRAW_CUBOID; + } + + dispatch({ + type: AnnotationActionTypes.REPEAT_DRAW_SHAPE, + payload: { + activeControl, + }, + }); + + canvasInstance.cancel(); + canvasInstance.draw({ + enabled: true, + redraw: activatedStateID, + shapeType: state.shapeType, + crosshair: state.shapeType === ShapeType.RECTANGLE, + }); + } + } + }; +} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-point-context-menu.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-point-context-menu.tsx index bd534747e77..2e782a4f40e 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-point-context-menu.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-point-context-menu.tsx @@ -2,40 +2,131 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; +import React, { useState } from 'react'; import ReactDOM from 'react-dom'; import Button from 'antd/lib/button'; import Tooltip from 'antd/lib/tooltip'; +import { connect } from 'react-redux'; -interface Props { - activatedStateID: number | null; +import { CombinedState, ContextMenuType } from 'reducers/interfaces'; +import { updateAnnotationsAsync, updateCanvasContextMenu } from 'actions/annotation-actions'; + +interface StateToProps { + activatedState: any | null; + selectedPoint: number | null; visible: boolean; - left: number; top: number; - onPointDelete(): void; + left: number; + type: ContextMenuType; } -export default function CanvasPointContextMenu(props: Props): JSX.Element | null { +function mapStateToProps(state: CombinedState): StateToProps { const { - onPointDelete, - activatedStateID, + annotation: { + annotations: { + states, + activatedStateID, + }, + canvas: { + contextMenu: { + visible, + top, + left, + type, + pointID: selectedPoint, + }, + }, + }, + } = state; + + return { + activatedState: activatedStateID === null + ? null : states.filter((_state) => _state.clientID === activatedStateID)[0] || null, + selectedPoint, visible, left, top, + type, + }; +} + +interface DispatchToProps { + onUpdateAnnotations(states: any[]): void; + onCloseContextMenu(): void; +} + +function mapDispatchToProps(dispatch: any): DispatchToProps { + return { + onUpdateAnnotations(states: any[]): void { + dispatch(updateAnnotationsAsync(states)); + }, + onCloseContextMenu(): void { + dispatch(updateCanvasContextMenu(false, 0, 0)); + }, + }; +} + +type Props = StateToProps & DispatchToProps; + +function CanvasPointContextMenu(props: Props): React.ReactPortal | null { + const { + onCloseContextMenu, + onUpdateAnnotations, + activatedState, + visible, + type, + top, + left, } = props; - if (!visible || activatedStateID === null) { - return null; + const [contextMenuFor, setContextMenuFor] = useState(activatedState); + + if (activatedState !== contextMenuFor) { + setContextMenuFor(activatedState); + if (visible && type === ContextMenuType.CANVAS_SHAPE_POINT) { + onCloseContextMenu(); + } } - return ReactDOM.createPortal( -
- - - -
, - window.document.body, - ); + const onPointDelete = (): void => { + const { selectedPoint } = props; + if (contextMenuFor && selectedPoint !== null) { + contextMenuFor.points = contextMenuFor.points.slice(0, selectedPoint * 2) + .concat(contextMenuFor.points.slice(selectedPoint * 2 + 2)); + onUpdateAnnotations([contextMenuFor]); + onCloseContextMenu(); + } + }; + + const onSetStartPoint = (): void => { + const { selectedPoint } = props; + if (contextMenuFor && selectedPoint !== null && contextMenuFor.shapeType === 'polygon') { + contextMenuFor.points = contextMenuFor.points.slice(selectedPoint * 2) + .concat(contextMenuFor.points.slice(0, selectedPoint * 2)); + onUpdateAnnotations([contextMenuFor]); + onCloseContextMenu(); + } + }; + + return visible && contextMenuFor && type === ContextMenuType.CANVAS_SHAPE_POINT + ? (ReactDOM.createPortal( +
+ + + + {contextMenuFor && contextMenuFor.shapeType === 'polygon' && ( + + )} +
, + window.document.body, + )) : null; } + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(CanvasPointContextMenu); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx index fb1f8fe1732..0eb7a5d0fc0 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -58,8 +58,6 @@ interface Props { contrastLevel: number; saturationLevel: number; resetZoom: boolean; - contextVisible: boolean; - contextType: ContextMenuType; aamZoomMargin: number; showObjectsTextAlways: boolean; workspace: Workspace; @@ -382,10 +380,9 @@ export default class CanvasWrapperComponent extends React.PureComponent { const { activatedStateID, onUpdateContextMenu, - contextType, } = this.props; - if (contextType !== ContextMenuType.CANVAS_SHAPE_POINT) { + if (e.target && !(e.target as HTMLElement).classList.contains('svg_select_points')) { onUpdateContextMenu(activatedStateID !== null, e.clientX, e.clientY, ContextMenuType.CANVAS_SHAPE); } diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index e4b672c92d7..f6ffeb2ae5b 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -37,13 +37,15 @@ interface Props { repeatDrawShape(): void; pasteShape(): void; resetGroup(): void; + redrawShape(): void; } export default function ControlsSideBarComponent(props: Props): JSX.Element { const { canvasInstance, activeControl, - + normalizedKeyMap, + keyMap, mergeObjects, groupObjects, splitTrack, @@ -51,8 +53,7 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { repeatDrawShape, pasteShape, resetGroup, - normalizedKeyMap, - keyMap, + redrawShape, } = props; const preventDefault = (event: KeyboardEvent | undefined): void => { @@ -89,7 +90,12 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { canvasInstance.cancel(); // repeateDrawShapes gets all the latest parameters // and calls canvasInstance.draw() with them - repeatDrawShape(); + + if (event && event.shiftKey) { + redrawShape(); + } else { + repeatDrawShape(); + } } else { canvasInstance.draw({ enabled: false }); } diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx index 39b3230beb8..070a0c84139 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx @@ -51,10 +51,6 @@ function DrawShapePopoverComponent(props: Props): JSX.Element { onChangeCuboidDrawingMethod, } = props; - const trackDisabled = shapeType === ShapeType.POLYGON - || shapeType === ShapeType.POLYLINE - || (shapeType === ShapeType.POINTS && numberOfPoints !== 1); - return (
@@ -198,7 +194,7 @@ function DrawShapePopoverComponent(props: Props): JSX.Element { - diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index da2bf957266..0dfb658b945 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -38,8 +38,8 @@ import { clamp } from 'utils/math'; function ItemMenu( serverID: number | undefined, locked: boolean, - objectType: ObjectType, shapeType: ShapeType, + objectType: ObjectType, copyShortcut: string, pasteShortcut: string, propagateShortcut: string, @@ -50,9 +50,9 @@ function ItemMenu( remove: (() => void), propagate: (() => void), createURL: (() => void), + switchOrientation: (() => void), toBackground: (() => void), toForeground: (() => void), - switchCuboidOrientation: (() => void), resetCuboidPerspective: (() => void), ): JSX.Element { return ( @@ -76,9 +76,9 @@ function ItemMenu( - {shapeType === ShapeType.CUBOID && ( + { [ShapeType.POLYGON, ShapeType.POLYLINE, ShapeType.CUBOID].includes(shapeType) && ( - @@ -143,8 +143,8 @@ interface ItemTopComponentProps { serverID: number | undefined; labelID: number; labels: any[]; - objectType: ObjectType; shapeType: ShapeType; + objectType: ObjectType; type: string; locked: boolean; copyShortcut: string; @@ -158,9 +158,9 @@ interface ItemTopComponentProps { remove(): void; propagate(): void; createURL(): void; + switchOrientation(): void; toBackground(): void; toForeground(): void; - switchCuboidOrientation(): void; resetCuboidPerspective(): void; } @@ -170,8 +170,8 @@ function ItemTopComponent(props: ItemTopComponentProps): JSX.Element { serverID, labelID, labels, - objectType, shapeType, + objectType, type, locked, copyShortcut, @@ -185,9 +185,9 @@ function ItemTopComponent(props: ItemTopComponentProps): JSX.Element { remove, propagate, createURL, + switchOrientation, toBackground, toForeground, - switchCuboidOrientation, resetCuboidPerspective, } = props; @@ -228,8 +228,8 @@ function ItemTopComponent(props: ItemTopComponentProps): JSX.Element { overlay={ItemMenu( serverID, locked, - objectType, shapeType, + objectType, copyShortcut, pasteShortcut, propagateShortcut, @@ -240,9 +240,9 @@ function ItemTopComponent(props: ItemTopComponentProps): JSX.Element { remove, propagate, createURL, + switchOrientation, toBackground, toForeground, - switchCuboidOrientation, resetCuboidPerspective, )} > @@ -749,6 +749,7 @@ interface Props { copy(): void; propagate(): void; createURL(): void; + switchOrientation(): void; toBackground(): void; toForeground(): void; remove(): void; @@ -768,7 +769,6 @@ interface Props { changeAttribute(attrID: number, value: string): void; changeColor(color: string): void; collapse(): void; - switchCuboidOrientation(): void; resetCuboidPerspective(): void; } @@ -828,6 +828,7 @@ function ObjectItemComponent(props: Props): JSX.Element { copy, propagate, createURL, + switchOrientation, toBackground, toForeground, remove, @@ -847,7 +848,6 @@ function ObjectItemComponent(props: Props): JSX.Element { changeAttribute, changeColor, collapse, - switchCuboidOrientation, resetCuboidPerspective, } = props; @@ -886,9 +886,9 @@ function ObjectItemComponent(props: Props): JSX.Element { clientID={clientID} labelID={labelID} labels={labels} + shapeType={shapeType} objectType={objectType} type={type} - shapeType={shapeType} locked={locked} copyShortcut={normalizedKeyMap.COPY_SHAPE} pasteShortcut={normalizedKeyMap.PASTE_SHAPE} @@ -901,9 +901,9 @@ function ObjectItemComponent(props: Props): JSX.Element { remove={remove} propagate={propagate} createURL={createURL} + switchOrientation={switchOrientation} toBackground={toBackground} toForeground={toForeground} - switchCuboidOrientation={switchCuboidOrientation} resetCuboidPerspective={resetCuboidPerspective} /> - + ); } diff --git a/cvat-ui/src/components/annotation-page/styles.scss b/cvat-ui/src/components/annotation-page/styles.scss index 5c6f2eba710..809b5e29f4f 100644 --- a/cvat-ui/src/components/annotation-page/styles.scss +++ b/cvat-ui/src/components/annotation-page/styles.scss @@ -231,12 +231,10 @@ } .cvat-canvas-point-context-menu { + display: grid; opacity: 0.6; position: fixed; - width: 135px; z-index: 10; - max-height: 50%; - overflow-y: auto; background-color: #ffffff; border-radius: 4px; diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index ad83d05bbbc..b5c3e4408ee 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -53,8 +53,8 @@ interface CVATAppProps { installedAutoAnnotation: boolean; installedTFAnnotation: boolean; installedTFSegmentation: boolean; - userAgreementsFetching: boolean, - userAgreementsInitialized: boolean, + userAgreementsFetching: boolean; + userAgreementsInitialized: boolean; notifications: NotificationsState; user: any; } @@ -72,7 +72,7 @@ class CVATApplication extends React.PureComponent window.document.hasFocus, userActivityCallback); - customWaViewHit(location.pathname, location.search, location.hash); + customWaViewHit(history.location.pathname, history.location.search, history.location.hash); history.listen((location) => { customWaViewHit(location.pathname, location.search, location.hash); }); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-point-context-menu.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-point-context-menu.tsx deleted file mode 100644 index 9dd2975b497..00000000000 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-point-context-menu.tsx +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (C) 2020 Intel Corporation -// -// SPDX-License-Identifier: MIT - -import React from 'react'; - -import { connect } from 'react-redux'; -import { CombinedState, ContextMenuType } from 'reducers/interfaces'; - -import { updateAnnotationsAsync, updateCanvasContextMenu } from 'actions/annotation-actions'; - -import CanvasPointContextMenuComponent from 'components/annotation-page/standard-workspace/canvas-point-context-menu'; - -interface StateToProps { - activatedStateID: number | null; - activatedPointID: number | null; - states: any[]; - visible: boolean; - top: number; - left: number; - type: ContextMenuType; -} - -function mapStateToProps(state: CombinedState): StateToProps { - const { - annotation: { - annotations: { - states, - activatedStateID, - }, - canvas: { - contextMenu: { - visible, - top, - left, - type, - pointID: activatedPointID, - }, - }, - }, - } = state; - - return { - activatedStateID, - activatedPointID, - states, - visible, - left, - top, - type, - }; -} - -interface DispatchToProps { - onUpdateAnnotations(states: any[]): void; - onCloseContextMenu(): void; -} - -function mapDispatchToProps(dispatch: any): DispatchToProps { - return { - onUpdateAnnotations(states: any[]): void { - dispatch(updateAnnotationsAsync(states)); - }, - onCloseContextMenu(): void { - dispatch(updateCanvasContextMenu(false, 0, 0)); - }, - }; -} - -type Props = StateToProps & DispatchToProps; - -interface State { - activatedStateID: number | null; - activatedPointID: number | null; - latestLeft: number; - latestTop: number; - left: number; - top: number; -} - -class CanvasPointContextMenuContainer extends React.PureComponent { - public constructor(props: Props) { - super(props); - - this.state = { - activatedStateID: null, - activatedPointID: null, - latestLeft: 0, - latestTop: 0, - left: 0, - top: 0, - }; - } - - static getDerivedStateFromProps(props: Props, state: State): State { - const newState: State = { ...state }; - - if (props.left !== state.latestLeft - || props.top !== state.latestTop) { - newState.latestLeft = props.left; - newState.latestTop = props.top; - newState.top = props.top; - newState.left = props.left; - } - - if (typeof state.activatedStateID !== typeof props.activatedStateID - || state.activatedPointID !== props.activatedPointID) { - newState.activatedStateID = props.activatedStateID; - newState.activatedPointID = props.activatedPointID; - } - - - return newState; - } - - public componentDidUpdate(): void { - const { - top, - left, - } = this.state; - - const { - innerWidth, - innerHeight, - } = window; - - const [element] = window.document.getElementsByClassName('cvat-canvas-point-context-menu'); - if (element) { - const height = element.clientHeight; - const width = element.clientWidth; - - if (top + height > innerHeight || left + width > innerWidth) { - this.setState({ - top: top - Math.max(top + height - innerHeight, 0), - left: left - Math.max(left + width - innerWidth, 0), - }); - } - } - } - - private deletePoint(): void { - const { - states, - onUpdateAnnotations, - onCloseContextMenu, - } = this.props; - - const { - activatedStateID, - activatedPointID, - } = this.state; - - const [objectState] = states.filter((e) => (e.clientID === activatedStateID)); - if (typeof activatedPointID === 'number') { - objectState.points = objectState.points.slice(0, activatedPointID * 2) - .concat(objectState.points.slice(activatedPointID * 2 + 2)); - onUpdateAnnotations([objectState]); - onCloseContextMenu(); - } - } - - public render(): JSX.Element { - const { - visible, - activatedStateID, - type, - } = this.props; - - const { - top, - left, - } = this.state; - - return ( - <> - {type === ContextMenuType.CANVAS_SHAPE_POINT && ( - this.deletePoint()} - /> - )} - - ); - } -} - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(CanvasPointContextMenuContainer); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx index f5c48061421..8ebe9844b1e 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -83,8 +83,6 @@ interface StateToProps { maxZLayer: number; curZLayer: number; automaticBordering: boolean; - contextVisible: boolean; - contextType: ContextMenuType; switchableAutomaticBordering: boolean; keyMap: Record; } @@ -124,10 +122,6 @@ function mapStateToProps(state: CombinedState): StateToProps { annotation: { canvas: { activeControl, - contextMenu: { - visible: contextVisible, - type: contextType, - }, instance: canvasInstance, }, drawing: { @@ -223,8 +217,6 @@ function mapStateToProps(state: CombinedState): StateToProps { minZLayer, maxZLayer, automaticBordering, - contextVisible, - contextType, workspace, keyMap, switchableAutomaticBordering: activeControl === ActiveControl.DRAW_POLYGON diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index 10be78cd7b9..ae2405caa9c 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -10,6 +10,7 @@ import { mergeObjects, groupObjects, splitTrack, + redrawShapeAsync, rotateCurrentFrame, repeatDrawShapeAsync, pasteShapeAsync, @@ -34,6 +35,7 @@ interface DispatchToProps { resetGroup(): void; repeatDrawShape(): void; pasteShape(): void; + redrawShape(): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -87,6 +89,9 @@ function dispatchToProps(dispatch: any): DispatchToProps { resetGroup(): void { dispatch(resetAnnotationsGroup()); }, + redrawShape(): void { + dispatch(redrawShapeAsync()); + }, }; } diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index 0f3bf9efa26..b2282a1c984 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -7,8 +7,13 @@ import copy from 'copy-to-clipboard'; import { connect } from 'react-redux'; import { LogType } from 'cvat-logger'; -import { Canvas, isAbleToChangeFrame } from 'cvat-canvas-wrapper'; -import { ActiveControl, CombinedState, ColorBy } from 'reducers/interfaces'; +import { Canvas } from 'cvat-canvas-wrapper'; +import { + ActiveControl, + CombinedState, + ColorBy, + ShapeType, +} from 'reducers/interfaces'; import { collapseObjectItems, changeLabelColorAsync, @@ -235,6 +240,33 @@ class ObjectItemContainer extends React.PureComponent { copy(url); }; + private switchOrientation = (): void => { + const { objectState, updateState } = this.props; + if (objectState.shapeType === ShapeType.CUBOID) { + this.switchCuboidOrientation(); + return; + } + + const reducedPoints = objectState.points.reduce( + (acc: number[][], _: number, index: number, array: number[]): number[][] => { + if (index % 2) { + acc.push([array[index - 1], array[index]]); + } + + return acc; + }, [], + ); + + if (objectState.shapeType === ShapeType.POLYGON) { + objectState.points = reducedPoints.slice(0, 1) + .concat(reducedPoints.reverse().slice(0, -1)).flat(); + updateState(objectState); + } else if (objectState.shapeType === ShapeType.POLYLINE) { + objectState.points = reducedPoints.reverse().flat(); + updateState(objectState); + } + }; + private toBackground = (): void => { const { objectState, @@ -394,7 +426,6 @@ class ObjectItemContainer extends React.PureComponent { this.commit(); }; - private switchCuboidOrientation = (): void => { function cuboidOrientationIsLeft(points: number[]): boolean { return points[12] > points[0]; @@ -444,7 +475,7 @@ class ObjectItemContainer extends React.PureComponent { private changeFrame(frame: number): void { const { changeFrame, canvasInstance } = this.props; - if (isAbleToChangeFrame(canvasInstance)) { + if (canvasInstance.isAbleToChangeFrame()) { changeFrame(frame); } } @@ -534,6 +565,7 @@ class ObjectItemContainer extends React.PureComponent { copy={this.copy} propagate={this.propagate} createURL={this.createURL} + switchOrientation={this.switchOrientation} toBackground={this.toBackground} toForeground={this.toForeground} setOccluded={this.setOccluded} @@ -552,7 +584,6 @@ class ObjectItemContainer extends React.PureComponent { changeLabel={this.changeLabel} changeAttribute={this.changeAttribute} collapse={this.collapse} - switchCuboidOrientation={this.switchCuboidOrientation} resetCuboidPerspective={() => this.resetCuboidPerspective()} /> ); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index f0603a2e417..43e6e2659ef 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -17,7 +17,7 @@ import { changeGroupColorAsync, changeLabelColorAsync, } from 'actions/annotation-actions'; -import { Canvas, isAbleToChangeFrame } from 'cvat-canvas-wrapper'; +import { Canvas } from 'cvat-canvas-wrapper'; import { CombinedState, StatesOrdering, @@ -446,7 +446,7 @@ class ObjectsListContainer extends React.PureComponent { if (state && state.objectType === ObjectType.TRACK) { const frame = typeof (state.keyframes.next) === 'number' ? state.keyframes.next : null; - if (frame !== null && isAbleToChangeFrame(canvasInstance)) { + if (frame !== null && canvasInstance.isAbleToChangeFrame()) { changeFrame(frame); } } @@ -457,7 +457,7 @@ class ObjectsListContainer extends React.PureComponent { if (state && state.objectType === ObjectType.TRACK) { const frame = typeof (state.keyframes.prev) === 'number' ? state.keyframes.prev : null; - if (frame !== null && isAbleToChangeFrame(canvasInstance)) { + if (frame !== null && canvasInstance.isAbleToChangeFrame()) { changeFrame(frame); } } diff --git a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx index ad773de3e77..5f759c4e97b 100644 --- a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx @@ -23,7 +23,7 @@ import { changeWorkspace as changeWorkspaceAction, activateObject, } from 'actions/annotation-actions'; -import { Canvas, isAbleToChangeFrame } from 'cvat-canvas-wrapper'; +import { Canvas } from 'cvat-canvas-wrapper'; import AnnotationTopBarComponent from 'components/annotation-page/top-bar/top-bar'; import { CombinedState, FrameSpeed, Workspace } from 'reducers/interfaces'; @@ -222,7 +222,7 @@ class AnnotationTopBarContainer extends React.PureComponent { setTimeout(() => { const { playing: stillPlaying } = this.props; if (stillPlaying) { - if (isAbleToChangeFrame(canvasInstance)) { + if (canvasInstance.isAbleToChangeFrame()) { onChangeFrame( frameNumber + 1 + framesSkiped, stillPlaying, framesSkiped + 1, @@ -252,7 +252,7 @@ class AnnotationTopBarContainer extends React.PureComponent { canvasInstance, } = this.props; - if (isAbleToChangeFrame(canvasInstance)) { + if (canvasInstance.isAbleToChangeFrame()) { undo(jobInstance, frameNumber); } }; @@ -265,7 +265,7 @@ class AnnotationTopBarContainer extends React.PureComponent { canvasInstance, } = this.props; - if (isAbleToChangeFrame(canvasInstance)) { + if (canvasInstance.isAbleToChangeFrame()) { redo(jobInstance, frameNumber); } }; @@ -446,7 +446,7 @@ class AnnotationTopBarContainer extends React.PureComponent { private changeFrame(frame: number): void { const { onChangeFrame, canvasInstance } = this.props; - if (isAbleToChangeFrame(canvasInstance)) { + if (canvasInstance.isAbleToChangeFrame()) { onChangeFrame(frame); } } @@ -551,7 +551,7 @@ class AnnotationTopBarContainer extends React.PureComponent { SEARCH_FORWARD: (event: KeyboardEvent | undefined) => { preventDefault(event); if (frameNumber + 1 <= stopFrame && canvasIsReady - && isAbleToChangeFrame(canvasInstance) + && canvasInstance.isAbleToChangeFrame() ) { searchAnnotations(jobInstance, frameNumber + 1, stopFrame); } @@ -559,7 +559,7 @@ class AnnotationTopBarContainer extends React.PureComponent { SEARCH_BACKWARD: (event: KeyboardEvent | undefined) => { preventDefault(event); if (frameNumber - 1 >= startFrame && canvasIsReady - && isAbleToChangeFrame(canvasInstance) + && canvasInstance.isAbleToChangeFrame() ) { searchAnnotations(jobInstance, frameNumber - 1, startFrame); } diff --git a/cvat-ui/src/cvat-canvas-wrapper.ts b/cvat-ui/src/cvat-canvas-wrapper.ts index 1c8dfa7ed53..631a52a915f 100644 --- a/cvat-ui/src/cvat-canvas-wrapper.ts +++ b/cvat-ui/src/cvat-canvas-wrapper.ts @@ -10,16 +10,10 @@ import { CuboidDrawingMethod, } from 'cvat-canvas/src/typescript/canvas'; -function isAbleToChangeFrame(canvas: Canvas): boolean { - return ![CanvasMode.DRAG, CanvasMode.EDIT, CanvasMode.RESIZE] - .includes(canvas.mode()); -} - export { Canvas, CanvasMode, CanvasVersion, RectDrawingMethod, CuboidDrawingMethod, - isAbleToChangeFrame, }; diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 6031435975c..0cf7ffcd8db 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -305,7 +305,6 @@ export enum StatesOrdering { } export enum ContextMenuType { - CANVAS = 'canvas', CANVAS_SHAPE = 'canvas_shape', CANVAS_SHAPE_POINT = 'canvas_shape_point', } diff --git a/cvat-ui/src/reducers/shortcuts-reducer.ts b/cvat-ui/src/reducers/shortcuts-reducer.ts index f42ed5d4eb9..c72aae78f9a 100644 --- a/cvat-ui/src/reducers/shortcuts-reducer.ts +++ b/cvat-ui/src/reducers/shortcuts-reducer.ts @@ -207,8 +207,8 @@ const defaultKeyMap = { }, SWITCH_DRAW_MODE: { name: 'Draw mode', - description: 'Repeat the latest procedure of drawing with the same parameters', - sequences: ['n'], + description: 'Repeat the latest procedure of drawing with the same parameters (shift to redraw an existing shape)', + sequences: ['shift+n', 'n'], action: 'keydown', }, SWITCH_MERGE_MODE: { diff --git a/cvat/apps/dataset_manager/annotation.py b/cvat/apps/dataset_manager/annotation.py index df66c99bbf9..54b0f21a647 100644 --- a/cvat/apps/dataset_manager/annotation.py +++ b/cvat/apps/dataset_manager/annotation.py @@ -442,34 +442,284 @@ def normalize_shape(shape): @staticmethod def get_interpolated_shapes(track, start_frame, end_frame): - def interpolate(shape0, shape1): + def copy_shape(source, frame, points = None): + copied = deepcopy(source) + copied["keyframe"] = True + copied["frame"] = frame + if points: + copied["points"] = points + return copied + + def simple_interpolation(shape0, shape1): + shapes = [] + distance = shape1["frame"] - shape0["frame"] + diff = np.subtract(shape1["points"], shape0["points"]) + + for frame in range(shape0["frame"] + 1, shape1["frame"]): + offset = (frame - shape0["frame"]) / distance + points = None + if shape1["outside"]: + points = np.asarray(shape0["points"]) + else: + points = shape0["points"] + diff * offset + + shapes.append(copy_shape(shape0, frame, points.tolist())) + + return shapes + + def points_interpolation(shape0, shape1): + if len(shape0["points"]) == 2 and len(shape1["points"]) == 2: + return simple_interpolation(shape0, shape1) + else: + shapes = [] + for frame in range(shape0["frame"] + 1, shape1["frame"]): + shapes.append(copy_shape(shape0, frame)) + + return shapes + + def interpolate_position(left_position, right_position, offset): + def to_array(points): + return np.asarray( + list(map(lambda point: [point["x"], point["y"]], points)) + ).flatten() + + def to_points(array): + return list(map( + lambda point: {"x": point[0], "y": point[1]}, np.asarray(array).reshape(-1, 2) + )) + + def curve_length(points): + length = 0 + for i in range(1, len(points)): + dx = points[i]["x"] - points[i - 1]["x"] + dy = points[i]["y"] - points[i - 1]["y"] + length += np.sqrt(dx ** 2 + dy ** 2) + return length + + def curve_to_offset_vec(points, length): + offset_vector = [0] + accumulated_length = 0 + for i in range(1, len(points)): + dx = points[i]["x"] - points[i - 1]["x"] + dy = points[i]["y"] - points[i - 1]["y"] + accumulated_length += np.sqrt(dx ** 2 + dy ** 2) + offset_vector.append(accumulated_length / length) + + return offset_vector + + def find_nearest_pair(value, curve): + minimum = [0, abs(value - curve[0])] + for i in range(1, len(curve)): + distance = abs(value - curve[i]) + if distance < minimum[1]: + minimum = [i, distance] + + return minimum[0] + + def match_left_right(left_curve, right_curve): + matching = {} + for i, left_curve_item in enumerate(left_curve): + matching[i] = [find_nearest_pair(left_curve_item, right_curve)] + return matching + + def match_right_left(left_curve, right_curve, left_right_matching): + matched_right_points = left_right_matching.values() + unmatched_right_points = filter(lambda x: x not in matched_right_points, range(len(right_curve))) + updated_matching = deepcopy(left_right_matching) + + for right_point in unmatched_right_points: + left_point = find_nearest_pair(right_curve[right_point], left_curve) + updated_matching[left_point].append(right_point) + + for key, value in updated_matching.items(): + updated_matching[key] = sorted(value) + + return updated_matching + + def reduce_interpolation(interpolated_points, matching, left_points, right_points): + def average_point(points): + sumX = 0 + sumY = 0 + for point in points: + sumX += point["x"] + sumY += point["y"] + + return { + "x": sumX / len(points), + "y": sumY / len(points) + } + + def compute_distance(point1, point2): + return np.sqrt( + ((point1["x"] - point2["x"])) ** 2 + + ((point1["y"] - point2["y"]) ** 2) + ) + + def minimize_segment(base_length, N, start_interpolated, stop_interpolated): + threshold = base_length / (2 * N) + minimized = [interpolated_points[start_interpolated]] + latest_pushed = start_interpolated + for i in range(start_interpolated + 1, stop_interpolated): + distance = compute_distance( + interpolated_points[latest_pushed], interpolated_points[i] + ) + + if distance >= threshold: + minimized.append(interpolated_points[i]) + latest_pushed = i + + minimized.append(interpolated_points[stop_interpolated]) + + if len(minimized) == 2: + distance = compute_distance( + interpolated_points[start_interpolated], + interpolated_points[stop_interpolated] + ) + + if distance < threshold: + return [average_point(minimized)] + + return minimized + + reduced = [] + interpolated_indexes = {} + accumulated = 0 + for i in range(len(left_points)): + interpolated_indexes[i] = [] + for _ in range(len(matching[i])): + interpolated_indexes[i].append(accumulated) + accumulated += 1 + + def left_segment(start, stop): + start_interpolated = interpolated_indexes[start][0] + stop_interpolated = interpolated_indexes[stop][0] + + if start_interpolated == stop_interpolated: + reduced.append(interpolated_points[start_interpolated]) + return + + base_length = curve_length(left_points[start: stop + 1]) + N = stop - start + 1 + + reduced.extend( + minimize_segment(base_length, N, start_interpolated, stop_interpolated) + ) + + + def right_segment(left_point): + start = matching[left_point][0] + stop = matching[left_point][-1] + start_interpolated = interpolated_indexes[left_point][0] + stop_interpolated = interpolated_indexes[left_point][-1] + base_length = curve_length(right_points[start: stop + 1]) + N = stop - start + 1 + + reduced.extend( + minimize_segment(base_length, N, start_interpolated, stop_interpolated) + ) + + previous_opened = None + for i in range(len(left_points)): + if len(matching[i]) == 1: + if previous_opened is not None: + if matching[i][0] == matching[previous_opened][0]: + continue + else: + start = previous_opened + stop = i - 1 + left_segment(start, stop) + previous_opened = i + else: + previous_opened = i + else: + if previous_opened is not None: + start = previous_opened + stop = i - 1 + left_segment(start, stop) + previous_opened = None + + right_segment(i) + + if previous_opened is not None: + left_segment(previous_opened, len(left_points) - 1) + + return reduced + + left_points = to_points(left_position["points"]) + right_points = to_points(right_position["points"]) + left_offset_vec = curve_to_offset_vec(left_points, curve_length(left_points)) + right_offset_vec = curve_to_offset_vec(right_points, curve_length(right_points)) + + matching = match_left_right(left_offset_vec, right_offset_vec) + completed_matching = match_right_left( + left_offset_vec, right_offset_vec, matching + ) + + interpolated_points = [] + for left_point_index, left_point in enumerate(left_points): + for right_point_index in completed_matching[left_point_index]: + right_point = right_points[right_point_index] + interpolated_points.append({ + "x": left_point["x"] + (right_point["x"] - left_point["x"]) * offset, + "y": left_point["y"] + (right_point["y"] - left_point["y"]) * offset + }) + + reducedPoints = reduce_interpolation( + interpolated_points, + completed_matching, + left_points, + right_points + ) + + return to_array(reducedPoints).tolist() + + def polyshape_interpolation(shape0, shape1): shapes = [] - is_same_type = shape0["type"] == shape1["type"] is_polygon = shape0["type"] == ShapeType.POLYGON - is_polyline = shape0["type"] == ShapeType.POLYLINE - is_same_size = len(shape0["points"]) == len(shape1["points"]) - if not is_same_type or is_polygon or is_polyline or not is_same_size: - shape0 = TrackManager.normalize_shape(shape0) - shape1 = TrackManager.normalize_shape(shape1) + if is_polygon: + shape0["points"].extend(shape0["points"][:2]) + shape1["points"].extend(shape1["points"][:2]) distance = shape1["frame"] - shape0["frame"] - step = np.subtract(shape1["points"], shape0["points"]) / distance for frame in range(shape0["frame"] + 1, shape1["frame"]): - off = frame - shape0["frame"] + offset = (frame - shape0["frame"]) / distance + points = None if shape1["outside"]: - points = np.asarray(shape0["points"]).reshape(-1, 2) - else: - points = (shape0["points"] + step * off).reshape(-1, 2) - shape = deepcopy(shape0) - if len(points) == 1: - shape["points"] = points.flatten() + points = np.asarray(shape0["points"]) else: - broken_line = geometry.LineString(points).simplify(0.05, False) - shape["points"] = [x for p in broken_line.coords for x in p] + points = interpolate_position(shape0, shape1, offset) + + shapes.append(copy_shape(shape0, frame, points)) + + if is_polygon: + shape0["points"] = shape0["points"][:-2] + shape1["points"] = shape1["points"][:-2] + for shape in shapes: + shape["points"] = shape["points"][:-2] + + return shapes + + def interpolate(shape0, shape1): + is_same_type = shape0["type"] == shape1["type"] + is_rectangle = shape0["type"] == ShapeType.RECTANGLE + is_cuboid = shape0["type"] == ShapeType.CUBOID + is_polygon = shape0["type"] == ShapeType.POLYGON + is_polyline = shape0["type"] == ShapeType.POLYLINE + is_points = shape0["type"] == ShapeType.POINTS + + if not is_same_type: + raise NotImplementedError() + + shapes = [] + if is_rectangle or is_cuboid: + shapes = simple_interpolation(shape0, shape1) + elif is_points: + shapes = points_interpolation(shape0, shape1) + elif is_polygon or is_polyline: + shapes = polyshape_interpolation(shape0, shape1) + else: + raise NotImplementedError() - shape["keyframe"] = False - shape["frame"] = frame - shapes.append(shape) return shapes if track.get("interpolated_shapes"):