[Dependent] Simple Review Pipeline (Client) (#2357)

* tmp

* Removed migration

* Rebased

* Added signals & rating

* Updated API views

* Added reviewer serializer

* Added permissions

* Fixed some code issues

* Fixed swagger docs

* Some fixes

* Updated api method to create review

* Added some API tests & some fixes

* Added some tests

* Removed extra code

* cvat-core, basic review view

* Removed extra components

* Fixed context menu

* Added api method to canvas, removed extra files

* Implemented roi selection

* Added method to display rois on canvas

* Updated README.md

* Create issue dialog, some fixes

* Setup chat dialog windows

* Code refactoring

* Some fixes

* Small enhancement

* Fixed core tests, removed extra code

* A couple of fixes on canvas

* Canvas issues highlighting

* Small UX fix

* Fixed width of chat

* Code refactoring

* Added dialogs & review summary

* Fixed tests

* Fixed bug during rendering

* Applied changes from client part

* Fixed bugs in tests

* Added docs & setup plugins in cvat-core

* Aborted cypress changes

* Fixed comments

* Removed extra change

* Renamed fields

* Updated versions, fixed some comments

* Merged develop

* Merged develop

* Removed extra changes

* Removed extra changes

* Fixed tests

* Fixed scale & animation

* Using hull instead of the whole set of points

* Fixed minor issue

* Added additional logic to improve UX

* Updated task instance after review submitting

* Removed extra dependenci from package lock

* Fixed REST API test

* Removed /comments/<id> [PUT]

* /issue/comments/create [POST] -> /comments [POST]

* /job/<id>/reviews/create [POST] -> /reviews [POST]

* [PATCH] /issue/<id>/resolve(reopen) -> [PATCH] /issue/id

* Minor fix

* Updated till the latest REST APi

* Fixed test

* Reviewed review summary

* Review summary client-side

* Removed unused import

* Checking permissions client side

* Checking job permissions

* UP issue dialog onmouseout

* Added shadow & pressenter event

* Added ref initial value

* Using the same box shadow in the whole project

* Removed extra files in the patch

* Removed extra files in the patch

* Updated versions

* Updated changelog

* Fixed merge issue

* Set updated flag on changing a reviewer

* Fixed submit review modal

* Two minor issue fixed

* Searching issues & additional sidebar tab

* Show text for locked objects

* Fixed permissions for reviewers

* Fixed typos

* Fixed review request/submit actions, added action to finish a job

* Star rating instead

* Fixed migration

* Removed animation, updated blur/highlight behaviour, updated hiddel labels position

* Hide/show issues button, adjusted header

* Context menu & buttons

* Removed ability to select an object in region selector

* Do not show issues in AAM & tag annotation

* Fixed jest tests

* Fixed: cannot read property label of undefined

* Getting hull before creating the issue

* Added ability to leave quick comment one of latest

Co-authored-by: Nikita Manovich <nikita.manovich@intel.com>
main
Boris Sekachev 5 years ago committed by GitHub
parent 45e2ea0707
commit d6ac8cc5be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Manual review pipeline: issues/comments/workspace (<https://github.com/openvinotoolkit/cvat/pull/2357>)
- Added basic projects implementation (<https://github.com/openvinotoolkit/cvat/pull/2255>)
### Changed

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

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

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

@ -58,6 +58,23 @@ polyline.cvat_shape_drawing_opacity {
fill: darkmagenta;
}
.cvat_canvas_shape_region_selection {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
fill: white;
stroke: white;
}
.cvat_canvas_issue_region {
display: none;
stroke-width: 0;
}
circle.cvat_canvas_issue_region {
opacity: 1 !important;
}
polyline.cvat_canvas_shape_grouping {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
@ -258,6 +275,15 @@ polyline.cvat_canvas_shape_splitting {
height: 100%;
}
#cvat_canvas_attachment_board {
position: absolute;
z-index: 4;
pointer-events: none;
width: 100%;
height: 100%;
user-select: none;
}
@keyframes loadingAnimation {
0% {
stroke-dashoffset: 1;

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

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

@ -56,6 +56,7 @@ export interface Configuration {
displayAllText?: boolean;
undefinedAttrValue?: string;
showProjections?: boolean;
forceDisableEditing?: boolean;
}
export interface DrawData {
@ -113,6 +114,7 @@ export enum UpdateReasons {
IMAGE_MOVED = 'image_moved',
GRID_UPDATED = 'grid_updated',
ISSUE_REGIONS_UPDATED = 'issue_regions_updated',
OBJECTS_UPDATED = 'objects_updated',
SHAPE_ACTIVATED = 'shape_activated',
SHAPE_FOCUSED = 'shape_focused',
@ -127,6 +129,7 @@ export enum UpdateReasons {
SELECT = 'select',
CANCEL = 'cancel',
BITMAP = 'bitmap',
SELECT_REGION = 'select_region',
DRAG_CANVAS = 'drag_canvas',
ZOOM_CANVAS = 'zoom_canvas',
CONFIG_UPDATED = 'config_updated',
@ -142,6 +145,7 @@ export enum Mode {
SPLIT = 'split',
GROUP = 'group',
INTERACT = 'interact',
SELECT_REGION = 'select_region',
DRAG_CANVAS = 'drag_canvas',
ZOOM_CANVAS = 'zoom_canvas',
}
@ -149,6 +153,7 @@ export enum Mode {
export interface CanvasModel {
readonly imageBitmap: boolean;
readonly image: Image | null;
readonly issueRegions: Record<number, number[]>;
readonly objects: any[];
readonly zLayer: number | null;
readonly gridSize: Size;
@ -168,6 +173,7 @@ export interface CanvasModel {
move(topOffset: number, leftOffset: number): void;
setup(frameData: any, objectStates: any[], zLayer: number): void;
setupIssueRegions(issueRegions: Record<number, number[]>): void;
activate(clientID: number | null, attributeID: number | null): void;
rotate(rotationAngle: number): void;
focus(clientID: number, padding: number): void;
@ -183,6 +189,7 @@ export interface CanvasModel {
fitCanvas(width: number, height: number): void;
bitmap(enabled: boolean): void;
selectRegion(enabled: boolean): void;
dragCanvas(enable: boolean): void;
zoomCanvas(enable: boolean): void;
@ -206,6 +213,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
gridSize: Size;
left: number;
objects: any[];
issueRegions: Record<number, number[]>;
scale: number;
top: number;
zLayer: number | null;
@ -254,6 +262,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
},
left: 0,
objects: [],
issueRegions: {},
scale: 1,
top: 0,
zLayer: null,
@ -288,15 +297,15 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
const mutiplier = Math.sin((angle * Math.PI) / 180) + Math.cos((angle * Math.PI) / 180);
if ((angle / 90) % 2) {
// 90, 270, ..
this.data.top +=
mutiplier * ((x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1)) * this.data.scale;
this.data.left -=
mutiplier * ((y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1)) * this.data.scale;
const topMultiplier = (x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1);
const leftMultiplier = (y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1);
this.data.top += mutiplier * topMultiplier * this.data.scale;
this.data.left -= mutiplier * leftMultiplier * this.data.scale;
} else {
this.data.left +=
mutiplier * ((x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1)) * this.data.scale;
this.data.top +=
mutiplier * ((y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1)) * this.data.scale;
const leftMultiplier = (x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1);
const topMultiplier = (y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1);
this.data.left += mutiplier * leftMultiplier * this.data.scale;
this.data.top += mutiplier * topMultiplier * this.data.scale;
}
this.notify(UpdateReasons.IMAGE_ZOOMED);
@ -325,6 +334,19 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.notify(UpdateReasons.BITMAP);
}
public selectRegion(enable: boolean): void {
if (enable && this.data.mode !== Mode.IDLE) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
if (!enable && this.data.mode !== Mode.SELECT_REGION) {
throw Error(`Canvas is not in the region selecting mode. Action: ${this.data.mode}`);
}
this.data.mode = enable ? Mode.SELECT_REGION : Mode.IDLE;
this.notify(UpdateReasons.SELECT_REGION);
}
public dragCanvas(enable: boolean): void {
if (enable && this.data.mode !== Mode.IDLE) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
@ -393,6 +415,11 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
});
}
public setupIssueRegions(issueRegions: Record<number, number[]>): void {
this.data.issueRegions = issueRegions;
this.notify(UpdateReasons.ISSUE_REGIONS_UPDATED);
}
public activate(clientID: number | null, attributeID: number | null): void {
if (this.data.activeElement.clientID === clientID && this.data.activeElement.attributeID === attributeID) {
return;
@ -599,13 +626,16 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.data.configuration.undefinedAttrValue = configuration.undefinedAttrValue;
}
if (typeof configuration.forceDisableEditing !== 'undefined') {
this.data.configuration.forceDisableEditing = configuration.forceDisableEditing;
}
this.notify(UpdateReasons.CONFIG_UPDATED);
}
public isAbleToChangeFrame(): boolean {
const isUnable =
[Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode) ||
(this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number');
const isUnable = [Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode)
|| (this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number');
return !isUnable;
}
@ -658,6 +688,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
return this.data.image;
}
public get issueRegions(): Record<number, number[]> {
return { ...this.data.issueRegions };
}
public get objects(): any[] {
if (this.data.zLayer !== null) {
return this.data.objects.filter((object: any): boolean => object.zOrder <= this.data.zLayer);

@ -15,6 +15,7 @@ import { EditHandler, EditHandlerImpl } from './editHandler';
import { MergeHandler, MergeHandlerImpl } from './mergeHandler';
import { SplitHandler, SplitHandlerImpl } from './splitHandler';
import { GroupHandler, GroupHandlerImpl } from './groupHandler';
import { RegionSelector, RegionSelectorImpl } from './regionSelector';
import { ZoomHandler, ZoomHandlerImpl } from './zoomHandler';
import { InteractionHandler, InteractionHandlerImpl } from './interactionHandler';
import { AutoborderHandler, AutoborderHandlerImpl } from './autoborderHandler';
@ -59,6 +60,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
private bitmap: HTMLCanvasElement;
private grid: SVGSVGElement;
private content: SVGSVGElement;
private attachmentBoard: HTMLDivElement;
private adoptedContent: SVG.Container;
private canvas: HTMLDivElement;
private gridPath: SVGPathElement;
@ -66,13 +68,17 @@ export class CanvasViewImpl implements CanvasView, Listener {
private controller: CanvasController;
private svgShapes: Record<number, SVG.Shape>;
private svgTexts: Record<number, SVG.Text>;
private issueRegionPattern_1: SVG.Pattern;
private issueRegionPattern_2: SVG.Pattern;
private drawnStates: Record<number, DrawnState>;
private drawnIssueRegions: Record<number, SVG.Shape>;
private geometry: Geometry;
private drawHandler: DrawHandler;
private editHandler: EditHandler;
private mergeHandler: MergeHandler;
private splitHandler: SplitHandler;
private groupHandler: GroupHandler;
private regionSelector: RegionSelector;
private zoomHandler: ZoomHandler;
private autoborderHandler: AutoborderHandler;
private interactionHandler: InteractionHandler;
@ -90,6 +96,31 @@ export class CanvasViewImpl implements CanvasView, Listener {
return this.controller.mode;
}
private stateIsLocked(state: any): boolean {
const { configuration } = this.controller;
return state.lock || configuration.forceDisableEditing;
}
private translateToCanvas(points: number[]): number[] {
const { offset } = this.controller.geometry;
return points.map((coord: number): number => coord + offset);
}
private translateFromCanvas(points: number[]): number[] {
const { offset } = this.controller.geometry;
return points.map((coord: number): number => coord - offset);
}
private stringifyToCanvas(points: number[]): string {
return points.reduce((acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc}${val} `;
}
return `${acc}${val},`;
}, '');
}
private isServiceHidden(clientID: number): boolean {
return this.serviceFlags.drawHidden[clientID] || false;
}
@ -329,6 +360,30 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.mode = Mode.IDLE;
}
private onRegionSelected(points?: number[]): void {
if (points) {
const event: CustomEvent = new CustomEvent('canvas.regionselected', {
bubbles: false,
cancelable: true,
detail: {
points,
},
});
this.canvas.dispatchEvent(event);
} else {
const event: CustomEvent = new CustomEvent('canvas.canceled', {
bubbles: false,
cancelable: true,
});
this.canvas.dispatchEvent(event);
}
this.controller.selectRegion(false);
this.mode = Mode.IDLE;
}
private onFindObject(e: MouseEvent): void {
if (e.which === 1 || e.which === 0) {
const { offset } = this.controller.geometry;
@ -401,7 +456,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
obj.style.left = `${this.geometry.left}px`;
}
for (const obj of [this.content, this.text]) {
for (const obj of [this.content, this.text, this.attachmentBoard]) {
obj.style.top = `${this.geometry.top - this.geometry.offset}px`;
obj.style.left = `${this.geometry.left - this.geometry.offset}px`;
}
@ -412,11 +467,12 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.zoomHandler.transform(this.geometry);
this.autoborderHandler.transform(this.geometry);
this.interactionHandler.transform(this.geometry);
this.regionSelector.transform(this.geometry);
}
private transformCanvas(): void {
// Transform canvas
for (const obj of [this.background, this.grid, this.content, this.bitmap]) {
for (const obj of [this.background, this.grid, this.content, this.bitmap, this.attachmentBoard]) {
obj.style.transform = `scale(${this.geometry.scale}) rotate(${this.geometry.angle}deg)`;
}
@ -455,19 +511,41 @@ export class CanvasViewImpl implements CanvasView, Listener {
// Transform all text
for (const key in this.svgShapes) {
if (
Object.prototype.hasOwnProperty.call(this.svgShapes, key) &&
Object.prototype.hasOwnProperty.call(this.svgTexts, key)
Object.prototype.hasOwnProperty.call(this.svgShapes, key)
&& Object.prototype.hasOwnProperty.call(this.svgTexts, key)
) {
this.updateTextPosition(this.svgTexts[key], this.svgShapes[key]);
}
}
// Transform all drawn issues region
for (const issueRegion of Object.values(this.drawnIssueRegions)) {
((issueRegion as any) as SVG.Shape).attr('r', `${(consts.BASE_POINT_SIZE * 3) / this.geometry.scale}`);
((issueRegion as any) as SVG.Shape).attr(
'stroke-width',
`${consts.BASE_STROKE_WIDTH / this.geometry.scale}`,
);
}
// Transform patterns
for (const pattern of [this.issueRegionPattern_1, this.issueRegionPattern_2]) {
pattern.attr({
width: consts.BASE_PATTERN_SIZE / this.geometry.scale,
height: consts.BASE_PATTERN_SIZE / this.geometry.scale,
});
pattern.children().forEach((element: SVG.Element): void => {
element.attr('stroke-width', consts.BASE_STROKE_WIDTH / this.geometry.scale);
});
}
// Transform handlers
this.drawHandler.transform(this.geometry);
this.editHandler.transform(this.geometry);
this.zoomHandler.transform(this.geometry);
this.autoborderHandler.transform(this.geometry);
this.interactionHandler.transform(this.geometry);
this.regionSelector.transform(this.geometry);
}
private resizeCanvas(): void {
@ -476,16 +554,66 @@ export class CanvasViewImpl implements CanvasView, Listener {
obj.style.height = `${this.geometry.image.height}px`;
}
for (const obj of [this.content, this.text]) {
for (const obj of [this.content, this.text, this.attachmentBoard]) {
obj.style.width = `${this.geometry.image.width + this.geometry.offset * 2}px`;
obj.style.height = `${this.geometry.image.height + this.geometry.offset * 2}px`;
}
}
private setupObjects(states: any[]): void {
const { offset } = this.controller.geometry;
const translate = (points: number[]): number[] => points.map((coord: number): number => coord + offset);
private setupIssueRegions(issueRegions: Record<number, number[]>): void {
for (const issueRegion of Object.keys(this.drawnIssueRegions)) {
if (!(issueRegion in issueRegions) || !+issueRegion) {
this.drawnIssueRegions[+issueRegion].remove();
delete this.drawnIssueRegions[+issueRegion];
}
}
for (const issueRegion of Object.keys(issueRegions)) {
if (issueRegion in this.drawnIssueRegions) continue;
const points = this.translateToCanvas(issueRegions[+issueRegion]);
if (points.length === 2) {
this.drawnIssueRegions[+issueRegion] = this.adoptedContent
.circle((consts.BASE_POINT_SIZE * 3 * 2) / this.geometry.scale)
.center(points[0], points[1])
.addClass('cvat_canvas_issue_region')
.attr({
id: `cvat_canvas_issue_region_${issueRegion}`,
fill: 'url(#cvat_issue_region_pattern_1)',
});
} else if (points.length === 4) {
const stringified = this.stringifyToCanvas([
points[0],
points[1],
points[2],
points[1],
points[2],
points[3],
points[0],
points[3],
]);
this.drawnIssueRegions[+issueRegion] = this.adoptedContent
.polygon(stringified)
.addClass('cvat_canvas_issue_region')
.attr({
id: `cvat_canvas_issue_region_${issueRegion}`,
fill: 'url(#cvat_issue_region_pattern_1)',
'stroke-width': `${consts.BASE_STROKE_WIDTH / this.geometry.scale}`,
});
} else {
const stringified = this.stringifyToCanvas(points);
this.drawnIssueRegions[+issueRegion] = this.adoptedContent
.polygon(stringified)
.addClass('cvat_canvas_issue_region')
.attr({
id: `cvat_canvas_issue_region_${issueRegion}`,
fill: 'url(#cvat_issue_region_pattern_1)',
'stroke-width': `${consts.BASE_STROKE_WIDTH / this.geometry.scale}`,
});
}
}
}
private setupObjects(states: any[]): void {
const created = [];
const updated = [];
for (const state of states) {
@ -520,8 +648,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
delete this.drawnStates[state.clientID];
}
this.addObjects(created, translate);
this.updateObjects(updated, translate);
this.addObjects(created);
this.updateObjects(updated);
this.sortObjects();
if (this.controller.activeElement.clientID !== null) {
@ -610,8 +738,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
private selectize(value: boolean, shape: SVG.Element): void {
const self = this;
const { offset } = this.controller.geometry;
const translate = (points: number[]): number[] => points.map((coord: number): number => coord - offset);
function mousedownHandler(e: MouseEvent): void {
if (e.button !== 0) return;
@ -661,7 +787,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
if (state.shapeType === 'cuboid') {
if (e.shiftKey) {
const points = translate(
const points = self.translateFromCanvas(
pointsToNumberArray((e.target as any).parentElement.parentElement.instance.attr('points')),
);
self.onEditDone(state, points);
@ -753,6 +879,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.svgShapes = {};
this.svgTexts = {};
this.drawnStates = {};
this.drawnIssueRegions = {};
this.activeElement = {
clientID: null,
attributeID: null,
@ -778,12 +905,36 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.content = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.adoptedContent = SVG.adopt((this.content as any) as HTMLElement) as SVG.Container;
this.attachmentBoard = window.document.createElement('div');
this.canvas = window.document.createElement('div');
const loadingCircle: SVGCircleElement = window.document.createElementNS('http://www.w3.org/2000/svg', 'circle');
const gridDefs: SVGDefsElement = window.document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const gridRect: SVGRectElement = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect');
// Setup defs
const contentDefs = this.adoptedContent.defs();
this.issueRegionPattern_1 = contentDefs
.pattern(consts.BASE_PATTERN_SIZE, consts.BASE_PATTERN_SIZE, (add): void => {
add.line(0, 0, 0, 10).stroke('red');
})
.attr({
id: 'cvat_issue_region_pattern_1',
patternTransform: 'rotate(45)',
patternUnits: 'userSpaceOnUse',
});
this.issueRegionPattern_2 = contentDefs
.pattern(consts.BASE_PATTERN_SIZE, consts.BASE_PATTERN_SIZE, (add): void => {
add.line(0, 0, 0, 10).stroke('yellow');
})
.attr({
id: 'cvat_issue_region_pattern_2',
patternTransform: 'rotate(45)',
patternUnits: 'userSpaceOnUse',
});
// Setup loading animation
this.loadingAnimation.setAttribute('id', 'cvat_canvas_loading_animation');
loadingCircle.setAttribute('id', 'cvat_canvas_loading_circle');
@ -813,6 +964,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.bitmap.setAttribute('id', 'cvat_canvas_bitmap');
this.bitmap.style.display = 'none';
// Setup sticked div
this.attachmentBoard.setAttribute('id', 'cvat_canvas_attachment_board');
// Setup wrappers
this.canvas.setAttribute('id', 'cvat_canvas_wrapper');
@ -830,6 +984,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.canvas.appendChild(this.bitmap);
this.canvas.appendChild(this.grid);
this.canvas.appendChild(this.content);
this.canvas.appendChild(this.attachmentBoard);
const self = this;
@ -858,6 +1013,11 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.onFindObject.bind(this),
this.adoptedContent,
);
this.regionSelector = new RegionSelectorImpl(
this.onRegionSelected.bind(this),
this.adoptedContent,
this.geometry,
);
this.zoomHandler = new ZoomHandlerImpl(this.onFocusRegion.bind(this), this.adoptedContent, this.geometry);
this.interactionHandler = new InteractionHandlerImpl(
this.onInteraction.bind(this),
@ -874,9 +1034,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.content.addEventListener('mousedown', (event): void => {
if ([0, 1].includes(event.button)) {
if (
[Mode.IDLE, Mode.DRAG_CANVAS, Mode.MERGE, Mode.SPLIT].includes(this.mode) ||
event.button === 1 ||
event.altKey
[Mode.IDLE, Mode.DRAG_CANVAS, Mode.MERGE, Mode.SPLIT].includes(this.mode)
|| event.button === 1
|| event.altKey
) {
self.controller.enableDrag(event.clientX, event.clientY);
}
@ -1022,6 +1182,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
const event: CustomEvent = new CustomEvent('canvas.setup');
this.canvas.dispatchEvent(event);
} else if (reason === UpdateReasons.ISSUE_REGIONS_UPDATED) {
this.setupIssueRegions(this.controller.issueRegions);
} else if (reason === UpdateReasons.GRID_UPDATED) {
const size: Size = this.geometry.grid;
this.gridPattern.setAttribute('width', `${size.width}`);
@ -1040,6 +1202,13 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
} else if (reason === UpdateReasons.SHAPE_ACTIVATED) {
this.activate(this.controller.activeElement);
} else if (reason === UpdateReasons.SELECT_REGION) {
if (this.mode === Mode.SELECT_REGION) {
this.regionSelector.select(true);
this.canvas.style.cursor = 'pointer';
} else {
this.regionSelector.select(false);
}
} else if (reason === UpdateReasons.DRAG_CANVAS) {
if (this.mode === Mode.DRAG_CANVAS) {
this.canvas.dispatchEvent(
@ -1151,6 +1320,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.splitHandler.cancel();
} else if (this.mode === Mode.GROUP) {
this.groupHandler.cancel();
} else if (this.mode === Mode.SELECT_REGION) {
this.regionSelector.cancel();
} else if (this.mode === Mode.EDIT) {
this.editHandler.cancel();
} else if (this.mode === Mode.DRAG_CANVAS) {
@ -1275,7 +1446,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
};
}
private updateObjects(states: any[], translate: (points: number[]) => number[]): void {
private updateObjects(states: any[]): void {
for (const state of states) {
const { clientID } = state;
const drawnState = this.drawnStates[clientID];
@ -1325,10 +1496,10 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
if (
state.points.length !== drawnState.points.length ||
state.points.some((p: number, id: number): boolean => p !== drawnState.points[id])
state.points.length !== drawnState.points.length
|| state.points.some((p: number, id: number): boolean => p !== drawnState.points[id])
) {
const translatedPoints: number[] = translate(state.points);
const translatedPoints: number[] = this.translateToCanvas(state.points);
if (state.shapeType === 'rectangle') {
const [xtl, ytl, xbr, ybr] = translatedPoints;
@ -1340,13 +1511,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
height: ybr - ytl,
});
} else {
const stringified = translatedPoints.reduce((acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc}${val} `;
}
return `${acc}${val},`;
}, '');
const stringified = this.stringifyToCanvas(translatedPoints);
if (state.shapeType !== 'cuboid') {
(shape as any).clear();
}
@ -1375,24 +1540,18 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
}
private addObjects(states: any[], translate: (points: number[]) => number[]): void {
private addObjects(states: any[]): void {
const { displayAllText } = this.configuration;
for (const state of states) {
const points: number[] = state.points as number[];
const translatedPoints: number[] = translate(points);
const translatedPoints: number[] = this.translateToCanvas(points);
// TODO: Use enums after typification cvat-core
if (state.shapeType === 'rectangle') {
this.svgShapes[state.clientID] = this.addRect(translatedPoints, state);
} else {
const stringified = translatedPoints.reduce((acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc}${val} `;
}
return `${acc}${val},`;
}, '');
const stringified = this.stringifyToCanvas(translatedPoints);
if (state.shapeType === 'polygon') {
this.svgShapes[state.clientID] = this.addPolygon(stringified, state);
@ -1542,7 +1701,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
if (state && state.shapeType === 'points') {
this.svgShapes[clientID]
.remember('_selectHandler')
.nested.style('pointer-events', state.lock ? 'none' : '');
.nested.style('pointer-events', this.stateIsLocked(state) ? 'none' : '');
}
if (!state || state.hidden || state.outside) {
@ -1550,8 +1709,14 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
const shape = this.svgShapes[clientID];
let text = this.svgTexts[clientID];
if (!text) {
text = this.addText(state);
this.svgTexts[state.clientID] = text;
}
this.updateTextPosition(text, shape);
if (state.lock) {
if (this.stateIsLocked(state)) {
return;
}
@ -1567,12 +1732,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
(shape as any).attr('projections', true);
}
let text = this.svgTexts[clientID];
if (!text) {
text = this.addText(state);
this.svgTexts[state.clientID] = text;
}
const hideText = (): void => {
if (text) {
text.addClass('cvat_canvas_hidden');
@ -1601,12 +1760,14 @@ export class CanvasViewImpl implements CanvasView, Listener {
const p2 = e.detail.p;
const delta = 1;
const { offset } = this.controller.geometry;
if (Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2) >= delta) {
const dx2 = (p1.x - p2.x) ** 2;
const dy2 = (p1.y - p2.y) ** 2;
if (Math.sqrt(dx2 + dy2) >= delta) {
const points = pointsToNumberArray(
shape.attr('points') ||
`${shape.attr('x')},${shape.attr('y')} ` +
`${shape.attr('x') + shape.attr('width')},` +
`${shape.attr('y') + shape.attr('height')}`,
shape.attr('points')
|| `${shape.attr('x')},${shape.attr('y')} `
+ `${shape.attr('x') + shape.attr('width')},`
+ `${shape.attr('y') + shape.attr('height')}`,
).map((x: number): number => x - offset);
this.drawnStates[state.clientID].points = points;
@ -1677,10 +1838,10 @@ export class CanvasViewImpl implements CanvasView, Listener {
const { offset } = this.controller.geometry;
const points = pointsToNumberArray(
shape.attr('points') ||
`${shape.attr('x')},${shape.attr('y')} ` +
`${shape.attr('x') + shape.attr('width')},` +
`${shape.attr('y') + shape.attr('height')}`,
shape.attr('points')
|| `${shape.attr('x')},${shape.attr('y')} `
+ `${shape.attr('x') + shape.attr('width')},`
+ `${shape.attr('y') + shape.attr('height')}`,
).map((x: number): number => x - offset);
this.drawnStates[state.clientID].points = points;
@ -1697,7 +1858,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
});
this.updateTextPosition(text, shape);
this.canvas.dispatchEvent(
new CustomEvent('canvas.activated', {
bubbles: false,
@ -1757,8 +1917,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
// Find the best place for a text
let [clientX, clientY]: number[] = [box.x + box.width, box.y];
if (
clientX + ((text.node as any) as SVGTextElement).getBBox().width + consts.TEXT_MARGIN >
this.canvas.offsetWidth
clientX + ((text.node as any) as SVGTextElement).getBBox().width + consts.TEXT_MARGIN
> this.canvas.offsetWidth
) {
[clientX, clientY] = [box.x, box.y];
}
@ -1778,7 +1938,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
private addText(state: any): SVG.Text {
const { undefinedAttrValue } = this.configuration;
const { label, clientID, attributes, source } = state;
const {
label, clientID, attributes, source,
} = state;
const attrNames = label.attributes.reduce((acc: any, val: any): void => {
acc[val.id] = val.name;
return acc;

@ -2,21 +2,21 @@
//
// SPDX-License-Identifier: MIT
const BASE_STROKE_WIDTH = 1.75;
const BASE_STROKE_WIDTH = 1.25;
const BASE_GRID_WIDTH = 2;
const BASE_POINT_SIZE = 5;
const TEXT_MARGIN = 10;
const AREA_THRESHOLD = 9;
const SIZE_THRESHOLD = 3;
const POINTS_STROKE_WIDTH = 1.5;
const POINTS_STROKE_WIDTH = 1;
const POINTS_SELECTED_STROKE_WIDTH = 4;
const MIN_EDGE_LENGTH = 3;
const CUBOID_ACTIVE_EDGE_STROKE_WIDTH = 2.5;
const CUBOID_UNACTIVE_EDGE_STROKE_WIDTH = 1.75;
const UNDEFINED_ATTRIBUTE_VALUE = '__undefined__';
const ARROW_PATH =
'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 ' +
'0 0-.012.59L4.2 6.72.096 12.192a.479.479 0 0 0 .585.724l12.48-5.76a.48.48 0 0 0 0-.872z';
const ARROW_PATH = 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 '
+ '0 0-.012.59L4.2 6.72.096 12.192a.479.479 0 0 0 .585.724l12.48-5.76a.48.48 0 0 0 0-.872z';
const BASE_PATTERN_SIZE = 5;
export default {
BASE_STROKE_WIDTH,
@ -32,4 +32,5 @@ export default {
CUBOID_UNACTIVE_EDGE_STROKE_WIDTH,
UNDEFINED_ATTRIBUTE_VALUE,
ARROW_PATH,
BASE_PATTERN_SIZE,
};

@ -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',
ecmaVersion: 2018,
},
plugins: ['security', 'jest', 'no-unsafe-innerhtml'],
plugins: ['security', 'jest', 'no-unsafe-innerhtml', 'no-unsanitized'],
extends: ['eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM', 'airbnb-base'],
rules: {
'no-await-in-loop': [0],

@ -1,6 +1,6 @@
{
"name": "cvat-core",
"version": "3.9.1",
"version": "3.10.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -17636,6 +17636,11 @@
"integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=",
"dev": true
},
"quickhull": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/quickhull/-/quickhull-1.0.3.tgz",
"integrity": "sha512-AQbLaXdzGDJdO9Mu3qY/NY5JWlDqIutCLW8vJbsQTq+/bydIZeltnMVRKCElp81Y5/uRm4Yw/RsMdcltFYsS6w=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",

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

@ -116,7 +116,7 @@
let users = null;
if ('self' in filter && filter.self) {
users = await serverProxy.users.getSelf();
users = await serverProxy.users.self();
users = [users];
} else {
const searchParams = {};
@ -125,7 +125,7 @@
searchParams[key] = filter[key];
}
}
users = await serverProxy.users.getUsers(new URLSearchParams(searchParams).toString());
users = await serverProxy.users.get(new URLSearchParams(searchParams).toString());
}
users = users.map((user) => new User(user));
@ -146,24 +146,23 @@
throw new ArgumentError('Job filter must not be empty');
}
let tasks = null;
let tasks = [];
if ('taskID' in filter) {
tasks = await serverProxy.tasks.getTasks(`id=${filter.taskID}`);
} else {
const job = await serverProxy.jobs.getJob(filter.jobID);
const job = await serverProxy.jobs.get(filter.jobID);
if (typeof job.task_id !== 'undefined') {
tasks = await serverProxy.tasks.getTasks(`id=${job.task_id}`);
}
}
// If task was found by its id, then create task instance and get Job instance from it
if (tasks !== null && tasks.length) {
if (tasks.length) {
const task = new Task(tasks[0]);
return filter.jobID ? task.jobs.filter((job) => job.id === filter.jobID) : task.jobs;
}
return [];
return tasks;
};
cvat.tasks.get.implementation = async (filter) => {

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

@ -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;
}
function negativeIDGenerator() {
const value = negativeIDGenerator.start;
negativeIDGenerator.start -= 1;
return value;
}
negativeIDGenerator.start = -1;
module.exports = {
isBoolean,
isInteger,
@ -75,5 +82,6 @@
isString,
checkFilter,
checkObjectType,
negativeIDGenerator,
};
})();

@ -33,6 +33,22 @@
COMPLETED: 'completed',
});
/**
* Review statuses
* @enum {string}
* @name ReviewStatus
* @memberof module:API.cvat.enums
* @property {string} ACCEPTED 'accepted'
* @property {string} REJECTED 'rejected'
* @property {string} REVIEW_FURTHER 'review_further'
* @readonly
*/
const ReviewStatus = Object.freeze({
ACCEPTED: 'accepted',
REJECTED: 'rejected',
REVIEW_FURTHER: 'review_further',
});
/**
* List of RQ statuses
* @enum {string}
@ -306,6 +322,7 @@
module.exports = {
ShareFileType,
TaskStatus,
ReviewStatus,
TaskMode,
AttributeType,
ObjectType,

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

@ -287,7 +287,7 @@
async function authorized() {
try {
await module.exports.users.getSelf();
await module.exports.users.self();
} catch (serverError) {
if (serverError.code === 401) {
return false;
@ -566,6 +566,90 @@
return response.data;
}
async function getJobReviews(jobID) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.get(`${backendAPI}/jobs/${jobID}/reviews`, {
proxy: config.proxy,
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function createReview(data) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.post(`${backendAPI}/reviews`, JSON.stringify(data), {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function getJobIssues(jobID) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.get(`${backendAPI}/jobs/${jobID}/issues`, {
proxy: config.proxy,
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function createComment(data) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.post(`${backendAPI}/comments`, JSON.stringify(data), {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function updateIssue(issueID, data) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.patch(`${backendAPI}/issues/${issueID}`, JSON.stringify(data), {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function saveJob(id, jobData) {
const { backendAPI } = config;
@ -932,16 +1016,21 @@
jobs: {
value: Object.freeze({
getJob,
saveJob,
get: getJob,
save: saveJob,
issues: getJobIssues,
reviews: {
get: getJobReviews,
create: createReview,
},
}),
writable: false,
},
users: {
value: Object.freeze({
getUsers,
getSelf,
get: getUsers,
self: getSelf,
}),
writable: false,
},
@ -983,6 +1072,20 @@
}),
writable: false,
},
issues: {
value: Object.freeze({
update: updateIssue,
}),
writable: false,
},
comments: {
value: Object.freeze({
create: createComment,
}),
writable: false,
},
}),
);
}

@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
(() => {
const store = require('store');
const PluginRegistry = require('./plugins');
const loggerStorage = require('./logger-storage');
const serverProxy = require('./server-proxy');
@ -13,6 +14,8 @@
const { TaskStatus } = require('./enums');
const { Label } = require('./labels');
const User = require('./user');
const Issue = require('./issue');
const Review = require('./review');
function buildDublicatedAPI(prototype) {
Object.defineProperties(prototype, {
@ -667,7 +670,8 @@
super();
const data = {
id: undefined,
assignee: undefined,
assignee: null,
reviewer: null,
status: undefined,
start_frame: undefined,
stop_frame: undefined,
@ -676,6 +680,7 @@
let updatedFields = {
assignee: false,
reviewer: false,
status: false,
};
@ -692,6 +697,7 @@
}
if (data.assignee) data.assignee = new User(data.assignee);
if (data.reviewer) data.reviewer = new User(data.reviewer);
Object.defineProperties(
this,
@ -707,7 +713,7 @@
get: () => data.id,
},
/**
* Instance of a user who is responsible for the job
* Instance of a user who is responsible for the job annotations
* @name assignee
* @type {module:API.cvat.classes.User}
* @memberof module:API.cvat.classes.Job
@ -724,6 +730,24 @@
data.assignee = assignee;
},
},
/**
* Instance of a user who is responsible for review
* @name reviewer
* @type {module:API.cvat.classes.User}
* @memberof module:API.cvat.classes.Job
* @instance
* @throws {module:API.cvat.exceptions.ArgumentError}
*/
reviewer: {
get: () => data.reviewer,
set: (reviewer) => {
if (reviewer !== null && !(reviewer instanceof User)) {
throw new ArgumentError('Value must be a user instance');
}
updatedFields.reviewer = true;
data.reviewer = reviewer;
},
},
/**
* @name status
* @type {module:API.cvat.enums.TaskStatus}
@ -847,6 +871,64 @@
const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.save);
return result;
}
/**
* Method returns a list of issues for a job
* @method issues
* @memberof module:API.cvat.classes.Job
* @type {module:API.cvat.classes.Issue[]}
* @readonly
* @instance
* @async
* @throws {module:API.cvat.exceptions.ServerError}
* @throws {module:API.cvat.exceptions.PluginError}
*/
async issues() {
const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.issues);
return result;
}
/**
* Method returns a list of reviews for a job
* @method reviews
* @type {module:API.cvat.classes.Review[]}
* @memberof module:API.cvat.classes.Job
* @readonly
* @instance
* @async
* @throws {module:API.cvat.exceptions.ServerError}
* @throws {module:API.cvat.exceptions.PluginError}
*/
async reviews() {
const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.reviews);
return result;
}
/**
* /**
* @typedef {Object} ReviewSummary
* @property {number} reviews Number of done reviews
* @property {number} average_estimated_quality
* @property {number} issues_unsolved
* @property {number} issues_resolved
* @property {string[]} assignees
* @property {string[]} reviewers
*/
/**
* Method returns brief summary of within all reviews
* @method reviewsSummary
* @type {ReviewSummary}
* @memberof module:API.cvat.classes.Job
* @readonly
* @instance
* @async
* @throws {module:API.cvat.exceptions.ServerError}
* @throws {module:API.cvat.exceptions.PluginError}
*/
async reviewsSummary() {
const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.reviewsSummary);
return result;
}
}
/**
@ -875,8 +957,8 @@
status: undefined,
size: undefined,
mode: undefined,
owner: undefined,
assignee: undefined,
owner: null,
assignee: null,
created_date: undefined,
updated_date: undefined,
bug_tracker: undefined,
@ -925,6 +1007,7 @@
url: job.url,
id: job.id,
assignee: job.assignee,
reviewer: job.reviewer,
status: job.status,
start_frame: segment.start_frame,
stop_frame: segment.stop_frame,
@ -1482,7 +1565,6 @@
buildDublicatedAPI(Task.prototype);
Job.prototype.save.implementation = async function () {
// TODO: Add ability to change an assignee
if (this.id) {
const jobData = {};
@ -1495,17 +1577,21 @@
case 'assignee':
jobData.assignee_id = this.assignee ? this.assignee.id : null;
break;
case 'reviewer':
jobData.reviewer_id = this.reviewer ? this.reviewer.id : null;
break;
default:
break;
}
}
}
await serverProxy.jobs.saveJob(this.id, jobData);
await serverProxy.jobs.save(this.id, jobData);
this.__updatedFields = {
status: false,
assignee: false,
reviewer: false,
};
return this;
@ -1514,6 +1600,42 @@
throw new ArgumentError('Can not save job without and id');
};
Job.prototype.issues.implementation = async function () {
const result = await serverProxy.jobs.issues(this.id);
return result.map((issue) => new Issue(issue));
};
Job.prototype.reviews.implementation = async function () {
const result = await serverProxy.jobs.reviews.get(this.id);
const reviews = result.map((review) => new Review(review));
// try to get not finished review from the local storage
const data = store.get(`job-${this.id}-review`);
if (data) {
reviews.push(new Review(JSON.parse(data)));
}
return reviews;
};
Job.prototype.reviewsSummary.implementation = async function () {
const reviews = await serverProxy.jobs.reviews.get(this.id);
const issues = await serverProxy.jobs.issues(this.id);
const qualities = reviews.map((review) => review.estimated_quality);
const reviewers = reviews.filter((review) => review.reviewer).map((review) => review.reviewer.username);
const assignees = reviews.filter((review) => review.assignee).map((review) => review.assignee.username);
return {
reviews: reviews.length,
average_estimated_quality: qualities.reduce((acc, quality) => acc + quality, 0) / (qualities.length || 1),
issues_unsolved: issues.filter((issue) => !issue.resolved_date).length,
issues_resolved: issues.filter((issue) => issue.resolved_date).length,
assignees: Array.from(new Set(assignees.filter((assignee) => assignee !== null))),
reviewers: Array.from(new Set(reviewers.filter((reviewer) => reviewer !== null))),
};
};
Job.prototype.frames.get.implementation = async function (frame, isPlaying, step) {
if (!Number.isInteger(frame) || frame < 0) {
throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`);

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

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

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

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

@ -1,6 +1,6 @@
{
"name": "cvat-ui",
"version": "1.10.9",
"version": "1.11.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -12878,7 +12878,7 @@
"requires": {
"axios": "^0.20.0",
"browser-or-node": "^1.2.1",
"detect-browser": "^5.0.0",
"detect-browser": "^5.2.0",
"error-stack-parser": "^2.0.2",
"form-data": "^2.5.0",
"jest-config": "^24.8.0",

@ -1,6 +1,6 @@
{
"name": "cvat-ui",
"version": "1.10.9",
"version": "1.11.0",
"description": "CVAT single-page application",
"main": "src/index.tsx",
"scripts": {

@ -123,6 +123,7 @@ export enum AnnotationActionTypes {
CONFIRM_CANVAS_READY = 'CONFIRM_CANVAS_READY',
DRAG_CANVAS = 'DRAG_CANVAS',
ZOOM_CANVAS = 'ZOOM_CANVAS',
SELECT_ISSUE_POSITION = 'SELECT_ISSUE_POSITION',
MERGE_OBJECTS = 'MERGE_OBJECTS',
GROUP_OBJECTS = 'GROUP_OBJECTS',
SPLIT_TRACK = 'SPLIT_TRACK',
@ -161,9 +162,6 @@ export enum AnnotationActionTypes {
COLLECT_STATISTICS = 'COLLECT_STATISTICS',
COLLECT_STATISTICS_SUCCESS = 'COLLECT_STATISTICS_SUCCESS',
COLLECT_STATISTICS_FAILED = 'COLLECT_STATISTICS_FAILED',
CHANGE_JOB_STATUS = 'CHANGE_JOB_STATUS',
CHANGE_JOB_STATUS_SUCCESS = 'CHANGE_JOB_STATUS_SUCCESS',
CHANGE_JOB_STATUS_FAILED = 'CHANGE_JOB_STATUS_FAILED',
UPLOAD_JOB_ANNOTATIONS = 'UPLOAD_JOB_ANNOTATIONS',
UPLOAD_JOB_ANNOTATIONS_SUCCESS = 'UPLOAD_JOB_ANNOTATIONS_SUCCESS',
UPLOAD_JOB_ANNOTATIONS_FAILED = 'UPLOAD_JOB_ANNOTATIONS_FAILED',
@ -187,6 +185,9 @@ export enum AnnotationActionTypes {
SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED',
INTERACT_WITH_CANVAS = 'INTERACT_WITH_CANVAS',
SET_AI_TOOLS_REF = 'SET_AI_TOOLS_REF',
SWITCH_REQUEST_REVIEW_DIALOG = 'SWITCH_REQUEST_REVIEW_DIALOG',
SWITCH_SUBMIT_REVIEW_DIALOG = 'SWITCH_SUBMIT_REVIEW_DIALOG',
SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG = 'SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG',
}
export function saveLogsAsync(): ThunkAction {
@ -393,36 +394,6 @@ export function uploadJobAnnotationsAsync(job: any, loader: any, file: File): Th
};
}
export function changeJobStatusAsync(jobInstance: any, status: string): ThunkAction {
return async (dispatch: ActionCreator<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 {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
try {
@ -896,7 +867,11 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
try {
const state: CombinedState = getStore().getState();
const filters = initialFilters;
const { showAllInterpolationTracks } = state.settings.workspace;
const {
settings: {
workspace: { showAllInterpolationTracks },
},
} = state;
dispatch({
type: AnnotationActionTypes.GET_JOB,
@ -940,6 +915,8 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
// to load and decode first chunk
await frameData.data();
const states = await job.annotations.get(frameNumber, showAllInterpolationTracks, filters);
const issues = await job.issues();
const reviews = await job.reviews();
const [minZ, maxZ] = computeZRange(states);
const colors = [...cvat.enums.colors];
@ -949,6 +926,8 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
type: AnnotationActionTypes.GET_JOB_SUCCESS,
payload: {
job,
issues,
reviews,
states,
frameNumber,
frameFilename: frameData.filename,
@ -971,7 +950,7 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
};
}
export function saveAnnotationsAsync(sessionInstance: any): ThunkAction {
export function saveAnnotationsAsync(sessionInstance: any, afterSave?: () => void): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters();
@ -997,6 +976,9 @@ export function saveAnnotationsAsync(sessionInstance: any): ThunkAction {
const { frame } = receiveAnnotationsParameters();
const states = await sessionInstance.annotations.get(frame, showAllInterpolationTracks, filters);
if (typeof afterSave === 'function') {
afterSave();
}
dispatch({
type: AnnotationActionTypes.SAVE_ANNOTATIONS_SUCCESS,
@ -1056,6 +1038,15 @@ export function shapeDrawn(): AnyAction {
};
}
export function selectIssuePosition(enabled: boolean): AnyAction {
return {
type: AnnotationActionTypes.SELECT_ISSUE_POSITION,
payload: {
enabled,
},
};
}
export function mergeObjects(enabled: boolean): AnyAction {
return {
type: AnnotationActionTypes.MERGE_OBJECTS,
@ -1481,3 +1472,30 @@ export function redrawShapeAsync(): ThunkAction {
}
};
}
export function switchRequestReviewDialog(visible: boolean): AnyAction {
return {
type: AnnotationActionTypes.SWITCH_REQUEST_REVIEW_DIALOG,
payload: {
visible,
},
};
}
export function switchSubmitReviewDialog(visible: boolean): AnyAction {
return {
type: AnnotationActionTypes.SWITCH_SUBMIT_REVIEW_DIALOG,
payload: {
visible,
},
};
}
export function setForceExitAnnotationFlag(forceExit: boolean): AnyAction {
return {
type: AnnotationActionTypes.SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG,
payload: {
forceExit,
},
};
}

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

@ -27,6 +27,7 @@ $transparent-color: rgba(0, 0, 0, 0);
$player-slider-color: #979797;
$player-buttons-color: #242424;
$danger-icon-color: #ff4136;
$ok-icon-color: #61c200;
$info-icon-color: #0074d9;
$objects-bar-tabs-color: #bebebe;
$objects-bar-icons-color: #242424; // #6e6e6e
@ -34,5 +35,8 @@ $active-label-background-color: #d8ecff;
$object-item-border-color: rgba(0, 0, 0, 0.7);
$slider-color: #1890ff;
$box-shadow-base: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
$monospaced-fonts-stack: Consolas, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono,
Courier New, monospace;

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

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

@ -6,7 +6,7 @@ import './styles.scss';
import React from 'react';
import Layout from 'antd/lib/layout';
import CanvasWrapperContainer from 'containers/annotation-page/standard-workspace/canvas-wrapper';
import CanvasWrapperContainer from 'containers/annotation-page/canvas/canvas-wrapper';
import AttributeAnnotationSidebar from './attribute-annotation-sidebar/attribute-annotation-sidebar';
export default function AttributeAnnotationWorkspace(): JSX.Element {

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

@ -30,6 +30,7 @@ interface Props {
activatedAttributeID: number | null;
selectedStatesID: number[];
annotations: any[];
frameIssues: any[] | null;
frameData: any;
frameAngle: number;
frameFetching: boolean;
@ -89,11 +90,14 @@ interface Props {
onSwitchGrid(enabled: boolean): void;
onSwitchAutomaticBordering(enabled: boolean): void;
onFetchAnnotation(): void;
onStartIssue(position: number[]): void;
}
export default class CanvasWrapperComponent extends React.PureComponent<Props> {
public componentDidMount(): void {
const { automaticBordering, showObjectsTextAlways, canvasInstance } = this.props;
const {
automaticBordering, showObjectsTextAlways, canvasInstance, workspace,
} = this.props;
// It's awful approach from the point of view React
// But we do not have another way because cvat-canvas returns regular DOM element
@ -104,9 +108,11 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
autoborders: automaticBordering,
undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE,
displayAllText: showObjectsTextAlways,
forceDisableEditing: workspace === Workspace.REVIEW_WORKSPACE,
});
this.initialSetup();
this.updateIssueRegions();
this.updateCanvas();
}
@ -118,6 +124,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
outlined,
outlineColor,
showBitmap,
frameIssues,
frameData,
frameAngle,
annotations,
@ -211,6 +218,10 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
}
}
if (prevProps.frameIssues !== frameIssues) {
this.updateIssueRegions();
}
if (
prevProps.annotations !== annotations ||
prevProps.frameData !== frameData ||
@ -247,6 +258,18 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.rotate(frameAngle);
}
if (prevProps.workspace !== workspace) {
if (workspace === Workspace.REVIEW_WORKSPACE) {
canvasInstance.configure({
forceDisableEditing: true,
});
} else if (prevProps.workspace === Workspace.REVIEW_WORKSPACE) {
canvasInstance.configure({
forceDisableEditing: false,
});
}
}
const loadingAnimation = window.document.getElementById('cvat_canvas_loading_animation');
if (loadingAnimation && frameFetching !== prevProps.frameFetching) {
if (frameFetching) {
@ -295,6 +318,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.html().removeEventListener('canvas.drawn', this.onCanvasShapeDrawn);
canvasInstance.html().removeEventListener('canvas.merged', this.onCanvasObjectsMerged);
canvasInstance.html().removeEventListener('canvas.groupped', this.onCanvasObjectsGroupped);
canvasInstance.html().removeEventListener('canvas.regionselected', this.onCanvasPositionSelected);
canvasInstance.html().removeEventListener('canvas.splitted', this.onCanvasTrackSplitted);
canvasInstance.html().removeEventListener('canvas.contextmenu', this.onCanvasPointContextMenu);
@ -353,6 +377,13 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
onGroupAnnotations(jobInstance, frame, states);
};
private onCanvasPositionSelected = (event: any): void => {
const { onResetCanvas, onStartIssue } = this.props;
const { points } = event.detail;
onStartIssue(points);
onResetCanvas();
};
private onCanvasTrackSplitted = (event: any): void => {
const {
jobInstance, frame, onSplitAnnotations, onSplitTrack,
@ -372,7 +403,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
private onCanvasMouseDown = (e: MouseEvent): void => {
const { workspace, activatedStateID, onActivateObject } = this.props;
if ((e.target as HTMLElement).tagName === 'svg') {
if ((e.target as HTMLElement).tagName === 'svg' && e.button !== 2) {
if (activatedStateID !== null && workspace !== Workspace.ATTRIBUTE_ANNOTATION) {
onActivateObject(null);
}
@ -380,7 +411,9 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
};
private onCanvasClicked = (): void => {
if (document.activeElement instanceof HTMLElement) {
const { canvasInstance, onUpdateContextMenu } = this.props;
onUpdateContextMenu(false, 0, 0, ContextMenuType.CANVAS_SHAPE);
if (!canvasInstance.html().contains(document.activeElement) && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
@ -440,7 +473,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
jobInstance, activatedStateID, workspace, onActivateObject,
} = this.props;
if (workspace !== Workspace.STANDARD) {
if (![Workspace.STANDARD, Workspace.REVIEW_WORKSPACE].includes(workspace)) {
return;
}
@ -598,6 +631,22 @@ export default class CanvasWrapperComponent extends React.PureComponent<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 {
const {
curZLayer, annotations, frameData, canvasInstance,
@ -692,6 +741,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.html().addEventListener('canvas.drawn', this.onCanvasShapeDrawn);
canvasInstance.html().addEventListener('canvas.merged', this.onCanvasObjectsMerged);
canvasInstance.html().addEventListener('canvas.groupped', this.onCanvasObjectsGroupped);
canvasInstance.html().addEventListener('canvas.regionselected', this.onCanvasPositionSelected);
canvasInstance.html().addEventListener('canvas.splitted', this.onCanvasTrackSplitted);
canvasInstance.html().addEventListener('canvas.contextmenu', this.onCanvasPointContextMenu);

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

@ -14,6 +14,7 @@ import { ObjectType, ShapeType, ColorBy } from 'reducers/interfaces';
import ItemMenu from './object-item-menu';
interface Props {
readonly: boolean;
clientID: number;
serverID: number | undefined;
labelID: number;
@ -46,6 +47,7 @@ interface Props {
function ItemTopComponent(props: Props): JSX.Element {
const {
readonly,
clientID,
serverID,
labelID,
@ -101,8 +103,9 @@ function ItemTopComponent(props: Props): JSX.Element {
</Text>
</Col>
<Col span={12}>
<Tooltip title='Change current label' mouseLeaveDelay={0}>
<Tooltip title={readonly ? 'Current label' : 'Change current label'} mouseLeaveDelay={0}>
<Select
disabled={readonly}
size='small'
value={`${labelID}`}
onChange={changeLabel}
@ -132,6 +135,7 @@ function ItemTopComponent(props: Props): JSX.Element {
onVisibleChange={changeMenuVisible}
placement='bottomLeft'
overlay={ItemMenu({
readonly,
serverID,
locked,
shapeType,

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

@ -9,6 +9,7 @@ import Collapse from 'antd/lib/collapse';
import ItemAttribute from './object-item-attribute';
interface Props {
readonly: boolean;
collapsed: boolean;
attributes: any[];
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 {
return (
nextProps.readonly === prevProps.readonly &&
nextProps.collapsed === prevProps.collapsed &&
nextProps.attributes === prevProps.attributes &&
attrValuesAreEqual(nextProps.values, prevProps.values)
@ -35,7 +37,9 @@ function attrAreTheSame(prevProps: Props, nextProps: Props): boolean {
}
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));
@ -57,6 +61,7 @@ function ItemAttributesComponent(props: Props): JSX.Element {
className='cvat-object-item-attribute-wrapper'
>
<ItemAttribute
readonly={readonly}
attrValue={values[attribute.id]}
attrInputType={attribute.inputType}
attrName={attribute.name}

@ -9,11 +9,14 @@ import Button from 'antd/lib/button';
import Modal from 'antd/lib/modal';
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 ColorPicker from './color-picker';
interface Props {
readonly: boolean;
serverID: number | undefined;
locked: boolean;
shapeType: ShapeType;
@ -41,142 +44,201 @@ interface Props {
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 {
serverID,
locked,
shapeType,
objectType,
color,
colorBy,
colorPickerVisible,
changeColorShortcut,
copyShortcut,
pasteShortcut,
propagateShortcut,
toBackgroundShortcut,
toForegroundShortcut,
removeShortcut,
colorBy,
changeColor,
copy,
remove,
propagate,
createURL,
switchOrientation,
toBackground,
toForeground,
resetCuboidPerspective,
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;
return (
<Menu className='cvat-object-item-menu'>
<Menu.Item>
<Button disabled={serverID === undefined} type='link' icon='link' onClick={createURL}>
Create object URL
</Button>
</Menu.Item>
<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>
<CreateURLItem toolProps={props} />
{!readonly && <MakeCopyItem toolProps={props} />}
{!readonly && <PropagateItem toolProps={props} />}
{!readonly && objectType === ObjectType.TRACK && shapeType === ShapeType.RECTANGLE && (
<TrackingItem toolProps={props} />
)}
{[ShapeType.POLYGON, ShapeType.POLYLINE, ShapeType.CUBOID].includes(shapeType) && (
<Menu.Item>
<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>
{!readonly && [ShapeType.POLYGON, ShapeType.POLYLINE, ShapeType.CUBOID].includes(shapeType) && (
<SwitchOrientationItem toolProps={props} />
)}
{objectType !== ObjectType.TAG && (
<Menu.Item>
<Tooltip title={`${toForegroundShortcut}`} mouseLeaveDelay={0}>
<Button type='link' onClick={toForeground}>
<Icon component={ForegroundIcon} />
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>
{!readonly && shapeType === ShapeType.CUBOID && <ResetPerspectiveItem toolProps={props} />}
{!readonly && objectType !== ObjectType.TAG && <ToBackgroundItem toolProps={props} />}
{!readonly && objectType !== ObjectType.TAG && <ToForegroundItem toolProps={props} />}
{[ColorBy.INSTANCE, ColorBy.GROUP].includes(colorBy) && <SwitchColorItem toolProps={props} />}
{!readonly && <RemoveItem toolProps={props} />}
</Menu>
);
}

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

@ -5,46 +5,14 @@
import React from 'react';
import { Row, Col } from 'antd/lib/grid';
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 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';
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 {
readonly: boolean;
statesHidden: boolean;
statesLocked: boolean;
statesCollapsed: boolean;
@ -60,22 +28,57 @@ interface Props {
showAllStates(): void;
}
function ObjectListHeader(props: Props): JSX.Element {
function LockAllSwitcher(props: Props): JSX.Element {
const {
statesHidden,
statesLocked,
statesCollapsed,
statesOrdering,
switchLockAllShortcut,
switchHiddenAllShortcut,
changeStatesOrdering,
lockAllStates,
unlockAllStates,
collapseAllStates,
expandAllStates,
hideAllStates,
showAllStates,
statesLocked, switchLockAllShortcut, unlockAllStates, lockAllStates,
} = props;
return (
<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 (
<div className='cvat-objects-sidebar-states-header'>
@ -85,33 +88,13 @@ function ObjectListHeader(props: Props): JSX.Element {
</Col>
</Row>
<Row type='flex' justify='space-between' align='middle'>
<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>
<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>
{!readonly && (
<>
<LockAllSwitcher {...props} />
<HideAllSwitcher {...props} />
</>
)}
<CollapseAllSwitcher {...props} />
<StatesOrderingSelector statesOrdering={statesOrdering} changeStatesOrdering={changeStatesOrdering} />
</Row>
</div>

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

@ -13,13 +13,17 @@ import Layout from 'antd/lib/layout';
import { Canvas } from 'cvat-canvas-wrapper';
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 {
collapseSidebar as collapseSidebarAction,
updateTabContentHeight as updateTabContentHeightAction,
} from 'actions/annotation-actions';
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 {
sidebarCollapsed: boolean;
@ -57,8 +61,10 @@ function mapDispatchToProps(dispatch: Dispatch<AnyAction>): DispatchToProps {
};
}
function ObjectsSideBar(props: StateToProps & DispatchToProps): JSX.Element {
const { sidebarCollapsed, canvasInstance, collapseSidebar, updateTabContentHeight } = props;
function ObjectsSideBar(props: StateToProps & DispatchToProps & OwnProps): JSX.Element {
const {
sidebarCollapsed, canvasInstance, collapseSidebar, updateTabContentHeight, objectsList,
} = props;
useEffect(() => {
const alignTabHeight = (): void => {
@ -117,11 +123,14 @@ function ObjectsSideBar(props: StateToProps & DispatchToProps): JSX.Element {
<Tabs type='card' defaultActiveKey='objects' className='cvat-objects-sidebar-tabs'>
<Tabs.TabPane tab={<Text strong>Objects</Text>} key='objects'>
<ObjectsListContainer />
{objectsList}
</Tabs.TabPane>
<Tabs.TabPane tab={<Text strong>Labels</Text>} key='labels'>
<LabelsListContainer />
</Tabs.TabPane>
<Tabs.TabPane tab={<Text strong>Issues</Text>} key='issues'>
<IssuesListComponent />
</Tabs.TabPane>
</Tabs>
{!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 {
background: $objects-bar-tabs-color;
padding: 5px;
@ -78,7 +127,7 @@
}
> div:nth-child(2) {
margin-top: 5px;
margin-top: $grid-unit-size;
> div {
text-align: center;
@ -88,11 +137,11 @@
@extend .cvat-object-sidebar-icon;
}
&:nth-child(4) {
&:last-child {
text-align: right;
> .ant-select {
margin-left: 5px;
> .cvat-objects-sidebar-ordering-selector {
margin-left: $grid-unit-size;
width: 60%;
}
}
@ -272,6 +321,12 @@
}
}
.cvat-context-menu-item.ant-menu-item {
&:hover {
background: $hover-menu-color;
}
}
.cvat-object-item-menu {
> li {
padding: 0;
@ -289,3 +344,9 @@
.cvat-label-color-picker .sketch-picker {
box-shadow: unset !important;
}
.cvat-states-ordering-selector {
:first-child {
margin-right: $grid-unit-size;
}
}

@ -6,22 +6,25 @@ import './styles.scss';
import React from 'react';
import Layout from 'antd/lib/layout';
import CanvasWrapperContainer from 'containers/annotation-page/standard-workspace/canvas-wrapper';
import CanvasWrapperContainer from 'containers/annotation-page/canvas/canvas-wrapper';
import ControlsSideBarContainer from 'containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar';
import PropagateConfirmContainer from 'containers/annotation-page/standard-workspace/propagate-confirm';
import CanvasContextMenuContainer from 'containers/annotation-page/standard-workspace/canvas-context-menu';
import CanvasContextMenuContainer from 'containers/annotation-page/canvas/canvas-context-menu';
import ObjectsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-list';
import ObjectSideBarComponent from 'components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar';
import CanvasPointContextMenuComponent from 'components/annotation-page/standard-workspace/canvas-point-context-menu';
import CanvasPointContextMenuComponent from 'components/annotation-page/canvas/canvas-point-context-menu';
import IssueAggregatorComponent from 'components/annotation-page/review/issues-aggregator';
export default function StandardWorkspaceComponent(): JSX.Element {
return (
<Layout hasSider className='cvat-standard-workspace'>
<ControlsSideBarContainer />
<CanvasWrapperContainer />
<ObjectSideBarComponent />
<ObjectSideBarComponent objectsList={<ObjectsListContainer />} />
<PropagateConfirmContainer />
<CanvasContextMenuContainer />
<CanvasPointContextMenuComponent />
<IssueAggregatorComponent />
</Layout>
);
}

@ -160,15 +160,6 @@
}
> div:nth-child(1) {
> div {
> .ant-select,
i {
margin-left: 10px;
}
}
}
> div:nth-child(2) {
> div {
> span {
font-size: 20px;
@ -176,7 +167,7 @@
}
}
> div:nth-child(3) {
> div:nth-child(2) {
> div {
display: grid;
}
@ -204,7 +195,7 @@
}
.ant-menu.cvat-annotation-menu {
box-shadow: 0 0 17px rgba(0, 0, 0, 0.2);
box-shadow: $box-shadow-base;
> li:hover {
background-color: $hover-menu-color;
@ -317,3 +308,28 @@
transform: scale(1.8);
}
}
.cvat-request-review-dialog {
> .ant-modal-content > .ant-modal-body {
> div:nth-child(2) {
margin-top: $grid-unit-size * 2;
}
> div:nth-child(3) {
margin-top: $grid-unit-size * 2;
}
}
}
.cvat-submit-review-dialog {
> .ant-modal-content > .ant-modal-body {
> div:nth-child(2) > div:nth-child(2) {
.ant-col {
> div:nth-child(2) {
margin-top: $grid-unit-size * 2;
margin-bottom: $grid-unit-size * 2;
}
}
}
}
}

@ -6,7 +6,7 @@ import './styles.scss';
import React from 'react';
import Layout from 'antd/lib/layout';
import CanvasWrapperContainer from 'containers/annotation-page/standard-workspace/canvas-wrapper';
import CanvasWrapperContainer from 'containers/annotation-page/canvas/canvas-wrapper';
import TagAnnotationSidebar from './tag-annotation-sidebar/tag-annotation-sidebar';
export default function TagAnnotationWorkspace(): JSX.Element {

@ -17,8 +17,11 @@ interface Props {
loadActivity: string | null;
dumpActivities: string[] | null;
exportActivities: string[] | null;
taskID: number;
isReviewer: boolean;
jobInstance: any;
onClickMenu(params: ClickParam, file?: File): void;
setForceExitAnnotationFlag(forceExit: boolean): void;
saveAnnotations(jobInstance: any, afterSave?: () => void): void;
}
export enum Actions {
@ -27,10 +30,29 @@ export enum Actions {
EXPORT_TASK_DATASET = 'export_task_dataset',
REMOVE_ANNO = 'remove_anno',
OPEN_TASK = 'open_task',
REQUEST_REVIEW = 'request_review',
SUBMIT_REVIEW = 'submit_review',
FINISH_JOB = 'finish_job',
RENEW_JOB = 'renew_job',
}
export default function AnnotationMenuComponent(props: Props): JSX.Element {
const { taskMode, loaders, dumpers, onClickMenu, loadActivity, dumpActivities, exportActivities, taskID } = props;
const {
taskMode,
loaders,
dumpers,
loadActivity,
dumpActivities,
exportActivities,
isReviewer,
jobInstance,
onClickMenu,
setForceExitAnnotationFlag,
saveAnnotations,
} = props;
const jobStatus = jobInstance.status;
const taskID = jobInstance.task.id;
let latestParams: ClickParam | null = null;
function onClickMenuWrapper(params: ClickParam | null, file?: File): void {
@ -40,6 +62,33 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element {
}
latestParams = params;
function checkUnsavedChanges(_copyParams: ClickParam): void {
if (jobInstance.annotations.hasUnsavedChanges()) {
Modal.confirm({
title: 'The job has unsaved annotations',
content: 'Would you like to save changes before continue?',
okButtonProps: {
children: 'Save',
},
cancelButtonProps: {
children: 'No',
},
onOk: () => {
saveAnnotations(jobInstance, () => onClickMenu(_copyParams));
},
onCancel: () => {
// do not ask leave confirmation
setForceExitAnnotationFlag(true);
setTimeout(() => {
onClickMenu(_copyParams);
});
},
});
} else {
onClickMenu(_copyParams);
}
}
if (copyParams.keyPath.length === 2) {
const [, action] = copyParams.keyPath;
if (action === Actions.LOAD_JOB_ANNO) {
@ -61,10 +110,10 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element {
}
} else if (copyParams.key === Actions.REMOVE_ANNO) {
Modal.confirm({
title: 'All annotations will be removed',
title: 'All the annotations will be removed',
content:
'You are going to remove all annotations from the client. ' +
'It will stay on the server till you save a job. Continue?',
'You are going to remove all the annotations from the client. ' +
'It will stay on the server till you save the job. Continue?',
onOk: () => {
onClickMenu(copyParams);
},
@ -73,6 +122,28 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element {
},
okText: 'Delete',
});
} else if ([Actions.REQUEST_REVIEW].includes(copyParams.key as Actions)) {
checkUnsavedChanges(copyParams);
} else if (copyParams.key === Actions.FINISH_JOB) {
Modal.confirm({
title: 'The job status is going to be switched',
content: 'Status will be changed to "completed". Would you like to continue?',
okText: 'Continue',
cancelText: 'Cancel',
onOk: () => {
checkUnsavedChanges(copyParams);
},
});
} else if (copyParams.key === Actions.RENEW_JOB) {
Modal.confirm({
title: 'The job status is going to be switched',
content: 'Status will be changed to "annotations". Would you like to continue?',
okText: 'Continue',
cancelText: 'Cancel',
onOk: () => {
onClickMenu(copyParams);
},
});
} else {
onClickMenu(copyParams);
}
@ -106,6 +177,12 @@ export default function AnnotationMenuComponent(props: Props): JSX.Element {
Open the task
</a>
</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>
);
}

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

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: MIT
import React from 'react';
import React, { useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router';
import { withRouter } from 'react-router-dom';
import { Row, Col } from 'antd/lib/grid';
@ -26,6 +26,72 @@ interface Props {
onJobUpdate(jobInstance: any): void;
}
function ReviewSummaryComponent({ jobInstance }: { jobInstance: any }): JSX.Element {
const [summary, setSummary] = useState<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 {
const {
taskInstance,
@ -64,7 +130,9 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element {
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: string): JSX.Element => {
className: 'cvat-job-item-status',
render: (jobInstance: any): JSX.Element => {
const { status } = jobInstance;
let progressColor = null;
if (status === 'completed') {
progressColor = 'cvat-job-completed-color';
@ -77,6 +145,9 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element {
return (
<Text strong className={progressColor}>
{status}
<Tooltip title={<ReviewSummaryComponent jobInstance={jobInstance} />}>
<Icon type='question-circle' />
</Tooltip>
</Text>
);
},
@ -97,20 +168,33 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element {
title: 'Assignee',
dataIndex: 'assignee',
key: 'assignee',
render: (jobInstance: any): JSX.Element => {
const assignee = jobInstance.assignee ? jobInstance.assignee : null;
return (
<UserSelector
value={assignee}
onSelect={(value: User | null): void => {
// eslint-disable-next-line
jobInstance.assignee = value;
onJobUpdate(jobInstance);
}}
/>
);
},
render: (jobInstance: any): JSX.Element => (
<UserSelector
className='cvat-job-assignee-selector'
value={jobInstance.assignee}
onSelect={(value: User | null): void => {
// eslint-disable-next-line
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,
job: job.id,
frames: `${job.startFrame}-${job.stopFrame}`,
status: `${job.status}`,
status: job,
started: `${created.format('MMMM Do YYYY HH:MM')}`,
duration: `${moment.duration(moment(moment.now()).diff(created)).humanize()}`,
assignee: job,
reviewer: job,
});
return acc;

@ -111,6 +111,26 @@
}
}
.cvat-job-item-status {
i {
margin-left: $grid-unit-size;
}
}
.cvat-review-summary-description {
color: white;
.ant-typography {
color: white;
}
tr {
> td:nth-child(2) {
padding-left: $grid-unit-size;
}
}
}
.cvat-job-completed-color {
color: $completed-progress-color;
}

@ -20,6 +20,7 @@ export interface User {
interface Props {
value: User | null;
className?: string;
onSelect: (user: User | null) => void;
}
@ -43,7 +44,7 @@ const searchUsers = debounce(
);
export default function UserSelector(props: Props): JSX.Element {
const { value, onSelect } = props;
const { value, className, onSelect } = props;
const [searchPhrase, setSearchPhrase] = useState('');
const [users, setUsers] = useState<User[]>([]);
@ -89,6 +90,7 @@ export default function UserSelector(props: Props): JSX.Element {
}
}, [value]);
const combinedClassName = className ? `${className} cvat-user-search-field` : 'cvat-user-search-field';
return (
<Autocomplete
ref={autocompleteRef}
@ -96,7 +98,7 @@ export default function UserSelector(props: Props): JSX.Element {
placeholder='Select a user'
onSearch={handleSearch}
onSelect={handleSelect}
className='cvat-user-search-field'
className={combinedClassName}
onDropdownVisibleChange={handleFocus}
dataSource={users.map((user) => ({
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';
const CANVAS_BACKGROUND_COLORS = ['#ffffff', '#f1f1f1', '#e5e5e5', '#d8d8d8', '#CCCCCC', '#B3B3B3', '#999999'];
const NEW_LABEL_COLOR = '#b3b3b3';
const LATEST_COMMENTS_SHOWN_QUICK_ISSUE = 3;
const QUICK_ISSUE_INCORRECT_POSITION_TEXT = 'Wrong position';
const QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT = 'Wrong attribute';
export default {
UNDEFINED_ATTRIBUTE_VALUE,
@ -33,4 +36,7 @@ export default {
CANVAS_BACKGROUND_COLORS,
NEW_LABEL_COLOR,
NUCLIO_GUIDE,
LATEST_COMMENTS_SHOWN_QUICK_ISSUE,
QUICK_ISSUE_INCORRECT_POSITION_TEXT,
QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT,
};

@ -5,49 +5,81 @@
import React from 'react';
import { connect } from 'react-redux';
import { CombinedState, ContextMenuType } from 'reducers/interfaces';
import { CombinedState, ContextMenuType, Workspace } from 'reducers/interfaces';
import CanvasContextMenuComponent from 'components/annotation-page/standard-workspace/canvas-context-menu';
import CanvasContextMenuComponent from 'components/annotation-page/canvas/canvas-context-menu';
import { updateCanvasContextMenu } from 'actions/annotation-actions';
import { reviewActions, finishIssueAsync } from 'actions/review-actions';
import { ThunkDispatch } from 'utils/redux';
interface OwnProps {
readonly: boolean;
}
interface StateToProps {
activatedStateID: number | null;
contextMenuClientID: number | null;
objectStates: any[];
visible: boolean;
top: number;
left: number;
type: ContextMenuType;
collapsed: boolean | undefined;
workspace: Workspace;
latestComments: string[];
}
interface DispatchToProps {
onStartIssue(position: number[]): void;
openIssue(position: number[], message: string): void;
}
function mapStateToProps(state: CombinedState): StateToProps {
const {
annotation: {
annotations: { activatedStateID, collapsed, states: objectStates },
annotations: { collapsed, states: objectStates },
canvas: {
contextMenu: {
visible, top, left, type,
visible, top, left, type, clientID,
},
ready,
},
workspace,
},
review: { latestComments },
} = state;
return {
activatedStateID,
collapsed: activatedStateID !== null ? collapsed[activatedStateID] : undefined,
contextMenuClientID: clientID,
collapsed: clientID !== null ? collapsed[clientID] : undefined,
objectStates,
visible:
activatedStateID !== null &&
clientID !== null &&
visible &&
ready &&
objectStates.map((_state: any): number => _state.clientID).includes(activatedStateID),
objectStates.map((_state: any): number => _state.clientID).includes(clientID),
left,
top,
type,
workspace,
latestComments,
};
}
function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps {
return {
onStartIssue(position: number[]): void {
dispatch(reviewActions.startIssue(position));
dispatch(updateCanvasContextMenu(false, 0, 0));
},
openIssue(position: number[], message: string): void {
dispatch(reviewActions.startIssue(position));
dispatch(finishIssueAsync(message));
dispatch(updateCanvasContextMenu(false, 0, 0));
},
};
}
type Props = StateToProps;
type Props = StateToProps & DispatchToProps & OwnProps;
interface State {
latestLeft: number;
@ -57,12 +89,13 @@ interface State {
}
class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
private initialized: HTMLDivElement | null;
static defaultProps = {
readonly: false,
};
private initialized: HTMLDivElement | null;
private dragging: boolean;
private dragInitPosX: number;
private dragInitPosY: number;
public constructor(props: Props) {
@ -154,7 +187,6 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
private updatePositionIfOutOfScreen(): void {
const { top, left } = this.state;
const { innerWidth, innerHeight } = window;
const [element] = window.document.getElementsByClassName('cvat-canvas-context-menu');
@ -174,18 +206,31 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
public render(): JSX.Element {
const { left, top } = this.state;
const {
visible, activatedStateID, objectStates, type,
visible,
contextMenuClientID,
objectStates,
type,
readonly,
workspace,
latestComments,
onStartIssue,
openIssue,
} = this.props;
return (
<>
{type === ContextMenuType.CANVAS_SHAPE && (
<CanvasContextMenuComponent
contextMenuClientID={contextMenuClientID}
readonly={readonly}
left={left}
top={top}
visible={visible}
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 { connect } from 'react-redux';
import CanvasWrapperComponent from 'components/annotation-page/standard-workspace/canvas-wrapper';
import CanvasWrapperComponent from 'components/annotation-page/canvas/canvas-wrapper';
import {
confirmCanvasReady,
dragCanvas,
@ -37,6 +37,7 @@ import {
changeSaturationLevel,
switchAutomaticBordering,
} from 'actions/settings-actions';
import { reviewActions } from 'actions/review-actions';
import {
ColorBy,
GridColor,
@ -57,6 +58,7 @@ interface StateToProps {
activatedAttributeID: number | null;
selectedStatesID: number[];
annotations: any[];
frameIssues: any[] | null;
frameData: any;
frameAngle: number;
frameFetching: boolean;
@ -119,6 +121,7 @@ interface DispatchToProps {
onSwitchGrid(enabled: boolean): void;
onSwitchAutomaticBordering(enabled: boolean): void;
onFetchAnnotation(): void;
onStartIssue(position: number[]): void;
}
function mapStateToProps(state: CombinedState): StateToProps {
@ -153,9 +156,14 @@ function mapStateToProps(state: CombinedState): StateToProps {
saturationLevel,
resetZoom,
},
workspace: { aamZoomMargin, showObjectsTextAlways, showAllInterpolationTracks, automaticBordering },
shapes: { opacity, colorBy, selectedOpacity, outlined, outlineColor, showBitmap, showProjections },
workspace: {
aamZoomMargin, showObjectsTextAlways, showAllInterpolationTracks, automaticBordering,
},
shapes: {
opacity, colorBy, selectedOpacity, outlined, outlineColor, showBitmap, showProjections,
},
},
review: { frameIssues, issuesHidden },
shortcuts: { keyMap },
} = state;
@ -163,6 +171,8 @@ function mapStateToProps(state: CombinedState): StateToProps {
sidebarCollapsed,
canvasInstance,
jobInstance,
frameIssues:
issuesHidden || ![Workspace.REVIEW_WORKSPACE, Workspace.STANDARD].includes(workspace) ? null : frameIssues,
frameData,
frameAngle: frameAngles[frame - jobInstance.startFrame],
frameFetching,
@ -298,6 +308,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
onFetchAnnotation(): void {
dispatch(fetchAnnotationsAsync());
},
onStartIssue(position: number[]): void {
dispatch(reviewActions.startIssue(position));
},
};
}

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

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

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

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

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

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

@ -9,7 +9,15 @@ import { Canvas, CanvasMode } from 'cvat-canvas-wrapper';
import { AnnotationActionTypes } from 'actions/annotation-actions';
import { AuthActionTypes } from 'actions/auth-actions';
import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { AnnotationState, ActiveControl, ShapeType, ObjectType, ContextMenuType, Workspace } from './interfaces';
import {
AnnotationState,
ActiveControl,
ShapeType,
ObjectType,
ContextMenuType,
Workspace,
TaskStatus,
} from './interfaces';
const defaultState: AnnotationState = {
activities: {
@ -22,6 +30,7 @@ const defaultState: AnnotationState = {
top: 0,
type: ContextMenuType.CANVAS_SHAPE,
pointID: null,
clientID: null,
},
instance: new Canvas(),
ready: false,
@ -57,6 +66,7 @@ const defaultState: AnnotationState = {
activatedStateID: null,
activatedAttributeID: null,
saving: {
forceExit: false,
uploading: false,
statuses: [],
},
@ -89,6 +99,8 @@ const defaultState: AnnotationState = {
colors: [],
sidebarCollapsed: false,
appearanceCollapsed: false,
requestReviewDialogVisible: false,
submitReviewDialogVisible: false,
tabContentHeight: 0,
workspace: Workspace.STANDARD,
};
@ -120,6 +132,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
maxZ,
} = action.payload;
const isReview = job.status === TaskStatus.REVIEW;
return {
...state,
job: {
@ -128,8 +142,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
instance: job,
labels: job.task.labels,
attributes: job.task.labels.reduce((acc: Record<number, any[]>, label: any): Record<
number,
any[]
number,
any[]
> => {
acc[label.id] = label.attributes;
return acc;
@ -165,6 +179,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
instance: new Canvas(),
},
colors,
workspace: isReview ? Workspace.REVIEW_WORKSPACE : Workspace.STANDARD,
};
}
case AnnotationActionTypes.GET_JOB_FAILED: {
@ -194,13 +209,15 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.CHANGE_FRAME_SUCCESS: {
const { number, data, filename, states, minZ, maxZ, curZ, delay, changeTime } = action.payload;
const {
number, data, filename, states, minZ, maxZ, curZ, delay, changeTime,
} = action.payload;
const activatedStateID = states
.map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID)
? state.annotations.activatedStateID
: null;
.includes(state.annotations.activatedStateID) ?
state.annotations.activatedStateID :
null;
return {
...state,
@ -245,9 +262,12 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
...state,
player: {
...state.player,
frameAngles: state.player.frameAngles.map((_angle: number, idx: number) =>
rotateAll || offset === idx ? angle : _angle,
),
frameAngles: state.player.frameAngles.map((_angle: number, idx: number) => {
if (rotateAll || offset === idx) {
return angle;
}
return _angle;
}),
},
};
}
@ -394,7 +414,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.REMEMBER_CREATED_OBJECT: {
const { shapeType, labelID, objectType, points, activeControl, rectDrawingMethod } = action.payload;
const {
shapeType, labelID, objectType, points, activeControl, rectDrawingMethod,
} = action.payload;
return {
...state,
@ -431,6 +453,22 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
case AnnotationActionTypes.SELECT_ISSUE_POSITION: {
const { enabled } = action.payload;
const activeControl = enabled ? ActiveControl.OPEN_ISSUE : ActiveControl.CURSOR;
return {
...state,
annotations: {
...state.annotations,
activatedStateID: null,
},
canvas: {
...state.canvas,
activeControl,
},
};
}
case AnnotationActionTypes.MERGE_OBJECTS: {
const { enabled } = action.payload;
const activeControl = enabled ? ActiveControl.MERGE : ActiveControl.CURSOR;
@ -489,7 +527,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.UPDATE_ANNOTATIONS_SUCCESS: {
const { history, states: updatedStates, minZ, maxZ } = action.payload;
const {
history, states: updatedStates, minZ, maxZ,
} = action.payload;
const { states: prevStates } = state.annotations;
const nextStates = [...prevStates];
@ -627,6 +667,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
}
case AnnotationActionTypes.REMOVE_OBJECT_SUCCESS: {
const { objectState, history } = action.payload;
const contextMenuClientID = state.canvas.contextMenu.clientID;
const contextMenuVisible = state.canvas.contextMenu.visible;
return {
...state,
@ -638,6 +680,14 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
(_objectState: any) => _objectState.clientID !== objectState.clientID,
),
},
canvas: {
...state.canvas,
contextMenu: {
...state.canvas.contextMenu,
clientID: objectState.clientID === contextMenuClientID ? null : contextMenuClientID,
visible: objectState.clientID === contextMenuClientID ? false : contextMenuVisible,
},
},
};
}
case AnnotationActionTypes.PASTE_SHAPE: {
@ -754,33 +804,6 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
case AnnotationActionTypes.CHANGE_JOB_STATUS: {
return {
...state,
job: {
...state.job,
saving: true,
},
};
}
case AnnotationActionTypes.CHANGE_JOB_STATUS_SUCCESS: {
return {
...state,
job: {
...state.job,
saving: false,
},
};
}
case AnnotationActionTypes.CHANGE_JOB_STATUS_FAILED: {
return {
...state,
job: {
...state.job,
saving: false,
},
};
}
case AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS: {
const { job, loader } = action.payload;
const { loads } = state.activities;
@ -851,7 +874,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.UPDATE_CANVAS_CONTEXT_MENU: {
const { visible, left, top, type, pointID } = action.payload;
const {
visible, left, top, type, pointID,
} = action.payload;
return {
...state,
@ -864,19 +889,22 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
top,
type,
pointID,
clientID: state.annotations.activatedStateID,
},
},
};
}
case AnnotationActionTypes.REDO_ACTION_SUCCESS:
case AnnotationActionTypes.UNDO_ACTION_SUCCESS: {
const { history, states, minZ, maxZ } = action.payload;
const {
history, states, minZ, maxZ,
} = action.payload;
const activatedStateID = states
.map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID)
? state.annotations.activatedStateID
: null;
.includes(state.annotations.activatedStateID) ?
state.annotations.activatedStateID :
null;
return {
...state,
@ -897,9 +925,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
const { states, minZ, maxZ } = action.payload;
const activatedStateID = states
.map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID)
? state.annotations.activatedStateID
: null;
.includes(state.annotations.activatedStateID) ?
state.annotations.activatedStateID :
null;
return {
...state,
@ -987,6 +1015,33 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
case AnnotationActionTypes.SWITCH_REQUEST_REVIEW_DIALOG: {
const { visible } = action.payload;
return {
...state,
requestReviewDialogVisible: visible,
};
}
case AnnotationActionTypes.SWITCH_SUBMIT_REVIEW_DIALOG: {
const { visible } = action.payload;
return {
...state,
submitReviewDialogVisible: visible,
};
}
case AnnotationActionTypes.SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG: {
const { forceExit } = action.payload;
return {
...state,
annotations: {
...state.annotations,
saving: {
...state.annotations.saving,
forceExit,
},
},
};
}
case AnnotationActionTypes.CHANGE_WORKSPACE: {
const { workspace } = action.payload;
if (state.canvas.activeControl !== ActiveControl.CURSOR) {

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

@ -15,6 +15,7 @@ import { AnnotationActionTypes } from 'actions/annotation-actions';
import { NotificationsActionType } from 'actions/notification-actions';
import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { UserAgreementsActionTypes } from 'actions/useragreements-actions';
import { ReviewActionTypes } from 'actions/review-actions';
import { NotificationsState } from './interfaces';
@ -93,6 +94,14 @@ const defaultState: NotificationsState = {
userAgreements: {
fetching: null,
},
review: {
commentingIssue: null,
finishingIssue: null,
initialization: null,
reopeningIssue: null,
resolvingIssue: null,
submittingReview: null,
},
},
messages: {
tasks: {
@ -802,21 +811,6 @@ export default function (state = defaultState, action: AnyAction): Notifications
},
};
}
case AnnotationActionTypes.CHANGE_JOB_STATUS_FAILED: {
return {
...state,
errors: {
...state.errors,
annotation: {
...state.errors.annotation,
savingJob: {
message: 'Could not save the job on the server',
reason: action.payload.error.toString(),
},
},
},
};
}
case AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_FAILED: {
const { job, error } = action.payload;
@ -976,6 +970,96 @@ export default function (state = defaultState, action: AnyAction): Notifications
},
};
}
case ReviewActionTypes.INITIALIZE_REVIEW_FAILED: {
return {
...state,
errors: {
...state.errors,
review: {
...state.errors.review,
initialization: {
message: 'Could not initialize review session',
reason: action.payload.error.toString(),
},
},
},
};
}
case ReviewActionTypes.FINISH_ISSUE_FAILED: {
return {
...state,
errors: {
...state.errors,
review: {
...state.errors.review,
finishingIssue: {
message: 'Could not open a new issue',
reason: action.payload.error.toString(),
},
},
},
};
}
case ReviewActionTypes.RESOLVE_ISSUE_FAILED: {
return {
...state,
errors: {
...state.errors,
review: {
...state.errors.review,
resolvingIssue: {
message: 'Could not resolve the issue',
reason: action.payload.error.toString(),
},
},
},
};
}
case ReviewActionTypes.REOPEN_ISSUE_FAILED: {
return {
...state,
errors: {
...state.errors,
review: {
...state.errors.review,
reopeningIssue: {
message: 'Could not reopen the issue',
reason: action.payload.error.toString(),
},
},
},
};
}
case ReviewActionTypes.COMMENT_ISSUE_FAILED: {
return {
...state,
errors: {
...state.errors,
review: {
...state.errors.review,
commentingIssue: {
message: 'Could not comment the issue',
reason: action.payload.error.toString(),
},
},
},
};
}
case ReviewActionTypes.SUBMIT_REVIEW_FAILED: {
return {
...state,
errors: {
...state.errors,
review: {
...state.errors.review,
submittingReview: {
message: 'Could not submit review session to the server',
reason: action.payload.error.toString(),
},
},
},
};
}
case NotificationsActionType.RESET_ERRORS: {
return {
...state,

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

@ -214,6 +214,12 @@ const defaultKeyMap = ({
sequences: ['shift+n', 'n'],
action: 'keydown',
},
OPEN_REVIEW_ISSUE: {
name: 'Open an issue',
description: 'Create a new issues in the review workspace',
sequences: ['n'],
action: 'keydown',
},
SWITCH_MERGE_MODE: {
name: 'Merge mode',
description: 'Activate or deactivate mode to merging shapes',

@ -99,7 +99,7 @@ context('Multiple users. Assign task, job.', () => {
cy.login();
cy.openTask(taskName);
cy.get('.cvat-task-job-list').within(() => {
cy.get('.cvat-user-search-field').click({ force: true });
cy.get('.cvat-job-assignee-selector').click({ force: true });
});
cy.contains(thirdUserName).click();
cy.logout();

Loading…
Cancel
Save