Merged develop

main
Boris Sekachev 5 years ago
commit fcc15ced82

@ -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>)
- Added documentation on how to mount cloud starage(AWS S3 bucket, Azure container, Google Drive) as FUSE (<https://github.com/openvinotoolkit/cvat/pull/2377>)
- Added ability to work with share files without copying inside (<https://github.com/openvinotoolkit/cvat/pull/2377>)
@ -68,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- MOTS png mask format support (<https://github.com/openvinotoolkit/cvat/pull/2198>)
- Ability to correct upload video with a rotation record in the metadata (<https://github.com/openvinotoolkit/cvat/pull/2218>)
- User search field for assignee fields (<https://github.com/openvinotoolkit/cvat/pull/2370>)
- Support of mxf videos (<https://github.com/openvinotoolkit/cvat/pull/2514>)
### Changed

@ -19,7 +19,7 @@ annotation team. Try it online [cvat.org](https://cvat.org).
- [Installation guide](cvat/apps/documentation/installation.md)
- [User's guide](cvat/apps/documentation/user_guide.md)
- [Django REST API documentation](#rest-api)
- [Datumaro dataset framework](datumaro/README.md)
- [Datumaro dataset framework](https://github.com/openvinotoolkit/datumaro/blob/develop/README.md)
- [Command line interface](utils/cli/)
- [XML annotation format](cvat/apps/documentation/xml_format.md)
- [AWS Deployment Guide](cvat/apps/documentation/AWS-Deployment-Guide.md)

@ -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 }
- canvas.error => { exception: Error }
@ -206,28 +214,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',
@ -143,6 +146,7 @@ export enum Mode {
SPLIT = 'split',
GROUP = 'group',
INTERACT = 'interact',
SELECT_REGION = 'select_region',
DRAG_CANVAS = 'drag_canvas',
ZOOM_CANVAS = 'zoom_canvas',
}
@ -150,6 +154,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;
@ -170,6 +175,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;
@ -185,6 +191,7 @@ export interface CanvasModel {
fitCanvas(width: number, height: number): void;
bitmap(enabled: boolean): void;
selectRegion(enabled: boolean): void;
dragCanvas(enable: boolean): void;
zoomCanvas(enable: boolean): void;
@ -208,6 +215,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;
@ -257,6 +265,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
},
left: 0,
objects: [],
issueRegions: {},
scale: 1,
top: 0,
zLayer: null,
@ -292,15 +301,15 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
const mutiplier = Math.sin((angle * Math.PI) / 180) + Math.cos((angle * Math.PI) / 180);
if ((angle / 90) % 2) {
// 90, 270, ..
this.data.top +=
mutiplier * ((x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1)) * this.data.scale;
this.data.left -=
mutiplier * ((y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1)) * this.data.scale;
const topMultiplier = (x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1);
const leftMultiplier = (y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1);
this.data.top += mutiplier * topMultiplier * this.data.scale;
this.data.left -= mutiplier * leftMultiplier * this.data.scale;
} else {
this.data.left +=
mutiplier * ((x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1)) * this.data.scale;
this.data.top +=
mutiplier * ((y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1)) * this.data.scale;
const leftMultiplier = (x - this.data.imageSize.width / 2) * (oldScale / this.data.scale - 1);
const topMultiplier = (y - this.data.imageSize.height / 2) * (oldScale / this.data.scale - 1);
this.data.left += mutiplier * leftMultiplier * this.data.scale;
this.data.top += mutiplier * topMultiplier * this.data.scale;
}
this.notify(UpdateReasons.IMAGE_ZOOMED);
@ -329,6 +338,19 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.notify(UpdateReasons.BITMAP);
}
public selectRegion(enable: boolean): void {
if (enable && this.data.mode !== Mode.IDLE) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
if (!enable && this.data.mode !== Mode.SELECT_REGION) {
throw Error(`Canvas is not in the region selecting mode. Action: ${this.data.mode}`);
}
this.data.mode = enable ? Mode.SELECT_REGION : Mode.IDLE;
this.notify(UpdateReasons.SELECT_REGION);
}
public dragCanvas(enable: boolean): void {
if (enable && this.data.mode !== Mode.IDLE) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
@ -399,6 +421,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;
@ -605,13 +632,16 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.data.configuration.undefinedAttrValue = configuration.undefinedAttrValue;
}
if (typeof configuration.forceDisableEditing !== 'undefined') {
this.data.configuration.forceDisableEditing = configuration.forceDisableEditing;
}
this.notify(UpdateReasons.CONFIG_UPDATED);
}
public isAbleToChangeFrame(): boolean {
const isUnable =
[Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode) ||
(this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number');
const isUnable = [Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode)
|| (this.data.mode === Mode.DRAW && typeof this.data.drawData.redraw === 'number');
return !isUnable;
}
@ -664,6 +694,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) {
@ -1283,7 +1454,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
};
}
private updateObjects(states: any[], translate: (points: number[]) => number[]): void {
private updateObjects(states: any[]): void {
for (const state of states) {
const { clientID } = state;
const drawnState = this.drawnStates[clientID];
@ -1333,10 +1504,10 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
if (
state.points.length !== drawnState.points.length ||
state.points.some((p: number, id: number): boolean => p !== drawnState.points[id])
state.points.length !== drawnState.points.length
|| state.points.some((p: number, id: number): boolean => p !== drawnState.points[id])
) {
const translatedPoints: number[] = translate(state.points);
const translatedPoints: number[] = this.translateToCanvas(state.points);
if (state.shapeType === 'rectangle') {
const [xtl, ytl, xbr, ybr] = translatedPoints;
@ -1348,13 +1519,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
height: ybr - ytl,
});
} else {
const stringified = translatedPoints.reduce((acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc}${val} `;
}
return `${acc}${val},`;
}, '');
const stringified = this.stringifyToCanvas(translatedPoints);
if (state.shapeType !== 'cuboid') {
(shape as any).clear();
}
@ -1383,24 +1548,18 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
}
private addObjects(states: any[], translate: (points: number[]) => number[]): void {
private addObjects(states: any[]): void {
const { displayAllText } = this.configuration;
for (const state of states) {
const points: number[] = state.points as number[];
const translatedPoints: number[] = translate(points);
const translatedPoints: number[] = this.translateToCanvas(points);
// TODO: Use enums after typification cvat-core
if (state.shapeType === 'rectangle') {
this.svgShapes[state.clientID] = this.addRect(translatedPoints, state);
} else {
const stringified = translatedPoints.reduce((acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc}${val} `;
}
return `${acc}${val},`;
}, '');
const stringified = this.stringifyToCanvas(translatedPoints);
if (state.shapeType === 'polygon') {
this.svgShapes[state.clientID] = this.addPolygon(stringified, state);
@ -1550,7 +1709,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
if (state && state.shapeType === 'points') {
this.svgShapes[clientID]
.remember('_selectHandler')
.nested.style('pointer-events', state.lock ? 'none' : '');
.nested.style('pointer-events', this.stateIsLocked(state) ? 'none' : '');
}
if (!state || state.hidden || state.outside) {
@ -1558,8 +1717,14 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
const shape = this.svgShapes[clientID];
let text = this.svgTexts[clientID];
if (!text) {
text = this.addText(state);
this.svgTexts[state.clientID] = text;
}
this.updateTextPosition(text, shape);
if (state.lock) {
if (this.stateIsLocked(state)) {
return;
}
@ -1575,12 +1740,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
(shape as any).attr('projections', true);
}
let text = this.svgTexts[clientID];
if (!text) {
text = this.addText(state);
this.svgTexts[state.clientID] = text;
}
const hideText = (): void => {
if (text) {
text.addClass('cvat_canvas_hidden');
@ -1609,12 +1768,14 @@ export class CanvasViewImpl implements CanvasView, Listener {
const p2 = e.detail.p;
const delta = 1;
const { offset } = this.controller.geometry;
if (Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2) >= delta) {
const dx2 = (p1.x - p2.x) ** 2;
const dy2 = (p1.y - p2.y) ** 2;
if (Math.sqrt(dx2 + dy2) >= delta) {
const points = pointsToNumberArray(
shape.attr('points') ||
`${shape.attr('x')},${shape.attr('y')} ` +
`${shape.attr('x') + shape.attr('width')},` +
`${shape.attr('y') + shape.attr('height')}`,
shape.attr('points')
|| `${shape.attr('x')},${shape.attr('y')} `
+ `${shape.attr('x') + shape.attr('width')},`
+ `${shape.attr('y') + shape.attr('height')}`,
).map((x: number): number => x - offset);
this.drawnStates[state.clientID].points = points;
@ -1685,10 +1846,10 @@ export class CanvasViewImpl implements CanvasView, Listener {
const { offset } = this.controller.geometry;
const points = pointsToNumberArray(
shape.attr('points') ||
`${shape.attr('x')},${shape.attr('y')} ` +
`${shape.attr('x') + shape.attr('width')},` +
`${shape.attr('y') + shape.attr('height')}`,
shape.attr('points')
|| `${shape.attr('x')},${shape.attr('y')} `
+ `${shape.attr('x') + shape.attr('width')},`
+ `${shape.attr('y') + shape.attr('height')}`,
).map((x: number): number => x - offset);
this.drawnStates[state.clientID].points = points;
@ -1705,7 +1866,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
});
this.updateTextPosition(text, shape);
this.canvas.dispatchEvent(
new CustomEvent('canvas.activated', {
bubbles: false,
@ -1765,8 +1925,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
// Find the best place for a text
let [clientX, clientY]: number[] = [box.x + box.width, box.y];
if (
clientX + ((text.node as any) as SVGTextElement).getBBox().width + consts.TEXT_MARGIN >
this.canvas.offsetWidth
clientX + ((text.node as any) as SVGTextElement).getBBox().width + consts.TEXT_MARGIN
> this.canvas.offsetWidth
) {
[clientX, clientY] = [box.x, box.y];
}
@ -1786,7 +1946,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
private addText(state: any): SVG.Text {
const { undefinedAttrValue } = this.configuration;
const { label, clientID, attributes, source } = state;
const {
label, clientID, attributes, source,
} = state;
const attrNames = label.attributes.reduce((acc: any, val: any): void => {
acc[val.id] = val.name;
return acc;

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

@ -293,7 +293,7 @@
async function authorized() {
try {
await module.exports.users.getSelf();
await module.exports.users.self();
} catch (serverError) {
if (serverError.code === 401) {
return false;
@ -572,6 +572,90 @@
return response.data;
}
async function getJobReviews(jobID) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.get(`${backendAPI}/jobs/${jobID}/reviews`, {
proxy: config.proxy,
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function createReview(data) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.post(`${backendAPI}/reviews`, JSON.stringify(data), {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function getJobIssues(jobID) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.get(`${backendAPI}/jobs/${jobID}/issues`, {
proxy: config.proxy,
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function createComment(data) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.post(`${backendAPI}/comments`, JSON.stringify(data), {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function updateIssue(issueID, data) {
const { backendAPI } = config;
let response = null;
try {
response = await Axios.patch(`${backendAPI}/issues/${issueID}`, JSON.stringify(data), {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) {
throw generateError(errorData);
}
return response.data;
}
async function saveJob(id, jobData) {
const { backendAPI } = config;
@ -945,16 +1029,21 @@
jobs: {
value: Object.freeze({
getJob,
saveJob,
get: getJob,
save: saveJob,
issues: getJobIssues,
reviews: {
get: getJobReviews,
create: createReview,
},
}),
writable: false,
},
users: {
value: Object.freeze({
getUsers,
getSelf,
get: getUsers,
self: getSelf,
}),
writable: false,
},
@ -996,6 +1085,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,
@ -926,6 +1008,7 @@
url: job.url,
id: job.id,
assignee: job.assignee,
reviewer: job.reviewer,
status: job.status,
start_frame: segment.start_frame,
stop_frame: segment.stop_frame,
@ -1499,7 +1582,6 @@
buildDublicatedAPI(Task.prototype);
Job.prototype.save.implementation = async function () {
// TODO: Add ability to change an assignee
if (this.id) {
const jobData = {};
@ -1512,17 +1594,21 @@
case 'assignee':
jobData.assignee_id = this.assignee ? this.assignee.id : null;
break;
case 'reviewer':
jobData.reviewer_id = this.reviewer ? this.reviewer.id : null;
break;
default:
break;
}
}
}
await serverProxy.jobs.saveJob(this.id, jobData);
await serverProxy.jobs.save(this.id, jobData);
this.__updatedFields = {
status: false,
assignee: false,
reviewer: false,
};
return this;
@ -1531,6 +1617,42 @@
throw new ArgumentError('Can not save job without and id');
};
Job.prototype.issues.implementation = async function () {
const result = await serverProxy.jobs.issues(this.id);
return result.map((issue) => new Issue(issue));
};
Job.prototype.reviews.implementation = async function () {
const result = await serverProxy.jobs.reviews.get(this.id);
const reviews = result.map((review) => new Review(review));
// try to get not finished review from the local storage
const data = store.get(`job-${this.id}-review`);
if (data) {
reviews.push(new Review(JSON.parse(data)));
}
return reviews;
};
Job.prototype.reviewsSummary.implementation = async function () {
const reviews = await serverProxy.jobs.reviews.get(this.id);
const issues = await serverProxy.jobs.issues(this.id);
const qualities = reviews.map((review) => review.estimated_quality);
const reviewers = reviews.filter((review) => review.reviewer).map((review) => review.reviewer.username);
const assignees = reviews.filter((review) => review.assignee).map((review) => review.assignee.username);
return {
reviews: reviews.length,
average_estimated_quality: qualities.reduce((acc, quality) => acc + quality, 0) / (qualities.length || 1),
issues_unsolved: issues.filter((issue) => !issue.resolved_date).length,
issues_resolved: issues.filter((issue) => issue.resolved_date).length,
assignees: Array.from(new Set(assignees.filter((assignee) => assignee !== null))),
reviewers: Array.from(new Set(reviewers.filter((reviewer) => reviewer !== null))),
};
};
Job.prototype.frames.get.implementation = async function (frame, isPlaying, step) {
if (!Number.isInteger(frame) || frame < 0) {
throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`);

@ -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',
@ -188,6 +186,9 @@ export enum AnnotationActionTypes {
INTERACT_WITH_CANVAS = 'INTERACT_WITH_CANVAS',
SET_AI_TOOLS_REF = 'SET_AI_TOOLS_REF',
GET_DATA_FAILED = 'GET_DATA_FAILED',
SWITCH_REQUEST_REVIEW_DIALOG = 'SWITCH_REQUEST_REVIEW_DIALOG',
SWITCH_SUBMIT_REVIEW_DIALOG = 'SWITCH_SUBMIT_REVIEW_DIALOG',
SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG = 'SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG',
}
export function saveLogsAsync(): ThunkAction {
@ -222,7 +223,7 @@ export function getDataFailed(error: any): AnyAction {
return {
type: AnnotationActionTypes.GET_DATA_FAILED,
payload: {
error: error,
error,
},
};
}
@ -403,36 +404,6 @@ export function uploadJobAnnotationsAsync(job: any, loader: any, file: File): Th
};
}
export function changeJobStatusAsync(jobInstance: any, status: string): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const oldStatus = jobInstance.status;
try {
dispatch({
type: AnnotationActionTypes.CHANGE_JOB_STATUS,
payload: {},
});
// eslint-disable-next-line no-param-reassign
jobInstance.status = status;
await jobInstance.save();
dispatch({
type: AnnotationActionTypes.CHANGE_JOB_STATUS_SUCCESS,
payload: {},
});
} catch (error) {
// eslint-disable-next-line no-param-reassign
jobInstance.status = oldStatus;
dispatch({
type: AnnotationActionTypes.CHANGE_JOB_STATUS_FAILED,
payload: {
error,
},
});
}
};
}
export function collectStatisticsAsync(sessionInstance: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
try {
@ -906,7 +877,11 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
try {
const state: CombinedState = getStore().getState();
const filters = initialFilters;
const { showAllInterpolationTracks } = state.settings.workspace;
const {
settings: {
workspace: { showAllInterpolationTracks },
},
} = state;
dispatch({
type: AnnotationActionTypes.GET_JOB,
@ -948,9 +923,9 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
const frameData = await job.frames.get(frameNumber);
// call first getting of frame data before rendering interface
// to load and decode first chunk
try{
try {
await frameData.data();
} catch(error){
} catch (error) {
dispatch({
type: AnnotationActionTypes.GET_DATA_FAILED,
payload: {
@ -959,6 +934,8 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
});
}
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];
@ -968,6 +945,8 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
type: AnnotationActionTypes.GET_JOB_SUCCESS,
payload: {
job,
issues,
reviews,
states,
frameNumber,
frameFilename: frameData.filename,
@ -990,7 +969,7 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
};
}
export function saveAnnotationsAsync(sessionInstance: any): ThunkAction {
export function saveAnnotationsAsync(sessionInstance: any, afterSave?: () => void): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters();
@ -1016,6 +995,9 @@ export function saveAnnotationsAsync(sessionInstance: any): ThunkAction {
const { frame } = receiveAnnotationsParameters();
const states = await sessionInstance.annotations.get(frame, showAllInterpolationTracks, filters);
if (typeof afterSave === 'function') {
afterSave();
}
dispatch({
type: AnnotationActionTypes.SAVE_ANNOTATIONS_SUCCESS,
@ -1075,6 +1057,15 @@ export function shapeDrawn(): AnyAction {
};
}
export function selectIssuePosition(enabled: boolean): AnyAction {
return {
type: AnnotationActionTypes.SELECT_ISSUE_POSITION,
payload: {
enabled,
},
};
}
export function mergeObjects(enabled: boolean): AnyAction {
return {
type: AnnotationActionTypes.MERGE_OBJECTS,
@ -1500,3 +1491,30 @@ export function redrawShapeAsync(): ThunkAction {
}
};
}
export function switchRequestReviewDialog(visible: boolean): AnyAction {
return {
type: AnnotationActionTypes.SWITCH_REQUEST_REVIEW_DIALOG,
payload: {
visible,
},
};
}
export function switchSubmitReviewDialog(visible: boolean): AnyAction {
return {
type: AnnotationActionTypes.SWITCH_SUBMIT_REVIEW_DIALOG,
payload: {
visible,
},
};
}
export function setForceExitAnnotationFlag(forceExit: boolean): AnyAction {
return {
type: AnnotationActionTypes.SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG,
payload: {
forceExit,
},
};
}

@ -0,0 +1,217 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { ActionUnion, createAction, ThunkAction } from 'utils/redux';
import getCore from 'cvat-core-wrapper';
import { updateTaskSuccess } from './tasks-actions';
const cvat = getCore();
export enum ReviewActionTypes {
INITIALIZE_REVIEW_SUCCESS = 'INITIALIZE_REVIEW_SUCCESS',
INITIALIZE_REVIEW_FAILED = 'INITIALIZE_REVIEW_FAILED',
CREATE_ISSUE = 'CREATE_ISSUE',
START_ISSUE = 'START_ISSUE',
FINISH_ISSUE_SUCCESS = 'FINISH_ISSUE_SUCCESS',
FINISH_ISSUE_FAILED = 'FINISH_ISSUE_FAILED',
CANCEL_ISSUE = 'CANCEL_ISSUE',
RESOLVE_ISSUE = 'RESOLVE_ISSUE',
RESOLVE_ISSUE_SUCCESS = 'RESOLVE_ISSUE_SUCCESS',
RESOLVE_ISSUE_FAILED = 'RESOLVE_ISSUE_FAILED',
REOPEN_ISSUE = 'REOPEN_ISSUE',
REOPEN_ISSUE_SUCCESS = 'REOPEN_ISSUE_SUCCESS',
REOPEN_ISSUE_FAILED = 'REOPEN_ISSUE_FAILED',
COMMENT_ISSUE = 'COMMENT_ISSUE',
COMMENT_ISSUE_SUCCESS = 'COMMENT_ISSUE_SUCCESS',
COMMENT_ISSUE_FAILED = 'COMMENT_ISSUE_FAILED',
SUBMIT_REVIEW = 'SUBMIT_REVIEW',
SUBMIT_REVIEW_SUCCESS = 'SUBMIT_REVIEW_SUCCESS',
SUBMIT_REVIEW_FAILED = 'SUBMIT_REVIEW_FAILED',
SWITCH_ISSUES_HIDDEN_FLAG = 'SWITCH_ISSUES_HIDDEN_FLAG',
}
export const reviewActions = {
initializeReviewSuccess: (reviewInstance: any, frame: number) =>
createAction(ReviewActionTypes.INITIALIZE_REVIEW_SUCCESS, { reviewInstance, frame }),
initializeReviewFailed: (error: any) => createAction(ReviewActionTypes.INITIALIZE_REVIEW_FAILED, { error }),
createIssue: () => createAction(ReviewActionTypes.CREATE_ISSUE, {}),
startIssue: (position: number[]) =>
createAction(ReviewActionTypes.START_ISSUE, { position: cvat.classes.Issue.hull(position) }),
finishIssueSuccess: (frame: number, issue: any) =>
createAction(ReviewActionTypes.FINISH_ISSUE_SUCCESS, { frame, issue }),
finishIssueFailed: (error: any) => createAction(ReviewActionTypes.FINISH_ISSUE_FAILED, { error }),
cancelIssue: () => createAction(ReviewActionTypes.CANCEL_ISSUE),
commentIssue: (issueId: number) => createAction(ReviewActionTypes.COMMENT_ISSUE, { issueId }),
commentIssueSuccess: () => createAction(ReviewActionTypes.COMMENT_ISSUE_SUCCESS),
commentIssueFailed: (error: any) => createAction(ReviewActionTypes.COMMENT_ISSUE_FAILED, { error }),
resolveIssue: (issueId: number) => createAction(ReviewActionTypes.RESOLVE_ISSUE, { issueId }),
resolveIssueSuccess: () => createAction(ReviewActionTypes.RESOLVE_ISSUE_SUCCESS),
resolveIssueFailed: (error: any) => createAction(ReviewActionTypes.RESOLVE_ISSUE_FAILED, { error }),
reopenIssue: (issueId: number) => createAction(ReviewActionTypes.REOPEN_ISSUE, { issueId }),
reopenIssueSuccess: () => createAction(ReviewActionTypes.REOPEN_ISSUE_SUCCESS),
reopenIssueFailed: (error: any) => createAction(ReviewActionTypes.REOPEN_ISSUE_FAILED, { error }),
submitReview: (reviewId: number) => createAction(ReviewActionTypes.SUBMIT_REVIEW, { reviewId }),
submitReviewSuccess: (activeReview: any, reviews: any[], issues: any[], frame: number) =>
createAction(ReviewActionTypes.SUBMIT_REVIEW_SUCCESS, {
activeReview,
reviews,
issues,
frame,
}),
submitReviewFailed: (error: any) => createAction(ReviewActionTypes.SUBMIT_REVIEW_FAILED, { error }),
switchIssuesHiddenFlag: (hidden: boolean) => createAction(ReviewActionTypes.SWITCH_ISSUES_HIDDEN_FLAG, { hidden }),
};
export type ReviewActions = ActionUnion<typeof reviewActions>;
export const initializeReviewAsync = (): ThunkAction => async (dispatch, getState) => {
try {
const state = getState();
const {
annotation: {
job: { instance: jobInstance },
player: {
frame: { number: frame },
},
},
} = state;
const reviews = await jobInstance.reviews();
const count = reviews.length;
let reviewInstance = null;
if (count && reviews[count - 1].id < 0) {
reviewInstance = reviews[count - 1];
} else {
reviewInstance = new cvat.classes.Review({ job: jobInstance.id });
}
dispatch(reviewActions.initializeReviewSuccess(reviewInstance, frame));
} catch (error) {
dispatch(reviewActions.initializeReviewFailed(error));
}
};
export const finishIssueAsync = (message: string): ThunkAction => async (dispatch, getState) => {
const state = getState();
const {
auth: { user },
annotation: {
player: {
frame: { number: frameNumber },
},
},
review: { activeReview, newIssuePosition },
} = state;
try {
const issue = await activeReview.openIssue({
frame: frameNumber,
position: newIssuePosition,
owner: user,
comment_set: [
{
message,
author: user,
},
],
});
await activeReview.toLocalStorage();
dispatch(reviewActions.finishIssueSuccess(frameNumber, issue));
} catch (error) {
dispatch(reviewActions.finishIssueFailed(error));
}
};
export const commentIssueAsync = (id: number, message: string): ThunkAction => async (dispatch, getState) => {
const state = getState();
const {
auth: { user },
review: { frameIssues, activeReview },
} = state;
try {
dispatch(reviewActions.commentIssue(id));
const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id);
await issue.comment({
message,
author: user,
});
if (activeReview && activeReview.issues.includes(issue)) {
await activeReview.toLocalStorage();
}
dispatch(reviewActions.commentIssueSuccess());
} catch (error) {
dispatch(reviewActions.commentIssueFailed(error));
}
};
export const resolveIssueAsync = (id: number): ThunkAction => async (dispatch, getState) => {
const state = getState();
const {
auth: { user },
review: { frameIssues, activeReview },
} = state;
try {
dispatch(reviewActions.resolveIssue(id));
const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id);
await issue.resolve(user);
if (activeReview && activeReview.issues.includes(issue)) {
await activeReview.toLocalStorage();
}
dispatch(reviewActions.resolveIssueSuccess());
} catch (error) {
dispatch(reviewActions.resolveIssueFailed(error));
}
};
export const reopenIssueAsync = (id: number): ThunkAction => async (dispatch, getState) => {
const state = getState();
const {
auth: { user },
review: { frameIssues, activeReview },
} = state;
try {
dispatch(reviewActions.reopenIssue(id));
const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id);
await issue.reopen(user);
if (activeReview && activeReview.issues.includes(issue)) {
await activeReview.toLocalStorage();
}
dispatch(reviewActions.reopenIssueSuccess());
} catch (error) {
dispatch(reviewActions.reopenIssueFailed(error));
}
};
export const submitReviewAsync = (review: any): ThunkAction => async (dispatch, getState) => {
const state = getState();
const {
annotation: {
job: { instance: jobInstance },
player: {
frame: { number: frame },
},
},
} = state;
try {
dispatch(reviewActions.submitReview(review.id));
await review.submit(jobInstance.id);
const [task] = await cvat.tasks.get({ id: jobInstance.task.id });
dispatch(updateTaskSuccess(task));
const reviews = await jobInstance.reviews();
const issues = await jobInstance.issues();
const reviewInstance = new cvat.classes.Review({ job: jobInstance.id });
dispatch(reviewActions.submitReviewSuccess(reviewInstance, reviews, issues, frame));
} catch (error) {
dispatch(reviewActions.submitReviewFailed(error));
}
};

@ -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;
@ -90,11 +91,14 @@ interface Props {
onSwitchAutomaticBordering(enabled: boolean): void;
onFetchAnnotation(): void;
onGetDataFailed(error: any): 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
@ -105,9 +109,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();
}
@ -119,6 +125,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
outlined,
outlineColor,
showBitmap,
frameIssues,
frameData,
frameAngle,
annotations,
@ -212,6 +219,10 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
}
}
if (prevProps.frameIssues !== frameIssues) {
this.updateIssueRegions();
}
if (
prevProps.annotations !== annotations ||
prevProps.frameData !== frameData ||
@ -248,6 +259,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) {
@ -296,6 +319,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);
@ -308,7 +332,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
const { exception } = event.detail;
const { onGetDataFailed } = this.props;
onGetDataFailed(exception);
}
};
private onCanvasShapeDrawn = (event: any): void => {
const {
@ -361,6 +385,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,
@ -380,7 +411,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);
}
@ -388,7 +419,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();
}
};
@ -448,7 +481,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;
}
@ -606,6 +639,22 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
}
}
private updateIssueRegions(): void {
const { canvasInstance, frameIssues } = this.props;
if (frameIssues === null) {
canvasInstance.setupIssueRegions({});
} else {
const regions = frameIssues.reduce((acc: Record<number, number[]>, issue: any): Record<
number,
number[]
> => {
acc[issue.id] = issue.position;
return acc;
}, {});
canvasInstance.setupIssueRegions(regions);
}
}
private updateCanvas(): void {
const {
curZLayer, annotations, frameData, canvasInstance,
@ -700,6 +749,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,
@ -38,6 +38,7 @@ import {
changeSaturationLevel,
switchAutomaticBordering,
} from 'actions/settings-actions';
import { reviewActions } from 'actions/review-actions';
import {
ColorBy,
GridColor,
@ -58,6 +59,7 @@ interface StateToProps {
activatedAttributeID: number | null;
selectedStatesID: number[];
annotations: any[];
frameIssues: any[] | null;
frameData: any;
frameAngle: number;
frameFetching: boolean;
@ -121,6 +123,7 @@ interface DispatchToProps {
onSwitchAutomaticBordering(enabled: boolean): void;
onFetchAnnotation(): void;
onGetDataFailed(error: any): void;
onStartIssue(position: number[]): void;
}
function mapStateToProps(state: CombinedState): StateToProps {
@ -155,9 +158,14 @@ function mapStateToProps(state: CombinedState): StateToProps {
saturationLevel,
resetZoom,
},
workspace: { aamZoomMargin, showObjectsTextAlways, showAllInterpolationTracks, automaticBordering },
shapes: { opacity, colorBy, selectedOpacity, outlined, outlineColor, showBitmap, showProjections },
workspace: {
aamZoomMargin, showObjectsTextAlways, showAllInterpolationTracks, automaticBordering,
},
shapes: {
opacity, colorBy, selectedOpacity, outlined, outlineColor, showBitmap, showProjections,
},
},
review: { frameIssues, issuesHidden },
shortcuts: { keyMap },
} = state;
@ -165,6 +173,8 @@ function mapStateToProps(state: CombinedState): StateToProps {
sidebarCollapsed,
canvasInstance,
jobInstance,
frameIssues:
issuesHidden || ![Workspace.REVIEW_WORKSPACE, Workspace.STANDARD].includes(workspace) ? null : frameIssues,
frameData,
frameAngle: frameAngles[frame - jobInstance.startFrame],
frameFetching,
@ -303,6 +313,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
onGetDataFailed(error: any): void {
dispatch(getDataFailed(error));
},
onStartIssue(position: number[]): void {
dispatch(reviewActions.startIssue(position));
},
};
}

@ -0,0 +1,95 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { ExtendedKeyMapOptions } from 'react-hotkeys';
import { connect } from 'react-redux';
import { Canvas } from 'cvat-canvas-wrapper';
import {
selectIssuePosition as selectIssuePositionAction,
mergeObjects,
groupObjects,
splitTrack,
redrawShapeAsync,
rotateCurrentFrame,
repeatDrawShapeAsync,
pasteShapeAsync,
resetAnnotationsGroup,
} from 'actions/annotation-actions';
import ControlsSideBarComponent from 'components/annotation-page/review-workspace/controls-side-bar/controls-side-bar';
import { ActiveControl, CombinedState, Rotation } from 'reducers/interfaces';
interface StateToProps {
canvasInstance: Canvas;
rotateAll: boolean;
activeControl: ActiveControl;
keyMap: Record<string, ExtendedKeyMapOptions>;
normalizedKeyMap: Record<string, string>;
}
interface DispatchToProps {
mergeObjects(enabled: boolean): void;
groupObjects(enabled: boolean): void;
splitTrack(enabled: boolean): void;
rotateFrame(angle: Rotation): void;
selectIssuePosition(enabled: boolean): void;
resetGroup(): void;
repeatDrawShape(): void;
pasteShape(): void;
redrawShape(): void;
}
function mapStateToProps(state: CombinedState): StateToProps {
const {
annotation: {
canvas: { instance: canvasInstance, activeControl },
},
settings: {
player: { rotateAll },
},
shortcuts: { keyMap, normalizedKeyMap },
} = state;
return {
rotateAll,
canvasInstance,
activeControl,
normalizedKeyMap,
keyMap,
};
}
function dispatchToProps(dispatch: any): DispatchToProps {
return {
mergeObjects(enabled: boolean): void {
dispatch(mergeObjects(enabled));
},
groupObjects(enabled: boolean): void {
dispatch(groupObjects(enabled));
},
splitTrack(enabled: boolean): void {
dispatch(splitTrack(enabled));
},
selectIssuePosition(enabled: boolean): void {
dispatch(selectIssuePositionAction(enabled));
},
rotateFrame(rotation: Rotation): void {
dispatch(rotateCurrentFrame(rotation));
},
repeatDrawShape(): void {
dispatch(repeatDrawShapeAsync());
},
pasteShape(): void {
dispatch(pasteShapeAsync());
},
resetGroup(): void {
dispatch(resetAnnotationsGroup());
},
redrawShape(): void {
dispatch(redrawShapeAsync());
},
};
}
export default connect(mapStateToProps, dispatchToProps)(ControlsSideBarComponent);

@ -13,6 +13,7 @@ import { CombinedState } from 'reducers/interfaces';
import ItemButtonsComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item-buttons';
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: {
@ -206,13 +221,15 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.CHANGE_FRAME_SUCCESS: {
const { number, data, filename, states, minZ, maxZ, curZ, delay, changeTime } = action.payload;
const {
number, data, filename, states, minZ, maxZ, curZ, delay, changeTime,
} = action.payload;
const activatedStateID = states
.map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID)
? state.annotations.activatedStateID
: null;
.includes(state.annotations.activatedStateID) ?
state.annotations.activatedStateID :
null;
return {
...state,
@ -257,9 +274,12 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
...state,
player: {
...state.player,
frameAngles: state.player.frameAngles.map((_angle: number, idx: number) =>
rotateAll || offset === idx ? angle : _angle,
),
frameAngles: state.player.frameAngles.map((_angle: number, idx: number) => {
if (rotateAll || offset === idx) {
return angle;
}
return _angle;
}),
},
};
}
@ -406,7 +426,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.REMEMBER_CREATED_OBJECT: {
const { shapeType, labelID, objectType, points, activeControl, rectDrawingMethod } = action.payload;
const {
shapeType, labelID, objectType, points, activeControl, rectDrawingMethod,
} = action.payload;
return {
...state,
@ -443,6 +465,22 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
case AnnotationActionTypes.SELECT_ISSUE_POSITION: {
const { enabled } = action.payload;
const activeControl = enabled ? ActiveControl.OPEN_ISSUE : ActiveControl.CURSOR;
return {
...state,
annotations: {
...state.annotations,
activatedStateID: null,
},
canvas: {
...state.canvas,
activeControl,
},
};
}
case AnnotationActionTypes.MERGE_OBJECTS: {
const { enabled } = action.payload;
const activeControl = enabled ? ActiveControl.MERGE : ActiveControl.CURSOR;
@ -501,7 +539,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.UPDATE_ANNOTATIONS_SUCCESS: {
const { history, states: updatedStates, minZ, maxZ } = action.payload;
const {
history, states: updatedStates, minZ, maxZ,
} = action.payload;
const { states: prevStates } = state.annotations;
const nextStates = [...prevStates];
@ -639,6 +679,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
}
case AnnotationActionTypes.REMOVE_OBJECT_SUCCESS: {
const { objectState, history } = action.payload;
const contextMenuClientID = state.canvas.contextMenu.clientID;
const contextMenuVisible = state.canvas.contextMenu.visible;
return {
...state,
@ -650,6 +692,14 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
(_objectState: any) => _objectState.clientID !== objectState.clientID,
),
},
canvas: {
...state.canvas,
contextMenu: {
...state.canvas.contextMenu,
clientID: objectState.clientID === contextMenuClientID ? null : contextMenuClientID,
visible: objectState.clientID === contextMenuClientID ? false : contextMenuVisible,
},
},
};
}
case AnnotationActionTypes.PASTE_SHAPE: {
@ -766,33 +816,6 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
case AnnotationActionTypes.CHANGE_JOB_STATUS: {
return {
...state,
job: {
...state.job,
saving: true,
},
};
}
case AnnotationActionTypes.CHANGE_JOB_STATUS_SUCCESS: {
return {
...state,
job: {
...state.job,
saving: false,
},
};
}
case AnnotationActionTypes.CHANGE_JOB_STATUS_FAILED: {
return {
...state,
job: {
...state.job,
saving: false,
},
};
}
case AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS: {
const { job, loader } = action.payload;
const { loads } = state.activities;
@ -863,7 +886,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.UPDATE_CANVAS_CONTEXT_MENU: {
const { visible, left, top, type, pointID } = action.payload;
const {
visible, left, top, type, pointID,
} = action.payload;
return {
...state,
@ -876,19 +901,22 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
top,
type,
pointID,
clientID: state.annotations.activatedStateID,
},
},
};
}
case AnnotationActionTypes.REDO_ACTION_SUCCESS:
case AnnotationActionTypes.UNDO_ACTION_SUCCESS: {
const { history, states, minZ, maxZ } = action.payload;
const {
history, states, minZ, maxZ,
} = action.payload;
const activatedStateID = states
.map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID)
? state.annotations.activatedStateID
: null;
.includes(state.annotations.activatedStateID) ?
state.annotations.activatedStateID :
null;
return {
...state,
@ -909,9 +937,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
const { states, minZ, maxZ } = action.payload;
const activatedStateID = states
.map((_state: any) => _state.clientID)
.includes(state.annotations.activatedStateID)
? state.annotations.activatedStateID
: null;
.includes(state.annotations.activatedStateID) ?
state.annotations.activatedStateID :
null;
return {
...state,
@ -999,6 +1027,33 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
case AnnotationActionTypes.SWITCH_REQUEST_REVIEW_DIALOG: {
const { visible } = action.payload;
return {
...state,
requestReviewDialogVisible: visible,
};
}
case AnnotationActionTypes.SWITCH_SUBMIT_REVIEW_DIALOG: {
const { visible } = action.payload;
return {
...state,
submitReviewDialogVisible: visible,
};
}
case AnnotationActionTypes.SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG: {
const { forceExit } = action.payload;
return {
...state,
annotations: {
...state.annotations,
saving: {
...state.annotations.saving,
forceExit,
},
},
};
}
case AnnotationActionTypes.CHANGE_WORKSPACE: {
const { workspace } = action.payload;
if (state.canvas.activeControl !== ActiveControl.CURSOR) {

@ -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',

@ -91,6 +91,11 @@ def is_project_annotator(db_user, db_project):
db_tasks = list(db_project.tasks.prefetch_related('segment_set').all())
return any([is_task_annotator(db_user, db_task) for db_task in db_tasks])
@rules.predicate
def is_project_reviewer(db_user, db_project):
db_tasks = list(db_project.tasks.prefetch_related('segment_set').all())
return any([is_task_reviewer(db_user, db_task) for db_task in db_tasks])
@rules.predicate
def is_task_owner(db_user, db_task):
# If owner is None (null) the task can be accessed/changed/deleted
@ -101,6 +106,12 @@ def is_task_owner(db_user, db_task):
def is_task_assignee(db_user, db_task):
return db_task.assignee == db_user or is_project_assignee(db_user, db_task.project)
@rules.predicate
def is_task_reviewer(db_user, db_task):
db_segments = list(db_task.segment_set.prefetch_related('job_set__assignee').all())
return any([is_job_reviewer(db_user, db_job)
for db_segment in db_segments for db_job in db_segment.job_set.all()])
@rules.predicate
def is_task_annotator(db_user, db_task):
db_segments = list(db_task.segment_set.prefetch_related('job_set__assignee').all())
@ -121,6 +132,33 @@ def is_job_annotator(db_user, db_job):
return has_rights
@rules.predicate
def has_change_permissions(db_user, db_job):
db_task = db_job.segment.task
# A job can be annotated by any user if the task's assignee is None.
has_rights = (db_task.assignee is None and not settings.RESTRICTIONS['reduce_task_visibility']) or is_task_assignee(db_user, db_task)
if db_job.assignee is not None:
has_rights |= (db_user == db_job.assignee) and (db_job.status == 'annotation')
if db_job.reviewer is not None:
has_rights |= (db_user == db_job.reviewer) and (db_job.status == 'validation')
return has_rights
@rules.predicate
def is_job_reviewer(db_user, db_job):
has_rights = db_job.reviewer == db_user
return has_rights
@rules.predicate
def is_issue_owner(db_user, db_issue):
has_rights = db_issue.owner == db_user
return has_rights
@rules.predicate
def is_comment_author(db_user, db_comment):
has_rights = (db_comment.author == db_user)
return has_rights
# AUTH PERMISSIONS RULES
rules.add_perm('engine.role.user', has_user_role)
rules.add_perm('engine.role.admin', has_admin_role)
@ -136,65 +174,71 @@ rules.add_perm('engine.project.delete', has_admin_role | is_project_owner)
rules.add_perm('engine.task.create', has_admin_role | has_user_role)
rules.add_perm('engine.task.access', has_admin_role | has_observer_role |
is_task_owner | is_task_annotator)
is_task_owner | is_task_annotator | is_task_reviewer)
rules.add_perm('engine.task.change', has_admin_role | is_task_owner |
is_task_assignee)
rules.add_perm('engine.task.delete', has_admin_role | is_task_owner)
rules.add_perm('engine.job.access', has_admin_role | has_observer_role |
is_job_owner | is_job_annotator)
rules.add_perm('engine.job.change', has_admin_role | is_job_owner |
is_job_annotator)
is_job_owner | is_job_annotator | is_job_reviewer)
rules.add_perm('engine.job.change', has_admin_role | is_job_owner | has_change_permissions)
rules.add_perm('engine.job.review', has_admin_role | (is_job_reviewer & has_change_permissions))
rules.add_perm('engine.issue.change', has_admin_role | is_issue_owner)
rules.add_perm('engine.issue.destroy', has_admin_role | is_issue_owner)
rules.add_perm('engine.comment.change', has_admin_role | is_comment_author)
class AdminRolePermission(BasePermission):
# pylint: disable=no-self-use
def has_permission(self, request, view):
return request.user.has_perm("engine.role.admin")
return request.user.has_perm('engine.role.admin')
class UserRolePermission(BasePermission):
# pylint: disable=no-self-use
def has_permission(self, request, view):
return request.user.has_perm("engine.role.user")
return request.user.has_perm('engine.role.user')
class AnnotatorRolePermission(BasePermission):
# pylint: disable=no-self-use
def has_permission(self, request, view):
return request.user.has_perm("engine.role.annotator")
return request.user.has_perm('engine.role.annotator')
class ObserverRolePermission(BasePermission):
# pylint: disable=no-self-use
def has_permission(self, request, view):
return request.user.has_perm("engine.role.observer")
return request.user.has_perm('engine.role.observer')
class ProjectCreatePermission(BasePermission):
# pylint: disable=no-self-use
def has_permission(self, request, view):
return request.user.has_perm("engine.project.create")
return request.user.has_perm('engine.project.create')
class ProjectAccessPermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm("engine.project.access", obj)
return request.user.has_perm('engine.project.access', obj)
class ProjectChangePermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm("engine.project.change", obj)
return request.user.has_perm('engine.project.change', obj)
class ProjectDeletePermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm("engine.project.delete", obj)
return request.user.has_perm('engine.project.delete', obj)
class TaskCreatePermission(BasePermission):
# pylint: disable=no-self-use
def has_permission(self, request, view):
return request.user.has_perm("engine.task.create")
return request.user.has_perm('engine.task.create')
class TaskAccessPermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm("engine.task.access", obj)
return request.user.has_perm('engine.task.access', obj)
class ProjectGetQuerySetMixin(object):
@ -207,7 +251,8 @@ class ProjectGetQuerySetMixin(object):
else:
return queryset.filter(Q(owner=user) | Q(assignee=user) |
Q(task__owner=user) | Q(task__assignee=user) |
Q(task__segment__job__assignee=user)).distinct()
Q(task__segment__job__assignee=user) |
Q(task__segment__job__reviewer=user)).distinct()
def filter_task_queryset(queryset, user):
# Don't filter queryset for admin, observer
@ -215,7 +260,7 @@ def filter_task_queryset(queryset, user):
return queryset
query_filter = Q(owner=user) | Q(assignee=user) | \
Q(segment__job__assignee=user)
Q(segment__job__assignee=user) | Q(segment__job__reviewer=user)
if not settings.RESTRICTIONS['reduce_task_visibility']:
query_filter |= Q(assignee=None)
@ -234,19 +279,53 @@ class TaskGetQuerySetMixin(object):
class TaskChangePermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm("engine.task.change", obj)
return request.user.has_perm('engine.task.change', obj)
class TaskDeletePermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm("engine.task.delete", obj)
return request.user.has_perm('engine.task.delete', obj)
class JobAccessPermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm("engine.job.access", obj)
return request.user.has_perm('engine.job.access', obj)
class JobChangePermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm("engine.job.change", obj)
return request.user.has_perm('engine.job.change', obj)
class JobReviewPermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm('engine.job.review', obj)
class IssueAccessPermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
db_job = obj.job
return request.user.has_perm('engine.job.access', db_job)
class IssueDestroyPermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm('engine.issue.destroy', obj)
class IssueChangePermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
db_job = obj.job
return (request.user.has_perm('engine.job.change', db_job)
or request.user.has_perm('engine.issue.change', obj))
class CommentCreatePermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj): # obj is db_job
return request.user.has_perm('engine.job.access', obj)
class CommentChangePermission(BasePermission):
# pylint: disable=no-self-use
def has_object_permission(self, request, view, obj):
return request.user.has_perm('engine.comment.change', obj)

@ -18,4 +18,5 @@ services:
environment:
CVAT_HOST: your-instance.amazonaws.com
```
In case of problems with using hostname, you can also use the public IPV4 instead of hostname. For AWS or any cloud based machines where the instances need to be terminated or stopped, the public IPV4 and hostname changes with every stop and reboot. To address this efficiently, avoid using spot instances that cannot be stopped, since copying the EBS to an AMI and restarting it throws problems. On the other hand, when a regular instance is stopped and restarted, the new hostname/IPV4 can be used in the `CVAT_HOST` variable in the `docker-compose.override.yml` and the build can happen instantly with CVAT tasks being available through the new IPV4.
In case of problems with using hostname, you can also use the public IPV4 instead of hostname. For AWS or any cloud based machines where the instances need to be terminated or stopped, the public IPV4 and hostname changes with every stop and reboot. To address this efficiently, avoid using spot instances that cannot be stopped, since copying the EBS to an AMI and restarting it throws problems. On the other hand, when a regular instance is stopped and restarted, the new hostname/IPV4 can be used in the `CVAT_HOST` variable in the `docker-compose.override.yml` and the build can happen instantly with CVAT tasks being available through the new IPV4.

@ -11,6 +11,9 @@
- [How to install CVAT on Windows 10 Home](#how-to-install-cvat-on-windows-10-home)
- [I do not have the Analytics tab on the header section. How can I add analytics](#i-do-not-have-the-analytics-tab-on-the-header-section-how-can-i-add-analytics)
- [How to upload annotations to an entire task from UI when there are multiple jobs in the task](#how-to-upload-annotations-to-an-entire-task-from-ui-when-there-are-multiple-jobs-in-the-task)
- [How to specify multiple hostnames for CVAT_HOST](#how-to-specify-multiple-hostnames-for-cvat_host)
- [How to create a task with multiple jobs](#how-to-create-a-task-with-multiple-jobs)
## How to update CVAT
@ -53,7 +56,7 @@ You should free up disk space or change the threshold, to do so check: [Elastics
The best way to do that is to create docker-compose.override.yml and override the host and port settings here.
version: "2.3"
version: "3.3"
```yaml
services:
@ -77,7 +80,7 @@ Follow the Docker manual and configure the directory that you want to use as a s
After that, it should be possible to use this directory as a CVAT share:
```yaml
version: '2.3'
version: '3.3'
services:
cvat:
@ -132,3 +135,17 @@ You should build CVAT images with ['Analytics' component](../../../components/an
You can upload annotation for a multi-job task from the Dasboard view or the Task view.
Uploading of annotation from the Annotation view only affects the current job.
## How to specify multiple hostnames for CVAT_HOST
```yaml
services:
cvat_proxy:
environment:
CVAT_HOST: example1.com example2.com
```
## How to create a task with multiple jobs
Set the segment size when you create a new task, this option is available in the
[Advanced configuration](user_guide.md#advanced-configuration) section.

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

@ -0,0 +1,74 @@
# Generated by Django 3.1.1 on 2020-11-25 14:26
import cvat.apps.engine.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def create_profile(apps, schema_editor):
User = apps.get_model('auth', 'User')
Profile = apps.get_model('engine', 'Profile')
for user in User.objects.all():
profile = Profile()
profile.user = user
profile.save()
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('engine', '0033_projects_adjastment'),
]
operations = [
migrations.AddField(
model_name='job',
name='reviewer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='review_job_set', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='Review',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('estimated_quality', models.FloatField()),
('status', models.CharField(choices=[('accepted', 'ACCEPTED'), ('rejected', 'REJECTED'), ('review_further', 'REVIEW_FURTHER')], max_length=16)),
('assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed', to=settings.AUTH_USER_MODEL)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='engine.job')),
('reviewer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviews', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.FloatField(default=0.0)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Issue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('frame', models.PositiveIntegerField()),
('position', cvat.apps.engine.models.FloatArrayField()),
('created_date', models.DateTimeField(auto_now_add=True)),
('resolved_date', models.DateTimeField(blank=True, null=True)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='engine.job')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issues', to=settings.AUTH_USER_MODEL)),
('resolver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_issues', to=settings.AUTH_USER_MODEL)),
('review', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='engine.review')),
],
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(default='')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='engine.issue')),
],
),
migrations.RunPython(create_profile),
]

@ -1,23 +0,0 @@
# Generated by Django 3.1.1 on 2020-10-22 09:29
import cvat.apps.engine.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('engine', '0033_projects_adjastment'),
]
operations = [
migrations.AddField(
model_name='data',
name='storage',
field=models.CharField(
choices=[('local', 'LOCAL'), ('share', 'SHARE')],
default=cvat.apps.engine.models.StorageChoice['LOCAL'],
max_length=15
),
),
]

@ -0,0 +1,19 @@
# Generated by Django 3.1.1 on 2020-12-02 06:47
import cvat.apps.engine.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('engine', '0034_auto_20201125_1426'),
]
operations = [
migrations.AddField(
model_name='data',
name='storage',
field=models.CharField(choices=[('local', 'LOCAL'), ('share', 'SHARE')], default=cvat.apps.engine.models.StorageChoice['LOCAL'], max_length=15),
),
]

@ -276,6 +276,7 @@ class Segment(models.Model):
class Job(models.Model):
segment = models.ForeignKey(Segment, on_delete=models.CASCADE)
assignee = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
reviewer = models.ForeignKey(User, null=True, blank=True, related_name='review_job_set', on_delete=models.SET_NULL)
status = models.CharField(max_length=32, choices=StatusChoice.choices(),
default=StatusChoice.ANNOTATION)
@ -360,6 +361,18 @@ class SourceType(str, Enum):
def __str__(self):
return self.value
class ReviewStatus(str, Enum):
ACCEPTED = 'accepted'
REJECTED = 'rejected'
REVIEW_FURTHER = 'review_further'
@classmethod
def choices(self):
return tuple((x.value, x.name) for x in self)
def __str__(self):
return self.value
class Annotation(models.Model):
id = models.BigAutoField(primary_key=True)
job = models.ForeignKey(Job, on_delete=models.CASCADE)
@ -440,3 +453,31 @@ class TrackedShape(Shape):
class TrackedShapeAttributeVal(AttributeVal):
shape = models.ForeignKey(TrackedShape, on_delete=models.CASCADE)
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
rating = models.FloatField(default=0.0)
class Review(models.Model):
job = models.ForeignKey(Job, on_delete=models.CASCADE)
reviewer = models.ForeignKey(User, null=True, blank=True, related_name='reviews', on_delete=models.SET_NULL)
assignee = models.ForeignKey(User, null=True, blank=True, related_name='reviewed', on_delete=models.SET_NULL)
estimated_quality = models.FloatField()
status = models.CharField(max_length=16, choices=ReviewStatus.choices())
class Issue(models.Model):
frame = models.PositiveIntegerField()
position = FloatArrayField()
job = models.ForeignKey(Job, on_delete=models.CASCADE)
review = models.ForeignKey(Review, null=True, blank=True, on_delete=models.SET_NULL)
owner = models.ForeignKey(User, null=True, blank=True, related_name='issues', on_delete=models.SET_NULL)
resolver = models.ForeignKey(User, null=True, blank=True, related_name='resolved_issues', on_delete=models.SET_NULL)
created_date = models.DateTimeField(auto_now_add=True)
resolved_date = models.DateTimeField(null=True, blank=True)
class Comment(models.Model):
issue = models.ForeignKey(Issue, on_delete=models.CASCADE)
author = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
message = models.TextField(default='')
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)

@ -126,19 +126,25 @@ class JobSerializer(serializers.ModelSerializer):
stop_frame = serializers.ReadOnlyField(source="segment.stop_frame")
assignee = BasicUserSerializer(allow_null=True, required=False)
assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
reviewer = BasicUserSerializer(allow_null=True, required=False)
reviewer_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
class Meta:
model = models.Job
fields = ('url', 'id', 'assignee', 'assignee_id', 'status', 'start_frame',
'stop_frame', 'task_id')
fields = ('url', 'id', 'assignee', 'assignee_id', 'reviewer',
'reviewer_id', 'status', 'start_frame', 'stop_frame', 'task_id')
read_only_fields = ('assignee', 'reviewer')
class SimpleJobSerializer(serializers.ModelSerializer):
assignee = BasicUserSerializer(allow_null=True)
assignee_id = serializers.IntegerField(write_only=True, allow_null=True)
reviewer = BasicUserSerializer(allow_null=True, required=False)
reviewer_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
class Meta:
model = models.Job
fields = ('url', 'id', 'assignee', 'assignee_id', 'status')
fields = ('url', 'id', 'assignee', 'assignee_id', 'reviewer', 'reviewer_id', 'status')
read_only_fields = ('assignee', 'reviewer')
class SegmentSerializer(serializers.ModelSerializer):
jobs = SimpleJobSerializer(many=True, source='job_set')
@ -330,7 +336,7 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer):
'bug_tracker', 'created_date', 'updated_date', 'overlap',
'segment_size', 'status', 'labels', 'segments',
'data_chunk_size', 'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data')
read_only_fields = ('mode', 'created_date', 'updated_date', 'status', 'data_chunk_size', 'owner', 'asignee',
read_only_fields = ('mode', 'created_date', 'updated_date', 'status', 'data_chunk_size', 'owner', 'assignee',
'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data')
write_once_fields = ('overlap', 'segment_size', 'project_id')
ordering = ['-id']
@ -581,3 +587,64 @@ class LogEventSerializer(serializers.Serializer):
class AnnotationFileSerializer(serializers.Serializer):
annotation_file = serializers.FileField()
class ReviewSerializer(serializers.ModelSerializer):
assignee = BasicUserSerializer(allow_null=True, required=False)
assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
reviewer = BasicUserSerializer(allow_null=True, required=False)
reviewer_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
class Meta:
model = models.Review
fields = '__all__'
read_only_fields = ('id', 'assignee', 'reviewer', )
write_once_fields = ('job', 'reviewer_id', 'assignee_id', 'estimated_quality', 'status', )
ordering = ['-id']
class IssueSerializer(serializers.ModelSerializer):
owner = BasicUserSerializer(allow_null=True, required=False)
owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
resolver = BasicUserSerializer(allow_null=True, required=False)
resolver_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
position = serializers.ListField(
child=serializers.FloatField(),
allow_empty=False,
)
class Meta:
model = models.Issue
fields = '__all__'
read_only_fields = ('created_date', 'id', 'owner', 'resolver', )
write_once_fields = ('frame', 'position', 'job', 'owner_id', 'review', )
ordering = ['-id']
class CommentSerializer(serializers.ModelSerializer):
author = BasicUserSerializer(allow_null=True, required=False)
author_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
class Meta:
model = models.Comment
fields = '__all__'
read_only_fields = ('created_date', 'updated_date', 'id', 'author', )
write_once_fields = ('issue', 'author_id', )
class CombinedIssueSerializer(IssueSerializer):
comment_set = CommentSerializer(many=True)
class CombinedReviewSerializer(ReviewSerializer):
issue_set = CombinedIssueSerializer(many=True)
def create(self, validated_data):
issues_validated_data = validated_data.pop('issue_set')
db_review = models.Review.objects.create(**validated_data)
for issue in issues_validated_data:
issue['review'] = db_review
comments_validated_data = issue.pop('comment_set')
db_issue = models.Issue.objects.create(**issue)
for comment in comments_validated_data:
comment['issue'] = db_issue
models.Comment.objects.create(**comment)
return db_review

@ -5,12 +5,14 @@ import shutil
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import (
Data,
Job,
StatusChoice,
Task,
Profile,
)
@ -28,6 +30,12 @@ def update_task_status(instance, **kwargs):
db_task.status = status
db_task.save()
@receiver(post_save, sender=User, dispatch_uid="create_a_profile_on_create_a_user")
def create_profile(instance, **kwargs):
if not hasattr(instance, 'profile'):
profile = Profile()
profile.user = instance
profile.save()
@receiver(post_delete, sender=Task, dispatch_uid="delete_task_files_on_delete_task")
def delete_task_files_on_delete_task(instance, **kwargs):

@ -360,6 +360,227 @@ class JobPartialUpdateAPITestCase(JobUpdateAPITestCase):
response = self._run_api_v1_jobs_id(self.job.id, self.owner, data)
self._check_request(response, data)
class JobReview(APITestCase):
def setUp(self):
self.client = APIClient()
@classmethod
def setUpTestData(cls):
create_db_users(cls)
cls.task = create_dummy_db_tasks(cls)[0]
cls.job = Job.objects.filter(segment__task_id=cls.task.id).first()
cls.reviewer = cls.annotator
cls.job.reviewer = cls.reviewer
cls.job.assignee = cls.assignee
cls.job.save()
cls.reject_review_data = {
"job": cls.job.id,
"issue_set": [
{
"position": [
50, 50, 100, 100
],
"comment_set": [
{
"message": "This is wrong!"
}, {
"message": "This is wrong 2!"
}
],
"frame": 0
}
],
"estimated_quality": 3,
"status": "rejected"
}
cls.accept_review_data = {
"job": cls.job.id,
"issue_set": [],
"estimated_quality": 5,
"status": "accepted"
}
cls.review_further_data = {
"job": cls.job.id,
"issue_set": [],
"estimated_quality": 4,
"status": "review_further",
"reviewer_id": cls.reviewer.id
}
cls.create_comment_data = [{
"message": "This is testing message"
}, {
"message": "This is testing message 2"
}, {
"message": "This is testing message 3"
}]
def _post_request(self, path, user, data):
with ForceLogin(user, self.client):
response = self.client.post(path, data=data, format='json')
return response
def _patch_request(self, path, user, data):
with ForceLogin(user, self.client):
response = self.client.patch(path, data=data, format='json')
return response
def _get_request(self, path, user):
with ForceLogin(user, self.client):
response = self.client.get(path)
return response
def _delete_request(self, path, user):
with ForceLogin(user, self.client):
response = self.client.delete(path)
return response
def _fetch_job_from_db(self):
self.job = Job.objects.prefetch_related(
'review_set',
'review_set__issue_set',
'review_set__issue_set__comment_set').filter(segment__task_id=self.task.id).first()
def _set_annotation_status(self):
self._patch_request('/api/v1/jobs/{}'.format(self.job.id), self.admin, {'status': 'annotation'})
def _set_validation_status(self):
self._patch_request('/api/v1/jobs/{}'.format(self.job.id), self.admin, {'status': 'validation'})
def test_api_v1_job_annotation_review(self):
self._set_annotation_status()
response = self._post_request('/api/v1/reviews', self.reviewer, self.accept_review_data)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
response = self._post_request('/api/v1/reviews', self.assignee, self.accept_review_data)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_api_v1_job_validation_review_create(self):
self._set_validation_status()
response = self._post_request('/api/v1/reviews', self.reviewer, self.accept_review_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self._fetch_job_from_db()
self.assertEqual(self.job.status, 'completed')
response = self._post_request('/api/v1/reviews', self.assignee, self.accept_review_data)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.job.review_set.first().delete()
def test_api_v1_job_reject_review(self):
self._set_validation_status()
response = self._post_request('/api/v1/reviews', self.reviewer, self.reject_review_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self._fetch_job_from_db()
self.assertEqual(self.job.status, 'annotation')
self.job.review_set.first().delete()
def test_api_v1_job_review_further(self):
self._set_validation_status()
response = self._post_request('/api/v1/reviews', self.reviewer, self.review_further_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self._fetch_job_from_db()
self.assertEqual(self.job.status, 'validation')
self.job.review_set.first().delete()
def test_api_v1_create_review_comment(self):
self._set_validation_status()
response = self._post_request('/api/v1/reviews', self.reviewer, self.reject_review_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
issue_id = response.data['issue_set'][0]['id']
comments = self.create_comment_data[:]
for comment in comments:
comment.update({
'issue': issue_id
})
response = self._post_request('/api/v1/comments', self.assignee, comment)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
response = self._get_request('/api/v1/issues/{}/comments'.format(issue_id), self.reviewer)
self.assertIsInstance(response.data, cls = list)
self.assertEqual(len(response.data), 5)
self.job.review_set.all().delete()
self.job.issue_set.all().delete()
def test_api_v1_edit_review_comment(self):
self._set_validation_status()
response = self._post_request('/api/v1/reviews', self.reviewer, self.reject_review_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
issue_id = response.data['issue_set'][0]['id']
comments = self.create_comment_data[:]
for comment in comments:
comment.update({
'issue': issue_id
})
response = self._post_request('/api/v1/comments', self.assignee, comment)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
response = self._get_request('/api/v1/issues/{}/comments'.format(issue_id), self.reviewer)
last_comment = max(response.data, key=lambda comment: comment['id'])
last_comment.update({
'message': 'fixed message 3'
})
last_comment.update({
'author_id': last_comment['author']['id'],
'author': None
})
response = self._patch_request('/api/v1/comments/{}'.format(last_comment['id']), self.reviewer, last_comment)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
response = self._patch_request('/api/v1/comments/{}'.format(last_comment['id']), self.assignee, last_comment)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['message'], last_comment['message'])
response = self._get_request('/api/v1/issues/{}/comments'.format(issue_id), self.reviewer)
updated_last_comment = max(response.data, key=lambda comment: comment['id'])
self.assertEqual(updated_last_comment['message'], last_comment['message'])
self.job.review_set.all().delete()
self.job.issue_set.all().delete()
def test_api_v1_remove_comment(self):
self._set_validation_status()
response = self._post_request('/api/v1/reviews', self.reviewer, self.reject_review_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
issue_id = response.data['issue_set'][0]['id']
comments = self.create_comment_data[:]
for comment in comments:
comment.update({
'issue': issue_id
})
response = self._post_request('/api/v1/comments', self.assignee, comment)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
response = self._get_request('/api/v1/issues/{}/comments'.format(issue_id), self.reviewer)
last_comment = max(response.data, key=lambda comment: comment['id'])
response = self._delete_request('/api/v1/comments/{}'.format(last_comment['id']), self.reviewer)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
response = self._delete_request('/api/v1/comments/{}'.format(last_comment['id']), self.assignee)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self._fetch_job_from_db()
ids = list(map(lambda comment: comment.id, self.job.issue_set.first().comment_set.all()))
self.assertNotIn(last_comment['id'], ids)
self.job.review_set.all().delete()
self.job.issue_set.all().delete()
def test_api_v1_resolve_reopen_issue(self):
self._set_validation_status()
response = self._post_request('/api/v1/reviews', self.reviewer, self.reject_review_data)
response = self._get_request('/api/v1/jobs/{}/issues'.format(self.job.id), self.assignee)
issue_id = response.data[0]['id']
response = self._patch_request('/api/v1/issues/{}'.format(issue_id), self.assignee, {'resolver_id': self.assignee.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self._get_request('/api/v1/jobs/{}/issues'.format(self.job.id), self.assignee)
self.assertEqual(response.data[0]['resolver']['id'], self.assignee.id)
response = self._patch_request('/api/v1/issues/{}'.format(issue_id), self.reviewer, {'resolver_id': None})
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self._get_request('/api/v1/jobs/{}/issues'.format(self.job.id), self.assignee)
self.assertEqual(response.data[0]['resolver'], None)
response = self._patch_request('/api/v1/issues/{}'.format(issue_id), self.reviewer, {'resolver_id': self.reviewer.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self._get_request('/api/v1/jobs/{}/issues'.format(self.job.id), self.reviewer)
self.assertEqual(response.data[0]['resolver']['id'], self.reviewer.id)
class ServerAboutAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
@ -1476,12 +1697,13 @@ def generate_image_files(*args):
return image_sizes, images
def generate_video_file(filename, width=1920, height=1080, duration=1, fps=25):
def generate_video_file(filename, width=1920, height=1080, duration=1, fps=25, codec_name='mpeg4'):
f = BytesIO()
total_frames = duration * fps
container = av.open(f, mode='w', format='mp4')
file_ext = os.path.splitext(filename)[1][1:]
container = av.open(f, mode='w', format=file_ext)
stream = container.add_stream('mpeg4', rate=fps)
stream = container.add_stream(codec_name=codec_name, rate=fps)
stream.width = width
stream.height = height
stream.pix_fmt = 'yuv420p'
@ -2176,6 +2398,23 @@ class TaskDataAPITestCase(APITestCase):
self._test_api_v1_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.IMAGESET,
self.ChunkType.VIDEO, image_sizes, StorageMethodChoice.CACHE)
task_spec = {
"name": "test mxf format",
"use_zip_chunks": False,
"labels": [
{"name": "car"},
{"name": "person"},
],
}
image_sizes, video = generate_video_file(filename="test_video_1.mxf", width=1280, height=720, codec_name='mpeg2video')
task_data = {
"client_files[0]": video,
"image_quality": 51,
}
self._test_api_v1_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.VIDEO, self.ChunkType.VIDEO, image_sizes)
def test_api_v1_tasks_id_data_admin(self):
self._test_api_v1_tasks_id_data(self.admin)

@ -49,6 +49,9 @@ router.register('tasks', views.TaskViewSet)
router.register('jobs', views.JobViewSet)
router.register('users', views.UserViewSet)
router.register('server', views.ServerViewSet, basename='server')
router.register('reviews', views.ReviewViewSet)
router.register('issues', views.IssueViewSet)
router.register('comments', views.CommentViewSet)
router.register('restrictions', RestrictionsViewSet, basename='restrictions')
urlpatterns = [

@ -11,6 +11,7 @@ from distutils.util import strtobool
from tempfile import mkstemp
import django_rq
from django.shortcuts import get_object_or_404
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
@ -37,15 +38,16 @@ from cvat.apps.authentication import auth
from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer
from cvat.apps.engine.frame_provider import FrameProvider
from cvat.apps.engine.models import (
Job, StatusChoice, Task, Project, StorageMethodChoice, StorageChoice
Job, StatusChoice, Task, Project, Review, Issue,
Comment, StorageMethodChoice, ReviewStatus, StorageChoice
)
from cvat.apps.engine.serializers import (
AboutSerializer, AnnotationFileSerializer, BasicUserSerializer,
DataMetaSerializer, DataSerializer, ExceptionSerializer,
FileInfoSerializer, JobSerializer, LabeledDataSerializer,
LogEventSerializer, ProjectSerializer, ProjectSearchSerializer,
RqStatusSerializer, TaskSerializer, UserSerializer,
PluginsSerializer,
LogEventSerializer, ProjectSerializer, ProjectSearchSerializer, RqStatusSerializer,
TaskSerializer, UserSerializer, PluginsSerializer, ReviewSerializer,
CombinedReviewSerializer, IssueSerializer, CombinedIssueSerializer, CommentSerializer
)
from cvat.apps.engine.utils import av_scan_paths
@ -674,7 +676,7 @@ class JobViewSet(viewsets.GenericViewSet,
if http_method in SAFE_METHODS:
permissions.append(auth.JobAccessPermission)
elif http_method in ["PATCH", "PUT", "DELETE"]:
elif http_method in ['PATCH', 'PUT', 'DELETE']:
permissions.append(auth.JobChangePermission)
else:
permissions.append(auth.AdminRolePermission)
@ -729,12 +731,174 @@ class JobViewSet(viewsets.GenericViewSet,
return Response(data=str(e), status=status.HTTP_400_BAD_REQUEST)
return Response(data)
@swagger_auto_schema(method='get', operation_summary='Method returns list of reviews for the job',
responses={'200': ReviewSerializer(many=True)}
)
@action(detail=True, methods=['GET'], serializer_class=ReviewSerializer)
def reviews(self, request, pk):
db_job = self.get_object()
queryset = db_job.review_set
serializer = ReviewSerializer(queryset, context={'request': request}, many=True)
return Response(serializer.data)
@swagger_auto_schema(method='get', operation_summary='Method returns list of issues for the job',
responses={'200': CombinedIssueSerializer(many=True)}
)
@action(detail=True, methods=['GET'], serializer_class=CombinedIssueSerializer)
def issues(self, request, pk):
db_job = self.get_object()
queryset = db_job.issue_set
serializer = CombinedIssueSerializer(queryset, context={'request': request}, many=True)
return Response(serializer.data)
@method_decorator(name='create', decorator=swagger_auto_schema(operation_summary='Submit a review for a job'))
@method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method removes a review from a job'))
class ReviewViewSet(viewsets.GenericViewSet, mixins.DestroyModelMixin, mixins.CreateModelMixin):
queryset = Review.objects.all().order_by('id')
def get_serializer_class(self):
if self.request.method == 'POST':
return CombinedReviewSerializer
else:
return ReviewSerializer
def get_permissions(self):
permissions = [IsAuthenticated]
if self.request.method == 'POST':
permissions.append(auth.JobReviewPermission)
else:
permissions.append(auth.AdminRolePermission)
return [perm() for perm in permissions]
def create(self, request, *args, **kwargs):
job_id = request.data['job']
db_job = get_object_or_404(Job, pk=job_id)
self.check_object_permissions(self.request, db_job)
if request.data['status'] == ReviewStatus.REVIEW_FURTHER:
if 'reviewer_id' not in request.data:
return Response('Must provide a new reviewer', status=status.HTTP_400_BAD_REQUEST)
reviewer_id = request.data['reviewer_id']
reviewer = get_object_or_404(User, pk=reviewer_id)
request.data.update({
'reviewer_id': request.user.id,
})
if db_job.assignee:
request.data.update({
'assignee_id': db_job.assignee.id,
})
issue_set = request.data['issue_set']
for issue in issue_set:
issue['job'] = db_job.id
issue['owner_id'] = request.user.id
comment_set = issue['comment_set']
for comment in comment_set:
comment['author_id'] = request.user.id
serializer = self.get_serializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
if serializer.data['status'] == ReviewStatus.ACCEPTED:
db_job.status = StatusChoice.COMPLETED
db_job.save()
elif serializer.data['status'] == ReviewStatus.REJECTED:
db_job.status = StatusChoice.ANNOTATION
db_job.save()
else:
db_job.reviewer = reviewer
db_job.save()
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method removes an issue from a job'))
@method_decorator(name='partial_update', decorator=swagger_auto_schema(operation_summary='Method updates an issue. It is used to resolve/reopen an issue'))
class IssueViewSet(viewsets.GenericViewSet, mixins.DestroyModelMixin, mixins.UpdateModelMixin):
queryset = Issue.objects.all().order_by('id')
http_method_names = ['get', 'patch', 'delete', 'options']
def get_serializer_class(self):
return IssueSerializer
def partial_update(self, request, *args, **kwargs):
db_issue = self.get_object()
if 'resolver_id' in request.data and request.data['resolver_id'] and db_issue.resolver is None:
# resolve
db_issue.resolver = request.user
db_issue.resolved_date = datetime.now()
db_issue.save(update_fields=['resolver', 'resolved_date'])
elif 'resolver_id' in request.data and not request.data['resolver_id'] and db_issue.resolver is not None:
# reopen
db_issue.resolver = None
db_issue.resolved_date = None
db_issue.save(update_fields=['resolver', 'resolved_date'])
serializer = self.get_serializer(db_issue)
return Response(serializer.data)
def get_permissions(self):
http_method = self.request.method
permissions = [IsAuthenticated]
if http_method in SAFE_METHODS:
permissions.append(auth.IssueAccessPermission)
elif http_method in ['DELETE']:
permissions.append(auth.IssueDestroyPermission)
elif http_method in ['PATCH']:
permissions.append(auth.IssueChangePermission)
else:
permissions.append(auth.AdminRolePermission)
return [perm() for perm in permissions]
@swagger_auto_schema(method='get', operation_summary='The action returns all comments of a specific issue',
responses={'200': CommentSerializer(many=True)}
)
@action(detail=True, methods=['GET'], serializer_class=CommentSerializer)
def comments(self, request, pk):
db_issue = self.get_object()
queryset = db_issue.comment_set
serializer = CommentSerializer(queryset, context={'request': request}, many=True)
return Response(serializer.data)
@method_decorator(name='partial_update', decorator=swagger_auto_schema(operation_summary='Method updates comment in an issue'))
@method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method removes a comment from an issue'))
class CommentViewSet(viewsets.GenericViewSet,
mixins.DestroyModelMixin, mixins.UpdateModelMixin, mixins.CreateModelMixin):
queryset = Comment.objects.all().order_by('id')
serializer_class = CommentSerializer
http_method_names = ['get', 'post', 'patch', 'delete', 'options']
def create(self, request, *args, **kwargs):
request.data.update({
'author_id': request.user.id,
})
issue_id = request.data['issue']
db_issue = get_object_or_404(Issue, pk=issue_id)
self.check_object_permissions(self.request, db_issue.job)
return super().create(request, args, kwargs)
def get_permissions(self):
http_method = self.request.method
permissions = [IsAuthenticated]
if http_method in ['PATCH', 'DELETE']:
permissions.append(auth.CommentChangePermission)
elif http_method in ['POST']:
permissions.append(auth.CommentCreatePermission)
else:
permissions.append(auth.AdminRolePermission)
return [perm() for perm in permissions]
class UserFilter(filters.FilterSet):
class Meta:
model = User
fields = ("id",)
@method_decorator(name='list', decorator=swagger_auto_schema(
manual_parameters=[
openapi.Parameter('id',openapi.IN_QUERY,description="A unique number value identifying this user",type=openapi.TYPE_NUMBER),

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

Loading…
Cancel
Save