diff --git a/CHANGELOG.md b/CHANGELOG.md index df36379d..4f23da5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Option to display shape text always - Dedicated message with clarifications when share is unmounted (https://github.com/opencv/cvat/pull/1373) - Ability to create one tracked point (https://github.com/opencv/cvat/pull/1383) +- Ability to draw/edit polygons and polylines with automatic bordering feature (https://github.com/opencv/cvat/pull/1394) - Tutorial: instructions for CVAT over HTTPS - Added deep extreme cut (semi-automatic segmentation) to the new UI (https://github.com/opencv/cvat/pull/1398) + ### Changed - Increase preview size of a task till 256, 256 on the server - Minor style updates diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md index 7d8e4e1c..00d60cef 100644 --- a/cvat-canvas/README.md +++ b/cvat-canvas/README.md @@ -153,6 +153,7 @@ Standard JS events are used. - canvas.fit - canvas.dragshape => {id: number} - canvas.resizeshape => {id: number} + - canvas.contextmenu => { mouseEvent: MouseEvent, objectState: ObjectState, pointID: number } ``` ### WEB @@ -196,7 +197,7 @@ Standard JS events are used. | dragCanvas() | + | - | - | - | - | - | + | - | - | + | | zoomCanvas() | + | - | - | - | - | - | - | + | + | - | | cancel() | - | + | + | + | + | + | + | + | + | + | -| configure() | + | - | - | - | - | - | - | - | - | - | +| configure() | + | + | + | + | + | + | + | + | + | + | | bitmap() | + | + | + | + | + | + | + | + | + | + | | setZLayer() | + | + | + | + | + | + | + | + | + | + | diff --git a/cvat-canvas/src/scss/canvas.scss b/cvat-canvas/src/scss/canvas.scss index fab01d0b..2f8a081c 100644 --- a/cvat-canvas/src/scss/canvas.scss +++ b/cvat-canvas/src/scss/canvas.scss @@ -103,6 +103,24 @@ polyline.cvat_canvas_shape_splitting { stroke-dasharray: 5; } +.cvat_canvas_autoborder_point { + opacity: 0.55; +} + +.cvat_canvas_autoborder_point:hover { + opacity: 1; + fill: red; +} + +.cvat_canvas_autoborder_point:active { + opacity: 0.55; + fill: red; +} + +.cvat_canvas_autoborder_point_direction { + fill: blueviolet; +} + .svg_select_boundingRect { opacity: 0; pointer-events: none; diff --git a/cvat-canvas/src/typescript/autoborderHandler.ts b/cvat-canvas/src/typescript/autoborderHandler.ts new file mode 100644 index 00000000..8f042948 --- /dev/null +++ b/cvat-canvas/src/typescript/autoborderHandler.ts @@ -0,0 +1,301 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import * as SVG from 'svg.js'; + +import consts from './consts'; +import { Geometry } from './canvasModel'; + +interface TransformedShape { + points: string; + color: string; +} + +export interface AutoborderHandler { + autoborder(enabled: boolean, currentShape?: SVG.Shape, ignoreCurrent?: boolean): void; + transform(geometry: Geometry): void; + updateObjects(): void; +} + +export class AutoborderHandlerImpl implements AutoborderHandler { + private currentShape: SVG.Shape | null; + private ignoreCurrent: boolean; + private frameContent: SVGSVGElement; + private enabled: boolean; + private scale: number; + private groups: SVGGElement[]; + private auxiliaryGroupID: number | null; + private auxiliaryClicks: number[]; + private listeners: Record void; + dblclick: (event: MouseEvent) => void; + }>>; + + public constructor(frameContent: SVGSVGElement) { + this.frameContent = frameContent; + this.ignoreCurrent = false; + this.currentShape = null; + this.enabled = false; + this.scale = 1; + this.groups = []; + this.auxiliaryGroupID = null; + this.auxiliaryClicks = []; + this.listeners = {}; + } + + private removeMarkers(): void { + this.groups.forEach((group: SVGGElement): void => { + const groupID = group.dataset.groupId; + Array.from(group.children) + .forEach((circle: SVGCircleElement, pointID: number): void => { + circle.removeEventListener('click', this.listeners[+groupID][pointID].click); + circle.removeEventListener('dblclick', this.listeners[+groupID][pointID].click); + circle.remove(); + }); + + group.remove(); + }); + + this.groups = []; + this.auxiliaryGroupID = null; + this.auxiliaryClicks = []; + this.listeners = {}; + } + + private release(): void { + this.removeMarkers(); + this.enabled = false; + this.currentShape = null; + } + + private addPointToCurrentShape(x: number, y: number): void { + const array: number[][] = (this.currentShape as any).array().valueOf(); + array.pop(); + + // need to append twice (specific of the library) + array.push([x, y]); + array.push([x, y]); + + const paintHandler = this.currentShape.remember('_paintHandler'); + paintHandler.drawCircles(); + paintHandler.set.members.forEach((el: SVG.Circle): void => { + el.attr('stroke-width', 1 / this.scale).attr('r', 2.5 / this.scale); + }); + (this.currentShape as any).plot(array); + } + + private resetAuxiliaryShape(): void { + if (this.auxiliaryGroupID !== null) { + while (this.auxiliaryClicks.length > 0) { + const resetID = this.auxiliaryClicks.pop(); + this.groups[this.auxiliaryGroupID] + .children[resetID].classList.remove('cvat_canvas_autoborder_point_direction'); + } + } + + this.auxiliaryClicks = []; + this.auxiliaryGroupID = null; + } + + // convert each shape to group of clicable points + // save all groups + private drawMarkers(transformedShapes: TransformedShape[]): void { + const svgNamespace = 'http://www.w3.org/2000/svg'; + + this.groups = transformedShapes + .map((shape: TransformedShape, groupID: number): SVGGElement => { + const group = document.createElementNS(svgNamespace, 'g'); + group.setAttribute('data-group-id', `${groupID}`); + + this.listeners[groupID] = this.listeners[groupID] || {}; + const circles = shape.points.split(/\s/).map(( + point: string, pointID: number, points: string[], + ): SVGCircleElement => { + const [x, y] = point.split(','); + + const circle = document.createElementNS(svgNamespace, 'circle'); + circle.classList.add('cvat_canvas_autoborder_point'); + circle.setAttribute('fill', shape.color); + circle.setAttribute('stroke', 'black'); + circle.setAttribute('stroke-width', `${consts.POINTS_STROKE_WIDTH / this.scale}`); + circle.setAttribute('cx', x); + circle.setAttribute('cy', y); + circle.setAttribute('r', `${consts.BASE_POINT_SIZE / this.scale}`); + + const click = (event: MouseEvent): void => { + event.stopPropagation(); + + // another shape was clicked + if (this.auxiliaryGroupID !== null + && this.auxiliaryGroupID !== groupID + ) { + this.resetAuxiliaryShape(); + } + + this.auxiliaryGroupID = groupID; + // up clicked group for convenience + this.frameContent.appendChild(group); + + if (this.auxiliaryClicks[1] === pointID) { + // the second point was clicked twice + this.addPointToCurrentShape(+x, +y); + this.resetAuxiliaryShape(); + return; + } + + // the first point can not be clicked twice + // just ignore such a click if it is + if (this.auxiliaryClicks[0] !== pointID) { + this.auxiliaryClicks.push(pointID); + } else { + return; + } + + // it is the first click + if (this.auxiliaryClicks.length === 1) { + const handler = this.currentShape.remember('_paintHandler'); + // draw and remove initial point just to initialize data structures + if (!handler || !handler.startPoint) { + (this.currentShape as any).draw('point', event); + (this.currentShape as any).draw('undo'); + } + + this.addPointToCurrentShape(+x, +y); + // is is the second click + } else if (this.auxiliaryClicks.length === 2) { + circle.classList.add('cvat_canvas_autoborder_point_direction'); + // it is the third click + } else { + // sign defines bypass direction + const landmarks = this.auxiliaryClicks; + const sign = Math.sign(landmarks[2] - landmarks[0]) + * Math.sign(landmarks[1] - landmarks[0]) + * Math.sign(landmarks[2] - landmarks[1]); + + // go via a polygon and get vertexes + // the first vertex has been already drawn + const way = []; + for (let i = landmarks[0] + sign; ; i += sign) { + if (i < 0) { + i = points.length - 1; + } else if (i === points.length) { + i = 0; + } + + way.push(points[i]); + + if (i === this.auxiliaryClicks[this.auxiliaryClicks.length - 1]) { + // put the last element twice + // specific of svg.draw.js + // way.push(points[i]); + break; + } + } + + // remove the latest cursor position from drawing array + for (const wayPoint of way) { + const [_x, _y] = wayPoint.split(',') + .map((coordinate: string): number => +coordinate); + this.addPointToCurrentShape(_x, _y); + } + + this.resetAuxiliaryShape(); + } + }; + + + const dblclick = (event: MouseEvent): void => { + event.stopPropagation(); + }; + + this.listeners[groupID][pointID] = { + click, + dblclick, + }; + + circle.addEventListener('mousedown', this.listeners[groupID][pointID].click); + circle.addEventListener('dblclick', this.listeners[groupID][pointID].click); + return circle; + }); + + group.append(...circles); + return group; + }); + + this.frameContent.append(...this.groups); + } + + public updateObjects(): void { + if (!this.enabled) return; + this.removeMarkers(); + + const currentClientID = this.currentShape.node.dataset.originClientId; + const shapes = Array.from(this.frameContent.getElementsByClassName('cvat_canvas_shape')); + const transformedShapes = shapes.map((shape: HTMLElement): TransformedShape | null => { + const color = shape.getAttribute('fill'); + const clientID = shape.getAttribute('clientID'); + + if (color === null || clientID === null) return null; + if (+clientID === +currentClientID) { + return null; + } + + let points = ''; + if (shape.tagName === 'polyline' || shape.tagName === 'polygon') { + points = shape.getAttribute('points'); + } else if (shape.tagName === 'rect') { + const x = +shape.getAttribute('x'); + const y = +shape.getAttribute('y'); + const width = +shape.getAttribute('width'); + const height = +shape.getAttribute('height'); + + if (Number.isNaN(x) || Number.isNaN(y) || Number.isNaN(x) || Number.isNaN(x)) { + return null; + } + + points = `${x},${y} ${x + width},${y} ${x + width},${y + height} ${x},${y + height}`; + } else if (shape.tagName === 'g') { + const polylineID = shape.dataset.polylineId; + const polyline = this.frameContent.getElementById(polylineID); + if (polyline && polyline.getAttribute('points')) { + points = polyline.getAttribute('points'); + } else { + return null; + } + } + + return { + color, + points: points.trim(), + }; + }).filter((state: TransformedShape | null): boolean => state !== null); + + this.drawMarkers(transformedShapes); + } + + public autoborder( + enabled: boolean, + currentShape?: SVG.Shape, + ignoreCurrent: boolean = false, + ): void { + if (enabled && !this.enabled && currentShape) { + this.enabled = true; + this.currentShape = currentShape; + this.ignoreCurrent = ignoreCurrent; + this.updateObjects(); + } else { + this.release(); + } + } + + public transform(geometry: Geometry): void { + this.scale = geometry.scale; + this.groups.forEach((group: SVGGElement): void => { + Array.from(group.children).forEach((circle: SVGCircleElement): void => { + circle.setAttribute('r', `${consts.BASE_POINT_SIZE / this.scale}`); + circle.setAttribute('stroke-width', `${consts.BASE_STROKE_WIDTH / this.scale}`); + }); + }); + } +} diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index 15a92f62..f7f095ea 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -47,6 +47,7 @@ export enum RectDrawingMethod { } export interface Configuration { + autoborders?: boolean; displayAllText?: boolean; undefinedAttrValue?: string; } @@ -206,6 +207,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { }, configuration: { displayAllText: false, + autoborders: false, undefinedAttrValue: '', }, imageBitmap: false, @@ -519,14 +521,14 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { } public configure(configuration: Configuration): void { - if (this.data.mode !== Mode.IDLE) { - throw Error(`Canvas is busy. Action: ${this.data.mode}`); - } - if (typeof (configuration.displayAllText) !== 'undefined') { this.data.configuration.displayAllText = configuration.displayAllText; } + if (typeof (configuration.autoborders) !== 'undefined') { + this.data.configuration.autoborders = configuration.autoborders; + } + if (typeof (configuration.undefinedAttrValue) !== 'undefined') { this.data.configuration.undefinedAttrValue = configuration.undefinedAttrValue; } diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index b347bbc5..801dde51 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -16,6 +16,7 @@ import { MergeHandler, MergeHandlerImpl } from './mergeHandler'; import { SplitHandler, SplitHandlerImpl } from './splitHandler'; import { GroupHandler, GroupHandlerImpl } from './groupHandler'; import { ZoomHandler, ZoomHandlerImpl } from './zoomHandler'; +import { AutoborderHandler, AutoborderHandlerImpl } from './autoborderHandler'; import consts from './consts'; import { translateToSVG, @@ -23,6 +24,7 @@ import { pointsToArray, displayShapeSize, ShapeSizeElement, + DrawnState, } from './shared'; import { CanvasModel, @@ -58,7 +60,7 @@ export class CanvasViewImpl implements CanvasView, Listener { private controller: CanvasController; private svgShapes: Record; private svgTexts: Record; - private drawnStates: Record; + private drawnStates: Record; private geometry: Geometry; private drawHandler: DrawHandler; private editHandler: EditHandler; @@ -66,6 +68,7 @@ export class CanvasViewImpl implements CanvasView, Listener { private splitHandler: SplitHandler; private groupHandler: GroupHandler; private zoomHandler: ZoomHandler; + private autoborderHandler: AutoborderHandler; private activeElement: ActiveElement; private configuration: Configuration; @@ -358,6 +361,7 @@ export class CanvasViewImpl implements CanvasView, Listener { // Transform handlers this.drawHandler.transform(this.geometry); this.editHandler.transform(this.geometry); + this.autoborderHandler.transform(this.geometry); } private resizeCanvas(): void { @@ -421,6 +425,8 @@ export class CanvasViewImpl implements CanvasView, Listener { this.activate(this.controller.activeElement); } } + + this.autoborderHandler.updateObjects(); } } @@ -461,7 +467,7 @@ export class CanvasViewImpl implements CanvasView, Listener { e.preventDefault(); } - function contextmenuHandler(e: MouseEvent): void { + function contextMenuHandler(e: MouseEvent): void { const pointID = Array.prototype.indexOf .call(((e.target as HTMLElement).parentElement as HTMLElement).children, e.target); if (self.activeElement.clientID !== null) { @@ -469,7 +475,7 @@ export class CanvasViewImpl implements CanvasView, Listener { .filter((_state: any): boolean => ( _state.clientID === self.activeElement.clientID )); - self.canvas.dispatchEvent(new CustomEvent('point.contextmenu', { + self.canvas.dispatchEvent(new CustomEvent('canvas.contextmenu', { bubbles: false, cancelable: true, detail: { @@ -504,7 +510,7 @@ export class CanvasViewImpl implements CanvasView, Listener { }); circle.on('dblclick', dblClickHandler); - circle.on('contextmenu', contextmenuHandler); + circle.on('contextmenu', contextMenuHandler); circle.addClass('cvat_canvas_selected_point'); }); @@ -514,7 +520,7 @@ export class CanvasViewImpl implements CanvasView, Listener { }); circle.off('dblclick', dblClickHandler); - circle.off('contextmenu', contextmenuHandler); + circle.off('contextmenu', contextMenuHandler); circle.removeClass('cvat_canvas_selected_point'); }); @@ -622,14 +628,19 @@ export class CanvasViewImpl implements CanvasView, Listener { const self = this; // Setup API handlers + this.autoborderHandler = new AutoborderHandlerImpl( + this.content, + ); this.drawHandler = new DrawHandlerImpl( this.onDrawDone.bind(this), this.adoptedContent, this.adoptedText, + this.autoborderHandler, ); this.editHandler = new EditHandlerImpl( this.onEditDone.bind(this), this.adoptedContent, + this.autoborderHandler, ); this.mergeHandler = new MergeHandlerImpl( this.onMergeDone.bind(this), @@ -714,8 +725,13 @@ export class CanvasViewImpl implements CanvasView, Listener { this.geometry = this.controller.geometry; if (reason === UpdateReasons.CONFIG_UPDATED) { this.configuration = model.configuration; - this.setupObjects([]); - this.setupObjects(model.objects); + this.editHandler.configurate(this.configuration); + this.drawHandler.configurate(this.configuration); + + // todo: setup text, add if doesn't exist and enabled + // remove if exist and not enabled + // this.setupObjects([]); + // this.setupObjects(model.objects); } else if (reason === UpdateReasons.BITMAP) { const { imageBitmap } = model; if (imageBitmap) { @@ -1053,7 +1069,7 @@ export class CanvasViewImpl implements CanvasView, Listener { } for (const attrID of Object.keys(state.attributes)) { - if (state.attributes[attrID] !== drawnState.attributes[attrID]) { + if (state.attributes[attrID] !== drawnState.attributes[+attrID]) { if (text) { const [span] = text.node .querySelectorAll(`[attrID="${attrID}"]`) as any as SVGTSpanElement[]; @@ -1541,6 +1557,7 @@ export class CanvasViewImpl implements CanvasView, Listener { .addClass('cvat_canvas_shape').attr({ clientID: state.clientID, id: `cvat_canvas_shape_${state.clientID}`, + 'data-polyline-id': basicPolyline.attr('id'), 'data-z-order': state.zOrder, }); diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index a2c401c5..5878c884 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -7,11 +7,7 @@ import consts from './consts'; import 'svg.draw.js'; import './svg.patch'; -import { - DrawData, - Geometry, - RectDrawingMethod, -} from './canvasModel'; +import { AutoborderHandler } from './autoborderHandler'; import { translateToSVG, @@ -23,7 +19,15 @@ import { Box, } from './shared'; +import { + DrawData, + Geometry, + RectDrawingMethod, + Configuration, +} from './canvasModel'; + export interface DrawHandler { + configurate(configuration: Configuration): void; draw(drawData: DrawData, geometry: Geometry): void; transform(geometry: Geometry): void; cancel(): void; @@ -45,6 +49,8 @@ export class DrawHandlerImpl implements DrawHandler { }; private drawData: DrawData; private geometry: Geometry; + private autoborderHandler: AutoborderHandler; + private autobordersEnabled: boolean; // we should use any instead of SVG.Shape because svg plugins cannot change declared interface // so, methods like draw() just undefined for SVG.Shape, but nevertheless they exist @@ -127,6 +133,7 @@ export class DrawHandlerImpl implements DrawHandler { return; } + this.autoborderHandler.autoborder(false); this.initialized = false; this.canvas.off('mousedown.draw'); this.canvas.off('mouseup.draw'); @@ -334,6 +341,9 @@ export class DrawHandlerImpl implements DrawHandler { }); this.drawPolyshape(); + if (this.autobordersEnabled) { + this.autoborderHandler.autoborder(true, this.drawInstance, false); + } } private drawPolyline(): void { @@ -344,6 +354,9 @@ export class DrawHandlerImpl implements DrawHandler { }); this.drawPolyshape(); + if (this.autobordersEnabled) { + this.autoborderHandler.autoborder(true, this.drawInstance, false); + } } private drawPoints(): void { @@ -599,7 +612,10 @@ export class DrawHandlerImpl implements DrawHandler { onDrawDone: (data: object | null, duration?: number, continueDraw?: boolean) => void, canvas: SVG.Container, text: SVG.Container, + autoborderHandler: AutoborderHandler, ) { + this.autoborderHandler = autoborderHandler; + this.autobordersEnabled = false; this.startTimestamp = Date.now(); this.onDrawDone = onDrawDone; this.canvas = canvas; @@ -629,6 +645,19 @@ export class DrawHandlerImpl implements DrawHandler { }); } + public configurate(configuration: Configuration): void { + if (typeof (configuration.autoborders) === 'boolean') { + this.autobordersEnabled = configuration.autoborders; + if (this.drawInstance) { + if (this.autobordersEnabled) { + this.autoborderHandler.autoborder(true, this.drawInstance, false); + } else { + this.autoborderHandler.autoborder(false); + } + } + } + } + public transform(geometry: Geometry): void { this.geometry = geometry; diff --git a/cvat-canvas/src/typescript/editHandler.ts b/cvat-canvas/src/typescript/editHandler.ts index ba0ed7f6..41d03edc 100644 --- a/cvat-canvas/src/typescript/editHandler.ts +++ b/cvat-canvas/src/typescript/editHandler.ts @@ -6,29 +6,27 @@ import * as SVG from 'svg.js'; import 'svg.select.js'; import consts from './consts'; -import { - translateFromSVG, - pointsToArray, -} from './shared'; -import { - EditData, - Geometry, -} from './canvasModel'; +import { translateFromSVG, pointsToArray } from './shared'; +import { EditData, Geometry, Configuration } from './canvasModel'; +import { AutoborderHandler } from './autoborderHandler'; export interface EditHandler { edit(editData: EditData): void; transform(geometry: Geometry): void; + configurate(configuration: Configuration): void; cancel(): void; } export class EditHandlerImpl implements EditHandler { private onEditDone: (state: any, points: number[]) => void; + private autoborderHandler: AutoborderHandler; private geometry: Geometry; private canvas: SVG.Container; private editData: EditData; private editedShape: SVG.Shape; private editLine: SVG.PolyLine; private clones: SVG.Polygon[]; + private autobordersEnabled: boolean; private startEdit(): void { // get started coordinates @@ -77,6 +75,8 @@ export class EditHandlerImpl implements EditHandler { (this.editLine as any).addClass('cvat_canvas_shape_drawing').style({ 'pointer-events': 'none', 'fill-opacity': 0, + }).attr({ + 'data-origin-client-id': this.editData.state.clientID, }).on('drawstart drawpoint', (e: CustomEvent): void => { this.transform(this.geometry); lastDrawnPoint.x = e.detail.event.clientX; @@ -89,6 +89,9 @@ export class EditHandlerImpl implements EditHandler { } this.setupEditEvents(); + if (this.autobordersEnabled) { + this.autoborderHandler.autoborder(true, this.editLine, true); + } } private setupEditEvents(): void { @@ -273,6 +276,7 @@ export class EditHandlerImpl implements EditHandler { this.canvas.off('mousedown.edit'); this.canvas.off('mouseup.edit'); this.canvas.off('mousemove.edit'); + this.autoborderHandler.autoborder(false); if (this.editedShape) { this.setupPoints(false); @@ -314,7 +318,10 @@ export class EditHandlerImpl implements EditHandler { public constructor( onEditDone: (state: any, points: number[]) => void, canvas: SVG.Container, + autoborderHandler: AutoborderHandler, ) { + this.autoborderHandler = autoborderHandler; + this.autobordersEnabled = false; this.onEditDone = onEditDone; this.canvas = canvas; this.editData = null; @@ -343,6 +350,19 @@ export class EditHandlerImpl implements EditHandler { this.onEditDone(null, null); } + public configurate(configuration: Configuration): void { + if (typeof (configuration.autoborders) === 'boolean') { + this.autobordersEnabled = configuration.autoborders; + if (this.editLine) { + if (this.autobordersEnabled) { + this.autoborderHandler.autoborder(true, this.editLine, true); + } else { + this.autoborderHandler.autoborder(false); + } + } + } + } + public transform(geometry: Geometry): void { this.geometry = geometry; diff --git a/cvat-canvas/src/typescript/shared.ts b/cvat-canvas/src/typescript/shared.ts index 2a1136ca..f230b60d 100644 --- a/cvat-canvas/src/typescript/shared.ts +++ b/cvat-canvas/src/typescript/shared.ts @@ -25,6 +25,21 @@ export interface BBox { y: number; } +export interface DrawnState { + clientID: number; + outside?: boolean; + occluded?: boolean; + hidden?: boolean; + lock: boolean; + shapeType: string; + points?: number[]; + attributes: Record; + zOrder?: number; + pinned?: boolean; + updated: number; + frame: number; +} + // Translate point array from the canvas coordinate system // to the coordinate system of a client export function translateFromSVG(svg: SVGSVGElement, points: number[]): number[] { diff --git a/cvat-ui/src/actions/settings-actions.ts b/cvat-ui/src/actions/settings-actions.ts index f06b0fb0..9a157967 100644 --- a/cvat-ui/src/actions/settings-actions.ts +++ b/cvat-ui/src/actions/settings-actions.ts @@ -28,6 +28,7 @@ export enum SettingsActionTypes { SWITCH_AUTO_SAVE = 'SWITCH_AUTO_SAVE', CHANGE_AUTO_SAVE_INTERVAL = 'CHANGE_AUTO_SAVE_INTERVAL', CHANGE_AAM_ZOOM_MARGIN = 'CHANGE_AAM_ZOOM_MARGIN', + SWITCH_AUTOMATIC_BORDERING = 'SWITCH_AUTOMATIC_BORDERING', SWITCH_SHOWNIG_INTERPOLATED_TRACKS = 'SWITCH_SHOWNIG_INTERPOLATED_TRACKS', SWITCH_SHOWING_OBJECTS_TEXT_ALWAYS = 'SWITCH_SHOWING_OBJECTS_TEXT_ALWAYS', } @@ -220,3 +221,12 @@ export function switchShowingObjectsTextAlways(showObjectsTextAlways: boolean): }, }; } + +export function switchAutomaticBordering(automaticBordering: boolean): AnyAction { + return { + type: SettingsActionTypes.SWITCH_AUTOMATIC_BORDERING, + payload: { + automaticBordering, + }, + }; +} 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 53be20c0..0c6e22cf 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 @@ -62,7 +62,9 @@ interface Props { aamZoomMargin: number; showObjectsTextAlways: boolean; workspace: Workspace; + automaticBordering: boolean; keyMap: Record; + switchableAutomaticBordering: boolean; onSetupCanvas: () => void; onDragCanvas: (enabled: boolean) => void; onZoomCanvas: (enabled: boolean) => void; @@ -89,11 +91,13 @@ interface Props { onChangeGridOpacity(opacity: number): void; onChangeGridColor(color: GridColor): void; onSwitchGrid(enabled: boolean): void; + onSwitchAutomaticBordering(enabled: boolean): void; } export default class CanvasWrapperComponent extends React.PureComponent { public componentDidMount(): void { const { + automaticBordering, showObjectsTextAlways, canvasInstance, curZLayer, @@ -106,6 +110,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { wrapper.appendChild(canvasInstance.html()); canvasInstance.configure({ + autoborders: automaticBordering, undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE, displayAllText: showObjectsTextAlways, }); @@ -139,12 +144,16 @@ export default class CanvasWrapperComponent extends React.PureComponent { workspace, frameFetching, showObjectsTextAlways, + automaticBordering, } = this.props; - if (prevProps.showObjectsTextAlways !== showObjectsTextAlways) { + if (prevProps.showObjectsTextAlways !== showObjectsTextAlways + || prevProps.automaticBordering !== automaticBordering + ) { canvasInstance.configure({ undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE, displayAllText: showObjectsTextAlways, + autoborders: automaticBordering, }); } @@ -262,7 +271,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.html().removeEventListener('canvas.groupped', this.onCanvasObjectsGroupped); canvasInstance.html().removeEventListener('canvas.splitted', this.onCanvasTrackSplitted); - canvasInstance.html().removeEventListener('point.contextmenu', this.onCanvasPointContextMenu); + canvasInstance.html().removeEventListener('canvas.contextmenu', this.onCanvasPointContextMenu); window.removeEventListener('resize', this.fitCanvas); } @@ -683,7 +692,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.html().addEventListener('canvas.groupped', this.onCanvasObjectsGroupped); canvasInstance.html().addEventListener('canvas.splitted', this.onCanvasTrackSplitted); - canvasInstance.html().addEventListener('point.contextmenu', this.onCanvasPointContextMenu); + canvasInstance.html().addEventListener('canvas.contextmenu', this.onCanvasPointContextMenu); } public render(): JSX.Element { @@ -696,16 +705,19 @@ export default class CanvasWrapperComponent extends React.PureComponent { brightnessLevel, contrastLevel, saturationLevel, + keyMap, grid, gridColor, gridOpacity, + switchableAutomaticBordering, + automaticBordering, onChangeBrightnessLevel, onChangeSaturationLevel, onChangeContrastLevel, onChangeGridColor, onChangeGridOpacity, onSwitchGrid, - keyMap, + onSwitchAutomaticBordering, } = this.props; const preventDefault = (event: KeyboardEvent | undefined): void => { @@ -724,8 +736,10 @@ export default class CanvasWrapperComponent extends React.PureComponent { INCREASE_GRID_OPACITY: keyMap.INCREASE_GRID_OPACITY, DECREASE_GRID_OPACITY: keyMap.DECREASE_GRID_OPACITY, CHANGE_GRID_COLOR: keyMap.CHANGE_GRID_COLOR, + SWITCH_AUTOMATIC_BORDERING: keyMap.SWITCH_AUTOMATIC_BORDERING, }; + const step = 10; const handlers = { INCREASE_BRIGHTNESS: (event: KeyboardEvent | undefined) => { @@ -800,6 +814,12 @@ export default class CanvasWrapperComponent extends React.PureComponent { const color = colors[indexOf >= colors.length ? 0 : indexOf]; onChangeGridColor(color); }, + SWITCH_AUTOMATIC_BORDERING: (event: KeyboardEvent | undefined) => { + if (switchableAutomaticBordering) { + preventDefault(event); + onSwitchAutomaticBordering(!automaticBordering); + } + }, }; return ( diff --git a/cvat-ui/src/components/settings-page/styles.scss b/cvat-ui/src/components/settings-page/styles.scss index 63269c43..b3d47572 100644 --- a/cvat-ui/src/components/settings-page/styles.scss +++ b/cvat-ui/src/components/settings-page/styles.scss @@ -23,10 +23,14 @@ .cvat-player-settings-grid, .cvat-workspace-settings-auto-save, +.cvat-workspace-settings-autoborders, .cvat-workspace-settings-show-text-always, -.cvat-workspace-settings-show-text-always-checkbox, -.cvat-workspace-settings-show-interpolated-checkbox { - margin-bottom: 10px; +.cvat-workspace-settings-show-interpolated { + margin-bottom: 25px; + + > div:first-child { + margin-bottom: 10px; + } } .cvat-player-settings-grid-size, @@ -36,8 +40,6 @@ .cvat-player-settings-speed, .cvat-player-settings-reset-zoom, .cvat-player-settings-rotate-all, -.cvat-workspace-settings-show-text-always, -.cvat-workspace-settings-show-interpolated, .cvat-workspace-settings-aam-zoom-margin, .cvat-workspace-settings-auto-save-interval { margin-bottom: 25px; diff --git a/cvat-ui/src/components/settings-page/workspace-settings.tsx b/cvat-ui/src/components/settings-page/workspace-settings.tsx index 0ee068ae..5c85b529 100644 --- a/cvat-ui/src/components/settings-page/workspace-settings.tsx +++ b/cvat-ui/src/components/settings-page/workspace-settings.tsx @@ -17,11 +17,13 @@ interface Props { aamZoomMargin: number; showAllInterpolationTracks: boolean; showObjectsTextAlways: boolean; + automaticBordering: boolean; onSwitchAutoSave(enabled: boolean): void; onChangeAutoSaveInterval(interval: number): void; onChangeAAMZoomMargin(margin: number): void; onSwitchShowingInterpolatedTracks(enabled: boolean): void; onSwitchShowingObjectsTextAlways(enabled: boolean): void; + onSwitchAutomaticBordering(enabled: boolean): void; } export default function WorkspaceSettingsComponent(props: Props): JSX.Element { @@ -31,11 +33,13 @@ export default function WorkspaceSettingsComponent(props: Props): JSX.Element { aamZoomMargin, showAllInterpolationTracks, showObjectsTextAlways, + automaticBordering, onSwitchAutoSave, onChangeAutoSaveInterval, onChangeAAMZoomMargin, onSwitchShowingInterpolatedTracks, onSwitchShowingObjectsTextAlways, + onSwitchAutomaticBordering, } = props; const minAutoSaveInterval = 5; @@ -82,7 +86,7 @@ export default function WorkspaceSettingsComponent(props: Props): JSX.Element { - + - + Show text for an object on the canvas not only when the object is activated + + + { + onSwitchAutomaticBordering(event.target.checked); + }} + > + Automatic bordering + + + + Enable automatic bordering for polygons and polylines during drawing/editing + + Attribute annotation mode (AAM) zoom margin 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 12501c44..ee7c6490 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 @@ -34,6 +34,7 @@ import { changeBrightnessLevel, changeContrastLevel, changeSaturationLevel, + switchAutomaticBordering, } from 'actions/settings-actions'; import { ColorBy, @@ -42,6 +43,7 @@ import { CombinedState, ContextMenuType, Workspace, + ActiveControl, } from 'reducers/interfaces'; import { Canvas } from 'cvat-canvas'; @@ -79,8 +81,10 @@ interface StateToProps { minZLayer: number; maxZLayer: number; curZLayer: number; + automaticBordering: boolean; contextVisible: boolean; contextType: ContextMenuType; + switchableAutomaticBordering: boolean; keyMap: Record; } @@ -111,12 +115,14 @@ interface DispatchToProps { onChangeGridOpacity(opacity: number): void; onChangeGridColor(color: GridColor): void; onSwitchGrid(enabled: boolean): void; + onSwitchAutomaticBordering(enabled: boolean): void; } function mapStateToProps(state: CombinedState): StateToProps { const { annotation: { canvas: { + activeControl, contextMenu: { visible: contextVisible, type: contextType, @@ -166,6 +172,7 @@ function mapStateToProps(state: CombinedState): StateToProps { workspace: { aamZoomMargin, showObjectsTextAlways, + automaticBordering, }, shapes: { opacity, @@ -212,10 +219,14 @@ function mapStateToProps(state: CombinedState): StateToProps { curZLayer, minZLayer, maxZLayer, + automaticBordering, contextVisible, contextType, workspace, keyMap, + switchableAutomaticBordering: activeControl === ActiveControl.DRAW_POLYGON + || activeControl === ActiveControl.DRAW_POLYLINE + || activeControl === ActiveControl.EDIT, }; } @@ -301,6 +312,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onSwitchGrid(enabled: boolean): void { dispatch(switchGrid(enabled)); }, + onSwitchAutomaticBordering(enabled: boolean): void { + dispatch(switchAutomaticBordering(enabled)); + }, }; } diff --git a/cvat-ui/src/containers/settings-page/workspace-settings.tsx b/cvat-ui/src/containers/settings-page/workspace-settings.tsx index db744564..8f360b72 100644 --- a/cvat-ui/src/containers/settings-page/workspace-settings.tsx +++ b/cvat-ui/src/containers/settings-page/workspace-settings.tsx @@ -11,6 +11,7 @@ import { changeAAMZoomMargin, switchShowingInterpolatedTracks, switchShowingObjectsTextAlways, + switchAutomaticBordering, } from 'actions/settings-actions'; import { CombinedState } from 'reducers/interfaces'; @@ -23,6 +24,7 @@ interface StateToProps { aamZoomMargin: number; showAllInterpolationTracks: boolean; showObjectsTextAlways: boolean; + automaticBordering: boolean; } interface DispatchToProps { @@ -31,6 +33,7 @@ interface DispatchToProps { onChangeAAMZoomMargin(margin: number): void; onSwitchShowingInterpolatedTracks(enabled: boolean): void; onSwitchShowingObjectsTextAlways(enabled: boolean): void; + onSwitchAutomaticBordering(enabled: boolean): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -41,6 +44,7 @@ function mapStateToProps(state: CombinedState): StateToProps { aamZoomMargin, showAllInterpolationTracks, showObjectsTextAlways, + automaticBordering, } = workspace; return { @@ -49,6 +53,7 @@ function mapStateToProps(state: CombinedState): StateToProps { aamZoomMargin, showAllInterpolationTracks, showObjectsTextAlways, + automaticBordering, }; } @@ -69,6 +74,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onSwitchShowingObjectsTextAlways(enabled: boolean): void { dispatch(switchShowingObjectsTextAlways(enabled)); }, + onSwitchAutomaticBordering(enabled: boolean): void { + dispatch(switchAutomaticBordering(enabled)); + }, }; } diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 0227b6ad..27f7f93d 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -429,6 +429,7 @@ export interface WorkspaceSettingsState { autoSave: boolean; autoSaveInterval: number; // in ms aamZoomMargin: number; + automaticBordering: boolean; showObjectsTextAlways: boolean; showAllInterpolationTracks: boolean; } diff --git a/cvat-ui/src/reducers/settings-reducer.ts b/cvat-ui/src/reducers/settings-reducer.ts index 00cc5527..f38902e0 100644 --- a/cvat-ui/src/reducers/settings-reducer.ts +++ b/cvat-ui/src/reducers/settings-reducer.ts @@ -28,6 +28,7 @@ const defaultState: SettingsState = { autoSave: false, autoSaveInterval: 15 * 60 * 1000, aamZoomMargin: 100, + automaticBordering: false, showObjectsTextAlways: false, showAllInterpolationTracks: false, }, @@ -237,6 +238,15 @@ export default (state = defaultState, action: AnyAction): SettingsState => { }, }; } + case SettingsActionTypes.SWITCH_AUTOMATIC_BORDERING: { + return { + ...state, + workspace: { + ...state.workspace, + automaticBordering: action.payload.automaticBordering, + }, + }; + } case BoundariesActionTypes.RESET_AFTER_ERROR: case AnnotationActionTypes.GET_JOB_SUCCESS: { const { job } = action.payload; diff --git a/cvat-ui/src/reducers/shortcuts-reducer.ts b/cvat-ui/src/reducers/shortcuts-reducer.ts index 83de3e71..376a6f4a 100644 --- a/cvat-ui/src/reducers/shortcuts-reducer.ts +++ b/cvat-ui/src/reducers/shortcuts-reducer.ts @@ -314,6 +314,12 @@ const defaultKeyMap = { sequences: ['`', '~'], action: 'keydown', }, + SWITCH_AUTOMATIC_BORDERING: { + name: 'Switch automatic bordering', + description: 'Switch automatic bordering for polygons and polylines during drawing/editing', + sequences: ['Control'], + action: 'keydown', + }, } as any as Record;