Merged develop

main
Boris Sekachev 5 years ago
commit 2c132012c2

@ -9,8 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Manual review pipeline: issues/comments/workspace (<https://github.com/openvinotoolkit/cvat/pull/2357>)
- Added basic projects implementation (<https://github.com/openvinotoolkit/cvat/pull/2255>) - Added basic projects implementation (<https://github.com/openvinotoolkit/cvat/pull/2255>)
<<<<<<< HEAD
- Tooltips in label selectors (<https://github.com/openvinotoolkit/cvat/pull/2509>) - Tooltips in label selectors (<https://github.com/openvinotoolkit/cvat/pull/2509>)
=======
- Added documentation on how to mount cloud starage(AWS S3 bucket, Azure container, Google Drive) as FUSE (<https://github.com/openvinotoolkit/cvat/pull/2377>)
- Added ability to work with share files without copying inside (<https://github.com/openvinotoolkit/cvat/pull/2377>)
>>>>>>> develop
### Changed ### Changed
@ -67,6 +73,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- MOTS png mask format support (<https://github.com/openvinotoolkit/cvat/pull/2198>) - MOTS png mask format support (<https://github.com/openvinotoolkit/cvat/pull/2198>)
- Ability to correct upload video with a rotation record in the metadata (<https://github.com/openvinotoolkit/cvat/pull/2218>) - Ability to correct upload video with a rotation record in the metadata (<https://github.com/openvinotoolkit/cvat/pull/2218>)
- User search field for assignee fields (<https://github.com/openvinotoolkit/cvat/pull/2370>) - User search field for assignee fields (<https://github.com/openvinotoolkit/cvat/pull/2370>)
- Support of mxf videos (<https://github.com/openvinotoolkit/cvat/pull/2514>)
### Changed ### Changed

@ -19,7 +19,7 @@ annotation team. Try it online [cvat.org](https://cvat.org).
- [Installation guide](cvat/apps/documentation/installation.md) - [Installation guide](cvat/apps/documentation/installation.md)
- [User's guide](cvat/apps/documentation/user_guide.md) - [User's guide](cvat/apps/documentation/user_guide.md)
- [Django REST API documentation](#rest-api) - [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/) - [Command line interface](utils/cli/)
- [XML annotation format](cvat/apps/documentation/xml_format.md) - [XML annotation format](cvat/apps/documentation/xml_format.md)
- [AWS Deployment Guide](cvat/apps/documentation/AWS-Deployment-Guide.md) - [AWS Deployment Guide](cvat/apps/documentation/AWS-Deployment-Guide.md)

@ -50,12 +50,13 @@ Canvas itself handles:
IDLE = 'idle', IDLE = 'idle',
DRAG = 'drag', DRAG = 'drag',
RESIZE = 'resize', RESIZE = 'resize',
INTERACT = 'interact',
DRAW = 'draw', DRAW = 'draw',
EDIT = 'edit', EDIT = 'edit',
MERGE = 'merge', MERGE = 'merge',
SPLIT = 'split', SPLIT = 'split',
GROUP = 'group', GROUP = 'group',
INTERACT = 'interact',
SELECT_ROI = 'select_roi',
DRAG_CANVAS = 'drag_canvas', DRAG_CANVAS = 'drag_canvas',
ZOOM_CANVAS = 'zoom_canvas', ZOOM_CANVAS = 'zoom_canvas',
} }
@ -111,23 +112,24 @@ Canvas itself handles:
interface Canvas { interface Canvas {
html(): HTMLDivElement; html(): HTMLDivElement;
setZLayer(zLayer: number | null): void; setup(frameData: any, objectStates: any[], zLayer?: number): void;
setup(frameData: any, objectStates: any[]): void; setupReviewROIs(reviewROIs: Record<number, number[]>): void;
activate(clientID: number, attributeID?: number): void; activate(clientID: number | null, attributeID?: number): void;
rotate(frameAngle: number): void; rotate(rotationAngle: number): void;
focus(clientID: number, padding?: number): void; focus(clientID: number, padding?: number): void;
fit(): void; fit(): void;
grid(stepX: number, stepY: number): void; grid(stepX: number, stepY: number): void;
draw(drawData: DrawData): void;
interact(interactionData: InteractionData): void; interact(interactionData: InteractionData): void;
draw(drawData: DrawData): void;
group(groupData: GroupData): void; group(groupData: GroupData): void;
split(splitData: SplitData): void; split(splitData: SplitData): void;
merge(mergeData: MergeData): void; merge(mergeData: MergeData): void;
select(objectState: any): void; select(objectState: any): void;
fitCanvas(): void; fitCanvas(): void;
bitmap(enabled: boolean): void; bitmap(enable: boolean): void;
selectROI(enable: boolean): void;
dragCanvas(enable: boolean): void; dragCanvas(enable: boolean): void;
zoomCanvas(enable: boolean): void; zoomCanvas(enable: boolean): void;
@ -135,6 +137,8 @@ Canvas itself handles:
cancel(): void; cancel(): void;
configure(configuration: Configuration): void; configure(configuration: Configuration): void;
isAbleToChangeFrame(): boolean; isAbleToChangeFrame(): boolean;
readonly geometry: Geometry;
} }
``` ```
@ -147,11 +151,14 @@ Canvas itself handles:
`cvat_canvas_shape_merging`, `cvat_canvas_shape_merging`,
`cvat_canvas_shape_drawing`, `cvat_canvas_shape_drawing`,
`cvat_canvas_shape_occluded` `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` - Drawn texts have the class `cvat_canvas_text`
- Tags have the class `cvat_canvas_tag` - Tags have the class `cvat_canvas_tag`
- Canvas image has ID `cvat_canvas_image` - Canvas image has ID `cvat_canvas_image`
- Grid on the canvas has ID `cvat_canvas_grid` and `cvat_canvas_grid_pattern` - Grid on the canvas has ID `cvat_canvas_grid` and `cvat_canvas_grid_pattern`
- Crosshair during a draw has class `cvat_canvas_crosshair` - 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 ### Events
@ -178,8 +185,10 @@ Standard JS events are used.
- canvas.zoom - canvas.zoom
- canvas.fit - canvas.fit
- canvas.dragshape => {id: number} - canvas.dragshape => {id: number}
- canvas.roiselected => {points: number[]}
- canvas.resizeshape => {id: number} - canvas.resizeshape => {id: number}
- canvas.contextmenu => { mouseEvent: MouseEvent, objectState: ObjectState, pointID: number } - canvas.contextmenu => { mouseEvent: MouseEvent, objectState: ObjectState, pointID: number }
- canvas.error => { exception: Error }
``` ```
### WEB ### WEB
@ -205,28 +214,33 @@ canvas.draw({
}); });
``` ```
<!--lint disable maximum-line-length-->
## API Reaction ## API Reaction
| | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS | INTERACT | | | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS | INTERACT |
| ------------ | ---- | ----- | ----- | ---- | ----- | ---- | ---- | ------ | ----------- | ----------- | -------- | | ----------------- | ---- | ----- | ----- | ---- | ----- | ---- | ---- | ------ | ----------- | ----------- | -------- |
| setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + | + | | setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + | + |
| activate() | + | - | - | - | - | - | - | - | - | - | - | | activate() | + | - | - | - | - | - | - | - | - | - | - |
| rotate() | + | + | + | + | + | + | + | + | + | + | + | | rotate() | + | + | + | + | + | + | + | + | + | + | + |
| focus() | + | + | + | + | + | + | + | + | + | + | + | | focus() | + | + | + | + | + | + | + | + | + | + | + |
| fit() | + | + | + | + | + | + | + | + | + | + | + | | fit() | + | + | + | + | + | + | + | + | + | + | + |
| grid() | + | + | + | + | + | + | + | + | + | + | + | | grid() | + | + | + | + | + | + | + | + | + | + | + |
| draw() | + | - | - | + | - | - | - | - | - | - | - | | draw() | + | - | - | + | - | - | - | - | - | - | - |
| interact() | + | - | - | - | - | - | - | - | - | - | + | | interact() | + | - | - | - | - | - | - | - | - | - | + |
| split() | + | - | + | - | - | - | - | - | - | - | - | | split() | + | - | + | - | - | - | - | - | - | - | - |
| group() | + | + | - | - | - | - | - | - | - | - | - | | group() | + | + | - | - | - | - | - | - | - | - | - |
| merge() | + | - | - | - | + | - | - | - | - | - | - | | merge() | + | - | - | - | + | - | - | - | - | - | - |
| fitCanvas() | + | + | + | + | + | + | + | + | + | + | + | | fitCanvas() | + | + | + | + | + | + | + | + | + | + | + |
| dragCanvas() | + | - | - | - | - | - | + | - | - | + | - | | dragCanvas() | + | - | - | - | - | - | + | - | - | + | - |
| zoomCanvas() | + | - | - | - | - | - | - | + | + | - | - | | zoomCanvas() | + | - | - | - | - | - | - | + | + | - | - |
| cancel() | - | + | + | + | + | + | + | + | + | + | + | | cancel() | - | + | + | + | + | + | + | + | + | + | + |
| configure() | + | + | + | + | + | + | + | + | + | + | + | | configure() | + | + | + | + | + | + | + | + | + | + | + |
| bitmap() | + | + | + | + | + | + | + | + | + | + | + | | bitmap() | + | + | + | + | + | + | + | + | + | + | + |
| setZLayer() | + | + | + | + | + | + | + | + | + | + | + | | setZLayer() | + | + | + | + | + | + | + | + | + | + | + |
| setupReviewROIs() | + | + | + | + | + | + | + | + | + | + | + |
<!--lint enable maximum-line-length-->
You can call setup() during editing, dragging, and resizing only to update objects, not to change a frame. 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 You can change frame during draw only when you do not redraw an existing object

@ -1,6 +1,6 @@
{ {
"name": "cvat-canvas", "name": "cvat-canvas",
"version": "2.1.3", "version": "2.2.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

@ -1,6 +1,6 @@
{ {
"name": "cvat-canvas", "name": "cvat-canvas",
"version": "2.1.3", "version": "2.2.0",
"description": "Part of Computer Vision Annotation Tool which presents its canvas library", "description": "Part of Computer Vision Annotation Tool which presents its canvas library",
"main": "src/canvas.ts", "main": "src/canvas.ts",
"scripts": { "scripts": {

@ -58,6 +58,23 @@ polyline.cvat_shape_drawing_opacity {
fill: darkmagenta; 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 { polyline.cvat_canvas_shape_grouping {
@extend .cvat_shape_action_dasharray; @extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity; @extend .cvat_shape_action_opacity;
@ -258,6 +275,15 @@ polyline.cvat_canvas_shape_splitting {
height: 100%; height: 100%;
} }
#cvat_canvas_attachment_board {
position: absolute;
z-index: 4;
pointer-events: none;
width: 100%;
height: 100%;
user-select: none;
}
@keyframes loadingAnimation { @keyframes loadingAnimation {
0% { 0% {
stroke-dashoffset: 1; stroke-dashoffset: 1;

@ -15,6 +15,7 @@ import {
RectDrawingMethod, RectDrawingMethod,
CuboidDrawingMethod, CuboidDrawingMethod,
Configuration, Configuration,
Geometry,
} from './canvasModel'; } from './canvasModel';
import { Master } from './master'; import { Master } from './master';
import { CanvasController, CanvasControllerImpl } from './canvasController'; import { CanvasController, CanvasControllerImpl } from './canvasController';
@ -28,6 +29,7 @@ const CanvasVersion = pjson.version;
interface Canvas { interface Canvas {
html(): HTMLDivElement; html(): HTMLDivElement;
setup(frameData: any, objectStates: any[], zLayer?: number): void; setup(frameData: any, objectStates: any[], zLayer?: number): void;
setupIssueRegions(issueRegions: Record<number, number[]>): void;
activate(clientID: number | null, attributeID?: number): void; activate(clientID: number | null, attributeID?: number): void;
rotate(rotationAngle: number): void; rotate(rotationAngle: number): void;
focus(clientID: number, padding?: number): void; focus(clientID: number, padding?: number): void;
@ -43,6 +45,7 @@ interface Canvas {
fitCanvas(): void; fitCanvas(): void;
bitmap(enable: boolean): void; bitmap(enable: boolean): void;
selectRegion(enable: boolean): void;
dragCanvas(enable: boolean): void; dragCanvas(enable: boolean): void;
zoomCanvas(enable: boolean): void; zoomCanvas(enable: boolean): void;
@ -50,6 +53,8 @@ interface Canvas {
cancel(): void; cancel(): void;
configure(configuration: Configuration): void; configure(configuration: Configuration): void;
isAbleToChangeFrame(): boolean; isAbleToChangeFrame(): boolean;
readonly geometry: Geometry;
} }
class CanvasImpl implements Canvas { class CanvasImpl implements Canvas {
@ -71,6 +76,10 @@ class CanvasImpl implements Canvas {
this.model.setup(frameData, objectStates, zLayer); this.model.setup(frameData, objectStates, zLayer);
} }
public setupIssueRegions(issueRegions: Record<number, number[]>): void {
this.model.setupIssueRegions(issueRegions);
}
public fitCanvas(): void { public fitCanvas(): void {
this.model.fitCanvas(this.view.html().clientWidth, this.view.html().clientHeight); this.model.fitCanvas(this.view.html().clientWidth, this.view.html().clientHeight);
} }
@ -79,6 +88,10 @@ class CanvasImpl implements Canvas {
this.model.bitmap(enable); this.model.bitmap(enable);
} }
public selectRegion(enable: boolean): void {
this.model.selectRegion(enable);
}
public dragCanvas(enable: boolean): void { public dragCanvas(enable: boolean): void {
this.model.dragCanvas(enable); this.model.dragCanvas(enable);
} }
@ -146,6 +159,10 @@ class CanvasImpl implements Canvas {
public isAbleToChangeFrame(): boolean { public isAbleToChangeFrame(): boolean {
return this.model.isAbleToChangeFrame(); return this.model.isAbleToChangeFrame();
} }
public get geometry(): Geometry {
return this.model.geometry;
}
} }
export { export {

@ -14,10 +14,12 @@ import {
GroupData, GroupData,
Mode, Mode,
InteractionData, InteractionData,
Configuration,
} from './canvasModel'; } from './canvasModel';
export interface CanvasController { export interface CanvasController {
readonly objects: any[]; readonly objects: any[];
readonly issueRegions: Record<number, number[]>;
readonly zLayer: number | null; readonly zLayer: number | null;
readonly focusData: FocusData; readonly focusData: FocusData;
readonly activeElement: ActiveElement; readonly activeElement: ActiveElement;
@ -27,6 +29,7 @@ export interface CanvasController {
readonly splitData: SplitData; readonly splitData: SplitData;
readonly groupData: GroupData; readonly groupData: GroupData;
readonly selected: any; readonly selected: any;
readonly configuration: Configuration;
mode: Mode; mode: Mode;
geometry: Geometry; geometry: Geometry;
@ -36,6 +39,7 @@ export interface CanvasController {
merge(mergeData: MergeData): void; merge(mergeData: MergeData): void;
split(splitData: SplitData): void; split(splitData: SplitData): void;
group(groupData: GroupData): void; group(groupData: GroupData): void;
selectRegion(enabled: boolean): void;
enableDrag(x: number, y: number): void; enableDrag(x: number, y: number): void;
drag(x: number, y: number): void; drag(x: number, y: number): void;
disableDrag(): void; disableDrag(): void;
@ -103,6 +107,10 @@ export class CanvasControllerImpl implements CanvasController {
this.model.group(groupData); this.model.group(groupData);
} }
public selectRegion(enable: boolean): void {
this.model.selectRegion(enable);
}
public get geometry(): Geometry { public get geometry(): Geometry {
return this.model.geometry; return this.model.geometry;
} }
@ -115,6 +123,10 @@ export class CanvasControllerImpl implements CanvasController {
return this.model.zLayer; return this.model.zLayer;
} }
public get issueRegions(): Record<number, number[]> {
return this.model.issueRegions;
}
public get objects(): any[] { public get objects(): any[] {
return this.model.objects; return this.model.objects;
} }
@ -151,6 +163,10 @@ export class CanvasControllerImpl implements CanvasController {
return this.model.selected; return this.model.selected;
} }
public get configuration(): Configuration {
return this.model.configuration;
}
public set mode(value: Mode) { public set mode(value: Mode) {
this.model.mode = value; this.model.mode = value;
} }

@ -56,6 +56,7 @@ export interface Configuration {
displayAllText?: boolean; displayAllText?: boolean;
undefinedAttrValue?: string; undefinedAttrValue?: string;
showProjections?: boolean; showProjections?: boolean;
forceDisableEditing?: boolean;
} }
export interface DrawData { export interface DrawData {
@ -113,6 +114,7 @@ export enum UpdateReasons {
IMAGE_MOVED = 'image_moved', IMAGE_MOVED = 'image_moved',
GRID_UPDATED = 'grid_updated', GRID_UPDATED = 'grid_updated',
ISSUE_REGIONS_UPDATED = 'issue_regions_updated',
OBJECTS_UPDATED = 'objects_updated', OBJECTS_UPDATED = 'objects_updated',
SHAPE_ACTIVATED = 'shape_activated', SHAPE_ACTIVATED = 'shape_activated',
SHAPE_FOCUSED = 'shape_focused', SHAPE_FOCUSED = 'shape_focused',
@ -127,9 +129,11 @@ export enum UpdateReasons {
SELECT = 'select', SELECT = 'select',
CANCEL = 'cancel', CANCEL = 'cancel',
BITMAP = 'bitmap', BITMAP = 'bitmap',
SELECT_REGION = 'select_region',
DRAG_CANVAS = 'drag_canvas', DRAG_CANVAS = 'drag_canvas',
ZOOM_CANVAS = 'zoom_canvas', ZOOM_CANVAS = 'zoom_canvas',
CONFIG_UPDATED = 'config_updated', CONFIG_UPDATED = 'config_updated',
DATA_FAILED = 'data_failed',
} }
export enum Mode { export enum Mode {
@ -142,6 +146,7 @@ export enum Mode {
SPLIT = 'split', SPLIT = 'split',
GROUP = 'group', GROUP = 'group',
INTERACT = 'interact', INTERACT = 'interact',
SELECT_REGION = 'select_region',
DRAG_CANVAS = 'drag_canvas', DRAG_CANVAS = 'drag_canvas',
ZOOM_CANVAS = 'zoom_canvas', ZOOM_CANVAS = 'zoom_canvas',
} }
@ -149,6 +154,7 @@ export enum Mode {
export interface CanvasModel { export interface CanvasModel {
readonly imageBitmap: boolean; readonly imageBitmap: boolean;
readonly image: Image | null; readonly image: Image | null;
readonly issueRegions: Record<number, number[]>;
readonly objects: any[]; readonly objects: any[];
readonly zLayer: number | null; readonly zLayer: number | null;
readonly gridSize: Size; readonly gridSize: Size;
@ -163,11 +169,13 @@ export interface CanvasModel {
readonly selected: any; readonly selected: any;
geometry: Geometry; geometry: Geometry;
mode: Mode; mode: Mode;
exception: Error | null;
zoom(x: number, y: number, direction: number): void; zoom(x: number, y: number, direction: number): void;
move(topOffset: number, leftOffset: number): void; move(topOffset: number, leftOffset: number): void;
setup(frameData: any, objectStates: any[], zLayer: number): void; setup(frameData: any, objectStates: any[], zLayer: number): void;
setupIssueRegions(issueRegions: Record<number, number[]>): void;
activate(clientID: number | null, attributeID: number | null): void; activate(clientID: number | null, attributeID: number | null): void;
rotate(rotationAngle: number): void; rotate(rotationAngle: number): void;
focus(clientID: number, padding: number): void; focus(clientID: number, padding: number): void;
@ -183,6 +191,7 @@ export interface CanvasModel {
fitCanvas(width: number, height: number): void; fitCanvas(width: number, height: number): void;
bitmap(enabled: boolean): void; bitmap(enabled: boolean): void;
selectRegion(enabled: boolean): void;
dragCanvas(enable: boolean): void; dragCanvas(enable: boolean): void;
zoomCanvas(enable: boolean): void; zoomCanvas(enable: boolean): void;
@ -206,6 +215,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
gridSize: Size; gridSize: Size;
left: number; left: number;
objects: any[]; objects: any[];
issueRegions: Record<number, number[]>;
scale: number; scale: number;
top: number; top: number;
zLayer: number | null; zLayer: number | null;
@ -216,6 +226,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
splitData: SplitData; splitData: SplitData;
selected: any; selected: any;
mode: Mode; mode: Mode;
exception: Error | null;
}; };
public constructor() { public constructor() {
@ -254,6 +265,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
}, },
left: 0, left: 0,
objects: [], objects: [],
issueRegions: {},
scale: 1, scale: 1,
top: 0, top: 0,
zLayer: null, zLayer: null,
@ -275,6 +287,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
}, },
selected: null, selected: null,
mode: Mode.IDLE, 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); const mutiplier = Math.sin((angle * Math.PI) / 180) + Math.cos((angle * Math.PI) / 180);
if ((angle / 90) % 2) { if ((angle / 90) % 2) {
// 90, 270, .. // 90, 270, ..
this.data.top += const topMultiplier = (x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1);
mutiplier * ((x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1)) * this.data.scale; const leftMultiplier = (y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1);
this.data.left -= this.data.top += mutiplier * topMultiplier * this.data.scale;
mutiplier * ((y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1)) * this.data.scale; this.data.left -= mutiplier * leftMultiplier * this.data.scale;
} else { } else {
this.data.left += const leftMultiplier = (x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1);
mutiplier * ((x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1)) * this.data.scale; const topMultiplier = (y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1);
this.data.top += this.data.left += mutiplier * leftMultiplier * this.data.scale;
mutiplier * ((y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1)) * this.data.scale; this.data.top += mutiplier * topMultiplier * this.data.scale;
} }
this.notify(UpdateReasons.IMAGE_ZOOMED); this.notify(UpdateReasons.IMAGE_ZOOMED);
@ -325,6 +338,19 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.notify(UpdateReasons.BITMAP); 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 { public dragCanvas(enable: boolean): void {
if (enable && this.data.mode !== Mode.IDLE) { if (enable && this.data.mode !== Mode.IDLE) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`); 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); this.notify(UpdateReasons.OBJECTS_UPDATED);
}) })
.catch((exception: any): void => { .catch((exception: any): void => {
this.data.exception = exception;
this.notify(UpdateReasons.DATA_FAILED);
throw exception; throw exception;
}); });
} }
public setupIssueRegions(issueRegions: Record<number, number[]>): void {
this.data.issueRegions = issueRegions;
this.notify(UpdateReasons.ISSUE_REGIONS_UPDATED);
}
public activate(clientID: number | null, attributeID: number | null): void { public activate(clientID: number | null, attributeID: number | null): void {
if (this.data.activeElement.clientID === clientID && this.data.activeElement.attributeID === attributeID) { if (this.data.activeElement.clientID === clientID && this.data.activeElement.attributeID === attributeID) {
return; return;
@ -599,13 +632,16 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.data.configuration.undefinedAttrValue = configuration.undefinedAttrValue; this.data.configuration.undefinedAttrValue = configuration.undefinedAttrValue;
} }
if (typeof configuration.forceDisableEditing !== 'undefined') {
this.data.configuration.forceDisableEditing = configuration.forceDisableEditing;
}
this.notify(UpdateReasons.CONFIG_UPDATED); this.notify(UpdateReasons.CONFIG_UPDATED);
} }
public isAbleToChangeFrame(): boolean { public isAbleToChangeFrame(): boolean {
const isUnable = const isUnable = [Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode)
[Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode) || || (this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number');
(this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number');
return !isUnable; return !isUnable;
} }
@ -658,6 +694,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
return this.data.image; return this.data.image;
} }
public get issueRegions(): Record<number, number[]> {
return { ...this.data.issueRegions };
}
public get objects(): any[] { public get objects(): any[] {
if (this.data.zLayer !== null) { if (this.data.zLayer !== null) {
return this.data.objects.filter((object: any): boolean => object.zOrder <= this.data.zLayer); 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 { public get mode(): Mode {
return this.data.mode; return this.data.mode;
} }
public get exception(): Error {
return this.data.exception;
}
} }

@ -15,6 +15,7 @@ import { EditHandler, EditHandlerImpl } from './editHandler';
import { MergeHandler, MergeHandlerImpl } from './mergeHandler'; import { MergeHandler, MergeHandlerImpl } from './mergeHandler';
import { SplitHandler, SplitHandlerImpl } from './splitHandler'; import { SplitHandler, SplitHandlerImpl } from './splitHandler';
import { GroupHandler, GroupHandlerImpl } from './groupHandler'; import { GroupHandler, GroupHandlerImpl } from './groupHandler';
import { RegionSelector, RegionSelectorImpl } from './regionSelector';
import { ZoomHandler, ZoomHandlerImpl } from './zoomHandler'; import { ZoomHandler, ZoomHandlerImpl } from './zoomHandler';
import { InteractionHandler, InteractionHandlerImpl } from './interactionHandler'; import { InteractionHandler, InteractionHandlerImpl } from './interactionHandler';
import { AutoborderHandler, AutoborderHandlerImpl } from './autoborderHandler'; import { AutoborderHandler, AutoborderHandlerImpl } from './autoborderHandler';
@ -59,6 +60,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
private bitmap: HTMLCanvasElement; private bitmap: HTMLCanvasElement;
private grid: SVGSVGElement; private grid: SVGSVGElement;
private content: SVGSVGElement; private content: SVGSVGElement;
private attachmentBoard: HTMLDivElement;
private adoptedContent: SVG.Container; private adoptedContent: SVG.Container;
private canvas: HTMLDivElement; private canvas: HTMLDivElement;
private gridPath: SVGPathElement; private gridPath: SVGPathElement;
@ -66,13 +68,17 @@ export class CanvasViewImpl implements CanvasView, Listener {
private controller: CanvasController; private controller: CanvasController;
private svgShapes: Record<number, SVG.Shape>; private svgShapes: Record<number, SVG.Shape>;
private svgTexts: Record<number, SVG.Text>; private svgTexts: Record<number, SVG.Text>;
private issueRegionPattern_1: SVG.Pattern;
private issueRegionPattern_2: SVG.Pattern;
private drawnStates: Record<number, DrawnState>; private drawnStates: Record<number, DrawnState>;
private drawnIssueRegions: Record<number, SVG.Shape>;
private geometry: Geometry; private geometry: Geometry;
private drawHandler: DrawHandler; private drawHandler: DrawHandler;
private editHandler: EditHandler; private editHandler: EditHandler;
private mergeHandler: MergeHandler; private mergeHandler: MergeHandler;
private splitHandler: SplitHandler; private splitHandler: SplitHandler;
private groupHandler: GroupHandler; private groupHandler: GroupHandler;
private regionSelector: RegionSelector;
private zoomHandler: ZoomHandler; private zoomHandler: ZoomHandler;
private autoborderHandler: AutoborderHandler; private autoborderHandler: AutoborderHandler;
private interactionHandler: InteractionHandler; private interactionHandler: InteractionHandler;
@ -90,6 +96,31 @@ export class CanvasViewImpl implements CanvasView, Listener {
return this.controller.mode; 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 { private isServiceHidden(clientID: number): boolean {
return this.serviceFlags.drawHidden[clientID] || false; return this.serviceFlags.drawHidden[clientID] || false;
} }
@ -329,6 +360,30 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.mode = Mode.IDLE; 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 { private onFindObject(e: MouseEvent): void {
if (e.which === 1 || e.which === 0) { if (e.which === 1 || e.which === 0) {
const { offset } = this.controller.geometry; const { offset } = this.controller.geometry;
@ -401,7 +456,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
obj.style.left = `${this.geometry.left}px`; 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.top = `${this.geometry.top - this.geometry.offset}px`;
obj.style.left = `${this.geometry.left - 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.zoomHandler.transform(this.geometry);
this.autoborderHandler.transform(this.geometry); this.autoborderHandler.transform(this.geometry);
this.interactionHandler.transform(this.geometry); this.interactionHandler.transform(this.geometry);
this.regionSelector.transform(this.geometry);
} }
private transformCanvas(): void { private transformCanvas(): void {
// Transform canvas // 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)`; 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 // Transform all text
for (const key in this.svgShapes) { for (const key in this.svgShapes) {
if ( if (
Object.prototype.hasOwnProperty.call(this.svgShapes, key) && Object.prototype.hasOwnProperty.call(this.svgShapes, key)
Object.prototype.hasOwnProperty.call(this.svgTexts, key) && Object.prototype.hasOwnProperty.call(this.svgTexts, key)
) { ) {
this.updateTextPosition(this.svgTexts[key], this.svgShapes[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 // Transform handlers
this.drawHandler.transform(this.geometry); this.drawHandler.transform(this.geometry);
this.editHandler.transform(this.geometry); this.editHandler.transform(this.geometry);
this.zoomHandler.transform(this.geometry); this.zoomHandler.transform(this.geometry);
this.autoborderHandler.transform(this.geometry); this.autoborderHandler.transform(this.geometry);
this.interactionHandler.transform(this.geometry); this.interactionHandler.transform(this.geometry);
this.regionSelector.transform(this.geometry);
} }
private resizeCanvas(): void { private resizeCanvas(): void {
@ -476,16 +554,66 @@ export class CanvasViewImpl implements CanvasView, Listener {
obj.style.height = `${this.geometry.image.height}px`; 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.width = `${this.geometry.image.width + this.geometry.offset * 2}px`;
obj.style.height = `${this.geometry.image.height + this.geometry.offset * 2}px`; obj.style.height = `${this.geometry.image.height + this.geometry.offset * 2}px`;
} }
} }
private setupObjects(states: any[]): void { private setupIssueRegions(issueRegions: Record<number, number[]>): void {
const { offset } = this.controller.geometry; for (const issueRegion of Object.keys(this.drawnIssueRegions)) {
const translate = (points: number[]): number[] => points.map((coord: number): number => coord + offset); 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 created = [];
const updated = []; const updated = [];
for (const state of states) { for (const state of states) {
@ -520,8 +648,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
delete this.drawnStates[state.clientID]; delete this.drawnStates[state.clientID];
} }
this.addObjects(created, translate); this.addObjects(created);
this.updateObjects(updated, translate); this.updateObjects(updated);
this.sortObjects(); this.sortObjects();
if (this.controller.activeElement.clientID !== null) { if (this.controller.activeElement.clientID !== null) {
@ -610,8 +738,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
private selectize(value: boolean, shape: SVG.Element): void { private selectize(value: boolean, shape: SVG.Element): void {
const self = this; 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 { function mousedownHandler(e: MouseEvent): void {
if (e.button !== 0) return; if (e.button !== 0) return;
@ -661,7 +787,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
if (state.shapeType === 'cuboid') { if (state.shapeType === 'cuboid') {
if (e.shiftKey) { if (e.shiftKey) {
const points = translate( const points = self.translateFromCanvas(
pointsToNumberArray((e.target as any).parentElement.parentElement.instance.attr('points')), pointsToNumberArray((e.target as any).parentElement.parentElement.instance.attr('points')),
); );
self.onEditDone(state, points); self.onEditDone(state, points);
@ -753,6 +879,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.svgShapes = {}; this.svgShapes = {};
this.svgTexts = {}; this.svgTexts = {};
this.drawnStates = {}; this.drawnStates = {};
this.drawnIssueRegions = {};
this.activeElement = { this.activeElement = {
clientID: null, clientID: null,
attributeID: 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.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.adoptedContent = SVG.adopt((this.content as any) as HTMLElement) as SVG.Container;
this.attachmentBoard = window.document.createElement('div');
this.canvas = window.document.createElement('div'); this.canvas = window.document.createElement('div');
const loadingCircle: SVGCircleElement = window.document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 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 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'); 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 // Setup loading animation
this.loadingAnimation.setAttribute('id', 'cvat_canvas_loading_animation'); this.loadingAnimation.setAttribute('id', 'cvat_canvas_loading_animation');
loadingCircle.setAttribute('id', 'cvat_canvas_loading_circle'); 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.setAttribute('id', 'cvat_canvas_bitmap');
this.bitmap.style.display = 'none'; this.bitmap.style.display = 'none';
// Setup sticked div
this.attachmentBoard.setAttribute('id', 'cvat_canvas_attachment_board');
// Setup wrappers // Setup wrappers
this.canvas.setAttribute('id', 'cvat_canvas_wrapper'); 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.bitmap);
this.canvas.appendChild(this.grid); this.canvas.appendChild(this.grid);
this.canvas.appendChild(this.content); this.canvas.appendChild(this.content);
this.canvas.appendChild(this.attachmentBoard);
const self = this; const self = this;
@ -858,6 +1013,11 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.onFindObject.bind(this), this.onFindObject.bind(this),
this.adoptedContent, 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.zoomHandler = new ZoomHandlerImpl(this.onFocusRegion.bind(this), this.adoptedContent, this.geometry);
this.interactionHandler = new InteractionHandlerImpl( this.interactionHandler = new InteractionHandlerImpl(
this.onInteraction.bind(this), this.onInteraction.bind(this),
@ -874,9 +1034,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.content.addEventListener('mousedown', (event): void => { this.content.addEventListener('mousedown', (event): void => {
if ([0, 1].includes(event.button)) { if ([0, 1].includes(event.button)) {
if ( if (
[Mode.IDLE, Mode.DRAG_CANVAS, Mode.MERGE, Mode.SPLIT].includes(this.mode) || [Mode.IDLE, Mode.DRAG_CANVAS, Mode.MERGE, Mode.SPLIT].includes(this.mode)
event.button === 1 || || event.button === 1
event.altKey || event.altKey
) { ) {
self.controller.enableDrag(event.clientX, event.clientY); self.controller.enableDrag(event.clientX, event.clientY);
} }
@ -1022,6 +1182,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
} }
const event: CustomEvent = new CustomEvent('canvas.setup'); const event: CustomEvent = new CustomEvent('canvas.setup');
this.canvas.dispatchEvent(event); this.canvas.dispatchEvent(event);
} else if (reason === UpdateReasons.ISSUE_REGIONS_UPDATED) {
this.setupIssueRegions(this.controller.issueRegions);
} else if (reason === UpdateReasons.GRID_UPDATED) { } else if (reason === UpdateReasons.GRID_UPDATED) {
const size: Size = this.geometry.grid; const size: Size = this.geometry.grid;
this.gridPattern.setAttribute('width', `${size.width}`); this.gridPattern.setAttribute('width', `${size.width}`);
@ -1040,6 +1202,13 @@ export class CanvasViewImpl implements CanvasView, Listener {
} }
} else if (reason === UpdateReasons.SHAPE_ACTIVATED) { } else if (reason === UpdateReasons.SHAPE_ACTIVATED) {
this.activate(this.controller.activeElement); 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) { } else if (reason === UpdateReasons.DRAG_CANVAS) {
if (this.mode === Mode.DRAG_CANVAS) { if (this.mode === Mode.DRAG_CANVAS) {
this.canvas.dispatchEvent( this.canvas.dispatchEvent(
@ -1151,6 +1320,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.splitHandler.cancel(); this.splitHandler.cancel();
} else if (this.mode === Mode.GROUP) { } else if (this.mode === Mode.GROUP) {
this.groupHandler.cancel(); this.groupHandler.cancel();
} else if (this.mode === Mode.SELECT_REGION) {
this.regionSelector.cancel();
} else if (this.mode === Mode.EDIT) { } else if (this.mode === Mode.EDIT) {
this.editHandler.cancel(); this.editHandler.cancel();
} else if (this.mode === Mode.DRAG_CANVAS) { } else if (this.mode === Mode.DRAG_CANVAS) {
@ -1172,6 +1343,14 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.mode = Mode.IDLE; this.mode = Mode.IDLE;
this.canvas.style.cursor = ''; 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)) { if (model.imageBitmap && [UpdateReasons.IMAGE_CHANGED, UpdateReasons.OBJECTS_UPDATED].includes(reason)) {
this.redrawBitmap(); 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) { for (const state of states) {
const { clientID } = state; const { clientID } = state;
const drawnState = this.drawnStates[clientID]; const drawnState = this.drawnStates[clientID];
@ -1325,10 +1504,10 @@ export class CanvasViewImpl implements CanvasView, Listener {
} }
if ( if (
state.points.length !== drawnState.points.length || state.points.length !== drawnState.points.length
state.points.some((p: number, id: number): boolean => p !== drawnState.points[id]) || 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') { if (state.shapeType === 'rectangle') {
const [xtl, ytl, xbr, ybr] = translatedPoints; const [xtl, ytl, xbr, ybr] = translatedPoints;
@ -1340,13 +1519,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
height: ybr - ytl, height: ybr - ytl,
}); });
} else { } else {
const stringified = translatedPoints.reduce((acc: string, val: number, idx: number): string => { const stringified = this.stringifyToCanvas(translatedPoints);
if (idx % 2) {
return `${acc}${val} `;
}
return `${acc}${val},`;
}, '');
if (state.shapeType !== 'cuboid') { if (state.shapeType !== 'cuboid') {
(shape as any).clear(); (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; const { displayAllText } = this.configuration;
for (const state of states) { for (const state of states) {
const points: number[] = state.points as number[]; 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 // TODO: Use enums after typification cvat-core
if (state.shapeType === 'rectangle') { if (state.shapeType === 'rectangle') {
this.svgShapes[state.clientID] = this.addRect(translatedPoints, state); this.svgShapes[state.clientID] = this.addRect(translatedPoints, state);
} else { } else {
const stringified = translatedPoints.reduce((acc: string, val: number, idx: number): string => { const stringified = this.stringifyToCanvas(translatedPoints);
if (idx % 2) {
return `${acc}${val} `;
}
return `${acc}${val},`;
}, '');
if (state.shapeType === 'polygon') { if (state.shapeType === 'polygon') {
this.svgShapes[state.clientID] = this.addPolygon(stringified, state); this.svgShapes[state.clientID] = this.addPolygon(stringified, state);
@ -1542,7 +1709,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
if (state && state.shapeType === 'points') { if (state && state.shapeType === 'points') {
this.svgShapes[clientID] this.svgShapes[clientID]
.remember('_selectHandler') .remember('_selectHandler')
.nested.style('pointer-events', state.lock ? 'none' : ''); .nested.style('pointer-events', this.stateIsLocked(state) ? 'none' : '');
} }
if (!state || state.hidden || state.outside) { if (!state || state.hidden || state.outside) {
@ -1550,8 +1717,14 @@ export class CanvasViewImpl implements CanvasView, Listener {
} }
const shape = this.svgShapes[clientID]; 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; return;
} }
@ -1567,12 +1740,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
(shape as any).attr('projections', true); (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 => { const hideText = (): void => {
if (text) { if (text) {
text.addClass('cvat_canvas_hidden'); text.addClass('cvat_canvas_hidden');
@ -1601,12 +1768,14 @@ export class CanvasViewImpl implements CanvasView, Listener {
const p2 = e.detail.p; const p2 = e.detail.p;
const delta = 1; const delta = 1;
const { offset } = this.controller.geometry; 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( const points = pointsToNumberArray(
shape.attr('points') || shape.attr('points')
`${shape.attr('x')},${shape.attr('y')} ` + || `${shape.attr('x')},${shape.attr('y')} `
`${shape.attr('x') + shape.attr('width')},` + + `${shape.attr('x') + shape.attr('width')},`
`${shape.attr('y') + shape.attr('height')}`, + `${shape.attr('y') + shape.attr('height')}`,
).map((x: number): number => x - offset); ).map((x: number): number => x - offset);
this.drawnStates[state.clientID].points = points; this.drawnStates[state.clientID].points = points;
@ -1677,10 +1846,10 @@ export class CanvasViewImpl implements CanvasView, Listener {
const { offset } = this.controller.geometry; const { offset } = this.controller.geometry;
const points = pointsToNumberArray( const points = pointsToNumberArray(
shape.attr('points') || shape.attr('points')
`${shape.attr('x')},${shape.attr('y')} ` + || `${shape.attr('x')},${shape.attr('y')} `
`${shape.attr('x') + shape.attr('width')},` + + `${shape.attr('x') + shape.attr('width')},`
`${shape.attr('y') + shape.attr('height')}`, + `${shape.attr('y') + shape.attr('height')}`,
).map((x: number): number => x - offset); ).map((x: number): number => x - offset);
this.drawnStates[state.clientID].points = points; this.drawnStates[state.clientID].points = points;
@ -1697,7 +1866,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
} }
}); });
this.updateTextPosition(text, shape);
this.canvas.dispatchEvent( this.canvas.dispatchEvent(
new CustomEvent('canvas.activated', { new CustomEvent('canvas.activated', {
bubbles: false, bubbles: false,
@ -1757,8 +1925,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
// Find the best place for a text // Find the best place for a text
let [clientX, clientY]: number[] = [box.x + box.width, box.y]; let [clientX, clientY]: number[] = [box.x + box.width, box.y];
if ( if (
clientX + ((text.node as any) as SVGTextElement).getBBox().width + consts.TEXT_MARGIN > clientX + ((text.node as any) as SVGTextElement).getBBox().width + consts.TEXT_MARGIN
this.canvas.offsetWidth > this.canvas.offsetWidth
) { ) {
[clientX, clientY] = [box.x, box.y]; [clientX, clientY] = [box.x, box.y];
} }
@ -1778,7 +1946,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
private addText(state: any): SVG.Text { private addText(state: any): SVG.Text {
const { undefinedAttrValue } = this.configuration; 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 => { const attrNames = label.attributes.reduce((acc: any, val: any): void => {
acc[val.id] = val.name; acc[val.id] = val.name;
return acc; return acc;

@ -2,21 +2,21 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
const BASE_STROKE_WIDTH = 1.75; const BASE_STROKE_WIDTH = 1.25;
const BASE_GRID_WIDTH = 2; const BASE_GRID_WIDTH = 2;
const BASE_POINT_SIZE = 5; const BASE_POINT_SIZE = 5;
const TEXT_MARGIN = 10; const TEXT_MARGIN = 10;
const AREA_THRESHOLD = 9; const AREA_THRESHOLD = 9;
const SIZE_THRESHOLD = 3; const SIZE_THRESHOLD = 3;
const POINTS_STROKE_WIDTH = 1.5; const POINTS_STROKE_WIDTH = 1;
const POINTS_SELECTED_STROKE_WIDTH = 4; const POINTS_SELECTED_STROKE_WIDTH = 4;
const MIN_EDGE_LENGTH = 3; const MIN_EDGE_LENGTH = 3;
const CUBOID_ACTIVE_EDGE_STROKE_WIDTH = 2.5; const CUBOID_ACTIVE_EDGE_STROKE_WIDTH = 2.5;
const CUBOID_UNACTIVE_EDGE_STROKE_WIDTH = 1.75; const CUBOID_UNACTIVE_EDGE_STROKE_WIDTH = 1.75;
const UNDEFINED_ATTRIBUTE_VALUE = '__undefined__'; const UNDEFINED_ATTRIBUTE_VALUE = '__undefined__';
const ARROW_PATH = const ARROW_PATH = 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 '
'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';
'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 { export default {
BASE_STROKE_WIDTH, BASE_STROKE_WIDTH,
@ -32,4 +32,5 @@ export default {
CUBOID_UNACTIVE_EDGE_STROKE_WIDTH, CUBOID_UNACTIVE_EDGE_STROKE_WIDTH,
UNDEFINED_ATTRIBUTE_VALUE, UNDEFINED_ATTRIBUTE_VALUE,
ARROW_PATH, ARROW_PATH,
BASE_PATTERN_SIZE,
}; };

@ -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,
});
}
}
}

@ -14,7 +14,7 @@ module.exports = {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 2018, 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'], extends: ['eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM', 'airbnb-base'],
rules: { rules: {
'no-await-in-loop': [0], 'no-await-in-loop': [0],

@ -1,6 +1,6 @@
{ {
"name": "cvat-core", "name": "cvat-core",
"version": "3.9.1", "version": "3.10.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -17636,6 +17636,11 @@
"integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=",
"dev": true "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": { "randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",

@ -1,6 +1,6 @@
{ {
"name": "cvat-core", "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", "description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "babel.config.js", "main": "babel.config.js",
"scripts": { "scripts": {
@ -43,6 +43,7 @@
"js-cookie": "^2.2.0", "js-cookie": "^2.2.0",
"jsonpath": "^1.0.2", "jsonpath": "^1.0.2",
"platform": "^1.3.5", "platform": "^1.3.5",
"quickhull": "^1.0.3",
"store": "^2.0.12", "store": "^2.0.12",
"worker-loader": "^2.0.0" "worker-loader": "^2.0.0"
} }

@ -116,7 +116,7 @@
let users = null; let users = null;
if ('self' in filter && filter.self) { if ('self' in filter && filter.self) {
users = await serverProxy.users.getSelf(); users = await serverProxy.users.self();
users = [users]; users = [users];
} else { } else {
const searchParams = {}; const searchParams = {};
@ -125,7 +125,7 @@
searchParams[key] = filter[key]; 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)); users = users.map((user) => new User(user));
@ -146,24 +146,23 @@
throw new ArgumentError('Job filter must not be empty'); throw new ArgumentError('Job filter must not be empty');
} }
let tasks = null; let tasks = [];
if ('taskID' in filter) { if ('taskID' in filter) {
tasks = await serverProxy.tasks.getTasks(`id=${filter.taskID}`); tasks = await serverProxy.tasks.getTasks(`id=${filter.taskID}`);
} else { } else {
const job = await serverProxy.jobs.getJob(filter.jobID); const job = await serverProxy.jobs.get(filter.jobID);
if (typeof job.task_id !== 'undefined') { if (typeof job.task_id !== 'undefined') {
tasks = await serverProxy.tasks.getTasks(`id=${job.task_id}`); 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 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]); const task = new Task(tasks[0]);
return filter.jobID ? task.jobs.filter((job) => job.id === filter.jobID) : task.jobs; return filter.jobID ? task.jobs.filter((job) => job.id === filter.jobID) : task.jobs;
} }
return []; return tasks;
}; };
cvat.tasks.get.implementation = async (filter) => { cvat.tasks.get.implementation = async (filter) => {

@ -13,24 +13,15 @@ function build() {
const Log = require('./log'); const Log = require('./log');
const ObjectState = require('./object-state'); const ObjectState = require('./object-state');
const Statistics = require('./statistics'); const Statistics = require('./statistics');
const Comment = require('./comment');
const Issue = require('./issue');
const Review = require('./review');
const { Job, Task } = require('./session'); const { Job, Task } = require('./session');
const { Project } = require('./project'); const { Project } = require('./project');
const { Attribute, Label } = require('./labels'); const { Attribute, Label } = require('./labels');
const MLModel = require('./ml-model'); const MLModel = require('./ml-model');
const { const enums = require('./enums');
ShareFileType,
TaskStatus,
TaskMode,
AttributeType,
ObjectType,
ObjectShape,
LogType,
HistoryActions,
RQStatus,
colors,
Source,
} = require('./enums');
const { const {
Exception, ArgumentError, DataError, ScriptingError, PluginError, ServerError, Exception, ArgumentError, DataError, ScriptingError, PluginError, ServerError,
@ -741,19 +732,7 @@ function build() {
* @namespace enums * @namespace enums
* @memberof module:API.cvat * @memberof module:API.cvat
*/ */
enums: { enums,
ShareFileType,
TaskStatus,
TaskMode,
AttributeType,
ObjectType,
ObjectShape,
LogType,
HistoryActions,
RQStatus,
colors,
Source,
},
/** /**
* Namespace is used for access to exceptions * Namespace is used for access to exceptions
* @namespace exceptions * @namespace exceptions
@ -783,6 +762,9 @@ function build() {
Statistics, Statistics,
ObjectState, ObjectState,
MLModel, MLModel,
Comment,
Issue,
Review,
}, },
}; };

@ -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;

@ -68,6 +68,13 @@
return true; return true;
} }
function negativeIDGenerator() {
const value = negativeIDGenerator.start;
negativeIDGenerator.start -= 1;
return value;
}
negativeIDGenerator.start = -1;
module.exports = { module.exports = {
isBoolean, isBoolean,
isInteger, isInteger,
@ -75,5 +82,6 @@
isString, isString,
checkFilter, checkFilter,
checkObjectType, checkObjectType,
negativeIDGenerator,
}; };
})(); })();

@ -20,7 +20,9 @@ onmessage = (e) => {
.catch((error) => { .catch((error) => {
postMessage({ postMessage({
id: e.data.id, id: e.data.id,
error, error: error,
status: error.response.status,
responseData: error.response.data,
isSuccess: false, isSuccess: false,
}); });
}); });

@ -33,6 +33,22 @@
COMPLETED: 'completed', 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 * List of RQ statuses
* @enum {string} * @enum {string}
@ -306,6 +322,7 @@
module.exports = { module.exports = {
ShareFileType, ShareFileType,
TaskStatus, TaskStatus,
ReviewStatus,
TaskMode, TaskMode,
AttributeType, AttributeType,
ObjectType, ObjectType,

@ -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;

@ -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;

@ -31,7 +31,13 @@
if (e.data.isSuccess) { if (e.data.isSuccess) {
requests[e.data.id].resolve(e.data.responseData); requests[e.data.id].resolve(e.data.responseData);
} else { } 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]; delete requests[e.data.id];
@ -287,7 +293,7 @@
async function authorized() { async function authorized() {
try { try {
await module.exports.users.getSelf(); await module.exports.users.self();
} catch (serverError) { } catch (serverError) {
if (serverError.code === 401) { if (serverError.code === 401) {
return false; return false;
@ -566,6 +572,90 @@
return response.data; 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) { async function saveJob(id, jobData) {
const { backendAPI } = config; const { backendAPI } = config;
@ -641,7 +731,14 @@
}, },
); );
} catch (errorData) { } catch (errorData) {
throw generateError(errorData); throw generateError({
...errorData,
message: '',
response: {
...errorData.response,
data: String.fromCharCode.apply(null, new Uint8Array(errorData.response.data)),
},
});
} }
return response; return response;
@ -932,16 +1029,21 @@
jobs: { jobs: {
value: Object.freeze({ value: Object.freeze({
getJob, get: getJob,
saveJob, save: saveJob,
issues: getJobIssues,
reviews: {
get: getJobReviews,
create: createReview,
},
}), }),
writable: false, writable: false,
}, },
users: { users: {
value: Object.freeze({ value: Object.freeze({
getUsers, get: getUsers,
getSelf, self: getSelf,
}), }),
writable: false, writable: false,
}, },
@ -983,6 +1085,20 @@
}), }),
writable: false, writable: false,
}, },
issues: {
value: Object.freeze({
update: updateIssue,
}),
writable: false,
},
comments: {
value: Object.freeze({
create: createComment,
}),
writable: false,
},
}), }),
); );
} }

@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
(() => { (() => {
const store = require('store');
const PluginRegistry = require('./plugins'); const PluginRegistry = require('./plugins');
const loggerStorage = require('./logger-storage'); const loggerStorage = require('./logger-storage');
const serverProxy = require('./server-proxy'); const serverProxy = require('./server-proxy');
@ -13,6 +14,8 @@
const { TaskStatus } = require('./enums'); const { TaskStatus } = require('./enums');
const { Label } = require('./labels'); const { Label } = require('./labels');
const User = require('./user'); const User = require('./user');
const Issue = require('./issue');
const Review = require('./review');
function buildDublicatedAPI(prototype) { function buildDublicatedAPI(prototype) {
Object.defineProperties(prototype, { Object.defineProperties(prototype, {
@ -667,7 +670,8 @@
super(); super();
const data = { const data = {
id: undefined, id: undefined,
assignee: undefined, assignee: null,
reviewer: null,
status: undefined, status: undefined,
start_frame: undefined, start_frame: undefined,
stop_frame: undefined, stop_frame: undefined,
@ -676,6 +680,7 @@
let updatedFields = { let updatedFields = {
assignee: false, assignee: false,
reviewer: false,
status: false, status: false,
}; };
@ -692,6 +697,7 @@
} }
if (data.assignee) data.assignee = new User(data.assignee); if (data.assignee) data.assignee = new User(data.assignee);
if (data.reviewer) data.reviewer = new User(data.reviewer);
Object.defineProperties( Object.defineProperties(
this, this,
@ -707,7 +713,7 @@
get: () => data.id, 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 * @name assignee
* @type {module:API.cvat.classes.User} * @type {module:API.cvat.classes.User}
* @memberof module:API.cvat.classes.Job * @memberof module:API.cvat.classes.Job
@ -724,6 +730,24 @@
data.assignee = assignee; 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 * @name status
* @type {module:API.cvat.enums.TaskStatus} * @type {module:API.cvat.enums.TaskStatus}
@ -847,6 +871,64 @@
const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.save); const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.save);
return result; 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, status: undefined,
size: undefined, size: undefined,
mode: undefined, mode: undefined,
owner: undefined, owner: null,
assignee: undefined, assignee: null,
created_date: undefined, created_date: undefined,
updated_date: undefined, updated_date: undefined,
bug_tracker: undefined, bug_tracker: undefined,
@ -891,6 +973,7 @@
data_original_chunk_type: undefined, data_original_chunk_type: undefined,
use_zip_chunks: undefined, use_zip_chunks: undefined,
use_cache: undefined, use_cache: undefined,
copy_data: undefined,
}; };
let updatedFields = { let updatedFields = {
@ -925,6 +1008,7 @@
url: job.url, url: job.url,
id: job.id, id: job.id,
assignee: job.assignee, assignee: job.assignee,
reviewer: job.reviewer,
status: job.status, status: job.status,
start_frame: segment.start_frame, start_frame: segment.start_frame,
stop_frame: segment.stop_frame, stop_frame: segment.stop_frame,
@ -1156,6 +1240,22 @@
data.use_cache = useCache; 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. * After task has been created value can be appended only.
* @name labels * @name labels
@ -1482,7 +1582,6 @@
buildDublicatedAPI(Task.prototype); buildDublicatedAPI(Task.prototype);
Job.prototype.save.implementation = async function () { Job.prototype.save.implementation = async function () {
// TODO: Add ability to change an assignee
if (this.id) { if (this.id) {
const jobData = {}; const jobData = {};
@ -1495,17 +1594,21 @@
case 'assignee': case 'assignee':
jobData.assignee_id = this.assignee ? this.assignee.id : null; jobData.assignee_id = this.assignee ? this.assignee.id : null;
break; break;
case 'reviewer':
jobData.reviewer_id = this.reviewer ? this.reviewer.id : null;
break;
default: default:
break; break;
} }
} }
} }
await serverProxy.jobs.saveJob(this.id, jobData); await serverProxy.jobs.save(this.id, jobData);
this.__updatedFields = { this.__updatedFields = {
status: false, status: false,
assignee: false, assignee: false,
reviewer: false,
}; };
return this; return this;
@ -1514,6 +1617,42 @@
throw new ArgumentError('Can not save job without and id'); 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) { Job.prototype.frames.get.implementation = async function (frame, isPlaying, step) {
if (!Number.isInteger(frame) || frame < 0) { if (!Number.isInteger(frame) || frame < 0) {
throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`);
@ -1786,6 +1925,9 @@
if (typeof this.dataChunkSize !== 'undefined') { if (typeof this.dataChunkSize !== 'undefined') {
taskDataSpec.chunk_size = this.dataChunkSize; taskDataSpec.chunk_size = this.dataChunkSize;
} }
if (typeof this.copyData !== 'undefined') {
taskDataSpec.copy_data = this.copyData;
}
const task = await serverProxy.tasks.createTask(taskSpec, taskDataSpec, onUpdate); const task = await serverProxy.tasks.createTask(taskSpec, taskDataSpec, onUpdate);
return new Task(task); return new Task(task);

@ -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; module.exports = User;

@ -236,6 +236,7 @@ const projectsDummyData = {
url: 'http://192.168.0.139:7000/api/v1/jobs/1', url: 'http://192.168.0.139:7000/api/v1/jobs/1',
id: 1, id: 1,
assignee: null, assignee: null,
reviewer: null,
status: 'completed', status: 'completed',
}, },
], ],
@ -248,6 +249,7 @@ const projectsDummyData = {
url: 'http://192.168.0.139:7000/api/v1/jobs/2', url: 'http://192.168.0.139:7000/api/v1/jobs/2',
id: 2, id: 2,
assignee: null, assignee: null,
reviewer: null,
status: 'completed', status: 'completed',
}, },
], ],
@ -260,6 +262,7 @@ const projectsDummyData = {
url: 'http://192.168.0.139:7000/api/v1/jobs/3', url: 'http://192.168.0.139:7000/api/v1/jobs/3',
id: 3, id: 3,
assignee: null, assignee: null,
reviewer: null,
status: 'completed', status: 'completed',
}, },
], ],
@ -272,6 +275,7 @@ const projectsDummyData = {
url: 'http://192.168.0.139:7000/api/v1/jobs/4', url: 'http://192.168.0.139:7000/api/v1/jobs/4',
id: 4, id: 4,
assignee: null, assignee: null,
reviewer: null,
status: 'completed', status: 'completed',
}, },
], ],
@ -284,6 +288,7 @@ const projectsDummyData = {
url: 'http://192.168.0.139:7000/api/v1/jobs/5', url: 'http://192.168.0.139:7000/api/v1/jobs/5',
id: 5, id: 5,
assignee: null, assignee: null,
reviewer: null,
status: 'completed', status: 'completed',
}, },
], ],
@ -350,6 +355,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/112', url: 'http://localhost:7000/api/v1/jobs/112',
id: 112, id: 112,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -399,6 +405,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/100', url: 'http://localhost:7000/api/v1/jobs/100',
id: 100, id: 100,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -602,6 +609,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/10', url: 'http://localhost:7000/api/v1/jobs/10',
id: 101, id: 101,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -614,6 +622,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/11', url: 'http://localhost:7000/api/v1/jobs/11',
id: 102, id: 102,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -626,6 +635,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/12', url: 'http://localhost:7000/api/v1/jobs/12',
id: 103, id: 103,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -638,6 +648,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/13', url: 'http://localhost:7000/api/v1/jobs/13',
id: 104, id: 104,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -650,6 +661,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/14', url: 'http://localhost:7000/api/v1/jobs/14',
id: 105, id: 105,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -662,6 +674,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/15', url: 'http://localhost:7000/api/v1/jobs/15',
id: 106, id: 106,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -674,6 +687,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/16', url: 'http://localhost:7000/api/v1/jobs/16',
id: 107, id: 107,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -686,6 +700,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/17', url: 'http://localhost:7000/api/v1/jobs/17',
id: 108, id: 108,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -698,6 +713,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/18', url: 'http://localhost:7000/api/v1/jobs/18',
id: 109, id: 109,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -710,6 +726,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/19', url: 'http://localhost:7000/api/v1/jobs/19',
id: 110, id: 110,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -722,6 +739,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/20', url: 'http://localhost:7000/api/v1/jobs/20',
id: 111, id: 111,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -926,6 +944,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/3', url: 'http://localhost:7000/api/v1/jobs/3',
id: 3, id: 3,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -938,6 +957,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/4', url: 'http://localhost:7000/api/v1/jobs/4',
id: 4, id: 4,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -1139,6 +1159,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/2', url: 'http://localhost:7000/api/v1/jobs/2',
id: 2, id: 2,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],
@ -1340,6 +1361,7 @@ const tasksDummyData = {
url: 'http://localhost:7000/api/v1/jobs/1', url: 'http://localhost:7000/api/v1/jobs/1',
id: 1, id: 1,
assignee: null, assignee: null,
reviewer: null,
status: 'annotation', status: 'annotation',
}, },
], ],

@ -345,16 +345,16 @@ class ServerProxy {
jobs: { jobs: {
value: Object.freeze({ value: Object.freeze({
getJob, get: getJob,
saveJob, save: saveJob,
}), }),
writable: false, writable: false,
}, },
users: { users: {
value: Object.freeze({ value: Object.freeze({
getUsers, get: getUsers,
getSelf, self: getSelf,
}), }),
writable: false, writable: false,
}, },
@ -373,8 +373,6 @@ class ServerProxy {
updateAnnotations, updateAnnotations,
getAnnotations, getAnnotations,
}, },
// To implement on of important tests
writable: true,
}, },
}), }),
); );

@ -21,16 +21,19 @@ module.exports = {
], ],
rules: { rules: {
'@typescript-eslint/indent': ['warn', 4], '@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': ['warn', 4],
'react/jsx-indent-props': ['warn', 4], 'react/jsx-indent-props': ['warn', 4],
'react/jsx-props-no-spreading': 0, 'react/jsx-props-no-spreading': 0,
'implicit-arrow-linebreak': 0,
'jsx-quotes': ['error', 'prefer-single'], 'jsx-quotes': ['error', 'prefer-single'],
'arrow-parens': ['error', 'always'], 'arrow-parens': ['error', 'always'],
'@typescript-eslint/no-explicit-any': [0], '@typescript-eslint/no-explicit-any': [0],
'@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }], '@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }],
'no-restricted-syntax': [0, { selector: 'ForOfStatement' }], 'no-restricted-syntax': [0, { selector: 'ForOfStatement' }],
'no-plusplus': [0], '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 'react/no-did-update-set-state': 0, // https://github.com/airbnb/javascript/issues/1875
quotes: ['error', 'single'], quotes: ['error', 'single'],
'max-len': ['error', { code: 120, ignoreStrings: true }], 'max-len': ['error', { code: 120, ignoreStrings: true }],

@ -1,6 +1,6 @@
{ {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.10.10", "version": "1.11.1",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -1213,9 +1213,9 @@
"dev": true "dev": true
}, },
"@types/react": { "@types/react": {
"version": "16.9.55", "version": "16.9.56",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.55.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.56.tgz",
"integrity": "sha512-6KLe6lkILeRwyyy7yG9rULKJ0sXplUsl98MGoCfpteXf9sPWFWWMknDcsvubcpaTdBuxtsLF6HDUwdApZL/xIg==", "integrity": "sha512-gIkl4J44G/qxbuC6r2Xh+D3CGZpJ+NdWTItAPmZbR5mUS+JQ8Zvzpl0ea5qT/ZT3ZNTUcDKUVqV3xBE8wv/DyQ==",
"requires": { "requires": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@ -12878,7 +12878,7 @@
"requires": { "requires": {
"axios": "^0.20.0", "axios": "^0.20.0",
"browser-or-node": "^1.2.1", "browser-or-node": "^1.2.1",
"detect-browser": "^5.0.0", "detect-browser": "^5.2.0",
"error-stack-parser": "^2.0.2", "error-stack-parser": "^2.0.2",
"form-data": "^2.5.0", "form-data": "^2.5.0",
"jest-config": "^24.8.0", "jest-config": "^24.8.0",

@ -1,6 +1,6 @@
{ {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.10.10", "version": "1.11.1",
"description": "CVAT single-page application", "description": "CVAT single-page application",
"main": "src/index.tsx", "main": "src/index.tsx",
"scripts": { "scripts": {
@ -49,7 +49,7 @@
"dependencies": { "dependencies": {
"@types/lodash": "^4.14.165", "@types/lodash": "^4.14.165",
"@types/platform": "^1.3.3", "@types/platform": "^1.3.3",
"@types/react": "^16.9.55", "@types/react": "^16.9.56",
"@types/react-color": "^3.0.4", "@types/react-color": "^3.0.4",
"@types/react-dom": "^16.9.9", "@types/react-dom": "^16.9.9",
"@types/react-redux": "^7.1.11", "@types/react-redux": "^7.1.11",

@ -123,6 +123,7 @@ export enum AnnotationActionTypes {
CONFIRM_CANVAS_READY = 'CONFIRM_CANVAS_READY', CONFIRM_CANVAS_READY = 'CONFIRM_CANVAS_READY',
DRAG_CANVAS = 'DRAG_CANVAS', DRAG_CANVAS = 'DRAG_CANVAS',
ZOOM_CANVAS = 'ZOOM_CANVAS', ZOOM_CANVAS = 'ZOOM_CANVAS',
SELECT_ISSUE_POSITION = 'SELECT_ISSUE_POSITION',
MERGE_OBJECTS = 'MERGE_OBJECTS', MERGE_OBJECTS = 'MERGE_OBJECTS',
GROUP_OBJECTS = 'GROUP_OBJECTS', GROUP_OBJECTS = 'GROUP_OBJECTS',
SPLIT_TRACK = 'SPLIT_TRACK', SPLIT_TRACK = 'SPLIT_TRACK',
@ -161,9 +162,6 @@ export enum AnnotationActionTypes {
COLLECT_STATISTICS = 'COLLECT_STATISTICS', COLLECT_STATISTICS = 'COLLECT_STATISTICS',
COLLECT_STATISTICS_SUCCESS = 'COLLECT_STATISTICS_SUCCESS', COLLECT_STATISTICS_SUCCESS = 'COLLECT_STATISTICS_SUCCESS',
COLLECT_STATISTICS_FAILED = 'COLLECT_STATISTICS_FAILED', 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 = 'UPLOAD_JOB_ANNOTATIONS',
UPLOAD_JOB_ANNOTATIONS_SUCCESS = 'UPLOAD_JOB_ANNOTATIONS_SUCCESS', UPLOAD_JOB_ANNOTATIONS_SUCCESS = 'UPLOAD_JOB_ANNOTATIONS_SUCCESS',
UPLOAD_JOB_ANNOTATIONS_FAILED = 'UPLOAD_JOB_ANNOTATIONS_FAILED', UPLOAD_JOB_ANNOTATIONS_FAILED = 'UPLOAD_JOB_ANNOTATIONS_FAILED',
@ -187,6 +185,10 @@ export enum AnnotationActionTypes {
SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED', SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED',
INTERACT_WITH_CANVAS = 'INTERACT_WITH_CANVAS', INTERACT_WITH_CANVAS = 'INTERACT_WITH_CANVAS',
SET_AI_TOOLS_REF = 'SET_AI_TOOLS_REF', 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 { 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 { export function addZLayer(): AnyAction {
return { return {
type: AnnotationActionTypes.ADD_Z_LAYER, 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<Dispatch>): Promise<void> => {
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 { export function collectStatisticsAsync(sessionInstance: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => { return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
try { try {
@ -896,7 +877,11 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
try { try {
const state: CombinedState = getStore().getState(); const state: CombinedState = getStore().getState();
const filters = initialFilters; const filters = initialFilters;
const { showAllInterpolationTracks } = state.settings.workspace; const {
settings: {
workspace: { showAllInterpolationTracks },
},
} = state;
dispatch({ dispatch({
type: AnnotationActionTypes.GET_JOB, 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); const frameData = await job.frames.get(frameNumber);
// call first getting of frame data before rendering interface // call first getting of frame data before rendering interface
// to load and decode first chunk // 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 states = await job.annotations.get(frameNumber, showAllInterpolationTracks, filters);
const issues = await job.issues();
const reviews = await job.reviews();
const [minZ, maxZ] = computeZRange(states); const [minZ, maxZ] = computeZRange(states);
const colors = [...cvat.enums.colors]; const colors = [...cvat.enums.colors];
@ -949,6 +945,8 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
type: AnnotationActionTypes.GET_JOB_SUCCESS, type: AnnotationActionTypes.GET_JOB_SUCCESS,
payload: { payload: {
job, job,
issues,
reviews,
states, states,
frameNumber, frameNumber,
frameFilename: frameData.filename, 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<Dispatch>): Promise<void> => { return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters(); const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters();
@ -997,6 +995,9 @@ export function saveAnnotationsAsync(sessionInstance: any): ThunkAction {
const { frame } = receiveAnnotationsParameters(); const { frame } = receiveAnnotationsParameters();
const states = await sessionInstance.annotations.get(frame, showAllInterpolationTracks, filters); const states = await sessionInstance.annotations.get(frame, showAllInterpolationTracks, filters);
if (typeof afterSave === 'function') {
afterSave();
}
dispatch({ dispatch({
type: AnnotationActionTypes.SAVE_ANNOTATIONS_SUCCESS, 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 { export function mergeObjects(enabled: boolean): AnyAction {
return { return {
type: AnnotationActionTypes.MERGE_OBJECTS, 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,
},
};
}

@ -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<typeof reviewActions>;
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));
}
};

@ -394,6 +394,9 @@ export function createTaskAsync(data: any): ThunkAction<Promise<void>, {}, {}, A
if (data.advanced.dataChunkSize) { if (data.advanced.dataChunkSize) {
description.data_chunk_size = 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); const taskInstance = new cvat.classes.Task(description);
taskInstance.clientFiles = data.files.local; taskInstance.clientFiles = data.files.local;

@ -27,6 +27,7 @@ $transparent-color: rgba(0, 0, 0, 0);
$player-slider-color: #979797; $player-slider-color: #979797;
$player-buttons-color: #242424; $player-buttons-color: #242424;
$danger-icon-color: #ff4136; $danger-icon-color: #ff4136;
$ok-icon-color: #61c200;
$info-icon-color: #0074d9; $info-icon-color: #0074d9;
$objects-bar-tabs-color: #bebebe; $objects-bar-tabs-color: #bebebe;
$objects-bar-icons-color: #242424; // #6e6e6e $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); $object-item-border-color: rgba(0, 0, 0, 0.7);
$slider-color: #1890ff; $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, $monospaced-fonts-stack: Consolas, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono,
Courier New, monospace; Courier New, monospace;

@ -5,7 +5,7 @@
@import '../../base.scss'; @import '../../base.scss';
.ant-menu.cvat-actions-menu { .ant-menu.cvat-actions-menu {
box-shadow: 0 0 17px rgba(0, 0, 0, 0.2); box-shadow: $box-shadow-base;
> li:hover { > li:hover {
background-color: $hover-menu-color; background-color: $hover-menu-color;

@ -12,9 +12,12 @@ import Result from 'antd/lib/result';
import { Workspace } from 'reducers/interfaces'; import { Workspace } from 'reducers/interfaces';
import AnnotationTopBarContainer from 'containers/annotation-page/top-bar/top-bar'; import AnnotationTopBarContainer from 'containers/annotation-page/top-bar/top-bar';
import StatisticsModalContainer from 'containers/annotation-page/top-bar/statistics-modal'; import StatisticsModalContainer from 'containers/annotation-page/top-bar/statistics-modal';
import StandardWorkspaceComponent from './standard-workspace/standard-workspace'; import StandardWorkspaceComponent from 'components/annotation-page/standard-workspace/standard-workspace';
import AttributeAnnotationWorkspace from './attribute-annotation-workspace/attribute-annotation-workspace'; import AttributeAnnotationWorkspace from 'components/annotation-page/attribute-annotation-workspace/attribute-annotation-workspace';
import TagAnnotationWorkspace from './tag-annotation-workspace/tag-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 { interface Props {
job: any | null | undefined; job: any | null | undefined;
@ -26,7 +29,9 @@ interface Props {
} }
export default function AnnotationPageComponent(props: Props): JSX.Element { 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(); const history = useHistory();
useEffect(() => { useEffect(() => {
@ -87,7 +92,14 @@ export default function AnnotationPageComponent(props: Props): JSX.Element {
<TagAnnotationWorkspace /> <TagAnnotationWorkspace />
</Layout.Content> </Layout.Content>
)} )}
{workspace === Workspace.REVIEW_WORKSPACE && (
<Layout.Content style={{ height: '100%' }}>
<ReviewAnnotationsWorkspace />
</Layout.Content>
)}
<StatisticsModalContainer /> <StatisticsModalContainer />
<SubmitAnnotationsModal />
<SubmitReviewModal />
</Layout> </Layout>
); );
} }

@ -6,7 +6,7 @@ import './styles.scss';
import React from 'react'; import React from 'react';
import Layout from 'antd/lib/layout'; 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'; import AttributeAnnotationSidebar from './attribute-annotation-sidebar/attribute-annotation-sidebar';
export default function AttributeAnnotationWorkspace(): JSX.Element { export default function AttributeAnnotationWorkspace(): JSX.Element {

@ -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 (
<Menu onClick={onClick} selectable={false} className='cvat-canvas-context-menu' style={{ top, left }}>
<Menu.Item className='cvat-context-menu-item' key={ReviewContextMenuKeys.OPEN_ISSUE}>
Open an issue ...
</Menu.Item>
<Menu.Item className='cvat-context-menu-item' key={ReviewContextMenuKeys.QUICK_ISSUE_POSITION}>
Quick issue: incorrect position
</Menu.Item>
<Menu.Item className='cvat-context-menu-item' key={ReviewContextMenuKeys.QUICK_ISSUE_ATTRIBUTE}>
Quick issue: incorrect attribute
</Menu.Item>
{latestComments.length ? (
<Menu.SubMenu
title='Quick issue ...'
className='cvat-context-menu-item'
key={ReviewContextMenuKeys.QUICK_ISSUE_FROM_LATEST}
>
{latestComments.map(
(comment: string, id: number): JSX.Element => (
<Menu.Item className='cvat-context-menu-item' key={`${id}`}>
{comment}
</Menu.Item>
),
)}
</Menu.SubMenu>
) : null}
</Menu>
);
}
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(
<ReviewContextMenu
key={contextMenuClientID}
top={top}
left={left}
latestComments={latestComments}
onClick={(param: ClickParam) => {
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(
<div className='cvat-canvas-context-menu' style={{ top, left }}>
<ObjectItemContainer
readonly={readonly}
key={contextMenuClientID}
clientID={contextMenuClientID}
objectStates={objectStates}
initialCollapsed
/>
</div>,
window.document.body,
);
}

@ -25,16 +25,18 @@ function mapStateToProps(state: CombinedState): StateToProps {
annotation: { annotation: {
annotations: { states, activatedStateID }, annotations: { states, activatedStateID },
canvas: { canvas: {
contextMenu: { visible, top, left, type, pointID: selectedPoint }, contextMenu: {
visible, top, left, type, pointID: selectedPoint,
},
}, },
}, },
} = state; } = state;
return { return {
activatedState: activatedState:
activatedStateID === null activatedStateID === null ?
? null null :
: states.filter((_state) => _state.clientID === activatedStateID)[0] || null, states.filter((_state) => _state.clientID === activatedStateID)[0] || null,
selectedPoint, selectedPoint,
visible, visible,
left, left,
@ -62,7 +64,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
type Props = StateToProps & DispatchToProps; type Props = StateToProps & DispatchToProps;
function CanvasPointContextMenu(props: Props): React.ReactPortal | null { 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); const [contextMenuFor, setContextMenuFor] = useState(activatedState);
@ -95,23 +99,23 @@ function CanvasPointContextMenu(props: Props): React.ReactPortal | null {
} }
}; };
return visible && contextMenuFor && type === ContextMenuType.CANVAS_SHAPE_POINT return visible && contextMenuFor && type === ContextMenuType.CANVAS_SHAPE_POINT ?
? ReactDOM.createPortal( ReactDOM.createPortal(
<div className='cvat-canvas-point-context-menu' style={{ top, left }}> <div className='cvat-canvas-point-context-menu' style={{ top, left }}>
<Tooltip title='Delete point [Alt + dblclick]' mouseLeaveDelay={0}> <Tooltip title='Delete point [Alt + dblclick]' mouseLeaveDelay={0}>
<Button type='link' icon='delete' onClick={onPointDelete}> <Button type='link' icon='delete' onClick={onPointDelete}>
Delete point Delete point
</Button> </Button>
</Tooltip> </Tooltip>
{contextMenuFor && contextMenuFor.shapeType === 'polygon' && ( {contextMenuFor && contextMenuFor.shapeType === 'polygon' && (
<Button type='link' icon='environment' onClick={onSetStartPoint}> <Button type='link' icon='environment' onClick={onSetStartPoint}>
Set start point Set start point
</Button> </Button>
)} )}
</div>, </div>,
window.document.body, window.document.body,
) ) :
: null; null;
} }
export default connect(mapStateToProps, mapDispatchToProps)(CanvasPointContextMenu); export default connect(mapStateToProps, mapDispatchToProps)(CanvasPointContextMenu);

@ -30,6 +30,7 @@ interface Props {
activatedAttributeID: number | null; activatedAttributeID: number | null;
selectedStatesID: number[]; selectedStatesID: number[];
annotations: any[]; annotations: any[];
frameIssues: any[] | null;
frameData: any; frameData: any;
frameAngle: number; frameAngle: number;
frameFetching: boolean; frameFetching: boolean;
@ -89,11 +90,15 @@ interface Props {
onSwitchGrid(enabled: boolean): void; onSwitchGrid(enabled: boolean): void;
onSwitchAutomaticBordering(enabled: boolean): void; onSwitchAutomaticBordering(enabled: boolean): void;
onFetchAnnotation(): void; onFetchAnnotation(): void;
onGetDataFailed(error: any): void;
onStartIssue(position: number[]): void;
} }
export default class CanvasWrapperComponent extends React.PureComponent<Props> { export default class CanvasWrapperComponent extends React.PureComponent<Props> {
public componentDidMount(): void { 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 // It's awful approach from the point of view React
// But we do not have another way because cvat-canvas returns regular DOM element // 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<Props> {
autoborders: automaticBordering, autoborders: automaticBordering,
undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE, undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE,
displayAllText: showObjectsTextAlways, displayAllText: showObjectsTextAlways,
forceDisableEditing: workspace === Workspace.REVIEW_WORKSPACE,
}); });
this.initialSetup(); this.initialSetup();
this.updateIssueRegions();
this.updateCanvas(); this.updateCanvas();
} }
@ -118,6 +125,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
outlined, outlined,
outlineColor, outlineColor,
showBitmap, showBitmap,
frameIssues,
frameData, frameData,
frameAngle, frameAngle,
annotations, annotations,
@ -211,6 +219,10 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
} }
} }
if (prevProps.frameIssues !== frameIssues) {
this.updateIssueRegions();
}
if ( if (
prevProps.annotations !== annotations || prevProps.annotations !== annotations ||
prevProps.frameData !== frameData || prevProps.frameData !== frameData ||
@ -247,6 +259,18 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.rotate(frameAngle); 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'); const loadingAnimation = window.document.getElementById('cvat_canvas_loading_animation');
if (loadingAnimation && frameFetching !== prevProps.frameFetching) { if (loadingAnimation && frameFetching !== prevProps.frameFetching) {
if (frameFetching) { if (frameFetching) {
@ -295,13 +319,21 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.html().removeEventListener('canvas.drawn', this.onCanvasShapeDrawn); canvasInstance.html().removeEventListener('canvas.drawn', this.onCanvasShapeDrawn);
canvasInstance.html().removeEventListener('canvas.merged', this.onCanvasObjectsMerged); canvasInstance.html().removeEventListener('canvas.merged', this.onCanvasObjectsMerged);
canvasInstance.html().removeEventListener('canvas.groupped', this.onCanvasObjectsGroupped); 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.splitted', this.onCanvasTrackSplitted);
canvasInstance.html().removeEventListener('canvas.contextmenu', this.onCanvasPointContextMenu); canvasInstance.html().removeEventListener('canvas.contextmenu', this.onCanvasPointContextMenu);
canvasInstance.html().removeEventListener('canvas.error', this.onCanvasErrorOccurrence);
window.removeEventListener('resize', this.fitCanvas); 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 => { private onCanvasShapeDrawn = (event: any): void => {
const { const {
jobInstance, activeLabelID, activeObjectType, frame, onShapeDrawn, onCreateAnnotations, jobInstance, activeLabelID, activeObjectType, frame, onShapeDrawn, onCreateAnnotations,
@ -353,6 +385,13 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
onGroupAnnotations(jobInstance, frame, states); 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 => { private onCanvasTrackSplitted = (event: any): void => {
const { const {
jobInstance, frame, onSplitAnnotations, onSplitTrack, jobInstance, frame, onSplitAnnotations, onSplitTrack,
@ -372,7 +411,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
private onCanvasMouseDown = (e: MouseEvent): void => { private onCanvasMouseDown = (e: MouseEvent): void => {
const { workspace, activatedStateID, onActivateObject } = this.props; 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) { if (activatedStateID !== null && workspace !== Workspace.ATTRIBUTE_ANNOTATION) {
onActivateObject(null); onActivateObject(null);
} }
@ -380,7 +419,9 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
}; };
private onCanvasClicked = (): void => { 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(); document.activeElement.blur();
} }
}; };
@ -440,7 +481,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
jobInstance, activatedStateID, workspace, onActivateObject, jobInstance, activatedStateID, workspace, onActivateObject,
} = this.props; } = this.props;
if (workspace !== Workspace.STANDARD) { if (![Workspace.STANDARD, Workspace.REVIEW_WORKSPACE].includes(workspace)) {
return; return;
} }
@ -598,6 +639,22 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
} }
} }
private updateIssueRegions(): void {
const { canvasInstance, frameIssues } = this.props;
if (frameIssues === null) {
canvasInstance.setupIssueRegions({});
} else {
const regions = frameIssues.reduce((acc: Record<number, number[]>, issue: any): Record<
number,
number[]
> => {
acc[issue.id] = issue.position;
return acc;
}, {});
canvasInstance.setupIssueRegions(regions);
}
}
private updateCanvas(): void { private updateCanvas(): void {
const { const {
curZLayer, annotations, frameData, canvasInstance, curZLayer, annotations, frameData, canvasInstance,
@ -692,9 +749,11 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.html().addEventListener('canvas.drawn', this.onCanvasShapeDrawn); canvasInstance.html().addEventListener('canvas.drawn', this.onCanvasShapeDrawn);
canvasInstance.html().addEventListener('canvas.merged', this.onCanvasObjectsMerged); canvasInstance.html().addEventListener('canvas.merged', this.onCanvasObjectsMerged);
canvasInstance.html().addEventListener('canvas.groupped', this.onCanvasObjectsGroupped); 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.splitted', this.onCanvasTrackSplitted);
canvasInstance.html().addEventListener('canvas.contextmenu', this.onCanvasPointContextMenu); canvasInstance.html().addEventListener('canvas.contextmenu', this.onCanvasPointContextMenu);
canvasInstance.html().addEventListener('canvas.error', this.onCanvasErrorOccurrence);
} }
public render(): JSX.Element { public render(): JSX.Element {

@ -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<User | null>(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 (
<Modal
className='cvat-request-review-dialog'
visible={isVisible}
destroyOnClose
onCancel={close}
onOk={submitAnnotations}
okText='Submit'
>
<Row type='flex' justify='start'>
<Col>
<Title level={4}>Assign a user who is responsible for review</Title>
</Col>
</Row>
<Row align='middle' type='flex' justify='start'>
<Col>
<Text type='secondary'>Reviewer: </Text>
</Col>
<Col offset={1}>
<UserSelector value={reviewer} onSelect={setReviewer} />
</Col>
</Row>
<Row type='flex' justify='start'>
<Text type='secondary'>You might not be able to change the job after this action. Continue?</Text>
</Row>
</Modal>
);
}

@ -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<string, ExtendedKeyMapOptions>;
normalizedKeyMap: Record<string, string>;
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 (
<Layout.Sider className='cvat-canvas-controls-sidebar' theme='light' width={44}>
<GlobalHotKeys keyMap={subKeyMap} handlers={handlers} allowChanges />
<CursorControl
cursorShortkey={normalizedKeyMap.CANCEL}
canvasInstance={canvasInstance}
activeControl={activeControl}
/>
<MoveControl canvasInstance={canvasInstance} activeControl={activeControl} />
<RotateControl
anticlockwiseShortcut={normalizedKeyMap.ANTICLOCKWISE_ROTATION}
clockwiseShortcut={normalizedKeyMap.CLOCKWISE_ROTATION}
rotateFrame={rotateFrame}
/>
<hr />
<FitControl canvasInstance={canvasInstance} />
<ResizeControl canvasInstance={canvasInstance} activeControl={activeControl} />
<hr />
<IssueControl
canvasInstance={canvasInstance}
activeControl={activeControl}
selectIssuePosition={selectIssuePosition}
/>
</Layout.Sider>
);
}

@ -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 (
<Tooltip title='Open an issue' placement='right' mouseLeaveDelay={0}>
<Icon
component={RectangleIcon}
className={
activeControl === ActiveControl.OPEN_ISSUE ?
'cvat-issue-control cvat-active-canvas-control' :
'cvat-issue-control'
}
onClick={(): void => {
if (activeControl === ActiveControl.OPEN_ISSUE) {
canvasInstance.selectRegion(false);
selectIssuePosition(false);
} else {
canvasInstance.cancel();
canvasInstance.selectRegion(true);
selectIssuePosition(true);
}
}}
/>
</Tooltip>
);
}
export default React.memo(ResizeControl);

@ -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 (
<Layout hasSider className='cvat-review-workspace'>
<ControlsSideBarContainer />
<CanvasWrapperContainer />
<ObjectSideBarComponent objectsList={<ObjectsListContainer readonly />} />
<CanvasContextMenuContainer readonly />
<IssueAggregatorComponent />
</Layout>
);
}

@ -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;
}
}

@ -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 (
<Form className='cvat-create-issue-dialog' style={{ top, left }} onSubmit={handleSubmit}>
<Form.Item>
{getFieldDecorator('issue_description', {
rules: [{ required: true, message: 'Please, fill out the field' }],
})(<Input autoComplete='off' placeholder='Please, describe the issue' />)}
</Form.Item>
<Row type='flex' justify='space-between'>
<Col>
<Button onClick={cancel} type='ghost'>
Cancel
</Button>
</Col>
<Col>
<Button type='primary' htmlType='submit'>
Submit
</Button>
</Col>
</Row>
</Form>
);
}
const WrappedMessageForm = Form.create<FormProps>()(MessageForm);
interface Props {
top: number;
left: number;
}
export default function CreateIssueDialog(props: Props): ReactPortal {
const dispatch = useDispatch();
const { top, left } = props;
return ReactDOM.createPortal(
<WrappedMessageForm
top={top}
left={left}
submit={(message: string) => {
dispatch(finishIssueAsync(message));
}}
cancel={() => {
dispatch(reviewActions.cancelIssue());
}}
/>,
window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement,
);
}

@ -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(
<Tooltip title={message}>
<Tag
id={elementID}
onClick={onClick}
onMouseEnter={highlight}
onMouseLeave={blur}
style={{ top, left }}
className='cvat-hidden-issue-label'
>
{resolved ? (
<Icon className='cvat-hidden-issue-resolved-indicator' type='check' />
) : (
<Icon className='cvat-hidden-issue-unsolved-indicator' type='close-circle' />
)}
{message}
</Tag>
</Tooltip>,
window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement,
);
}

@ -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<HTMLDivElement>(null);
const [currentText, setCurrentText] = useState<string>('');
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
avatar={null}
key={_comment.id}
author={<Text strong>{_comment.author ? _comment.author.username : 'Unknown'}</Text>}
content={<p>{_comment.message}</p>}
datetime={(
<Tooltip title={created.format('MMMM Do YYYY')}>
<span>{diff}</span>
</Tooltip>
)}
/>
);
},
);
const resolveButton = resolved ? (
<Button loading={isFetching} type='primary' onClick={reopen}>
Reopen
</Button>
) : (
<Button loading={isFetching} type='primary' onClick={resolve}>
Resolve
</Button>
);
return ReactDOM.createPortal(
<div style={{ top, left }} ref={ref} className='cvat-issue-dialog'>
<Row className='cvat-issue-dialog-header' type='flex' justify='space-between'>
<Col>
<Title level={4}>{id >= 0 ? `Issue #${id}` : 'Issue'}</Title>
</Col>
<Col>
<Tooltip title='Collapse the chat'>
<Icon type='close' onClick={collapse} />
</Tooltip>
</Col>
</Row>
<Row className='cvat-issue-dialog-chat' type='flex' justify='start'>
<Col style={{ display: 'block' }}>{lines}</Col>
</Row>
<Row className='cvat-issue-dialog-input' type='flex' justify='start'>
<Col span={24}>
<Input
placeholder='Print a comment here..'
value={currentText}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setCurrentText(event.target.value);
}}
onPressEnter={() => {
if (currentText) {
comment(currentText);
setCurrentText('');
}
}}
/>
</Col>
</Row>
<Row className='cvat-issue-dialog-footer' type='flex' justify='end'>
<Col>
{currentText.length ? (
<Button
loading={isFetching}
type='primary'
disabled={!currentText.length}
onClick={() => {
comment(currentText);
setCurrentText('');
}}
>
Comment
</Button>
) : (
resolveButton
)}
</Col>
</Row>
</div>,
window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement,
);
}

@ -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<number | null>(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<number, number[]>, issue: any): Record<number, number[]> => {
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(
<IssueDialog
key={issue.id}
id={issue.id}
top={minY}
left={minX}
isFetching={issueFetching !== null}
comments={issue.comments}
resolved={issueResolved}
highlight={highlight}
blur={blur}
collapse={() => {
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(
<HiddenIssueLabel
key={issue.id}
id={issue.id}
top={minY}
left={minX}
resolved={issueResolved}
message={issue.comments[issue.comments.length - 1].message}
highlight={highlight}
blur={blur}
onClick={() => {
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 && <CreateIssueDialog top={createTop} left={createLeft} />}
{issueDialogs}
{issueLabels}
</>
);
}

@ -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;
}

@ -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<boolean>(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<User | null>(job.reviewer ? job.reviewer : null);
const [reviewStatus, setReviewStatus] = useState<string>(ReviewStatus.ACCEPTED);
const [estimatedQuality, setEstimatedQuality] = useState<number>(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 (
<Modal
className='cvat-submit-review-dialog'
visible={isVisible}
destroyOnClose
confirmLoading={isSubmitting}
onOk={submitReview}
onCancel={close}
okText='Submit'
width={650}
>
<Row type='flex' justify='start'>
<Col>
<Title level={4}>Submitting your review</Title>
</Col>
</Row>
<Row type='flex' justify='start'>
<Col span={12}>
<Description title='Review summary' layout='horizontal' column={1} size='small' bordered>
<Description.Item label='Estimated quality: '>{estimatedQuality}</Description.Item>
<Description.Item label='Issues: '>
<Text>{numberOfIssues}</Text>
{!!numberOfNewIssues && <Text strong>{` (+${numberOfNewIssues})`}</Text>}
</Description.Item>
<Description.Item label='Reviewed frames '>{reviewedFrames}</Description.Item>
<Description.Item label='Reviewed objects: '>{reviewedStates}</Description.Item>
</Description>
</Col>
<Col span={11} offset={1}>
<Row>
<Col>
<Radio.Group
value={reviewStatus}
onChange={(event: RadioChangeEvent) => {
if (typeof event.target.value !== 'undefined') {
setReviewStatus(event.target.value);
}
}}
>
<RadioButton value={ReviewStatus.ACCEPTED}>Accept</RadioButton>
<RadioButton value={ReviewStatus.REVIEW_FURTHER}>Review next</RadioButton>
<RadioButton value={ReviewStatus.REJECTED}>Reject</RadioButton>
</Radio.Group>
{reviewStatus === ReviewStatus.REVIEW_FURTHER && (
<Row align='middle' type='flex' justify='start'>
<Col>
<Text type='secondary'>Reviewer: </Text>
</Col>
<Col offset={1}>
<UserSelector value={reviewer} onSelect={setReviewer} />
</Col>
</Row>
)}
<Row type='flex' justify='center' align='middle'>
<Col>
<Rate
value={Math.round(estimatedQuality)}
onChange={(value: number | undefined) => {
if (typeof value !== 'undefined') {
setEstimatedQuality(value);
}
}}
/>
</Col>
</Row>
</Col>
</Row>
</Col>
</Row>
</Modal>
);
}

@ -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(
<div className='cvat-canvas-context-menu' style={{ top, left }}>
<ObjectItemContainer
key={activatedStateID}
clientID={activatedStateID}
objectStates={objectStates}
initialCollapsed
/>
</div>,
window.document.body,
);
}

@ -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 (
<div style={{ height: tabContentHeight }}>
<div className='cvat-objects-sidebar-issues-list-header'>
<Row type='flex' justify='start' align='middle'>
<Col>
<Tooltip title='Find the previous frame with issues'>
<Icon type='left' {...dinamicLeftProps} />
</Tooltip>
</Col>
<Col offset={1}>
<Tooltip title='Find the next frame with issues'>
<Icon type='right' {...dinamicRightProps} />
</Tooltip>
</Col>
<Col offset={3}>
<Tooltip title='Show/hide all the issues'>
<Icon {...dinamicShowHideProps} />
</Tooltip>
</Col>
</Row>
</div>
<div className='cvat-objects-sidebar-issues-list'>
{frameIssues.map(
(frameIssue: any): JSX.Element => (
<div
className='cvat-objects-sidebar-issue-item'
onMouseEnter={() => {
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 ? (
<Alert
description={<span>{`By ${frameIssue.resolver.username}`}</span>}
message='Resolved'
type='success'
showIcon
/>
) : (
<Alert
description={<span>{`By ${frameIssue.owner.username}`}</span>}
message='Opened'
type='warning'
showIcon
/>
)}
</div>
),
)}
</div>
</div>
);
}

@ -15,6 +15,7 @@ import consts from 'consts';
import { clamp } from 'utils/math'; import { clamp } from 'utils/math';
interface Props { interface Props {
readonly: boolean;
attrInputType: string; attrInputType: string;
attrValues: string[]; attrValues: string[];
attrValue: string; attrValue: string;
@ -25,6 +26,7 @@ interface Props {
function attrIsTheSame(prevProps: Props, nextProps: Props): boolean { function attrIsTheSame(prevProps: Props, nextProps: Props): boolean {
return ( return (
nextProps.readonly === prevProps.readonly &&
nextProps.attrID === prevProps.attrID && nextProps.attrID === prevProps.attrID &&
nextProps.attrValue === prevProps.attrValue && nextProps.attrValue === prevProps.attrValue &&
nextProps.attrName === prevProps.attrName && nextProps.attrName === prevProps.attrName &&
@ -36,7 +38,9 @@ function attrIsTheSame(prevProps: Props, nextProps: Props): boolean {
} }
function ItemAttributeComponent(props: Props): JSX.Element { 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' }; const attrNameStyle: React.CSSProperties = { wordBreak: 'break-word', lineHeight: '1em' };
@ -46,6 +50,7 @@ function ItemAttributeComponent(props: Props): JSX.Element {
<Checkbox <Checkbox
className='cvat-object-item-checkbox-attribute' className='cvat-object-item-checkbox-attribute'
checked={attrValue === 'true'} checked={attrValue === 'true'}
disabled={readonly}
onChange={(event: CheckboxChangeEvent): void => { onChange={(event: CheckboxChangeEvent): void => {
const value = event.target.checked ? 'true' : 'false'; const value = event.target.checked ? 'true' : 'false';
changeAttribute(attrID, value); changeAttribute(attrID, value);
@ -69,6 +74,7 @@ function ItemAttributeComponent(props: Props): JSX.Element {
</Text> </Text>
</legend> </legend>
<Radio.Group <Radio.Group
disabled={readonly}
size='small' size='small'
value={attrValue} value={attrValue}
onChange={(event: RadioChangeEvent): void => { onChange={(event: RadioChangeEvent): void => {
@ -96,6 +102,7 @@ function ItemAttributeComponent(props: Props): JSX.Element {
</Col> </Col>
<Col span={16}> <Col span={16}>
<Select <Select
disabled={readonly}
size='small' size='small'
onChange={(value: string): void => { onChange={(value: string): void => {
changeAttribute(attrID, value); changeAttribute(attrID, value);
@ -125,6 +132,7 @@ function ItemAttributeComponent(props: Props): JSX.Element {
</Col> </Col>
<Col span={16}> <Col span={16}>
<InputNumber <InputNumber
disabled={readonly}
size='small' size='small'
onChange={(value: number | undefined): void => { onChange={(value: number | undefined): void => {
if (typeof value === 'number') { if (typeof value === 'number') {
@ -170,6 +178,7 @@ function ItemAttributeComponent(props: Props): JSX.Element {
<Input <Input
ref={ref} ref={ref}
size='small' size='small'
disabled={readonly}
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => { onChange={(event: React.ChangeEvent<HTMLInputElement>): void => {
if (ref.current && ref.current.input) { if (ref.current && ref.current.input) {
setSelection({ setSelection({

@ -14,6 +14,7 @@ import LabelSelector from 'components/label-selector/label-selector';
import ItemMenu from './object-item-menu'; import ItemMenu from './object-item-menu';
interface Props { interface Props {
readonly: boolean;
clientID: number; clientID: number;
serverID: number | undefined; serverID: number | undefined;
labelID: number; labelID: number;
@ -46,6 +47,7 @@ interface Props {
function ItemTopComponent(props: Props): JSX.Element { function ItemTopComponent(props: Props): JSX.Element {
const { const {
readonly,
clientID, clientID,
serverID, serverID,
labelID, labelID,
@ -102,7 +104,7 @@ function ItemTopComponent(props: Props): JSX.Element {
</Col> </Col>
<Col span={12}> <Col span={12}>
<Tooltip title='Change current label' mouseLeaveDelay={0}> <Tooltip title='Change current label' mouseLeaveDelay={0}>
<LabelSelector size='small' labels={labels} value={labelID} onChange={changeLabel} /> <LabelSelector disabled={readonly} size='small' labels={labels} value={labelID} onChange={changeLabel} />
</Tooltip> </Tooltip>
</Col> </Col>
<Col span={2}> <Col span={2}>
@ -111,6 +113,7 @@ function ItemTopComponent(props: Props): JSX.Element {
onVisibleChange={changeMenuVisible} onVisibleChange={changeMenuVisible}
placement='bottomLeft' placement='bottomLeft'
overlay={ItemMenu({ overlay={ItemMenu({
readonly,
serverID, serverID,
locked, locked,
shapeType, shapeType,

@ -7,12 +7,13 @@ import { Row, Col } from 'antd/lib/grid';
import Icon from 'antd/lib/icon'; import Icon from 'antd/lib/icon';
import Tooltip from 'antd/lib/tooltip'; import Tooltip from 'antd/lib/tooltip';
import { ObjectType, ShapeType } from 'reducers/interfaces';
import { import {
ObjectOutsideIcon, FirstIcon, LastIcon, PreviousIcon, NextIcon, ObjectOutsideIcon, FirstIcon, LastIcon, PreviousIcon, NextIcon,
} from 'icons'; } from 'icons';
import { ObjectType, ShapeType } from 'reducers/interfaces';
interface Props { interface Props {
readonly: boolean;
objectType: ObjectType; objectType: ObjectType;
shapeType: ShapeType; shapeType: ShapeType;
occluded: boolean; occluded: boolean;
@ -51,80 +52,187 @@ interface Props {
show(): void; show(): void;
} }
function ItemButtonsComponent(props: Props): JSX.Element { const classes = {
firstKeyFrame: { className: 'cvat-object-item-button-first-keyframe' },
prevKeyFrame: { className: 'cvat-object-item-button-prev-keyframe' },
nextKeyFrame: { className: 'cvat-object-item-button-next-keyframe' },
lastKeyFrame: { className: 'cvat-object-item-button-last-keyframe' },
outside: {
enabled: { className: 'cvat-object-item-button-outside cvat-object-item-button-outside-enabled' },
disabled: { className: 'cvat-object-item-button-outside' },
},
lock: {
enabled: { className: 'cvat-object-item-button-lock cvat-object-item-button-lock-enabled' },
disabled: { className: 'cvat-object-item-button-lock' },
},
occluded: {
enabled: { className: 'cvat-object-item-button-occluded cvat-object-item-button-occluded-enabled' },
disabled: { className: 'cvat-object-item-button-occluded' },
},
pinned: {
enabled: { className: 'cvat-object-item-button-pinned cvat-object-item-button-pinned-enabled' },
disabled: { className: 'cvat-object-item-button-pinned' },
},
hidden: {
enabled: { className: 'cvat-object-item-button-hidden cvat-object-item-button-hidden-enabled' },
disabled: { className: 'cvat-object-item-button-hidden' },
},
keyframe: {
enabled: { className: 'cvat-object-item-button-keyframe cvat-object-item-button-keyframe-enabled' },
disabled: { className: 'cvat-object-item-button-keyframe' },
},
};
function NavigateFirstKeyframe(props: Props): JSX.Element {
const { navigateFirstKeyframe } = props;
return navigateFirstKeyframe ? (
<Icon {...classes.firstKeyFrame} component={FirstIcon} onClick={navigateFirstKeyframe} />
) : (
<Icon {...classes.firstKeyFrame} component={FirstIcon} style={{ opacity: 0.5, pointerEvents: 'none' }} />
);
}
function NavigatePrevKeyframe(props: Props): JSX.Element {
const { prevKeyFrameShortcut, navigatePrevKeyframe } = props;
return navigatePrevKeyframe ? (
<Tooltip title={`Go to previous keyframe ${prevKeyFrameShortcut}`} mouseLeaveDelay={0}>
<Icon {...classes.prevKeyFrame} component={PreviousIcon} onClick={navigatePrevKeyframe} />
</Tooltip>
) : (
<Icon {...classes.prevKeyFrame} component={PreviousIcon} style={{ opacity: 0.5, pointerEvents: 'none' }} />
);
}
function NavigateNextKeyframe(props: Props): JSX.Element {
const { navigateNextKeyframe, nextKeyFrameShortcut } = props;
return navigateNextKeyframe ? (
<Tooltip title={`Go to next keyframe ${nextKeyFrameShortcut}`} mouseLeaveDelay={0}>
<Icon {...classes.nextKeyFrame} component={NextIcon} onClick={navigateNextKeyframe} />
</Tooltip>
) : (
<Icon {...classes.nextKeyFrame} component={NextIcon} style={{ opacity: 0.5, pointerEvents: 'none' }} />
);
}
function NavigateLastKeyframe(props: Props): JSX.Element {
const { navigateLastKeyframe } = props;
return navigateLastKeyframe ? (
<Icon {...classes.lastKeyFrame} component={LastIcon} onClick={navigateLastKeyframe} />
) : (
<Icon {...classes.lastKeyFrame} component={LastIcon} style={{ opacity: 0.5, pointerEvents: 'none' }} />
);
}
function SwitchLock(props: Props): JSX.Element {
const { const {
objectType, locked, switchLockShortcut, lock, unlock,
shapeType, } = props;
occluded, return (
outside, <Tooltip title={`Switch lock property ${switchLockShortcut}`} mouseLeaveDelay={0}>
locked, {locked ? (
pinned, <Icon {...classes.lock.enabled} type='lock' theme='filled' onClick={unlock} />
hidden, ) : (
keyframe, <Icon {...classes.lock.disabled} type='unlock' onClick={lock} />
outsideDisabled, )}
hiddenDisabled, </Tooltip>
keyframeDisabled, );
switchOccludedShortcut, }
switchOutsideShortcut,
switchLockShortcut,
switchHiddenShortcut,
switchKeyFrameShortcut,
nextKeyFrameShortcut,
prevKeyFrameShortcut,
navigateFirstKeyframe, function SwitchOccluded(props: Props): JSX.Element {
navigatePrevKeyframe, const {
navigateNextKeyframe, switchOccludedShortcut, occluded, unsetOccluded, setOccluded,
navigateLastKeyframe, } = props;
return (
<Tooltip title={`Switch occluded property ${switchOccludedShortcut}`} mouseLeaveDelay={0}>
{occluded ? (
<Icon {...classes.occluded.enabled} type='team' onClick={unsetOccluded} />
) : (
<Icon {...classes.occluded.disabled} type='user' onClick={setOccluded} />
)}
</Tooltip>
);
}
setOccluded, function SwitchPinned(props: Props): JSX.Element {
unsetOccluded, const { pinned, pin, unpin } = props;
setOutside, return (
unsetOutside, <Tooltip title='Switch pinned property' mouseLeaveDelay={0}>
setKeyframe, {pinned ? (
unsetKeyframe, <Icon {...classes.pinned.enabled} type='pushpin' theme='filled' onClick={unpin} />
lock, ) : (
unlock, <Icon {...classes.pinned.disabled} type='pushpin' onClick={pin} />
pin, )}
unpin, </Tooltip>
hide, );
show, }
function SwitchHidden(props: Props): JSX.Element {
const {
switchHiddenShortcut, hidden, hiddenDisabled, show, hide,
} = props; } = props;
const hiddenStyle = hiddenDisabled ? { opacity: 0.5, pointerEvents: 'none' as const } : {};
return (
<Tooltip title={`Switch hidden property ${switchHiddenShortcut}`} mouseLeaveDelay={0}>
{hidden ? (
<Icon
{...classes.hidden.enabled}
type='eye-invisible'
theme='filled'
onClick={show}
style={hiddenStyle}
/>
) : (
<Icon {...classes.hidden.disabled} type='eye' onClick={hide} style={hiddenStyle} />
)}
</Tooltip>
);
}
function SwitchOutside(props: Props): JSX.Element {
const {
outside, switchOutsideShortcut, outsideDisabled, unsetOutside, setOutside,
} = props;
const outsideStyle = outsideDisabled ? { opacity: 0.5, pointerEvents: 'none' as const } : {}; const outsideStyle = outsideDisabled ? { opacity: 0.5, pointerEvents: 'none' as const } : {};
const hiddenStyle = hiddenDisabled ? { opacity: 0.5, pointerEvents: 'none' as const } : {}; return (
<Tooltip title={`Switch outside property ${switchOutsideShortcut}`} mouseLeaveDelay={0}>
{outside ? (
<Icon
{...classes.outside.enabled}
component={ObjectOutsideIcon}
onClick={unsetOutside}
style={outsideStyle}
/>
) : (
<Icon {...classes.outside.disabled} type='select' onClick={setOutside} style={outsideStyle} />
)}
</Tooltip>
);
}
function SwitchKeyframe(props: Props): JSX.Element {
const {
keyframe, switchKeyFrameShortcut, keyframeDisabled, unsetKeyframe, setKeyframe,
} = props;
const keyframeStyle = keyframeDisabled ? { opacity: 0.5, pointerEvents: 'none' as const } : {}; const keyframeStyle = keyframeDisabled ? { opacity: 0.5, pointerEvents: 'none' as const } : {};
return (
<Tooltip title={`Switch keyframe property ${switchKeyFrameShortcut}`} mouseLeaveDelay={0}>
{keyframe ? (
<Icon
{...classes.keyframe.enabled}
type='star'
theme='filled'
onClick={unsetKeyframe}
style={keyframeStyle}
/>
) : (
<Icon {...classes.keyframe.disabled} type='star' onClick={setKeyframe} style={keyframeStyle} />
)}
</Tooltip>
);
}
const classes = { function ItemButtonsComponent(props: Props): JSX.Element {
firstKeyFrame: { className: 'cvat-object-item-button-first-keyframe' }, const { readonly, objectType, shapeType } = props;
prevKeyFrame: { className: 'cvat-object-item-button-prev-keyframe' },
nextKeyFrame: { className: 'cvat-object-item-button-next-keyframe' },
lastKeyFrame: { className: 'cvat-object-item-button-last-keyframe' },
outside: {
enabled: { className: 'cvat-object-item-button-outside cvat-object-item-button-outside-enabled' },
disabled: { className: 'cvat-object-item-button-outside' },
},
lock: {
enabled: { className: 'cvat-object-item-button-lock cvat-object-item-button-lock-enabled' },
disabled: { className: 'cvat-object-item-button-lock' },
},
occluded: {
enabled: { className: 'cvat-object-item-button-occluded cvat-object-item-button-occluded-enabled' },
disabled: { className: 'cvat-object-item-button-occluded' },
},
pinned: {
enabled: { className: 'cvat-object-item-button-pinned cvat-object-item-button-pinned-enabled' },
disabled: { className: 'cvat-object-item-button-pinned' },
},
hidden: {
enabled: { className: 'cvat-object-item-button-hidden cvat-object-item-button-hidden-enabled' },
disabled: { className: 'cvat-object-item-button-hidden' },
},
keyframe: {
enabled: { className: 'cvat-object-item-button-keyframe cvat-object-item-button-keyframe-enabled' },
disabled: { className: 'cvat-object-item-button-keyframe' },
},
};
if (objectType === ObjectType.TRACK) { if (objectType === ObjectType.TRACK) {
return ( return (
@ -132,174 +240,58 @@ function ItemButtonsComponent(props: Props): JSX.Element {
<Col span={20} style={{ textAlign: 'center' }}> <Col span={20} style={{ textAlign: 'center' }}>
<Row type='flex' justify='space-around'> <Row type='flex' justify='space-around'>
<Col> <Col>
{navigateFirstKeyframe ? ( <NavigateFirstKeyframe {...props} />
<Icon
{...classes.firstKeyFrame}
component={FirstIcon}
onClick={navigateFirstKeyframe}
/>
) : (
<Icon
{...classes.firstKeyFrame}
component={FirstIcon}
style={{ opacity: 0.5, pointerEvents: 'none' }}
/>
)}
</Col> </Col>
<Col> <Col>
{navigatePrevKeyframe ? ( <NavigatePrevKeyframe {...props} />
<Tooltip title={`Go to previous keyframe ${prevKeyFrameShortcut}`} mouseLeaveDelay={0}>
<Icon
{...classes.prevKeyFrame}
component={PreviousIcon}
onClick={navigatePrevKeyframe}
/>
</Tooltip>
) : (
<Icon
{...classes.prevKeyFrame}
component={PreviousIcon}
style={{ opacity: 0.5, pointerEvents: 'none' }}
/>
)}
</Col> </Col>
<Col> <Col>
{navigateNextKeyframe ? ( <NavigateNextKeyframe {...props} />
<Tooltip title={`Go to next keyframe ${nextKeyFrameShortcut}`} mouseLeaveDelay={0}>
<Icon
{...classes.nextKeyFrame}
component={NextIcon}
onClick={navigateNextKeyframe}
/>
</Tooltip>
) : (
<Icon
{...classes.nextKeyFrame}
component={NextIcon}
style={{ opacity: 0.5, pointerEvents: 'none' }}
/>
)}
</Col> </Col>
<Col> <Col>
{navigateLastKeyframe ? ( <NavigateLastKeyframe {...props} />
<Icon {...classes.lastKeyFrame} component={LastIcon} onClick={navigateLastKeyframe} />
) : (
<Icon
{...classes.lastKeyFrame}
component={LastIcon}
style={{ opacity: 0.5, pointerEvents: 'none' }}
/>
)}
</Col> </Col>
</Row> </Row>
<Row type='flex' justify='space-around'> {!readonly && (
<Col> <Row type='flex' justify='space-around'>
<Tooltip title={`Switch outside property ${switchOutsideShortcut}`} mouseLeaveDelay={0}>
{outside ? (
<Icon
{...classes.outside.enabled}
component={ObjectOutsideIcon}
onClick={unsetOutside}
style={outsideStyle}
/>
) : (
<Icon
type='select'
{...classes.outside.disabled}
onClick={setOutside}
style={outsideStyle}
/>
)}
</Tooltip>
</Col>
<Col>
<Tooltip title={`Switch lock property ${switchLockShortcut}`} mouseLeaveDelay={0}>
{locked ? (
<Icon {...classes.lock.enabled} type='lock' theme='filled' onClick={unlock} />
) : (
<Icon {...classes.lock.disabled} type='unlock' onClick={lock} />
)}
</Tooltip>
</Col>
<Col>
<Tooltip title={`Switch occluded property ${switchOccludedShortcut}`} mouseLeaveDelay={0}>
{occluded ? (
<Icon {...classes.occluded.enabled} type='team' onClick={unsetOccluded} />
) : (
<Icon {...classes.occluded.disabled} type='user' onClick={setOccluded} />
)}
</Tooltip>
</Col>
<Col>
<Tooltip title={`Switch hidden property ${switchHiddenShortcut}`} mouseLeaveDelay={0}>
{hidden ? (
<Icon
{...classes.hidden.enabled}
type='eye-invisible'
theme='filled'
onClick={show}
style={hiddenStyle}
/>
) : (
<Icon {...classes.hidden.disabled} type='eye' onClick={hide} style={hiddenStyle} />
)}
</Tooltip>
</Col>
<Col>
<Tooltip title={`Switch keyframe property ${switchKeyFrameShortcut}`} mouseLeaveDelay={0}>
{keyframe ? (
<Icon
{...classes.keyframe.enabled}
type='star'
theme='filled'
onClick={unsetKeyframe}
style={keyframeStyle}
/>
) : (
<Icon
{...classes.keyframe.disabled}
type='star'
onClick={setKeyframe}
style={keyframeStyle}
/>
)}
</Tooltip>
</Col>
{shapeType !== ShapeType.POINTS && (
<Col> <Col>
<Tooltip title='Switch pinned property' mouseLeaveDelay={0}> <SwitchOutside {...props} />
{pinned ? (
<Icon
{...classes.pinned.enabled}
type='pushpin'
theme='filled'
onClick={unpin}
/>
) : (
<Icon {...classes.pinned.disabled} type='pushpin' onClick={pin} />
)}
</Tooltip>
</Col> </Col>
)} <Col>
</Row> <SwitchLock {...props} />
</Col>
<Col>
<SwitchOccluded {...props} />
</Col>
<Col>
<SwitchHidden {...props} />
</Col>
<Col>
<SwitchKeyframe {...props} />
</Col>
{shapeType !== ShapeType.POINTS && (
<Col>
<SwitchPinned {...props} />
</Col>
)}
</Row>
)}
</Col> </Col>
</Row> </Row>
); );
} }
if (readonly) {
return <div />;
}
if (objectType === ObjectType.TAG) { if (objectType === ObjectType.TAG) {
return ( return (
<Row type='flex' align='middle' justify='space-around'> <Row type='flex' align='middle' justify='space-around'>
<Col span={20} style={{ textAlign: 'center' }}> <Col span={20} style={{ textAlign: 'center' }}>
<Row type='flex' justify='space-around'> <Row type='flex' justify='space-around'>
<Col> <Col>
<Tooltip title={`Switch lock property ${switchLockShortcut}`} mouseLeaveDelay={0}> <SwitchLock {...props} />
{locked ? (
<Icon {...classes.lock.enabled} type='lock' onClick={unlock} theme='filled' />
) : (
<Icon {...classes.lock.disabled} type='unlock' onClick={lock} />
)}
</Tooltip>
</Col> </Col>
</Row> </Row>
</Col> </Col>
@ -312,41 +304,17 @@ function ItemButtonsComponent(props: Props): JSX.Element {
<Col span={20} style={{ textAlign: 'center' }}> <Col span={20} style={{ textAlign: 'center' }}>
<Row type='flex' justify='space-around'> <Row type='flex' justify='space-around'>
<Col> <Col>
<Tooltip title={`Switch lock property ${switchLockShortcut}`} mouseLeaveDelay={0}> <SwitchLock {...props} />
{locked ? (
<Icon {...classes.lock.enabled} type='lock' onClick={unlock} theme='filled' />
) : (
<Icon {...classes.lock.disabled} type='unlock' onClick={lock} />
)}
</Tooltip>
</Col> </Col>
<Col> <Col>
<Tooltip title={`Switch occluded property ${switchOccludedShortcut}`} mouseLeaveDelay={0}> <SwitchOccluded {...props} />
{occluded ? (
<Icon {...classes.occluded.enabled} type='team' onClick={unsetOccluded} />
) : (
<Icon {...classes.occluded.disabled} type='user' onClick={setOccluded} />
)}
</Tooltip>
</Col> </Col>
<Col> <Col>
<Tooltip title={`Switch hidden property ${switchHiddenShortcut}`} mouseLeaveDelay={0}> <SwitchHidden {...props} />
{hidden ? (
<Icon {...classes.hidden.enabled} type='eye-invisible' onClick={show} />
) : (
<Icon {...classes.hidden.disabled} type='eye' onClick={hide} />
)}
</Tooltip>
</Col> </Col>
{shapeType !== ShapeType.POINTS && ( {shapeType !== ShapeType.POINTS && (
<Col> <Col>
<Tooltip title='Switch pinned property' mouseLeaveDelay={0}> <SwitchPinned {...props} />
{pinned ? (
<Icon {...classes.pinned.enabled} type='pushpin' theme='filled' onClick={unpin} />
) : (
<Icon {...classes.pinned.disabled} type='pushpin' onClick={pin} />
)}
</Tooltip>
</Col> </Col>
)} )}
</Row> </Row>

@ -9,6 +9,7 @@ import Collapse from 'antd/lib/collapse';
import ItemAttribute from './object-item-attribute'; import ItemAttribute from './object-item-attribute';
interface Props { interface Props {
readonly: boolean;
collapsed: boolean; collapsed: boolean;
attributes: any[]; attributes: any[];
values: Record<number, string>; values: Record<number, string>;
@ -28,6 +29,7 @@ export function attrValuesAreEqual(next: Record<number, string>, prev: Record<nu
function attrAreTheSame(prevProps: Props, nextProps: Props): boolean { function attrAreTheSame(prevProps: Props, nextProps: Props): boolean {
return ( return (
nextProps.readonly === prevProps.readonly &&
nextProps.collapsed === prevProps.collapsed && nextProps.collapsed === prevProps.collapsed &&
nextProps.attributes === prevProps.attributes && nextProps.attributes === prevProps.attributes &&
attrValuesAreEqual(nextProps.values, prevProps.values) attrValuesAreEqual(nextProps.values, prevProps.values)
@ -35,7 +37,9 @@ function attrAreTheSame(prevProps: Props, nextProps: Props): boolean {
} }
function ItemAttributesComponent(props: Props): JSX.Element { function ItemAttributesComponent(props: Props): JSX.Element {
const { collapsed, attributes, values, changeAttribute, collapse } = props; const {
collapsed, attributes, values, readonly, changeAttribute, collapse,
} = props;
const sorted = [...attributes].sort((a: any, b: any): number => a.inputType.localeCompare(b.inputType)); const sorted = [...attributes].sort((a: any, b: any): number => a.inputType.localeCompare(b.inputType));
@ -57,6 +61,7 @@ function ItemAttributesComponent(props: Props): JSX.Element {
className='cvat-object-item-attribute-wrapper' className='cvat-object-item-attribute-wrapper'
> >
<ItemAttribute <ItemAttribute
readonly={readonly}
attrValue={values[attribute.id]} attrValue={values[attribute.id]}
attrInputType={attribute.inputType} attrInputType={attribute.inputType}
attrName={attribute.name} attrName={attribute.name}

@ -9,11 +9,14 @@ import Button from 'antd/lib/button';
import Modal from 'antd/lib/modal'; import Modal from 'antd/lib/modal';
import Tooltip from 'antd/lib/tooltip'; import Tooltip from 'antd/lib/tooltip';
import { BackgroundIcon, ForegroundIcon, ResetPerspectiveIcon, ColorizeIcon } from 'icons'; import {
BackgroundIcon, ForegroundIcon, ResetPerspectiveIcon, ColorizeIcon,
} from 'icons';
import { ObjectType, ShapeType, ColorBy } from 'reducers/interfaces'; import { ObjectType, ShapeType, ColorBy } from 'reducers/interfaces';
import ColorPicker from './color-picker'; import ColorPicker from './color-picker';
interface Props { interface Props {
readonly: boolean;
serverID: number | undefined; serverID: number | undefined;
locked: boolean; locked: boolean;
shapeType: ShapeType; shapeType: ShapeType;
@ -41,142 +44,201 @@ interface Props {
activateTracking(): void; activateTracking(): void;
} }
export default function ItemMenu(props: Props): JSX.Element { interface ItemProps {
toolProps: Props;
}
function CreateURLItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { serverID, createURL } = toolProps;
return (
<Menu.Item {...rest}>
<Button disabled={serverID === undefined} type='link' icon='link' onClick={createURL}>
Create object URL
</Button>
</Menu.Item>
);
}
function MakeCopyItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { copyShortcut, pasteShortcut, copy } = toolProps;
return (
<Menu.Item {...rest}>
<Tooltip title={`${copyShortcut} and ${pasteShortcut}`} mouseLeaveDelay={0}>
<Button type='link' icon='copy' onClick={copy}>
Make a copy
</Button>
</Tooltip>
</Menu.Item>
);
}
function PropagateItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { propagateShortcut, propagate } = toolProps;
return (
<Menu.Item {...rest}>
<Tooltip title={`${propagateShortcut}`} mouseLeaveDelay={0}>
<Button type='link' icon='block' onClick={propagate}>
Propagate
</Button>
</Tooltip>
</Menu.Item>
);
}
function TrackingItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { activateTracking } = toolProps;
return (
<Menu.Item {...rest}>
<Tooltip title='Run tracking with the active tracker' mouseLeaveDelay={0}>
<Button type='link' onClick={activateTracking}>
<Icon type='gateway' />
Track
</Button>
</Tooltip>
</Menu.Item>
);
}
function SwitchOrientationItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { switchOrientation } = toolProps;
return (
<Menu.Item {...rest}>
<Button type='link' icon='retweet' onClick={switchOrientation}>
Switch orientation
</Button>
</Menu.Item>
);
}
function ResetPerspectiveItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { resetCuboidPerspective } = toolProps;
return (
<Menu.Item {...rest}>
<Button type='link' onClick={resetCuboidPerspective}>
<Icon component={ResetPerspectiveIcon} />
Reset perspective
</Button>
</Menu.Item>
);
}
function ToBackgroundItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { toBackgroundShortcut, toBackground } = toolProps;
return (
<Menu.Item {...rest}>
<Tooltip title={`${toBackgroundShortcut}`} mouseLeaveDelay={0}>
<Button type='link' onClick={toBackground}>
<Icon component={BackgroundIcon} />
To background
</Button>
</Tooltip>
</Menu.Item>
);
}
function ToForegroundItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { toForegroundShortcut, toForeground } = toolProps;
return (
<Menu.Item {...rest}>
<Tooltip title={`${toForegroundShortcut}`} mouseLeaveDelay={0}>
<Button type='link' onClick={toForeground}>
<Icon component={ForegroundIcon} />
To foreground
</Button>
</Tooltip>
</Menu.Item>
);
}
function SwitchColorItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { const {
serverID,
locked,
shapeType,
objectType,
color, color,
colorBy,
colorPickerVisible, colorPickerVisible,
changeColorShortcut, changeColorShortcut,
copyShortcut, colorBy,
pasteShortcut,
propagateShortcut,
toBackgroundShortcut,
toForegroundShortcut,
removeShortcut,
changeColor, changeColor,
copy,
remove,
propagate,
createURL,
switchOrientation,
toBackground,
toForeground,
resetCuboidPerspective,
changeColorPickerVisible, changeColorPickerVisible,
activateTracking, } = toolProps;
return (
<Menu.Item {...rest}>
<ColorPicker
value={color}
onChange={changeColor}
visible={colorPickerVisible}
onVisibleChange={changeColorPickerVisible}
resetVisible={false}
>
<Tooltip title={`${changeColorShortcut}`} mouseLeaveDelay={0}>
<Button type='link'>
<Icon component={ColorizeIcon} />
{`Change ${colorBy.toLowerCase()} color`}
</Button>
</Tooltip>
</ColorPicker>
</Menu.Item>
);
}
function RemoveItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { removeShortcut, locked, remove } = toolProps;
return (
<Menu.Item {...rest}>
<Tooltip title={`${removeShortcut}`} mouseLeaveDelay={0}>
<Button
type='link'
icon='delete'
onClick={(): void => {
if (locked) {
Modal.confirm({
title: 'Object is locked',
content: 'Are you sure you want to remove it?',
onOk() {
remove();
},
});
} else {
remove();
}
}}
>
Remove
</Button>
</Tooltip>
</Menu.Item>
);
}
export default function ItemMenu(props: Props): JSX.Element {
const {
readonly, shapeType, objectType, colorBy,
} = props; } = props;
return ( return (
<Menu className='cvat-object-item-menu'> <Menu className='cvat-object-item-menu'>
<Menu.Item> <CreateURLItem toolProps={props} />
<Button disabled={serverID === undefined} type='link' icon='link' onClick={createURL}> {!readonly && <MakeCopyItem toolProps={props} />}
Create object URL {!readonly && <PropagateItem toolProps={props} />}
</Button> {!readonly && objectType === ObjectType.TRACK && shapeType === ShapeType.RECTANGLE && (
</Menu.Item> <TrackingItem toolProps={props} />
<Menu.Item>
<Tooltip title={`${copyShortcut} and ${pasteShortcut}`} mouseLeaveDelay={0}>
<Button type='link' icon='copy' onClick={copy}>
Make a copy
</Button>
</Tooltip>
</Menu.Item>
<Menu.Item>
<Tooltip title={`${propagateShortcut}`} mouseLeaveDelay={0}>
<Button type='link' icon='block' onClick={propagate}>
Propagate
</Button>
</Tooltip>
</Menu.Item>
{objectType === ObjectType.TRACK && shapeType === ShapeType.RECTANGLE && (
<Menu.Item>
<Tooltip title='Run tracking with the active tracker' mouseLeaveDelay={0}>
<Button type='link' onClick={activateTracking}>
<Icon type='gateway' />
Track
</Button>
</Tooltip>
</Menu.Item>
)} )}
{[ShapeType.POLYGON, ShapeType.POLYLINE, ShapeType.CUBOID].includes(shapeType) && ( {!readonly && [ShapeType.POLYGON, ShapeType.POLYLINE, ShapeType.CUBOID].includes(shapeType) && (
<Menu.Item> <SwitchOrientationItem toolProps={props} />
<Button type='link' icon='retweet' onClick={switchOrientation}>
Switch orientation
</Button>
</Menu.Item>
)}
{shapeType === ShapeType.CUBOID && (
<Menu.Item>
<Button type='link' onClick={resetCuboidPerspective}>
<Icon component={ResetPerspectiveIcon} />
Reset perspective
</Button>
</Menu.Item>
)}
{objectType !== ObjectType.TAG && (
<Menu.Item>
<Tooltip title={`${toBackgroundShortcut}`} mouseLeaveDelay={0}>
<Button type='link' onClick={toBackground}>
<Icon component={BackgroundIcon} />
To background
</Button>
</Tooltip>
</Menu.Item>
)} )}
{objectType !== ObjectType.TAG && ( {!readonly && shapeType === ShapeType.CUBOID && <ResetPerspectiveItem toolProps={props} />}
<Menu.Item> {!readonly && objectType !== ObjectType.TAG && <ToBackgroundItem toolProps={props} />}
<Tooltip title={`${toForegroundShortcut}`} mouseLeaveDelay={0}> {!readonly && objectType !== ObjectType.TAG && <ToForegroundItem toolProps={props} />}
<Button type='link' onClick={toForeground}> {[ColorBy.INSTANCE, ColorBy.GROUP].includes(colorBy) && <SwitchColorItem toolProps={props} />}
<Icon component={ForegroundIcon} /> {!readonly && <RemoveItem toolProps={props} />}
To foreground
</Button>
</Tooltip>
</Menu.Item>
)}
{[ColorBy.INSTANCE, ColorBy.GROUP].includes(colorBy) && (
<Menu.Item>
<ColorPicker
value={color}
onChange={changeColor}
visible={colorPickerVisible}
onVisibleChange={changeColorPickerVisible}
resetVisible={false}
>
<Tooltip title={`${changeColorShortcut}`} mouseLeaveDelay={0}>
<Button type='link'>
<Icon component={ColorizeIcon} />
{`Change ${colorBy.toLowerCase()} color`}
</Button>
</Tooltip>
</ColorPicker>
</Menu.Item>
)}
<Menu.Item>
<Tooltip title={`${removeShortcut}`} mouseLeaveDelay={0}>
<Button
type='link'
icon='delete'
onClick={(): void => {
if (locked) {
Modal.confirm({
title: 'Object is locked',
content: 'Are you sure you want to remove it?',
onOk() {
remove();
},
});
} else {
remove();
}
}}
>
Remove
</Button>
</Tooltip>
</Menu.Item>
</Menu> </Menu>
); );
} }

@ -11,6 +11,7 @@ import ItemBasics from './object-item-basics';
interface Props { interface Props {
normalizedKeyMap: Record<string, string>; normalizedKeyMap: Record<string, string>;
readonly: boolean;
activated: boolean; activated: boolean;
objectType: ObjectType; objectType: ObjectType;
shapeType: ShapeType; shapeType: ShapeType;
@ -45,6 +46,7 @@ interface Props {
function objectItemsAreEqual(prevProps: Props, nextProps: Props): boolean { function objectItemsAreEqual(prevProps: Props, nextProps: Props): boolean {
return ( return (
nextProps.activated === prevProps.activated && nextProps.activated === prevProps.activated &&
nextProps.readonly === prevProps.readonly &&
nextProps.locked === prevProps.locked && nextProps.locked === prevProps.locked &&
nextProps.labelID === prevProps.labelID && nextProps.labelID === prevProps.labelID &&
nextProps.color === prevProps.color && nextProps.color === prevProps.color &&
@ -64,6 +66,7 @@ function objectItemsAreEqual(prevProps: Props, nextProps: Props): boolean {
function ObjectItemComponent(props: Props): JSX.Element { function ObjectItemComponent(props: Props): JSX.Element {
const { const {
activated, activated,
readonly,
objectType, objectType,
shapeType, shapeType,
clientID, clientID,
@ -114,6 +117,7 @@ function ObjectItemComponent(props: Props): JSX.Element {
style={{ backgroundColor: `${color}88` }} style={{ backgroundColor: `${color}88` }}
> >
<ItemBasics <ItemBasics
readonly={readonly}
serverID={serverID} serverID={serverID}
clientID={clientID} clientID={clientID}
labelID={labelID} labelID={labelID}
@ -143,9 +147,10 @@ function ObjectItemComponent(props: Props): JSX.Element {
resetCuboidPerspective={resetCuboidPerspective} resetCuboidPerspective={resetCuboidPerspective}
activateTracking={activateTracking} activateTracking={activateTracking}
/> />
<ObjectButtonsContainer clientID={clientID} /> <ObjectButtonsContainer readonly={readonly} clientID={clientID} />
{!!attributes.length && ( {!!attributes.length && (
<ItemDetails <ItemDetails
readonly={readonly}
collapsed={collapsed} collapsed={collapsed}
attributes={attributes} attributes={attributes}
values={attrValues} values={attrValues}

@ -5,46 +5,14 @@
import React from 'react'; import React from 'react';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Icon from 'antd/lib/icon'; import Icon from 'antd/lib/icon';
import Select from 'antd/lib/select';
import Text from 'antd/lib/typography/Text';
import Tooltip from 'antd/lib/tooltip'; import Tooltip from 'antd/lib/tooltip';
import AnnotationsFiltersInput from 'components/annotation-page/annotations-filters-input'; import AnnotationsFiltersInput from 'components/annotation-page/annotations-filters-input';
import StatesOrderingSelector from 'components/annotation-page/standard-workspace/objects-side-bar/states-ordering-selector';
import { StatesOrdering } from 'reducers/interfaces'; import { StatesOrdering } from 'reducers/interfaces';
interface StatesOrderingSelectorComponentProps {
statesOrdering: StatesOrdering;
changeStatesOrdering(value: StatesOrdering): void;
}
function StatesOrderingSelectorComponent(props: StatesOrderingSelectorComponentProps): JSX.Element {
const { statesOrdering, changeStatesOrdering } = props;
return (
<Col span={16}>
<Text strong>Sort by</Text>
<Select
className='cvat-objects-sidebar-ordering-selector'
value={statesOrdering}
onChange={changeStatesOrdering}
>
<Select.Option key={StatesOrdering.ID_DESCENT} value={StatesOrdering.ID_DESCENT}>
{StatesOrdering.ID_DESCENT}
</Select.Option>
<Select.Option key={StatesOrdering.ID_ASCENT} value={StatesOrdering.ID_ASCENT}>
{StatesOrdering.ID_ASCENT}
</Select.Option>
<Select.Option key={StatesOrdering.UPDATED} value={StatesOrdering.UPDATED}>
{StatesOrdering.UPDATED}
</Select.Option>
</Select>
</Col>
);
}
const StatesOrderingSelector = React.memo(StatesOrderingSelectorComponent);
interface Props { interface Props {
readonly: boolean;
statesHidden: boolean; statesHidden: boolean;
statesLocked: boolean; statesLocked: boolean;
statesCollapsed: boolean; statesCollapsed: boolean;
@ -60,22 +28,57 @@ interface Props {
showAllStates(): void; showAllStates(): void;
} }
function ObjectListHeader(props: Props): JSX.Element { function LockAllSwitcher(props: Props): JSX.Element {
const { const {
statesHidden, statesLocked, switchLockAllShortcut, unlockAllStates, lockAllStates,
statesLocked,
statesCollapsed,
statesOrdering,
switchLockAllShortcut,
switchHiddenAllShortcut,
changeStatesOrdering,
lockAllStates,
unlockAllStates,
collapseAllStates,
expandAllStates,
hideAllStates,
showAllStates,
} = props; } = props;
return (
<Col span={2}>
<Tooltip title={`Switch lock property for all ${switchLockAllShortcut}`} mouseLeaveDelay={0}>
{statesLocked ? (
<Icon type='lock' onClick={unlockAllStates} theme='filled' />
) : (
<Icon type='unlock' onClick={lockAllStates} />
)}
</Tooltip>
</Col>
);
}
function HideAllSwitcher(props: Props): JSX.Element {
const {
statesHidden, switchHiddenAllShortcut, showAllStates, hideAllStates,
} = props;
return (
<Col span={2}>
<Tooltip title={`Switch hidden property for all ${switchHiddenAllShortcut}`} mouseLeaveDelay={0}>
{statesHidden ? (
<Icon type='eye-invisible' onClick={showAllStates} />
) : (
<Icon type='eye' onClick={hideAllStates} />
)}
</Tooltip>
</Col>
);
}
function CollapseAllSwitcher(props: Props): JSX.Element {
const { statesCollapsed, expandAllStates, collapseAllStates } = props;
return (
<Col span={2}>
<Tooltip title='Expand/collapse all' mouseLeaveDelay={0}>
{statesCollapsed ? (
<Icon type='caret-down' onClick={expandAllStates} />
) : (
<Icon type='caret-up' onClick={collapseAllStates} />
)}
</Tooltip>
</Col>
);
}
function ObjectListHeader(props: Props): JSX.Element {
const { readonly, statesOrdering, changeStatesOrdering } = props;
return ( return (
<div className='cvat-objects-sidebar-states-header'> <div className='cvat-objects-sidebar-states-header'>
@ -85,33 +88,13 @@ function ObjectListHeader(props: Props): JSX.Element {
</Col> </Col>
</Row> </Row>
<Row type='flex' justify='space-between' align='middle'> <Row type='flex' justify='space-between' align='middle'>
<Col span={2}> {!readonly && (
<Tooltip title={`Switch lock property for all ${switchLockAllShortcut}`} mouseLeaveDelay={0}> <>
{statesLocked ? ( <LockAllSwitcher {...props} />
<Icon type='lock' onClick={unlockAllStates} theme='filled' /> <HideAllSwitcher {...props} />
) : ( </>
<Icon type='unlock' onClick={lockAllStates} /> )}
)} <CollapseAllSwitcher {...props} />
</Tooltip>
</Col>
<Col span={2}>
<Tooltip title={`Switch hidden property for all ${switchHiddenAllShortcut}`} mouseLeaveDelay={0}>
{statesHidden ? (
<Icon type='eye-invisible' onClick={showAllStates} />
) : (
<Icon type='eye' onClick={hideAllStates} />
)}
</Tooltip>
</Col>
<Col span={2}>
<Tooltip title='Expand/collapse all' mouseLeaveDelay={0}>
{statesCollapsed ? (
<Icon type='caret-down' onClick={expandAllStates} />
) : (
<Icon type='caret-up' onClick={collapseAllStates} />
)}
</Tooltip>
</Col>
<StatesOrderingSelector statesOrdering={statesOrdering} changeStatesOrdering={changeStatesOrdering} /> <StatesOrderingSelector statesOrdering={statesOrdering} changeStatesOrdering={changeStatesOrdering} />
</Row> </Row>
</div> </div>

@ -9,6 +9,7 @@ import ObjectItemContainer from 'containers/annotation-page/standard-workspace/o
import ObjectListHeader from './objects-list-header'; import ObjectListHeader from './objects-list-header';
interface Props { interface Props {
readonly: boolean;
listHeight: number; listHeight: number;
statesHidden: boolean; statesHidden: boolean;
statesLocked: boolean; statesLocked: boolean;
@ -29,6 +30,7 @@ interface Props {
function ObjectListComponent(props: Props): JSX.Element { function ObjectListComponent(props: Props): JSX.Element {
const { const {
readonly,
listHeight, listHeight,
statesHidden, statesHidden,
statesLocked, statesLocked,
@ -50,6 +52,7 @@ function ObjectListComponent(props: Props): JSX.Element {
return ( return (
<div style={{ height: listHeight }}> <div style={{ height: listHeight }}>
<ObjectListHeader <ObjectListHeader
readonly={readonly}
statesHidden={statesHidden} statesHidden={statesHidden}
statesLocked={statesLocked} statesLocked={statesLocked}
statesCollapsed={statesCollapsedAll} statesCollapsed={statesCollapsedAll}
@ -68,6 +71,7 @@ function ObjectListComponent(props: Props): JSX.Element {
{sortedStatesID.map( {sortedStatesID.map(
(id: number): JSX.Element => ( (id: number): JSX.Element => (
<ObjectItemContainer <ObjectItemContainer
readonly={readonly}
objectStates={objectStates} objectStates={objectStates}
key={id} key={id}
clientID={id} clientID={id}

@ -13,13 +13,17 @@ import Layout from 'antd/lib/layout';
import { Canvas } from 'cvat-canvas-wrapper'; import { Canvas } from 'cvat-canvas-wrapper';
import { CombinedState } from 'reducers/interfaces'; import { CombinedState } from 'reducers/interfaces';
import ObjectsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-list';
import LabelsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/labels-list'; import LabelsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/labels-list';
import { import {
collapseSidebar as collapseSidebarAction, collapseSidebar as collapseSidebarAction,
updateTabContentHeight as updateTabContentHeightAction, updateTabContentHeight as updateTabContentHeightAction,
} from 'actions/annotation-actions'; } from 'actions/annotation-actions';
import AppearanceBlock, { computeHeight } from 'components/annotation-page/appearance-block'; import AppearanceBlock, { computeHeight } from 'components/annotation-page/appearance-block';
import IssuesListComponent from 'components/annotation-page/standard-workspace/objects-side-bar/issues-list';
interface OwnProps {
objectsList: JSX.Element;
}
interface StateToProps { interface StateToProps {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
@ -57,8 +61,10 @@ function mapDispatchToProps(dispatch: Dispatch<AnyAction>): DispatchToProps {
}; };
} }
function ObjectsSideBar(props: StateToProps & DispatchToProps): JSX.Element { function ObjectsSideBar(props: StateToProps & DispatchToProps & OwnProps): JSX.Element {
const { sidebarCollapsed, canvasInstance, collapseSidebar, updateTabContentHeight } = props; const {
sidebarCollapsed, canvasInstance, collapseSidebar, updateTabContentHeight, objectsList,
} = props;
useEffect(() => { useEffect(() => {
const alignTabHeight = (): void => { const alignTabHeight = (): void => {
@ -117,11 +123,14 @@ function ObjectsSideBar(props: StateToProps & DispatchToProps): JSX.Element {
<Tabs type='card' defaultActiveKey='objects' className='cvat-objects-sidebar-tabs'> <Tabs type='card' defaultActiveKey='objects' className='cvat-objects-sidebar-tabs'>
<Tabs.TabPane tab={<Text strong>Objects</Text>} key='objects'> <Tabs.TabPane tab={<Text strong>Objects</Text>} key='objects'>
<ObjectsListContainer /> {objectsList}
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={<Text strong>Labels</Text>} key='labels'> <Tabs.TabPane tab={<Text strong>Labels</Text>} key='labels'>
<LabelsListContainer /> <LabelsListContainer />
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={<Text strong>Issues</Text>} key='issues'>
<IssuesListComponent />
</Tabs.TabPane>
</Tabs> </Tabs>
{!sidebarCollapsed && <AppearanceBlock />} {!sidebarCollapsed && <AppearanceBlock />}

@ -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 (
<Col span={16}>
<Text strong>Sort by</Text>
<Select
className='cvat-objects-sidebar-ordering-selector'
value={statesOrdering}
onChange={changeStatesOrdering}
>
<Select.Option key={StatesOrdering.ID_DESCENT} value={StatesOrdering.ID_DESCENT}>
{StatesOrdering.ID_DESCENT}
</Select.Option>
<Select.Option key={StatesOrdering.ID_ASCENT} value={StatesOrdering.ID_ASCENT}>
{StatesOrdering.ID_ASCENT}
</Select.Option>
<Select.Option key={StatesOrdering.UPDATED} value={StatesOrdering.UPDATED}>
{StatesOrdering.UPDATED}
</Select.Option>
</Select>
</Col>
);
}
export default React.memo(StatesOrderingSelectorComponent);

@ -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 { .cvat-objects-sidebar-states-header {
background: $objects-bar-tabs-color; background: $objects-bar-tabs-color;
padding: 5px; padding: 5px;
@ -78,7 +127,7 @@
} }
> div:nth-child(2) { > div:nth-child(2) {
margin-top: 5px; margin-top: $grid-unit-size;
> div { > div {
text-align: center; text-align: center;
@ -88,11 +137,11 @@
@extend .cvat-object-sidebar-icon; @extend .cvat-object-sidebar-icon;
} }
&:nth-child(4) { &:last-child {
text-align: right; text-align: right;
> .ant-select { > .cvat-objects-sidebar-ordering-selector {
margin-left: 5px; margin-left: $grid-unit-size;
width: 60%; width: 60%;
} }
} }
@ -272,6 +321,12 @@
} }
} }
.cvat-context-menu-item.ant-menu-item {
&:hover {
background: $hover-menu-color;
}
}
.cvat-object-item-menu { .cvat-object-item-menu {
> li { > li {
padding: 0; padding: 0;
@ -289,3 +344,9 @@
.cvat-label-color-picker .sketch-picker { .cvat-label-color-picker .sketch-picker {
box-shadow: unset !important; box-shadow: unset !important;
} }
.cvat-states-ordering-selector {
:first-child {
margin-right: $grid-unit-size;
}
}

@ -6,22 +6,25 @@ import './styles.scss';
import React from 'react'; import React from 'react';
import Layout from 'antd/lib/layout'; 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 ControlsSideBarContainer from 'containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar';
import PropagateConfirmContainer from 'containers/annotation-page/standard-workspace/propagate-confirm'; 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 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 { export default function StandardWorkspaceComponent(): JSX.Element {
return ( return (
<Layout hasSider className='cvat-standard-workspace'> <Layout hasSider className='cvat-standard-workspace'>
<ControlsSideBarContainer /> <ControlsSideBarContainer />
<CanvasWrapperContainer /> <CanvasWrapperContainer />
<ObjectSideBarComponent /> <ObjectSideBarComponent objectsList={<ObjectsListContainer />} />
<PropagateConfirmContainer /> <PropagateConfirmContainer />
<CanvasContextMenuContainer /> <CanvasContextMenuContainer />
<CanvasPointContextMenuComponent /> <CanvasPointContextMenuComponent />
<IssueAggregatorComponent />
</Layout> </Layout>
); );
} }

@ -160,15 +160,6 @@
} }
> div:nth-child(1) { > div:nth-child(1) {
> div {
> .ant-select,
i {
margin-left: 10px;
}
}
}
> div:nth-child(2) {
> div { > div {
> span { > span {
font-size: 20px; font-size: 20px;
@ -176,7 +167,7 @@
} }
} }
> div:nth-child(3) { > div:nth-child(2) {
> div { > div {
display: grid; display: grid;
} }
@ -204,7 +195,7 @@
} }
.ant-menu.cvat-annotation-menu { .ant-menu.cvat-annotation-menu {
box-shadow: 0 0 17px rgba(0, 0, 0, 0.2); box-shadow: $box-shadow-base;
> li:hover { > li:hover {
background-color: $hover-menu-color; background-color: $hover-menu-color;
@ -317,3 +308,28 @@
transform: scale(1.8); 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;
}
}
}
}
}

@ -6,7 +6,7 @@ import './styles.scss';
import React from 'react'; import React from 'react';
import Layout from 'antd/lib/layout'; 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'; import TagAnnotationSidebar from './tag-annotation-sidebar/tag-annotation-sidebar';
export default function TagAnnotationWorkspace(): JSX.Element { export default function TagAnnotationWorkspace(): JSX.Element {

@ -17,8 +17,11 @@ interface Props {
loadActivity: string | null; loadActivity: string | null;
dumpActivities: string[] | null; dumpActivities: string[] | null;
exportActivities: string[] | null; exportActivities: string[] | null;
taskID: number; isReviewer: boolean;
jobInstance: any;
onClickMenu(params: ClickParam, file?: File): void; onClickMenu(params: ClickParam, file?: File): void;
setForceExitAnnotationFlag(forceExit: boolean): void;
saveAnnotations(jobInstance: any, afterSave?: () => void): void;
} }
export enum Actions { export enum Actions {
@ -27,10 +30,29 @@ export enum Actions {
EXPORT_TASK_DATASET = 'export_task_dataset', EXPORT_TASK_DATASET = 'export_task_dataset',
REMOVE_ANNO = 'remove_anno', REMOVE_ANNO = 'remove_anno',
OPEN_TASK = 'open_task', 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 { 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; let latestParams: ClickParam | null = null;
function onClickMenuWrapper(params: ClickParam | null, file?: File): void { function onClickMenuWrapper(params: ClickParam | null, file?: File): void {
@ -40,6 +62,33 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element {
} }
latestParams = params; 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) { if (copyParams.keyPath.length === 2) {
const [, action] = copyParams.keyPath; const [, action] = copyParams.keyPath;
if (action === Actions.LOAD_JOB_ANNO) { 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) { } else if (copyParams.key === Actions.REMOVE_ANNO) {
Modal.confirm({ Modal.confirm({
title: 'All annotations will be removed', title: 'All the annotations will be removed',
content: content:
'You are going to remove all annotations from the client. ' + 'You are going to remove all the annotations from the client. ' +
'It will stay on the server till you save a job. Continue?', 'It will stay on the server till you save the job. Continue?',
onOk: () => { onOk: () => {
onClickMenu(copyParams); onClickMenu(copyParams);
}, },
@ -73,6 +122,28 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element {
}, },
okText: 'Delete', 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 { } else {
onClickMenu(copyParams); onClickMenu(copyParams);
} }
@ -106,6 +177,12 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element {
Open the task Open the task
</a> </a>
</Menu.Item> </Menu.Item>
{jobStatus === 'annotation' && <Menu.Item key={Actions.REQUEST_REVIEW}>Request a review</Menu.Item>}
{jobStatus === 'annotation' && <Menu.Item key={Actions.FINISH_JOB}>Finish the job</Menu.Item>}
{jobStatus === 'validation' && isReviewer && (
<Menu.Item key={Actions.SUBMIT_REVIEW}>Submit the review</Menu.Item>
)}
{jobStatus === 'completed' && <Menu.Item key={Actions.RENEW_JOB}>Renew the job</Menu.Item>}
</Menu> </Menu>
); );
} }

@ -5,7 +5,6 @@
import React from 'react'; import React from 'react';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Tooltip from 'antd/lib/tooltip'; import Tooltip from 'antd/lib/tooltip';
import Select from 'antd/lib/select';
import Table from 'antd/lib/table'; import Table from 'antd/lib/table';
import Modal from 'antd/lib/modal'; import Modal from 'antd/lib/modal';
import Spin from 'antd/lib/spin'; import Spin from 'antd/lib/spin';
@ -17,28 +16,18 @@ interface Props {
data: any; data: any;
visible: boolean; visible: boolean;
assignee: string; assignee: string;
reviewer: string;
startFrame: number; startFrame: number;
stopFrame: number; stopFrame: number;
bugTracker: string; bugTracker: string;
jobStatus: string; jobStatus: string;
savingJobStatus: boolean; savingJobStatus: boolean;
closeStatistics(): void; closeStatistics(): void;
changeJobStatus(status: string): void;
} }
export default function StatisticsModalComponent(props: Props): JSX.Element { export default function StatisticsModalComponent(props: Props): JSX.Element {
const { const {
collecting, collecting, data, visible, assignee, reviewer, startFrame, stopFrame, bugTracker, closeStatistics,
data,
visible,
jobStatus,
assignee,
startFrame,
stopFrame,
bugTracker,
closeStatistics,
changeJobStatus,
savingJobStatus,
} = props; } = props;
const baseProps = { const baseProps = {
@ -144,50 +133,37 @@ export default function StatisticsModalComponent(props: Props): JSX.Element {
return ( return (
<Modal {...baseProps}> <Modal {...baseProps}>
<div className='cvat-job-info-modal-window'> <div className='cvat-job-info-modal-window'>
<Row type='flex' justify='start'>
<Col>
<Text strong className='cvat-text'>
Job status
</Text>
<Select value={jobStatus} onChange={changeJobStatus}>
<Select.Option key='1' value='annotation'>
annotation
</Select.Option>
<Select.Option key='2' value='validation'>
validation
</Select.Option>
<Select.Option key='3' value='completed'>
completed
</Select.Option>
</Select>
{savingJobStatus && <Icon type='loading' />}
</Col>
</Row>
<Row type='flex' justify='start'> <Row type='flex' justify='start'>
<Col> <Col>
<Text className='cvat-text'>Overview</Text> <Text className='cvat-text'>Overview</Text>
</Col> </Col>
</Row> </Row>
<Row type='flex' justify='start'> <Row type='flex' justify='start'>
<Col span={5}> <Col span={4}>
<Text strong className='cvat-text'> <Text strong className='cvat-text'>
Assignee Assignee
</Text> </Text>
<Text className='cvat-text'>{assignee}</Text> <Text className='cvat-text'>{assignee}</Text>
</Col> </Col>
<Col span={5}> <Col span={4}>
<Text strong className='cvat-text'>
Reviewer
</Text>
<Text className='cvat-text'>{reviewer}</Text>
</Col>
<Col span={4}>
<Text strong className='cvat-text'> <Text strong className='cvat-text'>
Start frame Start frame
</Text> </Text>
<Text className='cvat-text'>{startFrame}</Text> <Text className='cvat-text'>{startFrame}</Text>
</Col> </Col>
<Col span={5}> <Col span={4}>
<Text strong className='cvat-text'> <Text strong className='cvat-text'>
Stop frame Stop frame
</Text> </Text>
<Text className='cvat-text'>{stopFrame}</Text> <Text className='cvat-text'>{stopFrame}</Text>
</Col> </Col>
<Col span={5}> <Col span={4}>
<Text strong className='cvat-text'> <Text strong className='cvat-text'>
Frames Frames
</Text> </Text>

@ -26,11 +26,13 @@ export interface AdvancedConfiguration {
useZipChunks: boolean; useZipChunks: boolean;
dataChunkSize?: number; dataChunkSize?: number;
useCache: boolean; useCache: boolean;
copyData?: boolean;
} }
type Props = FormComponentProps & { type Props = FormComponentProps & {
onSubmit(values: AdvancedConfiguration): void; onSubmit(values: AdvancedConfiguration): void;
installedGit: boolean; installedGit: boolean;
activeFileManagerTab: string;
}; };
function isPositiveInteger(_: any, value: any, callback: any): void { function isPositiveInteger(_: any, value: any, callback: any): void {
@ -114,6 +116,26 @@ class AdvancedConfigurationForm extends React.PureComponent<Props> {
form.resetFields(); form.resetFields();
} }
renderCopyDataChechbox(): JSX.Element {
const { form } = this.props;
return (
<Row>
<Col>
<Form.Item help='If you have a low data transfer rate over the network you can copy data into CVAT to speed up work'>
{form.getFieldDecorator('copyData', {
initialValue: false,
valuePropName: 'checked',
})(
<Checkbox>
<Text className='cvat-text-color'>Copy data into CVAT</Text>
</Checkbox>,
)}
</Form.Item>
</Col>
</Row>
);
}
private renderImageQuality(): JSX.Element { private renderImageQuality(): JSX.Element {
const { form } = this.props; const { form } = this.props;
@ -386,10 +408,12 @@ class AdvancedConfigurationForm extends React.PureComponent<Props> {
} }
public render(): JSX.Element { public render(): JSX.Element {
const { installedGit } = this.props; const { installedGit, activeFileManagerTab } = this.props;
return ( return (
<Form> <Form>
{activeFileManagerTab === 'share' ? this.renderCopyDataChechbox() : null}
<Row> <Row>
<Col>{this.renderUzeZipChunks()}</Col> <Col>{this.renderUzeZipChunks()}</Col>
</Row> </Row>

@ -25,6 +25,7 @@ export interface CreateTaskData {
advanced: AdvancedConfiguration; advanced: AdvancedConfiguration;
labels: any[]; labels: any[];
files: Files; files: Files;
activeFileManagerTab: string;
} }
interface Props { interface Props {
@ -53,6 +54,7 @@ const defaultState = {
share: [], share: [],
remote: [], remote: [],
}, },
activeFileManagerTab: 'local',
}; };
class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps, State> { class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps, State> {
@ -132,6 +134,14 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
}); });
}; };
private changeFileManagerTab = (key: string): void => {
const values = this.state;
this.setState({
...values,
activeFileManagerTab: key
});
};
private handleSubmitClick = (): void => { private handleSubmitClick = (): void => {
if (!this.validateLabelsOrProject()) { if (!this.validateLabelsOrProject()) {
notification.error({ notification.error({
@ -238,6 +248,7 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
<Text type='danger'>* </Text> <Text type='danger'>* </Text>
<Text className='cvat-text-color'>Select files:</Text> <Text className='cvat-text-color'>Select files:</Text>
<ConnectedFileManager <ConnectedFileManager
onChangeActiveKey={this.changeFileManagerTab}
ref={(container: any): void => { ref={(container: any): void => {
this.fileManagerContainer = container; this.fileManagerContainer = container;
}} }}
@ -255,6 +266,7 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
<Collapse.Panel key='1' header={<Text className='cvat-title'>Advanced configuration</Text>}> <Collapse.Panel key='1' header={<Text className='cvat-title'>Advanced configuration</Text>}>
<AdvancedConfigurationForm <AdvancedConfigurationForm
installedGit={installedGit} installedGit={installedGit}
activeFileManagerTab={this.state.activeFileManagerTab}
wrappedComponentRef={(component: any): void => { wrappedComponentRef={(component: any): void => {
this.advancedConfigurationComponent = component; this.advancedConfigurationComponent = component;
}} }}

@ -31,6 +31,7 @@ interface Props {
withRemote: boolean; withRemote: boolean;
treeData: TreeNodeNormal[]; treeData: TreeNodeNormal[];
onLoadData: (key: string, success: () => void, failure: () => void) => void; onLoadData: (key: string, success: () => void, failure: () => void) => void;
onChangeActiveKey(key: string): void;
} }
export default class FileManager extends React.PureComponent<Props, State> { export default class FileManager extends React.PureComponent<Props, State> {
@ -215,7 +216,7 @@ export default class FileManager extends React.PureComponent<Props, State> {
} }
public render(): JSX.Element { public render(): JSX.Element {
const { withRemote } = this.props; const { withRemote, onChangeActiveKey } = this.props;
const { active } = this.state; const { active } = this.state;
return ( return (
@ -224,11 +225,12 @@ export default class FileManager extends React.PureComponent<Props, State> {
type='card' type='card'
activeKey={active} activeKey={active}
tabBarGutter={5} tabBarGutter={5}
onChange={(activeKey: string): void => onChange={(activeKey: string): void => {
onChangeActiveKey(activeKey);
this.setState({ this.setState({
active: activeKey as any, active: activeKey as any,
}) });
} }}
> >
{this.renderLocalSelector()} {this.renderLocalSelector()}
{this.renderShareSelector()} {this.renderShareSelector()}

@ -2,7 +2,7 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import React from 'react'; import React, { useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router'; import { RouteComponentProps } from 'react-router';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
@ -26,6 +26,72 @@ interface Props {
onJobUpdate(jobInstance: any): void; onJobUpdate(jobInstance: any): void;
} }
function ReviewSummaryComponent({ jobInstance }: { jobInstance: any }): JSX.Element {
const [summary, setSummary] = useState<Record<string, any> | null>(null);
const [error, setError] = useState<any>(null);
useEffect(() => {
setError(null);
jobInstance
.reviewsSummary()
.then((_summary: Record<string, any>) => {
setSummary(_summary);
})
.catch((_error: any) => {
// eslint-disable-next-line
console.log(_error);
setError(_error);
});
}, []);
if (!summary) {
if (error) {
if (error.toString().includes('403')) {
return <p>You do not have permissions</p>;
}
return <p>Could not fetch, check console output</p>;
}
return (
<>
<p>Loading.. </p>
<Icon type='loading' />
</>
);
}
return (
<table className='cvat-review-summary-description'>
<tbody>
<tr>
<td>
<Text strong>Reviews</Text>
</td>
<td>{summary.reviews}</td>
</tr>
<tr>
<td>
<Text strong>Average quality</Text>
</td>
<td>{Number.parseFloat(summary.average_estimated_quality).toFixed(2)}</td>
</tr>
<tr>
<td>
<Text strong>Unsolved issues</Text>
</td>
<td>{summary.issues_unsolved}</td>
</tr>
<tr>
<td>
<Text strong>Resolved issues</Text>
</td>
<td>{summary.issues_resolved}</td>
</tr>
</tbody>
</table>
);
}
function JobListComponent(props: Props & RouteComponentProps): JSX.Element { function JobListComponent(props: Props & RouteComponentProps): JSX.Element {
const { const {
taskInstance, taskInstance,
@ -64,7 +130,9 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element {
title: 'Status', title: 'Status',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (status: string): JSX.Element => { className: 'cvat-job-item-status',
render: (jobInstance: any): JSX.Element => {
const { status } = jobInstance;
let progressColor = null; let progressColor = null;
if (status === 'completed') { if (status === 'completed') {
progressColor = 'cvat-job-completed-color'; progressColor = 'cvat-job-completed-color';
@ -77,6 +145,9 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element {
return ( return (
<Text strong className={progressColor}> <Text strong className={progressColor}>
{status} {status}
<Tooltip title={<ReviewSummaryComponent jobInstance={jobInstance} />}>
<Icon type='question-circle' />
</Tooltip>
</Text> </Text>
); );
}, },
@ -97,20 +168,33 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element {
title: 'Assignee', title: 'Assignee',
dataIndex: 'assignee', dataIndex: 'assignee',
key: 'assignee', key: 'assignee',
render: (jobInstance: any): JSX.Element => { render: (jobInstance: any): JSX.Element => (
const assignee = jobInstance.assignee ? jobInstance.assignee : null; <UserSelector
className='cvat-job-assignee-selector'
return ( value={jobInstance.assignee}
<UserSelector onSelect={(value: User | null): void => {
value={assignee} // eslint-disable-next-line
onSelect={(value: User | null): void => { jobInstance.assignee = value;
// eslint-disable-next-line onJobUpdate(jobInstance);
jobInstance.assignee = value; }}
onJobUpdate(jobInstance); />
}} ),
/> },
); {
}, title: 'Reviewer',
dataIndex: 'reviewer',
key: 'reviewer',
render: (jobInstance: any): JSX.Element => (
<UserSelector
className='cvat-job-reviewer-selector'
value={jobInstance.reviewer}
onSelect={(value: User | null): void => {
// eslint-disable-next-line
jobInstance.reviewer = value;
onJobUpdate(jobInstance);
}}
/>
),
}, },
]; ];
@ -126,10 +210,11 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element {
key: job.id, key: job.id,
job: job.id, job: job.id,
frames: `${job.startFrame}-${job.stopFrame}`, frames: `${job.startFrame}-${job.stopFrame}`,
status: `${job.status}`, status: job,
started: `${created.format('MMMM Do YYYY HH:MM')}`, started: `${created.format('MMMM Do YYYY HH:MM')}`,
duration: `${moment.duration(moment(moment.now()).diff(created)).humanize()}`, duration: `${moment.duration(moment(moment.now()).diff(created)).humanize()}`,
assignee: job, assignee: job,
reviewer: job,
}); });
return acc; return acc;

@ -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 { .cvat-job-completed-color {
color: $completed-progress-color; color: $completed-progress-color;
} }

@ -20,6 +20,7 @@ export interface User {
interface Props { interface Props {
value: User | null; value: User | null;
className?: string;
onSelect: (user: User | null) => void; onSelect: (user: User | null) => void;
} }
@ -43,7 +44,7 @@ const searchUsers = debounce(
); );
export default function UserSelector(props: Props): JSX.Element { export default function UserSelector(props: Props): JSX.Element {
const { value, onSelect } = props; const { value, className, onSelect } = props;
const [searchPhrase, setSearchPhrase] = useState(''); const [searchPhrase, setSearchPhrase] = useState('');
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
@ -89,6 +90,7 @@ export default function UserSelector(props: Props): JSX.Element {
} }
}, [value]); }, [value]);
const combinedClassName = className ? `${className} cvat-user-search-field` : 'cvat-user-search-field';
return ( return (
<Autocomplete <Autocomplete
ref={autocompleteRef} ref={autocompleteRef}
@ -96,7 +98,7 @@ export default function UserSelector(props: Props): JSX.Element {
placeholder='Select a user' placeholder='Select a user'
onSearch={handleSearch} onSearch={handleSearch}
onSelect={handleSelect} onSelect={handleSelect}
className='cvat-user-search-field' className={combinedClassName}
onDropdownVisibleChange={handleFocus} onDropdownVisibleChange={handleFocus}
dataSource={users.map((user) => ({ dataSource={users.map((user) => ({
value: user.id.toString(), value: user.id.toString(),

@ -18,6 +18,9 @@ const NUCLIO_GUIDE =
'https://github.com/openvinotoolkit/cvat/blob/develop/cvat/apps/documentation/installation.md#semi-automatic-and-automatic-annotation'; '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 CANVAS_BACKGROUND_COLORS = ['#ffffff', '#f1f1f1', '#e5e5e5', '#d8d8d8', '#CCCCCC', '#B3B3B3', '#999999'];
const NEW_LABEL_COLOR = '#b3b3b3'; 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 { export default {
UNDEFINED_ATTRIBUTE_VALUE, UNDEFINED_ATTRIBUTE_VALUE,
@ -33,4 +36,7 @@ export default {
CANVAS_BACKGROUND_COLORS, CANVAS_BACKGROUND_COLORS,
NEW_LABEL_COLOR, NEW_LABEL_COLOR,
NUCLIO_GUIDE, NUCLIO_GUIDE,
LATEST_COMMENTS_SHOWN_QUICK_ISSUE,
QUICK_ISSUE_INCORRECT_POSITION_TEXT,
QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT,
}; };

@ -5,49 +5,81 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; 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 { interface StateToProps {
activatedStateID: number | null; contextMenuClientID: number | null;
objectStates: any[]; objectStates: any[];
visible: boolean; visible: boolean;
top: number; top: number;
left: number; left: number;
type: ContextMenuType; type: ContextMenuType;
collapsed: boolean | undefined; collapsed: boolean | undefined;
workspace: Workspace;
latestComments: string[];
}
interface DispatchToProps {
onStartIssue(position: number[]): void;
openIssue(position: number[], message: string): void;
} }
function mapStateToProps(state: CombinedState): StateToProps { function mapStateToProps(state: CombinedState): StateToProps {
const { const {
annotation: { annotation: {
annotations: { activatedStateID, collapsed, states: objectStates }, annotations: { collapsed, states: objectStates },
canvas: { canvas: {
contextMenu: { contextMenu: {
visible, top, left, type, visible, top, left, type, clientID,
}, },
ready, ready,
}, },
workspace,
}, },
review: { latestComments },
} = state; } = state;
return { return {
activatedStateID, contextMenuClientID: clientID,
collapsed: activatedStateID !== null ? collapsed[activatedStateID] : undefined, collapsed: clientID !== null ? collapsed[clientID] : undefined,
objectStates, objectStates,
visible: visible:
activatedStateID !== null && clientID !== null &&
visible && visible &&
ready && ready &&
objectStates.map((_state: any): number => _state.clientID).includes(activatedStateID), objectStates.map((_state: any): number => _state.clientID).includes(clientID),
left, left,
top, top,
type, 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 { interface State {
latestLeft: number; latestLeft: number;
@ -57,12 +89,13 @@ interface State {
} }
class CanvasContextMenuContainer extends React.PureComponent<Props, State> { class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
private initialized: HTMLDivElement | null; static defaultProps = {
readonly: false,
};
private initialized: HTMLDivElement | null;
private dragging: boolean; private dragging: boolean;
private dragInitPosX: number; private dragInitPosX: number;
private dragInitPosY: number; private dragInitPosY: number;
public constructor(props: Props) { public constructor(props: Props) {
@ -154,7 +187,6 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
private updatePositionIfOutOfScreen(): void { private updatePositionIfOutOfScreen(): void {
const { top, left } = this.state; const { top, left } = this.state;
const { innerWidth, innerHeight } = window; const { innerWidth, innerHeight } = window;
const [element] = window.document.getElementsByClassName('cvat-canvas-context-menu'); const [element] = window.document.getElementsByClassName('cvat-canvas-context-menu');
@ -174,18 +206,31 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
public render(): JSX.Element { public render(): JSX.Element {
const { left, top } = this.state; const { left, top } = this.state;
const { const {
visible, activatedStateID, objectStates, type, visible,
contextMenuClientID,
objectStates,
type,
readonly,
workspace,
latestComments,
onStartIssue,
openIssue,
} = this.props; } = this.props;
return ( return (
<> <>
{type === ContextMenuType.CANVAS_SHAPE && ( {type === ContextMenuType.CANVAS_SHAPE && (
<CanvasContextMenuComponent <CanvasContextMenuComponent
contextMenuClientID={contextMenuClientID}
readonly={readonly}
left={left} left={left}
top={top} top={top}
visible={visible} visible={visible}
objectStates={objectStates} objectStates={objectStates}
activatedStateID={activatedStateID} workspace={workspace}
latestComments={latestComments}
onStartIssue={onStartIssue}
openIssue={openIssue}
/> />
)} )}
</> </>
@ -193,4 +238,4 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
} }
} }
export default connect(mapStateToProps)(CanvasContextMenuContainer); export default connect(mapStateToProps, mapDispatchToProps)(CanvasContextMenuContainer);

@ -5,7 +5,7 @@
import { ExtendedKeyMapOptions } from 'react-hotkeys'; import { ExtendedKeyMapOptions } from 'react-hotkeys';
import { connect } from 'react-redux'; 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 { import {
confirmCanvasReady, confirmCanvasReady,
dragCanvas, dragCanvas,
@ -27,6 +27,7 @@ import {
addZLayer, addZLayer,
switchZLayer, switchZLayer,
fetchAnnotationsAsync, fetchAnnotationsAsync,
getDataFailed,
} from 'actions/annotation-actions'; } from 'actions/annotation-actions';
import { import {
switchGrid, switchGrid,
@ -37,6 +38,7 @@ import {
changeSaturationLevel, changeSaturationLevel,
switchAutomaticBordering, switchAutomaticBordering,
} from 'actions/settings-actions'; } from 'actions/settings-actions';
import { reviewActions } from 'actions/review-actions';
import { import {
ColorBy, ColorBy,
GridColor, GridColor,
@ -57,6 +59,7 @@ interface StateToProps {
activatedAttributeID: number | null; activatedAttributeID: number | null;
selectedStatesID: number[]; selectedStatesID: number[];
annotations: any[]; annotations: any[];
frameIssues: any[] | null;
frameData: any; frameData: any;
frameAngle: number; frameAngle: number;
frameFetching: boolean; frameFetching: boolean;
@ -119,6 +122,8 @@ interface DispatchToProps {
onSwitchGrid(enabled: boolean): void; onSwitchGrid(enabled: boolean): void;
onSwitchAutomaticBordering(enabled: boolean): void; onSwitchAutomaticBordering(enabled: boolean): void;
onFetchAnnotation(): void; onFetchAnnotation(): void;
onGetDataFailed(error: any): void;
onStartIssue(position: number[]): void;
} }
function mapStateToProps(state: CombinedState): StateToProps { function mapStateToProps(state: CombinedState): StateToProps {
@ -153,9 +158,14 @@ function mapStateToProps(state: CombinedState): StateToProps {
saturationLevel, saturationLevel,
resetZoom, resetZoom,
}, },
workspace: { aamZoomMargin, showObjectsTextAlways, showAllInterpolationTracks, automaticBordering }, workspace: {
shapes: { opacity, colorBy, selectedOpacity, outlined, outlineColor, showBitmap, showProjections }, aamZoomMargin, showObjectsTextAlways, showAllInterpolationTracks, automaticBordering,
},
shapes: {
opacity, colorBy, selectedOpacity, outlined, outlineColor, showBitmap, showProjections,
},
}, },
review: { frameIssues, issuesHidden },
shortcuts: { keyMap }, shortcuts: { keyMap },
} = state; } = state;
@ -163,6 +173,8 @@ function mapStateToProps(state: CombinedState): StateToProps {
sidebarCollapsed, sidebarCollapsed,
canvasInstance, canvasInstance,
jobInstance, jobInstance,
frameIssues:
issuesHidden || ![Workspace.REVIEW_WORKSPACE, Workspace.STANDARD].includes(workspace) ? null : frameIssues,
frameData, frameData,
frameAngle: frameAngles[frame - jobInstance.startFrame], frameAngle: frameAngles[frame - jobInstance.startFrame],
frameFetching, frameFetching,
@ -298,6 +310,12 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
onFetchAnnotation(): void { onFetchAnnotation(): void {
dispatch(fetchAnnotationsAsync()); dispatch(fetchAnnotationsAsync());
}, },
onGetDataFailed(error: any): void {
dispatch(getDataFailed(error));
},
onStartIssue(position: number[]): void {
dispatch(reviewActions.startIssue(position));
},
}; };
} }

@ -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<string, ExtendedKeyMapOptions>;
normalizedKeyMap: Record<string, string>;
}
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);

@ -13,6 +13,7 @@ import { CombinedState } from 'reducers/interfaces';
import ItemButtonsComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item-buttons'; import ItemButtonsComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item-buttons';
interface OwnProps { interface OwnProps {
readonly: boolean;
clientID: number; clientID: number;
outsideDisabled?: boolean; outsideDisabled?: boolean;
hiddenDisabled?: boolean; hiddenDisabled?: boolean;
@ -48,7 +49,9 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps {
shortcuts: { normalizedKeyMap }, shortcuts: { normalizedKeyMap },
} = state; } = state;
const { clientID, outsideDisabled, hiddenDisabled, keyframeDisabled } = own; const {
clientID, outsideDisabled, hiddenDisabled, keyframeDisabled,
} = own;
const [objectState] = states.filter((_objectState): boolean => _objectState.clientID === clientID); const [objectState] = states.filter((_objectState): boolean => _objectState.clientID === clientID);
return { return {
@ -74,7 +77,7 @@ function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps {
}; };
} }
class ItemButtonsWrapper extends React.PureComponent<StateToProps & DispatchToProps> { class ItemButtonsWrapper extends React.PureComponent<StateToProps & DispatchToProps & OwnProps> {
private navigateFirstKeyframe = (): void => { private navigateFirstKeyframe = (): void => {
const { objectState, frameNumber } = this.props; const { objectState, frameNumber } = this.props;
const { first } = objectState.keyframes; const { first } = objectState.keyframes;
@ -108,83 +111,109 @@ class ItemButtonsWrapper extends React.PureComponent<StateToProps & DispatchToPr
}; };
private lock = (): void => { private lock = (): void => {
const { objectState, jobInstance } = this.props; const { objectState, jobInstance, readonly } = this.props;
jobInstance.logger.log(LogType.lockObject, { locked: true }); if (!readonly) {
objectState.lock = true; jobInstance.logger.log(LogType.lockObject, { locked: true });
this.commit(); objectState.lock = true;
this.commit();
}
}; };
private unlock = (): void => { private unlock = (): void => {
const { objectState, jobInstance } = this.props; const { objectState, jobInstance, readonly } = this.props;
jobInstance.logger.log(LogType.lockObject, { locked: false }); if (!readonly) {
objectState.lock = false; jobInstance.logger.log(LogType.lockObject, { locked: false });
this.commit(); objectState.lock = false;
this.commit();
}
}; };
private pin = (): void => { private pin = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.pinned = true; if (!readonly) {
this.commit(); objectState.pinned = true;
this.commit();
}
}; };
private unpin = (): void => { private unpin = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.pinned = false; if (!readonly) {
this.commit(); objectState.pinned = false;
this.commit();
}
}; };
private show = (): void => { private show = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.hidden = false; if (!readonly) {
this.commit(); objectState.hidden = false;
this.commit();
}
}; };
private hide = (): void => { private hide = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.hidden = true; if (!readonly) {
this.commit(); objectState.hidden = true;
this.commit();
}
}; };
private setOccluded = (): void => { private setOccluded = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.occluded = true; if (!readonly) {
this.commit(); objectState.occluded = true;
this.commit();
}
}; };
private unsetOccluded = (): void => { private unsetOccluded = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.occluded = false; if (!readonly) {
this.commit(); objectState.occluded = false;
this.commit();
}
}; };
private setOutside = (): void => { private setOutside = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.outside = true; if (!readonly) {
this.commit(); objectState.outside = true;
this.commit();
}
}; };
private unsetOutside = (): void => { private unsetOutside = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.outside = false; if (!readonly) {
this.commit(); objectState.outside = false;
this.commit();
}
}; };
private setKeyframe = (): void => { private setKeyframe = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.keyframe = true; if (!readonly) {
this.commit(); objectState.keyframe = true;
this.commit();
}
}; };
private unsetKeyframe = (): void => { private unsetKeyframe = (): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.keyframe = false; if (!readonly) {
this.commit(); objectState.keyframe = false;
this.commit();
}
}; };
private commit(): void { 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 { private changeFrame(frame: number): void {
@ -197,14 +226,17 @@ class ItemButtonsWrapper extends React.PureComponent<StateToProps & DispatchToPr
public render(): JSX.Element { public render(): JSX.Element {
const { const {
objectState, objectState,
normalizedKeyMap, readonly,
frameNumber, frameNumber,
outsideDisabled, outsideDisabled,
hiddenDisabled, hiddenDisabled,
keyframeDisabled, keyframeDisabled,
normalizedKeyMap,
} = this.props; } = this.props;
const { first, prev, next, last } = objectState.keyframes || { const {
first, prev, next, last,
} = objectState.keyframes || {
first: null, // shapes don't have keyframes, so we use null first: null, // shapes don't have keyframes, so we use null
prev: null, prev: null,
next: null, next: null,
@ -213,6 +245,7 @@ class ItemButtonsWrapper extends React.PureComponent<StateToProps & DispatchToPr
return ( return (
<ItemButtonsComponent <ItemButtonsComponent
readonly={readonly}
objectType={objectState.objectType} objectType={objectState.objectType}
shapeType={objectState.shapeType} shapeType={objectState.shapeType}
occluded={objectState.occluded} occluded={objectState.occluded}

@ -7,26 +7,26 @@ import copy from 'copy-to-clipboard';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { LogType } from 'cvat-logger'; import { LogType } from 'cvat-logger';
import {
ActiveControl, CombinedState, ColorBy, ShapeType,
} from 'reducers/interfaces';
import { import {
collapseObjectItems, collapseObjectItems,
updateAnnotationsAsync, updateAnnotationsAsync,
changeFrameAsync, changeFrameAsync,
removeObjectAsync, removeObjectAsync,
changeGroupColorAsync, changeGroupColorAsync,
pasteShapeAsync,
copyShape as copyShapeAction, copyShape as copyShapeAction,
activateObject as activateObjectAction, activateObject as activateObjectAction,
propagateObject as propagateObjectAction, propagateObject as propagateObjectAction,
pasteShapeAsync,
} from 'actions/annotation-actions'; } from 'actions/annotation-actions';
import {
ActiveControl, CombinedState, ColorBy, ShapeType,
} from 'reducers/interfaces';
import ObjectStateItemComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item'; import ObjectStateItemComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item';
import { ToolsControlComponent } from 'components/annotation-page/standard-workspace/controls-side-bar/tools-control'; import { ToolsControlComponent } from 'components/annotation-page/standard-workspace/controls-side-bar/tools-control';
import { shift } from 'utils/math'; import { shift } from 'utils/math';
interface OwnProps { interface OwnProps {
readonly: boolean;
clientID: number; clientID: number;
objectStates: any[]; objectStates: any[];
initialCollapsed: boolean; initialCollapsed: boolean;
@ -136,27 +136,34 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
}; };
} }
type Props = StateToProps & DispatchToProps; type Props = StateToProps & DispatchToProps & OwnProps;
class ObjectItemContainer extends React.PureComponent<Props> { class ObjectItemContainer extends React.PureComponent<Props> {
private copy = (): void => { private copy = (): void => {
const { objectState, copyShape } = this.props; const { objectState, readonly, copyShape } = this.props;
copyShape(objectState); if (!readonly) {
copyShape(objectState);
}
}; };
private propagate = (): void => { private propagate = (): void => {
const { objectState, propagateObject } = this.props; const { objectState, readonly, propagateObject } = this.props;
propagateObject(objectState); if (!readonly) {
propagateObject(objectState);
}
}; };
private remove = (): void => { 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 => { private createURL = (): void => {
const { objectState, frameNumber } = this.props; const { objectState, frameNumber } = this.props;
const { origin, pathname } = window.location; const { origin, pathname } = window.location;
const search = `frame=${frameNumber}&type=${objectState.objectType}&serverID=${objectState.serverID}`; const search = `frame=${frameNumber}&type=${objectState.objectType}&serverID=${objectState.serverID}`;
@ -165,7 +172,11 @@ class ObjectItemContainer extends React.PureComponent<Props> {
}; };
private switchOrientation = (): void => { private switchOrientation = (): void => {
const { objectState, updateState } = this.props; const { objectState, readonly, updateState } = this.props;
if (readonly) {
return;
}
if (objectState.shapeType === ShapeType.CUBOID) { if (objectState.shapeType === ShapeType.CUBOID) {
this.switchCuboidOrientation(); this.switchCuboidOrientation();
return; return;
@ -192,22 +203,26 @@ class ObjectItemContainer extends React.PureComponent<Props> {
}; };
private toBackground = (): void => { private toBackground = (): void => {
const { objectState, minZLayer } = this.props; const { objectState, readonly, minZLayer } = this.props;
objectState.zOrder = minZLayer - 1; if (!readonly) {
this.commit(); objectState.zOrder = minZLayer - 1;
this.commit();
}
}; };
private toForeground = (): void => { private toForeground = (): void => {
const { objectState, maxZLayer } = this.props; const { objectState, readonly, maxZLayer } = this.props;
objectState.zOrder = maxZLayer + 1; if (!readonly) {
this.commit(); objectState.zOrder = maxZLayer + 1;
this.commit();
}
}; };
private activate = (): void => { private activate = (): void => {
const { const {
activateObject, objectState, ready, activeControl, objectState, ready, activeControl, activateObject,
} = this.props; } = this.props;
if (ready && activeControl === ActiveControl.CURSOR) { if (ready && activeControl === ActiveControl.CURSOR) {
@ -222,8 +237,8 @@ class ObjectItemContainer extends React.PureComponent<Props> {
}; };
private activateTracking = (): void => { private activateTracking = (): void => {
const { objectState, aiToolsRef } = this.props; const { objectState, readonly, aiToolsRef } = this.props;
if (aiToolsRef.current && aiToolsRef.current.trackingAvailable()) { if (!readonly && aiToolsRef.current && aiToolsRef.current.trackingAvailable()) {
aiToolsRef.current.trackState(objectState); aiToolsRef.current.trackState(objectState);
} }
}; };
@ -240,22 +255,26 @@ class ObjectItemContainer extends React.PureComponent<Props> {
}; };
private changeLabel = (label: any): void => { private changeLabel = (label: any): void => {
const { objectState } = this.props; const { objectState, readonly } = this.props;
objectState.label = label; if (!readonly) {
this.commit(); objectState.label = label;
this.commit();
}
}; };
private changeAttribute = (id: number, value: string): void => { private changeAttribute = (id: number, value: string): void => {
const { objectState, jobInstance } = this.props; const { objectState, readonly, jobInstance } = this.props;
jobInstance.logger.log(LogType.changeAttribute, { if (!readonly) {
id, jobInstance.logger.log(LogType.changeAttribute, {
value, id,
object_id: objectState.clientID, value,
}); object_id: objectState.clientID,
const attr: Record<number, string> = {}; });
attr[id] = value; const attr: Record<number, string> = {};
objectState.attributes = attr; attr[id] = value;
this.commit(); objectState.attributes = attr;
this.commit();
}
}; };
private switchCuboidOrientation = (): void => { private switchCuboidOrientation = (): void => {
@ -263,56 +282,67 @@ class ObjectItemContainer extends React.PureComponent<Props> {
return points[12] > points[0]; 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 => { private resetCuboidPerspective = (commit = true): void => {
function cuboidOrientationIsLeft(points: number[]): boolean { function cuboidOrientationIsLeft(points: number[]): boolean {
return points[12] > points[0]; return points[12] > points[0];
} }
const { objectState, readonly } = this.props;
const { objectState } = this.props;
const { points } = objectState; if (!readonly) {
const minD = { const { points } = objectState;
x: (points[6] - points[2]) * 0.001, const minD = {
y: (points[3] - points[1]) * 0.001, 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; if (cuboidOrientationIsLeft(points)) {
points[15] = points[11] + points[3] - points[7]; points[14] = points[10] + points[2] - points[6] + minD.x;
points[8] = points[10] + points[4] - points[6]; points[15] = points[11] + points[3] - points[7];
points[9] = points[11] + points[5] - points[7] + minD.y; points[8] = points[10] + points[4] - points[6];
points[12] = points[14] + points[0] - points[2]; points[9] = points[11] + points[5] - points[7] + minD.y;
points[13] = points[15] + points[1] - points[3] + minD.y; points[12] = points[14] + points[0] - points[2];
} else { points[13] = points[15] + points[1] - points[3] + minD.y;
points[10] = points[14] + points[6] - points[2] - minD.x; } else {
points[11] = points[15] + points[7] - points[3]; points[10] = points[14] + points[6] - points[2] - minD.x;
points[12] = points[14] + points[0] - points[2]; points[11] = points[15] + points[7] - points[3];
points[13] = points[15] + points[1] - points[3] + minD.y; points[12] = points[14] + points[0] - points[2];
points[8] = points[12] + points[4] - points[0] - minD.x; points[13] = points[15] + points[1] - points[3] + minD.y;
points[9] = points[13] + points[5] - points[1]; 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 { private commit(): void {
const { objectState, updateState } = this.props; const { objectState, readonly, updateState } = this.props;
if (!readonly) {
updateState(objectState); updateState(objectState);
}
} }
public render(): JSX.Element { public render(): JSX.Element {
const { const {
objectState, collapsed, labels, attributes, activated, colorBy, normalizedKeyMap, objectState,
collapsed,
labels,
attributes,
activated,
colorBy,
normalizedKeyMap,
readonly,
} = this.props; } = this.props;
let stateColor = ''; let stateColor = '';
@ -326,6 +356,7 @@ class ObjectItemContainer extends React.PureComponent<Props> {
return ( return (
<ObjectStateItemComponent <ObjectStateItemComponent
readonly={readonly}
activated={activated} activated={activated}
objectType={objectState.objectType} objectType={objectState.objectType}
shapeType={objectState.shapeType} shapeType={objectState.shapeType}

@ -12,12 +12,18 @@ import {
removeObjectAsync, removeObjectAsync,
changeFrameAsync, changeFrameAsync,
collapseObjectItems, collapseObjectItems,
changeGroupColorAsync,
copyShape as copyShapeAction, copyShape as copyShapeAction,
propagateObject as propagateObjectAction, propagateObject as propagateObjectAction,
changeGroupColorAsync,
} from 'actions/annotation-actions'; } from 'actions/annotation-actions';
import { Canvas } from 'cvat-canvas-wrapper'; import { Canvas } from 'cvat-canvas-wrapper';
import { CombinedState, StatesOrdering, ObjectType, ColorBy } from 'reducers/interfaces'; import {
CombinedState, StatesOrdering, ObjectType, ColorBy,
} from 'reducers/interfaces';
interface OwnProps {
readonly: boolean;
}
interface StateToProps { interface StateToProps {
jobInstance: any; jobInstance: any;
@ -150,7 +156,7 @@ function sortAndMap(objectStates: any[], ordering: StatesOrdering): number[] {
return sorted.map((state: any) => state.clientID); return sorted.map((state: any) => state.clientID);
} }
type Props = StateToProps & DispatchToProps; type Props = StateToProps & DispatchToProps & OwnProps;
interface State { interface State {
statesOrdering: StatesOrdering; statesOrdering: StatesOrdering;
@ -159,6 +165,10 @@ interface State {
} }
class ObjectsListContainer extends React.PureComponent<Props, State> { class ObjectsListContainer extends React.PureComponent<Props, State> {
static defaultProps = {
readonly: false,
};
public constructor(props: Props) { public constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
@ -213,21 +223,27 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
}; };
private lockAllStates(locked: boolean): void { private lockAllStates(locked: boolean): void {
const { objectStates, updateAnnotations } = this.props; const { objectStates, updateAnnotations, readonly } = this.props;
for (const objectState of objectStates) {
objectState.lock = locked; if (!readonly) {
} for (const objectState of objectStates) {
objectState.lock = locked;
}
updateAnnotations(objectStates); updateAnnotations(objectStates);
}
} }
private hideAllStates(hidden: boolean): void { private hideAllStates(hidden: boolean): void {
const { objectStates, updateAnnotations } = this.props; const { objectStates, updateAnnotations, readonly } = this.props;
for (const objectState of objectStates) {
objectState.hidden = hidden; if (!readonly) {
} for (const objectState of objectStates) {
objectState.hidden = hidden;
}
updateAnnotations(objectStates); updateAnnotations(objectStates);
}
} }
private collapseAllStates(collapsed: boolean): void { private collapseAllStates(collapsed: boolean): void {
@ -242,12 +258,6 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
statesLocked, statesLocked,
activatedStateID, activatedStateID,
jobInstance, jobInstance,
updateAnnotations,
changeGroupColor,
removeObject,
copyShape,
propagateObject,
changeFrame,
maxZLayer, maxZLayer,
minZLayer, minZLayer,
keyMap, keyMap,
@ -255,6 +265,15 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
canvasInstance, canvasInstance,
colors, colors,
colorBy, colorBy,
readonly,
listHeight,
statesCollapsedAll,
updateAnnotations,
changeGroupColor,
removeObject,
copyShape,
propagateObject,
changeFrame,
} = this.props; } = this.props;
const { objectStates, sortedStatesID, statesOrdering } = this.state; const { objectStates, sortedStatesID, statesOrdering } = this.state;
@ -302,19 +321,21 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
SWITCH_LOCK: (event: KeyboardEvent | undefined) => { SWITCH_LOCK: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state) { if (state && !readonly) {
state.lock = !state.lock; state.lock = !state.lock;
updateAnnotations([state]); updateAnnotations([state]);
} }
}, },
SWITCH_ALL_HIDDEN: (event: KeyboardEvent | undefined) => { SWITCH_ALL_HIDDEN: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
this.hideAllStates(!statesHidden); if (!readonly) {
this.hideAllStates(!statesHidden);
}
}, },
SWITCH_HIDDEN: (event: KeyboardEvent | undefined) => { SWITCH_HIDDEN: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state) { if (state && !readonly) {
state.hidden = !state.hidden; state.hidden = !state.hidden;
updateAnnotations([state]); updateAnnotations([state]);
} }
@ -322,7 +343,7 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
SWITCH_OCCLUDED: (event: KeyboardEvent | undefined) => { SWITCH_OCCLUDED: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state && state.objectType !== ObjectType.TAG) { if (state && !readonly && state.objectType !== ObjectType.TAG) {
state.occluded = !state.occluded; state.occluded = !state.occluded;
updateAnnotations([state]); updateAnnotations([state]);
} }
@ -330,7 +351,7 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
SWITCH_KEYFRAME: (event: KeyboardEvent | undefined) => { SWITCH_KEYFRAME: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state && state.objectType === ObjectType.TRACK) { if (state && !readonly && state.objectType === ObjectType.TRACK) {
state.keyframe = !state.keyframe; state.keyframe = !state.keyframe;
updateAnnotations([state]); updateAnnotations([state]);
} }
@ -338,7 +359,7 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
SWITCH_OUTSIDE: (event: KeyboardEvent | undefined) => { SWITCH_OUTSIDE: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state && state.objectType === ObjectType.TRACK) { if (state && !readonly && state.objectType === ObjectType.TRACK) {
state.outside = !state.outside; state.outside = !state.outside;
updateAnnotations([state]); updateAnnotations([state]);
} }
@ -346,7 +367,7 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
DELETE_OBJECT: (event: KeyboardEvent | undefined) => { DELETE_OBJECT: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state) { if (state && !readonly) {
removeObject(jobInstance, state, event ? event.shiftKey : false); removeObject(jobInstance, state, event ? event.shiftKey : false);
} }
}, },
@ -370,7 +391,7 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
TO_BACKGROUND: (event: KeyboardEvent | undefined) => { TO_BACKGROUND: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state && state.objectType !== ObjectType.TAG) { if (state && !readonly && state.objectType !== ObjectType.TAG) {
state.zOrder = minZLayer - 1; state.zOrder = minZLayer - 1;
updateAnnotations([state]); updateAnnotations([state]);
} }
@ -378,7 +399,7 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
TO_FOREGROUND: (event: KeyboardEvent | undefined) => { TO_FOREGROUND: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state && state.objectType !== ObjectType.TAG) { if (state && !readonly && state.objectType !== ObjectType.TAG) {
state.zOrder = maxZLayer + 1; state.zOrder = maxZLayer + 1;
updateAnnotations([state]); updateAnnotations([state]);
} }
@ -386,14 +407,14 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
COPY_SHAPE: (event: KeyboardEvent | undefined) => { COPY_SHAPE: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state) { if (state && !readonly) {
copyShape(state); copyShape(state);
} }
}, },
PROPAGATE_OBJECT: (event: KeyboardEvent | undefined) => { PROPAGATE_OBJECT: (event: KeyboardEvent | undefined) => {
preventDefault(event); preventDefault(event);
const state = activatedStated(); const state = activatedStated();
if (state) { if (state && !readonly) {
propagateObject(state); propagateObject(state);
} }
}, },
@ -423,7 +444,11 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
<> <>
<GlobalHotKeys keyMap={subKeyMap} handlers={handlers} allowChanges /> <GlobalHotKeys keyMap={subKeyMap} handlers={handlers} allowChanges />
<ObjectsListComponent <ObjectsListComponent
{...this.props} listHeight={listHeight}
statesHidden={statesHidden}
statesLocked={statesLocked}
statesCollapsedAll={statesCollapsedAll}
readonly={readonly || false}
statesOrdering={statesOrdering} statesOrdering={statesOrdering}
sortedStatesID={sortedStatesID} sortedStatesID={sortedStatesID}
objectStates={objectStates} objectStates={objectStates}

@ -7,12 +7,17 @@ import { withRouter, RouteComponentProps } from 'react-router';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { ClickParam } from 'antd/lib/menu/index'; import { ClickParam } from 'antd/lib/menu/index';
import { CombinedState } from 'reducers/interfaces'; import { CombinedState, TaskStatus } from 'reducers/interfaces';
import AnnotationMenuComponent, { Actions } from 'components/annotation-page/top-bar/annotation-menu'; import AnnotationMenuComponent, { Actions } from 'components/annotation-page/top-bar/annotation-menu';
import { dumpAnnotationsAsync, exportDatasetAsync, updateJobAsync } from 'actions/tasks-actions';
import { dumpAnnotationsAsync, exportDatasetAsync } from 'actions/tasks-actions'; import {
uploadJobAnnotationsAsync,
import { uploadJobAnnotationsAsync, removeAnnotationsAsync } from 'actions/annotation-actions'; removeAnnotationsAsync,
saveAnnotationsAsync,
switchRequestReviewDialog as switchRequestReviewDialogAction,
switchSubmitReviewDialog as switchSubmitReviewDialogAction,
setForceExitAnnotationFlag as setForceExitAnnotationFlagAction,
} from 'actions/annotation-actions';
interface StateToProps { interface StateToProps {
annotationFormats: any; annotationFormats: any;
@ -20,6 +25,7 @@ interface StateToProps {
loadActivity: string | null; loadActivity: string | null;
dumpActivities: string[] | null; dumpActivities: string[] | null;
exportActivities: string[] | null; exportActivities: string[] | null;
user: any;
} }
interface DispatchToProps { interface DispatchToProps {
@ -27,6 +33,11 @@ interface DispatchToProps {
dumpAnnotations(task: any, dumper: any): void; dumpAnnotations(task: any, dumper: any): void;
exportDataset(task: any, exporter: any): void; exportDataset(task: any, exporter: any): void;
removeAnnotations(sessionInstance: any): void; removeAnnotations(sessionInstance: any): void;
switchRequestReviewDialog(visible: boolean): void;
switchSubmitReviewDialog(visible: boolean): void;
setForceExitAnnotationFlag(forceExit: boolean): void;
saveAnnotations(jobInstance: any, afterSave?: () => void): void;
updateJob(jobInstance: any): void;
} }
function mapStateToProps(state: CombinedState): StateToProps { function mapStateToProps(state: CombinedState): StateToProps {
@ -39,6 +50,7 @@ function mapStateToProps(state: CombinedState): StateToProps {
tasks: { tasks: {
activities: { dumps, loads, exports: activeExports }, activities: { dumps, loads, exports: activeExports },
}, },
auth: { user },
} = state; } = state;
const taskID = jobInstance.task.id; 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, loadActivity: taskID in loads || jobID in jobLoads ? loads[taskID] || jobLoads[jobID] : null,
jobInstance, jobInstance,
annotationFormats, annotationFormats,
user,
}; };
} }
@ -67,6 +80,21 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
removeAnnotations(sessionInstance: any): void { removeAnnotations(sessionInstance: any): void {
dispatch(removeAnnotationsAsync(sessionInstance)); 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 { function AnnotationMenuContainer(props: Props): JSX.Element {
const { const {
jobInstance, jobInstance,
user,
annotationFormats: { loaders, dumpers }, annotationFormats: { loaders, dumpers },
loadAnnotations,
dumpAnnotations,
exportDataset,
removeAnnotations,
history, history,
loadActivity, loadActivity,
dumpActivities, dumpActivities,
exportActivities, exportActivities,
loadAnnotations,
dumpAnnotations,
exportDataset,
removeAnnotations,
switchRequestReviewDialog,
switchSubmitReviewDialog,
setForceExitAnnotationFlag,
saveAnnotations,
updateJob,
} = props; } = props;
const onClickMenu = (params: ClickParam, file?: File): void => { const onClickMenu = (params: ClickParam, file?: File): void => {
@ -112,12 +146,26 @@ function AnnotationMenuContainer(props: Props): JSX.Element {
const [action] = params.keyPath; const [action] = params.keyPath;
if (action === Actions.REMOVE_ANNO) { if (action === Actions.REMOVE_ANNO) {
removeAnnotations(jobInstance); 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) { } else if (action === Actions.OPEN_TASK) {
history.push(`/tasks/${jobInstance.task.id}`); history.push(`/tasks/${jobInstance.task.id}`);
} }
} }
}; };
const isReviewer = jobInstance.reviewer?.id === user.id || user.isSuperuser;
return ( return (
<AnnotationMenuComponent <AnnotationMenuComponent
taskMode={jobInstance.task.mode} taskMode={jobInstance.task.mode}
@ -127,7 +175,10 @@ function AnnotationMenuContainer(props: Props): JSX.Element {
dumpActivities={dumpActivities} dumpActivities={dumpActivities}
exportActivities={exportActivities} exportActivities={exportActivities}
onClickMenu={onClickMenu} onClickMenu={onClickMenu}
taskID={jobInstance.task.id} setForceExitAnnotationFlag={setForceExitAnnotationFlag}
saveAnnotations={saveAnnotations}
jobInstance={jobInstance}
isReviewer={isReviewer}
/> />
); );
} }

@ -5,7 +5,7 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { CombinedState } from 'reducers/interfaces'; 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'; import StatisticsModalComponent from 'components/annotation-page/top-bar/statistics-modal';
interface StateToProps { interface StateToProps {
@ -18,7 +18,6 @@ interface StateToProps {
} }
interface DispatchToProps { interface DispatchToProps {
changeJobStatus(jobInstance: any, status: string): void;
closeStatistics(): void; closeStatistics(): void;
} }
@ -46,9 +45,6 @@ function mapStateToProps(state: CombinedState): StateToProps {
function mapDispatchToProps(dispatch: any): DispatchToProps { function mapDispatchToProps(dispatch: any): DispatchToProps {
return { return {
changeJobStatus(jobInstance: any, status: string): void {
dispatch(changeJobStatusAsync(jobInstance, status));
},
closeStatistics(): void { closeStatistics(): void {
dispatch(showStatistics(false)); dispatch(showStatistics(false));
}, },
@ -58,14 +54,10 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
type Props = StateToProps & DispatchToProps; type Props = StateToProps & DispatchToProps;
class StatisticsModalContainer extends React.PureComponent<Props> { class StatisticsModalContainer extends React.PureComponent<Props> {
private changeJobStatus = (status: string): void => {
const { jobInstance, changeJobStatus } = this.props;
changeJobStatus(jobInstance, status);
};
public render(): JSX.Element { 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 ( return (
<StatisticsModalComponent <StatisticsModalComponent
@ -74,13 +66,12 @@ class StatisticsModalContainer extends React.PureComponent<Props> {
visible={visible} visible={visible}
jobStatus={jobStatus} jobStatus={jobStatus}
bugTracker={jobInstance.task.bugTracker} bugTracker={jobInstance.task.bugTracker}
zOrder={jobInstance.task.zOrder}
startFrame={jobInstance.startFrame} startFrame={jobInstance.startFrame}
stopFrame={jobInstance.stopFrame} stopFrame={jobInstance.stopFrame}
assignee={jobInstance.assignee ? jobInstance.assignee.username : 'Nobody'} assignee={jobInstance.assignee ? jobInstance.assignee.username : 'Nobody'}
reviewer={jobInstance.reviewer ? jobInstance.reviewer.username : 'Nobody'}
savingJobStatus={savingJobStatus} savingJobStatus={savingJobStatus}
closeStatistics={closeStatistics} closeStatistics={closeStatistics}
changeJobStatus={this.changeJobStatus}
/> />
); );
} }

@ -23,6 +23,7 @@ import {
searchEmptyFrameAsync, searchEmptyFrameAsync,
changeWorkspace as changeWorkspaceAction, changeWorkspace as changeWorkspaceAction,
activateObject, activateObject,
setForceExitAnnotationFlag as setForceExitAnnotationFlagAction,
} from 'actions/annotation-actions'; } from 'actions/annotation-actions';
import { Canvas } from 'cvat-canvas-wrapper'; import { Canvas } from 'cvat-canvas-wrapper';
@ -48,6 +49,7 @@ interface StateToProps {
keyMap: Record<string, ExtendedKeyMapOptions>; keyMap: Record<string, ExtendedKeyMapOptions>;
normalizedKeyMap: Record<string, string>; normalizedKeyMap: Record<string, string>;
canvasInstance: Canvas; canvasInstance: Canvas;
forceExit: boolean;
} }
interface DispatchToProps { interface DispatchToProps {
@ -59,6 +61,7 @@ interface DispatchToProps {
redo(sessionInstance: any, frameNumber: any): void; redo(sessionInstance: any, frameNumber: any): void;
searchAnnotations(sessionInstance: any, frameFrom: number, frameTo: number): void; searchAnnotations(sessionInstance: any, frameFrom: number, frameTo: number): void;
searchEmptyFrame(sessionInstance: any, frameFrom: number, frameTo: number): void; searchEmptyFrame(sessionInstance: any, frameFrom: number, frameTo: number): void;
setForceExitAnnotationFlag(forceExit: boolean): void;
changeWorkspace(workspace: Workspace): void; changeWorkspace(workspace: Workspace): void;
} }
@ -70,7 +73,7 @@ function mapStateToProps(state: CombinedState): StateToProps {
frame: { filename: frameFilename, number: frameNumber, delay: frameDelay }, frame: { filename: frameFilename, number: frameNumber, delay: frameDelay },
}, },
annotations: { annotations: {
saving: { uploading: saving, statuses: savingStatuses }, saving: { uploading: saving, statuses: savingStatuses, forceExit },
history, history,
}, },
job: { instance: jobInstance }, job: { instance: jobInstance },
@ -103,6 +106,7 @@ function mapStateToProps(state: CombinedState): StateToProps {
keyMap, keyMap,
normalizedKeyMap, normalizedKeyMap,
canvasInstance, canvasInstance,
forceExit,
}; };
} }
@ -137,6 +141,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
dispatch(activateObject(null, null)); dispatch(activateObject(null, null));
dispatch(changeWorkspaceAction(workspace)); dispatch(changeWorkspaceAction(workspace));
}, },
setForceExitAnnotationFlag(forceExit: boolean): void {
dispatch(setForceExitAnnotationFlagAction(forceExit));
},
}; };
} }
@ -163,16 +170,30 @@ class AnnotationTopBarContainer extends React.PureComponent<Props, State> {
} }
public componentDidMount(): void { 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); 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) => { this.unblock = history.block((location: any) => {
const { forceExit } = self.props;
const { task, id: jobID } = jobInstance; const { task, id: jobID } = jobInstance;
const { id: taskID } = task; 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.'; return 'You have unsaved changes, please confirm leaving this page.';
} }
if (forceExit) {
setForceExitAnnotationFlag(false);
}
return undefined; return undefined;
}); });
@ -413,13 +434,17 @@ class AnnotationTopBarContainer extends React.PureComponent<Props, State> {
}; };
private beforeUnloadCallback = (event: BeforeUnloadEvent): string | undefined => { private beforeUnloadCallback = (event: BeforeUnloadEvent): string | undefined => {
const { jobInstance } = this.props; const { jobInstance, forceExit, setForceExitAnnotationFlag } = this.props;
if (jobInstance.annotations.hasUnsavedChanges()) { if (jobInstance.annotations.hasUnsavedChanges() && !forceExit) {
const confirmationMessage = 'You have unsaved changes, please confirm leaving this page.'; const confirmationMessage = 'You have unsaved changes, please confirm leaving this page.';
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
event.returnValue = confirmationMessage; event.returnValue = confirmationMessage;
return confirmationMessage; return confirmationMessage;
} }
if (forceExit) {
setForceExitAnnotationFlag(false);
}
return undefined; return undefined;
}; };

@ -14,6 +14,7 @@ import { ShareItem, CombinedState } from 'reducers/interfaces';
interface OwnProps { interface OwnProps {
ref: any; ref: any;
withRemote: boolean; withRemote: boolean;
onChangeActiveKey(key: string): void;
} }
interface StateToProps { interface StateToProps {
@ -68,12 +69,13 @@ export class FileManagerContainer extends React.PureComponent<Props> {
} }
public render(): JSX.Element { public render(): JSX.Element {
const { treeData, getTreeData, withRemote } = this.props; const { treeData, getTreeData, withRemote, onChangeActiveKey } = this.props;
return ( return (
<FileManagerComponent <FileManagerComponent
treeData={treeData} treeData={treeData}
onLoadData={getTreeData} onLoadData={getTreeData}
onChangeActiveKey={onChangeActiveKey}
withRemote={withRemote} withRemote={withRemote}
ref={(component): void => { ref={(component): void => {
this.managerComponentRef = component; this.managerComponentRef = component;

@ -9,7 +9,15 @@ import { Canvas, CanvasMode } from 'cvat-canvas-wrapper';
import { AnnotationActionTypes } from 'actions/annotation-actions'; import { AnnotationActionTypes } from 'actions/annotation-actions';
import { AuthActionTypes } from 'actions/auth-actions'; import { AuthActionTypes } from 'actions/auth-actions';
import { BoundariesActionTypes } from 'actions/boundaries-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 = { const defaultState: AnnotationState = {
activities: { activities: {
@ -22,6 +30,7 @@ const defaultState: AnnotationState = {
top: 0, top: 0,
type: ContextMenuType.CANVAS_SHAPE, type: ContextMenuType.CANVAS_SHAPE,
pointID: null, pointID: null,
clientID: null,
}, },
instance: new Canvas(), instance: new Canvas(),
ready: false, ready: false,
@ -57,6 +66,7 @@ const defaultState: AnnotationState = {
activatedStateID: null, activatedStateID: null,
activatedAttributeID: null, activatedAttributeID: null,
saving: { saving: {
forceExit: false,
uploading: false, uploading: false,
statuses: [], statuses: [],
}, },
@ -89,6 +99,8 @@ const defaultState: AnnotationState = {
colors: [], colors: [],
sidebarCollapsed: false, sidebarCollapsed: false,
appearanceCollapsed: false, appearanceCollapsed: false,
requestReviewDialogVisible: false,
submitReviewDialogVisible: false,
tabContentHeight: 0, tabContentHeight: 0,
workspace: Workspace.STANDARD, workspace: Workspace.STANDARD,
}; };
@ -120,6 +132,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
maxZ, maxZ,
} = action.payload; } = action.payload;
const isReview = job.status === TaskStatus.REVIEW;
return { return {
...state, ...state,
job: { job: {
@ -128,8 +142,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
instance: job, instance: job,
labels: job.task.labels, labels: job.task.labels,
attributes: job.task.labels.reduce((acc: Record<number, any[]>, label: any): Record< attributes: job.task.labels.reduce((acc: Record<number, any[]>, label: any): Record<
number, number,
any[] any[]
> => { > => {
acc[label.id] = label.attributes; acc[label.id] = label.attributes;
return acc; return acc;
@ -165,6 +179,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
instance: new Canvas(), instance: new Canvas(),
}, },
colors, colors,
workspace: isReview ? Workspace.REVIEW_WORKSPACE : Workspace.STANDARD,
}; };
} }
case AnnotationActionTypes.GET_JOB_FAILED: { 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: { case AnnotationActionTypes.CHANGE_FRAME: {
return { return {
...state, ...state,
@ -194,13 +221,15 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
}; };
} }
case AnnotationActionTypes.CHANGE_FRAME_SUCCESS: { 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 const activatedStateID = states
.map((_state: any) => _state.clientID) .map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID) .includes(state.annotations.activatedStateID) ?
? state.annotations.activatedStateID state.annotations.activatedStateID :
: null; null;
return { return {
...state, ...state,
@ -245,9 +274,12 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
...state, ...state,
player: { player: {
...state.player, ...state.player,
frameAngles: state.player.frameAngles.map((_angle: number, idx: number) => frameAngles: state.player.frameAngles.map((_angle: number, idx: number) => {
rotateAll || offset === idx ? angle : _angle, if (rotateAll || offset === idx) {
), return angle;
}
return _angle;
}),
}, },
}; };
} }
@ -394,7 +426,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
}; };
} }
case AnnotationActionTypes.REMEMBER_CREATED_OBJECT: { case AnnotationActionTypes.REMEMBER_CREATED_OBJECT: {
const { shapeType, labelID, objectType, points, activeControl, rectDrawingMethod } = action.payload; const {
shapeType, labelID, objectType, points, activeControl, rectDrawingMethod,
} = action.payload;
return { return {
...state, ...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: { case AnnotationActionTypes.MERGE_OBJECTS: {
const { enabled } = action.payload; const { enabled } = action.payload;
const activeControl = enabled ? ActiveControl.MERGE : ActiveControl.CURSOR; const activeControl = enabled ? ActiveControl.MERGE : ActiveControl.CURSOR;
@ -489,7 +539,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
}; };
} }
case AnnotationActionTypes.UPDATE_ANNOTATIONS_SUCCESS: { 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 { states: prevStates } = state.annotations;
const nextStates = [...prevStates]; const nextStates = [...prevStates];
@ -627,6 +679,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
} }
case AnnotationActionTypes.REMOVE_OBJECT_SUCCESS: { case AnnotationActionTypes.REMOVE_OBJECT_SUCCESS: {
const { objectState, history } = action.payload; const { objectState, history } = action.payload;
const contextMenuClientID = state.canvas.contextMenu.clientID;
const contextMenuVisible = state.canvas.contextMenu.visible;
return { return {
...state, ...state,
@ -638,6 +692,14 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
(_objectState: any) => _objectState.clientID !== objectState.clientID, (_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: { 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: { case AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS: {
const { job, loader } = action.payload; const { job, loader } = action.payload;
const { loads } = state.activities; const { loads } = state.activities;
@ -851,7 +886,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
}; };
} }
case AnnotationActionTypes.UPDATE_CANVAS_CONTEXT_MENU: { case AnnotationActionTypes.UPDATE_CANVAS_CONTEXT_MENU: {
const { visible, left, top, type, pointID } = action.payload; const {
visible, left, top, type, pointID,
} = action.payload;
return { return {
...state, ...state,
@ -864,19 +901,22 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
top, top,
type, type,
pointID, pointID,
clientID: state.annotations.activatedStateID,
}, },
}, },
}; };
} }
case AnnotationActionTypes.REDO_ACTION_SUCCESS: case AnnotationActionTypes.REDO_ACTION_SUCCESS:
case AnnotationActionTypes.UNDO_ACTION_SUCCESS: { case AnnotationActionTypes.UNDO_ACTION_SUCCESS: {
const { history, states, minZ, maxZ } = action.payload; const {
history, states, minZ, maxZ,
} = action.payload;
const activatedStateID = states const activatedStateID = states
.map((_state: any) => _state.clientID) .map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID) .includes(state.annotations.activatedStateID) ?
? state.annotations.activatedStateID state.annotations.activatedStateID :
: null; null;
return { return {
...state, ...state,
@ -897,9 +937,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
const { states, minZ, maxZ } = action.payload; const { states, minZ, maxZ } = action.payload;
const activatedStateID = states const activatedStateID = states
.map((_state: any) => _state.clientID) .map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID) .includes(state.annotations.activatedStateID) ?
? state.annotations.activatedStateID state.annotations.activatedStateID :
: null; null;
return { return {
...state, ...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: { case AnnotationActionTypes.CHANGE_WORKSPACE: {
const { workspace } = action.payload; const { workspace } = action.payload;
if (state.canvas.activeControl !== ActiveControl.CURSOR) { if (state.canvas.activeControl !== ActiveControl.CURSOR) {

@ -174,6 +174,12 @@ export interface Model {
}; };
} }
export enum TaskStatus {
ANNOTATION = 'annotation',
REVIEW = 'validation',
COMPLETED = 'completed',
}
export enum RQStatus { export enum RQStatus {
unknown = 'unknown', unknown = 'unknown',
queued = 'queued', queued = 'queued',
@ -284,6 +290,14 @@ export interface NotificationsState {
userAgreements: { userAgreements: {
fetching: null | ErrorState; fetching: null | ErrorState;
}; };
review: {
initialization: null | ErrorState;
finishingIssue: null | ErrorState;
resolvingIssue: null | ErrorState;
reopeningIssue: null | ErrorState;
commentingIssue: null | ErrorState;
submittingReview: null | ErrorState;
};
}; };
messages: { messages: {
tasks: { tasks: {
@ -314,6 +328,7 @@ export enum ActiveControl {
GROUP = 'group', GROUP = 'group',
SPLIT = 'split', SPLIT = 'split',
EDIT = 'edit', EDIT = 'edit',
OPEN_ISSUE = 'open_issue',
AI_TOOLS = 'ai_tools', AI_TOOLS = 'ai_tools',
} }
@ -361,6 +376,7 @@ export interface AnnotationState {
left: number; left: number;
type: ContextMenuType; type: ContextMenuType;
pointID: number | null; pointID: number | null;
clientID: number | null;
}; };
instance: Canvas; instance: Canvas;
ready: boolean; ready: boolean;
@ -410,6 +426,7 @@ export interface AnnotationState {
redo: [string, number][]; redo: [string, number][];
}; };
saving: { saving: {
forceExit: boolean;
uploading: boolean; uploading: boolean;
statuses: string[]; statuses: string[];
}; };
@ -429,6 +446,8 @@ export interface AnnotationState {
data: any; data: any;
}; };
colors: any[]; colors: any[];
requestReviewDialogVisible: boolean;
submitReviewDialogVisible: boolean;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
appearanceCollapsed: boolean; appearanceCollapsed: boolean;
tabContentHeight: number; tabContentHeight: number;
@ -440,6 +459,7 @@ export enum Workspace {
STANDARD = 'Standard', STANDARD = 'Standard',
ATTRIBUTE_ANNOTATION = 'Attribute annotation', ATTRIBUTE_ANNOTATION = 'Attribute annotation',
TAG_ANNOTATION = 'Tag annotation', TAG_ANNOTATION = 'Tag annotation',
REVIEW_WORKSPACE = 'Review',
} }
export enum GridColor { export enum GridColor {
@ -512,12 +532,24 @@ export interface ShortcutsState {
normalizedKeyMap: Record<string, string>; normalizedKeyMap: Record<string, string>;
} }
export interface MetaState { export enum ReviewStatus {
initialized: boolean; ACCEPTED = 'accepted',
fetching: boolean; REJECTED = 'rejected',
showTasksButton: boolean; REVIEW_FURTHER = 'review_further',
showAnalyticsButton: boolean; }
showModelsButton: boolean;
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 { export interface CombinedState {
@ -534,5 +566,5 @@ export interface CombinedState {
annotation: AnnotationState; annotation: AnnotationState;
settings: SettingsState; settings: SettingsState;
shortcuts: ShortcutsState; shortcuts: ShortcutsState;
meta: MetaState; review: ReviewState;
} }

@ -15,6 +15,7 @@ import { AnnotationActionTypes } from 'actions/annotation-actions';
import { NotificationsActionType } from 'actions/notification-actions'; import { NotificationsActionType } from 'actions/notification-actions';
import { BoundariesActionTypes } from 'actions/boundaries-actions'; import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { UserAgreementsActionTypes } from 'actions/useragreements-actions'; import { UserAgreementsActionTypes } from 'actions/useragreements-actions';
import { ReviewActionTypes } from 'actions/review-actions';
import { NotificationsState } from './interfaces'; import { NotificationsState } from './interfaces';
@ -93,6 +94,14 @@ const defaultState: NotificationsState = {
userAgreements: { userAgreements: {
fetching: null, fetching: null,
}, },
review: {
commentingIssue: null,
finishingIssue: null,
initialization: null,
reopeningIssue: null,
resolvingIssue: null,
submittingReview: null,
},
}, },
messages: { messages: {
tasks: { 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: { case AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_FAILED: {
const { job, error } = action.payload; 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: { case NotificationsActionType.RESET_ERRORS: {
return { return {
...state, ...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 BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: { case AuthActionTypes.LOGOUT_SUCCESS: {
return { ...defaultState }; return { ...defaultState };

@ -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;
}

@ -16,6 +16,7 @@ import annotationReducer from './annotation-reducer';
import settingsReducer from './settings-reducer'; import settingsReducer from './settings-reducer';
import shortcutsReducer from './shortcuts-reducer'; import shortcutsReducer from './shortcuts-reducer';
import userAgreementsReducer from './useragreements-reducer'; import userAgreementsReducer from './useragreements-reducer';
import reviewReducer from './review-reducer';
export default function createRootReducer(): Reducer { export default function createRootReducer(): Reducer {
return combineReducers({ return combineReducers({
@ -32,5 +33,6 @@ export default function createRootReducer(): Reducer {
settings: settingsReducer, settings: settingsReducer,
shortcuts: shortcutsReducer, shortcuts: shortcutsReducer,
userAgreements: userAgreementsReducer, userAgreements: userAgreementsReducer,
review: reviewReducer,
}); });
} }

@ -214,6 +214,12 @@ const defaultKeyMap = ({
sequences: ['shift+n', 'n'], sequences: ['shift+n', 'n'],
action: 'keydown', 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: { SWITCH_MERGE_MODE: {
name: 'Merge mode', name: 'Merge mode',
description: 'Activate or deactivate mode to merging shapes', description: 'Activate or deactivate mode to merging shapes',

@ -91,6 +91,11 @@ def is_project_annotator(db_user, db_project):
db_tasks = list(db_project.tasks.prefetch_related('segment_set').all()) 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]) 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 @rules.predicate
def is_task_owner(db_user, db_task): def is_task_owner(db_user, db_task):
# If owner is None (null) the task can be accessed/changed/deleted # 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): def is_task_assignee(db_user, db_task):
return db_task.assignee == db_user or is_project_assignee(db_user, db_task.project) 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 @rules.predicate
def is_task_annotator(db_user, db_task): def is_task_annotator(db_user, db_task):
db_segments = list(db_task.segment_set.prefetch_related('job_set__assignee').all()) 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 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 # AUTH PERMISSIONS RULES
rules.add_perm('engine.role.user', has_user_role) rules.add_perm('engine.role.user', has_user_role)
rules.add_perm('engine.role.admin', has_admin_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.create', has_admin_role | has_user_role)
rules.add_perm('engine.task.access', has_admin_role | has_observer_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 | rules.add_perm('engine.task.change', has_admin_role | is_task_owner |
is_task_assignee) is_task_assignee)
rules.add_perm('engine.task.delete', has_admin_role | is_task_owner) rules.add_perm('engine.task.delete', has_admin_role | is_task_owner)
rules.add_perm('engine.job.access', has_admin_role | has_observer_role | rules.add_perm('engine.job.access', has_admin_role | has_observer_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 | rules.add_perm('engine.job.change', has_admin_role | is_job_owner | has_change_permissions)
is_job_annotator) 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): class AdminRolePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_permission(self, request, view): 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): class UserRolePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_permission(self, request, view): 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): class AnnotatorRolePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_permission(self, request, view): 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): class ObserverRolePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_permission(self, request, view): 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): class ProjectCreatePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_permission(self, request, view): 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): class ProjectAccessPermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): 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): class ProjectChangePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): 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): class ProjectDeletePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): 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): class TaskCreatePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_permission(self, request, view): 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): class TaskAccessPermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): 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): class ProjectGetQuerySetMixin(object):
@ -207,7 +251,8 @@ class ProjectGetQuerySetMixin(object):
else: else:
return queryset.filter(Q(owner=user) | Q(assignee=user) | return queryset.filter(Q(owner=user) | Q(assignee=user) |
Q(task__owner=user) | Q(task__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): def filter_task_queryset(queryset, user):
# Don't filter queryset for admin, observer # Don't filter queryset for admin, observer
@ -215,7 +260,7 @@ def filter_task_queryset(queryset, user):
return queryset return queryset
query_filter = Q(owner=user) | Q(assignee=user) | \ 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']: if not settings.RESTRICTIONS['reduce_task_visibility']:
query_filter |= Q(assignee=None) query_filter |= Q(assignee=None)
@ -234,19 +279,53 @@ class TaskGetQuerySetMixin(object):
class TaskChangePermission(BasePermission): class TaskChangePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): 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): class TaskDeletePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): 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): class JobAccessPermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): 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): class JobChangePermission(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): 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)

@ -18,4 +18,5 @@ services:
environment: environment:
CVAT_HOST: your-instance.amazonaws.com 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.

@ -11,6 +11,9 @@
- [How to install CVAT on Windows 10 Home](#how-to-install-cvat-on-windows-10-home) - [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) - [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 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 ## 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. 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 ```yaml
services: 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: After that, it should be possible to use this directory as a CVAT share:
```yaml ```yaml
version: '2.3' version: '3.3'
services: services:
cvat: 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. 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. 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.

@ -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 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. 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 ### Email verification
You can enable email verification for newly registered users. You can enable email verification for newly registered users.

@ -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
### <a name="aws_s3_ubuntu_2004">Ubuntu 20.04</a>
#### <a name="aws_s3_mount">Mount</a>
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 <bucket_name> <mount_point> -o allow_other
```
For more details see [here](https://github.com/s3fs-fuse/s3fs-fuse).
#### <a name="aws_s3_automatically_mount">Automatically mount</a>
Follow the first 3 mounting steps above.
##### <a name="aws_s3_using_fstab">Using fstab</a>
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 <user_name> s3fs <backet_name> <mount_point> -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 <mount_point> fuse allow_other,user,_netdev 0 0
```
##### <a name="aws_s3_using_systemd">Using systemd</a>
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=<mount_point>"
User=<user_name>
Group=<user_name>
ExecStart=s3fs <bucket_name> ${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
```
#### <a name="aws_s3_check">Check</a>
A file `/etc/mtab` contains records of currently mounted filesystems.
```bash
cat /etc/mtab | grep 's3fs'
```
#### <a name="aws_s3_unmount_filesystem">Unmount filesystem</a>
```bash
fusermount -u <mount_point>
```
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
### <a name="azure_ubuntu_2004">Ubuntu 20.04</a>
#### <a name="azure_mount">Mount</a>
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=<account_name>
export AZURE_STORAGE_ACCESS_KEY=<account_key>
MOUNT_POINT=<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 <user> /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=<your_container> --tmp-path=/mnt/blobfusetmp -o allow_other
```
#### <a name="azure_automatically_mount">Automatically mount</a>
Follow the first 7 mounting steps above.
##### <a name="azure_using_fstab">Using fstab</a>
1. Create configuration file `connection.cfg` with same content, change accountName,
select one from accountKey or sasToken and replace with your value:
```bash
accountName <account-name-here>
# Please provide either an account key or a SAS token, and delete the other line.
accountKey <account-key-here-delete-next-line>
#change authType to specify only 1
sasToken <shared-access-token-here-delete-previous-line>
authType <MSI/SAS/SPN/Key/empty>
containerName <insert-container-name-here>
```
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 <user_name> blobfuse <mount_point> --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 </path/to/desired/mountpoint> fuse allow_other,user,_netdev
```
##### <a name="azure_using_systemd">Using systemd</a>
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=<mount_point>"
User=<user_name>
Group=<user_name>
ExecStart=blobfuse ${MOUNT_POINT} --container-name=<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)
#### <a name="azure_check">Check</a>
A file `/etc/mtab` contains records of currently mounted filesystems.
```bash
cat /etc/mtab | grep 'blobfuse'
```
#### <a name="azure_unmount_filesystem">Unmount filesystem</a>
```bash
fusermount -u <mount_point>
```
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
### <a name="google_drive_ubuntu_2004">Ubuntu 20.04</a>
#### <a name="google_drive_mount">Mount</a>
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="<mount_point>"
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
```
#### <a name="google_drive_automatically_mount">Automatically mount</a>
Follow the first 4 mounting steps above.
##### <a name="google_drive_using_fstab">Using fstab</a>
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 <user_name> google-drive-ocamlfuse -o allow_other -label <label> <mount_point>
exit 0
```
1. Give it the execution permission:
```bash
sudo chmod +x /usr/bin/gdfuse
```
1. Edit `/etc/fstab` adding a line like this, replace `mount_point`):
```bash
/absolute/path/to/gdfuse <mount_point> fuse allow_other,user,_netdev 0 0
```
For more details see [here](https://github.com/astrada/google-drive-ocamlfuse/wiki/Automounting)
##### <a name="google_drive_using_systemd">Using systemd</a>
1. Create unit file `sudo nano /etc/systemd/system/google-drive-ocamlfuse.service`.
(replace `user_name`, `label`(default `label=default`), `mount_point`):
```bash
[Unit]
Description=FUSE filesystem over Google Drive
After=network.target
[Service]
Environment="MOUNT_POINT=<mount_point>"
User=<user_name>
Group=<user_name>
ExecStart=google-drive-ocamlfuse -label <label> ${MOUNT_POINT}
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 drive:
```bash
sudo systemctl daemon-reload
sudo systemctl enable google-drive-ocamlfuse.service
sudo systemctl start google-drive-ocamlfuse.service
```
For more details see [here](https://github.com/astrada/google-drive-ocamlfuse/wiki/Automounting)
#### <a name="google_drive_check">Check</a>
A file `/etc/mtab` contains records of currently mounted filesystems.
```bash
cat /etc/mtab | grep 'google-drive-ocamlfuse'
```
#### <a name="google_drive_unmount_filesystem">Unmount filesystem</a>
```bash
fusermount -u <mount_point>
```
If you used [systemd](#google_drive_using_systemd) to mount a drive:
```bash
sudo systemctl stop google-drive-ocamlfuse.service
sudo systemctl disable google-drive-ocamlfuse.service
```

@ -10,10 +10,9 @@ from django.conf import settings
from cvat.apps.engine.media_extractors import (Mpeg4ChunkWriter, from cvat.apps.engine.media_extractors import (Mpeg4ChunkWriter,
Mpeg4CompressedChunkWriter, ZipChunkWriter, ZipCompressedChunkWriter) Mpeg4CompressedChunkWriter, ZipChunkWriter, ZipCompressedChunkWriter)
from cvat.apps.engine.models import DataChoice from cvat.apps.engine.models import DataChoice, StorageChoice
from cvat.apps.engine.prepare import PrepareInfo from cvat.apps.engine.prepare import PrepareInfo
class CacheInteraction: class CacheInteraction:
def __init__(self): def __init__(self):
self._cache = Cache(settings.CACHE_ROOT) self._cache = Cache(settings.CACHE_ROOT)
@ -31,28 +30,33 @@ class CacheInteraction:
def prepare_chunk_buff(self, db_data, quality, chunk_number): def prepare_chunk_buff(self, db_data, quality, chunk_number):
from cvat.apps.engine.frame_provider import FrameProvider # TODO: remove circular dependency from cvat.apps.engine.frame_provider import FrameProvider # TODO: remove circular dependency
extractor_classes = { writer_classes = {
FrameProvider.Quality.COMPRESSED : Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == DataChoice.VIDEO else ZipCompressedChunkWriter, FrameProvider.Quality.COMPRESSED : Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == DataChoice.VIDEO else ZipCompressedChunkWriter,
FrameProvider.Quality.ORIGINAL : Mpeg4ChunkWriter if db_data.original_chunk_type == DataChoice.VIDEO else ZipChunkWriter, FrameProvider.Quality.ORIGINAL : Mpeg4ChunkWriter if db_data.original_chunk_type == DataChoice.VIDEO else ZipChunkWriter,
} }
image_quality = 100 if extractor_classes[quality] in [Mpeg4ChunkWriter, ZipChunkWriter] else db_data.image_quality image_quality = 100 if writer_classes[quality] in [Mpeg4ChunkWriter, ZipChunkWriter] else db_data.image_quality
mime_type = 'video/mp4' if extractor_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] else 'application/zip' mime_type = 'video/mp4' if writer_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] else 'application/zip'
extractor = extractor_classes[quality](image_quality) writer = writer_classes[quality](image_quality)
images = [] images = []
buff = BytesIO() buff = BytesIO()
upload_dir = {
StorageChoice.LOCAL: db_data.get_upload_dirname(),
StorageChoice.SHARE: settings.SHARE_ROOT
}[db_data.storage]
if os.path.exists(db_data.get_meta_path()): if os.path.exists(db_data.get_meta_path()):
source_path = os.path.join(db_data.get_upload_dirname(), db_data.video.path) source_path = os.path.join(upload_dir, db_data.video.path)
meta = PrepareInfo(source_path=source_path, meta_path=db_data.get_meta_path()) meta = PrepareInfo(source_path=source_path, meta_path=db_data.get_meta_path())
for frame in meta.decode_needed_frames(chunk_number, db_data): for frame in meta.decode_needed_frames(chunk_number, db_data):
images.append(frame) images.append(frame)
extractor.save_as_chunk([(image, source_path, None) for image in images], buff) writer.save_as_chunk([(image, source_path, None) for image in images], buff)
else: else:
with open(db_data.get_dummy_chunk_path(chunk_number), 'r') as dummy_file: with open(db_data.get_dummy_chunk_path(chunk_number), 'r') as dummy_file:
images = [os.path.join(db_data.get_upload_dirname(), line.strip()) for line in dummy_file] images = [os.path.join(upload_dir, line.strip()) for line in dummy_file]
extractor.save_as_chunk([(image, image, None) for image in images], buff) writer.save_as_chunk([(image, image, None) for image in images], buff)
buff.seek(0) buff.seek(0)
return buff, mime_type return buff, mime_type

@ -61,6 +61,7 @@ video/mp2t m2t
video/mp2t bdmv video/mp2t bdmv
video/vnd.mpegurl m4u video/vnd.mpegurl m4u
video/mp4 m4v video/mp4 m4v
video/mxf mxf
# possible image formats # possible image formats
image/x-minolta-mrw mrw image/x-minolta-mrw mrw

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save