From 8a8077a19f011decd301e7690432229500265fc3 Mon Sep 17 00:00:00 2001 From: Dmitry Kalinin Date: Mon, 15 Feb 2021 13:40:41 +0300 Subject: [PATCH] Project task subsets (#2774) * Added task project subsets * Added components list key * Added subset field resetting and subset header * Added CHANGELOG and increased npm package version * Added replacing camelcase to snake --- CHANGELOG.md | 1 + cvat-core/package-lock.json | 2 +- cvat-core/package.json | 2 +- cvat-core/src/api-implementation.js | 66 +++++++-------- cvat-core/src/api.js | 2 +- cvat-core/src/common.js | 33 +++++++- cvat-core/src/project.js | 21 ++++- cvat-core/src/server-proxy.js | 2 +- cvat-core/src/session.js | 37 ++++++++- cvat-ui/src/actions/projects-actions.ts | 2 +- cvat-ui/src/actions/tasks-actions.ts | 5 +- .../create-task-page/create-task-content.tsx | 44 +++++++++- .../create-task-page/create-task-page.tsx | 2 +- .../create-task-page/project-search-field.tsx | 4 +- .../create-task-page/project-subset-field.tsx | 83 +++++++++++++++++++ .../components/project-page/project-page.tsx | 46 +++++----- cvat-ui/src/components/task-page/details.tsx | 37 ++++++++- cvat-ui/src/components/task-page/styles.scss | 4 + cvat-ui/src/consts.ts | 4 +- cvat-ui/src/containers/task-page/details.tsx | 12 ++- .../engine/migrations/0037_task_subset.py | 18 ++++ cvat/apps/engine/models.py | 1 + cvat/apps/engine/serializers.py | 28 ++++++- cvat/apps/engine/views.py | 6 +- 24 files changed, 383 insertions(+), 79 deletions(-) create mode 100644 cvat-ui/src/components/create-task-page/project-subset-field.tsx create mode 100644 cvat/apps/engine/migrations/0037_task_subset.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d73573ba26..d42ffae257aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 function for interative segmentation - Pre-built [cvat_server](https://hub.docker.com/r/openvino/cvat_server) and [cvat_ui](https://hub.docker.com/r/openvino/cvat_ui) images were published on DockerHub () +- Project task subsets () ### Changed diff --git a/cvat-core/package-lock.json b/cvat-core/package-lock.json index d16ae63c811b..5330094b7ce8 100644 --- a/cvat-core/package-lock.json +++ b/cvat-core/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "3.10.0", + "version": "3.11.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-core/package.json b/cvat-core/package.json index 93c1904b406d..6ebb5cfc6caf 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "3.10.0", + "version": "3.11.0", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "babel.config.js", "scripts": { diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index 1d2dbff5c54c..5e9ff610ca52 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -1,4 +1,4 @@ -// Copyright (C) 2019-2020 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT @@ -7,7 +7,13 @@ const serverProxy = require('./server-proxy'); const lambdaManager = require('./lambda-manager'); const { - isBoolean, isInteger, isEnum, isString, checkFilter, + isBoolean, + isInteger, + isEnum, + isString, + checkFilter, + checkExclusiveFields, + camelToSnake, } = require('./common'); const { TaskStatus, TaskMode, DimensionType } = require('./enums'); @@ -179,27 +185,21 @@ dimension: isEnum.bind(DimensionType), }); - if ('search' in filter && Object.keys(filter).length > 1) { - if (!('page' in filter && Object.keys(filter).length === 2)) { - throw new ArgumentError('Do not use the filter field "search" with others'); - } - } - - if ('id' in filter && Object.keys(filter).length > 1) { - if (!('page' in filter && Object.keys(filter).length === 2)) { - throw new ArgumentError('Do not use the filter field "id" with others'); - } - } - - if ( - 'projectId' in filter - && (('page' in filter && Object.keys(filter).length > 2) || Object.keys(filter).length > 2) - ) { - throw new ArgumentError('Do not use the filter field "projectId" with other'); - } + checkExclusiveFields(filter, ['id', 'search', 'projectId'], ['page']); const searchParams = new URLSearchParams(); - for (const field of ['name', 'owner', 'assignee', 'search', 'status', 'mode', 'id', 'page', 'projectId', 'dimension']) { + for (const field of [ + 'name', + 'owner', + 'assignee', + 'search', + 'status', + 'mode', + 'id', + 'page', + 'projectId', + 'dimension', + ]) { if (Object.prototype.hasOwnProperty.call(filter, field)) { searchParams.set(field, filter[field]); } @@ -222,30 +222,26 @@ owner: isString, search: isString, status: isEnum.bind(TaskStatus), + withoutTasks: isBoolean, }); - if ('search' in filter && Object.keys(filter).length > 1) { - if (!('page' in filter && Object.keys(filter).length === 2)) { - throw new ArgumentError('Do not use the filter field "search" with others'); - } - } - - if ('id' in filter && Object.keys(filter).length > 1) { - if (!('page' in filter && Object.keys(filter).length === 2)) { - throw new ArgumentError('Do not use the filter field "id" with others'); - } - } + checkExclusiveFields(filter, ['id', 'search'], ['page', 'withoutTasks']); const searchParams = new URLSearchParams(); - for (const field of ['name', 'assignee', 'owner', 'search', 'status', 'id', 'page']) { + for (const field of ['name', 'assignee', 'owner', 'search', 'status', 'id', 'page', 'withoutTasks']) { if (Object.prototype.hasOwnProperty.call(filter, field)) { - searchParams.set(field, filter[field]); + searchParams.set(camelToSnake(field), filter[field]); } } const projectsData = await serverProxy.projects.get(searchParams.toString()); // prettier-ignore - const projects = projectsData.map((project) => new Project(project)); + const projects = projectsData.map((project) => { + if (filter.withoutTasks) { + project.tasks = []; + } + return project; + }).map((project) => new Project(project)); projects.count = projectsData.count; diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index 7002a3abcf01..22dc4858e538 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -1,4 +1,4 @@ -// Copyright (C) 2019-2020 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT diff --git a/cvat-core/src/common.js b/cvat-core/src/common.js index d40312b47526..80134d06f0be 100644 --- a/cvat-core/src/common.js +++ b/cvat-core/src/common.js @@ -1,4 +1,4 @@ -// Copyright (C) 2019-2020 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT @@ -42,6 +42,25 @@ } } + function checkExclusiveFields(obj, exclusive, ignore) { + const fields = { + exclusive: [], + other: [], + }; + for (const field in Object.keys(obj)) { + if (!(field in ignore)) { + if (field in exclusive) { + if (fields.other.length) { + throw new ArgumentError(`Do not use the filter field "${field}" with others`); + } + fields.exclusive.push(field); + } else { + fields.other.push(field); + } + } + } + } + function checkObjectType(name, value, type, instance) { if (type) { if (typeof value !== type) { @@ -68,6 +87,16 @@ return true; } + function camelToSnake(str) { + if (typeof str !== 'string') { + throw new ArgumentError('str is expected to be string'); + } + + return ( + str[0].toLowerCase() + str.slice(1, str.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) + ); + } + function negativeIDGenerator() { const value = negativeIDGenerator.start; negativeIDGenerator.start -= 1; @@ -83,5 +112,7 @@ checkFilter, checkObjectType, negativeIDGenerator, + checkExclusiveFields, + camelToSnake, }; })(); diff --git a/cvat-core/src/project.js b/cvat-core/src/project.js index d53d5eece927..ba06b1fe3154 100644 --- a/cvat-core/src/project.js +++ b/cvat-core/src/project.js @@ -1,4 +1,4 @@ -// Copyright (C) 2019-2020 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT @@ -32,6 +32,7 @@ bug_tracker: undefined, created_date: undefined, updated_date: undefined, + task_subsets: undefined, }; for (const property in data) { @@ -56,6 +57,13 @@ data.tasks.push(taskInstance); } } + if (!data.task_subsets && data.tasks.length) { + const subsetsSet = new Set(); + for (const task in data.tasks) { + if (task.subset) subsetsSet.add(task.subset); + } + data.task_subsets = Array.from(subsetsSet); + } Object.defineProperties( this, @@ -192,6 +200,17 @@ tasks: { get: () => [...data.tasks], }, + /** + * Subsets array for linked tasks + * @name subsets + * @type {string[]} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + subsets: { + get: () => [...data.task_subsets], + }, }), ); } diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index 1fafc92ae75e..1e7018c53bc4 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -1,4 +1,4 @@ -// Copyright (C) 2021 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index 31fc5fe19e77..ddc7b7a22eaf 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -1,4 +1,4 @@ -// Copyright (C) 2021 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT @@ -972,6 +972,7 @@ created_date: undefined, updated_date: undefined, bug_tracker: undefined, + subset: undefined, overlap: undefined, segment_size: undefined, image_quality: undefined, @@ -991,6 +992,7 @@ name: false, assignee: false, bug_tracker: false, + subset: false, labels: false, }; @@ -1167,10 +1169,36 @@ bugTracker: { get: () => data.bug_tracker, set: (tracker) => { + if (typeof tracker !== 'string') { + throw new ArgumentError( + `Subset value must be a string. But ${typeof tracker} has been got.`, + ); + } + updatedFields.bug_tracker = true; data.bug_tracker = tracker; }, }, + /** + * @name subset + * @type {string} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exception.ArgumentError} + */ + subset: { + get: () => data.subset, + set: (subset) => { + if (typeof subset !== 'string') { + throw new ArgumentError( + `Subset value must be a string. But ${typeof subset} has been got.`, + ); + } + + updatedFields.subset = true; + data.subset = subset; + }, + }, /** * @name overlap * @type {integer} @@ -1888,6 +1916,9 @@ case 'bug_tracker': taskData.bug_tracker = this.bugTracker; break; + case 'subset': + taskData.subset = this.subset; + break; case 'labels': taskData.labels = [...this.labels.map((el) => el.toJSON())]; break; @@ -1903,6 +1934,7 @@ assignee: false, name: false, bugTracker: false, + subset: false, labels: false, }; @@ -1926,6 +1958,9 @@ if (typeof this.projectId !== 'undefined') { taskSpec.project_id = this.projectId; } + if (typeof this.subset !== 'undefined') { + taskSpec.subset = this.subset; + } const taskDataSpec = { client_files: this.clientFiles, diff --git a/cvat-ui/src/actions/projects-actions.ts b/cvat-ui/src/actions/projects-actions.ts index d2794db3d028..5408a42fbe17 100644 --- a/cvat-ui/src/actions/projects-actions.ts +++ b/cvat-ui/src/actions/projects-actions.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index bd0fd45066b4..3dcf75bcfbc8 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT @@ -397,6 +397,9 @@ export function createTaskAsync(data: any): ThunkAction, {}, {}, A if (data.advanced.copyData) { description.copy_data = data.advanced.copyData; } + if (data.subset) { + description.subset = data.subset; + } const taskInstance = new cvat.classes.Task(description); taskInstance.clientFiles = data.files.local; diff --git a/cvat-ui/src/components/create-task-page/create-task-content.tsx b/cvat-ui/src/components/create-task-page/create-task-content.tsx index 7abf5da2cb1e..e2c6ee3ee93d 100644 --- a/cvat-ui/src/components/create-task-page/create-task-content.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-content.tsx @@ -17,11 +17,13 @@ import LabelsEditor from 'components/labels-editor/labels-editor'; import { Files } from 'components/file-manager/file-manager'; import BasicConfigurationForm, { BaseConfiguration } from './basic-configuration-form'; import ProjectSearchField from './project-search-field'; +import ProjectSubsetField from './project-subset-field'; import AdvancedConfigurationForm, { AdvancedConfiguration } from './advanced-configuration-form'; export interface CreateTaskData { projectId: number | null; basic: BaseConfiguration; + subset: string; advanced: AdvancedConfiguration; labels: any[]; files: Files; @@ -43,6 +45,7 @@ const defaultState = { basic: { name: '', }, + subset: '', advanced: { lfs: false, useZipChunks: true, @@ -120,8 +123,11 @@ class CreateTaskContent extends React.PureComponent { + const { projectId, subset } = this.state; + this.setState({ projectId: value, + subset: value && value === projectId ? subset : '', }); }; @@ -137,6 +143,12 @@ class CreateTaskContent extends React.PureComponent { + this.setState({ + subset: value, + }); + }; + private changeFileManagerTab = (key: string): void => { const values = this.state; this.setState({ @@ -165,16 +177,18 @@ class CreateTaskContent extends React.PureComponent { if (this.advancedConfigurationComponent.current) { return this.advancedConfigurationComponent.current.submit(); } - return new Promise((resolve): void => { + return new Promise((resolve): void => { resolve(); }); - }).then((): void => { + }) + .then((): void => { const { onCreate } = this.props; onCreate(this.state); }) @@ -214,6 +228,29 @@ class CreateTaskContent extends React.PureComponent + + Subset: + + + + + + ); + } + + return null; + } + private renderLabelsBlock(): JSX.Element { const { projectId, labels } = this.state; @@ -293,6 +330,7 @@ class CreateTaskContent extends React.PureComponent; + value: string; + onChange: (value: string) => void; +} + +interface ProjectPartialWithSubsets { + id: number; + subsets: Array; +} + +export default function ProjectSubsetField(props: Props): JSX.Element { + const { + projectId, projectSubsets, value, onChange, + } = props; + + const [internalValue, setInternalValue] = useState(''); + const [internalSubsets, setInternalSubsets] = useState>(new Set()); + + useEffect(() => { + if (!projectSubsets?.length && projectId) { + core.projects.get({ id: projectId, withoutTasks: true }).then((response: ProjectPartialWithSubsets[]) => { + if (response.length) { + const [project] = response; + setInternalSubsets( + new Set([ + ...(internalValue ? [internalValue] : []), + ...consts.DEFAULT_PROJECT_SUBSETS, + ...project.subsets, + ]), + ); + } + }); + } else { + setInternalSubsets( + new Set([ + ...(internalValue ? [internalValue] : []), + ...consts.DEFAULT_PROJECT_SUBSETS, + ...(projectSubsets || []), + ]), + ); + } + }, [projectId, projectSubsets]); + + useEffect(() => { + setInternalValue(value); + }, [value]); + + return ( + setInternalValue(_value)} + onSelect={(_value) => { + if (_value !== internalValue) { + onChange(_value); + } + setInternalValue(_value); + }} + onBlur={() => onChange(internalValue)} + options={Array.from(new Set([...(internalValue ? [internalValue] : []), ...internalSubsets])).map( + (subset) => ({ + value: subset, + label: subset, + }), + )} + /> + ); +} diff --git a/cvat-ui/src/components/project-page/project-page.tsx b/cvat-ui/src/components/project-page/project-page.tsx index 49121d70dc1a..348b5d0e8a36 100644 --- a/cvat-ui/src/components/project-page/project-page.tsx +++ b/cvat-ui/src/components/project-page/project-page.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2019-2021 Intel Corporation // // SPDX-License-Identifier: MIT @@ -34,9 +34,12 @@ export default function ProjectPageComponent(): JSX.Element { const taskDeletes = useSelector((state: CombinedState) => state.tasks.activities.deletes); const tasksActiveInferences = useSelector((state: CombinedState) => state.models.inferences); const tasks = useSelector((state: CombinedState) => state.tasks.current); + const projectSubsets = useSelector((state: CombinedState) => { + const [project] = state.projects.current.filter((_project) => _project.id === id); + return project ? ([...new Set(project.tasks.map((task: any) => task.subset))] as string[]) : []; + }); - const filteredProjects = projects.filter((project) => project.id === id); - const project = filteredProjects[0]; + const [project] = projects.filter((_project) => _project.id === id); const deleteActivity = project && id in deletes ? deletes[id] : null; useEffect(() => { @@ -73,7 +76,7 @@ export default function ProjectPageComponent(): JSX.Element { - Tasks + Tasks