diff --git a/.vscode/settings.json b/.vscode/settings.json index 7efcd4e6a035..5718c4b7c14d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,5 @@ { "python.pythonPath": ".env/bin/python", - "eslint.enable": true, "eslint.probe": [ "javascript", "typescript", @@ -19,8 +18,10 @@ "!cwd": true } ], + "npm.exclude": "**/.env/**", + "python.linting.enabled": true, "python.linting.pylintEnabled": true, - "python.testing.unittestEnabled": true, + "python.linting.pycodestyleEnabled": false, "licenser.license": "Custom", "licenser.customHeader": "Copyright (C) @YEAR@ Intel Corporation\n\nSPDX-License-Identifier: MIT", "files.trimTrailingWhitespace": true diff --git a/CHANGELOG.md b/CHANGELOG.md index e9101a0475af..ce9415bb3961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [WiderFace](http://shuoyang1213.me/WIDERFACE/) format support () - [VGGFace2](https://github.com/ox-vgg/vgg_face2) format support () - [Backup/Restore guide](cvat/apps/documentation/backup_guide.md) () +- Label deletion from tasks and projects () ### Changed diff --git a/cvat-core/src/labels.js b/cvat-core/src/labels.js index 7e673245d119..e78224694d3a 100644 --- a/cvat-core/src/labels.js +++ b/cvat-core/src/labels.js @@ -133,6 +133,7 @@ id: undefined, name: undefined, color: undefined, + deleted: false, }; for (const key in data) { @@ -208,6 +209,12 @@ attributes: { get: () => [...data.attributes], }, + deleted: { + get: () => data.deleted, + set: (value) => { + data.deleted = value; + }, + }, }), ); } @@ -223,6 +230,10 @@ object.id = this.id; } + if (this.deleted) { + object.deleted = this.deleted; + } + return object; } } diff --git a/cvat-core/src/project.js b/cvat-core/src/project.js index ba06b1fe3154..f389205c6f48 100644 --- a/cvat-core/src/project.js +++ b/cvat-core/src/project.js @@ -186,7 +186,13 @@ ); } - data.labels = [...labels]; + const IDs = labels.map((_label) => _label.id); + const deletedLabels = data.labels.filter((_label) => !IDs.includes(_label.id)); + deletedLabels.forEach((_label) => { + _label.deleted = true; + }); + + data.labels = [...deletedLabels, ...labels]; }, }, /** @@ -211,6 +217,9 @@ subsets: { get: () => [...data.task_subsets], }, + _internalData: { + get: () => data, + }, }), ); } @@ -257,7 +266,7 @@ name: this.name, assignee_id: this.assignee ? this.assignee.id : null, bug_tracker: this.bugTracker, - labels: [...this.labels.map((el) => el.toJSON())], + labels: [...this._internalData.labels.map((el) => el.toJSON())], }; await serverProxy.projects.save(this.id, projectData); diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index ddc7b7a22eaf..fbc708e842af 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -1304,7 +1304,7 @@ * @throws {module:API.cvat.exceptions.ArgumentError} */ labels: { - get: () => [...data.labels], + get: () => data.labels.filter((_label) => !_label.deleted), set: (labels) => { if (!Array.isArray(labels)) { throw new ArgumentError('Value must be an array of Labels'); @@ -1318,8 +1318,14 @@ } } + const IDs = labels.map((_label) => _label.id); + const deletedLabels = data.labels.filter((_label) => !IDs.includes(_label.id)); + deletedLabels.forEach((_label) => { + _label.deleted = true; + }); + updatedFields.labels = true; - data.labels = [...labels]; + data.labels = [...deletedLabels, ...labels]; }, }, /** @@ -1485,12 +1491,6 @@ dataChunkType: { get: () => data.data_compressed_chunk_type, }, - __updatedFields: { - get: () => updatedFields, - set: (fields) => { - updatedFields = fields; - }, - }, dimension: { /** * @name enabled @@ -1501,6 +1501,15 @@ */ get: () => data.dimension, }, + _internalData: { + get: () => data, + }, + __updatedFields: { + get: () => updatedFields, + set: (fields) => { + updatedFields = fields; + }, + }, }), ); @@ -1920,7 +1929,7 @@ taskData.subset = this.subset; break; case 'labels': - taskData.labels = [...this.labels.map((el) => el.toJSON())]; + taskData.labels = [...this._internalData.labels.map((el) => el.toJSON())]; break; default: break; diff --git a/cvat-core/tests/mocks/server-proxy.mock.js b/cvat-core/tests/mocks/server-proxy.mock.js index d52f3aede177..4357e1769efa 100644 --- a/cvat-core/tests/mocks/server-proxy.mock.js +++ b/cvat-core/tests/mocks/server-proxy.mock.js @@ -1,4 +1,4 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2020-2021 Intel Corporation // // SPDX-License-Identifier: MIT @@ -97,7 +97,11 @@ class ServerProxy { Object.prototype.hasOwnProperty.call(projectData, prop) && Object.prototype.hasOwnProperty.call(object, prop) ) { - object[prop] = projectData[prop]; + if (prop === 'labels') { + object[prop] = projectData[prop].filter((label) => !label.deleted); + } else { + object[prop] = projectData[prop]; + } } } } @@ -156,7 +160,11 @@ class ServerProxy { Object.prototype.hasOwnProperty.call(taskData, prop) && Object.prototype.hasOwnProperty.call(object, prop) ) { - object[prop] = taskData[prop]; + if (prop === 'labels') { + object[prop] = taskData[prop].filter((label) => !label.deleted); + } else { + object[prop] = taskData[prop]; + } } } } diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 194edc1c06ae..40cc070d5e58 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: MIT +import { MutableRefObject } from 'react'; import { AnyAction, Dispatch, ActionCreator, Store, } from 'redux'; @@ -26,7 +27,6 @@ import getCore from 'cvat-core-wrapper'; import logger, { LogType } from 'cvat-logger'; import { RectDrawingMethod } from 'cvat-canvas-wrapper'; import { getCVATStore } from 'cvat-store'; -import { MutableRefObject } from 'react'; interface AnnotationsParameters { filters: string[]; @@ -919,10 +919,6 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init throw new Error(`Task ${tid} doesn't contain the job ${jid}`); } - if (!task.labels.length && task.projectId) { - throw new Error(`Project ${task.projectId} does not contain any label`); - } - const frameNumber = Math.max(Math.min(job.stopFrame, initialFrame), job.startFrame); const frameData = await job.frames.get(frameNumber); // call first getting of frame data before rendering interface diff --git a/cvat-ui/src/components/annotation-page/annotation-page.tsx b/cvat-ui/src/components/annotation-page/annotation-page.tsx index 484998597582..92410833bf7e 100644 --- a/cvat-ui/src/components/annotation-page/annotation-page.tsx +++ b/cvat-ui/src/components/annotation-page/annotation-page.tsx @@ -8,8 +8,10 @@ import { useHistory } from 'react-router'; import Layout from 'antd/lib/layout'; import Spin from 'antd/lib/spin'; import Result from 'antd/lib/result'; +import notification from 'antd/lib/notification'; import { Workspace } from 'reducers/interfaces'; +import { usePrevious } from 'utils/hooks'; import AnnotationTopBarContainer from 'containers/annotation-page/top-bar/top-bar'; import StatisticsModalContainer from 'containers/annotation-page/top-bar/statistics-modal'; import StandardWorkspaceComponent from 'components/annotation-page/standard-workspace/standard-workspace'; @@ -33,6 +35,8 @@ export default function AnnotationPageComponent(props: Props): JSX.Element { const { job, fetching, getJob, closeJob, saveLogs, workspace, } = props; + const prevJob = usePrevious(job); + const prevFetching = usePrevious(fetching); const history = useHistory(); useEffect(() => { @@ -60,6 +64,26 @@ export default function AnnotationPageComponent(props: Props): JSX.Element { } }, [job, fetching]); + useEffect(() => { + if (prevFetching && !fetching && !prevJob && job && !job.task.labels.length) { + notification.warning({ + message: 'No labels', + description: ( + + {`${job.task.projectId ? 'Project' : 'Task'} ${ + job.task.projectId || job.task.id + } does not contain any label. `} + + Add + + {' the first one for editing annotation.'} + + ), + placement: 'topRight', + }); + } + }, [job, fetching, prevJob, prevFetching]); + if (job === null) { return ; } diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index 2ac25ef204dd..87017d5a5bd1 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -3,8 +3,8 @@ // SPDX-License-Identifier: MIT import React from 'react'; -import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; import Layout from 'antd/lib/layout'; +import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; import { ActiveControl, Rotation } from 'reducers/interfaces'; import { Canvas } from 'cvat-canvas-wrapper'; @@ -32,6 +32,7 @@ interface Props { activeControl: ActiveControl; keyMap: KeyMap; normalizedKeyMap: Record; + labels: any[]; mergeObjects(enabled: boolean): void; groupObjects(enabled: boolean): void; @@ -64,10 +65,11 @@ const ObservedSplitControl = ControlVisibilityObserver(SplitC export default function ControlsSideBarComponent(props: Props): JSX.Element { const { - canvasInstance, activeControl, + canvasInstance, normalizedKeyMap, keyMap, + labels, mergeObjects, groupObjects, splitTrack, @@ -84,93 +86,13 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { } }; - const subKeyMap = { - PASTE_SHAPE: keyMap.PASTE_SHAPE, - SWITCH_DRAW_MODE: keyMap.SWITCH_DRAW_MODE, - SWITCH_MERGE_MODE: keyMap.SWITCH_MERGE_MODE, - SWITCH_SPLIT_MODE: keyMap.SWITCH_SPLIT_MODE, - SWITCH_GROUP_MODE: keyMap.SWITCH_GROUP_MODE, - RESET_GROUP: keyMap.RESET_GROUP, + let subKeyMap: any = { CANCEL: keyMap.CANCEL, CLOCKWISE_ROTATION: keyMap.CLOCKWISE_ROTATION, ANTICLOCKWISE_ROTATION: keyMap.ANTICLOCKWISE_ROTATION, }; - const handlers = { - PASTE_SHAPE: (event: KeyboardEvent | undefined) => { - preventDefault(event); - canvasInstance.cancel(); - pasteShape(); - }, - SWITCH_DRAW_MODE: (event: KeyboardEvent | undefined) => { - preventDefault(event); - const drawing = [ - ActiveControl.DRAW_POINTS, - ActiveControl.DRAW_POLYGON, - ActiveControl.DRAW_POLYLINE, - ActiveControl.DRAW_RECTANGLE, - ActiveControl.DRAW_CUBOID, - ActiveControl.AI_TOOLS, - ActiveControl.OPENCV_TOOLS, - ].includes(activeControl); - - if (!drawing) { - canvasInstance.cancel(); - // repeateDrawShapes gets all the latest parameters - // and calls canvasInstance.draw() with them - - if (event && event.shiftKey) { - redrawShape(); - } else { - repeatDrawShape(); - } - } else { - if ([ActiveControl.AI_TOOLS, ActiveControl.OPENCV_TOOLS].includes(activeControl)) { - // separated API method - canvasInstance.interact({ enabled: false }); - return; - } - - canvasInstance.draw({ enabled: false }); - } - }, - SWITCH_MERGE_MODE: (event: KeyboardEvent | undefined) => { - preventDefault(event); - const merging = activeControl === ActiveControl.MERGE; - if (!merging) { - canvasInstance.cancel(); - } - canvasInstance.merge({ enabled: !merging }); - mergeObjects(!merging); - }, - SWITCH_SPLIT_MODE: (event: KeyboardEvent | undefined) => { - preventDefault(event); - const splitting = activeControl === ActiveControl.SPLIT; - if (!splitting) { - canvasInstance.cancel(); - } - canvasInstance.split({ enabled: !splitting }); - splitTrack(!splitting); - }, - SWITCH_GROUP_MODE: (event: KeyboardEvent | undefined) => { - preventDefault(event); - const grouping = activeControl === ActiveControl.GROUP; - if (!grouping) { - canvasInstance.cancel(); - } - canvasInstance.group({ enabled: !grouping }); - groupObjects(!grouping); - }, - RESET_GROUP: (event: KeyboardEvent | undefined) => { - preventDefault(event); - const grouping = activeControl === ActiveControl.GROUP; - if (!grouping) { - return; - } - resetGroup(); - canvasInstance.group({ enabled: false }); - groupObjects(false); - }, + let handlers: any = { CANCEL: (event: KeyboardEvent | undefined) => { preventDefault(event); if (activeControl !== ActiveControl.CURSOR) { @@ -187,6 +109,95 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { }, }; + if (labels.length) { + handlers = { + ...handlers, + PASTE_SHAPE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + canvasInstance.cancel(); + pasteShape(); + }, + SWITCH_DRAW_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const drawing = [ + ActiveControl.DRAW_POINTS, + ActiveControl.DRAW_POLYGON, + ActiveControl.DRAW_POLYLINE, + ActiveControl.DRAW_RECTANGLE, + ActiveControl.DRAW_CUBOID, + ActiveControl.AI_TOOLS, + ActiveControl.OPENCV_TOOLS, + ].includes(activeControl); + + if (!drawing) { + canvasInstance.cancel(); + // repeateDrawShapes gets all the latest parameters + // and calls canvasInstance.draw() with them + + if (event && event.shiftKey) { + redrawShape(); + } else { + repeatDrawShape(); + } + } else { + if ([ActiveControl.AI_TOOLS, ActiveControl.OPENCV_TOOLS].includes(activeControl)) { + // separated API method + canvasInstance.interact({ enabled: false }); + return; + } + + canvasInstance.draw({ enabled: false }); + } + }, + SWITCH_MERGE_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const merging = activeControl === ActiveControl.MERGE; + if (!merging) { + canvasInstance.cancel(); + } + canvasInstance.merge({ enabled: !merging }); + mergeObjects(!merging); + }, + SWITCH_SPLIT_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const splitting = activeControl === ActiveControl.SPLIT; + if (!splitting) { + canvasInstance.cancel(); + } + canvasInstance.split({ enabled: !splitting }); + splitTrack(!splitting); + }, + SWITCH_GROUP_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const grouping = activeControl === ActiveControl.GROUP; + if (!grouping) { + canvasInstance.cancel(); + } + canvasInstance.group({ enabled: !grouping }); + groupObjects(!grouping); + }, + RESET_GROUP: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const grouping = activeControl === ActiveControl.GROUP; + if (!grouping) { + return; + } + resetGroup(); + canvasInstance.group({ enabled: false }); + groupObjects(false); + }, + }; + subKeyMap = { + ...subKeyMap, + PASTE_SHAPE: keyMap.PASTE_SHAPE, + SWITCH_DRAW_MODE: keyMap.SWITCH_DRAW_MODE, + SWITCH_MERGE_MODE: keyMap.SWITCH_MERGE_MODE, + SWITCH_SPLIT_MODE: keyMap.SWITCH_SPLIT_MODE, + SWITCH_GROUP_MODE: keyMap.SWITCH_GROUP_MODE, + RESET_GROUP: keyMap.RESET_GROUP, + }; + } + return ( @@ -213,24 +224,29 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { - +
@@ -239,6 +255,7 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { canvasInstance={canvasInstance} activeControl={activeControl} mergeObjects={mergeObjects} + disabled={!labels.length} /> diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-cuboid-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-cuboid-control.tsx index 50e28fecae54..29e5ee0f0d56 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-cuboid-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-cuboid-control.tsx @@ -17,11 +17,12 @@ import withVisibilityHandling from './handle-popover-visibility'; export interface Props { canvasInstance: Canvas; isDrawing: boolean; + disabled?: boolean; } const CustomPopover = withVisibilityHandling(Popover, 'draw-cuboid'); function DrawPolygonControl(props: Props): JSX.Element { - const { canvasInstance, isDrawing } = props; + const { canvasInstance, isDrawing, disabled } = props; const dynamcPopoverPros = isDrawing ? { overlayStyle: { @@ -41,7 +42,9 @@ function DrawPolygonControl(props: Props): JSX.Element { className: 'cvat-draw-cuboid-control', }; - return ( + return disabled ? ( + + ) : ( + ) : ( + ) : ( + ) : ( + ) : ( + ) : ( diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/merge-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/merge-control.tsx index e1c96c3baf82..804aa38bf5c8 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/merge-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/merge-control.tsx @@ -14,12 +14,13 @@ export interface Props { canvasInstance: Canvas; activeControl: ActiveControl; switchMergeShortcut: string; + disabled?: boolean; mergeObjects(enabled: boolean): void; } function MergeControl(props: Props): JSX.Element { const { - switchMergeShortcut, activeControl, canvasInstance, mergeObjects, + switchMergeShortcut, activeControl, canvasInstance, mergeObjects, disabled, } = props; const dynamicIconProps = @@ -40,7 +41,9 @@ function MergeControl(props: Props): JSX.Element { }, }; - return ( + return disabled ? ( + + ) : ( diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/opencv-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/opencv-control.tsx index 8bb4ea8740c8..d8c9c7615ddf 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/opencv-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/opencv-control.tsx @@ -108,7 +108,7 @@ class OpenCVControlComponent extends React.PureComponent { canvasInstance.interact({ enabled: false }); }, @@ -403,7 +403,9 @@ class OpenCVControlComponent extends React.PureComponent + ) : ( + ) : ( }> diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/split-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/split-control.tsx index c68774596986..4cc8057f7c4e 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/split-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/split-control.tsx @@ -14,12 +14,13 @@ export interface Props { canvasInstance: Canvas; activeControl: ActiveControl; switchSplitShortcut: string; + disabled?: boolean; splitTrack(enabled: boolean): void; } function SplitControl(props: Props): JSX.Element { const { - switchSplitShortcut, activeControl, canvasInstance, splitTrack, + switchSplitShortcut, activeControl, canvasInstance, splitTrack, disabled, } = props; const dynamicIconProps = @@ -40,7 +41,9 @@ function SplitControl(props: Props): JSX.Element { }, }; - return ( + return disabled ? ( + + ) : ( diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx index 8ff6aae647bb..f19971f15225 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx @@ -111,7 +111,7 @@ export class ToolsControlComponent extends React.PureComponent { this.state = { activeInteractor: props.interactors.length ? props.interactors[0] : null, activeTracker: props.trackers.length ? props.trackers[0] : null, - activeLabelID: props.labels[0].id, + activeLabelID: props.labels.length ? props.labels[0].id : null, interactiveStateID: null, trackingProgress: null, trackingFrames: 10, @@ -239,7 +239,7 @@ export class ToolsControlComponent extends React.PureComponent { const object = new core.classes.ObjectState({ frame, objectType: ObjectType.SHAPE, - label: labels.filter((label: any) => label.id === activeLabelID)[0], + label: labels.length ? labels.filter((label: any) => label.id === activeLabelID)[0] : null, shapeType: ShapeType.POLYGON, points: result.flat(), occluded: false, @@ -257,7 +257,7 @@ export class ToolsControlComponent extends React.PureComponent { const object = new core.classes.ObjectState({ frame, objectType: ObjectType.SHAPE, - label: labels.filter((label: any) => label.id === activeLabelID)[0], + label: labels.length ? labels.filter((label: any) => label.id === activeLabelID)[0] : null, shapeType: ShapeType.POLYGON, points: result.flat(), occluded: false, @@ -716,7 +716,7 @@ export class ToolsControlComponent extends React.PureComponent { public render(): JSX.Element | null { const { - interactors, detectors, trackers, isActivated, canvasInstance, + interactors, detectors, trackers, isActivated, canvasInstance, labels, } = this.props; const { fetching, trackingProgress } = this.state; @@ -732,7 +732,7 @@ export class ToolsControlComponent extends React.PureComponent { const dynamicIconProps = isActivated ? { - className: 'cvat-active-canvas-control cvat-tools-control', + className: 'cvat-tools-control cvat-active-canvas-control', onClick: (): void => { canvasInstance.interact({ enabled: false }); }, @@ -741,7 +741,9 @@ export class ToolsControlComponent extends React.PureComponent { className: 'cvat-tools-control', }; - return ( + return !labels.length ? ( + + ) : ( <> svg { + filter: opacity(0.45); +} + .cvat-rotate-canvas-controls-left, .cvat-rotate-canvas-controls-right { transform: scale(0.65); diff --git a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/styles.scss b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/styles.scss index 0f15c96830e3..ce04f7048cae 100644 --- a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/styles.scss +++ b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/styles.scss @@ -45,3 +45,7 @@ padding: 5px 10px; } } + +.labels-tag-annotation-sidebar-not-found-wrapper { + margin-top: $grid-unit-size * 4; +} diff --git a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-sidebar/tag-annotation-sidebar.tsx b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-sidebar/tag-annotation-sidebar.tsx index 775d13c42e96..f54b405e7643 100644 --- a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-sidebar/tag-annotation-sidebar.tsx +++ b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-sidebar/tag-annotation-sidebar.tsx @@ -4,7 +4,6 @@ import React, { useState, useEffect } from 'react'; import { connect } from 'react-redux'; -import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; import { Action } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import { Row, Col } from 'antd/lib/grid'; @@ -21,6 +20,7 @@ import { changeFrameAsync, rememberObject, } from 'actions/annotation-actions'; +import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; import { Canvas } from 'cvat-canvas-wrapper'; import { CombinedState, ObjectType } from 'reducers/interfaces'; import LabelSelector from 'components/label-selector/label-selector'; @@ -107,7 +107,7 @@ function TagAnnotationSidebar(props: StateToProps & DispatchToProps): JSX.Elemen } }; - const defaultLabelID = labels[0].id; + const defaultLabelID = labels.length ? labels[0].id : null; const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [frameTags, setFrameTags] = useState([] as any[]); @@ -196,7 +196,24 @@ function TagAnnotationSidebar(props: StateToProps & DispatchToProps): JSX.Elemen }, }; - return ( + return !labels.length ? ( + + {/* eslint-disable-next-line */} + setSidebarCollapsed(!sidebarCollapsed)} + > + {sidebarCollapsed ? : } + + + + No labels are available. + + + + ) : ( <> diff --git a/cvat-ui/src/components/labels-editor/constructor-viewer-item.tsx b/cvat-ui/src/components/labels-editor/constructor-viewer-item.tsx index 32c56dd7677e..fe436f610a9d 100644 --- a/cvat-ui/src/components/labels-editor/constructor-viewer-item.tsx +++ b/cvat-ui/src/components/labels-editor/constructor-viewer-item.tsx @@ -35,18 +35,16 @@ export default function ConstructorViewerItem(props: ConstructorViewerItemProps) - {label.id < 0 && ( - - onDelete(label)} - onKeyPress={(): boolean => false} - > - - - - )} + + onDelete(label)} + onKeyPress={(): boolean => false} + > + + + ); } diff --git a/cvat-ui/src/components/labels-editor/labels-editor.tsx b/cvat-ui/src/components/labels-editor/labels-editor.tsx index df92fc416feb..2eacf74a9b31 100644 --- a/cvat-ui/src/components/labels-editor/labels-editor.tsx +++ b/cvat-ui/src/components/labels-editor/labels-editor.tsx @@ -6,10 +6,12 @@ import './styles.scss'; import React from 'react'; import Tabs from 'antd/lib/tabs'; import Button from 'antd/lib/button'; -import notification from 'antd/lib/notification'; import Text from 'antd/lib/typography/Text'; +import ModalConfirm from 'antd/lib/modal/confirm'; import copy from 'copy-to-clipboard'; -import { CopyOutlined, EditOutlined, BuildOutlined } from '@ant-design/icons'; +import { + CopyOutlined, EditOutlined, BuildOutlined, ExclamationCircleOutlined, +} from '@ant-design/icons'; import CVATTooltip from 'components/common/cvat-tooltip'; import RawViewer from './raw-viewer'; @@ -144,20 +146,30 @@ export default class LabelsEditor extends React.PureComponent { - // the label is saved on the server, cannot delete it - if (typeof label.id !== 'undefined' && label.id >= 0) { - notification.error({ - message: 'Could not delete the label', - description: 'It has been already saved on the server', - }); - } + const deleteLabel = (): void => { + const { unsavedLabels, savedLabels } = this.state; - const { unsavedLabels, savedLabels } = this.state; + const filteredUnsavedLabels = unsavedLabels.filter((_label: Label): boolean => _label.id !== label.id); + const filteredSavedLabels = savedLabels.filter((_label: Label): boolean => _label.id !== label.id); - const filteredUnsavedLabels = unsavedLabels.filter((_label: Label): boolean => _label.id !== label.id); + this.setState({ savedLabels: filteredSavedLabels, unsavedLabels: filteredUnsavedLabels }); + this.handleSubmit(filteredSavedLabels, filteredUnsavedLabels); + }; - this.setState({ unsavedLabels: filteredUnsavedLabels }); - this.handleSubmit(savedLabels, filteredUnsavedLabels); + if (typeof label.id !== 'undefined' && label.id >= 0) { + ModalConfirm({ + title: `Do you want to delete "${label.name}" label?`, + icon: , + content: 'This action is irreversible. Annotation corresponding with this label will be deleted.', + type: 'warning', + okType: 'danger', + onOk() { + deleteLabel(); + }, + }); + } else { + deleteLabel(); + } }; private handleSubmit(savedLabels: Label[], unsavedLabels: Label[]): void { diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index 22a33f9dc497..f057ae6413d0 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import { KeyMap } from 'utils/mousetrap-react'; import { connect } from 'react-redux'; import { Canvas } from 'cvat-canvas-wrapper'; @@ -18,6 +17,7 @@ import { } from 'actions/annotation-actions'; import ControlsSideBarComponent from 'components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar'; import { ActiveControl, CombinedState, Rotation } from 'reducers/interfaces'; +import { KeyMap } from 'utils/mousetrap-react'; interface StateToProps { canvasInstance: Canvas; @@ -25,6 +25,7 @@ interface StateToProps { activeControl: ActiveControl; keyMap: KeyMap; normalizedKeyMap: Record; + labels: any[]; } interface DispatchToProps { @@ -42,6 +43,7 @@ function mapStateToProps(state: CombinedState): StateToProps { const { annotation: { canvas: { instance: canvasInstance, activeControl }, + job: { labels }, }, settings: { player: { rotateAll }, @@ -53,6 +55,7 @@ function mapStateToProps(state: CombinedState): StateToProps { rotateAll, canvasInstance, activeControl, + labels, normalizedKeyMap, keyMap, }; diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx index 937b61f0f06c..151be9789114 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2020-2021 Intel Corporation // // SPDX-License-Identifier: MIT @@ -79,7 +79,7 @@ class DrawShapePopoverContainer extends React.PureComponent { super(props); const { shapeType } = props; - const defaultLabelID = props.labels[0].id; + const defaultLabelID = props.labels.length ? props.labels[0].id : null; const defaultRectDrawingMethod = RectDrawingMethod.CLASSIC; const defaultCuboidDrawingMethod = CuboidDrawingMethod.CLASSIC; this.state = { diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index 0db2fbaa3492..3da1900848d4 100644 --- a/cvat-ui/src/reducers/annotation-reducer.ts +++ b/cvat-ui/src/reducers/annotation-reducer.ts @@ -182,7 +182,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, drawing: { ...state.drawing, - activeLabelID: job.task.labels[0].id, + activeLabelID: job.task.labels.length ? job.task.labels[0].id : null, activeObjectType: job.task.mode === 'interpolation' ? ObjectType.TRACK : ObjectType.SHAPE, }, canvas: { diff --git a/cvat-ui/src/utils/hooks.ts b/cvat-ui/src/utils/hooks.ts new file mode 100644 index 000000000000..74b58a1d233f --- /dev/null +++ b/cvat-ui/src/utils/hooks.ts @@ -0,0 +1,13 @@ +// Copyright (C) 2021 Intel Corporation +// +// SPDX-License-Identifier: MIT +import { useRef, useEffect } from 'react'; + +// eslint-disable-next-line import/prefer-default-export +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index c617b01fff3c..4aab32f4f4b9 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -70,10 +70,17 @@ class LabelSerializer(serializers.ModelSerializer): attributes = AttributeSerializer(many=True, source='attributespec_set', default=[]) color = serializers.CharField(allow_blank=True, required=False) + deleted = serializers.BooleanField(required=False) class Meta: model = models.Label - fields = ('id', 'name', 'color', 'attributes') + fields = ('id', 'name', 'color', 'attributes', 'deleted') + + def validate(self, attrs): + if attrs.get('deleted') == True and attrs.get('id') is None: + raise serializers.ValidationError('Deleted label must have an ID') + + return attrs @staticmethod def update_instance(validated_data, parent_instance): @@ -96,6 +103,9 @@ def update_instance(validated_data, parent_instance): else: db_label = models.Label.objects.create(name=validated_data.get('name'), **instance) logger.info("New {} label was created".format(db_label.name)) + if validated_data.get('deleted') == True: + db_label.delete() + return if not validated_data.get('color', None): label_names = [l.name for l in instance[tuple(instance.keys())[0]].label_set.exclude(id=db_label.id).order_by('id') diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 780b6c7086dd..f999f64997b7 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -28,7 +28,7 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase -from cvat.apps.engine.models import (AttributeType, Data, Job, Project, +from cvat.apps.engine.models import (AttributeSpec, AttributeType, Data, Job, Project, Segment, StatusChoice, Task, Label, StorageMethodChoice, StorageChoice) from cvat.apps.engine.prepare import prepare_meta, prepare_meta_for_upload from cvat.apps.engine.media_extractors import ValidateDimension @@ -72,6 +72,7 @@ def create_db_task(data): os.makedirs(db_data.get_data_dirname()) os.makedirs(db_data.get_upload_dirname()) + labels = data.pop('labels', None) db_task = Task.objects.create(**data) shutil.rmtree(db_task.get_task_dirname(), ignore_errors=True) os.makedirs(db_task.get_task_dirname()) @@ -80,6 +81,17 @@ def create_db_task(data): db_task.data = db_data db_task.save() + if not labels is None: + for label_data in labels: + attributes = label_data.pop('attributes', None) + db_label = Label(task=db_task, **label_data) + db_label.save() + + if not attributes is None: + for attribute_data in attributes: + db_attribute = AttributeSpec(label=db_label, **attribute_data) + db_attribute.save() + for x in range(0, db_task.data.size, db_task.segment_size): start_frame = x stop_frame = min(x + db_task.segment_size - 1, db_task.data.size - 1) @@ -96,6 +108,26 @@ def create_db_task(data): return db_task +def create_db_project(data): + labels = data.pop('labels', None) + db_project = Project.objects.create(**data) + shutil.rmtree(db_project.get_project_dirname(), ignore_errors=True) + os.makedirs(db_project.get_project_dirname()) + os.makedirs(db_project.get_project_logs_dirname()) + + if not labels is None: + for label_data in labels: + attributes = label_data.pop('attributes', None) + db_label = Label(project=db_project, **label_data) + db_label.save() + + if not attributes is None: + for attribute_data in attributes: + db_attribute = AttributeSpec(label=db_label, **attribute_data) + db_attribute.save() + + return db_project + def create_dummy_db_tasks(obj, project=None): tasks = [] @@ -159,14 +191,14 @@ def create_dummy_db_projects(obj): "owner": obj.owner, "assignee": obj.assignee, } - db_project = Project.objects.create(**data) + db_project = create_db_project(data) projects.append(db_project) data = { "name": "my project without assignee", "owner": obj.user, } - db_project = Project.objects.create(**data) + db_project = create_db_project(data) create_dummy_db_tasks(obj, db_project) projects.append(db_project) @@ -175,14 +207,14 @@ def create_dummy_db_projects(obj): "owner": obj.owner, "assignee": obj.assignee, } - db_project = Project.objects.create(**data) + db_project = create_db_project(data) create_dummy_db_tasks(obj, db_project) projects.append(db_project) data = { "name": "public project", } - db_project = Project.objects.create(**data) + db_project = create_db_project(data) create_dummy_db_tasks(obj, db_project) projects.append(db_project) @@ -191,7 +223,7 @@ def create_dummy_db_projects(obj): "owner": obj.admin, "assignee": obj.assignee, } - db_project = Project.objects.create(**data) + db_project = create_db_project(data) create_dummy_db_tasks(obj, db_project) projects.append(db_project) @@ -1157,7 +1189,7 @@ def _run_api_v1_projects_id(self, pid, user, data): def _check_response(self, response, db_project, data): self.assertEqual(response.status_code, status.HTTP_200_OK) - name = data.get("name", data.get("name", db_project.name)) + name = data.get("name", db_project.name) self.assertEqual(response.data["name"], name) response_owner = response.data["owner"]["id"] if response.data["owner"] else None db_owner = db_project.owner.id if db_project.owner else None @@ -1167,6 +1199,16 @@ def _check_response(self, response, db_project, data): self.assertEqual(response_assignee, data.get("assignee_id", db_assignee)) self.assertEqual(response.data["status"], data.get("status", db_project.status)) self.assertEqual(response.data["bug_tracker"], data.get("bug_tracker", db_project.bug_tracker)) + if data.get("labels"): + self.assertListEqual( + [label["name"] for label in data.get("labels") if not label.get("deleted", False)], + [label["name"] for label in response.data["labels"]] + ) + else: + self.assertListEqual( + [label.name for label in db_project.label_set.all()], + [label["name"] for label in response.data["labels"]] + ) def _check_api_v1_projects_id(self, user, data): for db_project in self.projects: @@ -1180,9 +1222,13 @@ def _check_api_v1_projects_id(self, user, data): def test_api_v1_projects_id_admin(self): data = { - "name": "new name for the project", + "name": "project with some labels", "owner_id": self.owner.id, "bug_tracker": "https://new.bug.tracker", + "labels": [ + {"name": "car"}, + {"name": "person"} + ], } self._check_api_v1_projects_id(self.admin, data) @@ -1205,6 +1251,103 @@ def test_api_v1_projects_id_no_auth(self): } self._check_api_v1_projects_id(None, data) +class UpdateLabelsAPITestCase(APITestCase): + def setUp(self): + self.client = APIClient() + + def assertLabelsEqual(self, label1, label2): + self.assertEqual(label1.get("name", label2.get("name")), label2.get("name")) + self.assertEqual(label1.get("color", label2.get("color")), label2.get("color")) + + def _check_response(self, response, db_object, data): + self.assertEqual(response.status_code, status.HTTP_200_OK) + db_labels = db_object.label_set.all() + response_labels = response.data["labels"] + for label in data["labels"]: + if label.get("id", None) is None: + self.assertLabelsEqual( + label, + [l for l in response_labels if label.get("name") == l.get("name")][0], + ) + db_labels = [l for l in db_labels if label.get("name") != l.name] + response_labels = [l for l in response_labels if label.get("name") != l.get("name")] + else: + if not label.get("deleted", False): + self.assertLabelsEqual( + label, + [l for l in response_labels if label.get("id") == l.get("id")][0], + ) + response_labels = [l for l in response_labels if label.get("id") != l.get("id")] + db_labels = [l for l in db_labels if label.get("id") != l.id] + else: + self.assertEqual( + len([l for l in response_labels if label.get("id") == l.get("id")]), 0 + ) + self.assertEqual(len(response_labels), len(db_labels)) + +class ProjectUpdateLabelsAPITestCase(UpdateLabelsAPITestCase): + @classmethod + def setUpTestData(cls): + project_data = { + "name": "Project with labels", + "bug_tracker": "https://new.bug.tracker", + "labels": [{ + "name": "car", + "color": "#ff00ff", + "attributes": [{ + "name": "bool_attribute", + "mutable": True, + "input_type": AttributeType.CHECKBOX, + "default_value": "true" + }], + }, { + "name": "person", + }] + } + + create_db_users(cls) + db_project = create_db_project(project_data) + create_dummy_db_tasks(cls, db_project) + cls.project = db_project + + def _check_api_v1_project(self, data): + response = self._run_api_v1_project_id(self.project.id, self.admin, data) + self._check_response(response, self.project, data) + + def _run_api_v1_project_id(self, pid, user, data): + with ForceLogin(user, self.client): + response = self.client.patch('/api/v1/projects/{}'.format(pid), + data=data, format="json") + + return response + + def test_api_v1_projects_create_label(self): + data = { + "labels": [{ + "name": "new label", + }], + } + self._check_api_v1_project(data) + + def test_api_v1_projects_edit_label(self): + data = { + "labels": [{ + "id": 1, + "name": "New name for label", + "color": "#fefefe", + }], + } + self._check_api_v1_project(data) + + def test_api_v1_projects_delete_label(self): + data = { + "labels": [{ + "id": 2, + "name": "Label for deletion", + "deleted": True + }] + } + self._check_api_v1_project(data) class ProjectListOfTasksAPITestCase(APITestCase): def setUp(self): self.client = APIClient() @@ -1566,6 +1709,73 @@ def test_api_v1_tasks_id_no_auth(self): } self._check_api_v1_tasks_id(None, data) +class TaskUpdateLabelsAPITestCase(UpdateLabelsAPITestCase): + @classmethod + def setUpTestData(cls): + task_data = { + "name": "Project with labels", + "bug_tracker": "https://new.bug.tracker", + "overlap": 0, + "segment_size": 100, + "image_quality": 75, + "size": 100, + "labels": [{ + "name": "car", + "color": "#ff00ff", + "attributes": [{ + "name": "bool_attribute", + "mutable": True, + "input_type": AttributeType.CHECKBOX, + "default_value": "true" + }], + }, { + "name": "person", + }] + } + + create_db_users(cls) + db_task = create_db_task(task_data) + cls.task = db_task + + def _check_api_v1_task(self, data): + response = self._run_api_v1_task_id(self.task.id, self.admin, data) + self._check_response(response, self.task, data) + + def _run_api_v1_task_id(self, tid, user, data): + with ForceLogin(user, self.client): + response = self.client.patch('/api/v1/tasks/{}'.format(tid), + data=data, format="json") + + return response + + def test_api_v1_tasks_create_label(self): + data = { + "labels": [{ + "name": "new label", + }], + } + self._check_api_v1_task(data) + + def test_api_v1_tasks_edit_label(self): + data = { + "labels": [{ + "id": 1, + "name": "New name for label", + "color": "#fefefe", + }], + } + self._check_api_v1_task(data) + + def test_api_v1_tasks_delete_label(self): + data = { + "labels": [{ + "id": 2, + "name": "Label for deletion", + "deleted": True + }] + } + self._check_api_v1_task(data) + class TaskCreateAPITestCase(APITestCase): def setUp(self): self.client = APIClient() diff --git a/cvat/settings/testing.py b/cvat/settings/testing.py index 9825349fd8b7..c55e6f421126 100644 --- a/cvat/settings/testing.py +++ b/cvat/settings/testing.py @@ -19,6 +19,9 @@ TASKS_ROOT = os.path.join(DATA_ROOT, 'tasks') os.makedirs(TASKS_ROOT, exist_ok=True) +PROJECTS_ROOT = os.path.join(DATA_ROOT, 'projects') +os.makedirs(PROJECTS_ROOT, exist_ok=True) + MODELS_ROOT = os.path.join(DATA_ROOT, 'models') os.makedirs(MODELS_ROOT, exist_ok=True)