diff --git a/CHANGELOG.md b/CHANGELOG.md index d91159ab0d24..d3712bfdcc7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Manual review pipeline: issues/comments/workspace () - Added basic projects implementation () ### Changed diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md index e12a764fe2c3..c7fe66d54b5d 100644 --- a/cvat-canvas/README.md +++ b/cvat-canvas/README.md @@ -50,12 +50,13 @@ Canvas itself handles: IDLE = 'idle', DRAG = 'drag', RESIZE = 'resize', - INTERACT = 'interact', DRAW = 'draw', EDIT = 'edit', MERGE = 'merge', SPLIT = 'split', GROUP = 'group', + INTERACT = 'interact', + SELECT_ROI = 'select_roi', DRAG_CANVAS = 'drag_canvas', ZOOM_CANVAS = 'zoom_canvas', } @@ -111,23 +112,24 @@ Canvas itself handles: interface Canvas { html(): HTMLDivElement; - setZLayer(zLayer: number | null): void; - setup(frameData: any, objectStates: any[]): void; - activate(clientID: number, attributeID?: number): void; - rotate(frameAngle: number): void; + setup(frameData: any, objectStates: any[], zLayer?: number): void; + setupReviewROIs(reviewROIs: Record): void; + activate(clientID: number | null, attributeID?: number): void; + rotate(rotationAngle: number): void; focus(clientID: number, padding?: number): void; fit(): void; grid(stepX: number, stepY: number): void; - draw(drawData: DrawData): void; interact(interactionData: InteractionData): void; + draw(drawData: DrawData): void; group(groupData: GroupData): void; split(splitData: SplitData): void; merge(mergeData: MergeData): void; select(objectState: any): void; fitCanvas(): void; - bitmap(enabled: boolean): void; + bitmap(enable: boolean): void; + selectROI(enable: boolean): void; dragCanvas(enable: boolean): void; zoomCanvas(enable: boolean): void; @@ -135,6 +137,8 @@ Canvas itself handles: cancel(): void; configure(configuration: Configuration): void; isAbleToChangeFrame(): boolean; + + readonly geometry: Geometry; } ``` @@ -147,11 +151,14 @@ Canvas itself handles: `cvat_canvas_shape_merging`, `cvat_canvas_shape_drawing`, `cvat_canvas_shape_occluded` +- Drawn review ROIs have an id `cvat_canvas_issue_region_{issue.id}` +- Drawn review roi has the class `cvat_canvas_issue_region` - Drawn texts have the class `cvat_canvas_text` - Tags have the class `cvat_canvas_tag` - Canvas image has ID `cvat_canvas_image` - Grid on the canvas has ID `cvat_canvas_grid` and `cvat_canvas_grid_pattern` - Crosshair during a draw has class `cvat_canvas_crosshair` +- To stick something to a specific position you can use an element with id `cvat_canvas_attachment_board` ### Events @@ -178,6 +185,7 @@ Standard JS events are used. - canvas.zoom - canvas.fit - canvas.dragshape => {id: number} + - canvas.roiselected => {points: number[]} - canvas.resizeshape => {id: number} - canvas.contextmenu => { mouseEvent: MouseEvent, objectState: ObjectState, pointID: number } ``` @@ -205,28 +213,33 @@ canvas.draw({ }); ``` + + ## API Reaction -| | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS | INTERACT | -| ------------ | ---- | ----- | ----- | ---- | ----- | ---- | ---- | ------ | ----------- | ----------- | -------- | -| setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + | + | -| activate() | + | - | - | - | - | - | - | - | - | - | - | -| rotate() | + | + | + | + | + | + | + | + | + | + | + | -| focus() | + | + | + | + | + | + | + | + | + | + | + | -| fit() | + | + | + | + | + | + | + | + | + | + | + | -| grid() | + | + | + | + | + | + | + | + | + | + | + | -| draw() | + | - | - | + | - | - | - | - | - | - | - | -| interact() | + | - | - | - | - | - | - | - | - | - | + | -| split() | + | - | + | - | - | - | - | - | - | - | - | -| group() | + | + | - | - | - | - | - | - | - | - | - | -| merge() | + | - | - | - | + | - | - | - | - | - | - | -| fitCanvas() | + | + | + | + | + | + | + | + | + | + | + | -| dragCanvas() | + | - | - | - | - | - | + | - | - | + | - | -| zoomCanvas() | + | - | - | - | - | - | - | + | + | - | - | -| cancel() | - | + | + | + | + | + | + | + | + | + | + | -| configure() | + | + | + | + | + | + | + | + | + | + | + | -| bitmap() | + | + | + | + | + | + | + | + | + | + | + | -| setZLayer() | + | + | + | + | + | + | + | + | + | + | + | +| | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS | INTERACT | +| ----------------- | ---- | ----- | ----- | ---- | ----- | ---- | ---- | ------ | ----------- | ----------- | -------- | +| setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + | + | +| activate() | + | - | - | - | - | - | - | - | - | - | - | +| rotate() | + | + | + | + | + | + | + | + | + | + | + | +| focus() | + | + | + | + | + | + | + | + | + | + | + | +| fit() | + | + | + | + | + | + | + | + | + | + | + | +| grid() | + | + | + | + | + | + | + | + | + | + | + | +| draw() | + | - | - | + | - | - | - | - | - | - | - | +| interact() | + | - | - | - | - | - | - | - | - | - | + | +| split() | + | - | + | - | - | - | - | - | - | - | - | +| group() | + | + | - | - | - | - | - | - | - | - | - | +| merge() | + | - | - | - | + | - | - | - | - | - | - | +| fitCanvas() | + | + | + | + | + | + | + | + | + | + | + | +| dragCanvas() | + | - | - | - | - | - | + | - | - | + | - | +| zoomCanvas() | + | - | - | - | - | - | - | + | + | - | - | +| cancel() | - | + | + | + | + | + | + | + | + | + | + | +| configure() | + | + | + | + | + | + | + | + | + | + | + | +| bitmap() | + | + | + | + | + | + | + | + | + | + | + | +| setZLayer() | + | + | + | + | + | + | + | + | + | + | + | +| setupReviewROIs() | + | + | + | + | + | + | + | + | + | + | + | + + You can call setup() during editing, dragging, and resizing only to update objects, not to change a frame. You can change frame during draw only when you do not redraw an existing object diff --git a/cvat-canvas/package-lock.json b/cvat-canvas/package-lock.json index 8fed7d27d097..cffc743f23ba 100644 --- a/cvat-canvas/package-lock.json +++ b/cvat-canvas/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.1.3", + "version": "2.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index d509f493c369..1c7f9bc7a10c 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.1.3", + "version": "2.2.0", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", "scripts": { diff --git a/cvat-canvas/src/scss/canvas.scss b/cvat-canvas/src/scss/canvas.scss index 055a6d2203a2..57293d29fc78 100644 --- a/cvat-canvas/src/scss/canvas.scss +++ b/cvat-canvas/src/scss/canvas.scss @@ -58,6 +58,23 @@ polyline.cvat_shape_drawing_opacity { fill: darkmagenta; } +.cvat_canvas_shape_region_selection { + @extend .cvat_shape_action_dasharray; + @extend .cvat_shape_action_opacity; + + fill: white; + stroke: white; +} + +.cvat_canvas_issue_region { + display: none; + stroke-width: 0; +} + +circle.cvat_canvas_issue_region { + opacity: 1 !important; +} + polyline.cvat_canvas_shape_grouping { @extend .cvat_shape_action_dasharray; @extend .cvat_shape_action_opacity; @@ -258,6 +275,15 @@ polyline.cvat_canvas_shape_splitting { height: 100%; } +#cvat_canvas_attachment_board { + position: absolute; + z-index: 4; + pointer-events: none; + width: 100%; + height: 100%; + user-select: none; +} + @keyframes loadingAnimation { 0% { stroke-dashoffset: 1; diff --git a/cvat-canvas/src/typescript/canvas.ts b/cvat-canvas/src/typescript/canvas.ts index 20731050684c..29355d509afb 100644 --- a/cvat-canvas/src/typescript/canvas.ts +++ b/cvat-canvas/src/typescript/canvas.ts @@ -15,6 +15,7 @@ import { RectDrawingMethod, CuboidDrawingMethod, Configuration, + Geometry, } from './canvasModel'; import { Master } from './master'; import { CanvasController, CanvasControllerImpl } from './canvasController'; @@ -28,6 +29,7 @@ const CanvasVersion = pjson.version; interface Canvas { html(): HTMLDivElement; setup(frameData: any, objectStates: any[], zLayer?: number): void; + setupIssueRegions(issueRegions: Record): void; activate(clientID: number | null, attributeID?: number): void; rotate(rotationAngle: number): void; focus(clientID: number, padding?: number): void; @@ -43,6 +45,7 @@ interface Canvas { fitCanvas(): void; bitmap(enable: boolean): void; + selectRegion(enable: boolean): void; dragCanvas(enable: boolean): void; zoomCanvas(enable: boolean): void; @@ -50,6 +53,8 @@ interface Canvas { cancel(): void; configure(configuration: Configuration): void; isAbleToChangeFrame(): boolean; + + readonly geometry: Geometry; } class CanvasImpl implements Canvas { @@ -71,6 +76,10 @@ class CanvasImpl implements Canvas { this.model.setup(frameData, objectStates, zLayer); } + public setupIssueRegions(issueRegions: Record): void { + this.model.setupIssueRegions(issueRegions); + } + public fitCanvas(): void { this.model.fitCanvas(this.view.html().clientWidth, this.view.html().clientHeight); } @@ -79,6 +88,10 @@ class CanvasImpl implements Canvas { this.model.bitmap(enable); } + public selectRegion(enable: boolean): void { + this.model.selectRegion(enable); + } + public dragCanvas(enable: boolean): void { this.model.dragCanvas(enable); } @@ -146,6 +159,10 @@ class CanvasImpl implements Canvas { public isAbleToChangeFrame(): boolean { return this.model.isAbleToChangeFrame(); } + + public get geometry(): Geometry { + return this.model.geometry; + } } export { diff --git a/cvat-canvas/src/typescript/canvasController.ts b/cvat-canvas/src/typescript/canvasController.ts index 786836d8b0cb..dca3c7d888b0 100644 --- a/cvat-canvas/src/typescript/canvasController.ts +++ b/cvat-canvas/src/typescript/canvasController.ts @@ -14,10 +14,12 @@ import { GroupData, Mode, InteractionData, + Configuration, } from './canvasModel'; export interface CanvasController { readonly objects: any[]; + readonly issueRegions: Record; readonly zLayer: number | null; readonly focusData: FocusData; readonly activeElement: ActiveElement; @@ -27,6 +29,7 @@ export interface CanvasController { readonly splitData: SplitData; readonly groupData: GroupData; readonly selected: any; + readonly configuration: Configuration; mode: Mode; geometry: Geometry; @@ -36,6 +39,7 @@ export interface CanvasController { merge(mergeData: MergeData): void; split(splitData: SplitData): void; group(groupData: GroupData): void; + selectRegion(enabled: boolean): void; enableDrag(x: number, y: number): void; drag(x: number, y: number): void; disableDrag(): void; @@ -103,6 +107,10 @@ export class CanvasControllerImpl implements CanvasController { this.model.group(groupData); } + public selectRegion(enable: boolean): void { + this.model.selectRegion(enable); + } + public get geometry(): Geometry { return this.model.geometry; } @@ -115,6 +123,10 @@ export class CanvasControllerImpl implements CanvasController { return this.model.zLayer; } + public get issueRegions(): Record { + return this.model.issueRegions; + } + public get objects(): any[] { return this.model.objects; } @@ -151,6 +163,10 @@ export class CanvasControllerImpl implements CanvasController { return this.model.selected; } + public get configuration(): Configuration { + return this.model.configuration; + } + public set mode(value: Mode) { this.model.mode = value; } diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index 4521c5709b51..f3860893ac92 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -56,6 +56,7 @@ export interface Configuration { displayAllText?: boolean; undefinedAttrValue?: string; showProjections?: boolean; + forceDisableEditing?: boolean; } export interface DrawData { @@ -113,6 +114,7 @@ export enum UpdateReasons { IMAGE_MOVED = 'image_moved', GRID_UPDATED = 'grid_updated', + ISSUE_REGIONS_UPDATED = 'issue_regions_updated', OBJECTS_UPDATED = 'objects_updated', SHAPE_ACTIVATED = 'shape_activated', SHAPE_FOCUSED = 'shape_focused', @@ -127,6 +129,7 @@ export enum UpdateReasons { SELECT = 'select', CANCEL = 'cancel', BITMAP = 'bitmap', + SELECT_REGION = 'select_region', DRAG_CANVAS = 'drag_canvas', ZOOM_CANVAS = 'zoom_canvas', CONFIG_UPDATED = 'config_updated', @@ -142,6 +145,7 @@ export enum Mode { SPLIT = 'split', GROUP = 'group', INTERACT = 'interact', + SELECT_REGION = 'select_region', DRAG_CANVAS = 'drag_canvas', ZOOM_CANVAS = 'zoom_canvas', } @@ -149,6 +153,7 @@ export enum Mode { export interface CanvasModel { readonly imageBitmap: boolean; readonly image: Image | null; + readonly issueRegions: Record; readonly objects: any[]; readonly zLayer: number | null; readonly gridSize: Size; @@ -168,6 +173,7 @@ export interface CanvasModel { move(topOffset: number, leftOffset: number): void; setup(frameData: any, objectStates: any[], zLayer: number): void; + setupIssueRegions(issueRegions: Record): void; activate(clientID: number | null, attributeID: number | null): void; rotate(rotationAngle: number): void; focus(clientID: number, padding: number): void; @@ -183,6 +189,7 @@ export interface CanvasModel { fitCanvas(width: number, height: number): void; bitmap(enabled: boolean): void; + selectRegion(enabled: boolean): void; dragCanvas(enable: boolean): void; zoomCanvas(enable: boolean): void; @@ -206,6 +213,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { gridSize: Size; left: number; objects: any[]; + issueRegions: Record; scale: number; top: number; zLayer: number | null; @@ -254,6 +262,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { }, left: 0, objects: [], + issueRegions: {}, scale: 1, top: 0, zLayer: null, @@ -288,15 +297,15 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { const mutiplier = Math.sin((angle * Math.PI) / 180) + Math.cos((angle * Math.PI) / 180); if ((angle / 90) % 2) { // 90, 270, .. - this.data.top += - mutiplier * ((x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1)) * this.data.scale; - this.data.left -= - mutiplier * ((y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1)) * this.data.scale; + const topMultiplier = (x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1); + const leftMultiplier = (y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1); + this.data.top += mutiplier * topMultiplier * this.data.scale; + this.data.left -= mutiplier * leftMultiplier * this.data.scale; } else { - this.data.left += - mutiplier * ((x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1)) * this.data.scale; - this.data.top += - mutiplier * ((y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1)) * this.data.scale; + const leftMultiplier = (x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1); + const topMultiplier = (y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1); + this.data.left += mutiplier * leftMultiplier * this.data.scale; + this.data.top += mutiplier * topMultiplier * this.data.scale; } this.notify(UpdateReasons.IMAGE_ZOOMED); @@ -325,6 +334,19 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.notify(UpdateReasons.BITMAP); } + public selectRegion(enable: boolean): void { + if (enable && this.data.mode !== Mode.IDLE) { + throw Error(`Canvas is busy. Action: ${this.data.mode}`); + } + + if (!enable && this.data.mode !== Mode.SELECT_REGION) { + throw Error(`Canvas is not in the region selecting mode. Action: ${this.data.mode}`); + } + + this.data.mode = enable ? Mode.SELECT_REGION : Mode.IDLE; + this.notify(UpdateReasons.SELECT_REGION); + } + public dragCanvas(enable: boolean): void { if (enable && this.data.mode !== Mode.IDLE) { throw Error(`Canvas is busy. Action: ${this.data.mode}`); @@ -393,6 +415,11 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { }); } + public setupIssueRegions(issueRegions: Record): void { + this.data.issueRegions = issueRegions; + this.notify(UpdateReasons.ISSUE_REGIONS_UPDATED); + } + public activate(clientID: number | null, attributeID: number | null): void { if (this.data.activeElement.clientID === clientID && this.data.activeElement.attributeID === attributeID) { return; @@ -599,13 +626,16 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.data.configuration.undefinedAttrValue = configuration.undefinedAttrValue; } + if (typeof configuration.forceDisableEditing !== 'undefined') { + this.data.configuration.forceDisableEditing = configuration.forceDisableEditing; + } + this.notify(UpdateReasons.CONFIG_UPDATED); } public isAbleToChangeFrame(): boolean { - const isUnable = - [Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode) || - (this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number'); + const isUnable = [Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode) + || (this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number'); return !isUnable; } @@ -658,6 +688,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { return this.data.image; } + public get issueRegions(): Record { + return { ...this.data.issueRegions }; + } + public get objects(): any[] { if (this.data.zLayer !== null) { return this.data.objects.filter((object: any): boolean => object.zOrder <= this.data.zLayer); diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 8d8e4177995d..32c962098465 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -15,6 +15,7 @@ import { EditHandler, EditHandlerImpl } from './editHandler'; import { MergeHandler, MergeHandlerImpl } from './mergeHandler'; import { SplitHandler, SplitHandlerImpl } from './splitHandler'; import { GroupHandler, GroupHandlerImpl } from './groupHandler'; +import { RegionSelector, RegionSelectorImpl } from './regionSelector'; import { ZoomHandler, ZoomHandlerImpl } from './zoomHandler'; import { InteractionHandler, InteractionHandlerImpl } from './interactionHandler'; import { AutoborderHandler, AutoborderHandlerImpl } from './autoborderHandler'; @@ -59,6 +60,7 @@ export class CanvasViewImpl implements CanvasView, Listener { private bitmap: HTMLCanvasElement; private grid: SVGSVGElement; private content: SVGSVGElement; + private attachmentBoard: HTMLDivElement; private adoptedContent: SVG.Container; private canvas: HTMLDivElement; private gridPath: SVGPathElement; @@ -66,13 +68,17 @@ export class CanvasViewImpl implements CanvasView, Listener { private controller: CanvasController; private svgShapes: Record; private svgTexts: Record; + private issueRegionPattern_1: SVG.Pattern; + private issueRegionPattern_2: SVG.Pattern; private drawnStates: Record; + private drawnIssueRegions: Record; private geometry: Geometry; private drawHandler: DrawHandler; private editHandler: EditHandler; private mergeHandler: MergeHandler; private splitHandler: SplitHandler; private groupHandler: GroupHandler; + private regionSelector: RegionSelector; private zoomHandler: ZoomHandler; private autoborderHandler: AutoborderHandler; private interactionHandler: InteractionHandler; @@ -90,6 +96,31 @@ export class CanvasViewImpl implements CanvasView, Listener { return this.controller.mode; } + private stateIsLocked(state: any): boolean { + const { configuration } = this.controller; + return state.lock || configuration.forceDisableEditing; + } + + private translateToCanvas(points: number[]): number[] { + const { offset } = this.controller.geometry; + return points.map((coord: number): number => coord + offset); + } + + private translateFromCanvas(points: number[]): number[] { + const { offset } = this.controller.geometry; + return points.map((coord: number): number => coord - offset); + } + + private stringifyToCanvas(points: number[]): string { + return points.reduce((acc: string, val: number, idx: number): string => { + if (idx % 2) { + return `${acc}${val} `; + } + + return `${acc}${val},`; + }, ''); + } + private isServiceHidden(clientID: number): boolean { return this.serviceFlags.drawHidden[clientID] || false; } @@ -329,6 +360,30 @@ export class CanvasViewImpl implements CanvasView, Listener { this.mode = Mode.IDLE; } + private onRegionSelected(points?: number[]): void { + if (points) { + const event: CustomEvent = new CustomEvent('canvas.regionselected', { + bubbles: false, + cancelable: true, + detail: { + points, + }, + }); + + this.canvas.dispatchEvent(event); + } else { + const event: CustomEvent = new CustomEvent('canvas.canceled', { + bubbles: false, + cancelable: true, + }); + + this.canvas.dispatchEvent(event); + } + + this.controller.selectRegion(false); + this.mode = Mode.IDLE; + } + private onFindObject(e: MouseEvent): void { if (e.which === 1 || e.which === 0) { const { offset } = this.controller.geometry; @@ -401,7 +456,7 @@ export class CanvasViewImpl implements CanvasView, Listener { obj.style.left = `${this.geometry.left}px`; } - for (const obj of [this.content, this.text]) { + for (const obj of [this.content, this.text, this.attachmentBoard]) { obj.style.top = `${this.geometry.top - this.geometry.offset}px`; obj.style.left = `${this.geometry.left - this.geometry.offset}px`; } @@ -412,11 +467,12 @@ export class CanvasViewImpl implements CanvasView, Listener { this.zoomHandler.transform(this.geometry); this.autoborderHandler.transform(this.geometry); this.interactionHandler.transform(this.geometry); + this.regionSelector.transform(this.geometry); } private transformCanvas(): void { // Transform canvas - for (const obj of [this.background, this.grid, this.content, this.bitmap]) { + for (const obj of [this.background, this.grid, this.content, this.bitmap, this.attachmentBoard]) { obj.style.transform = `scale(${this.geometry.scale}) rotate(${this.geometry.angle}deg)`; } @@ -455,19 +511,41 @@ export class CanvasViewImpl implements CanvasView, Listener { // Transform all text for (const key in this.svgShapes) { if ( - Object.prototype.hasOwnProperty.call(this.svgShapes, key) && - Object.prototype.hasOwnProperty.call(this.svgTexts, key) + Object.prototype.hasOwnProperty.call(this.svgShapes, key) + && Object.prototype.hasOwnProperty.call(this.svgTexts, key) ) { this.updateTextPosition(this.svgTexts[key], this.svgShapes[key]); } } + // Transform all drawn issues region + for (const issueRegion of Object.values(this.drawnIssueRegions)) { + ((issueRegion as any) as SVG.Shape).attr('r', `${(consts.BASE_POINT_SIZE * 3) / this.geometry.scale}`); + ((issueRegion as any) as SVG.Shape).attr( + 'stroke-width', + `${consts.BASE_STROKE_WIDTH / this.geometry.scale}`, + ); + } + + // Transform patterns + for (const pattern of [this.issueRegionPattern_1, this.issueRegionPattern_2]) { + pattern.attr({ + width: consts.BASE_PATTERN_SIZE / this.geometry.scale, + height: consts.BASE_PATTERN_SIZE / this.geometry.scale, + }); + + pattern.children().forEach((element: SVG.Element): void => { + element.attr('stroke-width', consts.BASE_STROKE_WIDTH / this.geometry.scale); + }); + } + // Transform handlers this.drawHandler.transform(this.geometry); this.editHandler.transform(this.geometry); this.zoomHandler.transform(this.geometry); this.autoborderHandler.transform(this.geometry); this.interactionHandler.transform(this.geometry); + this.regionSelector.transform(this.geometry); } private resizeCanvas(): void { @@ -476,16 +554,66 @@ export class CanvasViewImpl implements CanvasView, Listener { obj.style.height = `${this.geometry.image.height}px`; } - for (const obj of [this.content, this.text]) { + for (const obj of [this.content, this.text, this.attachmentBoard]) { obj.style.width = `${this.geometry.image.width + this.geometry.offset * 2}px`; obj.style.height = `${this.geometry.image.height + this.geometry.offset * 2}px`; } } - private setupObjects(states: any[]): void { - const { offset } = this.controller.geometry; - const translate = (points: number[]): number[] => points.map((coord: number): number => coord + offset); + private setupIssueRegions(issueRegions: Record): void { + for (const issueRegion of Object.keys(this.drawnIssueRegions)) { + if (!(issueRegion in issueRegions) || !+issueRegion) { + this.drawnIssueRegions[+issueRegion].remove(); + delete this.drawnIssueRegions[+issueRegion]; + } + } + for (const issueRegion of Object.keys(issueRegions)) { + if (issueRegion in this.drawnIssueRegions) continue; + const points = this.translateToCanvas(issueRegions[+issueRegion]); + if (points.length === 2) { + this.drawnIssueRegions[+issueRegion] = this.adoptedContent + .circle((consts.BASE_POINT_SIZE * 3 * 2) / this.geometry.scale) + .center(points[0], points[1]) + .addClass('cvat_canvas_issue_region') + .attr({ + id: `cvat_canvas_issue_region_${issueRegion}`, + fill: 'url(#cvat_issue_region_pattern_1)', + }); + } else if (points.length === 4) { + const stringified = this.stringifyToCanvas([ + points[0], + points[1], + points[2], + points[1], + points[2], + points[3], + points[0], + points[3], + ]); + this.drawnIssueRegions[+issueRegion] = this.adoptedContent + .polygon(stringified) + .addClass('cvat_canvas_issue_region') + .attr({ + id: `cvat_canvas_issue_region_${issueRegion}`, + fill: 'url(#cvat_issue_region_pattern_1)', + 'stroke-width': `${consts.BASE_STROKE_WIDTH / this.geometry.scale}`, + }); + } else { + const stringified = this.stringifyToCanvas(points); + this.drawnIssueRegions[+issueRegion] = this.adoptedContent + .polygon(stringified) + .addClass('cvat_canvas_issue_region') + .attr({ + id: `cvat_canvas_issue_region_${issueRegion}`, + fill: 'url(#cvat_issue_region_pattern_1)', + 'stroke-width': `${consts.BASE_STROKE_WIDTH / this.geometry.scale}`, + }); + } + } + } + + private setupObjects(states: any[]): void { const created = []; const updated = []; for (const state of states) { @@ -520,8 +648,8 @@ export class CanvasViewImpl implements CanvasView, Listener { delete this.drawnStates[state.clientID]; } - this.addObjects(created, translate); - this.updateObjects(updated, translate); + this.addObjects(created); + this.updateObjects(updated); this.sortObjects(); if (this.controller.activeElement.clientID !== null) { @@ -610,8 +738,6 @@ export class CanvasViewImpl implements CanvasView, Listener { private selectize(value: boolean, shape: SVG.Element): void { const self = this; - const { offset } = this.controller.geometry; - const translate = (points: number[]): number[] => points.map((coord: number): number => coord - offset); function mousedownHandler(e: MouseEvent): void { if (e.button !== 0) return; @@ -661,7 +787,7 @@ export class CanvasViewImpl implements CanvasView, Listener { if (state.shapeType === 'cuboid') { if (e.shiftKey) { - const points = translate( + const points = self.translateFromCanvas( pointsToNumberArray((e.target as any).parentElement.parentElement.instance.attr('points')), ); self.onEditDone(state, points); @@ -753,6 +879,7 @@ export class CanvasViewImpl implements CanvasView, Listener { this.svgShapes = {}; this.svgTexts = {}; this.drawnStates = {}; + this.drawnIssueRegions = {}; this.activeElement = { clientID: null, attributeID: null, @@ -778,12 +905,36 @@ export class CanvasViewImpl implements CanvasView, Listener { this.content = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.adoptedContent = SVG.adopt((this.content as any) as HTMLElement) as SVG.Container; + this.attachmentBoard = window.document.createElement('div'); + this.canvas = window.document.createElement('div'); const loadingCircle: SVGCircleElement = window.document.createElementNS('http://www.w3.org/2000/svg', 'circle'); const gridDefs: SVGDefsElement = window.document.createElementNS('http://www.w3.org/2000/svg', 'defs'); const gridRect: SVGRectElement = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + // Setup defs + const contentDefs = this.adoptedContent.defs(); + this.issueRegionPattern_1 = contentDefs + .pattern(consts.BASE_PATTERN_SIZE, consts.BASE_PATTERN_SIZE, (add): void => { + add.line(0, 0, 0, 10).stroke('red'); + }) + .attr({ + id: 'cvat_issue_region_pattern_1', + patternTransform: 'rotate(45)', + patternUnits: 'userSpaceOnUse', + }); + + this.issueRegionPattern_2 = contentDefs + .pattern(consts.BASE_PATTERN_SIZE, consts.BASE_PATTERN_SIZE, (add): void => { + add.line(0, 0, 0, 10).stroke('yellow'); + }) + .attr({ + id: 'cvat_issue_region_pattern_2', + patternTransform: 'rotate(45)', + patternUnits: 'userSpaceOnUse', + }); + // Setup loading animation this.loadingAnimation.setAttribute('id', 'cvat_canvas_loading_animation'); loadingCircle.setAttribute('id', 'cvat_canvas_loading_circle'); @@ -813,6 +964,9 @@ export class CanvasViewImpl implements CanvasView, Listener { this.bitmap.setAttribute('id', 'cvat_canvas_bitmap'); this.bitmap.style.display = 'none'; + // Setup sticked div + this.attachmentBoard.setAttribute('id', 'cvat_canvas_attachment_board'); + // Setup wrappers this.canvas.setAttribute('id', 'cvat_canvas_wrapper'); @@ -830,6 +984,7 @@ export class CanvasViewImpl implements CanvasView, Listener { this.canvas.appendChild(this.bitmap); this.canvas.appendChild(this.grid); this.canvas.appendChild(this.content); + this.canvas.appendChild(this.attachmentBoard); const self = this; @@ -858,6 +1013,11 @@ export class CanvasViewImpl implements CanvasView, Listener { this.onFindObject.bind(this), this.adoptedContent, ); + this.regionSelector = new RegionSelectorImpl( + this.onRegionSelected.bind(this), + this.adoptedContent, + this.geometry, + ); this.zoomHandler = new ZoomHandlerImpl(this.onFocusRegion.bind(this), this.adoptedContent, this.geometry); this.interactionHandler = new InteractionHandlerImpl( this.onInteraction.bind(this), @@ -874,9 +1034,9 @@ export class CanvasViewImpl implements CanvasView, Listener { this.content.addEventListener('mousedown', (event): void => { if ([0, 1].includes(event.button)) { if ( - [Mode.IDLE, Mode.DRAG_CANVAS, Mode.MERGE, Mode.SPLIT].includes(this.mode) || - event.button === 1 || - event.altKey + [Mode.IDLE, Mode.DRAG_CANVAS, Mode.MERGE, Mode.SPLIT].includes(this.mode) + || event.button === 1 + || event.altKey ) { self.controller.enableDrag(event.clientX, event.clientY); } @@ -1022,6 +1182,8 @@ export class CanvasViewImpl implements CanvasView, Listener { } const event: CustomEvent = new CustomEvent('canvas.setup'); this.canvas.dispatchEvent(event); + } else if (reason === UpdateReasons.ISSUE_REGIONS_UPDATED) { + this.setupIssueRegions(this.controller.issueRegions); } else if (reason === UpdateReasons.GRID_UPDATED) { const size: Size = this.geometry.grid; this.gridPattern.setAttribute('width', `${size.width}`); @@ -1040,6 +1202,13 @@ export class CanvasViewImpl implements CanvasView, Listener { } } else if (reason === UpdateReasons.SHAPE_ACTIVATED) { this.activate(this.controller.activeElement); + } else if (reason === UpdateReasons.SELECT_REGION) { + if (this.mode === Mode.SELECT_REGION) { + this.regionSelector.select(true); + this.canvas.style.cursor = 'pointer'; + } else { + this.regionSelector.select(false); + } } else if (reason === UpdateReasons.DRAG_CANVAS) { if (this.mode === Mode.DRAG_CANVAS) { this.canvas.dispatchEvent( @@ -1151,6 +1320,8 @@ export class CanvasViewImpl implements CanvasView, Listener { this.splitHandler.cancel(); } else if (this.mode === Mode.GROUP) { this.groupHandler.cancel(); + } else if (this.mode === Mode.SELECT_REGION) { + this.regionSelector.cancel(); } else if (this.mode === Mode.EDIT) { this.editHandler.cancel(); } else if (this.mode === Mode.DRAG_CANVAS) { @@ -1275,7 +1446,7 @@ export class CanvasViewImpl implements CanvasView, Listener { }; } - private updateObjects(states: any[], translate: (points: number[]) => number[]): void { + private updateObjects(states: any[]): void { for (const state of states) { const { clientID } = state; const drawnState = this.drawnStates[clientID]; @@ -1325,10 +1496,10 @@ export class CanvasViewImpl implements CanvasView, Listener { } if ( - state.points.length !== drawnState.points.length || - state.points.some((p: number, id: number): boolean => p !== drawnState.points[id]) + state.points.length !== drawnState.points.length + || state.points.some((p: number, id: number): boolean => p !== drawnState.points[id]) ) { - const translatedPoints: number[] = translate(state.points); + const translatedPoints: number[] = this.translateToCanvas(state.points); if (state.shapeType === 'rectangle') { const [xtl, ytl, xbr, ybr] = translatedPoints; @@ -1340,13 +1511,7 @@ export class CanvasViewImpl implements CanvasView, Listener { height: ybr - ytl, }); } else { - const stringified = translatedPoints.reduce((acc: string, val: number, idx: number): string => { - if (idx % 2) { - return `${acc}${val} `; - } - - return `${acc}${val},`; - }, ''); + const stringified = this.stringifyToCanvas(translatedPoints); if (state.shapeType !== 'cuboid') { (shape as any).clear(); } @@ -1375,24 +1540,18 @@ export class CanvasViewImpl implements CanvasView, Listener { } } - private addObjects(states: any[], translate: (points: number[]) => number[]): void { + private addObjects(states: any[]): void { const { displayAllText } = this.configuration; for (const state of states) { const points: number[] = state.points as number[]; - const translatedPoints: number[] = translate(points); + const translatedPoints: number[] = this.translateToCanvas(points); // TODO: Use enums after typification cvat-core if (state.shapeType === 'rectangle') { this.svgShapes[state.clientID] = this.addRect(translatedPoints, state); } else { - const stringified = translatedPoints.reduce((acc: string, val: number, idx: number): string => { - if (idx % 2) { - return `${acc}${val} `; - } - - return `${acc}${val},`; - }, ''); + const stringified = this.stringifyToCanvas(translatedPoints); if (state.shapeType === 'polygon') { this.svgShapes[state.clientID] = this.addPolygon(stringified, state); @@ -1542,7 +1701,7 @@ export class CanvasViewImpl implements CanvasView, Listener { if (state && state.shapeType === 'points') { this.svgShapes[clientID] .remember('_selectHandler') - .nested.style('pointer-events', state.lock ? 'none' : ''); + .nested.style('pointer-events', this.stateIsLocked(state) ? 'none' : ''); } if (!state || state.hidden || state.outside) { @@ -1550,8 +1709,14 @@ export class CanvasViewImpl implements CanvasView, Listener { } const shape = this.svgShapes[clientID]; + let text = this.svgTexts[clientID]; + if (!text) { + text = this.addText(state); + this.svgTexts[state.clientID] = text; + } + this.updateTextPosition(text, shape); - if (state.lock) { + if (this.stateIsLocked(state)) { return; } @@ -1567,12 +1732,6 @@ export class CanvasViewImpl implements CanvasView, Listener { (shape as any).attr('projections', true); } - let text = this.svgTexts[clientID]; - if (!text) { - text = this.addText(state); - this.svgTexts[state.clientID] = text; - } - const hideText = (): void => { if (text) { text.addClass('cvat_canvas_hidden'); @@ -1601,12 +1760,14 @@ export class CanvasViewImpl implements CanvasView, Listener { 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 dx2 = (p1.x - p2.x) ** 2; + const dy2 = (p1.y - p2.y) ** 2; + if (Math.sqrt(dx2 + dy2) >= delta) { const points = pointsToNumberArray( - shape.attr('points') || - `${shape.attr('x')},${shape.attr('y')} ` + - `${shape.attr('x') + shape.attr('width')},` + - `${shape.attr('y') + shape.attr('height')}`, + 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.drawnStates[state.clientID].points = points; @@ -1677,10 +1838,10 @@ export class CanvasViewImpl implements CanvasView, Listener { const { offset } = this.controller.geometry; const points = pointsToNumberArray( - shape.attr('points') || - `${shape.attr('x')},${shape.attr('y')} ` + - `${shape.attr('x') + shape.attr('width')},` + - `${shape.attr('y') + shape.attr('height')}`, + 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.drawnStates[state.clientID].points = points; @@ -1697,7 +1858,6 @@ export class CanvasViewImpl implements CanvasView, Listener { } }); - this.updateTextPosition(text, shape); this.canvas.dispatchEvent( new CustomEvent('canvas.activated', { bubbles: false, @@ -1757,8 +1917,8 @@ export class CanvasViewImpl implements CanvasView, Listener { // Find the best place for a text let [clientX, clientY]: number[] = [box.x + box.width, box.y]; if ( - clientX + ((text.node as any) as SVGTextElement).getBBox().width + consts.TEXT_MARGIN > - this.canvas.offsetWidth + clientX + ((text.node as any) as SVGTextElement).getBBox().width + consts.TEXT_MARGIN + > this.canvas.offsetWidth ) { [clientX, clientY] = [box.x, box.y]; } @@ -1778,7 +1938,9 @@ export class CanvasViewImpl implements CanvasView, Listener { private addText(state: any): SVG.Text { const { undefinedAttrValue } = this.configuration; - const { label, clientID, attributes, source } = state; + const { + label, clientID, attributes, source, + } = state; const attrNames = label.attributes.reduce((acc: any, val: any): void => { acc[val.id] = val.name; return acc; diff --git a/cvat-canvas/src/typescript/consts.ts b/cvat-canvas/src/typescript/consts.ts index ffe539319dd4..7dea5032c862 100644 --- a/cvat-canvas/src/typescript/consts.ts +++ b/cvat-canvas/src/typescript/consts.ts @@ -2,21 +2,21 @@ // // SPDX-License-Identifier: MIT -const BASE_STROKE_WIDTH = 1.75; +const BASE_STROKE_WIDTH = 1.25; const BASE_GRID_WIDTH = 2; const BASE_POINT_SIZE = 5; const TEXT_MARGIN = 10; const AREA_THRESHOLD = 9; const SIZE_THRESHOLD = 3; -const POINTS_STROKE_WIDTH = 1.5; +const POINTS_STROKE_WIDTH = 1; const POINTS_SELECTED_STROKE_WIDTH = 4; const MIN_EDGE_LENGTH = 3; const CUBOID_ACTIVE_EDGE_STROKE_WIDTH = 2.5; const CUBOID_UNACTIVE_EDGE_STROKE_WIDTH = 1.75; const UNDEFINED_ATTRIBUTE_VALUE = '__undefined__'; -const ARROW_PATH = - 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 ' + - '0 0-.012.59L4.2 6.72.096 12.192a.479.479 0 0 0 .585.724l12.48-5.76a.48.48 0 0 0 0-.872z'; +const ARROW_PATH = 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 ' + + '0 0-.012.59L4.2 6.72.096 12.192a.479.479 0 0 0 .585.724l12.48-5.76a.48.48 0 0 0 0-.872z'; +const BASE_PATTERN_SIZE = 5; export default { BASE_STROKE_WIDTH, @@ -32,4 +32,5 @@ export default { CUBOID_UNACTIVE_EDGE_STROKE_WIDTH, UNDEFINED_ATTRIBUTE_VALUE, ARROW_PATH, + BASE_PATTERN_SIZE, }; diff --git a/cvat-canvas/src/typescript/regionSelector.ts b/cvat-canvas/src/typescript/regionSelector.ts new file mode 100644 index 000000000000..189c1bbf9362 --- /dev/null +++ b/cvat-canvas/src/typescript/regionSelector.ts @@ -0,0 +1,133 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import * as SVG from 'svg.js'; + +import consts from './consts'; +import { translateToSVG } from './shared'; +import { Geometry } from './canvasModel'; + +export interface RegionSelector { + select(enabled: boolean): void; + cancel(): void; + transform(geometry: Geometry): void; +} + +export class RegionSelectorImpl implements RegionSelector { + private onRegionSelected: (points?: number[]) => void; + private geometry: Geometry; + private canvas: SVG.Container; + private selectionRect: SVG.Rect | null; + private startSelectionPoint: { + x: number; + y: number; + }; + + private getSelectionBox(event: MouseEvent): { xtl: number; ytl: number; xbr: number; ybr: number } { + const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]); + const stopSelectionPoint = { + x: point[0], + y: point[1], + }; + + return { + xtl: Math.min(this.startSelectionPoint.x, stopSelectionPoint.x), + ytl: Math.min(this.startSelectionPoint.y, stopSelectionPoint.y), + xbr: Math.max(this.startSelectionPoint.x, stopSelectionPoint.x), + ybr: Math.max(this.startSelectionPoint.y, stopSelectionPoint.y), + }; + } + + private onMouseMove = (event: MouseEvent): void => { + if (this.selectionRect) { + const box = this.getSelectionBox(event); + + this.selectionRect.attr({ + x: box.xtl, + y: box.ytl, + width: box.xbr - box.xtl, + height: box.ybr - box.ytl, + }); + } + }; + + private onMouseDown = (event: MouseEvent): void => { + if (!this.selectionRect && !event.altKey) { + const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]); + this.startSelectionPoint = { + x: point[0], + y: point[1], + }; + + this.selectionRect = this.canvas + .rect() + .attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }) + .addClass('cvat_canvas_shape_region_selection'); + this.selectionRect.attr({ ...this.startSelectionPoint }); + } + }; + + private onMouseUp = (): void => { + const { offset } = this.geometry; + if (this.selectionRect) { + const { + w, h, x, y, x2, y2, + } = this.selectionRect.bbox(); + this.selectionRect.remove(); + this.selectionRect = null; + if (w === 0 && h === 0) { + this.onRegionSelected([x - offset, y - offset]); + } else { + this.onRegionSelected([x - offset, y - offset, x2 - offset, y2 - offset]); + } + } + }; + + private startSelection(): void { + this.canvas.node.addEventListener('mousemove', this.onMouseMove); + this.canvas.node.addEventListener('mousedown', this.onMouseDown); + this.canvas.node.addEventListener('mouseup', this.onMouseUp); + } + + private stopSelection(): void { + this.canvas.node.removeEventListener('mousemove', this.onMouseMove); + this.canvas.node.removeEventListener('mousedown', this.onMouseDown); + this.canvas.node.removeEventListener('mouseup', this.onMouseUp); + } + + private release(): void { + this.stopSelection(); + } + + public constructor(onRegionSelected: (points?: number[]) => void, canvas: SVG.Container, geometry: Geometry) { + this.onRegionSelected = onRegionSelected; + this.geometry = geometry; + this.canvas = canvas; + this.selectionRect = null; + } + + public select(enabled: boolean): void { + if (enabled) { + this.startSelection(); + } else { + this.release(); + } + } + + public cancel(): void { + this.release(); + this.onRegionSelected(); + } + + public transform(geometry: Geometry): void { + this.geometry = geometry; + if (this.selectionRect) { + this.selectionRect.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale, + }); + } + } +} diff --git a/cvat-core/.eslintrc.js b/cvat-core/.eslintrc.js index 5d8830375861..122f56432190 100644 --- a/cvat-core/.eslintrc.js +++ b/cvat-core/.eslintrc.js @@ -14,7 +14,7 @@ module.exports = { sourceType: 'module', ecmaVersion: 2018, }, - plugins: ['security', 'jest', 'no-unsafe-innerhtml'], + plugins: ['security', 'jest', 'no-unsafe-innerhtml', 'no-unsanitized'], extends: ['eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM', 'airbnb-base'], rules: { 'no-await-in-loop': [0], diff --git a/cvat-core/package-lock.json b/cvat-core/package-lock.json index 8c5a61405632..49fd1e2ddd20 100644 --- a/cvat-core/package-lock.json +++ b/cvat-core/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "3.9.1", + "version": "3.10.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -17636,6 +17636,11 @@ "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", "dev": true }, + "quickhull": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/quickhull/-/quickhull-1.0.3.tgz", + "integrity": "sha512-AQbLaXdzGDJdO9Mu3qY/NY5JWlDqIutCLW8vJbsQTq+/bydIZeltnMVRKCElp81Y5/uRm4Yw/RsMdcltFYsS6w==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/cvat-core/package.json b/cvat-core/package.json index c6eb7235639b..eef263d21f46 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "3.9.1", + "version": "3.10.0", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "babel.config.js", "scripts": { @@ -43,6 +43,7 @@ "js-cookie": "^2.2.0", "jsonpath": "^1.0.2", "platform": "^1.3.5", + "quickhull": "^1.0.3", "store": "^2.0.12", "worker-loader": "^2.0.0" } diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index 3826873a87d5..f3e2aa26aa09 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -116,7 +116,7 @@ let users = null; if ('self' in filter && filter.self) { - users = await serverProxy.users.getSelf(); + users = await serverProxy.users.self(); users = [users]; } else { const searchParams = {}; @@ -125,7 +125,7 @@ searchParams[key] = filter[key]; } } - users = await serverProxy.users.getUsers(new URLSearchParams(searchParams).toString()); + users = await serverProxy.users.get(new URLSearchParams(searchParams).toString()); } users = users.map((user) => new User(user)); @@ -146,24 +146,23 @@ throw new ArgumentError('Job filter must not be empty'); } - let tasks = null; + let tasks = []; if ('taskID' in filter) { tasks = await serverProxy.tasks.getTasks(`id=${filter.taskID}`); } else { - const job = await serverProxy.jobs.getJob(filter.jobID); + const job = await serverProxy.jobs.get(filter.jobID); if (typeof job.task_id !== 'undefined') { tasks = await serverProxy.tasks.getTasks(`id=${job.task_id}`); } } // If task was found by its id, then create task instance and get Job instance from it - if (tasks !== null && tasks.length) { + if (tasks.length) { const task = new Task(tasks[0]); - return filter.jobID ? task.jobs.filter((job) => job.id === filter.jobID) : task.jobs; } - return []; + return tasks; }; cvat.tasks.get.implementation = async (filter) => { diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index a33ec2825939..7002a3abcf01 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -13,24 +13,15 @@ function build() { const Log = require('./log'); const ObjectState = require('./object-state'); const Statistics = require('./statistics'); + const Comment = require('./comment'); + const Issue = require('./issue'); + const Review = require('./review'); const { Job, Task } = require('./session'); const { Project } = require('./project'); const { Attribute, Label } = require('./labels'); const MLModel = require('./ml-model'); - const { - ShareFileType, - TaskStatus, - TaskMode, - AttributeType, - ObjectType, - ObjectShape, - LogType, - HistoryActions, - RQStatus, - colors, - Source, - } = require('./enums'); + const enums = require('./enums'); const { Exception, ArgumentError, DataError, ScriptingError, PluginError, ServerError, @@ -741,19 +732,7 @@ function build() { * @namespace enums * @memberof module:API.cvat */ - enums: { - ShareFileType, - TaskStatus, - TaskMode, - AttributeType, - ObjectType, - ObjectShape, - LogType, - HistoryActions, - RQStatus, - colors, - Source, - }, + enums, /** * Namespace is used for access to exceptions * @namespace exceptions @@ -783,6 +762,9 @@ function build() { Statistics, ObjectState, MLModel, + Comment, + Issue, + Review, }, }; diff --git a/cvat-core/src/comment.js b/cvat-core/src/comment.js new file mode 100644 index 000000000000..e8e18cb42c37 --- /dev/null +++ b/cvat-core/src/comment.js @@ -0,0 +1,153 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +const User = require('./user'); +const { ArgumentError } = require('./exceptions'); +const { negativeIDGenerator } = require('./common'); + +/** + * Class representing a single comment + * @memberof module:API.cvat.classes + * @hideconstructor + */ +class Comment { + constructor(initialData) { + const data = { + id: undefined, + message: undefined, + created_date: undefined, + updated_date: undefined, + removed: false, + author: undefined, + }; + + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; + } + } + + if (data.author && !(data.author instanceof User)) data.author = new User(data.author); + + if (typeof id === 'undefined') { + data.id = negativeIDGenerator(); + } + if (typeof data.created_date === 'undefined') { + data.created_date = new Date().toISOString(); + } + + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {integer} + * @memberof module:API.cvat.classes.Comment + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * @name message + * @type {string} + * @memberof module:API.cvat.classes.Comment + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + message: { + get: () => data.message, + set: (value) => { + if (!value.trim().length) { + throw new ArgumentError('Value must not be empty'); + } + data.message = value; + }, + }, + /** + * @name createdDate + * @type {string} + * @memberof module:API.cvat.classes.Comment + * @readonly + * @instance + */ + createdDate: { + get: () => data.created_date, + }, + /** + * @name updatedDate + * @type {string} + * @memberof module:API.cvat.classes.Comment + * @readonly + * @instance + */ + updatedDate: { + get: () => data.updated_date, + }, + /** + * Instance of a user who has created the comment + * @name author + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Comment + * @readonly + * @instance + */ + author: { + get: () => data.author, + }, + /** + * @name removed + * @type {boolean} + * @memberof module:API.cvat.classes.Comment + * @instance + */ + removed: { + get: () => data.removed, + set: (value) => { + if (typeof value !== 'boolean') { + throw new ArgumentError('Value must be a boolean value'); + } + data.removed = value; + }, + }, + __internal: { + get: () => data, + }, + }), + ); + } + + serialize() { + const data = { + message: this.message, + }; + + if (this.id > 0) { + data.id = this.id; + } + if (this.createdDate) { + data.created_date = this.createdDate; + } + if (this.updatedDate) { + data.updated_date = this.updatedDate; + } + if (this.author) { + data.author = this.author.serialize(); + } + + return data; + } + + toJSON() { + const data = this.serialize(); + const { author, ...updated } = data; + return { + ...updated, + author_id: author ? author.id : undefined, + }; + } +} + +module.exports = Comment; diff --git a/cvat-core/src/common.js b/cvat-core/src/common.js index 7f0d1ad01444..d40312b47526 100644 --- a/cvat-core/src/common.js +++ b/cvat-core/src/common.js @@ -68,6 +68,13 @@ return true; } + function negativeIDGenerator() { + const value = negativeIDGenerator.start; + negativeIDGenerator.start -= 1; + return value; + } + negativeIDGenerator.start = -1; + module.exports = { isBoolean, isInteger, @@ -75,5 +82,6 @@ isString, checkFilter, checkObjectType, + negativeIDGenerator, }; })(); diff --git a/cvat-core/src/enums.js b/cvat-core/src/enums.js index df1d8773061f..107bc86fcc5c 100644 --- a/cvat-core/src/enums.js +++ b/cvat-core/src/enums.js @@ -33,6 +33,22 @@ COMPLETED: 'completed', }); + /** + * Review statuses + * @enum {string} + * @name ReviewStatus + * @memberof module:API.cvat.enums + * @property {string} ACCEPTED 'accepted' + * @property {string} REJECTED 'rejected' + * @property {string} REVIEW_FURTHER 'review_further' + * @readonly + */ + const ReviewStatus = Object.freeze({ + ACCEPTED: 'accepted', + REJECTED: 'rejected', + REVIEW_FURTHER: 'review_further', + }); + /** * List of RQ statuses * @enum {string} @@ -306,6 +322,7 @@ module.exports = { ShareFileType, TaskStatus, + ReviewStatus, TaskMode, AttributeType, ObjectType, diff --git a/cvat-core/src/issue.js b/cvat-core/src/issue.js new file mode 100644 index 000000000000..e18ae3ed3d06 --- /dev/null +++ b/cvat-core/src/issue.js @@ -0,0 +1,335 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +const quickhull = require('quickhull'); + +const PluginRegistry = require('./plugins'); +const Comment = require('./comment'); +const User = require('./user'); +const { ArgumentError } = require('./exceptions'); +const { negativeIDGenerator } = require('./common'); +const serverProxy = require('./server-proxy'); + +/** + * Class representing a single issue + * @memberof module:API.cvat.classes + * @hideconstructor + */ +class Issue { + constructor(initialData) { + const data = { + id: undefined, + position: undefined, + comment_set: [], + frame: undefined, + created_date: undefined, + resolved_date: undefined, + owner: undefined, + resolver: undefined, + removed: false, + }; + + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; + } + } + + if (data.owner && !(data.owner instanceof User)) data.owner = new User(data.owner); + if (data.resolver && !(data.resolver instanceof User)) data.resolver = new User(data.resolver); + + if (data.comment_set) { + data.comment_set = data.comment_set.map((comment) => new Comment(comment)); + } + + if (typeof data.id === 'undefined') { + data.id = negativeIDGenerator(); + } + if (typeof data.created_date === 'undefined') { + data.created_date = new Date().toISOString(); + } + + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {integer} + * @memberof module:API.cvat.classes.Issue + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * Region of interests of the issue + * @name position + * @type {number[]} + * @memberof module:API.cvat.classes.Issue + * @instance + * @readonly + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + position: { + get: () => data.position, + set: (value) => { + if (Array.isArray(value) || value.some((coord) => typeof coord !== 'number')) { + throw new ArgumentError(`Array of numbers is expected. Got ${value}`); + } + data.position = value; + }, + }, + /** + * List of comments attached to the issue + * @name comments + * @type {module:API.cvat.classes.Comment[]} + * @memberof module:API.cvat.classes.Issue + * @instance + * @readonly + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + comments: { + get: () => data.comment_set.filter((comment) => !comment.removed), + }, + /** + * @name frame + * @type {integer} + * @memberof module:API.cvat.classes.Issue + * @readonly + * @instance + */ + frame: { + get: () => data.frame, + }, + /** + * @name createdDate + * @type {string} + * @memberof module:API.cvat.classes.Issue + * @readonly + * @instance + */ + createdDate: { + get: () => data.created_date, + }, + /** + * @name resolvedDate + * @type {string} + * @memberof module:API.cvat.classes.Issue + * @readonly + * @instance + */ + resolvedDate: { + get: () => data.resolved_date, + }, + /** + * An instance of a user who has raised the issue + * @name owner + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Issue + * @readonly + * @instance + */ + owner: { + get: () => data.owner, + }, + /** + * An instance of a user who has resolved the issue + * @name resolver + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Issue + * @readonly + * @instance + */ + resolver: { + get: () => data.resolver, + }, + /** + * @name removed + * @type {boolean} + * @memberof module:API.cvat.classes.Comment + * @instance + */ + removed: { + get: () => data.removed, + set: (value) => { + if (typeof value !== 'boolean') { + throw new ArgumentError('Value must be a boolean value'); + } + data.removed = value; + }, + }, + __internal: { + get: () => data, + }, + }), + ); + } + + static hull(coordinates) { + if (coordinates.length > 4) { + const points = coordinates.reduce((acc, coord, index, arr) => { + if (index % 2) acc.push({ x: arr[index - 1], y: coord }); + return acc; + }, []); + + return quickhull(points) + .map((point) => [point.x, point.y]) + .flat(); + } + + return coordinates; + } + + /** + * @typedef {Object} CommentData + * @property {number} [author] an ID of a user who has created the comment + * @property {string} message a comment message + * @global + */ + /** + * Method appends a comment to the issue + * For a new issue it saves comment locally, for a saved issue it saves comment on the server + * @method comment + * @memberof module:API.cvat.classes.Issue + * @param {CommentData} data + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + async comment(data) { + const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.comment, data); + return result; + } + + /** + * The method resolves the issue + * New issues are resolved locally, server-saved issues are resolved on the server + * @method resolve + * @memberof module:API.cvat.classes.Issue + * @param {module:API.cvat.classes.User} user + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + async resolve(user) { + const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.resolve, user); + return result; + } + + /** + * The method resolves the issue + * New issues are reopened locally, server-saved issues are reopened on the server + * @method reopen + * @memberof module:API.cvat.classes.Issue + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + async reopen() { + const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.reopen); + return result; + } + + serialize() { + const { comments } = this; + const data = { + position: this.position, + frame: this.frame, + comment_set: comments.map((comment) => comment.serialize()), + }; + + if (this.id > 0) { + data.id = this.id; + } + if (this.createdDate) { + data.created_date = this.createdDate; + } + if (this.resolvedDate) { + data.resolved_date = this.resolvedDate; + } + if (this.owner) { + data.owner = this.owner.toJSON(); + } + if (this.resolver) { + data.resolver = this.resolver.toJSON(); + } + + return data; + } + + toJSON() { + const data = this.serialize(); + const { owner, resolver, ...updated } = data; + return { + ...updated, + comment_set: this.comments.map((comment) => comment.toJSON()), + owner_id: owner ? owner.id : undefined, + resolver_id: resolver ? resolver.id : undefined, + }; + } +} + +Issue.prototype.comment.implementation = async function (data) { + if (typeof data !== 'object' || data === null) { + throw new ArgumentError(`The argument "data" must be a not null object. Got ${data}`); + } + if (typeof data.message !== 'string' || data.message.length < 1) { + throw new ArgumentError(`Comment message must be a not empty string. Got ${data.message}`); + } + if (!(data.author instanceof User)) { + throw new ArgumentError(`Author of the comment must a User instance. Got ${data.author}`); + } + + const comment = new Comment(data); + const { id } = this; + if (id >= 0) { + const jsonified = comment.toJSON(); + jsonified.issue = id; + const response = await serverProxy.comments.create(jsonified); + const savedComment = new Comment(response); + this.__internal.comment_set.push(savedComment); + } else { + this.__internal.comment_set.push(comment); + } +}; + +Issue.prototype.resolve.implementation = async function (user) { + if (!(user instanceof User)) { + throw new ArgumentError(`The argument "user" must be an instance of a User class. Got ${typeof user}`); + } + + const { id } = this; + if (id >= 0) { + const response = await serverProxy.issues.update(id, { resolver_id: user.id }); + this.__internal.resolved_date = response.resolved_date; + this.__internal.resolver = new User(response.resolver); + } else { + this.__internal.resolved_date = new Date().toISOString(); + this.__internal.resolver = user; + } +}; + +Issue.prototype.reopen.implementation = async function () { + const { id } = this; + if (id >= 0) { + const response = await serverProxy.issues.update(id, { resolver_id: null }); + this.__internal.resolved_date = response.resolved_date; + this.__internal.resolver = response.resolver; + } else { + this.__internal.resolved_date = null; + this.__internal.resolver = null; + } +}; + +module.exports = Issue; diff --git a/cvat-core/src/review.js b/cvat-core/src/review.js new file mode 100644 index 000000000000..db9491e4f8fb --- /dev/null +++ b/cvat-core/src/review.js @@ -0,0 +1,397 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +const store = require('store'); + +const PluginRegistry = require('./plugins'); +const Issue = require('./issue'); +const User = require('./user'); +const { ArgumentError, DataError } = require('./exceptions'); +const { ReviewStatus } = require('./enums'); +const { negativeIDGenerator } = require('./common'); +const serverProxy = require('./server-proxy'); + +/** + * Class representing a single review + * @memberof module:API.cvat.classes + * @hideconstructor + */ +class Review { + constructor(initialData) { + const data = { + id: undefined, + job: undefined, + issue_set: [], + estimated_quality: undefined, + status: undefined, + reviewer: undefined, + assignee: undefined, + reviewed_frames: undefined, + reviewed_states: undefined, + }; + + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; + } + } + + if (data.reviewer && !(data.reviewer instanceof User)) data.reviewer = new User(data.reviewer); + if (data.assignee && !(data.assignee instanceof User)) data.assignee = new User(data.assignee); + + data.reviewed_frames = Array.isArray(data.reviewed_frames) ? new Set(data.reviewed_frames) : new Set(); + data.reviewed_states = Array.isArray(data.reviewed_states) ? new Set(data.reviewed_states) : new Set(); + if (data.issue_set) { + data.issue_set = data.issue_set.map((issue) => new Issue(issue)); + } + + if (typeof data.id === 'undefined') { + data.id = negativeIDGenerator(); + } + + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {integer} + * @memberof module:API.cvat.classes.Review + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * An identifier of a job the review is attached to + * @name job + * @type {integer} + * @memberof module:API.cvat.classes.Review + * @readonly + * @instance + */ + job: { + get: () => data.job, + }, + /** + * List of attached issues + * @name issues + * @type {number[]} + * @memberof module:API.cvat.classes.Review + * @instance + * @readonly + */ + issues: { + get: () => data.issue_set.filter((issue) => !issue.removed), + }, + /** + * Estimated quality of the review + * @name estimatedQuality + * @type {number} + * @memberof module:API.cvat.classes.Review + * @instance + * @readonly + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + estimatedQuality: { + get: () => data.estimated_quality, + set: (value) => { + if (typeof value !== 'number' || value < 0 || value > 5) { + throw new ArgumentError(`Value must be a number in range [0, 5]. Got ${value}`); + } + data.estimated_quality = value; + }, + }, + /** + * @name status + * @type {module:API.cvat.enums.ReviewStatus} + * @memberof module:API.cvat.classes.Review + * @instance + * @readonly + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + status: { + get: () => data.status, + set: (status) => { + const type = ReviewStatus; + let valueInEnum = false; + for (const value in type) { + if (type[value] === status) { + valueInEnum = true; + break; + } + } + + if (!valueInEnum) { + throw new ArgumentError( + 'Value must be a value from the enumeration cvat.enums.ReviewStatus', + ); + } + + data.status = status; + }, + }, + /** + * An instance of a user who has done the review + * @name reviewer + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Review + * @readonly + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + reviewer: { + get: () => data.reviewer, + set: (reviewer) => { + if (!(reviewer instanceof User)) { + throw new ArgumentError(`Reviewer must be an instance of the User class. Got ${reviewer}`); + } + + data.reviewer = reviewer; + }, + }, + /** + * An instance of a user who was assigned for annotation before the review + * @name assignee + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Review + * @readonly + * @instance + */ + assignee: { + get: () => data.assignee, + }, + /** + * A set of frames that have been visited during review + * @name reviewedFrames + * @type {number[]} + * @memberof module:API.cvat.classes.Review + * @readonly + * @instance + */ + reviewedFrames: { + get: () => Array.from(data.reviewed_frames), + }, + /** + * A set of reviewed states (server IDs combined with frames) + * @name reviewedFrames + * @type {string[]} + * @memberof module:API.cvat.classes.Review + * @readonly + * @instance + */ + reviewedStates: { + get: () => Array.from(data.reviewed_states), + }, + __internal: { + get: () => data, + }, + }), + ); + } + + /** + * Method appends a frame to a set of reviewed frames + * Reviewed frames are saved only in local storage + * @method reviewFrame + * @memberof module:API.cvat.classes.Review + * @param {number} frame + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async reviewFrame(frame) { + const result = await PluginRegistry.apiWrapper.call(this, Review.prototype.reviewFrame, frame); + return result; + } + + /** + * Method appends a frame to a set of reviewed frames + * Reviewed states are saved only in local storage. They are used to automatic annotations quality assessment + * @method reviewStates + * @memberof module:API.cvat.classes.Review + * @param {string[]} stateIDs + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async reviewStates(stateIDs) { + const result = await PluginRegistry.apiWrapper.call(this, Review.prototype.reviewStates, stateIDs); + return result; + } + + /** + * @typedef {Object} IssueData + * @property {number} frame + * @property {number[]} position + * @property {number} owner + * @property {CommentData[]} comment_set + * @global + */ + /** + * Method adds a new issue to the review + * @method openIssue + * @memberof module:API.cvat.classes.Review + * @param {IssueData} data + * @returns {module:API.cvat.classes.Issue} + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async openIssue(data) { + const result = await PluginRegistry.apiWrapper.call(this, Review.prototype.openIssue, data); + return result; + } + + /** + * Method submits local review to the server + * @method submit + * @memberof module:API.cvat.classes.Review + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.DataError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async submit() { + const result = await PluginRegistry.apiWrapper.call(this, Review.prototype.submit); + return result; + } + + serialize() { + const { issues, reviewedFrames, reviewedStates } = this; + const data = { + job: this.job, + issue_set: issues.map((issue) => issue.serialize()), + reviewed_frames: Array.from(reviewedFrames), + reviewed_states: Array.from(reviewedStates), + }; + + if (this.id > 0) { + data.id = this.id; + } + if (typeof this.estimatedQuality !== 'undefined') { + data.estimated_quality = this.estimatedQuality; + } + if (typeof this.status !== 'undefined') { + data.status = this.status; + } + if (this.reviewer) { + data.reviewer = this.reviewer.toJSON(); + } + if (this.assignee) { + data.reviewer = this.assignee.toJSON(); + } + + return data; + } + + toJSON() { + const data = this.serialize(); + const { + reviewer, + assignee, + reviewed_frames: reviewedFrames, + reviewed_states: reviewedStates, + ...updated + } = data; + + return { + ...updated, + issue_set: this.issues.map((issue) => issue.toJSON()), + reviewer_id: reviewer ? reviewer.id : undefined, + assignee_id: assignee ? assignee.id : undefined, + }; + } + + async toLocalStorage() { + const data = this.serialize(); + store.set(`job-${this.job}-review`, JSON.stringify(data)); + } +} + +Review.prototype.reviewFrame.implementation = function (frame) { + if (!Number.isInteger(frame)) { + throw new ArgumentError(`The argument "frame" is expected to be an integer. Got ${frame}`); + } + this.__internal.reviewed_frames.add(frame); +}; + +Review.prototype.reviewStates.implementation = function (stateIDs) { + if (!Array.isArray(stateIDs) || stateIDs.some((stateID) => typeof stateID !== 'string')) { + throw new ArgumentError(`The argument "stateIDs" is expected to be an array of string. Got ${stateIDs}`); + } + + stateIDs.forEach((stateID) => this.__internal.reviewed_states.add(stateID)); +}; + +Review.prototype.openIssue.implementation = async function (data) { + if (typeof data !== 'object' || data === null) { + throw new ArgumentError(`The argument "data" must be a not null object. Got ${data}`); + } + + if (typeof data.frame !== 'number') { + throw new ArgumentError(`Issue frame must be a number. Got ${data.frame}`); + } + + if (!(data.owner instanceof User)) { + throw new ArgumentError(`Issue owner must be a User instance. Got ${data.owner}`); + } + + if (!Array.isArray(data.position) || data.position.some((coord) => typeof coord !== 'number')) { + throw new ArgumentError(`Issue position must be an array of numbers. Got ${data.position}`); + } + + if (!Array.isArray(data.comment_set)) { + throw new ArgumentError(`Issue comment set must be an array. Got ${data.comment_set}`); + } + + const copied = { + frame: data.frame, + position: Issue.hull(data.position), + owner: data.owner, + comment_set: [], + }; + + const issue = new Issue(copied); + + for (const comment of data.comment_set) { + await issue.comment.implementation.call(issue, comment); + } + + this.__internal.issue_set.push(issue); + return issue; +}; + +Review.prototype.submit.implementation = async function () { + if (typeof this.estimatedQuality === 'undefined') { + throw new DataError('Estimated quality is expected to be a number. Got "undefined"'); + } + + if (typeof this.status === 'undefined') { + throw new DataError('Review status is expected to be a string. Got "undefined"'); + } + + if (this.id < 0) { + const data = this.toJSON(); + + const response = await serverProxy.jobs.reviews.create(data); + store.remove(`job-${this.job}-review`); + this.__internal.id = response.id; + this.__internal.issue_set = response.issue_set.map((issue) => new Issue(issue)); + this.__internal.estimated_quality = response.estimated_quality; + this.__internal.status = response.status; + + if (response.reviewer) this.__internal.reviewer = new User(response.reviewer); + if (response.assignee) this.__internal.assignee = new User(response.assignee); + } +}; + +module.exports = Review; diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index 71e1cfbebf13..3df795eb5deb 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -287,7 +287,7 @@ async function authorized() { try { - await module.exports.users.getSelf(); + await module.exports.users.self(); } catch (serverError) { if (serverError.code === 401) { return false; @@ -566,6 +566,90 @@ return response.data; } + async function getJobReviews(jobID) { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.get(`${backendAPI}/jobs/${jobID}/reviews`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); + } + + return response.data; + } + + async function createReview(data) { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.post(`${backendAPI}/reviews`, JSON.stringify(data), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + + return response.data; + } + + async function getJobIssues(jobID) { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.get(`${backendAPI}/jobs/${jobID}/issues`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); + } + + return response.data; + } + + async function createComment(data) { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.post(`${backendAPI}/comments`, JSON.stringify(data), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + + return response.data; + } + + async function updateIssue(issueID, data) { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.patch(`${backendAPI}/issues/${issueID}`, JSON.stringify(data), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + + return response.data; + } + async function saveJob(id, jobData) { const { backendAPI } = config; @@ -932,16 +1016,21 @@ jobs: { value: Object.freeze({ - getJob, - saveJob, + get: getJob, + save: saveJob, + issues: getJobIssues, + reviews: { + get: getJobReviews, + create: createReview, + }, }), writable: false, }, users: { value: Object.freeze({ - getUsers, - getSelf, + get: getUsers, + self: getSelf, }), writable: false, }, @@ -983,6 +1072,20 @@ }), writable: false, }, + + issues: { + value: Object.freeze({ + update: updateIssue, + }), + writable: false, + }, + + comments: { + value: Object.freeze({ + create: createComment, + }), + writable: false, + }, }), ); } diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index e9ee3b8806e5..d6291f0535a5 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT (() => { + const store = require('store'); const PluginRegistry = require('./plugins'); const loggerStorage = require('./logger-storage'); const serverProxy = require('./server-proxy'); @@ -13,6 +14,8 @@ const { TaskStatus } = require('./enums'); const { Label } = require('./labels'); const User = require('./user'); + const Issue = require('./issue'); + const Review = require('./review'); function buildDublicatedAPI(prototype) { Object.defineProperties(prototype, { @@ -667,7 +670,8 @@ super(); const data = { id: undefined, - assignee: undefined, + assignee: null, + reviewer: null, status: undefined, start_frame: undefined, stop_frame: undefined, @@ -676,6 +680,7 @@ let updatedFields = { assignee: false, + reviewer: false, status: false, }; @@ -692,6 +697,7 @@ } if (data.assignee) data.assignee = new User(data.assignee); + if (data.reviewer) data.reviewer = new User(data.reviewer); Object.defineProperties( this, @@ -707,7 +713,7 @@ get: () => data.id, }, /** - * Instance of a user who is responsible for the job + * Instance of a user who is responsible for the job annotations * @name assignee * @type {module:API.cvat.classes.User} * @memberof module:API.cvat.classes.Job @@ -724,6 +730,24 @@ data.assignee = assignee; }, }, + /** + * Instance of a user who is responsible for review + * @name reviewer + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Job + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + reviewer: { + get: () => data.reviewer, + set: (reviewer) => { + if (reviewer !== null && !(reviewer instanceof User)) { + throw new ArgumentError('Value must be a user instance'); + } + updatedFields.reviewer = true; + data.reviewer = reviewer; + }, + }, /** * @name status * @type {module:API.cvat.enums.TaskStatus} @@ -847,6 +871,64 @@ const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.save); return result; } + + /** + * Method returns a list of issues for a job + * @method issues + * @memberof module:API.cvat.classes.Job + * @type {module:API.cvat.classes.Issue[]} + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async issues() { + const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.issues); + return result; + } + + /** + * Method returns a list of reviews for a job + * @method reviews + * @type {module:API.cvat.classes.Review[]} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async reviews() { + const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.reviews); + return result; + } + + /** + * /** + * @typedef {Object} ReviewSummary + * @property {number} reviews Number of done reviews + * @property {number} average_estimated_quality + * @property {number} issues_unsolved + * @property {number} issues_resolved + * @property {string[]} assignees + * @property {string[]} reviewers + */ + /** + * Method returns brief summary of within all reviews + * @method reviewsSummary + * @type {ReviewSummary} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async reviewsSummary() { + const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.reviewsSummary); + return result; + } } /** @@ -875,8 +957,8 @@ status: undefined, size: undefined, mode: undefined, - owner: undefined, - assignee: undefined, + owner: null, + assignee: null, created_date: undefined, updated_date: undefined, bug_tracker: undefined, @@ -925,6 +1007,7 @@ url: job.url, id: job.id, assignee: job.assignee, + reviewer: job.reviewer, status: job.status, start_frame: segment.start_frame, stop_frame: segment.stop_frame, @@ -1482,7 +1565,6 @@ buildDublicatedAPI(Task.prototype); Job.prototype.save.implementation = async function () { - // TODO: Add ability to change an assignee if (this.id) { const jobData = {}; @@ -1495,17 +1577,21 @@ case 'assignee': jobData.assignee_id = this.assignee ? this.assignee.id : null; break; + case 'reviewer': + jobData.reviewer_id = this.reviewer ? this.reviewer.id : null; + break; default: break; } } } - await serverProxy.jobs.saveJob(this.id, jobData); + await serverProxy.jobs.save(this.id, jobData); this.__updatedFields = { status: false, assignee: false, + reviewer: false, }; return this; @@ -1514,6 +1600,42 @@ throw new ArgumentError('Can not save job without and id'); }; + Job.prototype.issues.implementation = async function () { + const result = await serverProxy.jobs.issues(this.id); + return result.map((issue) => new Issue(issue)); + }; + + Job.prototype.reviews.implementation = async function () { + const result = await serverProxy.jobs.reviews.get(this.id); + const reviews = result.map((review) => new Review(review)); + + // try to get not finished review from the local storage + const data = store.get(`job-${this.id}-review`); + if (data) { + reviews.push(new Review(JSON.parse(data))); + } + + return reviews; + }; + + Job.prototype.reviewsSummary.implementation = async function () { + const reviews = await serverProxy.jobs.reviews.get(this.id); + const issues = await serverProxy.jobs.issues(this.id); + + const qualities = reviews.map((review) => review.estimated_quality); + const reviewers = reviews.filter((review) => review.reviewer).map((review) => review.reviewer.username); + const assignees = reviews.filter((review) => review.assignee).map((review) => review.assignee.username); + + return { + reviews: reviews.length, + average_estimated_quality: qualities.reduce((acc, quality) => acc + quality, 0) / (qualities.length || 1), + issues_unsolved: issues.filter((issue) => !issue.resolved_date).length, + issues_resolved: issues.filter((issue) => issue.resolved_date).length, + assignees: Array.from(new Set(assignees.filter((assignee) => assignee !== null))), + reviewers: Array.from(new Set(reviewers.filter((reviewer) => reviewer !== null))), + }; + }; + Job.prototype.frames.get.implementation = async function (frame, isPlaying, step) { if (!Number.isInteger(frame) || frame < 0) { throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); diff --git a/cvat-core/src/user.js b/cvat-core/src/user.js index c0475d8ec315..d826c82260cd 100644 --- a/cvat-core/src/user.js +++ b/cvat-core/src/user.js @@ -157,6 +157,27 @@ }), ); } + + serialize() { + return { + id: this.id, + username: this.username, + email: this.email, + first_name: this.firstName, + last_name: this.lastName, + groups: this.groups, + last_login: this.lastLogin, + date_joined: this.dateJoined, + is_staff: this.isStaff, + is_superuser: this.isSuperuser, + is_active: this.isActive, + email_verification_required: this.isVerified, + }; + } + + toJSON() { + return this.serialize(); + } } module.exports = User; diff --git a/cvat-core/tests/mocks/dummy-data.mock.js b/cvat-core/tests/mocks/dummy-data.mock.js index 115e04918b0b..33a3c500ce88 100644 --- a/cvat-core/tests/mocks/dummy-data.mock.js +++ b/cvat-core/tests/mocks/dummy-data.mock.js @@ -236,6 +236,7 @@ const projectsDummyData = { url: 'http://192.168.0.139:7000/api/v1/jobs/1', id: 1, assignee: null, + reviewer: null, status: 'completed', }, ], @@ -248,6 +249,7 @@ const projectsDummyData = { url: 'http://192.168.0.139:7000/api/v1/jobs/2', id: 2, assignee: null, + reviewer: null, status: 'completed', }, ], @@ -260,6 +262,7 @@ const projectsDummyData = { url: 'http://192.168.0.139:7000/api/v1/jobs/3', id: 3, assignee: null, + reviewer: null, status: 'completed', }, ], @@ -272,6 +275,7 @@ const projectsDummyData = { url: 'http://192.168.0.139:7000/api/v1/jobs/4', id: 4, assignee: null, + reviewer: null, status: 'completed', }, ], @@ -284,6 +288,7 @@ const projectsDummyData = { url: 'http://192.168.0.139:7000/api/v1/jobs/5', id: 5, assignee: null, + reviewer: null, status: 'completed', }, ], @@ -350,6 +355,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/112', id: 112, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -399,6 +405,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/100', id: 100, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -602,6 +609,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/10', id: 101, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -614,6 +622,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/11', id: 102, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -626,6 +635,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/12', id: 103, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -638,6 +648,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/13', id: 104, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -650,6 +661,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/14', id: 105, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -662,6 +674,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/15', id: 106, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -674,6 +687,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/16', id: 107, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -686,6 +700,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/17', id: 108, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -698,6 +713,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/18', id: 109, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -710,6 +726,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/19', id: 110, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -722,6 +739,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/20', id: 111, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -926,6 +944,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/3', id: 3, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -938,6 +957,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/4', id: 4, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -1139,6 +1159,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/2', id: 2, assignee: null, + reviewer: null, status: 'annotation', }, ], @@ -1340,6 +1361,7 @@ const tasksDummyData = { url: 'http://localhost:7000/api/v1/jobs/1', id: 1, assignee: null, + reviewer: null, status: 'annotation', }, ], diff --git a/cvat-core/tests/mocks/server-proxy.mock.js b/cvat-core/tests/mocks/server-proxy.mock.js index f9843b1c0b33..d52f3aede177 100644 --- a/cvat-core/tests/mocks/server-proxy.mock.js +++ b/cvat-core/tests/mocks/server-proxy.mock.js @@ -345,16 +345,16 @@ class ServerProxy { jobs: { value: Object.freeze({ - getJob, - saveJob, + get: getJob, + save: saveJob, }), writable: false, }, users: { value: Object.freeze({ - getUsers, - getSelf, + get: getUsers, + self: getSelf, }), writable: false, }, @@ -373,8 +373,6 @@ class ServerProxy { updateAnnotations, getAnnotations, }, - // To implement on of important tests - writable: true, }, }), ); diff --git a/cvat-ui/.eslintrc.js b/cvat-ui/.eslintrc.js index 699379152003..62da6905738b 100644 --- a/cvat-ui/.eslintrc.js +++ b/cvat-ui/.eslintrc.js @@ -21,16 +21,19 @@ module.exports = { ], rules: { '@typescript-eslint/indent': ['warn', 4], + '@typescript-eslint/lines-between-class-members': 0, + 'react/static-property-placement': ['error', 'static public field'], 'react/jsx-indent': ['warn', 4], 'react/jsx-indent-props': ['warn', 4], 'react/jsx-props-no-spreading': 0, + 'implicit-arrow-linebreak': 0, 'jsx-quotes': ['error', 'prefer-single'], 'arrow-parens': ['error', 'always'], '@typescript-eslint/no-explicit-any': [0], '@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }], 'no-restricted-syntax': [0, { selector: 'ForOfStatement' }], 'no-plusplus': [0], - 'lines-between-class-members': 0, + 'lines-between-class-members': [0], 'react/no-did-update-set-state': 0, // https://github.com/airbnb/javascript/issues/1875 quotes: ['error', 'single'], 'max-len': ['error', { code: 120, ignoreStrings: true }], diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index b63fe50b285b..08cea1144979 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.10.9", + "version": "1.11.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -12878,7 +12878,7 @@ "requires": { "axios": "^0.20.0", "browser-or-node": "^1.2.1", - "detect-browser": "^5.0.0", + "detect-browser": "^5.2.0", "error-stack-parser": "^2.0.2", "form-data": "^2.5.0", "jest-config": "^24.8.0", diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 88d5241b11c0..4dea1406d1a6 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.10.9", + "version": "1.11.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 44604467315e..fdf86a7f2d07 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -123,6 +123,7 @@ export enum AnnotationActionTypes { CONFIRM_CANVAS_READY = 'CONFIRM_CANVAS_READY', DRAG_CANVAS = 'DRAG_CANVAS', ZOOM_CANVAS = 'ZOOM_CANVAS', + SELECT_ISSUE_POSITION = 'SELECT_ISSUE_POSITION', MERGE_OBJECTS = 'MERGE_OBJECTS', GROUP_OBJECTS = 'GROUP_OBJECTS', SPLIT_TRACK = 'SPLIT_TRACK', @@ -161,9 +162,6 @@ export enum AnnotationActionTypes { 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', UPLOAD_JOB_ANNOTATIONS = 'UPLOAD_JOB_ANNOTATIONS', UPLOAD_JOB_ANNOTATIONS_SUCCESS = 'UPLOAD_JOB_ANNOTATIONS_SUCCESS', UPLOAD_JOB_ANNOTATIONS_FAILED = 'UPLOAD_JOB_ANNOTATIONS_FAILED', @@ -187,6 +185,9 @@ export enum AnnotationActionTypes { SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED', INTERACT_WITH_CANVAS = 'INTERACT_WITH_CANVAS', SET_AI_TOOLS_REF = 'SET_AI_TOOLS_REF', + SWITCH_REQUEST_REVIEW_DIALOG = 'SWITCH_REQUEST_REVIEW_DIALOG', + SWITCH_SUBMIT_REVIEW_DIALOG = 'SWITCH_SUBMIT_REVIEW_DIALOG', + SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG = 'SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG', } export function saveLogsAsync(): ThunkAction { @@ -393,36 +394,6 @@ export function uploadJobAnnotationsAsync(job: any, loader: any, file: File): Th }; } -export function changeJobStatusAsync(jobInstance: any, status: string): ThunkAction { - 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 { return async (dispatch: ActionCreator): Promise => { try { @@ -896,7 +867,11 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init try { const state: CombinedState = getStore().getState(); const filters = initialFilters; - const { showAllInterpolationTracks } = state.settings.workspace; + const { + settings: { + workspace: { showAllInterpolationTracks }, + }, + } = state; dispatch({ type: AnnotationActionTypes.GET_JOB, @@ -940,6 +915,8 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init // to load and decode first chunk await frameData.data(); const states = await job.annotations.get(frameNumber, showAllInterpolationTracks, filters); + const issues = await job.issues(); + const reviews = await job.reviews(); const [minZ, maxZ] = computeZRange(states); const colors = [...cvat.enums.colors]; @@ -949,6 +926,8 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init type: AnnotationActionTypes.GET_JOB_SUCCESS, payload: { job, + issues, + reviews, states, frameNumber, frameFilename: frameData.filename, @@ -971,7 +950,7 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init }; } -export function saveAnnotationsAsync(sessionInstance: any): ThunkAction { +export function saveAnnotationsAsync(sessionInstance: any, afterSave?: () => void): ThunkAction { return async (dispatch: ActionCreator): Promise => { const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters(); @@ -997,6 +976,9 @@ export function saveAnnotationsAsync(sessionInstance: any): ThunkAction { const { frame } = receiveAnnotationsParameters(); const states = await sessionInstance.annotations.get(frame, showAllInterpolationTracks, filters); + if (typeof afterSave === 'function') { + afterSave(); + } dispatch({ type: AnnotationActionTypes.SAVE_ANNOTATIONS_SUCCESS, @@ -1056,6 +1038,15 @@ export function shapeDrawn(): AnyAction { }; } +export function selectIssuePosition(enabled: boolean): AnyAction { + return { + type: AnnotationActionTypes.SELECT_ISSUE_POSITION, + payload: { + enabled, + }, + }; +} + export function mergeObjects(enabled: boolean): AnyAction { return { type: AnnotationActionTypes.MERGE_OBJECTS, @@ -1481,3 +1472,30 @@ export function redrawShapeAsync(): ThunkAction { } }; } + +export function switchRequestReviewDialog(visible: boolean): AnyAction { + return { + type: AnnotationActionTypes.SWITCH_REQUEST_REVIEW_DIALOG, + payload: { + visible, + }, + }; +} + +export function switchSubmitReviewDialog(visible: boolean): AnyAction { + return { + type: AnnotationActionTypes.SWITCH_SUBMIT_REVIEW_DIALOG, + payload: { + visible, + }, + }; +} + +export function setForceExitAnnotationFlag(forceExit: boolean): AnyAction { + return { + type: AnnotationActionTypes.SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG, + payload: { + forceExit, + }, + }; +} diff --git a/cvat-ui/src/actions/review-actions.ts b/cvat-ui/src/actions/review-actions.ts new file mode 100644 index 000000000000..6cca02ecf59a --- /dev/null +++ b/cvat-ui/src/actions/review-actions.ts @@ -0,0 +1,217 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; +import getCore from 'cvat-core-wrapper'; +import { updateTaskSuccess } from './tasks-actions'; + +const cvat = getCore(); + +export enum ReviewActionTypes { + INITIALIZE_REVIEW_SUCCESS = 'INITIALIZE_REVIEW_SUCCESS', + INITIALIZE_REVIEW_FAILED = 'INITIALIZE_REVIEW_FAILED', + CREATE_ISSUE = 'CREATE_ISSUE', + START_ISSUE = 'START_ISSUE', + FINISH_ISSUE_SUCCESS = 'FINISH_ISSUE_SUCCESS', + FINISH_ISSUE_FAILED = 'FINISH_ISSUE_FAILED', + CANCEL_ISSUE = 'CANCEL_ISSUE', + RESOLVE_ISSUE = 'RESOLVE_ISSUE', + RESOLVE_ISSUE_SUCCESS = 'RESOLVE_ISSUE_SUCCESS', + RESOLVE_ISSUE_FAILED = 'RESOLVE_ISSUE_FAILED', + REOPEN_ISSUE = 'REOPEN_ISSUE', + REOPEN_ISSUE_SUCCESS = 'REOPEN_ISSUE_SUCCESS', + REOPEN_ISSUE_FAILED = 'REOPEN_ISSUE_FAILED', + COMMENT_ISSUE = 'COMMENT_ISSUE', + COMMENT_ISSUE_SUCCESS = 'COMMENT_ISSUE_SUCCESS', + COMMENT_ISSUE_FAILED = 'COMMENT_ISSUE_FAILED', + SUBMIT_REVIEW = 'SUBMIT_REVIEW', + SUBMIT_REVIEW_SUCCESS = 'SUBMIT_REVIEW_SUCCESS', + SUBMIT_REVIEW_FAILED = 'SUBMIT_REVIEW_FAILED', + SWITCH_ISSUES_HIDDEN_FLAG = 'SWITCH_ISSUES_HIDDEN_FLAG', +} + +export const reviewActions = { + initializeReviewSuccess: (reviewInstance: any, frame: number) => + createAction(ReviewActionTypes.INITIALIZE_REVIEW_SUCCESS, { reviewInstance, frame }), + initializeReviewFailed: (error: any) => createAction(ReviewActionTypes.INITIALIZE_REVIEW_FAILED, { error }), + createIssue: () => createAction(ReviewActionTypes.CREATE_ISSUE, {}), + startIssue: (position: number[]) => + createAction(ReviewActionTypes.START_ISSUE, { position: cvat.classes.Issue.hull(position) }), + finishIssueSuccess: (frame: number, issue: any) => + createAction(ReviewActionTypes.FINISH_ISSUE_SUCCESS, { frame, issue }), + finishIssueFailed: (error: any) => createAction(ReviewActionTypes.FINISH_ISSUE_FAILED, { error }), + cancelIssue: () => createAction(ReviewActionTypes.CANCEL_ISSUE), + commentIssue: (issueId: number) => createAction(ReviewActionTypes.COMMENT_ISSUE, { issueId }), + commentIssueSuccess: () => createAction(ReviewActionTypes.COMMENT_ISSUE_SUCCESS), + commentIssueFailed: (error: any) => createAction(ReviewActionTypes.COMMENT_ISSUE_FAILED, { error }), + resolveIssue: (issueId: number) => createAction(ReviewActionTypes.RESOLVE_ISSUE, { issueId }), + resolveIssueSuccess: () => createAction(ReviewActionTypes.RESOLVE_ISSUE_SUCCESS), + resolveIssueFailed: (error: any) => createAction(ReviewActionTypes.RESOLVE_ISSUE_FAILED, { error }), + reopenIssue: (issueId: number) => createAction(ReviewActionTypes.REOPEN_ISSUE, { issueId }), + reopenIssueSuccess: () => createAction(ReviewActionTypes.REOPEN_ISSUE_SUCCESS), + reopenIssueFailed: (error: any) => createAction(ReviewActionTypes.REOPEN_ISSUE_FAILED, { error }), + submitReview: (reviewId: number) => createAction(ReviewActionTypes.SUBMIT_REVIEW, { reviewId }), + submitReviewSuccess: (activeReview: any, reviews: any[], issues: any[], frame: number) => + createAction(ReviewActionTypes.SUBMIT_REVIEW_SUCCESS, { + activeReview, + reviews, + issues, + frame, + }), + submitReviewFailed: (error: any) => createAction(ReviewActionTypes.SUBMIT_REVIEW_FAILED, { error }), + switchIssuesHiddenFlag: (hidden: boolean) => createAction(ReviewActionTypes.SWITCH_ISSUES_HIDDEN_FLAG, { hidden }), +}; + +export type ReviewActions = ActionUnion; + +export const initializeReviewAsync = (): ThunkAction => async (dispatch, getState) => { + try { + const state = getState(); + const { + annotation: { + job: { instance: jobInstance }, + player: { + frame: { number: frame }, + }, + }, + } = state; + + const reviews = await jobInstance.reviews(); + const count = reviews.length; + let reviewInstance = null; + if (count && reviews[count - 1].id < 0) { + reviewInstance = reviews[count - 1]; + } else { + reviewInstance = new cvat.classes.Review({ job: jobInstance.id }); + } + + dispatch(reviewActions.initializeReviewSuccess(reviewInstance, frame)); + } catch (error) { + dispatch(reviewActions.initializeReviewFailed(error)); + } +}; + +export const finishIssueAsync = (message: string): ThunkAction => async (dispatch, getState) => { + const state = getState(); + const { + auth: { user }, + annotation: { + player: { + frame: { number: frameNumber }, + }, + }, + review: { activeReview, newIssuePosition }, + } = state; + + try { + const issue = await activeReview.openIssue({ + frame: frameNumber, + position: newIssuePosition, + owner: user, + comment_set: [ + { + message, + author: user, + }, + ], + }); + await activeReview.toLocalStorage(); + dispatch(reviewActions.finishIssueSuccess(frameNumber, issue)); + } catch (error) { + dispatch(reviewActions.finishIssueFailed(error)); + } +}; + +export const commentIssueAsync = (id: number, message: string): ThunkAction => async (dispatch, getState) => { + const state = getState(); + const { + auth: { user }, + review: { frameIssues, activeReview }, + } = state; + + try { + dispatch(reviewActions.commentIssue(id)); + const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id); + await issue.comment({ + message, + author: user, + }); + if (activeReview && activeReview.issues.includes(issue)) { + await activeReview.toLocalStorage(); + } + dispatch(reviewActions.commentIssueSuccess()); + } catch (error) { + dispatch(reviewActions.commentIssueFailed(error)); + } +}; + +export const resolveIssueAsync = (id: number): ThunkAction => async (dispatch, getState) => { + const state = getState(); + const { + auth: { user }, + review: { frameIssues, activeReview }, + } = state; + + try { + dispatch(reviewActions.resolveIssue(id)); + const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id); + await issue.resolve(user); + if (activeReview && activeReview.issues.includes(issue)) { + await activeReview.toLocalStorage(); + } + + dispatch(reviewActions.resolveIssueSuccess()); + } catch (error) { + dispatch(reviewActions.resolveIssueFailed(error)); + } +}; + +export const reopenIssueAsync = (id: number): ThunkAction => async (dispatch, getState) => { + const state = getState(); + const { + auth: { user }, + review: { frameIssues, activeReview }, + } = state; + + try { + dispatch(reviewActions.reopenIssue(id)); + const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id); + await issue.reopen(user); + if (activeReview && activeReview.issues.includes(issue)) { + await activeReview.toLocalStorage(); + } + + dispatch(reviewActions.reopenIssueSuccess()); + } catch (error) { + dispatch(reviewActions.reopenIssueFailed(error)); + } +}; + +export const submitReviewAsync = (review: any): ThunkAction => async (dispatch, getState) => { + const state = getState(); + const { + annotation: { + job: { instance: jobInstance }, + player: { + frame: { number: frame }, + }, + }, + } = state; + + try { + dispatch(reviewActions.submitReview(review.id)); + await review.submit(jobInstance.id); + + const [task] = await cvat.tasks.get({ id: jobInstance.task.id }); + dispatch(updateTaskSuccess(task)); + + const reviews = await jobInstance.reviews(); + const issues = await jobInstance.issues(); + const reviewInstance = new cvat.classes.Review({ job: jobInstance.id }); + + dispatch(reviewActions.submitReviewSuccess(reviewInstance, reviews, issues, frame)); + } catch (error) { + dispatch(reviewActions.submitReviewFailed(error)); + } +}; diff --git a/cvat-ui/src/base.scss b/cvat-ui/src/base.scss index 90fa05b80b60..8cc0aeebaef6 100644 --- a/cvat-ui/src/base.scss +++ b/cvat-ui/src/base.scss @@ -27,6 +27,7 @@ $transparent-color: rgba(0, 0, 0, 0); $player-slider-color: #979797; $player-buttons-color: #242424; $danger-icon-color: #ff4136; +$ok-icon-color: #61c200; $info-icon-color: #0074d9; $objects-bar-tabs-color: #bebebe; $objects-bar-icons-color: #242424; // #6e6e6e @@ -34,5 +35,8 @@ $active-label-background-color: #d8ecff; $object-item-border-color: rgba(0, 0, 0, 0.7); $slider-color: #1890ff; +$box-shadow-base: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), + 0 9px 28px 8px rgba(0, 0, 0, 0.05); + $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/actions-menu/styles.scss b/cvat-ui/src/components/actions-menu/styles.scss index b2fc8dedc748..e051820598d0 100644 --- a/cvat-ui/src/components/actions-menu/styles.scss +++ b/cvat-ui/src/components/actions-menu/styles.scss @@ -5,7 +5,7 @@ @import '../../base.scss'; .ant-menu.cvat-actions-menu { - box-shadow: 0 0 17px rgba(0, 0, 0, 0.2); + box-shadow: $box-shadow-base; > li:hover { background-color: $hover-menu-color; diff --git a/cvat-ui/src/components/annotation-page/annotation-page.tsx b/cvat-ui/src/components/annotation-page/annotation-page.tsx index ed87c19c4380..2ef039fa2fa1 100644 --- a/cvat-ui/src/components/annotation-page/annotation-page.tsx +++ b/cvat-ui/src/components/annotation-page/annotation-page.tsx @@ -12,9 +12,12 @@ import Result from 'antd/lib/result'; import { Workspace } from 'reducers/interfaces'; 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'; -import AttributeAnnotationWorkspace from './attribute-annotation-workspace/attribute-annotation-workspace'; -import TagAnnotationWorkspace from './tag-annotation-workspace/tag-annotation-workspace'; +import StandardWorkspaceComponent from 'components/annotation-page/standard-workspace/standard-workspace'; +import AttributeAnnotationWorkspace from 'components/annotation-page/attribute-annotation-workspace/attribute-annotation-workspace'; +import TagAnnotationWorkspace from 'components/annotation-page/tag-annotation-workspace/tag-annotation-workspace'; +import ReviewAnnotationsWorkspace from 'components/annotation-page/review-workspace/review-workspace'; +import SubmitAnnotationsModal from 'components/annotation-page/request-review-modal'; +import SubmitReviewModal from 'components/annotation-page/review/submit-review-modal'; interface Props { job: any | null | undefined; @@ -26,7 +29,9 @@ interface Props { } export default function AnnotationPageComponent(props: Props): JSX.Element { - const { job, fetching, getJob, closeJob, saveLogs, workspace } = props; + const { + job, fetching, getJob, closeJob, saveLogs, workspace, + } = props; const history = useHistory(); useEffect(() => { @@ -87,7 +92,14 @@ export default function AnnotationPageComponent(props: Props): JSX.Element { )} + {workspace === Workspace.REVIEW_WORKSPACE && ( + + + + )} + + ); } diff --git a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-workspace.tsx b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-workspace.tsx index d4e5fd8de94d..5cd8a079d0c5 100644 --- a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-workspace.tsx +++ b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-workspace.tsx @@ -6,7 +6,7 @@ import './styles.scss'; import React from 'react'; import Layout from 'antd/lib/layout'; -import CanvasWrapperContainer from 'containers/annotation-page/standard-workspace/canvas-wrapper'; +import CanvasWrapperContainer from 'containers/annotation-page/canvas/canvas-wrapper'; import AttributeAnnotationSidebar from './attribute-annotation-sidebar/attribute-annotation-sidebar'; export default function AttributeAnnotationWorkspace(): JSX.Element { diff --git a/cvat-ui/src/components/annotation-page/canvas/canvas-context-menu.tsx b/cvat-ui/src/components/annotation-page/canvas/canvas-context-menu.tsx new file mode 100644 index 000000000000..9d7cc35c224e --- /dev/null +++ b/cvat-ui/src/components/annotation-page/canvas/canvas-context-menu.tsx @@ -0,0 +1,140 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import ReactDOM from 'react-dom'; +import Menu, { ClickParam } from 'antd/lib/menu'; + +import ObjectItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-item'; +import { Workspace } from 'reducers/interfaces'; +import consts from 'consts'; + +interface Props { + readonly: boolean; + workspace: Workspace; + contextMenuClientID: number | null; + objectStates: any[]; + visible: boolean; + left: number; + top: number; + onStartIssue(position: number[]): void; + openIssue(position: number[], message: string): void; + latestComments: string[]; +} + +interface ReviewContextMenuProps { + top: number; + left: number; + latestComments: string[]; + onClick: (param: ClickParam) => void; +} + +enum ReviewContextMenuKeys { + OPEN_ISSUE = 'open_issue', + QUICK_ISSUE_POSITION = 'quick_issue_position', + QUICK_ISSUE_ATTRIBUTE = 'quick_issue_attribute', + QUICK_ISSUE_FROM_LATEST = 'quick_issue_from_latest', +} + +function ReviewContextMenu({ + top, left, latestComments, onClick, +}: ReviewContextMenuProps): JSX.Element { + return ( + + + Open an issue ... + + + Quick issue: incorrect position + + + Quick issue: incorrect attribute + + {latestComments.length ? ( + + {latestComments.map( + (comment: string, id: number): JSX.Element => ( + + {comment} + + ), + )} + + ) : null} + + ); +} + +export default function CanvasContextMenu(props: Props): JSX.Element | null { + const { + contextMenuClientID, + objectStates, + visible, + left, + top, + readonly, + workspace, + latestComments, + onStartIssue, + openIssue, + } = props; + + if (!visible || contextMenuClientID === null) { + return null; + } + + if (workspace === Workspace.REVIEW_WORKSPACE) { + return ReactDOM.createPortal( + { + const [state] = objectStates.filter( + (_state: any): boolean => _state.clientID === contextMenuClientID, + ); + if (param.key === ReviewContextMenuKeys.OPEN_ISSUE) { + if (state) { + onStartIssue(state.points); + } + } else if (param.key === ReviewContextMenuKeys.QUICK_ISSUE_POSITION) { + if (state) { + openIssue(state.points, consts.QUICK_ISSUE_INCORRECT_POSITION_TEXT); + } + } else if (param.key === ReviewContextMenuKeys.QUICK_ISSUE_ATTRIBUTE) { + if (state) { + openIssue(state.points, consts.QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT); + } + } else if ( + param.keyPath.length === 2 && + param.keyPath[1] === ReviewContextMenuKeys.QUICK_ISSUE_FROM_LATEST + ) { + if (state) { + openIssue(state.points, latestComments[+param.keyPath[0]]); + } + } + }} + />, + window.document.body, + ); + } + + return ReactDOM.createPortal( +
+ +
, + window.document.body, + ); +} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-point-context-menu.tsx b/cvat-ui/src/components/annotation-page/canvas/canvas-point-context-menu.tsx similarity index 71% rename from cvat-ui/src/components/annotation-page/standard-workspace/canvas-point-context-menu.tsx rename to cvat-ui/src/components/annotation-page/canvas/canvas-point-context-menu.tsx index d871ce30ae49..f4aeceaffe9c 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-point-context-menu.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/canvas-point-context-menu.tsx @@ -25,16 +25,18 @@ function mapStateToProps(state: CombinedState): StateToProps { annotation: { annotations: { states, activatedStateID }, canvas: { - contextMenu: { visible, top, left, type, pointID: selectedPoint }, + contextMenu: { + visible, top, left, type, pointID: selectedPoint, + }, }, }, } = state; return { activatedState: - activatedStateID === null - ? null - : states.filter((_state) => _state.clientID === activatedStateID)[0] || null, + activatedStateID === null ? + null : + states.filter((_state) => _state.clientID === activatedStateID)[0] || null, selectedPoint, visible, left, @@ -62,7 +64,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { type Props = StateToProps & DispatchToProps; function CanvasPointContextMenu(props: Props): React.ReactPortal | null { - const { onCloseContextMenu, onUpdateAnnotations, activatedState, visible, type, top, left } = props; + const { + onCloseContextMenu, onUpdateAnnotations, activatedState, visible, type, top, left, + } = props; const [contextMenuFor, setContextMenuFor] = useState(activatedState); @@ -95,23 +99,23 @@ function CanvasPointContextMenu(props: Props): React.ReactPortal | null { } }; - return visible && contextMenuFor && type === ContextMenuType.CANVAS_SHAPE_POINT - ? ReactDOM.createPortal( -
- - - - {contextMenuFor && contextMenuFor.shapeType === 'polygon' && ( - - )} -
, - window.document.body, - ) - : null; + return visible && contextMenuFor && type === ContextMenuType.CANVAS_SHAPE_POINT ? + ReactDOM.createPortal( +
+ + + + {contextMenuFor && contextMenuFor.shapeType === 'polygon' && ( + + )} +
, + window.document.body, + ) : + null; } export default connect(mapStateToProps, mapDispatchToProps)(CanvasPointContextMenu); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx similarity index 93% rename from cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx rename to cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx index acc697ae0990..c2fac4461ef8 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx @@ -30,6 +30,7 @@ interface Props { activatedAttributeID: number | null; selectedStatesID: number[]; annotations: any[]; + frameIssues: any[] | null; frameData: any; frameAngle: number; frameFetching: boolean; @@ -89,11 +90,14 @@ interface Props { onSwitchGrid(enabled: boolean): void; onSwitchAutomaticBordering(enabled: boolean): void; onFetchAnnotation(): void; + onStartIssue(position: number[]): void; } export default class CanvasWrapperComponent extends React.PureComponent { public componentDidMount(): void { - const { automaticBordering, showObjectsTextAlways, canvasInstance } = this.props; + const { + automaticBordering, showObjectsTextAlways, canvasInstance, workspace, + } = this.props; // It's awful approach from the point of view React // But we do not have another way because cvat-canvas returns regular DOM element @@ -104,9 +108,11 @@ export default class CanvasWrapperComponent extends React.PureComponent { autoborders: automaticBordering, undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE, displayAllText: showObjectsTextAlways, + forceDisableEditing: workspace === Workspace.REVIEW_WORKSPACE, }); this.initialSetup(); + this.updateIssueRegions(); this.updateCanvas(); } @@ -118,6 +124,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { outlined, outlineColor, showBitmap, + frameIssues, frameData, frameAngle, annotations, @@ -211,6 +218,10 @@ export default class CanvasWrapperComponent extends React.PureComponent { } } + if (prevProps.frameIssues !== frameIssues) { + this.updateIssueRegions(); + } + if ( prevProps.annotations !== annotations || prevProps.frameData !== frameData || @@ -247,6 +258,18 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.rotate(frameAngle); } + if (prevProps.workspace !== workspace) { + if (workspace === Workspace.REVIEW_WORKSPACE) { + canvasInstance.configure({ + forceDisableEditing: true, + }); + } else if (prevProps.workspace === Workspace.REVIEW_WORKSPACE) { + canvasInstance.configure({ + forceDisableEditing: false, + }); + } + } + const loadingAnimation = window.document.getElementById('cvat_canvas_loading_animation'); if (loadingAnimation && frameFetching !== prevProps.frameFetching) { if (frameFetching) { @@ -295,6 +318,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.html().removeEventListener('canvas.drawn', this.onCanvasShapeDrawn); canvasInstance.html().removeEventListener('canvas.merged', this.onCanvasObjectsMerged); canvasInstance.html().removeEventListener('canvas.groupped', this.onCanvasObjectsGroupped); + canvasInstance.html().removeEventListener('canvas.regionselected', this.onCanvasPositionSelected); canvasInstance.html().removeEventListener('canvas.splitted', this.onCanvasTrackSplitted); canvasInstance.html().removeEventListener('canvas.contextmenu', this.onCanvasPointContextMenu); @@ -353,6 +377,13 @@ export default class CanvasWrapperComponent extends React.PureComponent { onGroupAnnotations(jobInstance, frame, states); }; + private onCanvasPositionSelected = (event: any): void => { + const { onResetCanvas, onStartIssue } = this.props; + const { points } = event.detail; + onStartIssue(points); + onResetCanvas(); + }; + private onCanvasTrackSplitted = (event: any): void => { const { jobInstance, frame, onSplitAnnotations, onSplitTrack, @@ -372,7 +403,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { private onCanvasMouseDown = (e: MouseEvent): void => { const { workspace, activatedStateID, onActivateObject } = this.props; - if ((e.target as HTMLElement).tagName === 'svg') { + if ((e.target as HTMLElement).tagName === 'svg' && e.button !== 2) { if (activatedStateID !== null && workspace !== Workspace.ATTRIBUTE_ANNOTATION) { onActivateObject(null); } @@ -380,7 +411,9 @@ export default class CanvasWrapperComponent extends React.PureComponent { }; private onCanvasClicked = (): void => { - if (document.activeElement instanceof HTMLElement) { + const { canvasInstance, onUpdateContextMenu } = this.props; + onUpdateContextMenu(false, 0, 0, ContextMenuType.CANVAS_SHAPE); + if (!canvasInstance.html().contains(document.activeElement) && document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } }; @@ -440,7 +473,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { jobInstance, activatedStateID, workspace, onActivateObject, } = this.props; - if (workspace !== Workspace.STANDARD) { + if (![Workspace.STANDARD, Workspace.REVIEW_WORKSPACE].includes(workspace)) { return; } @@ -598,6 +631,22 @@ export default class CanvasWrapperComponent extends React.PureComponent { } } + private updateIssueRegions(): void { + const { canvasInstance, frameIssues } = this.props; + if (frameIssues === null) { + canvasInstance.setupIssueRegions({}); + } else { + const regions = frameIssues.reduce((acc: Record, issue: any): Record< + number, + number[] + > => { + acc[issue.id] = issue.position; + return acc; + }, {}); + canvasInstance.setupIssueRegions(regions); + } + } + private updateCanvas(): void { const { curZLayer, annotations, frameData, canvasInstance, @@ -692,6 +741,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.html().addEventListener('canvas.drawn', this.onCanvasShapeDrawn); canvasInstance.html().addEventListener('canvas.merged', this.onCanvasObjectsMerged); canvasInstance.html().addEventListener('canvas.groupped', this.onCanvasObjectsGroupped); + canvasInstance.html().addEventListener('canvas.regionselected', this.onCanvasPositionSelected); canvasInstance.html().addEventListener('canvas.splitted', this.onCanvasTrackSplitted); canvasInstance.html().addEventListener('canvas.contextmenu', this.onCanvasPointContextMenu); diff --git a/cvat-ui/src/components/annotation-page/request-review-modal.tsx b/cvat-ui/src/components/annotation-page/request-review-modal.tsx new file mode 100644 index 000000000000..89d4e244eb53 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/request-review-modal.tsx @@ -0,0 +1,64 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React, { useState } from 'react'; +import { AnyAction } from 'redux'; +import { useSelector, useDispatch } from 'react-redux'; +import { useHistory } from 'react-router'; +import Text from 'antd/lib/typography/Text'; +import Title from 'antd/lib/typography/Title'; +import Modal from 'antd/lib/modal'; +import { Row, Col } from 'antd/lib/grid'; + +import UserSelector, { User } from 'components/task-page/user-selector'; +import { CombinedState, TaskStatus } from 'reducers/interfaces'; +import { switchRequestReviewDialog } from 'actions/annotation-actions'; +import { updateJobAsync } from 'actions/tasks-actions'; + +export default function RequestReviewModal(): JSX.Element | null { + const dispatch = useDispatch(); + const history = useHistory(); + const isVisible = useSelector((state: CombinedState): boolean => state.annotation.requestReviewDialogVisible); + const job = useSelector((state: CombinedState): any => state.annotation.job.instance); + const [reviewer, setReviewer] = useState(job.reviewer ? job.reviewer : null); + const close = (): AnyAction => dispatch(switchRequestReviewDialog(false)); + const submitAnnotations = (): void => { + job.reviewer = reviewer; + job.status = TaskStatus.REVIEW; + dispatch(updateJobAsync(job)); + history.push(`/tasks/${job.task.id}`); + }; + + if (!isVisible) { + return null; + } + + return ( + + + + Assign a user who is responsible for review + + + + + Reviewer: + + + + + + + You might not be able to change the job after this action. Continue? + + + ); +} diff --git a/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx new file mode 100644 index 000000000000..9eb53fa2ea49 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx @@ -0,0 +1,93 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { GlobalHotKeys, ExtendedKeyMapOptions } from 'react-hotkeys'; +import Layout from 'antd/lib/layout'; + +import { ActiveControl, Rotation } from 'reducers/interfaces'; +import { Canvas } from 'cvat-canvas-wrapper'; + +import RotateControl from 'components/annotation-page/standard-workspace/controls-side-bar/rotate-control'; +import CursorControl from 'components/annotation-page/standard-workspace/controls-side-bar/cursor-control'; +import MoveControl from 'components/annotation-page/standard-workspace/controls-side-bar/move-control'; +import FitControl from 'components/annotation-page/standard-workspace/controls-side-bar/fit-control'; +import ResizeControl from 'components/annotation-page/standard-workspace/controls-side-bar/resize-control'; +import IssueControl from './issue-control'; + +interface Props { + canvasInstance: Canvas; + activeControl: ActiveControl; + keyMap: Record; + normalizedKeyMap: Record; + + rotateFrame(rotation: Rotation): void; + selectIssuePosition(enabled: boolean): void; +} + +export default function ControlsSideBarComponent(props: Props): JSX.Element { + const { + canvasInstance, activeControl, normalizedKeyMap, keyMap, rotateFrame, selectIssuePosition, + } = props; + + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const subKeyMap = { + CANCEL: keyMap.CANCEL, + OPEN_REVIEW_ISSUE: keyMap.OPEN_REVIEW_ISSUE, + }; + + const handlers = { + CANCEL: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (activeControl !== ActiveControl.CURSOR) { + canvasInstance.cancel(); + } + }, + OPEN_REVIEW_ISSUE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (activeControl === ActiveControl.OPEN_ISSUE) { + canvasInstance.selectRegion(false); + selectIssuePosition(false); + } else { + canvasInstance.cancel(); + canvasInstance.selectRegion(true); + selectIssuePosition(true); + } + }, + }; + + return ( + + + + + + +
+ + + + +
+ +
+ ); +} diff --git a/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/issue-control.tsx b/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/issue-control.tsx new file mode 100644 index 000000000000..5286f8437882 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/issue-control.tsx @@ -0,0 +1,46 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import Icon from 'antd/lib/icon'; +import Tooltip from 'antd/lib/tooltip'; + +import { ActiveControl } from 'reducers/interfaces'; +import { Canvas } from 'cvat-canvas-wrapper'; +import { RectangleIcon } from 'icons'; + +interface Props { + canvasInstance: Canvas; + activeControl: ActiveControl; + selectIssuePosition(enabled: boolean): void; +} + +function ResizeControl(props: Props): JSX.Element { + const { activeControl, canvasInstance, selectIssuePosition } = props; + + return ( + + { + if (activeControl === ActiveControl.OPEN_ISSUE) { + canvasInstance.selectRegion(false); + selectIssuePosition(false); + } else { + canvasInstance.cancel(); + canvasInstance.selectRegion(true); + selectIssuePosition(true); + } + }} + /> + + ); +} + +export default React.memo(ResizeControl); diff --git a/cvat-ui/src/components/annotation-page/review-workspace/review-workspace.tsx b/cvat-ui/src/components/annotation-page/review-workspace/review-workspace.tsx new file mode 100644 index 000000000000..095a69c73fd1 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review-workspace/review-workspace.tsx @@ -0,0 +1,50 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useEffect } from 'react'; +import Layout from 'antd/lib/layout'; +import { useDispatch, useSelector } from 'react-redux'; + +import { CombinedState } from 'reducers/interfaces'; +import { initializeReviewAsync } from 'actions/review-actions'; + +import CanvasWrapperContainer from 'containers/annotation-page/canvas/canvas-wrapper'; +import ControlsSideBarContainer from 'containers/annotation-page/review-workspace/controls-side-bar/controls-side-bar'; +import ObjectSideBarComponent from 'components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar'; +import ObjectsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-list'; +import CanvasContextMenuContainer from 'containers/annotation-page/canvas/canvas-context-menu'; +import IssueAggregatorComponent from 'components/annotation-page/review/issues-aggregator'; + +export default function ReviewWorkspaceComponent(): JSX.Element { + const dispatch = useDispatch(); + const frame = useSelector((state: CombinedState): number => state.annotation.player.frame.number); + const states = useSelector((state: CombinedState): any[] => state.annotation.annotations.states); + const review = useSelector((state: CombinedState): any => state.review.activeReview); + + useEffect(() => { + if (review) { + review.reviewFrame(frame); + review.reviewStates( + states + .map((state: any): number | undefined => state.serverID) + .filter((serverID: number | undefined): boolean => typeof serverID !== 'undefined') + .map((serverID: number | undefined): string => `${frame}_${serverID}`), + ); + } + }, [frame, states, review]); + useEffect(() => { + dispatch(initializeReviewAsync()); + }, []); + + return ( + + + + } /> + + + + ); +} diff --git a/cvat-ui/src/components/annotation-page/review-workspace/styles.scss b/cvat-ui/src/components/annotation-page/review-workspace/styles.scss new file mode 100644 index 000000000000..1c9d33483d74 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review-workspace/styles.scss @@ -0,0 +1,21 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +@import 'base.scss'; + +.cvat-review-workspace.ant-layout { + height: 100%; +} + +.cvat-issue-control { + font-size: 40px; + + &::after { + content: '\FE56'; + font-size: 32px; + position: absolute; + bottom: $grid-unit-size; + right: -$grid-unit-size; + } +} diff --git a/cvat-ui/src/components/annotation-page/review/create-issue-dialog.tsx b/cvat-ui/src/components/annotation-page/review/create-issue-dialog.tsx new file mode 100644 index 000000000000..7b216be0b673 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review/create-issue-dialog.tsx @@ -0,0 +1,88 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React, { ReactPortal } from 'react'; +import ReactDOM from 'react-dom'; +import { useDispatch } from 'react-redux'; +import Form, { FormComponentProps } from 'antd/lib/form'; +import Input from 'antd/lib/input'; +import Button from 'antd/lib/button'; +import { Row, Col } from 'antd/lib/grid'; + +import { reviewActions, finishIssueAsync } from 'actions/review-actions'; + +type FormProps = { + top: number; + left: number; + submit(message: string): void; + cancel(): void; +} & FormComponentProps; + +function MessageForm(props: FormProps): JSX.Element { + const { + form: { getFieldDecorator }, + form, + top, + left, + submit, + cancel, + } = props; + + function handleSubmit(e: React.FormEvent): void { + e.preventDefault(); + form.validateFields((error, values): void => { + if (!error) { + submit(values.issue_description); + } + }); + } + + return ( +
+ + {getFieldDecorator('issue_description', { + rules: [{ required: true, message: 'Please, fill out the field' }], + })()} + + + + + + + + + +
+ ); +} + +const WrappedMessageForm = Form.create()(MessageForm); + +interface Props { + top: number; + left: number; +} + +export default function CreateIssueDialog(props: Props): ReactPortal { + const dispatch = useDispatch(); + const { top, left } = props; + + return ReactDOM.createPortal( + { + dispatch(finishIssueAsync(message)); + }} + cancel={() => { + dispatch(reviewActions.cancelIssue()); + }} + />, + window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement, + ); +} diff --git a/cvat-ui/src/components/annotation-page/review/hidden-issue-label.tsx b/cvat-ui/src/components/annotation-page/review/hidden-issue-label.tsx new file mode 100644 index 000000000000..4a2c15b459ba --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review/hidden-issue-label.tsx @@ -0,0 +1,56 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React, { ReactPortal, useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import Tag from 'antd/lib/tag'; +import Icon from 'antd/lib/icon'; +import Tooltip from 'antd/lib/tooltip'; + +interface Props { + id: number; + message: string; + top: number; + left: number; + resolved: boolean; + onClick: () => void; + highlight: () => void; + blur: () => void; +} + +export default function HiddenIssueLabel(props: Props): ReactPortal { + const { + id, message, top, left, resolved, onClick, highlight, blur, + } = props; + + useEffect(() => { + if (!resolved) { + setTimeout(highlight); + } else { + setTimeout(blur); + } + }, [resolved]); + + const elementID = `cvat-hidden-issue-label-${id}`; + return ReactDOM.createPortal( + + + {resolved ? ( + + ) : ( + + )} + {message} + + , + window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement, + ); +} diff --git a/cvat-ui/src/components/annotation-page/review/issue-dialog.tsx b/cvat-ui/src/components/annotation-page/review/issue-dialog.tsx new file mode 100644 index 000000000000..df8d9e76aabf --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review/issue-dialog.tsx @@ -0,0 +1,143 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React, { useState, useEffect, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import { Row, Col } from 'antd/lib/grid'; +import Comment from 'antd/lib/comment'; +import Text from 'antd/lib/typography/Text'; +import Title from 'antd/lib/typography/Title'; +import Tooltip from 'antd/lib/tooltip'; +import Button from 'antd/lib/button'; +import Input from 'antd/lib/input'; +import Icon from 'antd/lib/icon'; +import moment from 'moment'; + +interface Props { + id: number; + comments: any[]; + left: number; + top: number; + resolved: boolean; + isFetching: boolean; + collapse: () => void; + resolve: () => void; + reopen: () => void; + comment: (message: string) => void; + highlight: () => void; + blur: () => void; +} + +export default function IssueDialog(props: Props): JSX.Element { + const ref = useRef(null); + const [currentText, setCurrentText] = useState(''); + const { + comments, + id, + left, + top, + resolved, + isFetching, + collapse, + resolve, + reopen, + comment, + highlight, + blur, + } = props; + + useEffect(() => { + if (!resolved) { + setTimeout(highlight); + } else { + setTimeout(blur); + } + }, [resolved]); + + const lines = comments.map( + (_comment: any): JSX.Element => { + const created = _comment.createdDate ? moment(_comment.createdDate) : moment(moment.now()); + const diff = created.fromNow(); + + return ( + {_comment.author ? _comment.author.username : 'Unknown'}} + content={

{_comment.message}

} + datetime={( + + {diff} + + )} + /> + ); + }, + ); + + const resolveButton = resolved ? ( + + ) : ( + + ); + + return ReactDOM.createPortal( +
+ + + {id >= 0 ? `Issue #${id}` : 'Issue'} + + + + + + + + + {lines} + + + + ) => { + setCurrentText(event.target.value); + }} + onPressEnter={() => { + if (currentText) { + comment(currentText); + setCurrentText(''); + } + }} + /> + + + + + {currentText.length ? ( + + ) : ( + resolveButton + )} + + +
, + window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement, + ); +} diff --git a/cvat-ui/src/components/annotation-page/review/issues-aggregator.tsx b/cvat-ui/src/components/annotation-page/review/issues-aggregator.tsx new file mode 100644 index 000000000000..484e39e27db4 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review/issues-aggregator.tsx @@ -0,0 +1,167 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useState, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import { CombinedState } from 'reducers/interfaces'; +import { Canvas } from 'cvat-canvas/src/typescript/canvas'; + +import { commentIssueAsync, resolveIssueAsync, reopenIssueAsync } from 'actions/review-actions'; + +import CreateIssueDialog from './create-issue-dialog'; +import HiddenIssueLabel from './hidden-issue-label'; +import IssueDialog from './issue-dialog'; + +const scaleHandler = (canvasInstance: Canvas): void => { + const { geometry } = canvasInstance; + const createDialogs = window.document.getElementsByClassName('cvat-create-issue-dialog'); + const hiddenIssues = window.document.getElementsByClassName('cvat-hidden-issue-label'); + const issues = window.document.getElementsByClassName('cvat-issue-dialog'); + for (const element of [...Array.from(createDialogs), ...Array.from(hiddenIssues), ...Array.from(issues)]) { + (element as HTMLSpanElement).style.transform = `scale(${1 / geometry.scale}) rotate(${-geometry.angle}deg)`; + } +}; + +export default function IssueAggregatorComponent(): JSX.Element | null { + const dispatch = useDispatch(); + const [expandedIssue, setExpandedIssue] = useState(null); + const frameIssues = useSelector((state: CombinedState): any[] => state.review.frameIssues); + const canvasInstance = useSelector((state: CombinedState): Canvas => state.annotation.canvas.instance); + const canvasIsReady = useSelector((state: CombinedState): boolean => state.annotation.canvas.ready); + const newIssuePosition = useSelector((state: CombinedState): number[] | null => state.review.newIssuePosition); + const issuesHidden = useSelector((state: CombinedState): any => state.review.issuesHidden); + const issueFetching = useSelector((state: CombinedState): number | null => state.review.fetching.issueId); + const issueLabels: JSX.Element[] = []; + const issueDialogs: JSX.Element[] = []; + + useEffect(() => { + scaleHandler(canvasInstance); + }); + + useEffect(() => { + const regions = frameIssues.reduce((acc: Record, issue: any): Record => { + acc[issue.id] = issue.position; + return acc; + }, {}); + + if (newIssuePosition) { + regions[0] = newIssuePosition; + } + + canvasInstance.setupIssueRegions(regions); + + if (newIssuePosition) { + setExpandedIssue(null); + const element = window.document.getElementById('cvat_canvas_issue_region_0'); + if (element) { + element.style.display = 'block'; + } + } + }, [newIssuePosition]); + + useEffect(() => { + const listener = (): void => scaleHandler(canvasInstance); + + canvasInstance.html().addEventListener('canvas.zoom', listener); + canvasInstance.html().addEventListener('canvas.fit', listener); + + return () => { + canvasInstance.html().removeEventListener('canvas.zoom', listener); + canvasInstance.html().removeEventListener('canvas.fit', listener); + }; + }, []); + + if (!canvasIsReady) { + return null; + } + + const { geometry } = canvasInstance; + for (const issue of frameIssues) { + if (issuesHidden) break; + const issueResolved = !!issue.resolver; + const offset = 15; + const translated = issue.position.map((coord: number): number => coord + geometry.offset); + const minX = Math.min(...translated.filter((_: number, idx: number): boolean => idx % 2 === 0)) + offset; + const minY = Math.min(...translated.filter((_: number, idx: number): boolean => idx % 2 !== 0)) + offset; + const { id } = issue; + const highlight = (): void => { + const element = window.document.getElementById(`cvat_canvas_issue_region_${id}`); + if (element) { + element.style.display = 'block'; + } + }; + + const blur = (): void => { + if (issueResolved) { + const element = window.document.getElementById(`cvat_canvas_issue_region_${id}`); + if (element) { + element.style.display = ''; + } + } + }; + + if (expandedIssue === id) { + issueDialogs.push( + { + setExpandedIssue(null); + }} + resolve={() => { + dispatch(resolveIssueAsync(issue.id)); + setExpandedIssue(null); + }} + reopen={() => { + dispatch(reopenIssueAsync(issue.id)); + }} + comment={(message: string) => { + dispatch(commentIssueAsync(issue.id, message)); + }} + />, + ); + } else if (issue.comments.length) { + issueLabels.push( + { + setExpandedIssue(id); + }} + />, + ); + } + } + + const translated = newIssuePosition ? newIssuePosition.map((coord: number): number => coord + geometry.offset) : []; + const createLeft = translated.length ? + Math.max(...translated.filter((_: number, idx: number): boolean => idx % 2 === 0)) : + null; + const createTop = translated.length ? + Math.min(...translated.filter((_: number, idx: number): boolean => idx % 2 !== 0)) : + null; + + return ( + <> + {createLeft !== null && createTop !== null && } + {issueDialogs} + {issueLabels} + + ); +} diff --git a/cvat-ui/src/components/annotation-page/review/styles.scss b/cvat-ui/src/components/annotation-page/review/styles.scss new file mode 100644 index 000000000000..be2381c9c7c2 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review/styles.scss @@ -0,0 +1,112 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +@import 'base.scss'; + +.cvat-create-issue-dialog { + position: absolute; + pointer-events: auto; + width: $grid-unit-size * 30; + padding: $grid-unit-size; + background: $background-color-2; + z-index: 100; + transform-origin: top left; + box-shadow: $box-shadow-base; + + button { + width: $grid-unit-size * 12; + } +} + +.cvat-hidden-issue-label { + position: absolute; + min-width: 8 * $grid-unit-size; + opacity: 0.8; + z-index: 100; + transition: none; + pointer-events: auto; + max-width: 16 * $grid-unit-size; + overflow: hidden; + text-overflow: ellipsis; + border-radius: 0; + transform-origin: top left; + + &:hover { + opacity: 1; + } +} + +.cvat-issue-dialog { + width: $grid-unit-size * 35; + position: absolute; + z-index: 100; + transition: none; + pointer-events: auto; + background: $background-color-2; + padding: $grid-unit-size; + transform-origin: top left; + box-shadow: $box-shadow-base; + border-radius: 0.5 * $grid-unit-size; + opacity: 0.95; + + .cvat-issue-dialog-chat { + > div { + width: 100%; + } + + .ant-comment { + user-select: auto; + padding: $grid-unit-size; + padding-bottom: 0; + + .ant-comment-content { + line-height: 14px; + } + + .ant-comment-avatar { + margin: 0; + } + } + + border-radius: 0.5 * $grid-unit-size; + background: $background-color-1; + padding: $grid-unit-size; + max-height: $grid-unit-size * 45; + overflow-y: auto; + width: 100%; + } + + .cvat-issue-dialog-input { + background: $background-color-1; + margin-top: $grid-unit-size; + } + + .cvat-issue-dialog-footer { + margin-top: $grid-unit-size; + } + + .ant-comment > .ant-comment-inner { + padding: 0; + } + + &:hover { + opacity: 1; + } +} + +.cvat-hidden-issue-indicator { + margin-right: $grid-unit-size; +} + +.cvat-hidden-issue-resolved-indicator { + @extend .cvat-hidden-issue-indicator; + + color: $ok-icon-color; +} + +.cvat-hidden-issue-unsolved-indicator { + @extend .cvat-hidden-issue-indicator; + + color: $danger-icon-color; +} diff --git a/cvat-ui/src/components/annotation-page/review/submit-review-modal.tsx b/cvat-ui/src/components/annotation-page/review/submit-review-modal.tsx new file mode 100644 index 000000000000..b942d14a10f0 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/review/submit-review-modal.tsx @@ -0,0 +1,149 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React, { useState, useEffect } from 'react'; +import { AnyAction } from 'redux'; +import { useSelector, useDispatch } from 'react-redux'; +import Text from 'antd/lib/typography/Text'; +import Title from 'antd/lib/typography/Title'; +import Modal from 'antd/lib/modal'; +import Radio, { RadioChangeEvent } from 'antd/lib/radio'; +import RadioButton from 'antd/lib/radio/radioButton'; +import Description from 'antd/lib/descriptions'; +import Rate from 'antd/lib/rate'; +import { Row, Col } from 'antd/lib/grid'; + +import UserSelector, { User } from 'components/task-page/user-selector'; +import { CombinedState, ReviewStatus } from 'reducers/interfaces'; +import { switchSubmitReviewDialog } from 'actions/annotation-actions'; +import { submitReviewAsync } from 'actions/review-actions'; +import { clamp } from 'utils/math'; +import { useHistory } from 'react-router'; + +function computeEstimatedQuality(reviewedStates: number, openedIssues: number): number { + if (reviewedStates === 0 && openedIssues === 0) { + return 5; // corner case + } + + const K = 2; // means how many reviewed states are equivalent to one issue + const quality = reviewedStates / (reviewedStates + K * openedIssues); + return clamp(+(5 * quality).toPrecision(2), 0, 5); +} + +export default function SubmitReviewModal(): JSX.Element | null { + const dispatch = useDispatch(); + const history = useHistory(); + const isVisible = useSelector((state: CombinedState): boolean => state.annotation.submitReviewDialogVisible); + const job = useSelector((state: CombinedState): any => state.annotation.job.instance); + const activeReview = useSelector((state: CombinedState): any => state.review.activeReview); + const reviewIsBeingSubmitted = useSelector((state: CombinedState): any => state.review.fetching.reviewId); + const numberOfIssues = useSelector((state: CombinedState): any => state.review.issues.length); + const [isSubmitting, setIsSubmitting] = useState(false); + const numberOfNewIssues = activeReview ? activeReview.issues.length : 0; + const reviewedFrames = activeReview ? activeReview.reviewedFrames.length : 0; + const reviewedStates = activeReview ? activeReview.reviewedStates.length : 0; + + const [reviewer, setReviewer] = useState(job.reviewer ? job.reviewer : null); + const [reviewStatus, setReviewStatus] = useState(ReviewStatus.ACCEPTED); + const [estimatedQuality, setEstimatedQuality] = useState(0); + + const close = (): AnyAction => dispatch(switchSubmitReviewDialog(false)); + const submitReview = (): void => { + activeReview.estimatedQuality = estimatedQuality; + activeReview.status = reviewStatus; + if (reviewStatus === ReviewStatus.REVIEW_FURTHER) { + activeReview.reviewer = reviewer; + } + dispatch(submitReviewAsync(activeReview)); + }; + + useEffect(() => { + setEstimatedQuality(computeEstimatedQuality(reviewedStates, numberOfNewIssues)); + }, [reviewedStates, numberOfNewIssues]); + useEffect(() => { + if (!isSubmitting && activeReview && activeReview.id === reviewIsBeingSubmitted) { + setIsSubmitting(true); + } else if (isSubmitting && reviewIsBeingSubmitted === null) { + setIsSubmitting(false); + close(); + history.push(`/tasks/${job.task.id}`); + } + }, [reviewIsBeingSubmitted, activeReview]); + + if (!isVisible) { + return null; + } + + return ( + + + + Submitting your review + + + + + + {estimatedQuality} + + {numberOfIssues} + {!!numberOfNewIssues && {` (+${numberOfNewIssues})`}} + + {reviewedFrames} + {reviewedStates} + + + + + + { + if (typeof event.target.value !== 'undefined') { + setReviewStatus(event.target.value); + } + }} + > + Accept + Review next + Reject + + {reviewStatus === ReviewStatus.REVIEW_FURTHER && ( + + + Reviewer: + + + + + + )} + + + { + if (typeof value !== 'undefined') { + setEstimatedQuality(value); + } + }} + /> + + + + + + + + ); +} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-context-menu.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-context-menu.tsx deleted file mode 100644 index 36623adb8e7f..000000000000 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-context-menu.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (C) 2020 Intel Corporation -// -// SPDX-License-Identifier: MIT - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import ObjectItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-item'; - -interface Props { - activatedStateID: number | null; - objectStates: any[]; - visible: boolean; - left: number; - top: number; -} - -export default function CanvasContextMenu(props: Props): JSX.Element | null { - const { activatedStateID, objectStates, visible, left, top } = props; - - if (!visible || activatedStateID === null) { - return null; - } - - return ReactDOM.createPortal( -
- -
, - window.document.body, - ); -} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/issues-list.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/issues-list.tsx new file mode 100644 index 000000000000..4269d8ef2ce3 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/issues-list.tsx @@ -0,0 +1,124 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { CombinedState } from 'reducers/interfaces'; +import Icon, { IconProps } from 'antd/lib/icon'; +import Tooltip from 'antd/lib/tooltip'; +import Alert from 'antd/lib/alert'; +import { Row, Col } from 'antd/lib/grid'; + +import { changeFrameAsync } from 'actions/annotation-actions'; +import { reviewActions } from 'actions/review-actions'; + +export default function LabelsListComponent(): JSX.Element { + const dispatch = useDispatch(); + const tabContentHeight = useSelector((state: CombinedState) => state.annotation.tabContentHeight); + const frame = useSelector((state: CombinedState): number => state.annotation.player.frame.number); + const frameIssues = useSelector((state: CombinedState): any[] => state.review.frameIssues); + const issues = useSelector((state: CombinedState): any[] => state.review.issues); + const activeReview = useSelector((state: CombinedState): any => state.review.activeReview); + const issuesHidden = useSelector((state: CombinedState): any => state.review.issuesHidden); + const combinedIssues = activeReview ? issues.concat(activeReview.issues) : issues; + const frames = combinedIssues.map((issue: any): number => issue.frame).sort((a: number, b: number) => +a - +b); + const nearestLeft = frames.filter((_frame: number): boolean => _frame < frame).reverse()[0]; + const dinamicLeftProps: IconProps = Number.isInteger(nearestLeft) ? + { + onClick: () => dispatch(changeFrameAsync(nearestLeft)), + } : + { + style: { + pointerEvents: 'none', + opacity: 0.5, + }, + }; + + const nearestRight = frames.filter((_frame: number): boolean => _frame > frame)[0]; + const dinamicRightProps: IconProps = Number.isInteger(nearestRight) ? + { + onClick: () => dispatch(changeFrameAsync(nearestRight)), + } : + { + style: { + pointerEvents: 'none', + opacity: 0.5, + }, + }; + + const dinamicShowHideProps: IconProps = issuesHidden ? + { + onClick: () => dispatch(reviewActions.switchIssuesHiddenFlag(false)), + type: 'eye-invisible', + } : + { + onClick: () => dispatch(reviewActions.switchIssuesHiddenFlag(true)), + type: 'eye', + }; + + return ( +
+
+ + + + + + + + + + + + + + + + + +
+
+ {frameIssues.map( + (frameIssue: any): JSX.Element => ( +
{ + const element = window.document.getElementById( + `cvat_canvas_issue_region_${frameIssue.id}`, + ); + if (element) { + element.setAttribute('fill', 'url(#cvat_issue_region_pattern_2)'); + } + }} + onMouseLeave={() => { + const element = window.document.getElementById( + `cvat_canvas_issue_region_${frameIssue.id}`, + ); + if (element) { + element.setAttribute('fill', 'url(#cvat_issue_region_pattern_1)'); + } + }} + > + {frameIssue.resolver ? ( + {`By ${frameIssue.resolver.username}`}} + message='Resolved' + type='success' + showIcon + /> + ) : ( + {`By ${frameIssue.owner.username}`}} + message='Opened' + type='warning' + showIcon + /> + )} +
+ ), + )} +
+
+ ); +} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-attribute.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-attribute.tsx index 01a7640a6635..78ac86348701 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-attribute.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-attribute.tsx @@ -15,6 +15,7 @@ import consts from 'consts'; import { clamp } from 'utils/math'; interface Props { + readonly: boolean; attrInputType: string; attrValues: string[]; attrValue: string; @@ -25,6 +26,7 @@ interface Props { function attrIsTheSame(prevProps: Props, nextProps: Props): boolean { return ( + nextProps.readonly === prevProps.readonly && nextProps.attrID === prevProps.attrID && nextProps.attrValue === prevProps.attrValue && nextProps.attrName === prevProps.attrName && @@ -36,7 +38,9 @@ function attrIsTheSame(prevProps: Props, nextProps: Props): boolean { } function ItemAttributeComponent(props: Props): JSX.Element { - const { attrInputType, attrValues, attrValue, attrName, attrID, changeAttribute } = props; + const { + attrInputType, attrValues, attrValue, attrName, attrID, readonly, changeAttribute, + } = props; const attrNameStyle: React.CSSProperties = { wordBreak: 'break-word', lineHeight: '1em' }; @@ -46,6 +50,7 @@ function ItemAttributeComponent(props: Props): JSX.Element { { const value = event.target.checked ? 'true' : 'false'; changeAttribute(attrID, value); @@ -69,6 +74,7 @@ function ItemAttributeComponent(props: Props): JSX.Element { { @@ -96,6 +102,7 @@ function ItemAttributeComponent(props: Props): JSX.Element { ): void => { if (ref.current && ref.current.input) { setSelection({ diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx index a0839ae8cf93..2fe26cd7add0 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx @@ -14,6 +14,7 @@ import { ObjectType, ShapeType, ColorBy } from 'reducers/interfaces'; import ItemMenu from './object-item-menu'; interface Props { + readonly: boolean; clientID: number; serverID: number | undefined; labelID: number; @@ -46,6 +47,7 @@ interface Props { function ItemTopComponent(props: Props): JSX.Element { const { + readonly, clientID, serverID, labelID, @@ -101,8 +103,9 @@ function ItemTopComponent(props: Props): JSX.Element { - + - - {StatesOrdering.ID_DESCENT} - - - {StatesOrdering.ID_ASCENT} - - - {StatesOrdering.UPDATED} - - - - ); -} - -const StatesOrderingSelector = React.memo(StatesOrderingSelectorComponent); - interface Props { + readonly: boolean; statesHidden: boolean; statesLocked: boolean; statesCollapsed: boolean; @@ -60,22 +28,57 @@ interface Props { showAllStates(): void; } -function ObjectListHeader(props: Props): JSX.Element { +function LockAllSwitcher(props: Props): JSX.Element { const { - statesHidden, - statesLocked, - statesCollapsed, - statesOrdering, - switchLockAllShortcut, - switchHiddenAllShortcut, - changeStatesOrdering, - lockAllStates, - unlockAllStates, - collapseAllStates, - expandAllStates, - hideAllStates, - showAllStates, + statesLocked, switchLockAllShortcut, unlockAllStates, lockAllStates, } = props; + return ( + + + {statesLocked ? ( + + ) : ( + + )} + + + ); +} + +function HideAllSwitcher(props: Props): JSX.Element { + const { + statesHidden, switchHiddenAllShortcut, showAllStates, hideAllStates, + } = props; + return ( + + + + ); +} + +function CollapseAllSwitcher(props: Props): JSX.Element { + const { statesCollapsed, expandAllStates, collapseAllStates } = props; + return ( + + + {statesCollapsed ? ( + + ) : ( + + )} + + + ); +} + +function ObjectListHeader(props: Props): JSX.Element { + const { readonly, statesOrdering, changeStatesOrdering } = props; return (
@@ -85,33 +88,13 @@ function ObjectListHeader(props: Props): JSX.Element { - - - {statesLocked ? ( - - ) : ( - - )} - - - - - - - - {statesCollapsed ? ( - - ) : ( - - )} - - + {!readonly && ( + <> + + + + )} +
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 6415d319c180..abacb02e9387 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 @@ -9,6 +9,7 @@ import ObjectItemContainer from 'containers/annotation-page/standard-workspace/o import ObjectListHeader from './objects-list-header'; interface Props { + readonly: boolean; listHeight: number; statesHidden: boolean; statesLocked: boolean; @@ -29,6 +30,7 @@ interface Props { function ObjectListComponent(props: Props): JSX.Element { const { + readonly, listHeight, statesHidden, statesLocked, @@ -50,6 +52,7 @@ function ObjectListComponent(props: Props): JSX.Element { return (
( ): DispatchToProps { }; } -function ObjectsSideBar(props: StateToProps & DispatchToProps): JSX.Element { - const { sidebarCollapsed, canvasInstance, collapseSidebar, updateTabContentHeight } = props; +function ObjectsSideBar(props: StateToProps & DispatchToProps & OwnProps): JSX.Element { + const { + sidebarCollapsed, canvasInstance, collapseSidebar, updateTabContentHeight, objectsList, + } = props; useEffect(() => { const alignTabHeight = (): void => { @@ -117,11 +123,14 @@ function ObjectsSideBar(props: StateToProps & DispatchToProps): JSX.Element { Objects} key='objects'> - + {objectsList} Labels} key='labels'> + Issues} key='issues'> + + {!sidebarCollapsed && } diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/states-ordering-selector.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/states-ordering-selector.tsx new file mode 100644 index 000000000000..7c92593e197d --- /dev/null +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/states-ordering-selector.tsx @@ -0,0 +1,42 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { Col } from 'antd/lib/grid'; +import Select from 'antd/lib/select'; +import Text from 'antd/lib/typography/Text'; + +import { StatesOrdering } from 'reducers/interfaces'; + +interface StatesOrderingSelectorComponentProps { + statesOrdering: StatesOrdering; + changeStatesOrdering(value: StatesOrdering): void; +} + +function StatesOrderingSelectorComponent(props: StatesOrderingSelectorComponentProps): JSX.Element { + const { statesOrdering, changeStatesOrdering } = props; + + return ( + + Sort by + + + ); +} + +export default React.memo(StatesOrderingSelectorComponent); 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 06f6e86e38ad..188257d2c68c 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 @@ -68,6 +68,55 @@ } } +.cvat-objects-sidebar-issues-list-header { + background: $objects-bar-tabs-color; + padding: $grid-unit-size; + height: $grid-unit-size * 4; + box-sizing: border-box; + + > div > div { + > i { + font-size: 16px; + color: $objects-bar-icons-color; + + &:hover { + transform: scale(1.1); + opacity: 0.8; + } + + &:active { + transform: scale(1); + opacity: 0.7; + } + } + } +} + +.cvat-objects-sidebar-issues-list { + background-color: $background-color-2; + height: calc(100% - 32px); + overflow-y: auto; + overflow-x: hidden; +} + +.cvat-objects-sidebar-issue-item { + width: 100%; + margin: 1px; + padding: 2px; + + &:hover { + padding: 0; + + > .ant-alert { + border-width: 3px; + } + } + + > .ant-alert.ant-alert-with-description { + padding: $grid-unit-size $grid-unit-size $grid-unit-size $grid-unit-size * 8; + } +} + .cvat-objects-sidebar-states-header { background: $objects-bar-tabs-color; padding: 5px; @@ -78,7 +127,7 @@ } > div:nth-child(2) { - margin-top: 5px; + margin-top: $grid-unit-size; > div { text-align: center; @@ -88,11 +137,11 @@ @extend .cvat-object-sidebar-icon; } - &:nth-child(4) { + &:last-child { text-align: right; - > .ant-select { - margin-left: 5px; + > .cvat-objects-sidebar-ordering-selector { + margin-left: $grid-unit-size; width: 60%; } } @@ -272,6 +321,12 @@ } } +.cvat-context-menu-item.ant-menu-item { + &:hover { + background: $hover-menu-color; + } +} + .cvat-object-item-menu { > li { padding: 0; @@ -289,3 +344,9 @@ .cvat-label-color-picker .sketch-picker { box-shadow: unset !important; } + +.cvat-states-ordering-selector { + :first-child { + margin-right: $grid-unit-size; + } +} 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 d57be1ec2249..75adab1514d7 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 @@ -6,22 +6,25 @@ import './styles.scss'; import React from 'react'; import Layout from 'antd/lib/layout'; -import CanvasWrapperContainer from 'containers/annotation-page/standard-workspace/canvas-wrapper'; +import CanvasWrapperContainer from 'containers/annotation-page/canvas/canvas-wrapper'; import ControlsSideBarContainer from 'containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar'; import PropagateConfirmContainer from 'containers/annotation-page/standard-workspace/propagate-confirm'; -import CanvasContextMenuContainer from 'containers/annotation-page/standard-workspace/canvas-context-menu'; +import CanvasContextMenuContainer from 'containers/annotation-page/canvas/canvas-context-menu'; +import ObjectsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-list'; import ObjectSideBarComponent from 'components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar'; -import CanvasPointContextMenuComponent from 'components/annotation-page/standard-workspace/canvas-point-context-menu'; +import CanvasPointContextMenuComponent from 'components/annotation-page/canvas/canvas-point-context-menu'; +import IssueAggregatorComponent from 'components/annotation-page/review/issues-aggregator'; export default function StandardWorkspaceComponent(): JSX.Element { return ( - + } /> + ); } diff --git a/cvat-ui/src/components/annotation-page/styles.scss b/cvat-ui/src/components/annotation-page/styles.scss index 6836cfac11ab..731e71e57692 100644 --- a/cvat-ui/src/components/annotation-page/styles.scss +++ b/cvat-ui/src/components/annotation-page/styles.scss @@ -160,15 +160,6 @@ } > div:nth-child(1) { - > div { - > .ant-select, - i { - margin-left: 10px; - } - } - } - - > div:nth-child(2) { > div { > span { font-size: 20px; @@ -176,7 +167,7 @@ } } - > div:nth-child(3) { + > div:nth-child(2) { > div { display: grid; } @@ -204,7 +195,7 @@ } .ant-menu.cvat-annotation-menu { - box-shadow: 0 0 17px rgba(0, 0, 0, 0.2); + box-shadow: $box-shadow-base; > li:hover { background-color: $hover-menu-color; @@ -317,3 +308,28 @@ transform: scale(1.8); } } + +.cvat-request-review-dialog { + > .ant-modal-content > .ant-modal-body { + > div:nth-child(2) { + margin-top: $grid-unit-size * 2; + } + + > div:nth-child(3) { + margin-top: $grid-unit-size * 2; + } + } +} + +.cvat-submit-review-dialog { + > .ant-modal-content > .ant-modal-body { + > div:nth-child(2) > div:nth-child(2) { + .ant-col { + > div:nth-child(2) { + margin-top: $grid-unit-size * 2; + margin-bottom: $grid-unit-size * 2; + } + } + } + } +} diff --git a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-workspace.tsx b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-workspace.tsx index b08d12388741..a3bb39070807 100644 --- a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-workspace.tsx +++ b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-workspace.tsx @@ -6,7 +6,7 @@ import './styles.scss'; import React from 'react'; import Layout from 'antd/lib/layout'; -import CanvasWrapperContainer from 'containers/annotation-page/standard-workspace/canvas-wrapper'; +import CanvasWrapperContainer from 'containers/annotation-page/canvas/canvas-wrapper'; import TagAnnotationSidebar from './tag-annotation-sidebar/tag-annotation-sidebar'; export default function TagAnnotationWorkspace(): JSX.Element { diff --git a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx index 1866bd350631..9818bbcb282a 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx @@ -17,8 +17,11 @@ interface Props { loadActivity: string | null; dumpActivities: string[] | null; exportActivities: string[] | null; - taskID: number; + isReviewer: boolean; + jobInstance: any; onClickMenu(params: ClickParam, file?: File): void; + setForceExitAnnotationFlag(forceExit: boolean): void; + saveAnnotations(jobInstance: any, afterSave?: () => void): void; } export enum Actions { @@ -27,10 +30,29 @@ export enum Actions { EXPORT_TASK_DATASET = 'export_task_dataset', REMOVE_ANNO = 'remove_anno', OPEN_TASK = 'open_task', + REQUEST_REVIEW = 'request_review', + SUBMIT_REVIEW = 'submit_review', + FINISH_JOB = 'finish_job', + RENEW_JOB = 'renew_job', } export default function AnnotationMenuComponent(props: Props): JSX.Element { - const { taskMode, loaders, dumpers, onClickMenu, loadActivity, dumpActivities, exportActivities, taskID } = props; + const { + taskMode, + loaders, + dumpers, + loadActivity, + dumpActivities, + exportActivities, + isReviewer, + jobInstance, + onClickMenu, + setForceExitAnnotationFlag, + saveAnnotations, + } = props; + + const jobStatus = jobInstance.status; + const taskID = jobInstance.task.id; let latestParams: ClickParam | null = null; function onClickMenuWrapper(params: ClickParam | null, file?: File): void { @@ -40,6 +62,33 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element { } latestParams = params; + function checkUnsavedChanges(_copyParams: ClickParam): void { + if (jobInstance.annotations.hasUnsavedChanges()) { + Modal.confirm({ + title: 'The job has unsaved annotations', + content: 'Would you like to save changes before continue?', + okButtonProps: { + children: 'Save', + }, + cancelButtonProps: { + children: 'No', + }, + onOk: () => { + saveAnnotations(jobInstance, () => onClickMenu(_copyParams)); + }, + onCancel: () => { + // do not ask leave confirmation + setForceExitAnnotationFlag(true); + setTimeout(() => { + onClickMenu(_copyParams); + }); + }, + }); + } else { + onClickMenu(_copyParams); + } + } + if (copyParams.keyPath.length === 2) { const [, action] = copyParams.keyPath; if (action === Actions.LOAD_JOB_ANNO) { @@ -61,10 +110,10 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element { } } else if (copyParams.key === Actions.REMOVE_ANNO) { Modal.confirm({ - title: 'All annotations will be removed', + title: 'All the annotations will be removed', content: - 'You are going to remove all annotations from the client. ' + - 'It will stay on the server till you save a job. Continue?', + 'You are going to remove all the annotations from the client. ' + + 'It will stay on the server till you save the job. Continue?', onOk: () => { onClickMenu(copyParams); }, @@ -73,6 +122,28 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element { }, okText: 'Delete', }); + } else if ([Actions.REQUEST_REVIEW].includes(copyParams.key as Actions)) { + checkUnsavedChanges(copyParams); + } else if (copyParams.key === Actions.FINISH_JOB) { + Modal.confirm({ + title: 'The job status is going to be switched', + content: 'Status will be changed to "completed". Would you like to continue?', + okText: 'Continue', + cancelText: 'Cancel', + onOk: () => { + checkUnsavedChanges(copyParams); + }, + }); + } else if (copyParams.key === Actions.RENEW_JOB) { + Modal.confirm({ + title: 'The job status is going to be switched', + content: 'Status will be changed to "annotations". Would you like to continue?', + okText: 'Continue', + cancelText: 'Cancel', + onOk: () => { + onClickMenu(copyParams); + }, + }); } else { onClickMenu(copyParams); } @@ -106,6 +177,12 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element { Open the task + {jobStatus === 'annotation' && Request a review} + {jobStatus === 'annotation' && Finish the job} + {jobStatus === 'validation' && isReviewer && ( + Submit the review + )} + {jobStatus === 'completed' && Renew the job} ); } 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 index 16f85d4dc0bf..fe8b9131e936 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/statistics-modal.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/statistics-modal.tsx @@ -5,7 +5,6 @@ import React from 'react'; import { Row, Col } from 'antd/lib/grid'; import Tooltip from 'antd/lib/tooltip'; -import Select from 'antd/lib/select'; import Table from 'antd/lib/table'; import Modal from 'antd/lib/modal'; import Spin from 'antd/lib/spin'; @@ -17,28 +16,18 @@ interface Props { data: any; visible: boolean; assignee: string; + reviewer: string; startFrame: number; stopFrame: number; 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, - bugTracker, - closeStatistics, - changeJobStatus, - savingJobStatus, + collecting, data, visible, assignee, reviewer, startFrame, stopFrame, bugTracker, closeStatistics, } = props; const baseProps = { @@ -144,50 +133,37 @@ export default function StatisticsModalComponent(props: Props): JSX.Element { return (
- - - - Job status - - - {savingJobStatus && } - - Overview - + Assignee {assignee} - + + + Reviewer + + {reviewer} + + Start frame {startFrame} - + Stop frame {stopFrame} - + Frames diff --git a/cvat-ui/src/components/task-page/job-list.tsx b/cvat-ui/src/components/task-page/job-list.tsx index 2b10e2256f63..15a5b8e91bb6 100644 --- a/cvat-ui/src/components/task-page/job-list.tsx +++ b/cvat-ui/src/components/task-page/job-list.tsx @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router'; import { withRouter } from 'react-router-dom'; import { Row, Col } from 'antd/lib/grid'; @@ -26,6 +26,72 @@ interface Props { onJobUpdate(jobInstance: any): void; } +function ReviewSummaryComponent({ jobInstance }: { jobInstance: any }): JSX.Element { + const [summary, setSummary] = useState | null>(null); + const [error, setError] = useState(null); + useEffect(() => { + setError(null); + jobInstance + .reviewsSummary() + .then((_summary: Record) => { + setSummary(_summary); + }) + .catch((_error: any) => { + // eslint-disable-next-line + console.log(_error); + setError(_error); + }); + }, []); + + if (!summary) { + if (error) { + if (error.toString().includes('403')) { + return

You do not have permissions

; + } + + return

Could not fetch, check console output

; + } + + return ( + <> +

Loading..

+ + + ); + } + + return ( + + + + + + + + + + + + + + + + + + + +
+ Reviews + {summary.reviews}
+ Average quality + {Number.parseFloat(summary.average_estimated_quality).toFixed(2)}
+ Unsolved issues + {summary.issues_unsolved}
+ Resolved issues + {summary.issues_resolved}
+ ); +} + function JobListComponent(props: Props & RouteComponentProps): JSX.Element { const { taskInstance, @@ -64,7 +130,9 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element { title: 'Status', dataIndex: 'status', key: 'status', - render: (status: string): JSX.Element => { + className: 'cvat-job-item-status', + render: (jobInstance: any): JSX.Element => { + const { status } = jobInstance; let progressColor = null; if (status === 'completed') { progressColor = 'cvat-job-completed-color'; @@ -77,6 +145,9 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element { return ( {status} + }> + + ); }, @@ -97,20 +168,33 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element { title: 'Assignee', dataIndex: 'assignee', key: 'assignee', - render: (jobInstance: any): JSX.Element => { - const assignee = jobInstance.assignee ? jobInstance.assignee : null; - - return ( - { - // eslint-disable-next-line - jobInstance.assignee = value; - onJobUpdate(jobInstance); - }} - /> - ); - }, + render: (jobInstance: any): JSX.Element => ( + { + // eslint-disable-next-line + jobInstance.assignee = value; + onJobUpdate(jobInstance); + }} + /> + ), + }, + { + title: 'Reviewer', + dataIndex: 'reviewer', + key: 'reviewer', + render: (jobInstance: any): JSX.Element => ( + { + // eslint-disable-next-line + jobInstance.reviewer = value; + onJobUpdate(jobInstance); + }} + /> + ), }, ]; @@ -126,10 +210,11 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element { key: job.id, job: job.id, frames: `${job.startFrame}-${job.stopFrame}`, - status: `${job.status}`, + status: job, started: `${created.format('MMMM Do YYYY HH:MM')}`, duration: `${moment.duration(moment(moment.now()).diff(created)).humanize()}`, assignee: job, + reviewer: job, }); return acc; diff --git a/cvat-ui/src/components/task-page/styles.scss b/cvat-ui/src/components/task-page/styles.scss index 66a7158959ad..bf5d364a6902 100644 --- a/cvat-ui/src/components/task-page/styles.scss +++ b/cvat-ui/src/components/task-page/styles.scss @@ -111,6 +111,26 @@ } } +.cvat-job-item-status { + i { + margin-left: $grid-unit-size; + } +} + +.cvat-review-summary-description { + color: white; + + .ant-typography { + color: white; + } + + tr { + > td:nth-child(2) { + padding-left: $grid-unit-size; + } + } +} + .cvat-job-completed-color { color: $completed-progress-color; } diff --git a/cvat-ui/src/components/task-page/user-selector.tsx b/cvat-ui/src/components/task-page/user-selector.tsx index f5fdcf0b8efa..5a4211f55f8b 100644 --- a/cvat-ui/src/components/task-page/user-selector.tsx +++ b/cvat-ui/src/components/task-page/user-selector.tsx @@ -20,6 +20,7 @@ export interface User { interface Props { value: User | null; + className?: string; onSelect: (user: User | null) => void; } @@ -43,7 +44,7 @@ const searchUsers = debounce( ); export default function UserSelector(props: Props): JSX.Element { - const { value, onSelect } = props; + const { value, className, onSelect } = props; const [searchPhrase, setSearchPhrase] = useState(''); const [users, setUsers] = useState([]); @@ -89,6 +90,7 @@ export default function UserSelector(props: Props): JSX.Element { } }, [value]); + const combinedClassName = className ? `${className} cvat-user-search-field` : 'cvat-user-search-field'; return ( ({ value: user.id.toString(), diff --git a/cvat-ui/src/consts.ts b/cvat-ui/src/consts.ts index 3a54116adff9..5dc30952eed0 100644 --- a/cvat-ui/src/consts.ts +++ b/cvat-ui/src/consts.ts @@ -18,6 +18,9 @@ const NUCLIO_GUIDE = 'https://github.com/openvinotoolkit/cvat/blob/develop/cvat/apps/documentation/installation.md#semi-automatic-and-automatic-annotation'; const CANVAS_BACKGROUND_COLORS = ['#ffffff', '#f1f1f1', '#e5e5e5', '#d8d8d8', '#CCCCCC', '#B3B3B3', '#999999']; const NEW_LABEL_COLOR = '#b3b3b3'; +const LATEST_COMMENTS_SHOWN_QUICK_ISSUE = 3; +const QUICK_ISSUE_INCORRECT_POSITION_TEXT = 'Wrong position'; +const QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT = 'Wrong attribute'; export default { UNDEFINED_ATTRIBUTE_VALUE, @@ -33,4 +36,7 @@ export default { CANVAS_BACKGROUND_COLORS, NEW_LABEL_COLOR, NUCLIO_GUIDE, + LATEST_COMMENTS_SHOWN_QUICK_ISSUE, + QUICK_ISSUE_INCORRECT_POSITION_TEXT, + QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT, }; diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-context-menu.tsx b/cvat-ui/src/containers/annotation-page/canvas/canvas-context-menu.tsx similarity index 68% rename from cvat-ui/src/containers/annotation-page/standard-workspace/canvas-context-menu.tsx rename to cvat-ui/src/containers/annotation-page/canvas/canvas-context-menu.tsx index c172abb58809..b25b297c6cc9 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-context-menu.tsx +++ b/cvat-ui/src/containers/annotation-page/canvas/canvas-context-menu.tsx @@ -5,49 +5,81 @@ import React from 'react'; import { connect } from 'react-redux'; -import { CombinedState, ContextMenuType } from 'reducers/interfaces'; +import { CombinedState, ContextMenuType, Workspace } from 'reducers/interfaces'; -import CanvasContextMenuComponent from 'components/annotation-page/standard-workspace/canvas-context-menu'; +import CanvasContextMenuComponent from 'components/annotation-page/canvas/canvas-context-menu'; +import { updateCanvasContextMenu } from 'actions/annotation-actions'; +import { reviewActions, finishIssueAsync } from 'actions/review-actions'; +import { ThunkDispatch } from 'utils/redux'; + +interface OwnProps { + readonly: boolean; +} interface StateToProps { - activatedStateID: number | null; + contextMenuClientID: number | null; objectStates: any[]; visible: boolean; top: number; left: number; type: ContextMenuType; collapsed: boolean | undefined; + workspace: Workspace; + latestComments: string[]; +} + +interface DispatchToProps { + onStartIssue(position: number[]): void; + openIssue(position: number[], message: string): void; } function mapStateToProps(state: CombinedState): StateToProps { const { annotation: { - annotations: { activatedStateID, collapsed, states: objectStates }, + annotations: { collapsed, states: objectStates }, canvas: { contextMenu: { - visible, top, left, type, + visible, top, left, type, clientID, }, ready, }, + workspace, }, + review: { latestComments }, } = state; return { - activatedStateID, - collapsed: activatedStateID !== null ? collapsed[activatedStateID] : undefined, + contextMenuClientID: clientID, + collapsed: clientID !== null ? collapsed[clientID] : undefined, objectStates, visible: - activatedStateID !== null && + clientID !== null && visible && ready && - objectStates.map((_state: any): number => _state.clientID).includes(activatedStateID), + objectStates.map((_state: any): number => _state.clientID).includes(clientID), left, top, type, + workspace, + latestComments, + }; +} + +function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps { + return { + onStartIssue(position: number[]): void { + dispatch(reviewActions.startIssue(position)); + dispatch(updateCanvasContextMenu(false, 0, 0)); + }, + openIssue(position: number[], message: string): void { + dispatch(reviewActions.startIssue(position)); + dispatch(finishIssueAsync(message)); + dispatch(updateCanvasContextMenu(false, 0, 0)); + }, }; } -type Props = StateToProps; +type Props = StateToProps & DispatchToProps & OwnProps; interface State { latestLeft: number; @@ -57,12 +89,13 @@ interface State { } class CanvasContextMenuContainer extends React.PureComponent { - private initialized: HTMLDivElement | null; + static defaultProps = { + readonly: false, + }; + private initialized: HTMLDivElement | null; private dragging: boolean; - private dragInitPosX: number; - private dragInitPosY: number; public constructor(props: Props) { @@ -154,7 +187,6 @@ class CanvasContextMenuContainer extends React.PureComponent { private updatePositionIfOutOfScreen(): void { const { top, left } = this.state; - const { innerWidth, innerHeight } = window; const [element] = window.document.getElementsByClassName('cvat-canvas-context-menu'); @@ -174,18 +206,31 @@ class CanvasContextMenuContainer extends React.PureComponent { public render(): JSX.Element { const { left, top } = this.state; const { - visible, activatedStateID, objectStates, type, + visible, + contextMenuClientID, + objectStates, + type, + readonly, + workspace, + latestComments, + onStartIssue, + openIssue, } = this.props; return ( <> {type === ContextMenuType.CANVAS_SHAPE && ( )} @@ -193,4 +238,4 @@ class CanvasContextMenuContainer extends React.PureComponent { } } -export default connect(mapStateToProps)(CanvasContextMenuContainer); +export default connect(mapStateToProps, mapDispatchToProps)(CanvasContextMenuContainer); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/containers/annotation-page/canvas/canvas-wrapper.tsx similarity index 92% rename from cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx rename to cvat-ui/src/containers/annotation-page/canvas/canvas-wrapper.tsx index 817ef4be831a..5bbb191c925d 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/containers/annotation-page/canvas/canvas-wrapper.tsx @@ -5,7 +5,7 @@ import { ExtendedKeyMapOptions } from 'react-hotkeys'; import { connect } from 'react-redux'; -import CanvasWrapperComponent from 'components/annotation-page/standard-workspace/canvas-wrapper'; +import CanvasWrapperComponent from 'components/annotation-page/canvas/canvas-wrapper'; import { confirmCanvasReady, dragCanvas, @@ -37,6 +37,7 @@ import { changeSaturationLevel, switchAutomaticBordering, } from 'actions/settings-actions'; +import { reviewActions } from 'actions/review-actions'; import { ColorBy, GridColor, @@ -57,6 +58,7 @@ interface StateToProps { activatedAttributeID: number | null; selectedStatesID: number[]; annotations: any[]; + frameIssues: any[] | null; frameData: any; frameAngle: number; frameFetching: boolean; @@ -119,6 +121,7 @@ interface DispatchToProps { onSwitchGrid(enabled: boolean): void; onSwitchAutomaticBordering(enabled: boolean): void; onFetchAnnotation(): void; + onStartIssue(position: number[]): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -153,9 +156,14 @@ function mapStateToProps(state: CombinedState): StateToProps { saturationLevel, resetZoom, }, - workspace: { aamZoomMargin, showObjectsTextAlways, showAllInterpolationTracks, automaticBordering }, - shapes: { opacity, colorBy, selectedOpacity, outlined, outlineColor, showBitmap, showProjections }, + workspace: { + aamZoomMargin, showObjectsTextAlways, showAllInterpolationTracks, automaticBordering, + }, + shapes: { + opacity, colorBy, selectedOpacity, outlined, outlineColor, showBitmap, showProjections, + }, }, + review: { frameIssues, issuesHidden }, shortcuts: { keyMap }, } = state; @@ -163,6 +171,8 @@ function mapStateToProps(state: CombinedState): StateToProps { sidebarCollapsed, canvasInstance, jobInstance, + frameIssues: + issuesHidden || ![Workspace.REVIEW_WORKSPACE, Workspace.STANDARD].includes(workspace) ? null : frameIssues, frameData, frameAngle: frameAngles[frame - jobInstance.startFrame], frameFetching, @@ -298,6 +308,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onFetchAnnotation(): void { dispatch(fetchAnnotationsAsync()); }, + onStartIssue(position: number[]): void { + dispatch(reviewActions.startIssue(position)); + }, }; } diff --git a/cvat-ui/src/containers/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/containers/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx new file mode 100644 index 000000000000..03dc9cb1fb6c --- /dev/null +++ b/cvat-ui/src/containers/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx @@ -0,0 +1,95 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { ExtendedKeyMapOptions } from 'react-hotkeys'; +import { connect } from 'react-redux'; + +import { Canvas } from 'cvat-canvas-wrapper'; +import { + selectIssuePosition as selectIssuePositionAction, + mergeObjects, + groupObjects, + splitTrack, + redrawShapeAsync, + rotateCurrentFrame, + repeatDrawShapeAsync, + pasteShapeAsync, + resetAnnotationsGroup, +} from 'actions/annotation-actions'; +import ControlsSideBarComponent from 'components/annotation-page/review-workspace/controls-side-bar/controls-side-bar'; +import { ActiveControl, CombinedState, Rotation } from 'reducers/interfaces'; + +interface StateToProps { + canvasInstance: Canvas; + rotateAll: boolean; + activeControl: ActiveControl; + keyMap: Record; + normalizedKeyMap: Record; +} + +interface DispatchToProps { + mergeObjects(enabled: boolean): void; + groupObjects(enabled: boolean): void; + splitTrack(enabled: boolean): void; + rotateFrame(angle: Rotation): void; + selectIssuePosition(enabled: boolean): void; + resetGroup(): void; + repeatDrawShape(): void; + pasteShape(): void; + redrawShape(): void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { + annotation: { + canvas: { instance: canvasInstance, activeControl }, + }, + settings: { + player: { rotateAll }, + }, + shortcuts: { keyMap, normalizedKeyMap }, + } = state; + + return { + rotateAll, + canvasInstance, + activeControl, + normalizedKeyMap, + keyMap, + }; +} + +function dispatchToProps(dispatch: any): DispatchToProps { + return { + mergeObjects(enabled: boolean): void { + dispatch(mergeObjects(enabled)); + }, + groupObjects(enabled: boolean): void { + dispatch(groupObjects(enabled)); + }, + splitTrack(enabled: boolean): void { + dispatch(splitTrack(enabled)); + }, + selectIssuePosition(enabled: boolean): void { + dispatch(selectIssuePositionAction(enabled)); + }, + rotateFrame(rotation: Rotation): void { + dispatch(rotateCurrentFrame(rotation)); + }, + repeatDrawShape(): void { + dispatch(repeatDrawShapeAsync()); + }, + pasteShape(): void { + dispatch(pasteShapeAsync()); + }, + resetGroup(): void { + dispatch(resetAnnotationsGroup()); + }, + redrawShape(): void { + dispatch(redrawShapeAsync()); + }, + }; +} + +export default connect(mapStateToProps, dispatchToProps)(ControlsSideBarComponent); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-buttons.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-buttons.tsx index ae839c4dd307..cdb5960e5ed6 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-buttons.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-buttons.tsx @@ -13,6 +13,7 @@ import { CombinedState } from 'reducers/interfaces'; import ItemButtonsComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item-buttons'; interface OwnProps { + readonly: boolean; clientID: number; outsideDisabled?: boolean; hiddenDisabled?: boolean; @@ -48,7 +49,9 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { shortcuts: { normalizedKeyMap }, } = state; - const { clientID, outsideDisabled, hiddenDisabled, keyframeDisabled } = own; + const { + clientID, outsideDisabled, hiddenDisabled, keyframeDisabled, + } = own; const [objectState] = states.filter((_objectState): boolean => _objectState.clientID === clientID); return { @@ -74,7 +77,7 @@ function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps { }; } -class ItemButtonsWrapper extends React.PureComponent { +class ItemButtonsWrapper extends React.PureComponent { private navigateFirstKeyframe = (): void => { const { objectState, frameNumber } = this.props; const { first } = objectState.keyframes; @@ -108,83 +111,109 @@ class ItemButtonsWrapper extends React.PureComponent { - const { objectState, jobInstance } = this.props; - jobInstance.logger.log(LogType.lockObject, { locked: true }); - objectState.lock = true; - this.commit(); + const { objectState, jobInstance, readonly } = this.props; + if (!readonly) { + jobInstance.logger.log(LogType.lockObject, { locked: true }); + objectState.lock = true; + this.commit(); + } }; private unlock = (): void => { - const { objectState, jobInstance } = this.props; - jobInstance.logger.log(LogType.lockObject, { locked: false }); - objectState.lock = false; - this.commit(); + const { objectState, jobInstance, readonly } = this.props; + if (!readonly) { + jobInstance.logger.log(LogType.lockObject, { locked: false }); + objectState.lock = false; + this.commit(); + } }; private pin = (): void => { - const { objectState } = this.props; - objectState.pinned = true; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.pinned = true; + this.commit(); + } }; private unpin = (): void => { - const { objectState } = this.props; - objectState.pinned = false; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.pinned = false; + this.commit(); + } }; private show = (): void => { - const { objectState } = this.props; - objectState.hidden = false; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.hidden = false; + this.commit(); + } }; private hide = (): void => { - const { objectState } = this.props; - objectState.hidden = true; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.hidden = true; + this.commit(); + } }; private setOccluded = (): void => { - const { objectState } = this.props; - objectState.occluded = true; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.occluded = true; + this.commit(); + } }; private unsetOccluded = (): void => { - const { objectState } = this.props; - objectState.occluded = false; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.occluded = false; + this.commit(); + } }; private setOutside = (): void => { - const { objectState } = this.props; - objectState.outside = true; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.outside = true; + this.commit(); + } }; private unsetOutside = (): void => { - const { objectState } = this.props; - objectState.outside = false; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.outside = false; + this.commit(); + } }; private setKeyframe = (): void => { - const { objectState } = this.props; - objectState.keyframe = true; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.keyframe = true; + this.commit(); + } }; private unsetKeyframe = (): void => { - const { objectState } = this.props; - objectState.keyframe = false; - this.commit(); + const { objectState, readonly } = this.props; + if (!readonly) { + objectState.keyframe = false; + this.commit(); + } }; private commit(): void { - const { objectState, updateAnnotations } = this.props; + const { objectState, readonly, updateAnnotations } = this.props; - updateAnnotations([objectState]); + if (!readonly) { + updateAnnotations([objectState]); + } } private changeFrame(frame: number): void { @@ -197,14 +226,17 @@ class ItemButtonsWrapper extends React.PureComponent { private copy = (): void => { - const { objectState, copyShape } = this.props; - copyShape(objectState); + const { objectState, readonly, copyShape } = this.props; + if (!readonly) { + copyShape(objectState); + } }; private propagate = (): void => { - const { objectState, propagateObject } = this.props; - propagateObject(objectState); + const { objectState, readonly, propagateObject } = this.props; + if (!readonly) { + propagateObject(objectState); + } }; private remove = (): void => { - const { objectState, removeObject, jobInstance } = this.props; + const { + objectState, jobInstance, readonly, removeObject, + } = this.props; - removeObject(jobInstance, objectState); + if (!readonly) { + removeObject(jobInstance, objectState); + } }; private createURL = (): void => { const { objectState, frameNumber } = this.props; - const { origin, pathname } = window.location; const search = `frame=${frameNumber}&type=${objectState.objectType}&serverID=${objectState.serverID}`; @@ -165,7 +172,11 @@ class ObjectItemContainer extends React.PureComponent { }; private switchOrientation = (): void => { - const { objectState, updateState } = this.props; + const { objectState, readonly, updateState } = this.props; + if (readonly) { + return; + } + if (objectState.shapeType === ShapeType.CUBOID) { this.switchCuboidOrientation(); return; @@ -192,22 +203,26 @@ class ObjectItemContainer extends React.PureComponent { }; private toBackground = (): void => { - const { objectState, minZLayer } = this.props; + const { objectState, readonly, minZLayer } = this.props; - objectState.zOrder = minZLayer - 1; - this.commit(); + if (!readonly) { + objectState.zOrder = minZLayer - 1; + this.commit(); + } }; private toForeground = (): void => { - const { objectState, maxZLayer } = this.props; + const { objectState, readonly, maxZLayer } = this.props; - objectState.zOrder = maxZLayer + 1; - this.commit(); + if (!readonly) { + objectState.zOrder = maxZLayer + 1; + this.commit(); + } }; private activate = (): void => { const { - activateObject, objectState, ready, activeControl, + objectState, ready, activeControl, activateObject, } = this.props; if (ready && activeControl === ActiveControl.CURSOR) { @@ -222,8 +237,8 @@ class ObjectItemContainer extends React.PureComponent { }; private activateTracking = (): void => { - const { objectState, aiToolsRef } = this.props; - if (aiToolsRef.current && aiToolsRef.current.trackingAvailable()) { + const { objectState, readonly, aiToolsRef } = this.props; + if (!readonly && aiToolsRef.current && aiToolsRef.current.trackingAvailable()) { aiToolsRef.current.trackState(objectState); } }; @@ -240,24 +255,29 @@ class ObjectItemContainer extends React.PureComponent { }; private changeLabel = (labelID: string): void => { - const { objectState, labels } = this.props; + const { objectState, readonly, labels } = this.props; + + if (!readonly) { + const [label] = labels.filter((_label: any): boolean => _label.id === +labelID); + objectState.label = label; + } - const [label] = labels.filter((_label: any): boolean => _label.id === +labelID); - objectState.label = label; this.commit(); }; private changeAttribute = (id: number, value: string): void => { - const { objectState, jobInstance } = this.props; - jobInstance.logger.log(LogType.changeAttribute, { - id, - value, - object_id: objectState.clientID, - }); - const attr: Record = {}; - attr[id] = value; - objectState.attributes = attr; - this.commit(); + const { objectState, readonly, jobInstance } = this.props; + if (!readonly) { + jobInstance.logger.log(LogType.changeAttribute, { + id, + value, + object_id: objectState.clientID, + }); + const attr: Record = {}; + attr[id] = value; + objectState.attributes = attr; + this.commit(); + } }; private switchCuboidOrientation = (): void => { @@ -265,56 +285,67 @@ class ObjectItemContainer extends React.PureComponent { return points[12] > points[0]; } - const { objectState } = this.props; + const { objectState, readonly } = this.props; - this.resetCuboidPerspective(false); + if (!readonly) { + this.resetCuboidPerspective(false); + objectState.points = shift(objectState.points, cuboidOrientationIsLeft(objectState.points) ? 4 : -4); - objectState.points = shift(objectState.points, cuboidOrientationIsLeft(objectState.points) ? 4 : -4); - - this.commit(); + this.commit(); + } }; private resetCuboidPerspective = (commit = true): void => { function cuboidOrientationIsLeft(points: number[]): boolean { return points[12] > points[0]; } - - const { objectState } = this.props; - const { points } = objectState; - const minD = { - x: (points[6] - points[2]) * 0.001, - y: (points[3] - points[1]) * 0.001, - }; - - if (cuboidOrientationIsLeft(points)) { - points[14] = points[10] + points[2] - points[6] + minD.x; - points[15] = points[11] + points[3] - points[7]; - points[8] = points[10] + points[4] - points[6]; - points[9] = points[11] + points[5] - points[7] + minD.y; - points[12] = points[14] + points[0] - points[2]; - points[13] = points[15] + points[1] - points[3] + minD.y; - } else { - points[10] = points[14] + points[6] - points[2] - minD.x; - points[11] = points[15] + points[7] - points[3]; - points[12] = points[14] + points[0] - points[2]; - points[13] = points[15] + points[1] - points[3] + minD.y; - points[8] = points[12] + points[4] - points[0] - minD.x; - points[9] = points[13] + points[5] - points[1]; + const { objectState, readonly } = this.props; + + if (!readonly) { + const { points } = objectState; + const minD = { + x: (points[6] - points[2]) * 0.001, + y: (points[3] - points[1]) * 0.001, + }; + + if (cuboidOrientationIsLeft(points)) { + points[14] = points[10] + points[2] - points[6] + minD.x; + points[15] = points[11] + points[3] - points[7]; + points[8] = points[10] + points[4] - points[6]; + points[9] = points[11] + points[5] - points[7] + minD.y; + points[12] = points[14] + points[0] - points[2]; + points[13] = points[15] + points[1] - points[3] + minD.y; + } else { + points[10] = points[14] + points[6] - points[2] - minD.x; + points[11] = points[15] + points[7] - points[3]; + points[12] = points[14] + points[0] - points[2]; + points[13] = points[15] + points[1] - points[3] + minD.y; + points[8] = points[12] + points[4] - points[0] - minD.x; + points[9] = points[13] + points[5] - points[1]; + } + + objectState.points = points; + if (commit) this.commit(); } - - objectState.points = points; - if (commit) this.commit(); }; private commit(): void { - const { objectState, updateState } = this.props; - - updateState(objectState); + const { objectState, readonly, updateState } = this.props; + if (!readonly) { + updateState(objectState); + } } public render(): JSX.Element { const { - objectState, collapsed, labels, attributes, activated, colorBy, normalizedKeyMap, + objectState, + collapsed, + labels, + attributes, + activated, + colorBy, + normalizedKeyMap, + readonly, } = this.props; let stateColor = ''; @@ -328,6 +359,7 @@ class ObjectItemContainer extends React.PureComponent { return ( state.clientID); } -type Props = StateToProps & DispatchToProps; +type Props = StateToProps & DispatchToProps & OwnProps; interface State { statesOrdering: StatesOrdering; @@ -159,6 +165,10 @@ interface State { } class ObjectsListContainer extends React.PureComponent { + static defaultProps = { + readonly: false, + }; + public constructor(props: Props) { super(props); this.state = { @@ -213,21 +223,27 @@ class ObjectsListContainer extends React.PureComponent { }; private lockAllStates(locked: boolean): void { - const { objectStates, updateAnnotations } = this.props; - for (const objectState of objectStates) { - objectState.lock = locked; - } + const { objectStates, updateAnnotations, readonly } = this.props; + + if (!readonly) { + for (const objectState of objectStates) { + objectState.lock = locked; + } - updateAnnotations(objectStates); + updateAnnotations(objectStates); + } } private hideAllStates(hidden: boolean): void { - const { objectStates, updateAnnotations } = this.props; - for (const objectState of objectStates) { - objectState.hidden = hidden; - } + const { objectStates, updateAnnotations, readonly } = this.props; + + if (!readonly) { + for (const objectState of objectStates) { + objectState.hidden = hidden; + } - updateAnnotations(objectStates); + updateAnnotations(objectStates); + } } private collapseAllStates(collapsed: boolean): void { @@ -242,12 +258,6 @@ class ObjectsListContainer extends React.PureComponent { statesLocked, activatedStateID, jobInstance, - updateAnnotations, - changeGroupColor, - removeObject, - copyShape, - propagateObject, - changeFrame, maxZLayer, minZLayer, keyMap, @@ -255,6 +265,15 @@ class ObjectsListContainer extends React.PureComponent { canvasInstance, colors, colorBy, + readonly, + listHeight, + statesCollapsedAll, + updateAnnotations, + changeGroupColor, + removeObject, + copyShape, + propagateObject, + changeFrame, } = this.props; const { objectStates, sortedStatesID, statesOrdering } = this.state; @@ -302,19 +321,21 @@ class ObjectsListContainer extends React.PureComponent { SWITCH_LOCK: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state) { + if (state && !readonly) { state.lock = !state.lock; updateAnnotations([state]); } }, SWITCH_ALL_HIDDEN: (event: KeyboardEvent | undefined) => { preventDefault(event); - this.hideAllStates(!statesHidden); + if (!readonly) { + this.hideAllStates(!statesHidden); + } }, SWITCH_HIDDEN: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state) { + if (state && !readonly) { state.hidden = !state.hidden; updateAnnotations([state]); } @@ -322,7 +343,7 @@ class ObjectsListContainer extends React.PureComponent { SWITCH_OCCLUDED: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state && state.objectType !== ObjectType.TAG) { + if (state && !readonly && state.objectType !== ObjectType.TAG) { state.occluded = !state.occluded; updateAnnotations([state]); } @@ -330,7 +351,7 @@ class ObjectsListContainer extends React.PureComponent { SWITCH_KEYFRAME: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state && state.objectType === ObjectType.TRACK) { + if (state && !readonly && state.objectType === ObjectType.TRACK) { state.keyframe = !state.keyframe; updateAnnotations([state]); } @@ -338,7 +359,7 @@ class ObjectsListContainer extends React.PureComponent { SWITCH_OUTSIDE: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state && state.objectType === ObjectType.TRACK) { + if (state && !readonly && state.objectType === ObjectType.TRACK) { state.outside = !state.outside; updateAnnotations([state]); } @@ -346,7 +367,7 @@ class ObjectsListContainer extends React.PureComponent { DELETE_OBJECT: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state) { + if (state && !readonly) { removeObject(jobInstance, state, event ? event.shiftKey : false); } }, @@ -370,7 +391,7 @@ class ObjectsListContainer extends React.PureComponent { TO_BACKGROUND: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state && state.objectType !== ObjectType.TAG) { + if (state && !readonly && state.objectType !== ObjectType.TAG) { state.zOrder = minZLayer - 1; updateAnnotations([state]); } @@ -378,7 +399,7 @@ class ObjectsListContainer extends React.PureComponent { TO_FOREGROUND: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state && state.objectType !== ObjectType.TAG) { + if (state && !readonly && state.objectType !== ObjectType.TAG) { state.zOrder = maxZLayer + 1; updateAnnotations([state]); } @@ -386,14 +407,14 @@ class ObjectsListContainer extends React.PureComponent { COPY_SHAPE: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state) { + if (state && !readonly) { copyShape(state); } }, PROPAGATE_OBJECT: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedStated(); - if (state) { + if (state && !readonly) { propagateObject(state); } }, @@ -423,7 +444,11 @@ class ObjectsListContainer extends React.PureComponent { <> void): void; + updateJob(jobInstance: any): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -39,6 +50,7 @@ function mapStateToProps(state: CombinedState): StateToProps { tasks: { activities: { dumps, loads, exports: activeExports }, }, + auth: { user }, } = state; const taskID = jobInstance.task.id; @@ -50,6 +62,7 @@ function mapStateToProps(state: CombinedState): StateToProps { loadActivity: taskID in loads || jobID in jobLoads ? loads[taskID] || jobLoads[jobID] : null, jobInstance, annotationFormats, + user, }; } @@ -67,6 +80,21 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { removeAnnotations(sessionInstance: any): void { dispatch(removeAnnotationsAsync(sessionInstance)); }, + switchRequestReviewDialog(visible: boolean): void { + dispatch(switchRequestReviewDialogAction(visible)); + }, + switchSubmitReviewDialog(visible: boolean): void { + dispatch(switchSubmitReviewDialogAction(visible)); + }, + setForceExitAnnotationFlag(forceExit: boolean): void { + dispatch(setForceExitAnnotationFlagAction(forceExit)); + }, + saveAnnotations(jobInstance: any, afterSave?: () => void): void { + dispatch(saveAnnotationsAsync(jobInstance, afterSave)); + }, + updateJob(jobInstance: any): void { + dispatch(updateJobAsync(jobInstance)); + }, }; } @@ -75,15 +103,21 @@ type Props = StateToProps & DispatchToProps & RouteComponentProps; function AnnotationMenuContainer(props: Props): JSX.Element { const { jobInstance, + user, annotationFormats: { loaders, dumpers }, - loadAnnotations, - dumpAnnotations, - exportDataset, - removeAnnotations, history, loadActivity, dumpActivities, exportActivities, + loadAnnotations, + dumpAnnotations, + exportDataset, + removeAnnotations, + switchRequestReviewDialog, + switchSubmitReviewDialog, + setForceExitAnnotationFlag, + saveAnnotations, + updateJob, } = props; const onClickMenu = (params: ClickParam, file?: File): void => { @@ -112,12 +146,26 @@ function AnnotationMenuContainer(props: Props): JSX.Element { const [action] = params.keyPath; if (action === Actions.REMOVE_ANNO) { removeAnnotations(jobInstance); + } else if (action === Actions.REQUEST_REVIEW) { + switchRequestReviewDialog(true); + } else if (action === Actions.SUBMIT_REVIEW) { + switchSubmitReviewDialog(true); + } else if (action === Actions.RENEW_JOB) { + jobInstance.status = TaskStatus.ANNOTATION; + updateJob(jobInstance); + history.push(`/tasks/${jobInstance.task.id}`); + } else if (action === Actions.FINISH_JOB) { + jobInstance.status = TaskStatus.COMPLETED; + updateJob(jobInstance); + history.push(`/tasks/${jobInstance.task.id}`); } else if (action === Actions.OPEN_TASK) { history.push(`/tasks/${jobInstance.task.id}`); } } }; + const isReviewer = jobInstance.reviewer?.id === user.id || user.isSuperuser; + return ( ); } 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 index d1fc52a5a39e..18fba67f4c64 100644 --- a/cvat-ui/src/containers/annotation-page/top-bar/statistics-modal.tsx +++ b/cvat-ui/src/containers/annotation-page/top-bar/statistics-modal.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { CombinedState } from 'reducers/interfaces'; -import { showStatistics, changeJobStatusAsync } from 'actions/annotation-actions'; +import { showStatistics } from 'actions/annotation-actions'; import StatisticsModalComponent from 'components/annotation-page/top-bar/statistics-modal'; interface StateToProps { @@ -18,7 +18,6 @@ interface StateToProps { } interface DispatchToProps { - changeJobStatus(jobInstance: any, status: string): void; closeStatistics(): void; } @@ -46,9 +45,6 @@ function mapStateToProps(state: CombinedState): StateToProps { function mapDispatchToProps(dispatch: any): DispatchToProps { return { - changeJobStatus(jobInstance: any, status: string): void { - dispatch(changeJobStatusAsync(jobInstance, status)); - }, closeStatistics(): void { dispatch(showStatistics(false)); }, @@ -58,14 +54,10 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { 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; + const { + jobInstance, visible, collecting, data, closeStatistics, jobStatus, savingJobStatus, + } = this.props; return ( { visible={visible} jobStatus={jobStatus} bugTracker={jobInstance.task.bugTracker} - zOrder={jobInstance.task.zOrder} startFrame={jobInstance.startFrame} stopFrame={jobInstance.stopFrame} assignee={jobInstance.assignee ? jobInstance.assignee.username : 'Nobody'} + reviewer={jobInstance.reviewer ? jobInstance.reviewer.username : 'Nobody'} savingJobStatus={savingJobStatus} closeStatistics={closeStatistics} - changeJobStatus={this.changeJobStatus} /> ); } 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 1d12a1db7436..e89252cf23d5 100644 --- a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx @@ -23,6 +23,7 @@ import { searchEmptyFrameAsync, changeWorkspace as changeWorkspaceAction, activateObject, + setForceExitAnnotationFlag as setForceExitAnnotationFlagAction, } from 'actions/annotation-actions'; import { Canvas } from 'cvat-canvas-wrapper'; @@ -48,6 +49,7 @@ interface StateToProps { keyMap: Record; normalizedKeyMap: Record; canvasInstance: Canvas; + forceExit: boolean; } interface DispatchToProps { @@ -59,6 +61,7 @@ interface DispatchToProps { redo(sessionInstance: any, frameNumber: any): void; searchAnnotations(sessionInstance: any, frameFrom: number, frameTo: number): void; searchEmptyFrame(sessionInstance: any, frameFrom: number, frameTo: number): void; + setForceExitAnnotationFlag(forceExit: boolean): void; changeWorkspace(workspace: Workspace): void; } @@ -70,7 +73,7 @@ function mapStateToProps(state: CombinedState): StateToProps { frame: { filename: frameFilename, number: frameNumber, delay: frameDelay }, }, annotations: { - saving: { uploading: saving, statuses: savingStatuses }, + saving: { uploading: saving, statuses: savingStatuses, forceExit }, history, }, job: { instance: jobInstance }, @@ -103,6 +106,7 @@ function mapStateToProps(state: CombinedState): StateToProps { keyMap, normalizedKeyMap, canvasInstance, + forceExit, }; } @@ -137,6 +141,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { dispatch(activateObject(null, null)); dispatch(changeWorkspaceAction(workspace)); }, + setForceExitAnnotationFlag(forceExit: boolean): void { + dispatch(setForceExitAnnotationFlagAction(forceExit)); + }, }; } @@ -163,16 +170,30 @@ class AnnotationTopBarContainer extends React.PureComponent { } public componentDidMount(): void { - const { autoSaveInterval, history, jobInstance } = this.props; + const { + autoSaveInterval, history, jobInstance, setForceExitAnnotationFlag, + } = this.props; this.autoSaveInterval = window.setInterval(this.autoSave.bind(this), autoSaveInterval); + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; this.unblock = history.block((location: any) => { + const { forceExit } = self.props; const { task, id: jobID } = jobInstance; const { id: taskID } = task; - if (jobInstance.annotations.hasUnsavedChanges() && location.pathname !== `/tasks/${taskID}/jobs/${jobID}`) { + if ( + jobInstance.annotations.hasUnsavedChanges() && + location.pathname !== `/tasks/${taskID}/jobs/${jobID}` && + !forceExit + ) { return 'You have unsaved changes, please confirm leaving this page.'; } + + if (forceExit) { + setForceExitAnnotationFlag(false); + } + return undefined; }); @@ -413,13 +434,17 @@ class AnnotationTopBarContainer extends React.PureComponent { }; private beforeUnloadCallback = (event: BeforeUnloadEvent): string | undefined => { - const { jobInstance } = this.props; - if (jobInstance.annotations.hasUnsavedChanges()) { + const { jobInstance, forceExit, setForceExitAnnotationFlag } = this.props; + if (jobInstance.annotations.hasUnsavedChanges() && !forceExit) { const confirmationMessage = 'You have unsaved changes, please confirm leaving this page.'; // eslint-disable-next-line no-param-reassign event.returnValue = confirmationMessage; return confirmationMessage; } + + if (forceExit) { + setForceExitAnnotationFlag(false); + } return undefined; }; diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index 310767bf5fc0..1049eb54c5a7 100644 --- a/cvat-ui/src/reducers/annotation-reducer.ts +++ b/cvat-ui/src/reducers/annotation-reducer.ts @@ -9,7 +9,15 @@ import { Canvas, CanvasMode } from 'cvat-canvas-wrapper'; import { AnnotationActionTypes } from 'actions/annotation-actions'; import { AuthActionTypes } from 'actions/auth-actions'; import { BoundariesActionTypes } from 'actions/boundaries-actions'; -import { AnnotationState, ActiveControl, ShapeType, ObjectType, ContextMenuType, Workspace } from './interfaces'; +import { + AnnotationState, + ActiveControl, + ShapeType, + ObjectType, + ContextMenuType, + Workspace, + TaskStatus, +} from './interfaces'; const defaultState: AnnotationState = { activities: { @@ -22,6 +30,7 @@ const defaultState: AnnotationState = { top: 0, type: ContextMenuType.CANVAS_SHAPE, pointID: null, + clientID: null, }, instance: new Canvas(), ready: false, @@ -57,6 +66,7 @@ const defaultState: AnnotationState = { activatedStateID: null, activatedAttributeID: null, saving: { + forceExit: false, uploading: false, statuses: [], }, @@ -89,6 +99,8 @@ const defaultState: AnnotationState = { colors: [], sidebarCollapsed: false, appearanceCollapsed: false, + requestReviewDialogVisible: false, + submitReviewDialogVisible: false, tabContentHeight: 0, workspace: Workspace.STANDARD, }; @@ -120,6 +132,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { maxZ, } = action.payload; + const isReview = job.status === TaskStatus.REVIEW; + return { ...state, job: { @@ -128,8 +142,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { instance: job, labels: job.task.labels, attributes: job.task.labels.reduce((acc: Record, label: any): Record< - number, - any[] + number, + any[] > => { acc[label.id] = label.attributes; return acc; @@ -165,6 +179,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { instance: new Canvas(), }, colors, + workspace: isReview ? Workspace.REVIEW_WORKSPACE : Workspace.STANDARD, }; } case AnnotationActionTypes.GET_JOB_FAILED: { @@ -194,13 +209,15 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }; } case AnnotationActionTypes.CHANGE_FRAME_SUCCESS: { - const { number, data, filename, states, minZ, maxZ, curZ, delay, changeTime } = action.payload; + const { + number, data, filename, states, minZ, maxZ, curZ, delay, changeTime, + } = action.payload; const activatedStateID = states .map((_state: any) => _state.clientID) - .includes(state.annotations.activatedStateID) - ? state.annotations.activatedStateID - : null; + .includes(state.annotations.activatedStateID) ? + state.annotations.activatedStateID : + null; return { ...state, @@ -245,9 +262,12 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { ...state, player: { ...state.player, - frameAngles: state.player.frameAngles.map((_angle: number, idx: number) => - rotateAll || offset === idx ? angle : _angle, - ), + frameAngles: state.player.frameAngles.map((_angle: number, idx: number) => { + if (rotateAll || offset === idx) { + return angle; + } + return _angle; + }), }, }; } @@ -394,7 +414,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }; } case AnnotationActionTypes.REMEMBER_CREATED_OBJECT: { - const { shapeType, labelID, objectType, points, activeControl, rectDrawingMethod } = action.payload; + const { + shapeType, labelID, objectType, points, activeControl, rectDrawingMethod, + } = action.payload; return { ...state, @@ -431,6 +453,22 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.SELECT_ISSUE_POSITION: { + const { enabled } = action.payload; + const activeControl = enabled ? ActiveControl.OPEN_ISSUE : ActiveControl.CURSOR; + + return { + ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, + canvas: { + ...state.canvas, + activeControl, + }, + }; + } case AnnotationActionTypes.MERGE_OBJECTS: { const { enabled } = action.payload; const activeControl = enabled ? ActiveControl.MERGE : ActiveControl.CURSOR; @@ -489,7 +527,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }; } case AnnotationActionTypes.UPDATE_ANNOTATIONS_SUCCESS: { - const { history, states: updatedStates, minZ, maxZ } = action.payload; + const { + history, states: updatedStates, minZ, maxZ, + } = action.payload; const { states: prevStates } = state.annotations; const nextStates = [...prevStates]; @@ -627,6 +667,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { } case AnnotationActionTypes.REMOVE_OBJECT_SUCCESS: { const { objectState, history } = action.payload; + const contextMenuClientID = state.canvas.contextMenu.clientID; + const contextMenuVisible = state.canvas.contextMenu.visible; return { ...state, @@ -638,6 +680,14 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { (_objectState: any) => _objectState.clientID !== objectState.clientID, ), }, + canvas: { + ...state.canvas, + contextMenu: { + ...state.canvas.contextMenu, + clientID: objectState.clientID === contextMenuClientID ? null : contextMenuClientID, + visible: objectState.clientID === contextMenuClientID ? false : contextMenuVisible, + }, + }, }; } case AnnotationActionTypes.PASTE_SHAPE: { @@ -754,33 +804,6 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } - 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.UPLOAD_JOB_ANNOTATIONS: { const { job, loader } = action.payload; const { loads } = state.activities; @@ -851,7 +874,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }; } case AnnotationActionTypes.UPDATE_CANVAS_CONTEXT_MENU: { - const { visible, left, top, type, pointID } = action.payload; + const { + visible, left, top, type, pointID, + } = action.payload; return { ...state, @@ -864,19 +889,22 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { top, type, pointID, + clientID: state.annotations.activatedStateID, }, }, }; } case AnnotationActionTypes.REDO_ACTION_SUCCESS: case AnnotationActionTypes.UNDO_ACTION_SUCCESS: { - const { history, states, minZ, maxZ } = action.payload; + const { + history, states, minZ, maxZ, + } = action.payload; const activatedStateID = states .map((_state: any) => _state.clientID) - .includes(state.annotations.activatedStateID) - ? state.annotations.activatedStateID - : null; + .includes(state.annotations.activatedStateID) ? + state.annotations.activatedStateID : + null; return { ...state, @@ -897,9 +925,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { const { states, minZ, maxZ } = action.payload; const activatedStateID = states .map((_state: any) => _state.clientID) - .includes(state.annotations.activatedStateID) - ? state.annotations.activatedStateID - : null; + .includes(state.annotations.activatedStateID) ? + state.annotations.activatedStateID : + null; return { ...state, @@ -987,6 +1015,33 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.SWITCH_REQUEST_REVIEW_DIALOG: { + const { visible } = action.payload; + return { + ...state, + requestReviewDialogVisible: visible, + }; + } + case AnnotationActionTypes.SWITCH_SUBMIT_REVIEW_DIALOG: { + const { visible } = action.payload; + return { + ...state, + submitReviewDialogVisible: visible, + }; + } + case AnnotationActionTypes.SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG: { + const { forceExit } = action.payload; + return { + ...state, + annotations: { + ...state.annotations, + saving: { + ...state.annotations.saving, + forceExit, + }, + }, + }; + } case AnnotationActionTypes.CHANGE_WORKSPACE: { const { workspace } = action.payload; if (state.canvas.activeControl !== ActiveControl.CURSOR) { diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 5a4802c6bcf0..088be88d04aa 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -174,6 +174,12 @@ export interface Model { }; } +export enum TaskStatus { + ANNOTATION = 'annotation', + REVIEW = 'validation', + COMPLETED = 'completed', +} + export enum RQStatus { unknown = 'unknown', queued = 'queued', @@ -284,6 +290,14 @@ export interface NotificationsState { userAgreements: { fetching: null | ErrorState; }; + review: { + initialization: null | ErrorState; + finishingIssue: null | ErrorState; + resolvingIssue: null | ErrorState; + reopeningIssue: null | ErrorState; + commentingIssue: null | ErrorState; + submittingReview: null | ErrorState; + }; }; messages: { tasks: { @@ -314,6 +328,7 @@ export enum ActiveControl { GROUP = 'group', SPLIT = 'split', EDIT = 'edit', + OPEN_ISSUE = 'open_issue', AI_TOOLS = 'ai_tools', } @@ -361,6 +376,7 @@ export interface AnnotationState { left: number; type: ContextMenuType; pointID: number | null; + clientID: number | null; }; instance: Canvas; ready: boolean; @@ -410,6 +426,7 @@ export interface AnnotationState { redo: [string, number][]; }; saving: { + forceExit: boolean; uploading: boolean; statuses: string[]; }; @@ -429,6 +446,8 @@ export interface AnnotationState { data: any; }; colors: any[]; + requestReviewDialogVisible: boolean; + submitReviewDialogVisible: boolean; sidebarCollapsed: boolean; appearanceCollapsed: boolean; tabContentHeight: number; @@ -440,6 +459,7 @@ export enum Workspace { STANDARD = 'Standard', ATTRIBUTE_ANNOTATION = 'Attribute annotation', TAG_ANNOTATION = 'Tag annotation', + REVIEW_WORKSPACE = 'Review', } export enum GridColor { @@ -512,12 +532,24 @@ export interface ShortcutsState { normalizedKeyMap: Record; } -export interface MetaState { - initialized: boolean; - fetching: boolean; - showTasksButton: boolean; - showAnalyticsButton: boolean; - showModelsButton: boolean; +export enum ReviewStatus { + ACCEPTED = 'accepted', + REJECTED = 'rejected', + REVIEW_FURTHER = 'review_further', +} + +export interface ReviewState { + reviews: any[]; + issues: any[]; + frameIssues: any[]; + latestComments: string[]; + activeReview: any | null; + newIssuePosition: number[] | null; + issuesHidden: boolean; + fetching: { + reviewId: number | null; + issueId: number | null; + }; } export interface CombinedState { @@ -534,5 +566,5 @@ export interface CombinedState { annotation: AnnotationState; settings: SettingsState; shortcuts: ShortcutsState; - meta: MetaState; + review: ReviewState; } diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index 3a34a4140d48..44f7b59876e8 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -15,6 +15,7 @@ import { AnnotationActionTypes } from 'actions/annotation-actions'; import { NotificationsActionType } from 'actions/notification-actions'; import { BoundariesActionTypes } from 'actions/boundaries-actions'; import { UserAgreementsActionTypes } from 'actions/useragreements-actions'; +import { ReviewActionTypes } from 'actions/review-actions'; import { NotificationsState } from './interfaces'; @@ -93,6 +94,14 @@ const defaultState: NotificationsState = { userAgreements: { fetching: null, }, + review: { + commentingIssue: null, + finishingIssue: null, + initialization: null, + reopeningIssue: null, + resolvingIssue: null, + submittingReview: null, + }, }, messages: { tasks: { @@ -802,21 +811,6 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } - 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(), - }, - }, - }, - }; - } case AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_FAILED: { const { job, error } = action.payload; @@ -976,6 +970,96 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case ReviewActionTypes.INITIALIZE_REVIEW_FAILED: { + return { + ...state, + errors: { + ...state.errors, + review: { + ...state.errors.review, + initialization: { + message: 'Could not initialize review session', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case ReviewActionTypes.FINISH_ISSUE_FAILED: { + return { + ...state, + errors: { + ...state.errors, + review: { + ...state.errors.review, + finishingIssue: { + message: 'Could not open a new issue', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case ReviewActionTypes.RESOLVE_ISSUE_FAILED: { + return { + ...state, + errors: { + ...state.errors, + review: { + ...state.errors.review, + resolvingIssue: { + message: 'Could not resolve the issue', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case ReviewActionTypes.REOPEN_ISSUE_FAILED: { + return { + ...state, + errors: { + ...state.errors, + review: { + ...state.errors.review, + reopeningIssue: { + message: 'Could not reopen the issue', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case ReviewActionTypes.COMMENT_ISSUE_FAILED: { + return { + ...state, + errors: { + ...state.errors, + review: { + ...state.errors.review, + commentingIssue: { + message: 'Could not comment the issue', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case ReviewActionTypes.SUBMIT_REVIEW_FAILED: { + return { + ...state, + errors: { + ...state.errors, + review: { + ...state.errors.review, + submittingReview: { + message: 'Could not submit review session to the server', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } case NotificationsActionType.RESET_ERRORS: { return { ...state, diff --git a/cvat-ui/src/reducers/review-reducer.ts b/cvat-ui/src/reducers/review-reducer.ts new file mode 100644 index 000000000000..f7b025aab656 --- /dev/null +++ b/cvat-ui/src/reducers/review-reducer.ts @@ -0,0 +1,192 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import consts from 'consts'; +import { AnnotationActionTypes } from 'actions/annotation-actions'; +import { ReviewActionTypes } from 'actions/review-actions'; +import { ReviewState } from './interfaces'; + +const defaultState: ReviewState = { + reviews: [], // saved on the server + issues: [], // saved on the server + latestComments: [], + frameIssues: [], // saved on the server and not saved on the server + activeReview: null, // not saved on the server + newIssuePosition: null, + issuesHidden: false, + fetching: { + reviewId: null, + issueId: null, + }, +}; + +function computeFrameIssues(issues: any[], activeReview: any, frame: number): any[] { + const combinedIssues = activeReview ? issues.concat(activeReview.issues) : issues; + return combinedIssues.filter((issue: any): boolean => issue.frame === frame); +} + +export default function (state: ReviewState = defaultState, action: any): ReviewState { + switch (action.type) { + case AnnotationActionTypes.GET_JOB_SUCCESS: { + const { + reviews, + issues, + frameData: { number: frame }, + } = action.payload; + const frameIssues = computeFrameIssues(issues, state.activeReview, frame); + + return { + ...state, + reviews, + issues, + frameIssues, + }; + } + case AnnotationActionTypes.CHANGE_FRAME: { + return { + ...state, + newIssuePosition: null, + }; + } + case ReviewActionTypes.SUBMIT_REVIEW: { + const { reviewId } = action.payload; + return { + ...state, + fetching: { + ...state.fetching, + reviewId, + }, + }; + } + case ReviewActionTypes.SUBMIT_REVIEW_SUCCESS: { + const { + activeReview, reviews, issues, frame, + } = action.payload; + const frameIssues = computeFrameIssues(issues, activeReview, frame); + + return { + ...state, + activeReview, + reviews, + issues, + frameIssues, + fetching: { + ...state.fetching, + reviewId: null, + }, + }; + } + case ReviewActionTypes.SUBMIT_REVIEW_FAILED: { + return { + ...state, + fetching: { + ...state.fetching, + reviewId: null, + }, + }; + } + case AnnotationActionTypes.CHANGE_FRAME_SUCCESS: { + const { number: frame } = action.payload; + return { + ...state, + frameIssues: computeFrameIssues(state.issues, state.activeReview, frame), + }; + } + case ReviewActionTypes.INITIALIZE_REVIEW_SUCCESS: { + const { reviewInstance, frame } = action.payload; + const frameIssues = computeFrameIssues(state.issues, reviewInstance, frame); + + return { + ...state, + activeReview: reviewInstance, + frameIssues, + }; + } + case ReviewActionTypes.START_ISSUE: { + const { position } = action.payload; + return { + ...state, + newIssuePosition: position, + }; + } + case ReviewActionTypes.FINISH_ISSUE_SUCCESS: { + const { frame, issue } = action.payload; + const frameIssues = computeFrameIssues(state.issues, state.activeReview, frame); + + return { + ...state, + latestComments: state.latestComments.includes(issue.comments[0].message) ? + state.latestComments : + Array.from( + new Set( + [...state.latestComments, issue.comments[0].message].filter( + (message: string): boolean => + ![ + consts.QUICK_ISSUE_INCORRECT_POSITION_TEXT, + consts.QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT, + ].includes(message), + ), + ), + ).slice(-consts.LATEST_COMMENTS_SHOWN_QUICK_ISSUE), + frameIssues, + newIssuePosition: null, + }; + } + case ReviewActionTypes.CANCEL_ISSUE: { + return { + ...state, + newIssuePosition: null, + }; + } + case ReviewActionTypes.COMMENT_ISSUE: + case ReviewActionTypes.RESOLVE_ISSUE: + case ReviewActionTypes.REOPEN_ISSUE: { + const { issueId } = action.payload; + return { + ...state, + fetching: { + ...state.fetching, + issueId, + }, + }; + } + case ReviewActionTypes.COMMENT_ISSUE_FAILED: + case ReviewActionTypes.RESOLVE_ISSUE_FAILED: + case ReviewActionTypes.REOPEN_ISSUE_FAILED: { + return { + ...state, + fetching: { + ...state.fetching, + issueId: null, + }, + }; + } + case ReviewActionTypes.RESOLVE_ISSUE_SUCCESS: + case ReviewActionTypes.REOPEN_ISSUE_SUCCESS: + case ReviewActionTypes.COMMENT_ISSUE_SUCCESS: { + const { issues, frameIssues } = state; + + return { + ...state, + issues: [...issues], + frameIssues: [...frameIssues], + fetching: { + ...state.fetching, + issueId: null, + }, + }; + } + case ReviewActionTypes.SWITCH_ISSUES_HIDDEN_FLAG: { + const { hidden } = action.payload; + return { + ...state, + issuesHidden: hidden, + }; + } + default: + return state; + } + + return state; +} diff --git a/cvat-ui/src/reducers/root-reducer.ts b/cvat-ui/src/reducers/root-reducer.ts index 9d73e0fd529e..04358b44e636 100644 --- a/cvat-ui/src/reducers/root-reducer.ts +++ b/cvat-ui/src/reducers/root-reducer.ts @@ -16,6 +16,7 @@ import annotationReducer from './annotation-reducer'; import settingsReducer from './settings-reducer'; import shortcutsReducer from './shortcuts-reducer'; import userAgreementsReducer from './useragreements-reducer'; +import reviewReducer from './review-reducer'; export default function createRootReducer(): Reducer { return combineReducers({ @@ -32,5 +33,6 @@ export default function createRootReducer(): Reducer { settings: settingsReducer, shortcuts: shortcutsReducer, userAgreements: userAgreementsReducer, + review: reviewReducer, }); } diff --git a/cvat-ui/src/reducers/shortcuts-reducer.ts b/cvat-ui/src/reducers/shortcuts-reducer.ts index 5d85743d1262..788c28314d84 100644 --- a/cvat-ui/src/reducers/shortcuts-reducer.ts +++ b/cvat-ui/src/reducers/shortcuts-reducer.ts @@ -214,6 +214,12 @@ const defaultKeyMap = ({ sequences: ['shift+n', 'n'], action: 'keydown', }, + OPEN_REVIEW_ISSUE: { + name: 'Open an issue', + description: 'Create a new issues in the review workspace', + sequences: ['n'], + action: 'keydown', + }, SWITCH_MERGE_MODE: { name: 'Merge mode', description: 'Activate or deactivate mode to merging shapes', diff --git a/tests/cypress/integration/actions_users/registration_involved/case_4_assign_taks_job_users.js b/tests/cypress/integration/actions_users/registration_involved/case_4_assign_taks_job_users.js index 41ffb3e4e8d2..7fcaead56d4f 100644 --- a/tests/cypress/integration/actions_users/registration_involved/case_4_assign_taks_job_users.js +++ b/tests/cypress/integration/actions_users/registration_involved/case_4_assign_taks_job_users.js @@ -99,7 +99,7 @@ context('Multiple users. Assign task, job.', () => { cy.login(); cy.openTask(taskName); cy.get('.cvat-task-job-list').within(() => { - cy.get('.cvat-user-search-field').click({ force: true }); + cy.get('.cvat-job-assignee-selector').click({ force: true }); }); cy.contains(thirdUserName).click(); cy.logout();