diff --git a/CHANGELOG.md b/CHANGELOG.md index d91159ab..7023ec7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ 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 () +- Added documentation on how to mount cloud starage(AWS S3 bucket, Azure container, Google Drive) as FUSE () +- Added ability to work with share files without copying inside () ### Changed @@ -66,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MOTS png mask format support () - Ability to correct upload video with a rotation record in the metadata () - User search field for assignee fields () +- Support of mxf videos () ### Changed diff --git a/README.md b/README.md index 33901c51..de2baca2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ annotation team. Try it online [cvat.org](https://cvat.org). - [Installation guide](cvat/apps/documentation/installation.md) - [User's guide](cvat/apps/documentation/user_guide.md) - [Django REST API documentation](#rest-api) -- [Datumaro dataset framework](datumaro/README.md) +- [Datumaro dataset framework](https://github.com/openvinotoolkit/datumaro/blob/develop/README.md) - [Command line interface](utils/cli/) - [XML annotation format](cvat/apps/documentation/xml_format.md) - [AWS Deployment Guide](cvat/apps/documentation/AWS-Deployment-Guide.md) diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md index e12a764f..126dc885 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,8 +185,10 @@ 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 } + - canvas.error => { exception: Error } ``` ### WEB @@ -205,28 +214,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 8fed7d27..cffc743f 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 d509f493..1c7f9bc7 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 055a6d22..57293d29 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 20731050..29355d50 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 786836d8..dca3c7d8 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 4521c570..4a30e618 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,9 +129,11 @@ 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', + DATA_FAILED = 'data_failed', } export enum Mode { @@ -142,6 +146,7 @@ export enum Mode { SPLIT = 'split', GROUP = 'group', INTERACT = 'interact', + SELECT_REGION = 'select_region', DRAG_CANVAS = 'drag_canvas', ZOOM_CANVAS = 'zoom_canvas', } @@ -149,6 +154,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; @@ -163,11 +169,13 @@ export interface CanvasModel { readonly selected: any; geometry: Geometry; mode: Mode; + exception: Error | null; zoom(x: number, y: number, direction: number): void; 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 +191,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 +215,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { gridSize: Size; left: number; objects: any[]; + issueRegions: Record; scale: number; top: number; zLayer: number | null; @@ -216,6 +226,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { splitData: SplitData; selected: any; mode: Mode; + exception: Error | null; }; public constructor() { @@ -254,6 +265,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { }, left: 0, objects: [], + issueRegions: {}, scale: 1, top: 0, zLayer: null, @@ -275,6 +287,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { }, selected: null, mode: Mode.IDLE, + exception: null, }; } @@ -288,15 +301,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 +338,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}`); @@ -389,10 +415,17 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.notify(UpdateReasons.OBJECTS_UPDATED); }) .catch((exception: any): void => { + this.data.exception = exception; + this.notify(UpdateReasons.DATA_FAILED); throw exception; }); } + 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 +632,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 +694,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); @@ -709,4 +749,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { public get mode(): Mode { return this.data.mode; } + public get exception(): Error { + return this.data.exception; + } } diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 8d8e4177..ddec7d15 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) { @@ -1172,6 +1343,14 @@ export class CanvasViewImpl implements CanvasView, Listener { this.mode = Mode.IDLE; this.canvas.style.cursor = ''; } + else if (reason === UpdateReasons.DATA_FAILED) { + const event: CustomEvent = new CustomEvent('canvas.error', { + detail: { + exception: model.exception, + }, + }); + this.canvas.dispatchEvent(event); + } if (model.imageBitmap && [UpdateReasons.IMAGE_CHANGED, UpdateReasons.OBJECTS_UPDATED].includes(reason)) { this.redrawBitmap(); @@ -1275,7 +1454,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 +1504,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 +1519,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 +1548,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 +1709,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 +1717,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 +1740,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 +1768,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 +1846,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 +1866,6 @@ export class CanvasViewImpl implements CanvasView, Listener { } }); - this.updateTextPosition(text, shape); this.canvas.dispatchEvent( new CustomEvent('canvas.activated', { bubbles: false, @@ -1757,8 +1925,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 +1946,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 ffe53931..7dea5032 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 00000000..189c1bbf --- /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 5d883037..122f5643 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 8c5a6140..49fd1e2d 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 c6eb7235..eef263d2 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 3826873a..f3e2aa26 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 a33ec282..7002a3ab 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 00000000..e8e18cb4 --- /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 7f0d1ad0..d40312b4 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/download.worker.js b/cvat-core/src/download.worker.js index 027ba87a..35d899d2 100644 --- a/cvat-core/src/download.worker.js +++ b/cvat-core/src/download.worker.js @@ -20,7 +20,9 @@ onmessage = (e) => { .catch((error) => { postMessage({ id: e.data.id, - error, + error: error, + status: error.response.status, + responseData: error.response.data, isSuccess: false, }); }); diff --git a/cvat-core/src/enums.js b/cvat-core/src/enums.js index df1d8773..107bc86f 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 00000000..e18ae3ed --- /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 00000000..db9491e4 --- /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 71e1cfbe..7865df66 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -31,7 +31,13 @@ if (e.data.isSuccess) { requests[e.data.id].resolve(e.data.responseData); } else { - requests[e.data.id].reject(e.data.error); + requests[e.data.id].reject({ + error: e.data.error, + response: { + status: e.data.status, + data: e.data.responseData, + }, + }); } delete requests[e.data.id]; @@ -287,7 +293,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 +572,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; @@ -641,7 +731,14 @@ }, ); } catch (errorData) { - throw generateError(errorData); + throw generateError({ + ...errorData, + message: '', + response: { + ...errorData.response, + data: String.fromCharCode.apply(null, new Uint8Array(errorData.response.data)), + }, + }); } return response; @@ -932,16 +1029,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 +1085,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 e9ee3b88..19bc32df 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, @@ -891,6 +973,7 @@ data_original_chunk_type: undefined, use_zip_chunks: undefined, use_cache: undefined, + copy_data: undefined, }; let updatedFields = { @@ -925,6 +1008,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, @@ -1156,6 +1240,22 @@ data.use_cache = useCache; }, }, + /** + * @name copyData + * @type {boolean} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + copyData: { + get: () => data.copy_data, + set: (copyData) => { + if (typeof copyData !== 'boolean') { + throw new ArgumentError('Value must be a boolean'); + } + data.copy_data = copyData; + }, + }, /** * After task has been created value can be appended only. * @name labels @@ -1482,7 +1582,6 @@ buildDublicatedAPI(Task.prototype); Job.prototype.save.implementation = async function () { - // TODO: Add ability to change an assignee if (this.id) { const jobData = {}; @@ -1495,17 +1594,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 +1617,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}"`); @@ -1786,6 +1925,9 @@ if (typeof this.dataChunkSize !== 'undefined') { taskDataSpec.chunk_size = this.dataChunkSize; } + if (typeof this.copyData !== 'undefined') { + taskDataSpec.copy_data = this.copyData; + } const task = await serverProxy.tasks.createTask(taskSpec, taskDataSpec, onUpdate); return new Task(task); diff --git a/cvat-core/src/user.js b/cvat-core/src/user.js index c0475d8e..d826c822 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 115e0491..33a3c500 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 f9843b1c..d52f3aed 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 69937915..62da6905 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 3ba1e868..08cea114 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.10.10", + "version": "1.11.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1213,9 +1213,9 @@ "dev": true }, "@types/react": { - "version": "16.9.55", - "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.55.tgz", - "integrity": "sha512-6KLe6lkILeRwyyy7yG9rULKJ0sXplUsl98MGoCfpteXf9sPWFWWMknDcsvubcpaTdBuxtsLF6HDUwdApZL/xIg==", + "version": "16.9.56", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.56.tgz", + "integrity": "sha512-gIkl4J44G/qxbuC6r2Xh+D3CGZpJ+NdWTItAPmZbR5mUS+JQ8Zvzpl0ea5qT/ZT3ZNTUcDKUVqV3xBE8wv/DyQ==", "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -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 0a4083bd..4dea1406 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.10.10", + "version": "1.11.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { @@ -49,7 +49,7 @@ "dependencies": { "@types/lodash": "^4.14.165", "@types/platform": "^1.3.3", - "@types/react": "^16.9.55", + "@types/react": "^16.9.56", "@types/react-color": "^3.0.4", "@types/react-dom": "^16.9.9", "@types/react-redux": "^7.1.11", diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 44604467..8f486e96 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,10 @@ export enum AnnotationActionTypes { SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED', INTERACT_WITH_CANVAS = 'INTERACT_WITH_CANVAS', SET_AI_TOOLS_REF = 'SET_AI_TOOLS_REF', + GET_DATA_FAILED = 'GET_DATA_FAILED', + 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 { @@ -217,6 +219,15 @@ export function changeWorkspace(workspace: Workspace): AnyAction { }; } +export function getDataFailed(error: any): AnyAction { + return { + type: AnnotationActionTypes.GET_DATA_FAILED, + payload: { + error, + }, + }; +} + export function addZLayer(): AnyAction { return { type: AnnotationActionTypes.ADD_Z_LAYER, @@ -393,36 +404,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 +877,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, @@ -938,8 +923,19 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init const frameData = await job.frames.get(frameNumber); // call first getting of frame data before rendering interface // to load and decode first chunk - await frameData.data(); + try { + await frameData.data(); + } catch (error) { + dispatch({ + type: AnnotationActionTypes.GET_DATA_FAILED, + payload: { + error, + }, + }); + } 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 +945,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 +969,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 +995,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 +1057,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 +1491,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 00000000..6cca02ec --- /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/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 349cd880..2be26d82 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -394,6 +394,9 @@ export function createTaskAsync(data: any): ThunkAction, {}, {}, A if (data.advanced.dataChunkSize) { description.data_chunk_size = data.advanced.dataChunkSize; } + if (data.advanced.copyData) { + description.copy_data = data.advanced.copyData; + } const taskInstance = new cvat.classes.Task(description); taskInstance.clientFiles = data.files.local; diff --git a/cvat-ui/src/base.scss b/cvat-ui/src/base.scss index 90fa05b8..8cc0aeeb 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 b2fc8ded..e0518205 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 ed87c19c..2ef039fa 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 d4e5fd8d..5cd8a079 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 00000000..9d7cc35c --- /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 d871ce30..f4aeceaf 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 92% 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 acc697ae..20007713 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,15 @@ interface Props { onSwitchGrid(enabled: boolean): void; onSwitchAutomaticBordering(enabled: boolean): void; onFetchAnnotation(): void; + onGetDataFailed(error: any): 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 +109,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 +125,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { outlined, outlineColor, showBitmap, + frameIssues, frameData, frameAngle, annotations, @@ -211,6 +219,10 @@ export default class CanvasWrapperComponent extends React.PureComponent { } } + if (prevProps.frameIssues !== frameIssues) { + this.updateIssueRegions(); + } + if ( prevProps.annotations !== annotations || prevProps.frameData !== frameData || @@ -247,6 +259,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,13 +319,21 @@ 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); + canvasInstance.html().removeEventListener('canvas.error', this.onCanvasErrorOccurrence); window.removeEventListener('resize', this.fitCanvas); } + private onCanvasErrorOccurrence = (event: any): void => { + const { exception } = event.detail; + const { onGetDataFailed } = this.props; + onGetDataFailed(exception); + }; + private onCanvasShapeDrawn = (event: any): void => { const { jobInstance, activeLabelID, activeObjectType, frame, onShapeDrawn, onCreateAnnotations, @@ -353,6 +385,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 +411,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 +419,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 +481,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 +639,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,9 +749,11 @@ 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); + canvasInstance.html().addEventListener('canvas.error', this.onCanvasErrorOccurrence); } public render(): JSX.Element { 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 00000000..89d4e244 --- /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 00000000..9eb53fa2 --- /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 00000000..5286f843 --- /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 00000000..095a69c7 --- /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 00000000..1c9d3348 --- /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 00000000..7b216be0 --- /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 00000000..4a2c15b4 --- /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 00000000..df8d9e76 --- /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 00000000..484e39e2 --- /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 00000000..be2381c9 --- /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 00000000..b942d14a --- /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 36623adb..00000000 --- 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 00000000..4269d8ef --- /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 01a7640a..78ac8634 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 a0839ae8..2fe26cd7 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 6415d319..abacb02e 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 00000000..7c92593e --- /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 06f6e86e..188257d2 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 d57be1ec..75adab15 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 6836cfac..731e71e5 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 b08d1238..a3bb3907 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 1866bd35..9818bbcb 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 16f85d4d..fe8b9131 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/create-task-page/advanced-configuration-form.tsx b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx index 88175b7e..fc12853e 100644 --- a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx @@ -26,11 +26,13 @@ export interface AdvancedConfiguration { useZipChunks: boolean; dataChunkSize?: number; useCache: boolean; + copyData?: boolean; } type Props = FormComponentProps & { onSubmit(values: AdvancedConfiguration): void; installedGit: boolean; + activeFileManagerTab: string; }; function isPositiveInteger(_: any, value: any, callback: any): void { @@ -114,6 +116,26 @@ class AdvancedConfigurationForm extends React.PureComponent { form.resetFields(); } + renderCopyDataChechbox(): JSX.Element { + const { form } = this.props; + return ( + + + + {form.getFieldDecorator('copyData', { + initialValue: false, + valuePropName: 'checked', + })( + + Copy data into CVAT + , + )} + + + + ); + } + private renderImageQuality(): JSX.Element { const { form } = this.props; @@ -386,10 +408,12 @@ class AdvancedConfigurationForm extends React.PureComponent { } public render(): JSX.Element { - const { installedGit } = this.props; - + const { installedGit, activeFileManagerTab } = this.props; return (
+ + {activeFileManagerTab === 'share' ? this.renderCopyDataChechbox() : null} + {this.renderUzeZipChunks()} diff --git a/cvat-ui/src/components/create-task-page/create-task-content.tsx b/cvat-ui/src/components/create-task-page/create-task-content.tsx index 2fcaa247..7c76aba4 100644 --- a/cvat-ui/src/components/create-task-page/create-task-content.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-content.tsx @@ -25,6 +25,7 @@ export interface CreateTaskData { advanced: AdvancedConfiguration; labels: any[]; files: Files; + activeFileManagerTab: string; } interface Props { @@ -53,6 +54,7 @@ const defaultState = { share: [], remote: [], }, + activeFileManagerTab: 'local', }; class CreateTaskContent extends React.PureComponent { @@ -132,6 +134,14 @@ class CreateTaskContent extends React.PureComponent { + const values = this.state; + this.setState({ + ...values, + activeFileManagerTab: key + }); + }; + private handleSubmitClick = (): void => { if (!this.validateLabelsOrProject()) { notification.error({ @@ -238,6 +248,7 @@ class CreateTaskContent extends React.PureComponent* Select files: { this.fileManagerContainer = container; }} @@ -255,6 +266,7 @@ class CreateTaskContent extends React.PureComponentAdvanced configuration}> { this.advancedConfigurationComponent = component; }} diff --git a/cvat-ui/src/components/file-manager/file-manager.tsx b/cvat-ui/src/components/file-manager/file-manager.tsx index d62d724b..c4b9e2a8 100644 --- a/cvat-ui/src/components/file-manager/file-manager.tsx +++ b/cvat-ui/src/components/file-manager/file-manager.tsx @@ -31,6 +31,7 @@ interface Props { withRemote: boolean; treeData: TreeNodeNormal[]; onLoadData: (key: string, success: () => void, failure: () => void) => void; + onChangeActiveKey(key: string): void; } export default class FileManager extends React.PureComponent { @@ -215,7 +216,7 @@ export default class FileManager extends React.PureComponent { } public render(): JSX.Element { - const { withRemote } = this.props; + const { withRemote, onChangeActiveKey } = this.props; const { active } = this.state; return ( @@ -224,11 +225,12 @@ export default class FileManager extends React.PureComponent { type='card' activeKey={active} tabBarGutter={5} - onChange={(activeKey: string): void => + onChange={(activeKey: string): void => { + onChangeActiveKey(activeKey); this.setState({ active: activeKey as any, - }) - } + }); + }} > {this.renderLocalSelector()} {this.renderShareSelector()} diff --git a/cvat-ui/src/components/task-page/job-list.tsx b/cvat-ui/src/components/task-page/job-list.tsx index 2b10e225..15a5b8e9 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 66a71589..bf5d364a 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 f5fdcf0b..5a4211f5 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 3a54116a..5dc30952 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 c172abb5..b25b297c 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 90% 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 817ef4be..ef8b5dc3 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, @@ -27,6 +27,7 @@ import { addZLayer, switchZLayer, fetchAnnotationsAsync, + getDataFailed, } from 'actions/annotation-actions'; import { switchGrid, @@ -37,6 +38,7 @@ import { changeSaturationLevel, switchAutomaticBordering, } from 'actions/settings-actions'; +import { reviewActions } from 'actions/review-actions'; import { ColorBy, GridColor, @@ -57,6 +59,7 @@ interface StateToProps { activatedAttributeID: number | null; selectedStatesID: number[]; annotations: any[]; + frameIssues: any[] | null; frameData: any; frameAngle: number; frameFetching: boolean; @@ -119,6 +122,8 @@ interface DispatchToProps { onSwitchGrid(enabled: boolean): void; onSwitchAutomaticBordering(enabled: boolean): void; onFetchAnnotation(): void; + onGetDataFailed(error: any): void; + onStartIssue(position: number[]): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -153,9 +158,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 +173,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 +310,12 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onFetchAnnotation(): void { dispatch(fetchAnnotationsAsync()); }, + onGetDataFailed(error: any): void { + dispatch(getDataFailed(error)); + }, + 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 00000000..03dc9cb1 --- /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 ae839c4d..cdb5960e 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 d1fc52a5..18fba67f 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 1d12a1db..e89252cf 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/containers/file-manager/file-manager.tsx b/cvat-ui/src/containers/file-manager/file-manager.tsx index 8a74964c..3db64770 100644 --- a/cvat-ui/src/containers/file-manager/file-manager.tsx +++ b/cvat-ui/src/containers/file-manager/file-manager.tsx @@ -14,6 +14,7 @@ import { ShareItem, CombinedState } from 'reducers/interfaces'; interface OwnProps { ref: any; withRemote: boolean; + onChangeActiveKey(key: string): void; } interface StateToProps { @@ -68,12 +69,13 @@ export class FileManagerContainer extends React.PureComponent { } public render(): JSX.Element { - const { treeData, getTreeData, withRemote } = this.props; + const { treeData, getTreeData, withRemote, onChangeActiveKey } = this.props; return ( { this.managerComponentRef = component; diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index 310767bf..7780eb9d 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: { @@ -177,6 +192,18 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.GET_DATA_FAILED: { + return { + ...state, + player: { + ...state.player, + frame: { + ...state.player.frame, + fetching: false, + }, + }, + } + } case AnnotationActionTypes.CHANGE_FRAME: { return { ...state, @@ -194,13 +221,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 +274,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 +426,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 +465,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 +539,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 +679,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 +692,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 +816,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 +886,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 +901,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 +937,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 +1027,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 5a4802c6..088be88d 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 3a34a414..9f2caec0 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, @@ -992,6 +1076,21 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case AnnotationActionTypes.GET_DATA_FAILED: { + return { + ...state, + errors: { + ...state.errors, + annotation: { + ...state.errors.annotation, + jobFetching: { + message: 'Could not fetch frame data from the server', + reason: action.payload.error, + }, + }, + }, + }; + } case BoundariesActionTypes.RESET_AFTER_ERROR: case AuthActionTypes.LOGOUT_SUCCESS: { return { ...defaultState }; diff --git a/cvat-ui/src/reducers/review-reducer.ts b/cvat-ui/src/reducers/review-reducer.ts new file mode 100644 index 00000000..f7b025aa --- /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 9d73e0fd..04358b44 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 5d85743d..788c2831 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/cvat/apps/authentication/auth.py b/cvat/apps/authentication/auth.py index 349df39b..5e19efb7 100644 --- a/cvat/apps/authentication/auth.py +++ b/cvat/apps/authentication/auth.py @@ -91,6 +91,11 @@ def is_project_annotator(db_user, db_project): db_tasks = list(db_project.tasks.prefetch_related('segment_set').all()) return any([is_task_annotator(db_user, db_task) for db_task in db_tasks]) +@rules.predicate +def is_project_reviewer(db_user, db_project): + db_tasks = list(db_project.tasks.prefetch_related('segment_set').all()) + return any([is_task_reviewer(db_user, db_task) for db_task in db_tasks]) + @rules.predicate def is_task_owner(db_user, db_task): # If owner is None (null) the task can be accessed/changed/deleted @@ -101,6 +106,12 @@ def is_task_owner(db_user, db_task): def is_task_assignee(db_user, db_task): return db_task.assignee == db_user or is_project_assignee(db_user, db_task.project) +@rules.predicate +def is_task_reviewer(db_user, db_task): + db_segments = list(db_task.segment_set.prefetch_related('job_set__assignee').all()) + return any([is_job_reviewer(db_user, db_job) + for db_segment in db_segments for db_job in db_segment.job_set.all()]) + @rules.predicate def is_task_annotator(db_user, db_task): db_segments = list(db_task.segment_set.prefetch_related('job_set__assignee').all()) @@ -121,6 +132,33 @@ def is_job_annotator(db_user, db_job): return has_rights +@rules.predicate +def has_change_permissions(db_user, db_job): + db_task = db_job.segment.task + # A job can be annotated by any user if the task's assignee is None. + has_rights = (db_task.assignee is None and not settings.RESTRICTIONS['reduce_task_visibility']) or is_task_assignee(db_user, db_task) + if db_job.assignee is not None: + has_rights |= (db_user == db_job.assignee) and (db_job.status == 'annotation') + if db_job.reviewer is not None: + has_rights |= (db_user == db_job.reviewer) and (db_job.status == 'validation') + + return has_rights + +@rules.predicate +def is_job_reviewer(db_user, db_job): + has_rights = db_job.reviewer == db_user + return has_rights + +@rules.predicate +def is_issue_owner(db_user, db_issue): + has_rights = db_issue.owner == db_user + return has_rights + +@rules.predicate +def is_comment_author(db_user, db_comment): + has_rights = (db_comment.author == db_user) + return has_rights + # AUTH PERMISSIONS RULES rules.add_perm('engine.role.user', has_user_role) rules.add_perm('engine.role.admin', has_admin_role) @@ -136,65 +174,71 @@ rules.add_perm('engine.project.delete', has_admin_role | is_project_owner) rules.add_perm('engine.task.create', has_admin_role | has_user_role) rules.add_perm('engine.task.access', has_admin_role | has_observer_role | - is_task_owner | is_task_annotator) + is_task_owner | is_task_annotator | is_task_reviewer) rules.add_perm('engine.task.change', has_admin_role | is_task_owner | is_task_assignee) rules.add_perm('engine.task.delete', has_admin_role | is_task_owner) rules.add_perm('engine.job.access', has_admin_role | has_observer_role | - is_job_owner | is_job_annotator) -rules.add_perm('engine.job.change', has_admin_role | is_job_owner | - is_job_annotator) + is_job_owner | is_job_annotator | is_job_reviewer) +rules.add_perm('engine.job.change', has_admin_role | is_job_owner | has_change_permissions) +rules.add_perm('engine.job.review', has_admin_role | (is_job_reviewer & has_change_permissions)) + +rules.add_perm('engine.issue.change', has_admin_role | is_issue_owner) +rules.add_perm('engine.issue.destroy', has_admin_role | is_issue_owner) + +rules.add_perm('engine.comment.change', has_admin_role | is_comment_author) + class AdminRolePermission(BasePermission): # pylint: disable=no-self-use def has_permission(self, request, view): - return request.user.has_perm("engine.role.admin") + return request.user.has_perm('engine.role.admin') class UserRolePermission(BasePermission): # pylint: disable=no-self-use def has_permission(self, request, view): - return request.user.has_perm("engine.role.user") + return request.user.has_perm('engine.role.user') class AnnotatorRolePermission(BasePermission): # pylint: disable=no-self-use def has_permission(self, request, view): - return request.user.has_perm("engine.role.annotator") + return request.user.has_perm('engine.role.annotator') class ObserverRolePermission(BasePermission): # pylint: disable=no-self-use def has_permission(self, request, view): - return request.user.has_perm("engine.role.observer") + return request.user.has_perm('engine.role.observer') class ProjectCreatePermission(BasePermission): # pylint: disable=no-self-use def has_permission(self, request, view): - return request.user.has_perm("engine.project.create") + return request.user.has_perm('engine.project.create') class ProjectAccessPermission(BasePermission): # pylint: disable=no-self-use def has_object_permission(self, request, view, obj): - return request.user.has_perm("engine.project.access", obj) + return request.user.has_perm('engine.project.access', obj) class ProjectChangePermission(BasePermission): # pylint: disable=no-self-use def has_object_permission(self, request, view, obj): - return request.user.has_perm("engine.project.change", obj) + return request.user.has_perm('engine.project.change', obj) class ProjectDeletePermission(BasePermission): # pylint: disable=no-self-use def has_object_permission(self, request, view, obj): - return request.user.has_perm("engine.project.delete", obj) + return request.user.has_perm('engine.project.delete', obj) class TaskCreatePermission(BasePermission): # pylint: disable=no-self-use def has_permission(self, request, view): - return request.user.has_perm("engine.task.create") + return request.user.has_perm('engine.task.create') class TaskAccessPermission(BasePermission): # pylint: disable=no-self-use def has_object_permission(self, request, view, obj): - return request.user.has_perm("engine.task.access", obj) + return request.user.has_perm('engine.task.access', obj) class ProjectGetQuerySetMixin(object): @@ -207,7 +251,8 @@ class ProjectGetQuerySetMixin(object): else: return queryset.filter(Q(owner=user) | Q(assignee=user) | Q(task__owner=user) | Q(task__assignee=user) | - Q(task__segment__job__assignee=user)).distinct() + Q(task__segment__job__assignee=user) | + Q(task__segment__job__reviewer=user)).distinct() def filter_task_queryset(queryset, user): # Don't filter queryset for admin, observer @@ -215,7 +260,7 @@ def filter_task_queryset(queryset, user): return queryset query_filter = Q(owner=user) | Q(assignee=user) | \ - Q(segment__job__assignee=user) + Q(segment__job__assignee=user) | Q(segment__job__reviewer=user) if not settings.RESTRICTIONS['reduce_task_visibility']: query_filter |= Q(assignee=None) @@ -234,19 +279,53 @@ class TaskGetQuerySetMixin(object): class TaskChangePermission(BasePermission): # pylint: disable=no-self-use def has_object_permission(self, request, view, obj): - return request.user.has_perm("engine.task.change", obj) + return request.user.has_perm('engine.task.change', obj) class TaskDeletePermission(BasePermission): # pylint: disable=no-self-use def has_object_permission(self, request, view, obj): - return request.user.has_perm("engine.task.delete", obj) + return request.user.has_perm('engine.task.delete', obj) class JobAccessPermission(BasePermission): # pylint: disable=no-self-use def has_object_permission(self, request, view, obj): - return request.user.has_perm("engine.job.access", obj) + return request.user.has_perm('engine.job.access', obj) class JobChangePermission(BasePermission): # pylint: disable=no-self-use def has_object_permission(self, request, view, obj): - return request.user.has_perm("engine.job.change", obj) + return request.user.has_perm('engine.job.change', obj) + +class JobReviewPermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): + return request.user.has_perm('engine.job.review', obj) + +class IssueAccessPermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): + db_job = obj.job + return request.user.has_perm('engine.job.access', db_job) + +class IssueDestroyPermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): + return request.user.has_perm('engine.issue.destroy', obj) + +class IssueChangePermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): + db_job = obj.job + return (request.user.has_perm('engine.job.change', db_job) + or request.user.has_perm('engine.issue.change', obj)) + +class CommentCreatePermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): # obj is db_job + return request.user.has_perm('engine.job.access', obj) + +class CommentChangePermission(BasePermission): + # pylint: disable=no-self-use + def has_object_permission(self, request, view, obj): + return request.user.has_perm('engine.comment.change', obj) + diff --git a/cvat/apps/documentation/AWS-Deployment-Guide.md b/cvat/apps/documentation/AWS-Deployment-Guide.md index 7e354f79..6e0a03d3 100644 --- a/cvat/apps/documentation/AWS-Deployment-Guide.md +++ b/cvat/apps/documentation/AWS-Deployment-Guide.md @@ -18,4 +18,5 @@ services: environment: CVAT_HOST: your-instance.amazonaws.com ``` -In case of problems with using hostname, you can also use the public IPV4 instead of hostname. For AWS or any cloud based machines where the instances need to be terminated or stopped, the public IPV4 and hostname changes with every stop and reboot. To address this efficiently, avoid using spot instances that cannot be stopped, since copying the EBS to an AMI and restarting it throws problems. On the other hand, when a regular instance is stopped and restarted, the new hostname/IPV4 can be used in the `CVAT_HOST` variable in the `docker-compose.override.yml` and the build can happen instantly with CVAT tasks being available through the new IPV4. + +In case of problems with using hostname, you can also use the public IPV4 instead of hostname. For AWS or any cloud based machines where the instances need to be terminated or stopped, the public IPV4 and hostname changes with every stop and reboot. To address this efficiently, avoid using spot instances that cannot be stopped, since copying the EBS to an AMI and restarting it throws problems. On the other hand, when a regular instance is stopped and restarted, the new hostname/IPV4 can be used in the `CVAT_HOST` variable in the `docker-compose.override.yml` and the build can happen instantly with CVAT tasks being available through the new IPV4. diff --git a/cvat/apps/documentation/faq.md b/cvat/apps/documentation/faq.md index be362b78..7e897c52 100644 --- a/cvat/apps/documentation/faq.md +++ b/cvat/apps/documentation/faq.md @@ -11,6 +11,9 @@ - [How to install CVAT on Windows 10 Home](#how-to-install-cvat-on-windows-10-home) - [I do not have the Analytics tab on the header section. How can I add analytics](#i-do-not-have-the-analytics-tab-on-the-header-section-how-can-i-add-analytics) - [How to upload annotations to an entire task from UI when there are multiple jobs in the task](#how-to-upload-annotations-to-an-entire-task-from-ui-when-there-are-multiple-jobs-in-the-task) +- [How to specify multiple hostnames for CVAT_HOST](#how-to-specify-multiple-hostnames-for-cvat_host) +- [How to create a task with multiple jobs](#how-to-create-a-task-with-multiple-jobs) + ## How to update CVAT @@ -53,7 +56,7 @@ You should free up disk space or change the threshold, to do so check: [Elastics The best way to do that is to create docker-compose.override.yml and override the host and port settings here. -version: "2.3" +version: "3.3" ```yaml services: @@ -77,7 +80,7 @@ Follow the Docker manual and configure the directory that you want to use as a s After that, it should be possible to use this directory as a CVAT share: ```yaml -version: '2.3' +version: '3.3' services: cvat: @@ -132,3 +135,17 @@ You should build CVAT images with ['Analytics' component](../../../components/an You can upload annotation for a multi-job task from the Dasboard view or the Task view. Uploading of annotation from the Annotation view only affects the current job. + +## How to specify multiple hostnames for CVAT_HOST + +```yaml +services: + cvat_proxy: + environment: + CVAT_HOST: example1.com example2.com +``` + +## How to create a task with multiple jobs + +Set the segment size when you create a new task, this option is available in the +[Advanced configuration](user_guide.md#advanced-configuration) section. diff --git a/cvat/apps/documentation/installation.md b/cvat/apps/documentation/installation.md index ca536ea5..8abd316d 100644 --- a/cvat/apps/documentation/installation.md +++ b/cvat/apps/documentation/installation.md @@ -373,6 +373,9 @@ You can change the share device path to your actual share. For user convenience we have defined the environment variable \$CVAT_SHARE_URL. This variable contains a text (url for example) which is shown in the client-share browser. +You can [mount](/cvat/apps/documentation/mounting_cloud_storages.md) +your cloud storage as a FUSE and use it later as a share. + ### Email verification You can enable email verification for newly registered users. diff --git a/cvat/apps/documentation/mounting_cloud_storages.md b/cvat/apps/documentation/mounting_cloud_storages.md new file mode 100644 index 00000000..f8d1de55 --- /dev/null +++ b/cvat/apps/documentation/mounting_cloud_storages.md @@ -0,0 +1,385 @@ +- [Mounting cloud storage](#mounting-cloud-storage) + - [AWS S3 bucket](#aws-s3-bucket-as-filesystem) + - [Ubuntu 20.04](#aws_s3_ubuntu_2004) + - [Mount](#aws_s3_mount) + - [Automatically mount](#aws_s3_automatically_mount) + - [Using /etc/fstab](#aws_s3_using_fstab) + - [Using systemd](#aws_s3_using_systemd) + - [Check](#aws_s3_check) + - [Unmount](#aws_s3_unmount_filesystem) + - [Azure container](#microsoft-azure-container-as-filesystem) + - [Ubuntu 20.04](#azure_ubuntu_2004) + - [Mount](#azure_mount) + - [Automatically mount](#azure_automatically_mount) + - [Using /etc/fstab](#azure_using_fstab) + - [Using systemd](#azure_using_systemd) + - [Check](#azure_check) + - [Unmount](#azure_unmount_filesystem) + - [Google Drive](#google-drive-as-filesystem) + - [Ubuntu 20.04](#google_drive_ubuntu_2004) + - [Mount](#google_drive_mount) + - [Automatically mount](#google_drive_automatically_mount) + - [Using /etc/fstab](#google_drive_using_fstab) + - [Using systemd](#google_drive_using_systemd) + - [Check](#google_drive_check) + - [Unmount](#google_drive_unmount_filesystem) + +# Mounting cloud storage +## AWS S3 bucket as filesystem +### Ubuntu 20.04 +#### Mount + +1. Install s3fs: + + ```bash + sudo apt install s3fs + ``` + +1. Enter your credentials in a file `${HOME}/.passwd-s3fs` and set owner-only permissions: + + ```bash + echo ACCESS_KEY_ID:SECRET_ACCESS_KEY > ${HOME}/.passwd-s3fs + chmod 600 ${HOME}/.passwd-s3fs + ``` + +1. Uncomment `user_allow_other` in the `/etc/fuse.conf` file: `sudo nano /etc/fuse.conf` +1. Run s3fs, replace `bucket_name`, `mount_point`: + + ```bash + s3fs -o allow_other + ``` + +For more details see [here](https://github.com/s3fs-fuse/s3fs-fuse). + +#### Automatically mount +Follow the first 3 mounting steps above. + +##### Using fstab + +1. Create a bash script named aws_s3_fuse(e.g in /usr/bin, as root) with this content + (replace `user_name` on whose behalf the disk will be mounted, `backet_name`, `mount_point`, `/path/to/.passwd-s3fs`): + + ```bash + #!/bin/bash + sudo -u s3fs -o passwd_file=/path/to/.passwd-s3fs -o allow_other + exit 0 + ``` + +1. Give it the execution permission: + + ```bash + sudo chmod +x /usr/bin/aws_s3_fuse + ``` + +1. Edit `/etc/fstab` adding a line like this, replace `mount_point`): + + ```bash + /absolute/path/to/aws_s3_fuse fuse allow_other,user,_netdev 0 0 + ``` + +##### Using systemd + +1. Create unit file `sudo nano /etc/systemd/system/s3fs.service` + (replace `user_name`, `bucket_name`, `mount_point`, `/path/to/.passwd-s3fs`): + + ```bash + [Unit] + Description=FUSE filesystem over AWS S3 bucket + After=network.target + + [Service] + Environment="MOUNT_POINT=" + User= + Group= + ExecStart=s3fs ${MOUNT_POINT} -o passwd_file=/path/to/.passwd-s3fs -o allow_other + ExecStop=fusermount -u ${MOUNT_POINT} + Restart=always + Type=forking + + [Install] + WantedBy=multi-user.target + ``` + +1. Update the system configurations, enable unit autorun when the system boots, mount the bucket: + + ```bash + sudo systemctl daemon-reload + sudo systemctl enable s3fs.service + sudo systemctl start s3fs.service + ``` + +#### Check +A file `/etc/mtab` contains records of currently mounted filesystems. +```bash +cat /etc/mtab | grep 's3fs' +``` + +#### Unmount filesystem +```bash +fusermount -u +``` + +If you used [systemd](#aws_s3_using_systemd) to mount a bucket: + +```bash +sudo systemctl stop s3fs.service +sudo systemctl disable s3fs.service +``` + +## Microsoft Azure container as filesystem +### Ubuntu 20.04 +#### Mount +1. Set up the Microsoft package repository.(More [here](https://docs.microsoft.com/en-us/windows-server/administration/Linux-Package-Repository-for-Microsoft-Software#configuring-the-repositories)) + + ```bash + wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb + sudo dpkg -i packages-microsoft-prod.deb + sudo apt-get update + ``` + +1. Install `blobfuse` and `fuse`: + + ```bash + sudo apt-get install blobfuse fuse + ``` + For more details see [here](https://github.com/Azure/azure-storage-fuse/wiki/1.-Installation) + +1. Create enviroments(replace `account_name`, `account_key`, `mount_point`): + + ```bash + export AZURE_STORAGE_ACCOUNT= + export AZURE_STORAGE_ACCESS_KEY= + MOUNT_POINT= + ``` + +1. Create a folder for cache: + ```bash + sudo mkdir -p /mnt/blobfusetmp + ``` + +1. Make sure the file must be owned by the user who mounts the container: + ```bash + sudo chown /mnt/blobfusetmp + ``` + +1. Create the mount point, if it doesn't exists: + ```bash + mkdir -p ${MOUNT_POINT} + ``` + +1. Uncomment `user_allow_other` in the `/etc/fuse.conf` file: `sudo nano /etc/fuse.conf` +1. Mount container(replace `your_container`): + + ```bash + blobfuse ${MOUNT_POINT} --container-name= --tmp-path=/mnt/blobfusetmp -o allow_other + ``` + +#### Automatically mount +Follow the first 7 mounting steps above. +##### Using fstab + +1. Create configuration file `connection.cfg` with same content, change accountName, + select one from accountKey or sasToken and replace with your value: + + ```bash + accountName + # Please provide either an account key or a SAS token, and delete the other line. + accountKey + #change authType to specify only 1 + sasToken + authType + containerName + ``` + +1. Create a bash script named `azure_fuse`(e.g in /usr/bin, as root) with content below + (replace `user_name` on whose behalf the disk will be mounted, `mount_point`, `/path/to/blobfusetmp`,`/path/to/connection.cfg`): + + ```bash + #!/bin/bash + sudo -u blobfuse --tmp-path=/path/to/blobfusetmp --config-file=/path/to/connection.cfg -o allow_other + exit 0 + ``` + +1. Give it the execution permission: + ```bash + sudo chmod +x /usr/bin/azure_fuse + ``` + +1. Edit `/etc/fstab` with the blobfuse script. Add the following line(replace paths): +```bash +/absolute/path/to/azure_fuse fuse allow_other,user,_netdev +``` + +##### Using systemd + +1. Create unit file `sudo nano /etc/systemd/system/blobfuse.service`. + (replace `user_name`, `mount_point`, `container_name`,`/path/to/connection.cfg`): + + ```bash + [Unit] + Description=FUSE filesystem over Azure container + After=network.target + + [Service] + Environment="MOUNT_POINT=" + User= + Group= + ExecStart=blobfuse ${MOUNT_POINT} --container-name= --tmp-path=/mnt/blobfusetmp --config-file=/path/to/connection.cfg -o allow_other + ExecStop=fusermount -u ${MOUNT_POINT} + Restart=always + Type=forking + + [Install] + WantedBy=multi-user.target + ``` + +1. Update the system configurations, enable unit autorun when the system boots, mount the container: + + ```bash + sudo systemctl daemon-reload + sudo systemctl enable blobfuse.service + sudo systemctl start blobfuse.service + ``` + Or for more detail [see here](https://github.com/Azure/azure-storage-fuse/tree/master/systemd) + +#### Check +A file `/etc/mtab` contains records of currently mounted filesystems. +```bash +cat /etc/mtab | grep 'blobfuse' +``` + +#### Unmount filesystem +```bash +fusermount -u +``` + +If you used [systemd](#azure_using_systemd) to mount a container: + +```bash +sudo systemctl stop blobfuse.service +sudo systemctl disable blobfuse.service +``` + +If you have any mounting problems, check out the [answers](https://github.com/Azure/azure-storage-fuse/wiki/3.-Troubleshoot-FAQ) +to common problems + +## Google Drive as filesystem +### Ubuntu 20.04 +#### Mount +To mount a google drive as a filesystem in user space(FUSE) +you can use [google-drive-ocamlfuse](https://github.com/astrada/google-drive-ocamlfuse) +To do this follow the instructions below: + +1. Install google-drive-ocamlfuse: + + ```bash + sudo add-apt-repository ppa:alessandro-strada/ppa + sudo apt-get update + sudo apt-get install google-drive-ocamlfuse + ``` + +1. Run `google-drive-ocamlfuse` without parameters: + + ```bash + google-drive-ocamlfuse + ``` + + This command will create the default application directory (~/.gdfuse/default), + containing the configuration file config (see the [wiki](https://github.com/astrada/google-drive-ocamlfuse/wiki) + page for more details about configuration). + And it will start a web browser to obtain authorization to access your Google Drive. + This will let you modify default configuration before mounting the filesystem. + + Then you can choose a local directory to mount your Google Drive (e.g.: ~/GoogleDrive). + +1. Create the mount point, if it doesn't exist(replace mount_point): + + ```bash + mountpoint="" + mkdir -p $mountpoint + ``` + +1. Uncomment `user_allow_other` in the `/etc/fuse.conf` file: `sudo nano /etc/fuse.conf` +1. Mount the filesystem: + + ```bash + google-drive-ocamlfuse -o allow_other $mountpoint + ``` + +#### Automatically mount +Follow the first 4 mounting steps above. +##### Using fstab + +1. Create a bash script named gdfuse(e.g in /usr/bin, as root) with this content + (replace `user_name` on whose behalf the disk will be mounted, `label`, `mount_point`): + + ```bash + #!/bin/bash + sudo -u google-drive-ocamlfuse -o allow_other -label