diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md index a7def23c98e..59f4baa6f29 100644 --- a/cvat-canvas/README.md +++ b/cvat-canvas/README.md @@ -111,11 +111,12 @@ Canvas itself handles: Standard JS events are used. ```js - canvas.setup - - canvas.activated => ObjectState - - canvas.deactivated + - canvas.activated => {state: ObjectState} + - canvas.clicked => {state: ObjectState} - canvas.moved => {states: ObjectState[], x: number, y: number} - canvas.find => {states: ObjectState[], x: number, y: number} - canvas.drawn => {state: DrawnData} + - canvas.editstart - canvas.edited => {state: ObjectState, points: number[]} - canvas.splitted => {state: ObjectState} - canvas.groupped => {states: ObjectState[]} diff --git a/cvat-canvas/src/scss/canvas.scss b/cvat-canvas/src/scss/canvas.scss index 529067ce2e8..57faf8cad84 100644 --- a/cvat-canvas/src/scss/canvas.scss +++ b/cvat-canvas/src/scss/canvas.scss @@ -3,15 +3,35 @@ } .cvat_canvas_shape { - fill-opacity: 0.03; stroke-opacity: 1; } polyline.cvat_canvas_shape { fill-opacity: 0; +} + +.cvat_shape_action_opacity { + fill-opacity: 0.5; + stroke-opacity: 1; +} + +polyline.cvat_shape_action_opacity { + fill-opacity: 0; +} + +.cvat_shape_drawing_opacity { + fill-opacity: 0.2; stroke-opacity: 1; } +polyline.cvat_shape_drawing_opacity { + fill-opacity: 0; +} + +.cvat_shape_action_dasharray { + stroke-dasharray: 4 1 2 3; +} + .cvat_canvas_text { font-weight: bold; font-size: 1.2em; @@ -27,51 +47,52 @@ polyline.cvat_canvas_shape { stroke: red; } -.cvat_canvas_shape_activated { - fill-opacity: 0.3; -} - .cvat_canvas_shape_grouping { + @extend .cvat_shape_action_dasharray; + @extend .cvat_shape_action_opacity; fill: darkmagenta; - fill-opacity: 0.5; } polyline.cvat_canvas_shape_grouping { + @extend .cvat_shape_action_dasharray; + @extend .cvat_shape_action_opacity; stroke: darkmagenta; - stroke-opacity: 1; } .cvat_canvas_shape_merging { + @extend .cvat_shape_action_dasharray; + @extend .cvat_shape_action_opacity; fill: blue; - fill-opacity: 0.5; +} + +polyline.cvat_canvas_shape_merging { + @extend .cvat_shape_action_dasharray; + @extend .cvat_shape_action_opacity; + stroke: blue; } polyline.cvat_canvas_shape_splitting { + @extend .cvat_shape_action_dasharray; + @extend .cvat_shape_action_opacity; stroke: dodgerblue; - stroke-opacity: 1; } .cvat_canvas_shape_splitting { + @extend .cvat_shape_action_dasharray; + @extend .cvat_shape_action_opacity; fill: dodgerblue; - fill-opacity: 0.5; -} - -polyline.cvat_canvas_shape_merging { - stroke: blue; - stroke-opacity: 1; } .cvat_canvas_shape_drawing { - fill-opacity: 0.1; - stroke-opacity: 1; + @extend .cvat_shape_drawing_opacity; fill: white; stroke: black; } .cvat_canvas_zoom_selection { + @extend .cvat_shape_action_dasharray; stroke: #096dd9; fill-opacity: 0; - stroke-dasharray: 4; } .cvat_canvas_shape_occluded { diff --git a/cvat-canvas/src/typescript/canvas.ts b/cvat-canvas/src/typescript/canvas.ts index 3c6dfec3421..e5c4974ebd3 100644 --- a/cvat-canvas/src/typescript/canvas.ts +++ b/cvat-canvas/src/typescript/canvas.ts @@ -32,7 +32,7 @@ import '../scss/canvas.scss'; interface Canvas { html(): HTMLDivElement; setup(frameData: any, objectStates: any[]): void; - activate(clientID: number, attributeID?: number): void; + activate(clientID: number | null, attributeID?: number): void; rotate(rotation: Rotation, remember?: boolean): void; focus(clientID: number, padding?: number): void; fit(): void; @@ -85,7 +85,7 @@ class CanvasImpl implements Canvas { this.model.zoomCanvas(enable); } - public activate(clientID: number, attributeID: number | null = null): void { + public activate(clientID: number | null, attributeID: number | null = null): void { this.model.activate(clientID, attributeID); } diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index d6b9825606c..58e22952d25 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -110,7 +110,7 @@ export enum Mode { } export interface CanvasModel { - readonly image: string; + readonly image: HTMLImageElement | null; readonly objects: any[]; readonly gridSize: Size; readonly focusData: FocusData; @@ -127,7 +127,7 @@ export interface CanvasModel { move(topOffset: number, leftOffset: number): void; setup(frameData: any, objectStates: any[]): void; - activate(clientID: number, attributeID: number | null): void; + activate(clientID: number | null, attributeID: number | null): void; rotate(rotation: Rotation, remember: boolean): void; focus(clientID: number, padding: number): void; fit(): void; @@ -151,7 +151,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { activeElement: ActiveElement; angle: number; canvasSize: Size; - image: string; + image: HTMLImageElement | null; imageID: number | null; imageOffset: number; imageSize: Size; @@ -183,7 +183,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { height: 0, width: 0, }, - image: '', + image: null, imageID: null, imageOffset: 0, imageSize: { @@ -291,22 +291,33 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { } public setup(frameData: any, objectStates: any[]): void { + if (frameData.number === this.data.imageID) { + this.data.objects = objectStates; + this.notify(UpdateReasons.OBJECTS_UPDATED); + return; + } + + this.data.imageID = frameData.number; frameData.data( (): void => { - this.data.image = ''; + this.data.image = null; this.notify(UpdateReasons.IMAGE_CHANGED); }, - ).then((data: string): void => { + ).then((data: HTMLImageElement): void => { + if (frameData.number !== this.data.imageID) { + // already another image + return; + } + + if (!this.data.rememberAngle) { + this.data.angle = 0; + } + this.data.imageSize = { height: (frameData.height as number), width: (frameData.width as number), }; - if (this.data.imageID !== frameData.number && !this.data.rememberAngle) { - this.data.angle = 0; - } - this.data.imageID = frameData.number; - this.data.image = data; this.notify(UpdateReasons.IMAGE_CHANGED); this.data.objects = objectStates; @@ -316,8 +327,8 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { }); } - public activate(clientID: number, attributeID: number | null): void { - if (this.data.mode !== Mode.IDLE) { + public activate(clientID: number | null, attributeID: number | null): void { + if (this.data.mode !== Mode.IDLE && clientID !== null) { // Exception or just return? throw Error(`Canvas is busy. Action: ${this.data.mode}`); } @@ -503,7 +514,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { )); } - public get image(): string { + public get image(): HTMLImageElement | null { return this.data.image; } diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 185a194b864..98efb21e993 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -21,7 +21,6 @@ import consts from './consts'; import { translateToSVG, translateFromSVG, - translateBetweenSVG, pointsToArray, displayShapeSize, ShapeSizeElement, @@ -44,25 +43,11 @@ export interface CanvasView { html(): HTMLDivElement; } -function darker(color: string, percentage: number): string { - const R = Math.round(parseInt(color.slice(1, 3), 16) * (1 - percentage / 100)); - const G = Math.round(parseInt(color.slice(3, 5), 16) * (1 - percentage / 100)); - const B = Math.round(parseInt(color.slice(5, 7), 16) * (1 - percentage / 100)); - - const rHex = Math.max(0, R).toString(16); - const gHex = Math.max(0, G).toString(16); - const bHex = Math.max(0, B).toString(16); - - return `#${rHex.length === 1 ? `0${rHex}` : rHex}` - + `${gHex.length === 1 ? `0${gHex}` : gHex}` - + `${bHex.length === 1 ? `0${bHex}` : bHex}`; -} - export class CanvasViewImpl implements CanvasView, Listener { private loadingAnimation: SVGSVGElement; private text: SVGSVGElement; private adoptedText: SVG.Container; - private background: SVGSVGElement; + private background: HTMLCanvasElement; private grid: SVGSVGElement; private content: SVGSVGElement; private adoptedContent: SVG.Container; @@ -80,10 +65,7 @@ export class CanvasViewImpl implements CanvasView, Listener { private splitHandler: SplitHandler; private groupHandler: GroupHandler; private zoomHandler: ZoomHandler; - private activeElement: { - state: any; - attributeID: number; - } | null; + private activeElement: ActiveElement; private set mode(value: Mode) { this.controller.mode = value; @@ -93,7 +75,7 @@ export class CanvasViewImpl implements CanvasView, Listener { return this.controller.mode; } - private onDrawDone(data: object): void { + private onDrawDone(data: object, continueDraw?: boolean): void { if (data) { const event: CustomEvent = new CustomEvent('canvas.drawn', { bubbles: false, @@ -101,6 +83,7 @@ export class CanvasViewImpl implements CanvasView, Listener { detail: { // eslint-disable-next-line new-cap state: data, + continue: continueDraw, }, }); @@ -114,11 +97,18 @@ export class CanvasViewImpl implements CanvasView, Listener { this.canvas.dispatchEvent(event); } - this.controller.draw({ - enabled: false, - }); + if (continueDraw) { + this.drawHandler.draw( + this.controller.drawData, + this.geometry, + ); + } else { + this.controller.draw({ + enabled: false, + }); - this.mode = Mode.IDLE; + this.mode = Mode.IDLE; + } } private onEditDone(state: any, points: number[]): void { @@ -229,13 +219,14 @@ export class CanvasViewImpl implements CanvasView, Listener { private onFindObject(e: MouseEvent): void { if (e.which === 1 || e.which === 0) { - const [x, y] = translateToSVG(this.background, [e.clientX, e.clientY]); + const { offset } = this.controller.geometry; + const [x, y] = translateToSVG(this.content, [e.clientX, e.clientY]); const event: CustomEvent = new CustomEvent('canvas.find', { bubbles: false, cancelable: true, detail: { - x, - y, + x: x - offset, + y: y - offset, states: this.controller.objects, }, }); @@ -339,11 +330,9 @@ export class CanvasViewImpl implements CanvasView, Listener { for (const key in this.svgShapes) { if (Object.prototype.hasOwnProperty.call(this.svgShapes, key)) { const object = this.svgShapes[key]; - if (object.attr('stroke-width')) { - object.attr({ - 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - }); - } + object.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }); } } @@ -376,7 +365,9 @@ export class CanvasViewImpl implements CanvasView, Listener { } private setupObjects(states: any[]): void { - this.deactivate(); + const { offset } = this.controller.geometry; + const translate = (points: number[]): number[] => points + .map((coord: number): number => coord + offset); const created = []; const updated = []; @@ -394,17 +385,31 @@ export class CanvasViewImpl implements CanvasView, Listener { const deleted = Object.keys(this.drawnStates).map((clientID: string): number => +clientID) .filter((id: number): boolean => !newIDs.includes(id)) .map((id: number): any => this.drawnStates[id]); + + + if (this.activeElement.clientID !== null) { + this.deactivate(); + } + for (const state of deleted) { if (state.clientID in this.svgTexts) { this.svgTexts[state.clientID].remove(); } + this.svgShapes[state.clientID].off('click.canvas'); this.svgShapes[state.clientID].remove(); delete this.drawnStates[state.clientID]; } - this.addObjects(created); - this.updateObjects(updated); + this.addObjects(created, translate); + this.updateObjects(updated, translate); + + if (this.controller.activeElement.clientID !== null) { + const { clientID } = this.controller.activeElement; + if (states.map((state: any): number => state.clientID).includes(clientID)) { + this.activate(this.controller.activeElement); + } + } } private selectize(value: boolean, shape: SVG.Element): void { @@ -414,16 +419,24 @@ export class CanvasViewImpl implements CanvasView, Listener { const pointID = Array.prototype.indexOf .call(((e.target as HTMLElement).parentElement as HTMLElement).children, e.target); - if (self.activeElement) { + if (self.activeElement.clientID !== null) { + const [state] = self.controller.objects + .filter((_state: any): boolean => ( + _state.clientID === self.activeElement.clientID + )); if (e.ctrlKey) { - const { points } = self.activeElement.state; + const { points } = state; self.onEditDone( - self.activeElement.state, + 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; - const { state } = self.activeElement; self.deactivate(); self.editHandler.edit({ enabled: true, @@ -483,7 +496,10 @@ export class CanvasViewImpl implements CanvasView, Listener { this.svgShapes = {}; this.svgTexts = {}; this.drawnStates = {}; - this.activeElement = null; + this.activeElement = { + clientID: null, + attributeID: null, + }; this.mode = Mode.IDLE; // Create HTML elements @@ -491,7 +507,8 @@ export class CanvasViewImpl implements CanvasView, Listener { .createElementNS('http://www.w3.org/2000/svg', 'svg'); this.text = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.adoptedText = (SVG.adopt((this.text as any as HTMLElement)) as SVG.Container); - this.background = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.background = window.document.createElement('canvas'); + // window.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.grid = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.gridPath = window.document.createElementNS('http://www.w3.org/2000/svg', 'path'); @@ -560,12 +577,10 @@ export class CanvasViewImpl implements CanvasView, Listener { this.onDrawDone.bind(this), this.adoptedContent, this.adoptedText, - this.background, ); this.editHandler = new EditHandlerImpl( this.onEditDone.bind(this), this.adoptedContent, - this.background, ); this.mergeHandler = new MergeHandlerImpl( this.onMergeDone.bind(this), @@ -613,8 +628,9 @@ export class CanvasViewImpl implements CanvasView, Listener { }); this.content.addEventListener('wheel', (event): void => { - const point = translateToSVG(self.background, [event.clientX, event.clientY]); - self.controller.zoom(point[0], point[1], event.deltaY > 0 ? -1 : 1); + const { offset } = this.controller.geometry; + const point = translateToSVG(this.content, [event.clientX, event.clientY]); + self.controller.zoom(point[0] - offset, point[1] - offset, event.deltaY > 0 ? -1 : 1); this.canvas.dispatchEvent(new CustomEvent('canvas.zoom', { bubbles: false, cancelable: true, @@ -628,13 +644,14 @@ export class CanvasViewImpl implements CanvasView, Listener { if (this.mode !== Mode.IDLE) return; if (e.ctrlKey || e.shiftKey) return; - const [x, y] = translateToSVG(this.background, [e.clientX, e.clientY]); + const { offset } = this.controller.geometry; + const [x, y] = translateToSVG(this.content, [e.clientX, e.clientY]); const event: CustomEvent = new CustomEvent('canvas.moved', { bubbles: false, cancelable: true, detail: { - x, - y, + x: x - offset, + y: y - offset, states: this.controller.objects, }, }); @@ -649,11 +666,17 @@ export class CanvasViewImpl implements CanvasView, Listener { public notify(model: CanvasModel & Master, reason: UpdateReasons): void { this.geometry = this.controller.geometry; if (reason === UpdateReasons.IMAGE_CHANGED) { - if (!model.image.length) { + const { image } = model; + if (!image) { this.loadingAnimation.classList.remove('cvat_canvas_hidden'); } else { this.loadingAnimation.classList.add('cvat_canvas_hidden'); - this.background.style.backgroundImage = `url("${model.image}")`; + const ctx = this.background.getContext('2d'); + this.background.setAttribute('width', `${image.width}px`); + this.background.setAttribute('height', `${image.height}px`); + if (ctx) { + ctx.drawImage(image, 0, 0); + } this.moveCanvas(); this.resizeCanvas(); this.transformCanvas(); @@ -696,7 +719,6 @@ export class CanvasViewImpl implements CanvasView, Listener { } else if (reason === UpdateReasons.SHAPE_ACTIVATED) { this.activate(this.controller.activeElement); } else if (reason === UpdateReasons.DRAG_CANVAS) { - this.deactivate(); if (this.mode === Mode.DRAG_CANVAS) { this.canvas.dispatchEvent(new CustomEvent('canvas.dragstart', { bubbles: false, @@ -711,7 +733,6 @@ export class CanvasViewImpl implements CanvasView, Listener { this.canvas.style.cursor = ''; } } else if (reason === UpdateReasons.ZOOM_CANVAS) { - this.deactivate(); if (this.mode === Mode.ZOOM_CANVAS) { this.canvas.dispatchEvent(new CustomEvent('canvas.zoomstart', { bubbles: false, @@ -731,28 +752,24 @@ export class CanvasViewImpl implements CanvasView, Listener { const data: DrawData = this.controller.drawData; if (data.enabled) { this.mode = Mode.DRAW; - this.deactivate(); } this.drawHandler.draw(data, this.geometry); } else if (reason === UpdateReasons.MERGE) { const data: MergeData = this.controller.mergeData; if (data.enabled) { this.mode = Mode.MERGE; - this.deactivate(); } this.mergeHandler.merge(data); } else if (reason === UpdateReasons.SPLIT) { const data: SplitData = this.controller.splitData; if (data.enabled) { this.mode = Mode.SPLIT; - this.deactivate(); } this.splitHandler.split(data); } else if (reason === UpdateReasons.GROUP) { const data: GroupData = this.controller.groupData; if (data.enabled) { this.mode = Mode.GROUP; - this.deactivate(); } this.groupHandler.group(data); } else if (reason === UpdateReasons.SELECT) { @@ -807,14 +824,19 @@ export class CanvasViewImpl implements CanvasView, Listener { }; } - private updateObjects(states: any[]): void { + private updateObjects(states: any[], translate: (points: number[]) => number[]): void { for (const state of states) { const { clientID } = state; const drawnState = this.drawnStates[clientID]; if (drawnState.hidden !== state.hidden || drawnState.outside !== state.outside) { const none = state.hidden || state.outside; - this.svgShapes[clientID].style('display', none ? 'none' : ''); + if (state.shapeType === 'points') { + this.svgShapes[clientID].remember('_selectHandler').nested + .style('display', none ? 'none' : ''); + } else { + this.svgShapes[clientID].style('display', none ? 'none' : ''); + } } if (drawnState.occluded !== state.occluded) { @@ -825,12 +847,10 @@ export class CanvasViewImpl implements CanvasView, Listener { } } - if (drawnState.points - .some((p: number, id: number): boolean => p !== state.points[id]) + if (state.points + .some((p: number, id: number): boolean => p !== drawnState.points[id]) ) { - const translatedPoints: number[] = translateBetweenSVG( - this.background, this.content, state.points, - ); + const translatedPoints: number[] = translate(state.points); if (state.shapeType === 'rectangle') { const [xtl, ytl, xbr, ybr] = translatedPoints; @@ -851,8 +871,13 @@ export class CanvasViewImpl implements CanvasView, Listener { return `${acc}${val},`; }, '', ); - + (this.svgShapes[clientID] as any).clear(); this.svgShapes[clientID].attr('points', stringified); + + if (state.shapeType === 'points') { + this.selectize(false, this.svgShapes[clientID]); + this.setupPoints(this.svgShapes[clientID] as SVG.PolyLine, state); + } } } @@ -874,15 +899,13 @@ export class CanvasViewImpl implements CanvasView, Listener { } } - private addObjects(states: any[]): void { + private addObjects(states: any[], translate: (points: number[]) => number[]): void { for (const state of states) { if (state.objectType === 'tag') { this.addTag(state); } else { const points: number[] = (state.points as number[]); - const translatedPoints: number[] = translateBetweenSVG( - this.background, this.content, points, - ); + const translatedPoints: number[] = translate(points); // TODO: Use enums after typification cvat-core if (state.shapeType === 'rectangle') { @@ -910,6 +933,16 @@ export class CanvasViewImpl implements CanvasView, Listener { .addPoints(stringified, state); } } + + this.svgShapes[state.clientID].on('click.canvas', (): void => { + this.canvas.dispatchEvent(new CustomEvent('canvas.clicked', { + bubbles: false, + cancelable: true, + detail: { + state, + }, + })); + }); } this.saveState(state); @@ -917,9 +950,11 @@ export class CanvasViewImpl implements CanvasView, Listener { } private deactivate(): void { - if (this.activeElement) { - const { state } = this.activeElement; - const shape = this.svgShapes[this.activeElement.state.clientID]; + if (this.activeElement.clientID !== null) { + const { clientID } = this.activeElement; + const [state] = this.controller.objects + .filter((_state: any): boolean => _state.clientID === clientID); + const shape = this.svgShapes[state.clientID]; shape.removeClass('cvat_canvas_shape_activated'); (shape as any).off('dragstart'); @@ -941,15 +976,19 @@ export class CanvasViewImpl implements CanvasView, Listener { text.remove(); delete this.svgTexts[state.clientID]; } - this.activeElement = null; + + this.activeElement = { + clientID: null, + attributeID: null, + }; } } private activate(activeElement: ActiveElement): void { // Check if other element have been already activated - if (this.activeElement) { + if (this.activeElement.clientID !== null) { // Check if it is the same element - if (this.activeElement.state.clientID === activeElement.clientID) { + if (this.activeElement.clientID === activeElement.clientID) { return; } @@ -957,16 +996,27 @@ export class CanvasViewImpl implements CanvasView, Listener { this.deactivate(); } - const state = this.controller.objects - .filter((el): boolean => el.clientID === activeElement.clientID)[0]; - this.activeElement = { - attributeID: activeElement.attributeID, - state, - }; + const { clientID } = activeElement; + if (clientID === null) { + return; + } + + const [state] = this.controller.objects + .filter((_state: any): boolean => _state.clientID === clientID); + + if (state.shapeType === 'points') { + this.svgShapes[clientID].remember('_selectHandler').nested + .style('pointer-events', state.lock ? 'none' : ''); + } + + if (state.hidden || state.lock) { + return; + } - const shape = this.svgShapes[activeElement.clientID]; + this.activeElement = { ...activeElement }; + const shape = this.svgShapes[clientID]; shape.addClass('cvat_canvas_shape_activated'); - let text = this.svgTexts[activeElement.clientID]; + let text = this.svgTexts[clientID]; // Draw text if it's hidden by default if (!text) { text = this.addText(state); @@ -998,14 +1048,15 @@ export class CanvasViewImpl implements CanvasView, Listener { 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( shape.attr('points') || `${shape.attr('x')},${shape.attr('y')} ` + `${shape.attr('x') + shape.attr('width')},` + `${shape.attr('y') + shape.attr('height')}`, - ); + ).map((x: number): number => x - offset); - this.onEditDone(state, translateBetweenSVG(this.content, this.background, points)); + this.onEditDone(state, points); } }); @@ -1045,15 +1096,25 @@ export class CanvasViewImpl implements CanvasView, Listener { this.mode = Mode.IDLE; if (resized) { + const { offset } = this.controller.geometry; + const points = pointsToArray( shape.attr('points') || `${shape.attr('x')},${shape.attr('y')} ` + `${shape.attr('x') + shape.attr('width')},` + `${shape.attr('y') + shape.attr('height')}`, - ); + ).map((x: number): number => x - offset); - this.onEditDone(state, translateBetweenSVG(this.content, this.background, points)); + this.onEditDone(state, points); } }); + + this.canvas.dispatchEvent(new CustomEvent('canvas.activated', { + bubbles: false, + cancelable: true, + detail: { + state, + }, + })); } // Update text position after corresponding box has been moved, resized, etc. @@ -1122,7 +1183,7 @@ export class CanvasViewImpl implements CanvasView, Listener { id: `cvat_canvas_shape_${state.clientID}`, fill: state.color, 'shape-rendering': 'geometricprecision', - stroke: darker(state.color, 20), + stroke: state.color, 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, zOrder: state.zOrder, }).move(xtl, ytl) @@ -1146,7 +1207,7 @@ export class CanvasViewImpl implements CanvasView, Listener { id: `cvat_canvas_shape_${state.clientID}`, fill: state.color, 'shape-rendering': 'geometricprecision', - stroke: darker(state.color, 20), + stroke: state.color, 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, zOrder: state.zOrder, }).addClass('cvat_canvas_shape'); @@ -1169,7 +1230,7 @@ export class CanvasViewImpl implements CanvasView, Listener { id: `cvat_canvas_shape_${state.clientID}`, fill: state.color, 'shape-rendering': 'geometricprecision', - stroke: darker(state.color, 20), + stroke: state.color, 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, zOrder: state.zOrder, }).addClass('cvat_canvas_shape'); @@ -1185,6 +1246,25 @@ export class CanvasViewImpl implements CanvasView, Listener { return polyline; } + private setupPoints(basicPolyline: SVG.PolyLine, state: any): any { + this.selectize(true, basicPolyline); + + const group = basicPolyline.remember('_selectHandler').nested + .addClass('cvat_canvas_shape').attr({ + clientID: state.clientID, + zOrder: state.zOrder, + id: `cvat_canvas_shape_${state.clientID}`, + fill: state.color, + }).style({ + 'fill-opacity': 1, + }); + + group.bbox = basicPolyline.bbox.bind(basicPolyline); + group.clone = basicPolyline.clone.bind(basicPolyline); + + return group; + } + private addPoints(points: string, state: any): SVG.PolyLine { const shape = this.adoptedContent.polyline(points).attr({ 'color-rendering': 'optimizeQuality', @@ -1196,25 +1276,12 @@ export class CanvasViewImpl implements CanvasView, Listener { opacity: 0, }); - this.selectize(true, shape); - - const group = shape.remember('_selectHandler').nested - .addClass('cvat_canvas_shape').attr({ - clientID: state.clientID, - zOrder: state.zOrder, - id: `cvat_canvas_shape_${state.clientID}`, - fill: state.color, - }).style({ - 'fill-opacity': 1, - }); + const group = this.setupPoints(shape, state); if (state.hidden || state.outside) { group.style('display', 'none'); } - group.bbox = shape.bbox.bind(shape); - group.clone = shape.clone.bind(shape); - shape.remove = (): SVG.PolyLine => { this.selectize(false, shape); shape.constructor.prototype.remove.call(shape); diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index 7e8e7690122..ed27cf7c370 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -15,7 +15,6 @@ import { import { translateToSVG, - translateBetweenSVG, displayShapeSize, ShapeSizeElement, pointsToString, @@ -32,10 +31,9 @@ export interface DrawHandler { export class DrawHandlerImpl implements DrawHandler { // callback is used to notify about creating new shape - private onDrawDone: (data: object) => void; + private onDrawDone: (data: object, continueDraw?: boolean) => void; private canvas: SVG.Container; private text: SVG.Container; - private background: SVGSVGElement; private crosshair: { x: SVG.Line; y: SVG.Line; @@ -46,17 +44,16 @@ export class DrawHandlerImpl implements DrawHandler { // 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 private drawInstance: any; + private pointsGroup: SVG.G | null; private shapeSizeElement: ShapeSizeElement; private getFinalRectCoordinates(bbox: BBox): number[] { const frameWidth = this.geometry.image.width; const frameHeight = this.geometry.image.height; + const { offset } = this.geometry; - let [xtl, ytl, xbr, ybr] = translateBetweenSVG( - this.canvas.node as any as SVGSVGElement, - this.background, - [bbox.x, bbox.y, bbox.x + bbox.width, bbox.y + bbox.height], - ); + let [xtl, ytl, xbr, ybr] = [bbox.x, bbox.y, bbox.x + bbox.width, bbox.y + bbox.height] + .map((coord: number): number => coord - offset); xtl = Math.min(Math.max(xtl, 0), frameWidth); xbr = Math.min(Math.max(xbr, 0), frameWidth); @@ -70,12 +67,8 @@ export class DrawHandlerImpl implements DrawHandler { points: number[]; box: Box; } { - const points = translateBetweenSVG( - this.canvas.node as any as SVGSVGElement, - this.background, - targetPoints, - ); - + const { offset } = this.geometry; + const points = targetPoints.map((coord: number): number => coord - offset); const box = { xtl: Number.MAX_SAFE_INTEGER, ytl: Number.MAX_SAFE_INTEGER, @@ -125,6 +118,11 @@ export class DrawHandlerImpl implements DrawHandler { this.canvas.off('mousemove.draw'); this.canvas.off('click.draw'); + if (this.pointsGroup) { + this.pointsGroup.remove(); + this.pointsGroup = null; + } + if (this.drawInstance) { // Draw plugin isn't activated when draw from initialState // So, we don't need to use any draw events @@ -311,7 +309,7 @@ export class DrawHandlerImpl implements DrawHandler { private drawPolygon(): void { this.drawInstance = (this.canvas as any).polygon().draw({ snapToGrid: 0.1, - }).addClass('cvat_canvas_shape_drawing').style({ + }).addClass('cvat_canvas_shape_drawing').attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, }); @@ -321,7 +319,7 @@ export class DrawHandlerImpl implements DrawHandler { private drawPolyline(): void { this.drawInstance = (this.canvas as any).polyline().draw({ snapToGrid: 0.1, - }).addClass('cvat_canvas_shape_drawing').style({ + }).addClass('cvat_canvas_shape_drawing').attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, 'fill-opacity': 0, }); @@ -332,7 +330,7 @@ export class DrawHandlerImpl implements DrawHandler { private drawPoints(): void { this.drawInstance = (this.canvas as any).polygon().draw({ snapToGrid: 0.1, - }).addClass('cvat_canvas_shape_drawing').style({ + }).addClass('cvat_canvas_shape_drawing').attr({ 'stroke-width': 0, opacity: 0, }); @@ -342,21 +340,22 @@ export class DrawHandlerImpl implements DrawHandler { private pastePolyshape(): void { this.canvas.on('click.draw', (e: MouseEvent): void => { - const targetPoints = (e.target as SVGElement) - .getAttribute('points') + const targetPoints = this.drawInstance + .attr('points') .split(/[,\s]/g) - .map((coord): number => +coord); + .map((coord: string): number => +coord); const { points } = this.getFinalPolyshapeCoordinates(targetPoints); this.release(); this.onDrawDone({ - shapeType: this.drawData.shapeType, + shapeType: this.drawData.initialState.shapeType, + objectType: this.drawData.initialState.objectType, points, occluded: this.drawData.initialState.occluded, attributes: { ...this.drawData.initialState.attributes }, label: this.drawData.initialState.label, color: this.drawData.initialState.color, - }); + }, e.ctrlKey); }); } @@ -380,30 +379,31 @@ export class DrawHandlerImpl implements DrawHandler { private pasteBox(box: BBox): void { this.drawInstance = (this.canvas as any).rect(box.width, box.height) .move(box.x, box.y) - .addClass('cvat_canvas_shape_drawing').style({ + .addClass('cvat_canvas_shape_drawing').attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, }); this.pasteShape(); this.canvas.on('click.draw', (e: MouseEvent): void => { - const bbox = (e.target as SVGRectElement).getBBox(); + const bbox = this.drawInstance.node.getBBox(); const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(bbox); this.release(); this.onDrawDone({ - shapeType: this.drawData.shapeType, + shapeType: this.drawData.initialState.shapeType, + objectType: this.drawData.initialState.objectType, points: [xtl, ytl, xbr, ybr], occluded: this.drawData.initialState.occluded, attributes: { ...this.drawData.initialState.attributes }, label: this.drawData.initialState.label, color: this.drawData.initialState.color, - }); + }, e.ctrlKey); }); } private pastePolygon(points: string): void { this.drawInstance = (this.canvas as any).polygon(points) - .addClass('cvat_canvas_shape_drawing').style({ + .addClass('cvat_canvas_shape_drawing').attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, }); this.pasteShape(); @@ -412,7 +412,7 @@ export class DrawHandlerImpl implements DrawHandler { private pastePolyline(points: string): void { this.drawInstance = (this.canvas as any).polyline(points) - .addClass('cvat_canvas_shape_drawing').style({ + .addClass('cvat_canvas_shape_drawing').attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, }); this.pasteShape(); @@ -424,19 +424,52 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing').style({ 'stroke-width': 0, }); - this.pasteShape(); + + this.pointsGroup = this.canvas.group(); + for (const point of points.split(' ')) { + const radius = consts.BASE_POINT_SIZE / this.geometry.scale; + const stroke = consts.POINTS_STROKE_WIDTH / this.geometry.scale; + const [x, y] = point.split(',').map((coord: string): number => +coord); + this.pointsGroup.circle().move(x - radius / 2, y - radius / 2) + .fill('white').stroke('black').attr({ + r: radius, + 'stroke-width': stroke, + }); + } + + this.pointsGroup.attr({ + z_order: Number.MAX_SAFE_INTEGER, + }); + + this.canvas.on('mousemove.draw', (e: MouseEvent): void => { + const [x, y] = translateToSVG( + this.canvas.node as any as SVGSVGElement, + [e.clientX, e.clientY], + ); + + const bbox = this.drawInstance.bbox(); + this.drawInstance.move(x - bbox.width / 2, y - bbox.height / 2); + const radius = consts.BASE_POINT_SIZE / this.geometry.scale; + const newPoints = this.drawInstance.attr('points').split(' '); + if (this.pointsGroup) { + this.pointsGroup.children() + .forEach((child: SVG.Element, idx: number): void => { + const [px, py] = newPoints[idx].split(','); + child.move(px - radius / 2, py - radius / 2); + }); + } + }); + this.pastePolyshape(); } private startDraw(): void { // TODO: Use enums after typification cvat-core if (this.drawData.initialState) { + const { offset } = this.geometry; if (this.drawData.shapeType === 'rectangle') { - const [xtl, ytl, xbr, ybr] = translateBetweenSVG( - this.background, - this.canvas.node as any as SVGSVGElement, - this.drawData.initialState.points, - ); + const [xtl, ytl, xbr, ybr] = this.drawData.initialState.points + .map((coord: number): number => coord + offset); this.pasteBox({ x: xtl, @@ -445,12 +478,8 @@ export class DrawHandlerImpl implements DrawHandler { height: ybr - ytl, }); } else { - const points = translateBetweenSVG( - this.background, - this.canvas.node as any as SVGSVGElement, - this.drawData.initialState.points, - ); - + const points = this.drawData.initialState.points + .map((coord: number): number => coord + offset); const stringifiedPoints = pointsToString(points); if (this.drawData.shapeType === 'polygon') { @@ -475,19 +504,18 @@ export class DrawHandlerImpl implements DrawHandler { } public constructor( - onDrawDone: (data: object) => void, + onDrawDone: (data: object, continueDraw?: boolean) => void, canvas: SVG.Container, text: SVG.Container, - background: SVGSVGElement, ) { this.onDrawDone = onDrawDone; this.canvas = canvas; this.text = text; - this.background = background; this.drawData = null; this.geometry = null; this.crosshair = null; this.drawInstance = null; + this.pointsGroup = null; this.canvas.on('mousemove.crosshair', (e: MouseEvent): void => { if (this.crosshair) { @@ -525,16 +553,25 @@ export class DrawHandlerImpl implements DrawHandler { }); } + if (this.pointsGroup) { + for (const point of this.pointsGroup.children()) { + point.attr({ + 'stroke-width': consts.POINTS_STROKE_WIDTH / geometry.scale, + r: consts.BASE_POINT_SIZE / geometry.scale, + }); + } + } + if (this.drawInstance) { this.drawInstance.draw('transform'); - this.drawInstance.style({ + this.drawInstance.attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale, }); const paintHandler = this.drawInstance.remember('_paintHandler'); for (const point of (paintHandler as any).set.members) { - point.style( + point.attr( 'stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`, ); diff --git a/cvat-canvas/src/typescript/editHandler.ts b/cvat-canvas/src/typescript/editHandler.ts index bc0dc82309a..8daf9055f16 100644 --- a/cvat-canvas/src/typescript/editHandler.ts +++ b/cvat-canvas/src/typescript/editHandler.ts @@ -9,7 +9,6 @@ import 'svg.select.js'; import consts from './consts'; import { translateFromSVG, - translateBetweenSVG, pointsToArray, } from './shared'; import { @@ -27,7 +26,6 @@ export class EditHandlerImpl implements EditHandler { private onEditDone: (state: any, points: number[]) => void; private geometry: Geometry; private canvas: SVG.Container; - private background: SVGSVGElement; private editData: EditData; private editedShape: SVG.Shape; private editLine: SVG.PolyLine; @@ -94,21 +92,19 @@ export class EditHandlerImpl implements EditHandler { } } - private stopEdit(e: MouseEvent): void { - function selectPolygon(shape: SVG.Polygon): void { - const points = translateBetweenSVG( - this.canvas.node as any as SVGSVGElement, - this.background, - pointsToArray(shape.attr('points')), - ); - - const { state } = this.editData; - this.edit({ - enabled: false, - }); - this.onEditDone(state, points); - } + private selectPolygon(shape: SVG.Polygon): void { + const { offset } = this.geometry; + const points = pointsToArray(shape.attr('points')) + .map((coord: number): number => coord - offset); + const { state } = this.editData; + this.edit({ + enabled: false, + }); + this.onEditDone(state, points); + } + + private stopEdit(e: MouseEvent): void { if (!this.editLine) { return; } @@ -154,7 +150,7 @@ export class EditHandlerImpl implements EditHandler { } for (const clone of this.clones) { - clone.on('click', selectPolygon.bind(this, clone)); + clone.on('click', (): void => this.selectPolygon(clone)); clone.on('mouseenter', (): void => { clone.addClass('cvat_canvas_shape_splitting'); }).on('mouseleave', (): void => { @@ -170,6 +166,7 @@ export class EditHandlerImpl implements EditHandler { } let points = null; + const { offset } = this.geometry; if (this.editData.state.shapeType === 'polyline') { if (start !== this.editData.pointID) { linePoints.reverse(); @@ -181,11 +178,8 @@ export class EditHandlerImpl implements EditHandler { points = oldPoints.concat(linePoints.slice(0, -1)); } - points = translateBetweenSVG( - this.canvas.node as any as SVGSVGElement, - this.background, - pointsToArray(points.join(' ')), - ); + points = pointsToArray(points.join(' ')) + .map((coord: number): number => coord - offset); const { state } = this.editData; this.edit({ @@ -284,11 +278,9 @@ export class EditHandlerImpl implements EditHandler { public constructor( onEditDone: (state: any, points: number[]) => void, canvas: SVG.Container, - background: SVGSVGElement, ) { this.onEditDone = onEditDone; this.canvas = canvas; - this.background = background; this.editData = null; this.editedShape = null; this.editLine = null; diff --git a/cvat-canvas/src/typescript/shared.ts b/cvat-canvas/src/typescript/shared.ts index 226dc95bde9..f89ed7eb96d 100644 --- a/cvat-canvas/src/typescript/shared.ts +++ b/cvat-canvas/src/typescript/shared.ts @@ -58,23 +58,13 @@ export function translateToSVG(svg: SVGSVGElement, points: number[]): number[] { return output; } -// Translate point array from the first canvas coordinate system -// to another -export function translateBetweenSVG( - from: SVGSVGElement, - to: SVGSVGElement, - points: number[], -): number[] { - return translateToSVG(to, translateFromSVG(from, points)); -} - export function pointsToString(points: number[]): string { return points.reduce((acc, val, idx): string => { if (idx % 2) { return `${acc},${val}`; } - return `${acc} ${val}`; + return `${acc} ${val}`.trim(); }, ''); } diff --git a/cvat-core/src/annotations-collection.js b/cvat-core/src/annotations-collection.js index 02a208eeaf4..2df189f9a1a 100644 --- a/cvat-core/src/annotations-collection.js +++ b/cvat-core/src/annotations-collection.js @@ -529,6 +529,10 @@ } for (const object of Object.values(this.objects)) { + if (object.removed) { + continue; + } + let objectType = null; if (object instanceof Shape) { objectType = 'shape'; @@ -712,7 +716,7 @@ let minimumState = null; for (const state of objectStates) { checkObjectType('object state', state, null, ObjectState); - if (state.outside) continue; + if (state.outside || state.hidden) continue; const object = this.objects[state.clientID]; if (typeof (object) === 'undefined') { diff --git a/cvat-core/src/annotations-objects.js b/cvat-core/src/annotations-objects.js index 1e05b1e91bf..b689f372147 100644 --- a/cvat-core/src/annotations-objects.js +++ b/cvat-core/src/annotations-objects.js @@ -13,6 +13,7 @@ checkObjectType, } = require('./common'); const { + colors, ObjectShape, ObjectType, AttributeType, @@ -26,6 +27,8 @@ const { Label } = require('./labels'); + const defaultGroupColor = '#E0E0E0'; + // Called with the Annotation context function objectStateFactory(frame, data) { const objectState = new ObjectState(data); @@ -165,7 +168,7 @@ updateTimestamp(updated) { const anyChanges = updated.label || updated.attributes || updated.points || updated.outside || updated.occluded || updated.keyframe - || updated.group || updated.zOrder; + || updated.zOrder; if (anyChanges) { this.updated = Date.now(); @@ -202,6 +205,96 @@ return this.collectionZ[frame]; } + validateStateBeforeSave(frame, data) { + let fittedPoints = []; + const updated = data.updateFlags; + + if (updated.label) { + checkObjectType('label', data.label, null, Label); + } + + const labelAttributes = data.label.attributes + .reduce((accumulator, value) => { + accumulator[value.id] = value; + return accumulator; + }, {}); + + if (updated.attributes) { + for (const attrID of Object.keys(data.attributes)) { + const value = data.attributes[attrID]; + if (attrID in labelAttributes) { + if (!validateAttributeValue(value, labelAttributes[attrID])) { + throw new ArgumentError( + `Trying to save an attribute attribute with id ${attrID} and invalid value ${value}`, + ); + } + } else { + throw new ArgumentError( + `The label of the shape doesn't have the attribute with id ${attrID} and value ${value}`, + ); + } + } + } + + if (updated.points) { + checkObjectType('points', data.points, null, Array); + checkNumberOfPoints(this.shapeType, data.points); + // cut points + const { width, height } = this.frameMeta[frame]; + for (let i = 0; i < data.points.length - 1; i += 2) { + const x = data.points[i]; + const y = data.points[i + 1]; + + checkObjectType('coordinate', x, 'number', null); + checkObjectType('coordinate', y, 'number', null); + + fittedPoints.push( + Math.clamp(x, 0, width), + Math.clamp(y, 0, height), + ); + } + + if (!checkShapeArea(this.shapeType, fittedPoints)) { + fittedPoints = false; + } + } + + if (updated.occluded) { + checkObjectType('occluded', data.occluded, 'boolean', null); + } + + if (updated.outside) { + checkObjectType('outside', data.outside, 'boolean', null); + } + + if (updated.zOrder) { + checkObjectType('zOrder', data.zOrder, 'integer', null); + } + + if (updated.lock) { + checkObjectType('lock', data.lock, 'boolean', null); + } + + if (updated.color) { + checkObjectType('color', data.color, 'string', null); + if (/^#[0-9A-F]{6}$/i.test(data.color)) { + throw new ArgumentError( + `Got invalid color value: "${data.color}"`, + ); + } + } + + if (updated.hidden) { + checkObjectType('hidden', data.hidden, 'boolean', null); + } + + if (updated.keyframe) { + checkObjectType('keyframe', data.keyframe, 'boolean', null); + } + + return fittedPoints; + } + save() { throw new ScriptingError( 'Is not implemented', @@ -289,7 +382,10 @@ points: [...this.points], attributes: { ...this.attributes }, label: this.label, - group: this.group, + group: { + color: this.group ? colors[this.group % colors.length] : defaultGroupColor, + id: this.group, + }, color: this.color, hidden: this.hidden, updated: this.updated, @@ -308,111 +404,46 @@ return objectStateFactory.call(this, frame, this.get(frame)); } - // All changes are done in this temporary object - const copy = this.get(frame); + const fittedPoints = this.validateStateBeforeSave(frame, data); const updated = data.updateFlags; + // Now when all fields are validated, we can apply them if (updated.label) { - checkObjectType('label', data.label, null, Label); - copy.label = data.label; - copy.attributes = {}; - this.appendDefaultAttributes.call(copy, copy.label); + this.label = data.label; + this.attributes = {}; + this.appendDefaultAttributes(data.label); } if (updated.attributes) { - const labelAttributes = copy.label.attributes - .reduce((accumulator, value) => { - accumulator[value.id] = value; - return accumulator; - }, {}); - for (const attrID of Object.keys(data.attributes)) { - const value = data.attributes[attrID]; - if (attrID in labelAttributes) { - if (validateAttributeValue(value, labelAttributes[attrID])) { - copy.attributes[attrID] = value; - } else { - throw new ArgumentError( - `Trying to save an attribute attribute with id ${attrID} and invalid value ${value}`, - ); - } - } else { - throw new ArgumentError( - `Trying to save unknown attribute with id ${attrID} and value ${value}`, - ); - } + this.attributes[attrID] = data.attributes[attrID]; } } - if (updated.points) { - checkObjectType('points', data.points, null, Array); - checkNumberOfPoints(this.shapeType, data.points); - - // cut points - const { width, height } = this.frameMeta[frame]; - const cutPoints = []; - for (let i = 0; i < data.points.length - 1; i += 2) { - const x = data.points[i]; - const y = data.points[i + 1]; - - checkObjectType('coordinate', x, 'number', null); - checkObjectType('coordinate', y, 'number', null); - - cutPoints.push( - Math.clamp(x, 0, width), - Math.clamp(y, 0, height), - ); - } - - if (checkShapeArea(this.shapeType, cutPoints)) { - copy.points = cutPoints; - } + if (updated.points && fittedPoints.length) { + this.points = [...fittedPoints]; } if (updated.occluded) { - checkObjectType('occluded', data.occluded, 'boolean', null); - copy.occluded = data.occluded; - } - - if (updated.group) { - checkObjectType('group', data.group, 'integer', null); - copy.group = data.group; + this.occluded = data.occluded; } if (updated.zOrder) { - checkObjectType('zOrder', data.zOrder, 'integer', null); - copy.zOrder = data.zOrder; + this.zOrder = data.zOrder; } if (updated.lock) { - checkObjectType('lock', data.lock, 'boolean', null); - copy.lock = data.lock; + this.lock = data.lock; } if (updated.color) { - checkObjectType('color', data.color, 'string', null); - if (/^#[0-9A-F]{6}$/i.test(data.color)) { - throw new ArgumentError( - `Got invalid color value: "${data.color}"`, - ); - } - - copy.color = data.color; + this.color = data.color; } if (updated.hidden) { - checkObjectType('hidden', data.hidden, 'boolean', null); - copy.hidden = data.hidden; + this.hidden = data.hidden; } - // Commit state - for (const prop of Object.keys(copy)) { - if (prop in this) { - this[prop] = copy[prop]; - } - } - - // Reset flags and commit all changes this.updateTimestamp(updated); updated.reset(); @@ -496,10 +527,20 @@ // Method is used to construct ObjectState objects get(frame) { + const { + prev, + next, + first, + last, + } = this.boundedKeyframes(frame); + return { - ...this.getPosition(frame), + ...this.getPosition(frame, prev, next), attributes: this.getAttributes(frame), - group: this.group, + group: { + color: this.group ? colors[this.group % colors.length] : defaultGroupColor, + id: this.group, + }, objectType: ObjectType.TRACK, shapeType: this.shapeType, clientID: this.clientID, @@ -509,30 +550,48 @@ hidden: this.hidden, updated: this.updated, label: this.label, + keyframes: { + prev, + next, + first, + last, + }, frame, }; } - neighborsFrames(targetFrame) { + boundedKeyframes(targetFrame) { const frames = Object.keys(this.shapes).map((frame) => +frame); let lDiff = Number.MAX_SAFE_INTEGER; let rDiff = Number.MAX_SAFE_INTEGER; + let first = Number.MAX_SAFE_INTEGER; + let last = Number.MIN_SAFE_INTEGER; for (const frame of frames) { + if (frame < first) { + first = frame; + } + if (frame > last) { + last = frame; + } + const diff = Math.abs(targetFrame - frame); - if (frame <= targetFrame && diff < lDiff) { + + if (frame < targetFrame && diff < lDiff) { lDiff = diff; - } else if (diff < rDiff) { + } else if (frame > targetFrame && diff < rDiff) { rDiff = diff; } } - const leftFrame = lDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame - lDiff; - const rightFrame = rDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame + rDiff; + const prev = lDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame - lDiff; + const next = rDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame + rDiff; return { - leftFrame, - rightFrame, + prev, + next, + first, + last, }; } @@ -568,181 +627,80 @@ return objectStateFactory.call(this, frame, this.get(frame)); } - // All changes are done in this temporary object - const copy = Object.assign(this.get(frame)); - copy.attributes = Object.assign(copy.attributes); - copy.points = [...copy.points]; - + const fittedPoints = this.validateStateBeforeSave(frame, data); const updated = data.updateFlags; - let positionUpdated = false; - - if (updated.label) { - checkObjectType('label', data.label, null, Label); - copy.label = data.label; - copy.attributes = {}; - - // Shape attributes will be removed later after all checks - this.appendDefaultAttributes.call(copy, copy.label); - } - - const labelAttributes = copy.label.attributes + const current = this.get(frame); + const labelAttributes = data.label.attributes .reduce((accumulator, value) => { accumulator[value.id] = value; return accumulator; }, {}); - if (updated.attributes) { - for (const attrID of Object.keys(data.attributes)) { - const value = data.attributes[attrID]; - if (attrID in labelAttributes) { - if (validateAttributeValue(value, labelAttributes[attrID])) { - copy.attributes[attrID] = value; - } else { - throw new ArgumentError( - `Trying to save an attribute attribute with id ${attrID} and invalid value ${value}`, - ); - } - } else { - throw new ArgumentError( - `Trying to save unknown attribute with id ${attrID} and value ${value}`, - ); - } - } - } - - if (updated.points) { - checkObjectType('points', data.points, null, Array); - checkNumberOfPoints(this.shapeType, data.points); - - // cut points - const { width, height } = this.frameMeta[frame]; - const cutPoints = []; - for (let i = 0; i < data.points.length - 1; i += 2) { - const x = data.points[i]; - const y = data.points[i + 1]; - - checkObjectType('coordinate', x, 'number', null); - checkObjectType('coordinate', y, 'number', null); - - cutPoints.push( - Math.clamp(x, 0, width), - Math.clamp(y, 0, height), - ); - } - - if (checkShapeArea(this.shapeType, cutPoints)) { - copy.points = cutPoints; - positionUpdated = true; - } - } - - if (updated.occluded) { - checkObjectType('occluded', data.occluded, 'boolean', null); - copy.occluded = data.occluded; - positionUpdated = true; - } - - if (updated.outside) { - checkObjectType('outside', data.outside, 'boolean', null); - copy.outside = data.outside; - positionUpdated = true; - } - - if (updated.group) { - checkObjectType('group', data.group, 'integer', null); - copy.group = data.group; - } - - if (updated.zOrder) { - checkObjectType('zOrder', data.zOrder, 'integer', null); - copy.zOrder = data.zOrder; - positionUpdated = true; - } - - if (updated.lock) { - checkObjectType('lock', data.lock, 'boolean', null); - copy.lock = data.lock; - } - - if (updated.color) { - checkObjectType('color', data.color, 'string', null); - if (/^#[0-9A-F]{6}$/i.test(data.color)) { - throw new ArgumentError( - `Got invalid color value: "${data.color}"`, - ); - } - - copy.color = data.color; - } - - if (updated.hidden) { - checkObjectType('hidden', data.hidden, 'boolean', null); - copy.hidden = data.hidden; - } - - if (updated.keyframe) { - // Just check here - checkObjectType('keyframe', data.keyframe, 'boolean', null); - } - - // Commit all changes - for (const prop of Object.keys(copy)) { - if (prop in this) { - this[prop] = copy[prop]; + if (updated.label) { + this.label = data.label; + this.attributes = {}; + for (const shape of Object.values(this.shapes)) { + shape.attributes = {}; } + this.appendDefaultAttributes(data.label); } + let mutableAttributesUpdated = false; if (updated.attributes) { - // Mutable attributes will be updated below - for (const attrID of Object.keys(copy.attributes)) { + for (const attrID of Object.keys(data.attributes)) { if (!labelAttributes[attrID].mutable) { this.attributes[attrID] = data.attributes[attrID]; this.attributes[attrID] = data.attributes[attrID]; + } else if (data.attributes[attrID] !== current.attributes[attrID]) { + mutableAttributesUpdated = mutableAttributesUpdated + // not keyframe yet + || !(frame in this.shapes) + // keyframe, but without this attrID + || !(attrID in this.shapes[frame]) + // keyframe with attrID, but with another value + || (this.shapes[frame][attrID] !== data.attributes[attrID]); } } } - if (updated.label) { - for (const shape of Object.values(this.shapes)) { - shape.attributes = {}; - } + if (updated.lock) { + this.lock = data.lock; } - // Remove keyframe - if (updated.keyframe && !data.keyframe) { - if (frame in this.shapes) { - if (Object.keys(this.shapes).length === 1) { - throw new DataError('You cannot remove the latest keyframe of a track'); - } - - delete this.shapes[frame]; - this.updateTimestamp(updated); - updated.reset(); - } - - return objectStateFactory.call(this, frame, this.get(frame)); + if (updated.color) { + this.color = data.color; } - // Add/update keyframe - if (positionUpdated || updated.attributes || (updated.keyframe && data.keyframe)) { - data.keyframe = true; + if (updated.hidden) { + this.hidden = data.hidden; + } + if (updated.points || updated.keyframe || updated.outside + || updated.occluded || updated.zOrder || mutableAttributesUpdated) { + const mutableAttributes = frame in this.shapes ? this.shapes[frame].attributes : {}; this.shapes[frame] = { frame, - zOrder: copy.zOrder, - points: copy.points, - outside: copy.outside, - occluded: copy.occluded, - attributes: {}, + zOrder: data.zOrder, + points: updated.points && fittedPoints.length ? fittedPoints : current.points, + outside: data.outside, + occluded: data.occluded, + attributes: mutableAttributes, }; - if (updated.attributes) { - // Unmutable attributes were updated above - for (const attrID of Object.keys(copy.attributes)) { - if (labelAttributes[attrID].mutable) { - this.shapes[frame].attributes[attrID] = data.attributes[attrID]; - this.shapes[frame].attributes[attrID] = data.attributes[attrID]; - } + for (const attrID of Object.keys(data.attributes)) { + if (labelAttributes[attrID].mutable + && data.attributes[attrID] !== current.attributes[attrID]) { + this.shapes[frame].attributes[attrID] = data.attributes[attrID]; + this.shapes[frame].attributes[attrID] = data.attributes[attrID]; + } + } + + if (updated.keyframe && !data.keyframe) { + if (Object.keys(this.shapes).length === 1) { + throw new DataError('You are not able to remove the latest keyframe for a track. ' + + 'Consider removing a track instead'); + } else { + delete this.shapes[frame]; } } } @@ -753,58 +711,45 @@ return objectStateFactory.call(this, frame, this.get(frame)); } - getPosition(targetFrame) { - const { - leftFrame, - rightFrame, - } = this.neighborsFrames(targetFrame); - + getPosition(targetFrame, leftKeyframe, rightFrame) { + const leftFrame = targetFrame in this.shapes ? targetFrame : leftKeyframe; const rightPosition = Number.isInteger(rightFrame) ? this.shapes[rightFrame] : null; const leftPosition = Number.isInteger(leftFrame) ? this.shapes[leftFrame] : null; - if (leftPosition && leftFrame === targetFrame) { - return { - points: [...leftPosition.points], - occluded: leftPosition.occluded, - outside: leftPosition.outside, - zOrder: leftPosition.zOrder, - keyframe: true, - }; - } - - if (rightPosition && leftPosition) { + if (leftPosition && rightPosition) { return { ...this.interpolatePosition( leftPosition, rightPosition, (targetFrame - leftFrame) / (rightFrame - leftFrame), ), - keyframe: false, + keyframe: targetFrame in this.shapes, }; } - if (rightPosition) { + if (leftPosition) { return { - points: [...rightPosition.points], - occluded: rightPosition.occluded, - outside: true, + points: [...leftPosition.points], + occluded: leftPosition.occluded, + outside: leftPosition.outside, zOrder: 0, - keyframe: false, + keyframe: targetFrame in this.shapes, }; } - if (leftPosition) { + if (rightPosition) { return { - points: [...leftPosition.points], - occluded: leftPosition.occluded, - outside: leftPosition.outside, + points: [...rightPosition.points], + occluded: rightPosition.occluded, + outside: true, zOrder: 0, - keyframe: false, + keyframe: targetFrame in this.shapes, }; } - throw new ScriptingError( - `No one neightbour frame found for the track with client ID: "${this.id}"`, + throw new DataError( + 'No one left position or right position was found. ' + + `Interpolation impossible. Client ID: ${this.id}`, ); } @@ -813,7 +758,7 @@ this.removed = true; } - return true; + return this.removed; } } @@ -873,46 +818,61 @@ return objectStateFactory.call(this, frame, this.get(frame)); } - // All changes are done in this temporary object - const copy = this.get(frame); + if (this.lock && data.lock) { + return objectStateFactory.call(this, frame, this.get(frame)); + } + const updated = data.updateFlags; + // First validate all the fields if (updated.label) { checkObjectType('label', data.label, null, Label); - copy.label = data.label; - copy.attributes = {}; - this.appendDefaultAttributes.call(copy, copy.label); } if (updated.attributes) { - const labelAttributes = copy.label - .attributes.map((attr) => `${attr.id}`); + const labelAttributes = data.label.attributes + .reduce((accumulator, value) => { + accumulator[value.id] = value; + return accumulator; + }, {}); for (const attrID of Object.keys(data.attributes)) { - if (labelAttributes.includes(attrID)) { - copy.attributes[attrID] = data.attributes[attrID]; + const value = data.attributes[attrID]; + if (attrID in labelAttributes) { + if (!validateAttributeValue(value, labelAttributes[attrID])) { + throw new ArgumentError( + `Trying to save an attribute attribute with id ${attrID} and invalid value ${value}`, + ); + } + } else { + throw new ArgumentError( + `Trying to save unknown attribute with id ${attrID} and value ${value}`, + ); } } } - if (updated.group) { - checkObjectType('group', data.group, 'integer', null); - copy.group = data.group; - } - if (updated.lock) { checkObjectType('lock', data.lock, 'boolean', null); - copy.lock = data.lock; } - // Commit state - for (const prop of Object.keys(copy)) { - if (prop in this) { - this[prop] = copy[prop]; + // Now when all fields are validated, we can apply them + if (updated.label) { + this.label = data.label; + this.attributes = {}; + this.appendDefaultAttributes(data.label); + } + + if (updated.attributes) { + for (const attrID of Object.keys(data.attributes)) { + this.attributes[attrID] = data.attributes[attrID]; } } - // Reset flags and commit all changes + if (updated.lock) { + this.lock = data.lock; + } + this.updateTimestamp(updated); updated.reset(); diff --git a/cvat-core/src/common.js b/cvat-core/src/common.js index 51819bffae1..93165db9d0d 100644 --- a/cvat-core/src/common.js +++ b/cvat-core/src/common.js @@ -56,7 +56,7 @@ if (typeof (value) !== type) { // specific case for integers which aren't native type in JS if (type === 'integer' && Number.isInteger(value)) { - return; + return true; } throw new ArgumentError( @@ -77,6 +77,8 @@ ); } } + + return true; } module.exports = { diff --git a/cvat-core/src/enums.js b/cvat-core/src/enums.js index a0e7b2c41d4..0e9b066b31d 100644 --- a/cvat-core/src/enums.js +++ b/cvat-core/src/enums.js @@ -167,7 +167,7 @@ }; /** - * Array of hex color + * Array of hex colors * @type {module:API.cvat.classes.Loader[]} values * @name colors * @memberof module:API.cvat.enums diff --git a/cvat-core/src/frames.js b/cvat-core/src/frames.js index 1ab2eaf21a9..4141de07bfc 100644 --- a/cvat-core/src/frames.js +++ b/cvat-core/src/frames.js @@ -100,9 +100,14 @@ } else if (isBrowser) { const reader = new FileReader(); reader.onload = () => { - frameCache[this.tid][this.number] = reader.result; - resolve(frameCache[this.tid][this.number]); + const image = new Image(frame.width, frame.height); + image.onload = () => { + frameCache[this.tid][this.number] = image; + resolve(frameCache[this.tid][this.number]); + }; + image.src = reader.result; }; + reader.readAsDataURL(frame); } } diff --git a/cvat-core/src/object-state.js b/cvat-core/src/object-state.js index 5d130d5e62b..ff38205068d 100644 --- a/cvat-core/src/object-state.js +++ b/cvat-core/src/object-state.js @@ -21,7 +21,7 @@ * initial information about an ObjectState; * Necessary fields: objectType, shapeType, frame, updated * Optional fields: points, group, zOrder, outside, occluded, hidden, - * attributes, lock, label, mode, color, keyframe, clientID, serverID + * attributes, lock, label, mode, color, keyframe, keyframes, clientID, serverID * These fields can be set later via setters */ constructor(serialized) { @@ -34,11 +34,12 @@ occluded: null, keyframe: null, - group: null, zOrder: null, lock: null, color: null, hidden: null, + group: serialized.group, + keyframes: serialized.keyframes, updated: serialized.updated, clientID: serialized.clientID, @@ -61,7 +62,6 @@ this.occluded = false; this.keyframe = false; - this.group = false; this.zOrder = false; this.lock = false; this.color = false; @@ -190,16 +190,14 @@ }, group: { /** + * Object with short group info { color, id } * @name group - * @type {integer} + * @type {object} * @memberof module:API.cvat.classes.ObjectState * @instance + * @readonly */ get: () => data.group, - set: (group) => { - data.updateFlags.group = true; - data.group = group; - }, }, zOrder: { /** @@ -240,6 +238,22 @@ data.keyframe = keyframe; }, }, + keyframes: { + /** + * Object of keyframes { first, prev, next, last } + * @name keyframes + * @type {object} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => { + if (data.keyframes) { + return { ...data.keyframes }; + } + return null; + }, + }, occluded: { /** * @name occluded @@ -306,7 +320,6 @@ })); this.label = serialized.label; - this.group = serialized.group; this.zOrder = serialized.zOrder; this.outside = serialized.outside; this.keyframe = serialized.keyframe; diff --git a/cvat-core/tests/api/annotations.js b/cvat-core/tests/api/annotations.js index 71a70f46844..18f84f720da 100644 --- a/cvat-core/tests/api/annotations.js +++ b/cvat-core/tests/api/annotations.js @@ -548,7 +548,7 @@ describe('Feature: group annotations', () => { expect(typeof (groupID)).toBe('number'); annotations = await task.annotations.get(0); for (const state of annotations) { - expect(state.group).toBe(groupID); + expect(state.group.id).toBe(groupID); } }); @@ -559,7 +559,7 @@ describe('Feature: group annotations', () => { expect(typeof (groupID)).toBe('number'); annotations = await job.annotations.get(0); for (const state of annotations) { - expect(state.group).toBe(groupID); + expect(state.group.id).toBe(groupID); } }); diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 80f51c0cdd2..0bf029ba7cc 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -32,6 +32,8 @@ export enum AnnotationActionTypes { MERGE_OBJECTS = 'MERGE_OBJECTS', GROUP_OBJECTS = 'GROUP_OBJECTS', SPLIT_TRACK = 'SPLIT_TRACK', + COPY_SHAPE = 'COPY_SHAPE', + EDIT_SHAPE = 'EDIT_SHAPE', DRAW_SHAPE = 'DRAW_SHAPE', SHAPE_DRAWN = 'SHAPE_DRAWN', RESET_CANVAS = 'RESET_CANVAS', @@ -50,7 +52,215 @@ export enum AnnotationActionTypes { UPDATE_TAB_CONTENT_HEIGHT = 'UPDATE_TAB_CONTENT_HEIGHT', COLLAPSE_SIDEBAR = 'COLLAPSE_SIDEBAR', COLLAPSE_APPEARANCE = 'COLLAPSE_APPEARANCE', - COLLAPSE_OBJECT_ITEMS = 'COLLAPSE_OBJECT_ITEMS' + COLLAPSE_OBJECT_ITEMS = 'COLLAPSE_OBJECT_ITEMS', + ACTIVATE_OBJECT = 'ACTIVATE_OBJECT', + SELECT_OBJECTS = 'SELECT_OBJECTS', + REMOVE_OBJECT_SUCCESS = 'REMOVE_OBJECT_SUCCESS', + REMOVE_OBJECT_FAILED = 'REMOVE_OBJECT_FAILED', + PROPAGATE_OBJECT = 'PROPAGATE_OBJECT', + PROPAGATE_OBJECT_SUCCESS = 'PROPAGATE_OBJECT_SUCCESS', + PROPAGATE_OBJECT_FAILED = 'PROPAGATE_OBJECT_FAILED', + CHANGE_PROPAGATE_FRAMES = 'CHANGE_PROPAGATE_FRAMES', + SWITCH_SHOWING_STATISTICS = 'SWITCH_SHOWING_STATISTICS', + COLLECT_STATISTICS = 'COLLECT_STATISTICS', + COLLECT_STATISTICS_SUCCESS = 'COLLECT_STATISTICS_SUCCESS', + COLLECT_STATISTICS_FAILED = 'COLLECT_STATISTICS_FAILED', + CHANGE_JOB_STATUS = 'CHANGE_JOB_STATUS', + CHANGE_JOB_STATUS_SUCCESS = 'CHANGE_JOB_STATUS_SUCCESS', + CHANGE_JOB_STATUS_FAILED = 'CHANGE_JOB_STATUS_FAILED', +} + +export function changeJobStatusAsync(jobInstance: any, status: string): +ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + const oldStatus = jobInstance.status; + try { + dispatch({ + type: AnnotationActionTypes.CHANGE_JOB_STATUS, + payload: {}, + }); + + // eslint-disable-next-line no-param-reassign + jobInstance.status = status; + await jobInstance.save(); + + dispatch({ + type: AnnotationActionTypes.CHANGE_JOB_STATUS_SUCCESS, + payload: {}, + }); + } catch (error) { + // eslint-disable-next-line no-param-reassign + jobInstance.status = oldStatus; + dispatch({ + type: AnnotationActionTypes.CHANGE_JOB_STATUS_FAILED, + payload: { + error, + }, + }); + } + }; +} + +export function collectStatisticsAsync(sessionInstance: any): +ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + dispatch({ + type: AnnotationActionTypes.COLLECT_STATISTICS, + payload: {}, + }); + + const data = await sessionInstance.annotations.statistics(); + + dispatch({ + type: AnnotationActionTypes.COLLECT_STATISTICS_SUCCESS, + payload: { + data, + }, + }); + } catch (error) { + dispatch({ + type: AnnotationActionTypes.COLLECT_STATISTICS_FAILED, + payload: { + error, + }, + }); + } + }; +} + +export function showStatistics(visible: boolean): AnyAction { + return { + type: AnnotationActionTypes.SWITCH_SHOWING_STATISTICS, + payload: { + visible, + }, + }; +} + +export function propagateObjectAsync( + sessionInstance: any, + objectState: any, + from: number, + to: number, +): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + const copy = { + attributes: objectState.attributes, + points: objectState.points, + occluded: objectState.occluded, + objectType: objectState.objectType !== ObjectType.TRACK + ? objectState.objectType : ObjectType.SHAPE, + shapeType: objectState.shapeType, + label: objectState.label, + frame: from, + }; + + const states = []; + for (let frame = from; frame <= to; frame++) { + copy.frame = frame; + const newState = new cvat.classes.ObjectState(copy); + states.push(newState); + } + + await sessionInstance.annotations.put(states); + + dispatch({ + type: AnnotationActionTypes.PROPAGATE_OBJECT_SUCCESS, + payload: { + objectState, + }, + }); + } catch (error) { + dispatch({ + type: AnnotationActionTypes.PROPAGATE_OBJECT_FAILED, + payload: { + error, + }, + }); + } + }; +} + +export function propagateObject(objectState: any | null): AnyAction { + return { + type: AnnotationActionTypes.PROPAGATE_OBJECT, + payload: { + objectState, + }, + }; +} + +export function changePropagateFrames(frames: number): AnyAction { + return { + type: AnnotationActionTypes.CHANGE_PROPAGATE_FRAMES, + payload: { + frames, + }, + }; +} + +export function removeObjectAsync(objectState: any, force: boolean): +ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + const removed = await objectState.delete(force); + if (removed) { + dispatch({ + type: AnnotationActionTypes.REMOVE_OBJECT_SUCCESS, + payload: { + objectState, + }, + }); + } else { + throw new Error('Could not remove the object. Is it locked?'); + } + } catch (error) { + dispatch({ + type: AnnotationActionTypes.REMOVE_OBJECT_FAILED, + payload: { + objectState, + }, + }); + } + }; +} + +export function editShape(enabled: boolean): AnyAction { + return { + type: AnnotationActionTypes.EDIT_SHAPE, + payload: { + enabled, + }, + }; +} + +export function copyShape(objectState: any): AnyAction { + return { + type: AnnotationActionTypes.COPY_SHAPE, + payload: { + objectState, + }, + }; +} + +export function selectObjects(selectedStatesID: number[]): AnyAction { + return { + type: AnnotationActionTypes.SELECT_OBJECTS, + payload: { + selectedStatesID, + }, + }; +} + +export function activateObject(activatedStateID: number | null): AnyAction { + return { + type: AnnotationActionTypes.ACTIVATE_OBJECT, + payload: { + activatedStateID, + }, + }; } export function updateTabContentHeight(tabContentHeight: number): AnyAction { @@ -452,23 +662,32 @@ ThunkAction, {}, {}, AnyAction> { }; } -export function changeLabelColor(label: any, color: string): AnyAction { - try { - const updatedLabel = label; - updatedLabel.color = color; - - return { - type: AnnotationActionTypes.CHANGE_LABEL_COLOR_SUCCESS, - payload: { - label: updatedLabel, - }, - }; - } catch (error) { - return { - type: AnnotationActionTypes.CHANGE_LABEL_COLOR_FAILED, - payload: { - error, - }, - }; - } +export function changeLabelColorAsync( + sessionInstance: any, + frameNumber: number, + label: any, + color: string, +): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + const updatedLabel = label; + updatedLabel.color = color; + const states = await sessionInstance.annotations.get(frameNumber); + + dispatch({ + type: AnnotationActionTypes.CHANGE_LABEL_COLOR_SUCCESS, + payload: { + label: updatedLabel, + states, + }, + }); + } catch (error) { + dispatch({ + type: AnnotationActionTypes.CHANGE_LABEL_COLOR_FAILED, + payload: { + error, + }, + }); + } + }; } diff --git a/cvat-ui/src/actions/settings-actions.ts b/cvat-ui/src/actions/settings-actions.ts index 33802c780f8..e1614cdfb42 100644 --- a/cvat-ui/src/actions/settings-actions.ts +++ b/cvat-ui/src/actions/settings-actions.ts @@ -1,6 +1,7 @@ import { AnyAction } from 'redux'; import { GridColor, + ColorBy, } from 'reducers/interfaces'; export enum SettingsActionTypes { @@ -9,6 +10,46 @@ export enum SettingsActionTypes { CHANGE_GRID_SIZE = 'CHANGE_GRID_SIZE', CHANGE_GRID_COLOR = 'CHANGE_GRID_COLOR', CHANGE_GRID_OPACITY = 'CHANGE_GRID_OPACITY', + CHANGE_SHAPES_OPACITY = 'CHANGE_SHAPES_OPACITY', + CHANGE_SELECTED_SHAPES_OPACITY = 'CHANGE_SELECTED_SHAPES_OPACITY', + CHANGE_SHAPES_COLOR_BY = 'CHANGE_SHAPES_COLOR_BY', + CHANGE_SHAPES_BLACK_BORDERS = 'CHANGE_SHAPES_BLACK_BORDERS', +} + +export function changeShapesOpacity(opacity: number): AnyAction { + return { + type: SettingsActionTypes.CHANGE_SHAPES_OPACITY, + payload: { + opacity, + }, + }; +} + +export function changeSelectedShapesOpacity(selectedOpacity: number): AnyAction { + return { + type: SettingsActionTypes.CHANGE_SELECTED_SHAPES_OPACITY, + payload: { + selectedOpacity, + }, + }; +} + +export function changeShapesColorBy(colorBy: ColorBy): AnyAction { + return { + type: SettingsActionTypes.CHANGE_SHAPES_COLOR_BY, + payload: { + colorBy, + }, + }; +} + +export function changeShapesBlackBorders(blackBorders: boolean): AnyAction { + return { + type: SettingsActionTypes.CHANGE_SHAPES_BLACK_BORDERS, + payload: { + blackBorders, + }, + }; } export function switchRotateAll(rotateAll: boolean): AnyAction { diff --git a/cvat-ui/src/base.scss b/cvat-ui/src/base.scss index 9d43c1ad0ff..c4a83cd8dde 100644 --- a/cvat-ui/src/base.scss +++ b/cvat-ui/src/base.scss @@ -17,5 +17,6 @@ $info-icon-color: #0074D9; $objects-bar-tabs-color: #BEBEBE; $objects-bar-icons-color: #242424; // #6E6E6E $active-object-item-background-color: #D8ECFF; +$slider-color: #1890FF; $monospaced-fonts-stack: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace; diff --git a/cvat-ui/src/components/annotation-page/annotation-page.tsx b/cvat-ui/src/components/annotation-page/annotation-page.tsx index 842b1b0217c..82c5bcff835 100644 --- a/cvat-ui/src/components/annotation-page/annotation-page.tsx +++ b/cvat-ui/src/components/annotation-page/annotation-page.tsx @@ -8,6 +8,7 @@ import { } from 'antd'; import AnnotationTopBarContainer from 'containers/annotation-page/top-bar/top-bar'; +import StatisticsModalContainer from 'containers/annotation-page/top-bar/statistics-modal'; import StandardWorkspaceComponent from './standard-workspace/standard-workspace'; interface Props { @@ -46,6 +47,7 @@ export default function AnnotationPageComponent(props: Props): JSX.Element { + ); } 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 f070d61d9a1..696ec2cd020 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 @@ -5,6 +5,7 @@ import { } from 'antd'; import { + ColorBy, GridColor, ObjectType, } from 'reducers/interfaces'; @@ -23,9 +24,15 @@ interface Props { sidebarCollapsed: boolean; canvasInstance: Canvas; jobInstance: any; + activatedStateID: number | null; + selectedStatesID: number[]; annotations: any[]; frameData: any; frame: number; + opacity: number; + colorBy: ColorBy; + selectedOpacity: number; + blackBorders: boolean; grid: boolean; gridSize: number; gridColor: GridColor; @@ -38,6 +45,7 @@ interface Props { onMergeObjects: (enabled: boolean) => void; onGroupObjects: (enabled: boolean) => void; onSplitTrack: (enabled: boolean) => void; + onEditShape: (enabled: boolean) => void; onShapeDrawn: () => void; onResetCanvas: () => void; onUpdateAnnotations(sessionInstance: any, frame: number, states: any[]): void; @@ -45,6 +53,8 @@ interface Props { onMergeAnnotations(sessionInstance: any, frame: number, states: any[]): void; onGroupAnnotations(sessionInstance: any, frame: number, states: any[]): void; onSplitAnnotations(sessionInstance: any, frame: number, state: any): void; + onActivateObject: (activatedStateID: number | null) => void; + onSelectObjects: (selectedStatesID: number[]) => void; } export default class CanvasWrapperComponent extends React.PureComponent { @@ -65,12 +75,19 @@ export default class CanvasWrapperComponent extends React.PureComponent { public componentDidUpdate(prevProps: Props): void { const { + opacity, + colorBy, + selectedOpacity, + blackBorders, grid, gridSize, gridColor, gridOpacity, + frameData, + annotations, canvasInstance, sidebarCollapsed, + activatedStateID, } = this.props; if (prevProps.sidebarCollapsed !== sidebarCollapsed) { @@ -107,10 +124,32 @@ export default class CanvasWrapperComponent extends React.PureComponent { } } - this.updateCanvas(); + if (prevProps.activatedStateID !== null + && prevProps.activatedStateID !== activatedStateID) { + canvasInstance.activate(null); + const el = window.document.getElementById(`cvat_canvas_shape_${prevProps.activatedStateID}`); + if (el) { + (el as any).instance.fill({ opacity: opacity / 100 }); + } + } + + if (prevProps.annotations !== annotations || prevProps.frameData !== frameData) { + this.updateCanvas(); + } + + if (prevProps.opacity !== opacity || prevProps.blackBorders !== blackBorders + || prevProps.selectedOpacity !== selectedOpacity || prevProps.colorBy !== colorBy) { + this.updateShapesView(); + } + + this.activateOnCanvas(); } - private async onShapeDrawn(event: any): Promise { + public componentWillUnmount(): void { + window.removeEventListener('resize', this.fitCanvas); + } + + private onShapeDrawn(event: any): void { const { jobInstance, activeLabelID, @@ -120,7 +159,9 @@ export default class CanvasWrapperComponent extends React.PureComponent { onCreateAnnotations, } = this.props; - onShapeDrawn(); + if (!event.detail.continue) { + onShapeDrawn(); + } const { state } = event.detail; if (!state.objectType) { @@ -132,7 +173,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { .filter((label: any) => label.id === activeLabelID); } - if (!state.occluded) { + if (typeof (state.occluded) === 'undefined') { state.occluded = false; } @@ -141,13 +182,16 @@ export default class CanvasWrapperComponent extends React.PureComponent { onCreateAnnotations(jobInstance, frame, [objectState]); } - private async onShapeEdited(event: any): Promise { + private onShapeEdited(event: any): void { const { jobInstance, frame, + onEditShape, onUpdateAnnotations, } = this.props; + onEditShape(false); + const { state, points, @@ -156,7 +200,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { onUpdateAnnotations(jobInstance, frame, [state]); } - private async onObjectsMerged(event: any): Promise { + private onObjectsMerged(event: any): void { const { jobInstance, frame, @@ -170,7 +214,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { onMergeAnnotations(jobInstance, frame, states); } - private async onObjectsGroupped(event: any): Promise { + private onObjectsGroupped(event: any): void { const { jobInstance, frame, @@ -184,7 +228,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { onGroupAnnotations(jobInstance, frame, states); } - private async onTrackSplitted(event: any): Promise { + private onTrackSplitted(event: any): void { const { jobInstance, frame, @@ -198,6 +242,61 @@ export default class CanvasWrapperComponent extends React.PureComponent { onSplitAnnotations(jobInstance, frame, state); } + private fitCanvas = (): void => { + const { canvasInstance } = this.props; + canvasInstance.fitCanvas(); + }; + + private activateOnCanvas(): void { + const { + activatedStateID, + canvasInstance, + selectedOpacity, + } = this.props; + + if (activatedStateID !== null) { + canvasInstance.activate(activatedStateID); + const el = window.document.getElementById(`cvat_canvas_shape_${activatedStateID}`); + if (el) { + (el as any as SVGElement).setAttribute('fill-opacity', `${selectedOpacity / 100}`); + } + } + } + + private updateShapesView(): void { + const { + annotations, + opacity, + colorBy, + blackBorders, + } = this.props; + + for (const state of annotations) { + let shapeColor = ''; + if (colorBy === ColorBy.INSTANCE) { + shapeColor = state.color; + } else if (colorBy === ColorBy.GROUP) { + shapeColor = state.group.color; + } else if (colorBy === ColorBy.LABEL) { + shapeColor = state.label.color; + } + + // TODO: In this approach CVAT-UI know details of implementations CVAT-CANVAS (svg.js) + const shapeView = window.document.getElementById(`cvat_canvas_shape_${state.clientID}`); + if (shapeView) { + if (['rect', 'polygon', 'polyline'].includes(shapeView.tagName)) { + (shapeView as any).instance.fill({ color: shapeColor, opacity: opacity / 100 }); + (shapeView as any).instance.stroke({ color: blackBorders ? 'black' : shapeColor }); + } else { + // group of points + for (const child of (shapeView as any).instance.children()) { + child.fill({ color: shapeColor }); + } + } + } + } + } + private updateCanvas(): void { const { annotations, @@ -222,10 +321,13 @@ export default class CanvasWrapperComponent extends React.PureComponent { onDragCanvas, onZoomCanvas, onResetCanvas, + onActivateObject, + onEditShape, } = this.props; // Size - canvasInstance.fitCanvas(); + window.addEventListener('resize', this.fitCanvas); + this.fitCanvas(); // Grid const gridElement = window.document.getElementById('cvat_canvas_grid'); @@ -240,8 +342,21 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.grid(gridSize, gridSize); // Events + canvasInstance.html().addEventListener('click', (e: MouseEvent): void => { + if ((e.target as HTMLElement).tagName === 'svg') { + onActivateObject(null); + } + }); + + canvasInstance.html().addEventListener('canvas.editstart', (): void => { + onActivateObject(null); + onEditShape(true); + }); + canvasInstance.html().addEventListener('canvas.setup', (): void => { onSetupCanvas(); + this.updateShapesView(); + this.activateOnCanvas(); }); canvasInstance.html().addEventListener('canvas.setup', () => { @@ -268,7 +383,29 @@ export default class CanvasWrapperComponent extends React.PureComponent { onZoomCanvas(false); }); + canvasInstance.html().addEventListener('canvas.clicked', (e: any) => { + const { clientID } = e.detail.state; + const sidebarItem = window.document + .getElementById(`cvat-objects-sidebar-state-item-${clientID}`); + if (sidebarItem) { + sidebarItem.scrollIntoView(); + } + }); + + canvasInstance.html().addEventListener('canvas.deactivated', (e: any): void => { + const { activatedStateID } = this.props; + const { state } = e.detail; + + // when we activate element, canvas deactivates the previous + // and triggers this event + // in this case we do not need to update our state + if (state.clientID === activatedStateID) { + onActivateObject(null); + } + }); + canvasInstance.html().addEventListener('canvas.moved', async (event: any): Promise => { + const { activatedStateID } = this.props; const result = await jobInstance.annotations.select( event.detail.states, event.detail.x, @@ -282,7 +419,9 @@ export default class CanvasWrapperComponent extends React.PureComponent { } } - canvasInstance.activate(result.state.clientID); + if (activatedStateID !== result.state.clientID) { + onActivateObject(result.state.clientID); + } } }); 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 7bb0c4c4f5e..494718972e0 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 @@ -87,7 +87,7 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { /> - +
diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/cursor-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/cursor-control.tsx index dcb3c03e56f..ae590cc44aa 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/cursor-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/cursor-control.tsx @@ -22,7 +22,7 @@ interface Props { activeControl: ActiveControl; } -const CursorControl = React.memo((props: Props): JSX.Element => { +function CursorControl(props: Props): JSX.Element { const { canvasInstance, activeControl, @@ -43,6 +43,6 @@ const CursorControl = React.memo((props: Props): JSX.Element => { /> ); -}); +} -export default CursorControl; +export default React.memo(CursorControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-points-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-points-control.tsx index b83e547e5d0..2d4351923e8 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-points-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-points-control.tsx @@ -15,7 +15,7 @@ interface Props { isDrawing: boolean; } -const DrawPointsControl = React.memo((props: Props): JSX.Element => { +function DrawPointsControl(props: Props): JSX.Element { const { canvasInstance, isDrawing, @@ -49,6 +49,6 @@ const DrawPointsControl = React.memo((props: Props): JSX.Element => { /> ); -}); +} -export default DrawPointsControl; +export default React.memo(DrawPointsControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-polygon-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-polygon-control.tsx index 837254d5e73..3ebebaadbef 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-polygon-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-polygon-control.tsx @@ -15,7 +15,7 @@ interface Props { isDrawing: boolean; } -const DrawPolygonControl = React.memo((props: Props): JSX.Element => { +function DrawPolygonControl(props: Props): JSX.Element { const { canvasInstance, isDrawing, @@ -49,6 +49,6 @@ const DrawPolygonControl = React.memo((props: Props): JSX.Element => { /> ); -}); +} -export default DrawPolygonControl; +export default React.memo(DrawPolygonControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-polyline-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-polyline-control.tsx index 961a520aeab..1959010350e 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-polyline-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-polyline-control.tsx @@ -15,7 +15,7 @@ interface Props { isDrawing: boolean; } -const DrawPolylineControl = React.memo((props: Props): JSX.Element => { +function DrawPolylineControl(props: Props): JSX.Element { const { canvasInstance, isDrawing, @@ -49,6 +49,6 @@ const DrawPolylineControl = React.memo((props: Props): JSX.Element => { /> ); -}); +} -export default DrawPolylineControl; +export default React.memo(DrawPolylineControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-rectangle-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-rectangle-control.tsx index 10e1eb1d8f0..4201ccd0315 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-rectangle-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-rectangle-control.tsx @@ -15,7 +15,7 @@ interface Props { isDrawing: boolean; } -const DrawRectangleControl = React.memo((props: Props): JSX.Element => { +function DrawRectangleControl(props: Props): JSX.Element { const { canvasInstance, isDrawing, @@ -49,6 +49,6 @@ const DrawRectangleControl = React.memo((props: Props): JSX.Element => { /> ); -}); +} -export default DrawRectangleControl; +export default React.memo(DrawRectangleControl); 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 efad75e5072..ba43a8c368d 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 @@ -26,7 +26,7 @@ interface Props { onDrawShape(): void; } -const DrawShapePopoverComponent = React.memo((props: Props): JSX.Element => { +function DrawShapePopoverComponent(props: Props): JSX.Element { const { labels, shapeType, @@ -106,6 +106,6 @@ const DrawShapePopoverComponent = React.memo((props: Props): JSX.Element => { ); -}); +} -export default DrawShapePopoverComponent; +export default React.memo(DrawShapePopoverComponent); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/fit-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/fit-control.tsx index 998d00820a8..6fc8e750e85 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/fit-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/fit-control.tsx @@ -17,7 +17,7 @@ interface Props { canvasInstance: Canvas; } -const FitControl = React.memo((props: Props): JSX.Element => { +function FitControl(props: Props): JSX.Element { const { canvasInstance, } = props; @@ -27,6 +27,6 @@ const FitControl = React.memo((props: Props): JSX.Element => { canvasInstance.fit()} /> ); -}); +} -export default FitControl; +export default React.memo(FitControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/group-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/group-control.tsx index 1c2732f8f28..f6b6c717585 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/group-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/group-control.tsx @@ -19,7 +19,7 @@ interface Props { groupObjects(enabled: boolean): void; } -const GroupControl = React.memo((props: Props): JSX.Element => { +function GroupControl(props: Props): JSX.Element { const { activeControl, canvasInstance, @@ -46,6 +46,6 @@ const GroupControl = React.memo((props: Props): JSX.Element => { ); -}); +} -export default GroupControl; +export default React.memo(GroupControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/merge-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/merge-control.tsx index b4dcc0076f5..9e43855473c 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/merge-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/merge-control.tsx @@ -19,7 +19,7 @@ interface Props { mergeObjects(enabled: boolean): void; } -const MergeControl = React.memo((props: Props): JSX.Element => { +function MergeControl(props: Props): JSX.Element { const { activeControl, canvasInstance, @@ -46,6 +46,6 @@ const MergeControl = React.memo((props: Props): JSX.Element => { ); -}); +} -export default MergeControl; +export default React.memo(MergeControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/move-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/move-control.tsx index 89aa6542cb1..e17659081de 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/move-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/move-control.tsx @@ -22,7 +22,7 @@ interface Props { activeControl: ActiveControl; } -const MoveControl = React.memo((props: Props): JSX.Element => { +function MoveControl(props: Props): JSX.Element { const { canvasInstance, activeControl, @@ -46,6 +46,6 @@ const MoveControl = React.memo((props: Props): JSX.Element => { /> ); -}); +} -export default MoveControl; +export default React.memo(MoveControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/resize-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/resize-control.tsx index 819b3c70c27..5acca3d3f1c 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/resize-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/resize-control.tsx @@ -22,7 +22,7 @@ interface Props { activeControl: ActiveControl; } -const ResizeControl = React.memo((props: Props): JSX.Element => { +function ResizeControl(props: Props): JSX.Element { const { activeControl, canvasInstance, @@ -46,6 +46,6 @@ const ResizeControl = React.memo((props: Props): JSX.Element => { /> ); -}); +} -export default ResizeControl; +export default React.memo(ResizeControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/rotate-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/rotate-control.tsx index d0acee42b5c..c5dca6d772a 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/rotate-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/rotate-control.tsx @@ -20,7 +20,7 @@ interface Props { rotateAll: boolean; } -const RotateControl = React.memo((props: Props): JSX.Element => { +function RotateControl(props: Props): JSX.Element { const { rotateAll, canvasInstance, @@ -55,6 +55,6 @@ const RotateControl = React.memo((props: Props): JSX.Element => { ); -}); +} -export default RotateControl; +export default React.memo(RotateControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/split-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/split-control.tsx index c4725f7a77d..6190d4dfd12 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/split-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/split-control.tsx @@ -19,7 +19,7 @@ interface Props { splitTrack(enabled: boolean): void; } -const SplitControl = React.memo((props: Props): JSX.Element => { +function SplitControl(props: Props): JSX.Element { const { activeControl, canvasInstance, @@ -46,6 +46,6 @@ const SplitControl = React.memo((props: Props): JSX.Element => { ); -}); +} -export default SplitControl; +export default React.memo(SplitControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/appearance-block.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/appearance-block.tsx new file mode 100644 index 00000000000..530899533a0 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/appearance-block.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { + Checkbox, + Collapse, + Slider, + Radio, +} from 'antd'; + +import Text from 'antd/lib/typography/Text'; +import { RadioChangeEvent } from 'antd/lib/radio'; +import { SliderValue } from 'antd/lib/slider'; +import { CheckboxChangeEvent } from 'antd/lib/checkbox'; + +import { ColorBy } from 'reducers/interfaces'; + +interface Props { + appearanceCollapsed: boolean; + colorBy: ColorBy; + opacity: number; + selectedOpacity: number; + blackBorders: boolean; + + collapseAppearance(): void; + changeShapesColorBy(event: RadioChangeEvent): void; + changeShapesOpacity(event: SliderValue): void; + changeSelectedShapesOpacity(event: SliderValue): void; + changeShapesBlackBorders(event: CheckboxChangeEvent): void; +} + +function AppearanceBlock(props: Props): JSX.Element { + const { + appearanceCollapsed, + colorBy, + opacity, + selectedOpacity, + blackBorders, + collapseAppearance, + changeShapesColorBy, + changeShapesOpacity, + changeSelectedShapesOpacity, + changeShapesBlackBorders, + } = props; + + return ( + + Appearance + } + key='appearance' + > +
+ Color by + + {ColorBy.INSTANCE} + {ColorBy.GROUP} + {ColorBy.LABEL} + + Opacity + + Selected opacity + + + Black borders + +
+
+
+ ); +} + +export default React.memo(AppearanceBlock); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/label-item.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/label-item.tsx index 90284e39bb9..940ee66eb69 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/label-item.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/label-item.tsx @@ -71,7 +71,7 @@ interface Props { changeColor(color: string): void; } -const LabelItemComponent = React.memo((props: Props): JSX.Element => { +function LabelItemComponent(props: Props): JSX.Element { const { labelName, labelColor, @@ -125,6 +125,6 @@ const LabelItemComponent = React.memo((props: Props): JSX.Element => { ); -}); +} -export default LabelItemComponent; +export default React.memo(LabelItemComponent); 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 d45c16afe58..155530a767a 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 @@ -10,11 +10,15 @@ import { Collapse, Checkbox, InputNumber, + Dropdown, + Menu, + Button, + Modal, } from 'antd'; import Text from 'antd/lib/typography/Text'; -import { CheckboxChangeEvent } from 'antd/lib/checkbox'; import { RadioChangeEvent } from 'antd/lib/radio'; +import { CheckboxChangeEvent } from 'antd/lib/checkbox'; import { ObjectOutsideIcon, @@ -28,21 +32,72 @@ import { ObjectType, ShapeType, } from 'reducers/interfaces'; -interface ItemTopProps { +function ItemMenu( + locked: boolean, + copy: (() => void), + remove: (() => void), + propagate: (() => void), +): JSX.Element { + return ( + + + + + + + + + + + + ); +} + +interface ItemTopComponentProps { clientID: number; labelID: number; labels: any[]; type: string; + locked: boolean; changeLabel(labelID: string): void; + copy(): void; + remove(): void; + propagate(): void; } -const ItemTop = React.memo((props: ItemTopProps): JSX.Element => { +function ItemTopComponent(props: ItemTopComponentProps): JSX.Element { const { clientID, labelID, labels, type, + locked, changeLabel, + copy, + remove, + propagate, } = props; return ( @@ -62,13 +117,20 @@ const ItemTop = React.memo((props: ItemTopProps): JSX.Element => { - + + + ); -}); +} + +const ItemTop = React.memo(ItemTopComponent); -interface ItemButtonsProps { +interface ItemButtonsComponentProps { objectType: ObjectType; occluded: boolean; outside: boolean | undefined; @@ -76,6 +138,11 @@ interface ItemButtonsProps { hidden: boolean; keyframe: boolean | undefined; + navigateFirstKeyframe: null | (() => void); + navigatePrevKeyframe: null | (() => void); + navigateNextKeyframe: null | (() => void); + navigateLastKeyframe: null | (() => void); + setOccluded(): void; unsetOccluded(): void; setOutside(): void; @@ -88,7 +155,7 @@ interface ItemButtonsProps { show(): void; } -const ItemButtons = React.memo((props: ItemButtonsProps): JSX.Element => { +function ItemButtonsComponent(props: ItemButtonsComponentProps): JSX.Element { const { objectType, occluded, @@ -96,6 +163,12 @@ const ItemButtons = React.memo((props: ItemButtonsProps): JSX.Element => { locked, hidden, keyframe, + + navigateFirstKeyframe, + navigatePrevKeyframe, + navigateNextKeyframe, + navigateLastKeyframe, + setOccluded, unsetOccluded, setOutside, @@ -114,16 +187,28 @@ const ItemButtons = React.memo((props: ItemButtonsProps): JSX.Element => { - + { navigateFirstKeyframe + ? + : + } - + { navigatePrevKeyframe + ? + : + } - + { navigateNextKeyframe + ? + : + } - + { navigateLastKeyframe + ? + : + } @@ -189,9 +274,11 @@ const ItemButtons = React.memo((props: ItemButtonsProps): JSX.Element => { ); -}); +} -interface ItemAttributeProps { +const ItemButtons = React.memo(ItemButtonsComponent); + +interface ItemAttributeComponentProps { attrInputType: string; attrValues: string[]; attrValue: string; @@ -200,7 +287,10 @@ interface ItemAttributeProps { changeAttribute(attrID: number, value: string): void; } -function attrIsTheSame(prevProps: ItemAttributeProps, nextProps: ItemAttributeProps): boolean { +function attrIsTheSame( + prevProps: ItemAttributeComponentProps, + nextProps: ItemAttributeComponentProps, +): boolean { return nextProps.attrID === prevProps.attrID && nextProps.attrValue === prevProps.attrValue && nextProps.attrName === prevProps.attrName @@ -210,7 +300,7 @@ function attrIsTheSame(prevProps: ItemAttributeProps, nextProps: ItemAttributePr .every((value: boolean): boolean => value); } -const ItemAttribute = React.memo((props: ItemAttributeProps): JSX.Element => { +function ItemAttributeComponent(props: ItemAttributeComponentProps): JSX.Element { const { attrInputType, attrValues, @@ -332,10 +422,12 @@ const ItemAttribute = React.memo((props: ItemAttributeProps): JSX.Element => { ); -}, attrIsTheSame); +} +const ItemAttribute = React.memo(ItemAttributeComponent, attrIsTheSame); -interface ItemAttributesProps { + +interface ItemAttributesComponentProps { collapsed: boolean; attributes: any[]; values: Record; @@ -352,13 +444,16 @@ function attrValuesAreEqual(next: Record, prev: Record value); } -function attrAreTheSame(prevProps: ItemAttributesProps, nextProps: ItemAttributesProps): boolean { +function attrAreTheSame( + prevProps: ItemAttributesComponentProps, + nextProps: ItemAttributesComponentProps, +): boolean { return nextProps.collapsed === prevProps.collapsed && nextProps.attributes === prevProps.attributes && attrValuesAreEqual(nextProps.values, prevProps.values); } -const ItemAttributes = React.memo((props: ItemAttributesProps): JSX.Element => { +function ItemAttributesComponent(props: ItemAttributesComponentProps): JSX.Element { const { collapsed, attributes, @@ -403,9 +498,12 @@ const ItemAttributes = React.memo((props: ItemAttributesProps): JSX.Element => { ); -}, attrAreTheSame); +} + +const ItemAttributes = React.memo(ItemAttributesComponent, attrAreTheSame); interface Props { + activated: boolean; objectType: ObjectType; shapeType: ShapeType; clientID: number; @@ -421,7 +519,15 @@ interface Props { labels: any[]; attributes: any[]; collapsed: boolean; - + navigateFirstKeyframe: null | (() => void); + navigatePrevKeyframe: null | (() => void); + navigateNextKeyframe: null | (() => void); + navigateLastKeyframe: null | (() => void); + + activate(): void; + copy(): void; + propagate(): void; + remove(): void; setOccluded(): void; unsetOccluded(): void; setOutside(): void; @@ -438,12 +544,13 @@ interface Props { } function objectItemsAreEqual(prevProps: Props, nextProps: Props): boolean { - return nextProps.locked === prevProps.locked + return nextProps.activated === prevProps.activated + && nextProps.locked === prevProps.locked && nextProps.occluded === prevProps.occluded && nextProps.outside === prevProps.outside && nextProps.hidden === prevProps.hidden && nextProps.keyframe === prevProps.keyframe - && nextProps.label === prevProps.label + && nextProps.labelID === prevProps.labelID && nextProps.color === prevProps.color && nextProps.clientID === prevProps.clientID && nextProps.objectType === prevProps.objectType @@ -451,11 +558,16 @@ function objectItemsAreEqual(prevProps: Props, nextProps: Props): boolean { && nextProps.collapsed === prevProps.collapsed && nextProps.labels === prevProps.labels && nextProps.attributes === prevProps.attributes + && nextProps.navigateFirstKeyframe === prevProps.navigateFirstKeyframe + && nextProps.navigatePrevKeyframe === prevProps.navigatePrevKeyframe + && nextProps.navigateNextKeyframe === prevProps.navigateNextKeyframe + && nextProps.navigateLastKeyframe === prevProps.navigateLastKeyframe && attrValuesAreEqual(nextProps.attrValues, prevProps.attrValues); } -const ObjectItem = React.memo((props: Props): JSX.Element => { +function ObjectItemComponent(props: Props): JSX.Element { const { + activated, objectType, shapeType, clientID, @@ -471,7 +583,15 @@ const ObjectItem = React.memo((props: Props): JSX.Element => { attributes, labels, collapsed, - + navigateFirstKeyframe, + navigatePrevKeyframe, + navigateNextKeyframe, + navigateLastKeyframe, + + activate, + copy, + propagate, + remove, setOccluded, unsetOccluded, setOutside, @@ -490,9 +610,14 @@ const ObjectItem = React.memo((props: Props): JSX.Element => { const type = objectType === ObjectType.TAG ? ObjectType.TAG.toUpperCase() : `${shapeType.toUpperCase()} ${objectType.toUpperCase()}`; + const className = !activated ? 'cvat-objects-sidebar-state-item' + : 'cvat-objects-sidebar-state-item cvat-objects-sidebar-state-active-item'; + return (
{ labelID={labelID} labels={labels} type={type} + locked={locked} changeLabel={changeLabel} + copy={copy} + remove={remove} + propagate={propagate} /> { locked={locked} hidden={hidden} keyframe={keyframe} + navigateFirstKeyframe={navigateFirstKeyframe} + navigatePrevKeyframe={navigatePrevKeyframe} + navigateNextKeyframe={navigateNextKeyframe} + navigateLastKeyframe={navigateLastKeyframe} setOccluded={setOccluded} unsetOccluded={unsetOccluded} setOutside={setOutside} @@ -533,6 +666,6 @@ const ObjectItem = React.memo((props: Props): JSX.Element => { }
); -}, objectItemsAreEqual); +} -export default ObjectItem; +export default React.memo(ObjectItemComponent, objectItemsAreEqual); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list-header.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list-header.tsx index 4b43490b2c5..35b2a59a77e 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list-header.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list-header.tsx @@ -13,12 +13,12 @@ import Text from 'antd/lib/typography/Text'; import { StatesOrdering } from 'reducers/interfaces'; -interface StatesOrderingSelectorProps { +interface StatesOrderingSelectorComponentProps { statesOrdering: StatesOrdering; changeStatesOrdering(value: StatesOrdering): void; } -const StatesOrderingSelector = React.memo((props: StatesOrderingSelectorProps): JSX.Element => { +function StatesOrderingSelectorComponent(props: StatesOrderingSelectorComponentProps): JSX.Element { const { statesOrdering, changeStatesOrdering, @@ -49,7 +49,9 @@ const StatesOrderingSelector = React.memo((props: StatesOrderingSelectorProps): ); -}); +} + +const StatesOrderingSelector = React.memo(StatesOrderingSelectorComponent); interface Props { statesHidden: boolean; @@ -65,7 +67,7 @@ interface Props { showAllStates(): void; } -const Header = React.memo((props: Props): JSX.Element => { +function ObjectListHeader(props: Props): JSX.Element { const { statesHidden, statesLocked, @@ -116,6 +118,6 @@ const Header = React.memo((props: Props): JSX.Element => { ); -}); +} -export default Header; +export default React.memo(ObjectListHeader); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index 8e6dda18a23..ff3385c191a 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { StatesOrdering } from 'reducers/interfaces'; import ObjectItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-item'; -import Header from './objects-list-header'; +import ObjectListHeader from './objects-list-header'; interface Props { @@ -22,7 +22,7 @@ interface Props { showAllStates(): void; } -const ObjectListComponent = React.memo((props: Props): JSX.Element => { +function ObjectListComponent(props: Props): JSX.Element { const { listHeight, statesHidden, @@ -41,7 +41,7 @@ const ObjectListComponent = React.memo((props: Props): JSX.Element => { return (
-
{
); -}); +} -export default ObjectListComponent; +export default React.memo(ObjectListComponent); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx index 982fa5f2deb..51af704958c 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx @@ -5,29 +5,66 @@ import { Icon, Tabs, Layout, - Collapse, } from 'antd'; import Text from 'antd/lib/typography/Text'; +import { RadioChangeEvent } from 'antd/lib/radio'; +import { SliderValue } from 'antd/lib/slider'; +import { CheckboxChangeEvent } from 'antd/lib/checkbox'; + +import { ColorBy } from 'reducers/interfaces'; import ObjectsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-list'; import LabelsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/labels-list'; +import AppearanceBlock from './appearance-block'; interface Props { sidebarCollapsed: boolean; appearanceCollapsed: boolean; + colorBy: ColorBy; + opacity: number; + selectedOpacity: number; + blackBorders: boolean; + collapseSidebar(): void; collapseAppearance(): void; + + changeShapesColorBy(event: RadioChangeEvent): void; + changeShapesOpacity(event: SliderValue): void; + changeSelectedShapesOpacity(event: SliderValue): void; + changeShapesBlackBorders(event: CheckboxChangeEvent): void; } -const ObjectsSideBar = React.memo((props: Props): JSX.Element => { +function ObjectsSideBar(props: Props): JSX.Element { const { sidebarCollapsed, appearanceCollapsed, + colorBy, + opacity, + selectedOpacity, + blackBorders, collapseSidebar, collapseAppearance, + changeShapesColorBy, + changeShapesOpacity, + changeSelectedShapesOpacity, + changeShapesBlackBorders, } = props; + const appearanceProps = { + collapseAppearance, + appearanceCollapsed, + colorBy, + opacity, + selectedOpacity, + blackBorders, + + changeShapesColorBy, + changeShapesOpacity, + changeSelectedShapesOpacity, + changeShapesBlackBorders, + }; + return ( { - - Appearance - } - key='appearance' - > - - - + { !sidebarCollapsed && } ); -}); +} -export default ObjectsSideBar; +export default React.memo(ObjectsSideBar); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/styles.scss b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/styles.scss index 65fd1de4170..41d30bcf1e6 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/styles.scss +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/styles.scss @@ -18,7 +18,7 @@ > .ant-collapse-content { background: $background-color-2; border-bottom: none; - height: 150px; + height: 230px; } } } @@ -32,10 +32,6 @@ &:hover { transform: scale(1.1); } - - &:active { - transform: scale(1); - } } .cvat-objects-sidebar-tabs.ant-tabs.ant-tabs-card { @@ -144,10 +140,6 @@ > div:nth-child(3) { margin-top: 10px; } - - &:hover { - @extend .cvat-objects-sidebar-state-active-item; - } } .cvat-objects-sidebar-state-item-collapse { @@ -245,4 +237,29 @@ width: 30px; height: 20px; border-radius: 5px; -} \ No newline at end of file +} + +.cvat-objects-appearance-content { + > div { + width: 100%; + + > label { + text-align: center; + width: 33%; + } + } +} + +.cvat-object-item-menu { + > li { + padding: 0px; + + > button { + padding: 5px 32px; + color: $text-color; + width: 100%; + height: 100%; + text-align: left; + } + } +} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/propagate-confirm.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/propagate-confirm.tsx new file mode 100644 index 00000000000..ffacaee0c05 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/standard-workspace/propagate-confirm.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { + Modal, + InputNumber, +} from 'antd'; + +import Text from 'antd/lib/typography/Text'; + +interface Props { + visible: boolean; + propagateFrames: number; + propagateUpToFrame: number; + propagateObject(): void; + cancel(): void; + changePropagateFrames(value: number | undefined): void; + changeUpToFrame(value: number | undefined): void; +} + +export default function PropagateConfirmComponent(props: Props): JSX.Element { + const { + visible, + propagateFrames, + propagateUpToFrame, + propagateObject, + changePropagateFrames, + changeUpToFrame, + cancel, + } = props; + + return ( + +
+ Do you want to make a copy of the object on + + { + propagateFrames > 1 + ? frames + : frame + } + up to the + + frame +
+
+ ); +} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/standard-workspace.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/standard-workspace.tsx index b2656397e1e..0951e5e9845 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/standard-workspace.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/standard-workspace.tsx @@ -8,7 +8,7 @@ import { import CanvasWrapperContainer from 'containers/annotation-page/standard-workspace/canvas-wrapper'; import ControlsSideBarContainer from 'containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar'; import ObjectSideBarContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar'; - +import PropagateConfirmContainer from 'containers/annotation-page/standard-workspace/propagate-confirm'; export default function StandardWorkspaceComponent(): JSX.Element { return ( @@ -16,6 +16,7 @@ export default function StandardWorkspaceComponent(): JSX.Element { + ); } diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss b/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss index efb38d67a24..0d9cae20e3b 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss +++ b/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss @@ -104,3 +104,10 @@ } } } + +.cvat-propagate-confirm { + > .ant-input-number { + width: 70px; + margin: 0px 5px; + } +} \ No newline at end of file diff --git a/cvat-ui/src/components/annotation-page/statistics-modal.tsx b/cvat-ui/src/components/annotation-page/statistics-modal.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/cvat-ui/src/components/annotation-page/styles.scss b/cvat-ui/src/components/annotation-page/styles.scss index 83a8c02cb7f..3d77640a070 100644 --- a/cvat-ui/src/components/annotation-page/styles.scss +++ b/cvat-ui/src/components/annotation-page/styles.scss @@ -12,9 +12,7 @@ } .cvat-annotation-header-left-group { - height: 100%; - - > div:first-child { + > button:first-child { filter: invert(0.9); background: $background-color-1; border-radius: 0px; @@ -22,15 +20,21 @@ } } -.cvat-annotation-header-button { +.ant-btn.cvat-annotation-header-button { padding: 0px; width: 54px; height: 54px; float: left; text-align: center; user-select: none; + color: $text-color; + display: flex; + flex-direction: column; + align-items: center; + margin: 0px 3px; > span { + margin-left: 0px; font-size: 10px; } @@ -40,7 +44,7 @@ } &:hover > i { - transform: scale(0.9); + transform: scale(0.85); } &:active > i { @@ -119,38 +123,62 @@ } .cvat-annotation-header-right-group { - height: 100%; - > div { - height: 54px; float: left; - text-align: center; - margin-right: 20px; + display: block; + height: 54px; + margin-right: 15px; + } +} - > span { - font-size: 10px; - } +.cvat-workspace-selector { + width: 150px; +} - > i { - transform: scale(0.8); - padding: 3px; +.cvat-job-info-modal-window { + > div { + margin-top: 10px; + } + + > div:nth-child(1) { + > div { + > .ant-select, i { + margin-left: 10px; + } } + } - &:hover > i { - transform: scale(0.9); + > div:nth-child(2) { + > div { + > span { + font-size: 20px; + } } + } - &:active > i { - transform: scale(0.8); + > div:nth-child(3) { + > div { + display: grid; } } - > div:not(:nth-child(3)) > * { - display: block; - line-height: 0px; + > .cvat-job-info-bug-tracker { + > div { + display: grid; + } } -} -.cvat-workspace-selector { - width: 150px; -} + > .cvat-job-info-statistics { + > div { + > span { + font-size: 20px; + } + + .ant-table-thead { + > tr > th { + padding: 5px 5px; + } + } + } + } +} \ No newline at end of file diff --git a/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx b/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx index 532664abf21..486e286fd36 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx @@ -4,6 +4,7 @@ import { Col, Icon, Modal, + Button, Timeline, } from 'antd'; @@ -20,7 +21,7 @@ interface Props { onSaveAnnotation(): void; } -const LeftGroup = React.memo((props: Props): JSX.Element => { +function LeftGroup(props: Props): JSX.Element { const { saving, savingStatuses, @@ -29,20 +30,20 @@ const LeftGroup = React.memo((props: Props): JSX.Element => { return ( -
+
-
+
-
+ +
-
+ Undo + +
+ Redo + ); -}); +} -export default LeftGroup; +export default React.memo(LeftGroup); diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx index 1f919434a2c..3c624c854dc 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx @@ -28,7 +28,7 @@ interface Props { onLastFrame(): void; } -const PlayerButtons = React.memo((props: Props): JSX.Element => { +function PlayerButtons(props: Props): JSX.Element { const { playing, onSwitchPlay, @@ -82,6 +82,6 @@ const PlayerButtons = React.memo((props: Props): JSX.Element => { ); -}); +} -export default PlayerButtons; +export default React.memo(PlayerButtons); diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx index 32ef0c34fe7..8dcabee0bc6 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx @@ -19,7 +19,7 @@ interface Props { onInputChange(value: number | undefined): void; } -const PlayerNavigation = React.memo((props: Props): JSX.Element => { +function PlayerNavigation(props: Props): JSX.Element { const { startFrame, stopFrame, @@ -61,6 +61,6 @@ const PlayerNavigation = React.memo((props: Props): JSX.Element => { ); -}); +} -export default PlayerNavigation; +export default React.memo(PlayerNavigation); diff --git a/cvat-ui/src/components/annotation-page/top-bar/right-group.tsx b/cvat-ui/src/components/annotation-page/top-bar/right-group.tsx index 2703ecd5289..2add0490e09 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/right-group.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/right-group.tsx @@ -4,6 +4,7 @@ import { Col, Icon, Select, + Button, } from 'antd'; import { @@ -11,23 +12,43 @@ import { FullscreenIcon, } from '../../../icons'; -const RightGroup = React.memo((): JSX.Element => ( - -
- - Fullscreen -
-
- - Info -
-
- -
- -)); +interface Props { + showStatistics(): void; +} -export default RightGroup; +function RightGroup(props: Props): JSX.Element { + const { showStatistics } = props; + + return ( + + + +
+ +
+ + ); +} + +export default React.memo(RightGroup); diff --git a/cvat-ui/src/components/annotation-page/top-bar/statistics-modal.tsx b/cvat-ui/src/components/annotation-page/top-bar/statistics-modal.tsx new file mode 100644 index 00000000000..1d8301ae07d --- /dev/null +++ b/cvat-ui/src/components/annotation-page/top-bar/statistics-modal.tsx @@ -0,0 +1,203 @@ +import React from 'react'; + +import { + Tooltip, + Select, + Table, + Modal, + Spin, + Icon, + Row, + Col, +} from 'antd'; + +import Text from 'antd/lib/typography/Text'; + +interface Props { + collecting: boolean; + data: any; + visible: boolean; + assignee: string; + startFrame: number; + stopFrame: number; + zOrder: boolean; + bugTracker: string; + jobStatus: string; + savingJobStatus: boolean; + closeStatistics(): void; + changeJobStatus(status: string): void; +} + +export default function StatisticsModalComponent(props: Props): JSX.Element { + const { + collecting, + data, + visible, + jobStatus, + assignee, + startFrame, + stopFrame, + zOrder, + bugTracker, + closeStatistics, + changeJobStatus, + savingJobStatus, + } = props; + + const baseProps = { + cancelButtonProps: { style: { display: 'none' } }, + okButtonProps: { style: { width: 100 } }, + onOk: closeStatistics, + width: 1000, + visible, + closable: false, + }; + + if (collecting || !data) { + return ( + + + + ); + } + + const rows = Object.keys(data.label).map((key: string) => ({ + key, + label: key, + rectangle: `${data.label[key].rectangle.shape} / ${data.label[key].rectangle.track}`, + polygon: `${data.label[key].polygon.shape} / ${data.label[key].polygon.track}`, + polyline: `${data.label[key].polyline.shape} / ${data.label[key].polyline.track}`, + points: `${data.label[key].points.shape} / ${data.label[key].points.track}`, + tags: data.label[key].tags, + manually: data.label[key].manually, + interpolated: data.label[key].interpolated, + total: data.label[key].total, + })); + + rows.push({ + key: '___total', + label: 'Total', + rectangle: `${data.total.rectangle.shape} / ${data.total.rectangle.track}`, + polygon: `${data.total.polygon.shape} / ${data.total.polygon.track}`, + polyline: `${data.total.polyline.shape} / ${data.total.polyline.track}`, + points: `${data.total.points.shape} / ${data.total.points.track}`, + tags: data.total.tags, + manually: data.total.manually, + interpolated: data.total.interpolated, + total: data.total.total, + }); + + const makeShapesTracksTitle = (title: string): JSX.Element => ( + + {title} + + + ); + + const columns = [{ + title: Label , + dataIndex: 'label', + key: 'label', + }, { + title: makeShapesTracksTitle('Rectangle'), + dataIndex: 'rectangle', + key: 'rectangle', + }, { + title: makeShapesTracksTitle('Polygon'), + dataIndex: 'polygon', + key: 'polygon', + }, { + title: makeShapesTracksTitle('Polyline'), + dataIndex: 'polyline', + key: 'polyline', + }, { + title: makeShapesTracksTitle('Points'), + dataIndex: 'points', + key: 'points', + }, { + title: Tags , + dataIndex: 'tags', + key: 'tags', + }, { + title: Manually , + dataIndex: 'manually', + key: 'manually', + }, { + title: Interpolated , + dataIndex: 'interpolated', + key: 'interpolated', + }, { + title: Total , + dataIndex: 'total', + key: 'total', + }]; + + return ( + +
+ + + Job status + + {savingJobStatus && } + + + + + Overview + + + + + Assignee + {assignee} + + + Start frame + {startFrame} + + + Stop frame + {stopFrame} + + + Frames + {stopFrame - startFrame + 1} + + + Z-Order + {zOrder.toString()} + + + { !!bugTracker && ( + + + Bug tracker + {bugTracker} + + + )} + + + Annotations statistics + + + + + + ); +} diff --git a/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx index cc92915ecbc..399dc3f6ac0 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx @@ -20,6 +20,7 @@ interface Props { frameNumber: number; startFrame: number; stopFrame: number; + showStatistics(): void; onSwitchPlay(): void; onSaveAnnotation(): void; onPrevFrame(): void; @@ -41,7 +42,7 @@ function propsAreEqual(curProps: Props, prevProps: Props): boolean { && curProps.savingStatuses.length === prevProps.savingStatuses.length; } -const AnnotationTopBarComponent = React.memo((props: Props): JSX.Element => { +function AnnotationTopBarComponent(props: Props): JSX.Element { const { saving, savingStatuses, @@ -49,6 +50,7 @@ const AnnotationTopBarComponent = React.memo((props: Props): JSX.Element => { frameNumber, startFrame, stopFrame, + showStatistics, onSwitchPlay, onSaveAnnotation, onPrevFrame, @@ -90,10 +92,10 @@ const AnnotationTopBarComponent = React.memo((props: Props): JSX.Element => { /> - + ); -}, propsAreEqual); +} -export default AnnotationTopBarComponent; +export default React.memo(AnnotationTopBarComponent, propsAreEqual); diff --git a/cvat-ui/src/components/model-runner-modal/model-runner-modal.tsx b/cvat-ui/src/components/model-runner-modal/model-runner-modal.tsx index 3589e1456d8..af33b533eae 100644 --- a/cvat-ui/src/components/model-runner-modal/model-runner-modal.tsx +++ b/cvat-ui/src/components/model-runner-modal/model-runner-modal.tsx @@ -289,7 +289,7 @@ export default class ModelRunnerModalComponent extends React.PureComponent - + diff --git a/cvat-ui/src/components/model-runner-modal/styles.scss b/cvat-ui/src/components/model-runner-modal/styles.scss index f37f13bc446..178551aa236 100644 --- a/cvat-ui/src/components/model-runner-modal/styles.scss +++ b/cvat-ui/src/components/model-runner-modal/styles.scss @@ -4,10 +4,6 @@ margin-top: 10px; } -.cvat-run-model-dialog-info-icon { - color: $info-icon-color; -} - .cvat-run-model-dialog-remove-mapping-icon { color: $danger-icon-color; } diff --git a/cvat-ui/src/components/settings-page/styles.scss b/cvat-ui/src/components/settings-page/styles.scss index 3e9669652e6..5ee3a2090ee 100644 --- a/cvat-ui/src/components/settings-page/styles.scss +++ b/cvat-ui/src/components/settings-page/styles.scss @@ -61,8 +61,8 @@ } .cvat-player-settings-step > div > span > i { - vertical-align: -1em; - transform: scale(0.3); + margin: 0px 5px; + font-size: 10px; } .cvat-player-settings-speed > div > .ant-select { 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 0aaacba2857..ff1e81a92e7 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 @@ -12,13 +12,17 @@ import { mergeObjects, groupObjects, splitTrack, + editShape, updateAnnotationsAsync, createAnnotationsAsync, mergeAnnotationsAsync, groupAnnotationsAsync, splitAnnotationsAsync, + activateObject, + selectObjects, } from 'actions/annotation-actions'; import { + ColorBy, GridColor, ObjectType, CombinedState, @@ -30,9 +34,15 @@ interface StateToProps { sidebarCollapsed: boolean; canvasInstance: Canvas; jobInstance: any; + activatedStateID: number | null; + selectedStatesID: number[]; annotations: any[]; frameData: any; frame: number; + opacity: number; + colorBy: ColorBy; + selectedOpacity: number; + blackBorders: boolean; grid: boolean; gridSize: number; gridColor: GridColor; @@ -50,11 +60,14 @@ interface DispatchToProps { onMergeObjects: (enabled: boolean) => void; onGroupObjects: (enabled: boolean) => void; onSplitTrack: (enabled: boolean) => void; + onEditShape: (enabled: boolean) => void; onUpdateAnnotations(sessionInstance: any, frame: number, states: any[]): void; onCreateAnnotations(sessionInstance: any, frame: number, states: any[]): void; onMergeAnnotations(sessionInstance: any, frame: number, states: any[]): void; onGroupAnnotations(sessionInstance: any, frame: number, states: any[]): void; onSplitAnnotations(sessionInstance: any, frame: number, state: any): void; + onActivateObject: (activatedStateID: number | null) => void; + onSelectObjects: (selectedStatesID: number[]) => void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -78,6 +91,8 @@ function mapStateToProps(state: CombinedState): StateToProps { }, annotations: { states: annotations, + activatedStateID, + selectedStatesID, }, sidebarCollapsed, }, @@ -88,6 +103,12 @@ function mapStateToProps(state: CombinedState): StateToProps { gridColor, gridOpacity, }, + shapes: { + opacity, + colorBy, + selectedOpacity, + blackBorders, + }, }, } = state; @@ -97,7 +118,13 @@ function mapStateToProps(state: CombinedState): StateToProps { jobInstance, frameData, frame, + activatedStateID, + selectedStatesID, annotations, + opacity, + colorBy, + selectedOpacity, + blackBorders, grid, gridSize, gridColor, @@ -133,6 +160,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onSplitTrack(enabled: boolean): void { dispatch(splitTrack(enabled)); }, + onEditShape(enabled: boolean): void { + dispatch(editShape(enabled)); + }, onUpdateAnnotations(sessionInstance: any, frame: number, states: any[]): void { dispatch(updateAnnotationsAsync(sessionInstance, frame, states)); }, @@ -148,6 +178,12 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onSplitAnnotations(sessionInstance: any, frame: number, state: any): void { dispatch(splitAnnotationsAsync(sessionInstance, frame, state)); }, + onActivateObject(activatedStateID: number | null): void { + dispatch(activateObject(activatedStateID)); + }, + onSelectObjects(selectedStatesID: number[]): void { + dispatch(selectObjects(selectedStatesID)); + }, }; } diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx index 32992c9e903..b1759456659 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { - changeLabelColor as changeLabelColorAction, + changeLabelColorAsync, updateAnnotationsAsync, } from 'actions/annotation-actions'; @@ -26,7 +26,7 @@ interface StateToProps { interface DispatchToProps { updateAnnotations(sessionInstance: any, frameNumber: number, states: any[]): void; - changeLabelColor(label: any, color: string): void; + changeLabelColor(sessionInstance: any, frameNumber: number, label: any, color: string): void; } function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { @@ -36,8 +36,8 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { states: objectStates, }, job: { - instance: jobInstance, labels, + instance: jobInstance, }, player: { frame: { @@ -48,7 +48,8 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { }, } = state; - const [label] = labels.filter((_label: any) => _label.id === own.labelID); + const [label] = labels + .filter((_label: any) => _label.id === own.labelID); return { label, @@ -66,8 +67,13 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { updateAnnotations(sessionInstance: any, frameNumber: number, states: any[]): void { dispatch(updateAnnotationsAsync(sessionInstance, frameNumber, states)); }, - changeLabelColor(label: any, color: string): void { - dispatch(changeLabelColorAction(label, color)); + changeLabelColor( + sessionInstance: any, + frameNumber: number, + label: any, + color: string, + ): void { + dispatch(changeLabelColorAsync(sessionInstance, frameNumber, label, color)); }, }; } @@ -139,9 +145,11 @@ class LabelItemContainer extends React.PureComponent { const { changeLabelColor, label, + frameNumber, + jobInstance, } = this.props; - changeLabelColor(label, color); + changeLabelColor(jobInstance, frameNumber, label, color); }; private switchHidden(value: boolean): void { 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 0c8c7f3be08..dd71608c089 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 @@ -1,11 +1,18 @@ import React from 'react'; import { connect } from 'react-redux'; import { + ActiveControl, CombinedState, + ColorBy, } from 'reducers/interfaces'; import { collapseObjectItems, updateAnnotationsAsync, + changeFrameAsync, + removeObjectAsync, + copyShape as copyShapeAction, + activateObject as activateObjectAction, + propagateObject as propagateObjectAction, } from 'actions/annotation-actions'; import ObjectStateItemComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item'; @@ -21,11 +28,20 @@ interface StateToProps { attributes: any[]; jobInstance: any; frameNumber: number; + activated: boolean; + colorBy: ColorBy; + ready: boolean; + activeControl: ActiveControl; } interface DispatchToProps { + changeFrame(frame: number): void; updateState(sessionInstance: any, frameNumber: number, objectState: any): void; collapseOrExpand(objectStates: any[], collapsed: boolean): void; + activateObject: (activatedStateID: number | null) => void; + removeObject: (objectState: any) => void; + copyShape: (objectState: any) => void; + propagateObject: (objectState: any) => void; } function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { @@ -34,17 +50,27 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { annotations: { states, collapsed: statesCollapsed, + activatedStateID, }, job: { - labels, attributes: jobAttributes, instance: jobInstance, + labels, }, player: { frame: { number: frameNumber, }, }, + canvas: { + ready, + activeControl, + }, + }, + settings: { + shapes: { + colorBy, + }, }, } = state; @@ -60,24 +86,135 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { collapsed: collapsedState, attributes: jobAttributes[states[index].label.id], labels, + ready, + activeControl, + colorBy, jobInstance, frameNumber, + activated: activatedStateID === own.clientID, }; } function mapDispatchToProps(dispatch: any): DispatchToProps { return { + changeFrame(frame: number): void { + dispatch(changeFrameAsync(frame)); + }, updateState(sessionInstance: any, frameNumber: number, state: any): void { dispatch(updateAnnotationsAsync(sessionInstance, frameNumber, [state])); }, collapseOrExpand(objectStates: any[], collapsed: boolean): void { dispatch(collapseObjectItems(objectStates, collapsed)); }, + activateObject(activatedStateID: number | null): void { + dispatch(activateObjectAction(activatedStateID)); + }, + removeObject(objectState: any): void { + dispatch(removeObjectAsync(objectState, true)); + }, + copyShape(objectState: any): void { + dispatch(copyShapeAction(objectState)); + }, + propagateObject(objectState: any): void { + dispatch(propagateObjectAction(objectState)); + }, }; } type Props = StateToProps & DispatchToProps; class ObjectItemContainer extends React.PureComponent { + private navigateFirstKeyframe = (): void => { + const { + objectState, + changeFrame, + frameNumber, + } = this.props; + + const { first } = objectState.keyframes; + if (first !== frameNumber) { + changeFrame(first); + } + }; + + private navigatePrevKeyframe = (): void => { + const { + objectState, + changeFrame, + frameNumber, + } = this.props; + + const { prev } = objectState.keyframes; + if (prev !== null && prev !== frameNumber) { + changeFrame(prev); + } + }; + + private navigateNextKeyframe = (): void => { + const { + objectState, + changeFrame, + frameNumber, + } = this.props; + + const { next } = objectState.keyframes; + if (next !== null && next !== frameNumber) { + changeFrame(next); + } + }; + + private navigateLastKeyframe = (): void => { + const { + objectState, + changeFrame, + frameNumber, + } = this.props; + + const { last } = objectState.keyframes; + if (last !== frameNumber) { + changeFrame(last); + } + }; + + private copy = (): void => { + const { + objectState, + copyShape, + } = this.props; + + copyShape(objectState); + }; + + private propagate = (): void => { + const { + objectState, + propagateObject, + } = this.props; + + propagateObject(objectState); + }; + + private remove = (): void => { + const { + objectState, + removeObject, + } = this.props; + + removeObject(objectState); + }; + + private activate = (): void => { + const { + activateObject, + objectState, + ready, + activeControl, + } = this.props; + + if (ready && activeControl === ActiveControl.CURSOR) { + activateObject(objectState.clientID); + } + }; + private lock = (): void => { const { objectState } = this.props; objectState.lock = true; @@ -184,10 +321,35 @@ class ObjectItemContainer extends React.PureComponent { collapsed, labels, attributes, + frameNumber, + activated, + colorBy, } = this.props; + const { + first, + prev, + next, + last, + } = objectState.keyframes || { + first: null, // shapes don't have keyframes, so we use null + prev: null, + next: null, + last: null, + }; + + let stateColor = ''; + if (colorBy === ColorBy.INSTANCE) { + stateColor = objectState.color; + } else if (colorBy === ColorBy.GROUP) { + stateColor = objectState.group.color; + } else if (colorBy === ColorBy.LABEL) { + stateColor = objectState.label.color; + } + return ( { keyframe={objectState.keyframe} attrValues={{ ...objectState.attributes }} labelID={objectState.label.id} - color={objectState.color} + color={stateColor} attributes={attributes} labels={labels} collapsed={collapsed} + navigateFirstKeyframe={ + first >= frameNumber || first === null + ? null : this.navigateFirstKeyframe + } + navigatePrevKeyframe={ + prev === frameNumber || prev === null + ? null : this.navigatePrevKeyframe + } + navigateNextKeyframe={ + next === frameNumber || next === null + ? null : this.navigateNextKeyframe + } + navigateLastKeyframe={ + last <= frameNumber || last === null + ? null : this.navigateLastKeyframe + } + activate={this.activate} + remove={this.remove} + copy={this.copy} + propagate={this.propagate} setOccluded={this.setOccluded} unsetOccluded={this.unsetOccluded} setOutside={this.setOutside} diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx index bd1fba14e01..f0c1bb15379 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx @@ -2,23 +2,47 @@ import React from 'react'; import { connect } from 'react-redux'; +import { RadioChangeEvent } from 'antd/lib/radio'; +import { SliderValue } from 'antd/lib/slider'; +import { CheckboxChangeEvent } from 'antd/lib/checkbox'; + import ObjectsSidebarComponent from 'components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar'; -import { CombinedState } from 'reducers/interfaces'; +import { + CombinedState, + ColorBy, +} from 'reducers/interfaces'; + import { collapseSidebar as collapseSidebarAction, collapseAppearance as collapseAppearanceAction, updateTabContentHeight as updateTabContentHeightAction, } from 'actions/annotation-actions'; +import { + changeShapesColorBy as changeShapesColorByAction, + changeShapesOpacity as changeShapesOpacityAction, + changeSelectedShapesOpacity as changeSelectedShapesOpacityAction, + changeShapesBlackBorders as changeShapesBlackBordersAction, +} from 'actions/settings-actions'; + + interface StateToProps { sidebarCollapsed: boolean; appearanceCollapsed: boolean; + colorBy: ColorBy; + opacity: number; + selectedOpacity: number; + blackBorders: boolean; } interface DispatchToProps { collapseSidebar(): void; collapseAppearance(): void; updateTabContentHeight(): void; + changeShapesColorBy(colorBy: ColorBy): void; + changeShapesOpacity(shapesOpacity: number): void; + changeSelectedShapesOpacity(selectedShapesOpacity: number): void; + changeShapesBlackBorders(blackBorders: boolean): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -27,11 +51,23 @@ function mapStateToProps(state: CombinedState): StateToProps { sidebarCollapsed, appearanceCollapsed, }, + settings: { + shapes: { + colorBy, + opacity, + selectedOpacity, + blackBorders, + }, + }, } = state; return { sidebarCollapsed, appearanceCollapsed, + colorBy, + opacity, + selectedOpacity, + blackBorders, }; } @@ -80,19 +116,90 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { ), ); }, + changeShapesColorBy(colorBy: ColorBy): void { + dispatch(changeShapesColorByAction(colorBy)); + }, + changeShapesOpacity(shapesOpacity: number): void { + dispatch(changeShapesOpacityAction(shapesOpacity)); + }, + changeSelectedShapesOpacity(selectedShapesOpacity: number): void { + dispatch(changeSelectedShapesOpacityAction(selectedShapesOpacity)); + }, + changeShapesBlackBorders(blackBorders: boolean): void { + dispatch(changeShapesBlackBordersAction(blackBorders)); + }, }; } type Props = StateToProps & DispatchToProps; class ObjectsSideBarContainer extends React.PureComponent { public componentDidMount(): void { - const { updateTabContentHeight } = this.props; - updateTabContentHeight(); + window.addEventListener('resize', this.alignTabHeight); + this.alignTabHeight(); } + public componentWillUnmount(): void { + window.removeEventListener('resize', this.alignTabHeight); + } + + private alignTabHeight = (): void => { + const { + sidebarCollapsed, + updateTabContentHeight, + } = this.props; + + if (!sidebarCollapsed) { + updateTabContentHeight(); + } + }; + + private changeShapesColorBy = (event: RadioChangeEvent): void => { + const { changeShapesColorBy } = this.props; + changeShapesColorBy(event.target.value); + }; + + private changeShapesOpacity = (value: SliderValue): void => { + const { changeShapesOpacity } = this.props; + changeShapesOpacity(value as number); + }; + + private changeSelectedShapesOpacity = (value: SliderValue): void => { + const { changeSelectedShapesOpacity } = this.props; + changeSelectedShapesOpacity(value as number); + }; + + private changeShapesBlackBorders = (event: CheckboxChangeEvent): void => { + const { changeShapesBlackBorders } = this.props; + changeShapesBlackBorders(event.target.checked); + }; + public render(): JSX.Element { + const { + sidebarCollapsed, + appearanceCollapsed, + colorBy, + opacity, + selectedOpacity, + blackBorders, + collapseSidebar, + collapseAppearance, + } = this.props; + return ( - + ); } } diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/propagate-confirm.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/propagate-confirm.tsx new file mode 100644 index 00000000000..951a5e5ea5e --- /dev/null +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/propagate-confirm.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { + propagateObject as propagateObjectAction, + changePropagateFrames as changePropagateFramesAction, + propagateObjectAsync, +} from 'actions/annotation-actions'; + +import { CombinedState } from 'reducers/interfaces'; +import PropagateConfirmComponent from 'components/annotation-page/standard-workspace/propagate-confirm'; + +interface StateToProps { + objectState: any | null; + frameNumber: number; + stopFrame: number; + propagateFrames: number; + jobInstance: any; +} + +interface DispatchToProps { + cancel(): void; + propagateObject(sessionInstance: any, objectState: any, from: number, to: number): void; + changePropagateFrames(frames: number): void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { + annotation: { + propagate: { + objectState, + frames: propagateFrames, + }, + job: { + instance: { + stopFrame, + }, + instance: jobInstance, + }, + player: { + frame: { + number: frameNumber, + }, + }, + }, + } = state; + + return { + objectState, + frameNumber, + stopFrame, + propagateFrames, + jobInstance, + }; +} + +function mapDispatchToProps(dispatch: any): DispatchToProps { + return { + propagateObject(sessionInstance: any, objectState: any, from: number, to: number): void { + dispatch(propagateObjectAsync(sessionInstance, objectState, from, to)); + }, + changePropagateFrames(frames: number): void { + dispatch(changePropagateFramesAction(frames)); + }, + cancel(): void { + dispatch(propagateObjectAction(null)); + }, + }; +} + +type Props = StateToProps & DispatchToProps; +class PropagateConfirmContainer extends React.PureComponent { + private propagateObject = (): void => { + const { + propagateObject, + objectState, + propagateFrames, + frameNumber, + stopFrame, + jobInstance, + } = this.props; + + const propagateUpToFrame = Math.min(frameNumber + propagateFrames, stopFrame); + propagateObject(jobInstance, objectState, frameNumber + 1, propagateUpToFrame); + }; + + private changePropagateFrames = (value: number | undefined): void => { + const { changePropagateFrames } = this.props; + if (typeof (value) !== 'undefined') { + changePropagateFrames(value); + } + }; + + private changeUpToFrame = (value: number | undefined): void => { + const { + stopFrame, + frameNumber, + changePropagateFrames, + } = this.props; + if (typeof (value) !== 'undefined') { + const propagateFrames = Math.max(0, Math.min(stopFrame, value)) - frameNumber; + changePropagateFrames(propagateFrames); + } + }; + + public render(): JSX.Element { + const { + frameNumber, + stopFrame, + propagateFrames, + cancel, + objectState, + } = this.props; + + const propagateUpToFrame = Math.min(frameNumber + propagateFrames, stopFrame); + + return ( + + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(PropagateConfirmContainer); diff --git a/cvat-ui/src/containers/annotation-page/top-bar/statistics-modal.tsx b/cvat-ui/src/containers/annotation-page/top-bar/statistics-modal.tsx new file mode 100644 index 00000000000..0125812bdf1 --- /dev/null +++ b/cvat-ui/src/containers/annotation-page/top-bar/statistics-modal.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { CombinedState } from 'reducers/interfaces'; +import { + showStatistics, + changeJobStatusAsync, +} from 'actions/annotation-actions'; +import StatisticsModalComponent from 'components/annotation-page/top-bar/statistics-modal'; + +interface StateToProps { + visible: boolean; + collecting: boolean; + data: any; + jobInstance: any; + jobStatus: string; + savingJobStatus: boolean; +} + +interface DispatchToProps { + changeJobStatus(jobInstance: any, status: string): void; + closeStatistics(): void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { + annotation: { + statistics: { + visible, + collecting, + data, + }, + job: { + saving: savingJobStatus, + instance: { + status: jobStatus, + }, + instance: jobInstance, + }, + }, + } = state; + + return { + visible, + collecting, + data, + jobInstance, + jobStatus, + savingJobStatus, + }; +} + +function mapDispatchToProps(dispatch: any): DispatchToProps { + return { + changeJobStatus(jobInstance: any, status: string): void { + dispatch(changeJobStatusAsync(jobInstance, status)); + }, + closeStatistics(): void { + dispatch(showStatistics(false)); + }, + }; +} + +type Props = StateToProps & DispatchToProps; + +class StatisticsModalContainer extends React.PureComponent { + private changeJobStatus = (status: string): void => { + const { + jobInstance, + changeJobStatus, + } = this.props; + + changeJobStatus(jobInstance, status); + }; + + public render(): JSX.Element { + const { + jobInstance, + visible, + collecting, + data, + closeStatistics, + jobStatus, + savingJobStatus, + } = this.props; + + return ( + + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(StatisticsModalContainer); 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 fd039a0cd13..c063a73d88e 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 @@ -7,6 +7,8 @@ import { changeFrameAsync, switchPlay, saveAnnotationsAsync, + collectStatisticsAsync, + showStatistics as showStatisticsAction, } from 'actions/annotation-actions'; import AnnotationTopBarComponent from 'components/annotation-page/top-bar/top-bar'; @@ -26,6 +28,7 @@ interface DispatchToProps { onChangeFrame(frame: number): void; onSwitchPlay(playing: boolean): void; onSaveAnnotation(sessionInstance: any): void; + showStatistics(sessionInstance: any): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -79,6 +82,10 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onSaveAnnotation(sessionInstance: any): void { dispatch(saveAnnotationsAsync(sessionInstance)); }, + showStatistics(sessionInstance: any): void { + dispatch(collectStatisticsAsync(sessionInstance)); + dispatch(showStatisticsAction(true)); + }, }; } @@ -108,6 +115,15 @@ class AnnotationTopBarContainer extends React.PureComponent { } } + private showStatistics = (): void => { + const { + jobInstance, + showStatistics, + } = this.props; + + showStatistics(jobInstance); + }; + private onSwitchPlay = (): void => { const { frameNumber, @@ -288,6 +304,7 @@ class AnnotationTopBarContainer extends React.PureComponent { return ( { states, } = action.payload; + const activatedStateID = states + .map((_state: any) => _state.clientID).includes(state.annotations.activatedStateID) + ? state.annotations.activatedStateID : null; + return { ...state, player: { @@ -147,6 +163,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, annotations: { ...state.annotations, + activatedStateID, states, }, }; @@ -279,6 +296,10 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { return { ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, canvas: { ...state.canvas, activeControl, @@ -292,6 +313,10 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { return { ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, canvas: { ...state.canvas, activeControl, @@ -309,6 +334,10 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { return { ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, canvas: { ...state.canvas, activeControl, @@ -328,6 +357,10 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { return { ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, canvas: { ...state.canvas, activeControl, @@ -341,6 +374,10 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { return { ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, canvas: { ...state.canvas, activeControl, @@ -354,6 +391,10 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { return { ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, canvas: { ...state.canvas, activeControl, @@ -447,6 +488,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { case AnnotationActionTypes.CHANGE_LABEL_COLOR_SUCCESS: { const { label, + states, } = action.payload; const { instance: job } = state.job; @@ -454,13 +496,202 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { const index = labels.indexOf(label); labels[index] = label; - return { ...state, job: { ...state.job, labels, }, + annotations: { + ...state.annotations, + states, + }, + }; + } + case AnnotationActionTypes.ACTIVATE_OBJECT: { + const { + activatedStateID, + } = action.payload; + + return { + ...state, + annotations: { + ...state.annotations, + activatedStateID, + }, + }; + } + case AnnotationActionTypes.SELECT_OBJECTS: { + const { + selectedStatesID, + } = action.payload; + + return { + ...state, + annotations: { + ...state.annotations, + selectedStatesID, + }, + }; + } + case AnnotationActionTypes.REMOVE_OBJECT_SUCCESS: { + const { + objectState, + } = action.payload; + + return { + ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + states: state.annotations.states + .filter((_objectState: any) => ( + _objectState.clientID !== objectState.clientID + )), + }, + }; + } + case AnnotationActionTypes.COPY_SHAPE: { + const { + objectState, + } = action.payload; + + state.canvas.instance.cancel(); + state.canvas.instance.draw({ + enabled: true, + initialState: objectState, + }); + + let activeControl = ActiveControl.DRAW_RECTANGLE; + if (objectState.shapeType === ShapeType.POINTS) { + activeControl = ActiveControl.DRAW_POINTS; + } else if (objectState.shapeType === ShapeType.POLYGON) { + activeControl = ActiveControl.DRAW_POLYGON; + } else if (objectState.shapeType === ShapeType.POLYLINE) { + activeControl = ActiveControl.DRAW_POLYLINE; + } + + return { + ...state, + canvas: { + ...state.canvas, + activeControl, + }, + annotations: { + ...state.annotations, + activatedStateID: null, + }, + }; + } + case AnnotationActionTypes.EDIT_SHAPE: { + const { enabled } = action.payload; + const activeControl = enabled + ? ActiveControl.EDIT : ActiveControl.CURSOR; + + return { + ...state, + canvas: { + ...state.canvas, + activeControl, + }, + }; + } + case AnnotationActionTypes.PROPAGATE_OBJECT: { + const { objectState } = action.payload; + return { + ...state, + propagate: { + ...state.propagate, + objectState, + }, + }; + } + case AnnotationActionTypes.PROPAGATE_OBJECT_SUCCESS: { + return { + ...state, + propagate: { + ...state.propagate, + objectState: null, + }, + }; + } + case AnnotationActionTypes.CHANGE_PROPAGATE_FRAMES: { + const { frames } = action.payload; + + return { + ...state, + propagate: { + ...state.propagate, + frames, + }, + }; + } + case AnnotationActionTypes.SWITCH_SHOWING_STATISTICS: { + const { visible } = action.payload; + + return { + ...state, + statistics: { + ...state.statistics, + visible, + }, + }; + } + case AnnotationActionTypes.COLLECT_STATISTICS: { + return { + ...state, + statistics: { + ...state.statistics, + collecting: true, + }, + }; + } + case AnnotationActionTypes.COLLECT_STATISTICS_SUCCESS: { + const { data } = action.payload; + return { + ...state, + statistics: { + ...state.statistics, + collecting: false, + data, + }, + }; + } + case AnnotationActionTypes.COLLECT_STATISTICS_FAILED: { + return { + ...state, + statistics: { + ...state.statistics, + collecting: false, + data: null, + }, + }; + } + case AnnotationActionTypes.CHANGE_JOB_STATUS: { + return { + ...state, + job: { + ...state.job, + saving: true, + }, + }; + } + case AnnotationActionTypes.CHANGE_JOB_STATUS_SUCCESS: { + return { + ...state, + job: { + ...state.job, + saving: false, + }, + }; + } + case AnnotationActionTypes.CHANGE_JOB_STATUS_FAILED: { + return { + ...state, + job: { + ...state.job, + saving: false, + }, }; } case AnnotationActionTypes.RESET_CANVAS: { diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index d7589ab3640..686686eeb5f 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -211,6 +211,10 @@ export interface NotificationsState { merging: null | ErrorState; grouping: null | ErrorState; splitting: null | ErrorState; + removing: null | ErrorState; + propagating: null | ErrorState; + collectingStatistics: null | ErrorState; + savingJob: null | ErrorState; }; [index: string]: any; @@ -238,6 +242,7 @@ export enum ActiveControl { MERGE = 'merge', GROUP = 'group', SPLIT = 'split', + EDIT = 'edit', } export enum ShapeType { @@ -266,10 +271,11 @@ export interface AnnotationState { activeControl: ActiveControl; }; job: { - instance: any | null | undefined; labels: any[]; + instance: any | null | undefined; attributes: Record; fetching: boolean; + saving: boolean; }; player: { frame: { @@ -286,6 +292,8 @@ export interface AnnotationState { activeObjectType: ObjectType; }; annotations: { + selectedStatesID: number[]; + activatedStateID: number | null; collapsed: Record; states: any[]; saving: { @@ -293,6 +301,15 @@ export interface AnnotationState { statuses: string[]; }; }; + propagate: { + objectState: any | null; + frames: number; + }; + statistics: { + collecting: boolean; + visible: boolean; + data: any; + }; colors: any[]; sidebarCollapsed: boolean; appearanceCollapsed: boolean; @@ -316,6 +333,12 @@ export enum FrameSpeed { Slowest = 1, } +export enum ColorBy { + INSTANCE = 'Instance', + GROUP = 'Group', + LABEL = 'Label', +} + export interface PlayerSettingsState { frameStep: number; frameSpeed: FrameSpeed; @@ -337,7 +360,15 @@ export interface WorkspaceSettingsState { showAllInterpolationTracks: boolean; } +export interface ShapesSettingsState { + colorBy: ColorBy; + opacity: number; + selectedOpacity: number; + blackBorders: boolean; +} + export interface SettingsState { + shapes: ShapesSettingsState; workspace: WorkspaceSettingsState; player: PlayerSettingsState; } diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index e02e8220c8c..e843e1a537a 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -59,6 +59,10 @@ const defaultState: NotificationsState = { merging: null, grouping: null, splitting: null, + removing: null, + propagating: null, + collectingStatistics: null, + savingJob: null, }, }, messages: { @@ -564,7 +568,67 @@ export default function (state = defaultState, action: AnyAction): Notifications annotation: { ...state.errors.annotation, splitting: { - message: 'Could not split a track', + message: 'Could not split the track', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case AnnotationActionTypes.REMOVE_OBJECT_FAILED: { + return { + ...state, + errors: { + ...state.errors, + annotation: { + ...state.errors.annotation, + removing: { + message: 'Could not remove the object', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case AnnotationActionTypes.PROPAGATE_OBJECT_FAILED: { + return { + ...state, + errors: { + ...state.errors, + annotation: { + ...state.errors.annotation, + propagating: { + message: 'Could not propagate the object', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case AnnotationActionTypes.COLLECT_STATISTICS_FAILED: { + return { + ...state, + errors: { + ...state.errors, + annotation: { + ...state.errors.annotation, + collectingStatistics: { + message: 'Could not collect annotations statistics', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case AnnotationActionTypes.CHANGE_JOB_STATUS_FAILED: { + return { + ...state, + errors: { + ...state.errors, + annotation: { + ...state.errors.annotation, + savingJob: { + message: 'Could not save the job on the server', reason: action.payload.error.toString(), }, }, diff --git a/cvat-ui/src/reducers/settings-reducer.ts b/cvat-ui/src/reducers/settings-reducer.ts index d033ee0543f..b35dd37df43 100644 --- a/cvat-ui/src/reducers/settings-reducer.ts +++ b/cvat-ui/src/reducers/settings-reducer.ts @@ -5,9 +5,16 @@ import { SettingsState, GridColor, FrameSpeed, + ColorBy, } from './interfaces'; const defaultState: SettingsState = { + shapes: { + colorBy: ColorBy.INSTANCE, + opacity: 3, + selectedOpacity: 30, + blackBorders: false, + }, workspace: { autoSave: false, autoSaveInterval: 15 * 60 * 1000, @@ -76,6 +83,42 @@ export default (state = defaultState, action: AnyAction): SettingsState => { }, }; } + case SettingsActionTypes.CHANGE_SHAPES_COLOR_BY: { + return { + ...state, + shapes: { + ...state.shapes, + colorBy: action.payload.colorBy, + }, + }; + } + case SettingsActionTypes.CHANGE_SHAPES_OPACITY: { + return { + ...state, + shapes: { + ...state.shapes, + opacity: action.payload.opacity, + }, + }; + } + case SettingsActionTypes.CHANGE_SELECTED_SHAPES_OPACITY: { + return { + ...state, + shapes: { + ...state.shapes, + selectedOpacity: action.payload.selectedOpacity, + }, + }; + } + case SettingsActionTypes.CHANGE_SHAPES_BLACK_BORDERS: { + return { + ...state, + shapes: { + ...state.shapes, + blackBorders: action.payload.blackBorders, + }, + }; + } default: { return state; } diff --git a/cvat-ui/src/styles.scss b/cvat-ui/src/styles.scss index d3b3c0a7711..222b2f2bf55 100644 --- a/cvat-ui/src/styles.scss +++ b/cvat-ui/src/styles.scss @@ -25,6 +25,20 @@ hr { padding-top: 5px; } +.ant-slider { + > .ant-slider-track { + background-color: $slider-color; + } + + > .ant-slider-handle { + border-color: $slider-color; + } +} + +.cvat-info-circle-icon { + color: $info-icon-color; +} + #root { width: 100%; height: 100%;