From 7e20b279afc2baa84f430296cc5e6820cf3ee6e2 Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Tue, 16 Aug 2022 16:14:56 +0300 Subject: [PATCH] Added support of skeletons (#1) --- .eslintrc.js | 9 +- CHANGELOG.md | 3 +- cvat-canvas/package.json | 2 +- cvat-canvas/src/scss/canvas.scss | 19 +- .../src/typescript/autoborderHandler.ts | 23 +- cvat-canvas/src/typescript/canvasModel.ts | 47 +- cvat-canvas/src/typescript/canvasView.ts | 800 ++- cvat-canvas/src/typescript/consts.ts | 13 +- cvat-canvas/src/typescript/drawHandler.ts | 372 +- cvat-canvas/src/typescript/editHandler.ts | 40 +- .../src/typescript/interactionHandler.ts | 34 +- cvat-canvas/src/typescript/shared.ts | 124 +- cvat-canvas3d/src/typescript/canvas3dView.ts | 1 - cvat-core/jest.config.js | 6 + cvat-core/package.json | 5 +- cvat-core/src/annotation-formats.ts | 375 +- cvat-core/src/annotations-collection.ts | 398 +- cvat-core/src/annotations-filter.ts | 16 +- cvat-core/src/annotations-history.ts | 30 +- cvat-core/src/annotations-objects.ts | 4457 ++++++++++------- cvat-core/src/annotations-saver.ts | 1 + cvat-core/src/annotations.ts | 2 +- cvat-core/src/api-implementation.ts | 2 +- cvat-core/src/api.ts | 9 +- cvat-core/src/cloud-storage.ts | 2 +- cvat-core/src/common.ts | 187 +- cvat-core/src/config.ts | 4 +- cvat-core/src/enums.ts | 825 ++- cvat-core/src/exceptions.ts | 464 +- cvat-core/src/frames.ts | 2 +- cvat-core/src/issue.ts | 2 +- cvat-core/src/labels.ts | 541 +- cvat-core/src/log.ts | 2 +- cvat-core/src/logger-storage.ts | 4 +- cvat-core/src/object-state.ts | 1134 +++-- cvat-core/src/organization.ts | 2 +- cvat-core/src/plugins.ts | 156 +- cvat-core/src/project.ts | 8 +- cvat-core/src/session.ts | 10 +- cvat-core/src/statistics.ts | 74 +- cvat-core/tests/api/annotations.js | 94 +- cvat-core/tests/api/object-state.js | 26 +- cvat-ui/package.json | 4 +- cvat-ui/src/actions/about-actions.ts | 2 +- cvat-ui/src/actions/annotation-actions.ts | 79 +- cvat-ui/src/actions/auth-actions.ts | 2 +- cvat-ui/src/actions/boundaries-actions.ts | 2 +- cvat-ui/src/actions/cloud-storage-actions.ts | 4 +- cvat-ui/src/actions/formats-actions.ts | 2 +- cvat-ui/src/actions/import-actions.ts | 2 +- cvat-ui/src/actions/jobs-actions.ts | 4 +- cvat-ui/src/actions/models-actions.ts | 4 +- cvat-ui/src/actions/organization-actions.ts | 2 +- cvat-ui/src/actions/plugins-actions.ts | 4 +- cvat-ui/src/actions/projects-actions.ts | 4 +- cvat-ui/src/actions/review-actions.ts | 2 +- cvat-ui/src/actions/settings-actions.ts | 16 +- cvat-ui/src/actions/share-actions.ts | 4 +- cvat-ui/src/actions/tasks-actions.ts | 4 +- cvat-ui/src/actions/useragreements-actions.ts | 4 +- cvat-ui/src/assets/point-icon.svg | 9 +- cvat-ui/src/assets/rotate-icon (copy).svg | 6 + cvat-ui/src/assets/skeleton-icon.svg | 23 + .../components/actions-menu/actions-menu.tsx | 2 +- .../components/actions-menu/load-submenu.tsx | 2 +- .../annotation-page/annotation-page.tsx | 2 +- .../annotation-page/appearance-block.tsx | 2 +- .../attribute-annotation-sidebar.tsx | 4 +- .../canvas/canvas-context-menu.tsx | 20 +- .../canvas/canvas-point-context-menu.tsx | 2 +- .../annotation-page/canvas/canvas-wrapper.tsx | 123 +- .../canvas/canvas-wrapper3D.tsx | 4 +- .../canvas/image-setups-content.tsx | 2 +- .../controls-side-bar/controls-side-bar.tsx | 2 +- .../controls-side-bar/issue-control.tsx | 2 +- .../review/issues-aggregator.tsx | 2 +- .../context-image/context-image.tsx | 2 +- .../controls-side-bar/controls-side-bar.tsx | 130 +- .../controls-side-bar/cursor-control.tsx | 2 +- .../controls-side-bar/draw-cuboid-control.tsx | 6 +- .../draw-ellipse-control.tsx | 6 +- .../controls-side-bar/draw-points-control.tsx | 2 +- .../draw-polygon-control.tsx | 2 +- .../draw-polyline-control.tsx | 2 +- .../draw-rectangle-control.tsx | 2 +- .../controls-side-bar/draw-shape-popover.tsx | 10 +- .../draw-skeleton-control.tsx | 52 + .../controls-side-bar/group-control.tsx | 2 +- .../controls-side-bar/merge-control.tsx | 2 +- .../controls-side-bar/move-control.tsx | 2 +- .../controls-side-bar/opencv-control.tsx | 4 +- .../controls-side-bar/resize-control.tsx | 2 +- .../controls-side-bar/rotate-control.tsx | 2 +- .../controls-side-bar/setup-tag-control.tsx | 12 +- .../controls-side-bar/setup-tag-popover.tsx | 8 +- .../controls-side-bar/split-control.tsx | 2 +- .../controls-side-bar/tools-control.tsx | 6 +- .../objects-side-bar/color-picker.tsx | 2 +- .../objects-side-bar/issues-list.tsx | 2 +- .../label-key-selector-popover.tsx | 2 +- .../objects-side-bar/labels-list.tsx | 2 +- .../object-item-attribute.tsx | 2 +- .../objects-side-bar/object-item-basics.tsx | 4 +- .../objects-side-bar/object-item-buttons.tsx | 12 +- .../objects-side-bar/object-item-details.tsx | 3 +- .../objects-side-bar/object-item-element.tsx | 66 + .../objects-side-bar/object-item-menu.tsx | 2 +- .../objects-side-bar/object-item.tsx | 84 +- .../objects-side-bar/objects-list-header.tsx | 2 +- .../objects-side-bar/objects-list.tsx | 3 +- .../objects-side-bar/objects-side-bar.tsx | 2 +- .../objects-side-bar/shared.ts | 15 + .../states-ordering-selector.tsx | 2 +- .../objects-side-bar/styles.scss | 31 +- .../standard-workspace/remove-confirm.tsx | 2 +- .../standard-workspace/styles.scss | 1 + .../controls-side-bar/controls-side-bar.tsx | 2 +- .../controls-side-bar/photo-context.tsx | 2 +- .../tag-annotation-workspace/frame-tags.tsx | 2 +- .../shortcuts-select.tsx | 2 +- .../tag-annotation-sidebar.tsx | 4 +- .../top-bar/annotation-menu.tsx | 4 +- .../annotation-page/top-bar/filters-modal.tsx | 3 +- .../annotation-page/top-bar/left-group.tsx | 2 +- .../annotation-page/top-bar/right-group.tsx | 2 +- .../top-bar/statistics-modal.tsx | 56 +- .../annotation-page/top-bar/top-bar.tsx | 2 +- .../change-password-modal.tsx | 2 +- .../cloud-storage-item.tsx | 2 +- .../cloud-storage-preview.tsx | 2 +- .../cloud-storage-status.tsx | 2 +- .../cloud-storages-list.tsx | 2 +- .../cloud-storages-page.tsx | 2 +- .../cloud-storages-page/top-bar.tsx | 2 +- .../cloud-storage-form.tsx | 2 +- .../create-organization-form.tsx | 2 +- .../create-project-page.tsx | 4 +- .../create-task-page/create-task-page.tsx | 2 +- .../create-task-page/project-search-field.tsx | 2 +- .../create-task-page/project-subset-field.tsx | 2 +- .../components/create-task-page/styles.scss | 6 +- cvat-ui/src/components/cvat-app.tsx | 99 +- .../export-dataset/export-dataset-modal.tsx | 4 +- .../file-manager/cloud-storages-files.tsx | 21 +- .../file-manager/cloud-storages-tab.tsx | 4 +- .../components/file-manager/file-manager.tsx | 2 +- .../global-error-boundary.tsx | 2 +- cvat-ui/src/components/header/header.tsx | 4 +- .../header/settings-modal/player-settings.tsx | 2 +- .../header/settings-modal/settings-modal.tsx | 2 +- .../header/settings-modal/styles.scss | 3 +- .../settings-modal/workspace-settings.tsx | 23 + .../import-dataset-modal.tsx | 2 +- .../import-dataset-status-modal.tsx | 2 +- .../src/components/jobs-page/jobs-content.tsx | 2 +- .../src/components/jobs-page/jobs-page.tsx | 2 +- cvat-ui/src/components/jobs-page/top-bar.tsx | 2 +- .../src/components/labels-editor/common.ts | 68 +- .../labels-editor/constructor-creator.tsx | 63 +- .../labels-editor/constructor-updater.tsx | 47 +- .../labels-editor/constructor-viewer-item.tsx | 10 +- .../labels-editor/constructor-viewer.tsx | 22 +- .../components/labels-editor/label-form.tsx | 54 +- .../labels-editor/labels-editor.tsx | 84 +- .../components/labels-editor/raw-viewer.tsx | 88 +- .../labels-editor/skeleton-configurator.tsx | 821 +++ .../skeleton-element-context-menu.tsx | 87 + .../src/components/labels-editor/styles.scss | 127 +- .../components/layout-grid/layout-grid.tsx | 2 +- .../model-runner-modal/detector-runner.tsx | 4 +- .../model-runner-dialog.tsx | 2 +- .../models-page/deployed-model-item.tsx | 2 +- .../models-page/deployed-models-list.tsx | 2 +- .../components/models-page/models-page.tsx | 2 +- .../move-task-modal/move-task-modal.tsx | 4 +- .../organization-page/members-list.tsx | 2 +- .../organization-page/organization-page.tsx | 2 +- .../src/components/project-page/details.tsx | 2 +- .../components/project-page/project-page.tsx | 2 +- .../src/components/project-page/top-bar.tsx | 2 +- .../components/projects-page/actions-menu.tsx | 2 +- .../components/projects-page/project-item.tsx | 2 +- .../components/projects-page/project-list.tsx | 2 +- .../projects-page/projects-page.tsx | 2 +- .../src/components/projects-page/top-bar.tsx | 2 +- .../register-page/register-form.tsx | 2 +- .../register-page/register-page.tsx | 2 +- .../reset-password-confirm-page.tsx | 2 +- .../reset-password-page.tsx | 2 +- .../resource-sorting-filtering/filtering.tsx | 2 +- .../resource-sorting-filtering/index.ts | 2 +- .../shortcuts-dialog/shortcuts-dialog.tsx | 2 +- cvat-ui/src/components/shortcuts.context.tsx | 22 + cvat-ui/src/components/task-page/details.tsx | 4 +- cvat-ui/src/components/task-page/job-list.tsx | 4 +- .../src/components/task-page/task-page.tsx | 2 +- .../components/task-page/user-selector.tsx | 2 +- .../automatic-annotation-progress.tsx | 2 +- .../src/components/tasks-page/empty-list.tsx | 2 +- .../src/components/tasks-page/task-item.tsx | 2 +- .../src/components/tasks-page/tasks-page.tsx | 2 +- cvat-ui/src/components/tasks-page/top-bar.tsx | 2 +- .../update-cloud-storage-page.tsx | 2 +- .../containers/actions-menu/actions-menu.tsx | 11 +- .../annotation-page/annotation-page.tsx | 2 +- .../canvas/canvas-context-menu.tsx | 24 +- .../annotation-page/canvas/canvas-wrapper.tsx | 14 +- .../canvas/canvas-wrapper3D.tsx | 2 +- .../controls-side-bar/controls-side-bar.tsx | 2 +- .../controls-side-bar/controls-side-bar.tsx | 2 +- .../controls-side-bar/draw-shape-popover.tsx | 61 +- .../controls-side-bar/setup-tag-popover.tsx | 30 +- .../objects-side-bar/label-item.tsx | 2 +- .../objects-side-bar/object-buttons.tsx | 9 +- .../objects-side-bar/object-item-details.tsx | 124 + .../objects-side-bar/object-item.tsx | 73 +- .../objects-side-bar/objects-list.tsx | 90 +- .../standard-workspace/propagate-confirm.tsx | 2 +- .../controls-side-bar/controls-side-bar.tsx | 2 +- .../top-bar/annotation-menu.tsx | 4 +- .../annotation-page/top-bar/top-bar.tsx | 4 +- .../create-task-page/create-task-page.tsx | 2 +- .../containers/file-manager/file-manager.tsx | 2 +- .../header/settings-modal/player-settings.tsx | 2 +- .../settings-modal/workspace-settings.tsx | 63 +- .../src/containers/login-page/login-page.tsx | 2 +- .../containers/models-page/models-page.tsx | 2 +- .../register-page/register-page.tsx | 2 +- cvat-ui/src/containers/task-page/details.tsx | 2 +- cvat-ui/src/containers/task-page/job-list.tsx | 2 +- .../src/containers/task-page/task-page.tsx | 2 +- .../src/containers/tasks-page/task-item.tsx | 2 +- .../src/containers/tasks-page/tasks-list.tsx | 2 +- .../src/containers/tasks-page/tasks-page.tsx | 2 +- cvat-ui/src/cvat-core-wrapper.ts | 20 +- cvat-ui/src/cvat-logger.ts | 2 +- cvat-ui/src/icons.tsx | 2 + cvat-ui/src/index.tsx | 2 +- cvat-ui/src/reducers/about-reducer.ts | 4 +- cvat-ui/src/reducers/annotation-reducer.ts | 27 +- cvat-ui/src/reducers/auth-reducer.ts | 2 +- .../src/reducers/cloud-storages-reducer.ts | 2 +- cvat-ui/src/reducers/export-reducer.ts | 4 +- cvat-ui/src/reducers/formats-reducer.ts | 2 +- cvat-ui/src/reducers/import-reducer.ts | 2 +- .../src/reducers/{interfaces.ts => index.ts} | 5 + cvat-ui/src/reducers/jobs-reducer.ts | 2 +- cvat-ui/src/reducers/models-reducer.ts | 2 +- cvat-ui/src/reducers/notifications-reducer.ts | 4 +- cvat-ui/src/reducers/organizations-reducer.ts | 2 +- cvat-ui/src/reducers/plugins-reducer.ts | 2 +- cvat-ui/src/reducers/projects-reducer.ts | 2 +- cvat-ui/src/reducers/review-reducer.ts | 2 +- cvat-ui/src/reducers/settings-reducer.ts | 12 +- cvat-ui/src/reducers/share-reducer.ts | 2 +- cvat-ui/src/reducers/shortcuts-reducer.ts | 9 +- cvat-ui/src/reducers/tasks-reducer.ts | 2 +- .../src/reducers/useragreements-reducer.ts | 2 +- cvat-ui/src/utils/git-utils.ts | 2 +- cvat-ui/src/utils/is-able-to-change-frame.ts | 2 +- .../utils/opencv-wrapper/opencv-wrapper.ts | 2 +- cvat-ui/src/utils/redux.ts | 2 +- cvat-ui/src/utils/url-checker.ts | 2 +- cvat/apps/dataset_manager/annotation.py | 43 +- cvat/apps/dataset_manager/bindings.py | 141 +- cvat/apps/dataset_manager/formats/cvat.py | 390 +- cvat/apps/dataset_manager/task.py | 250 +- cvat/apps/engine/backup.py | 77 +- .../migrations/0057_auto_20220726_0926.py | 128 + .../migrations/0058_auto_20220809_1236.py | 60 + .../migrations/0059_labeledshape_outside.py | 18 + cvat/apps/engine/models.py | 47 +- cvat/apps/engine/serializers.py | 166 +- cvat/apps/engine/tests/test_rest_api_3D.py | 2 + .../case_20_objects_ordering_feature.js | 2 +- .../case_99_save_filtered_object_in_AAM.js | 16 +- .../case_108_rotated_bounding_boxes.js | 28 +- .../case_115_ellipse_shape_track_label.js | 15 +- .../case_14_appearance_features.js | 30 +- ...case_117_paste_labels_from_another_task.js | 2 + .../case_97_export_import_task.js | 6 +- ...se_48_issue_2663_annotations_statistics.js | 10 +- .../actions_tasks3/case_74_drag_canvas.js | 16 +- .../issues_prs/issue_1919_check_text_attr.js | 2 +- tests/cypress/support/commands.js | 47 +- tests/python/shared/assets/annotations.json | 48 + tests/python/shared/assets/jobs.json | 156 +- tests/python/shared/assets/projects.json | 30 +- tests/python/shared/assets/tasks.json | 101 +- tests/python/shared/assets/users.json | 2 +- yarn.lock | 1441 +++--- 291 files changed, 10882 insertions(+), 6098 deletions(-) create mode 100644 cvat-ui/src/assets/rotate-icon (copy).svg create mode 100644 cvat-ui/src/assets/skeleton-icon.svg create mode 100644 cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-skeleton-control.tsx create mode 100644 cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-element.tsx create mode 100644 cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/shared.ts create mode 100644 cvat-ui/src/components/labels-editor/skeleton-configurator.tsx create mode 100644 cvat-ui/src/components/labels-editor/skeleton-element-context-menu.tsx create mode 100644 cvat-ui/src/components/shortcuts.context.tsx create mode 100644 cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item-details.tsx rename cvat-ui/src/reducers/{interfaces.ts => index.ts} (99%) create mode 100644 cvat/apps/engine/migrations/0057_auto_20220726_0926.py create mode 100644 cvat/apps/engine/migrations/0058_auto_20220809_1236.py create mode 100644 cvat/apps/engine/migrations/0059_labeledshape_outside.py diff --git a/.eslintrc.js b/.eslintrc.js index 2891b5c9..20119f95 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,10 +24,10 @@ module.exports = { 'plugin:import/typescript', 'plugin:@typescript-eslint/recommended', 'airbnb-typescript/base', ], rules: { - 'header/header': [2, 'line', [{ - pattern: ' {1}Copyright \\(C\\) (?:20\\d{2}-)?2022 Intel Corporation', - template: ' Copyright (C) 2022 Intel Corporation' - }, '', ' SPDX-License-Identifier: MIT']], + // 'header/header': [2, 'line', [{ + // pattern: ' {1}Copyright \\(C\\) (?:20\\d{2}-)?2022 Intel Corporation', + // template: ' Copyright (C) 2022 Intel Corporation' + // }, '', ' SPDX-License-Identifier: MIT']], 'no-plusplus': 0, 'no-continue': 0, 'no-console': 0, @@ -52,6 +52,7 @@ module.exports = { 'import/order': ['error', {'groups': ['builtin', 'external', 'internal']}], 'import/prefer-default-export': 0, // works incorrect with interfaces + '@typescript-eslint/ban-ts-comment': 0, '@typescript-eslint/no-explicit-any': 0, '@typescript-eslint/indent': ['error', 4], '@typescript-eslint/lines-between-class-members': 0, diff --git a/CHANGELOG.md b/CHANGELOG.md index b13c11dd..9e26f428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## \[2.2.0] - Unreleased ### Added +- Added ability to delete frames from a job based on () - Support of attributes returned by serverless functions based on () - Project/task backups uploading via chunk uploads - Fixed UX bug when jobs pagination is reset after changing a job @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documentation for LDAP authentication () - OpenCV.js caching and autoload () - Publishing dev version of CVAT docker images () +- Support of Human Pose Estimation, Facial Landmarks (and similar) use-cases, new shape type: Skeleton () ### Changed - Bumped nuclio version to 1.8.14 @@ -57,7 +59,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Advanced filtration and sorting for a list of tasks/projects/cloudstorages () - Project dataset importing via chunk uploads () - Support paginated list for job commits () -- Added ability to delete frames from a job () ### Changed - Added missing geos dependency into Dockerfile () diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index 0664dbf2..ef594a23 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.14.0", + "version": "2.15.0", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", "scripts": { diff --git a/cvat-canvas/src/scss/canvas.scss b/cvat-canvas/src/scss/canvas.scss index 7cd0661c..a16e76c5 100644 --- a/cvat-canvas/src/scss/canvas.scss +++ b/cvat-canvas/src/scss/canvas.scss @@ -10,6 +10,12 @@ stroke-opacity: 1; } +g.cvat_canvas_shape { + > circle { + fill-opacity: 1; + } +} + polyline.cvat_canvas_shape { fill-opacity: 0; } @@ -120,7 +126,6 @@ polyline.cvat_canvas_shape_splitting { @extend .cvat_shape_drawing_opacity; fill: white; - stroke: black; } .cvat_canvas_zoom_selection { @@ -134,6 +139,12 @@ polyline.cvat_canvas_shape_splitting { stroke-dasharray: 5; } +g.cvat_canvas_shape_occluded { + > rect { + stroke-dasharray: 5; + } +} + .svg_select_points_rot { fill: white; } @@ -226,6 +237,12 @@ polyline.cvat_canvas_shape_splitting { } } +.cvat_canvas_skeleton_wrapping_rect { + // wrapping rect must not apply transform attribute from selectize.js + // otherwise it rotated twice, because we apply the same rotation value to parent element (skeleton itself) + transform: none !important; +} + .cvat_canvas_pixelized { image-rendering: optimizeSpeed; /* Legal fallback */ image-rendering: -moz-crisp-edges; /* Firefox */ diff --git a/cvat-canvas/src/typescript/autoborderHandler.ts b/cvat-canvas/src/typescript/autoborderHandler.ts index 78ba533c..aaf119d0 100644 --- a/cvat-canvas/src/typescript/autoborderHandler.ts +++ b/cvat-canvas/src/typescript/autoborderHandler.ts @@ -5,7 +5,7 @@ import * as SVG from 'svg.js'; import consts from './consts'; -import { Geometry } from './canvasModel'; +import { Configuration, Geometry } from './canvasModel'; interface TransformedShape { points: string; @@ -14,6 +14,7 @@ interface TransformedShape { export interface AutoborderHandler { autoborder(enabled: boolean, currentShape?: SVG.Shape, currentID?: number): void; + configurate(configuration: Configuration): void; transform(geometry: Geometry): void; updateObjects(): void; } @@ -24,19 +25,14 @@ export class AutoborderHandlerImpl implements AutoborderHandler { private frameContent: SVGSVGElement; private enabled: boolean; private scale: number; + private controlPointsSize: number; private groups: SVGGElement[]; private auxiliaryGroupID: number | null; private auxiliaryClicks: number[]; - private listeners: Record< - number, - Record< - number, - { + private listeners: Record void; dblclick: (event: MouseEvent) => void; - } - > - >; + }>>; public constructor(frameContent: SVGSVGElement) { this.frameContent = frameContent; @@ -45,6 +41,7 @@ export class AutoborderHandlerImpl implements AutoborderHandler { this.enabled = false; this.scale = 1; this.groups = []; + this.controlPointsSize = consts.BASE_POINT_SIZE; this.auxiliaryGroupID = null; this.auxiliaryClicks = []; this.listeners = {}; @@ -126,7 +123,7 @@ export class AutoborderHandlerImpl implements AutoborderHandler { circle.setAttribute('stroke-width', `${consts.POINTS_STROKE_WIDTH / this.scale}`); circle.setAttribute('cx', x); circle.setAttribute('cy', y); - circle.setAttribute('r', `${consts.BASE_POINT_SIZE / this.scale}`); + circle.setAttribute('r', `${this.controlPointsSize / this.scale}`); const click = (event: MouseEvent): void => { event.stopPropagation(); @@ -303,9 +300,13 @@ export class AutoborderHandlerImpl implements AutoborderHandler { this.scale = geometry.scale; this.groups.forEach((group: SVGGElement): void => { Array.from(group.children).forEach((circle: SVGCircleElement): void => { - circle.setAttribute('r', `${consts.BASE_POINT_SIZE / this.scale}`); + circle.setAttribute('r', `${this.controlPointsSize / this.scale}`); circle.setAttribute('stroke-width', `${consts.BASE_STROKE_WIDTH / this.scale}`); }); }); } + + public configurate(configuration: Configuration): void { + this.controlPointsSize = configuration.controlPointsSize || consts.BASE_POINT_SIZE; + } } diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index 5c35d4e3..79502ca3 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -64,8 +64,12 @@ export interface Configuration { forceDisableEditing?: boolean; intelligentPolygonCrop?: boolean; forceFrameUpdate?: boolean; - creationOpacity?: number; CSSImageFilter?: string; + colorBy?: string; + selectedShapeOpacity?: number; + shapeOpacity?: number; + controlPointsSize?: number; + outlinedBorders?: string | false; } export interface DrawData { @@ -73,6 +77,7 @@ export interface DrawData { shapeType?: string; rectDrawingMethod?: RectDrawingMethod; cuboidDrawingMethod?: CuboidDrawingMethod; + skeletonSVG?: string; numberOfPoints?: number; initialState?: any; crosshair?: boolean; @@ -265,12 +270,23 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { width: 0, }, configuration: { - displayAllText: false, + smoothImage: true, autoborders: false, - undefinedAttrValue: '', - textContent: 'id,label,attributes,source,descriptions', - textPosition: 'auto', + displayAllText: false, + showProjections: false, + forceDisableEditing: false, + intelligentPolygonCrop: false, + forceFrameUpdate: false, + CSSImageFilter: '', + colorBy: 'Label', + selectedShapeOpacity: 0.5, + shapeOpacity: 0.2, + outlinedBorders: false, textFontSize: consts.DEFAULT_SHAPE_TEXT_SIZE, + controlPointsSize: consts.BASE_POINT_SIZE, + textPosition: consts.DEFAULT_SHAPE_TEXT_POSITION, + textContent: consts.DEFAULT_SHAPE_TEXT_CONTENT, + undefinedAttrValue: consts.DEFAULT_UNDEFINED_ATTR_VALUE, }, imageBitmap: false, image: null, @@ -541,6 +557,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { } if (drawData.enabled) { + if (drawData.shapeType === 'skeleton' && !drawData.skeletonSVG) { + throw new Error('Skeleton template must be specified when drawing a skeleton'); + } + if (this.data.drawData.enabled) { throw new Error('Drawing has been already started'); } else if (!drawData.shapeType && !drawData.initialState) { @@ -670,6 +690,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.data.configuration.textFontSize = configuration.textFontSize; } + if (typeof configuration.controlPointsSize === 'number') { + this.data.configuration.controlPointsSize = configuration.controlPointsSize; + } + if (['auto', 'center'].includes(configuration.textPosition)) { this.data.configuration.textPosition = configuration.textPosition; } @@ -702,8 +726,17 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { if (typeof configuration.forceFrameUpdate === 'boolean') { this.data.configuration.forceFrameUpdate = configuration.forceFrameUpdate; } - if (typeof configuration.creationOpacity === 'number') { - this.data.configuration.creationOpacity = configuration.creationOpacity; + if (typeof configuration.selectedShapeOpacity === 'number') { + this.data.configuration.selectedShapeOpacity = configuration.selectedShapeOpacity; + } + if (typeof configuration.shapeOpacity === 'number') { + this.data.configuration.shapeOpacity = configuration.shapeOpacity; + } + if (['string', 'boolean'].includes(typeof configuration.outlinedBorders)) { + this.data.configuration.outlinedBorders = configuration.outlinedBorders; + } + if (['Instance', 'Group', 'Label'].includes(configuration.colorBy)) { + this.data.configuration.colorBy = configuration.colorBy; } if (typeof configuration.CSSImageFilter === 'string') { diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 4d6ca1da..b340675f 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -35,6 +35,8 @@ import { DrawnState, rotate2DPoints, readPointsFromShape, + setupSkeletonEdges, + makeSVGFromTemplate, } from './shared'; import { CanvasModel, @@ -117,20 +119,29 @@ export class CanvasViewImpl implements CanvasView, Listener { return translateFromCanvas(offset, points); } - private translatePointsFromRotatedShape(shape: SVG.Shape, points: number[]): number[] { + private translatePointsFromRotatedShape( + shape: SVG.Shape, points: number[], cx: number = null, cy: number = null, + ): number[] { const { rotation } = shape.transform(); - // currently shape is rotated and shifted somehow additionally (css transform property) + // currently shape is rotated and SHIFTED somehow additionally (css transform property) // let's remove rotation to get correct transformation matrix (element -> screen) // correct means that we do not consider points to be rotated // because rotation property is stored separately and already saved - shape.rotate(0); + if (cx !== null && cy !== null) { + shape.rotate(0, cx, cy); + } else { + shape.rotate(0); + } + const result = []; try { // get each point and apply a couple of matrix transformation to it const point = this.content.createSVGPoint(); - // matrix to convert from ELEMENT file system to CLIENT coordinate system - const ctm = ((shape.node as any) as SVGRectElement | SVGPolygonElement | SVGPolylineElement).getScreenCTM(); + // matrix to convert from ELEMENT coordinate system to CLIENT coordinate system + const ctm = ( + (shape.node as any) as SVGRectElement | SVGPolygonElement | SVGPolylineElement | SVGGElement + ).getScreenCTM(); // matrix to convert from CLIENT coordinate system to CANVAS coordinate system const ctm1 = this.content.getScreenCTM().inverse(); // NOTE: I tried to use element.getCTM(), but this way does not work on firefox @@ -144,7 +155,11 @@ export class CanvasViewImpl implements CanvasView, Listener { result.push(transformedPoint.x, transformedPoint.y); } } finally { - shape.rotate(rotation); + if (cx !== null && cy !== null) { + shape.rotate(rotation, cx, cy); + } else { + shape.rotate(rotation); + } } return result; @@ -193,7 +208,7 @@ export class CanvasViewImpl implements CanvasView, Listener { if (text) { text.removeClass('cvat_canvas_hidden'); - this.updateTextPosition(text, shape); + this.updateTextPosition(text); } } } @@ -530,7 +545,7 @@ export class CanvasViewImpl implements CanvasView, Listener { ...window.document.getElementsByClassName('svg_select_points_rot'), ]) { element.setAttribute('stroke-width', `${consts.POINTS_STROKE_WIDTH / this.geometry.scale}`); - element.setAttribute('r', `${consts.BASE_POINT_SIZE / this.geometry.scale}`); + element.setAttribute('r', `${this.configuration.controlPointsSize / this.geometry.scale}`); } for (const element of window.document.getElementsByClassName('cvat_canvas_poly_direction')) { @@ -546,24 +561,24 @@ export class CanvasViewImpl implements CanvasView, Listener { element.setAttribute('stroke-width', `${+previousWidth * 2}`); } - // Transform all drawn shapes - for (const key in this.svgShapes) { - if (Object.prototype.hasOwnProperty.call(this.svgShapes, key)) { - const object = this.svgShapes[key]; - object.attr({ - 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - }); + // Transform all drawn shapes and text + for (const key of Object.keys(this.svgShapes)) { + const clientID = +key; + const object = this.svgShapes[clientID]; + object.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }); + if (object.type === 'circle') { + object.attr('r', `${this.configuration.controlPointsSize / this.geometry.scale}`); + } + if (clientID in this.svgTexts) { + this.updateTextPosition(this.svgTexts[clientID]); } } - // Transform all text - for (const key in this.svgShapes) { - if ( - Object.prototype.hasOwnProperty.call(this.svgShapes, key) && - Object.prototype.hasOwnProperty.call(this.svgTexts, key) - ) { - this.updateTextPosition(this.svgTexts[key], this.svgShapes[key]); - } + // Transform skeleton edges + for (const skeletonEdge of window.document.getElementsByClassName('cvat_canvas_skeleton_edge')) { + skeletonEdge.setAttribute('stroke-width', `${consts.BASE_STROKE_WIDTH / this.geometry.scale}`); } // Transform all drawn issues region @@ -692,7 +707,16 @@ export class CanvasViewImpl implements CanvasView, Listener { this.deleteObjects(deleted); this.addObjects(created); - this.updateObjects(updated); + + const updatedSkeletons = updated.filter((state: any): boolean => state.shapeType === 'skeleton'); + const updatedNotSkeletons = updated.filter((state: any): boolean => state.shapeType !== 'skeleton'); + // todo: implement updateObjects for skeletons, add group and color to updateObjects function + // change colors if necessary (for example when instance color is changed) + this.updateObjects(updatedNotSkeletons); + + this.deleteObjects(updatedSkeletons); + this.addObjects(updatedSkeletons); + this.sortObjects(); if (this.controller.activeElement.clientID !== null) { @@ -873,7 +897,7 @@ export class CanvasViewImpl implements CanvasView, Listener { const getActiveElement = (): ActiveElement => this.activeElement; (shape as any).selectize(value, { deepSelect: true, - pointSize: (2 * consts.BASE_POINT_SIZE) / this.geometry.scale, + pointSize: (2 * this.configuration.controlPointsSize) / this.geometry.scale, rotationPoint: shape.type === 'rect' || shape.type === 'ellipse', pointType(cx: number, cy: number): SVG.Circle { const circle: SVG.Circle = this.nested @@ -936,7 +960,14 @@ export class CanvasViewImpl implements CanvasView, Listener { } const [rotationPoint] = window.document.getElementsByClassName('svg_select_points_rot'); + const [topPoint] = window.document.getElementsByClassName('svg_select_points_t'); if (rotationPoint && !rotationPoint.children.length) { + if (topPoint) { + const rotY = +(rotationPoint as SVGEllipseElement).getAttribute('cy'); + const topY = +(topPoint as SVGEllipseElement).getAttribute('cy'); + (rotationPoint as SVGCircleElement).style.transform = `translate(0px, -${rotY - topY + 20}px)`; + } + const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); title.textContent = 'Hold Shift to snap angle'; rotationPoint.appendChild(title); @@ -949,7 +980,14 @@ export class CanvasViewImpl implements CanvasView, Listener { if (this.activeElement) { const shape = this.svgShapes[this.activeElement.clientID]; if (shape && shape.hasClass('cvat_canvas_shape_activated')) { - (shape as any).resize({ snapToAngle: this.snapToAngleResize }); + if (this.drawnStates[this.activeElement.clientID]?.shapeType === 'skeleton') { + const wrappingRect = (shape as any).children().find((child: SVG.Element) => child.type === 'rect'); + if (wrappingRect) { + (wrappingRect as any).resize({ snapToAngle: this.snapToAngleResize }); + } + } else { + (shape as any).resize({ snapToAngle: this.snapToAngleResize }); + } } } } @@ -961,7 +999,14 @@ export class CanvasViewImpl implements CanvasView, Listener { if (this.activeElement) { const shape = this.svgShapes[this.activeElement.clientID]; if (shape && shape.hasClass('cvat_canvas_shape_activated')) { - (shape as any).resize({ snapToAngle: this.snapToAngleResize }); + if (this.drawnStates[this.activeElement.clientID]?.shapeType === 'skeleton') { + const wrappingRect = (shape as any).children().find((child: SVG.Element) => child.type === 'rect'); + if (wrappingRect) { + (wrappingRect as any).resize({ snapToAngle: this.snapToAngleResize }); + } + } else { + (shape as any).resize({ snapToAngle: this.snapToAngleResize }); + } } } } @@ -1196,6 +1241,34 @@ export class CanvasViewImpl implements CanvasView, Listener { this.deactivate(); const { configuration } = model; + const updateShapeViews = (states: DrawnState[], parentState?: DrawnState): void => { + for (const state of states) { + const { fill, stroke, 'fill-opacity': fillOpacity } = this.getShapeColorization(state, { configuration, parentState }); + const shapeView = window.document.getElementById(`cvat_canvas_shape_${state.clientID}`); + if (shapeView) { + const handler = (shapeView as any).instance.remember('_selectHandler'); + if (handler && handler.nested) { + handler.nested.fill({ color: fill }); + } + + (shapeView as any).instance + .fill({ color: fill, opacity: fillOpacity }) + .stroke({ color: stroke }); + } + + if (state.elements) { + updateShapeViews(state.elements, state); + } + } + }; + + if (configuration.shapeOpacity !== this.configuration.shapeOpacity || + configuration.selectedShapeOpacity !== this.configuration.selectedShapeOpacity || + configuration.outlinedBorders !== this.configuration.outlinedBorders || + configuration.colorBy !== this.configuration.colorBy) { + updateShapeViews(Object.values(this.drawnStates)); + } + if (configuration.displayAllText && !this.configuration.displayAllText) { for (const i in this.drawnStates) { if (!(i in this.svgTexts)) { @@ -1203,10 +1276,9 @@ export class CanvasViewImpl implements CanvasView, Listener { } } } else if (configuration.displayAllText === false && this.configuration.displayAllText) { - for (const i in this.drawnStates) { - if (i in this.svgTexts && Number.parseInt(i, 10) !== activeElement.clientID) { - this.svgTexts[i].remove(); - delete this.svgTexts[i]; + for (const clientID in this.drawnStates) { + if (+clientID !== activeElement.clientID) { + this.deleteText(+clientID); } } } @@ -1230,9 +1302,10 @@ export class CanvasViewImpl implements CanvasView, Listener { const clientID = +key; const [state] = states.filter((_state: any) => _state.clientID === clientID); if (clientID in this.svgTexts) { - this.svgTexts[clientID].remove(); - delete this.svgTexts[clientID]; - if (state) this.svgTexts[clientID] = this.addText(state); + this.deleteText(+clientID); + if (state) { + this.svgTexts[clientID] = this.addText(state); + } } } } @@ -1240,7 +1313,7 @@ export class CanvasViewImpl implements CanvasView, Listener { if (updateTextPosition) { for (const i in this.drawnStates) { if (i in this.svgTexts) { - this.updateTextPosition(this.svgTexts[i], this.svgShapes[i]); + this.updateTextPosition(this.svgTexts[i]); } } } @@ -1252,7 +1325,9 @@ export class CanvasViewImpl implements CanvasView, Listener { this.activate(activeElement); this.editHandler.configurate(this.configuration); this.drawHandler.configurate(this.configuration); + this.autoborderHandler.configurate(this.configuration); this.interactionHandler.configurate(this.configuration); + this.transformCanvas(); // remove if exist and not enabled // this.setupObjects([]); @@ -1632,8 +1707,8 @@ export class CanvasViewImpl implements CanvasView, Listener { } } - private saveState(state: any): void { - this.drawnStates[state.clientID] = { + private saveState(state: any): DrawnState { + const result = { clientID: state.clientID, outside: state.outside, occluded: state.occluded, @@ -1650,6 +1725,38 @@ export class CanvasViewImpl implements CanvasView, Listener { updated: state.updated, frame: state.frame, label: state.label, + group: { id: state.group.id, color: state.group.color }, + color: state.color, + elements: state.shapeType === 'skeleton' ? + state.elements.map((element: any) => this.saveState(element)) : null, + }; + + return result; + } + + private getShapeColorization(state: any, opts: { + configuration?: Configuration, + parentState?: any, + } = {}): { fill: string; stroke: string, 'fill-opacity': number } { + const { shapeType } = state; + const parentShapeType = opts.parentState?.shapeType; + const configuration = opts.configuration || this.configuration; + const { colorBy, shapeOpacity, outlinedBorders } = configuration; + let shapeColor = ''; + + if (colorBy === 'Instance') { + shapeColor = state.color; + } else if (colorBy === 'Group') { + shapeColor = state.group.color; + } else if (colorBy === 'Label') { + shapeColor = state.label.color; + } + const outlinedColor = parentShapeType === 'skeleton' ? 'black' : outlinedBorders || shapeColor; + + return { + fill: shapeColor, + stroke: outlinedColor, + 'fill-opacity': !['polyline', 'points', 'skeleton'].includes(shapeType) || parentShapeType === 'skeleton' ? shapeOpacity : 0, }; } @@ -1675,7 +1782,7 @@ export class CanvasViewImpl implements CanvasView, Listener { ); if (text) { text.removeClass('cvat_canvas_hidden'); - this.updateTextPosition(text, shape); + this.updateTextPosition(text); } } } @@ -1775,23 +1882,37 @@ export class CanvasViewImpl implements CanvasView, Listener { } } - this.saveState(state); + if ( + drawnState.group.id !== state.group.id || drawnState.group.color !== state.group.color + ) { + shape.attr({ ...this.getShapeColorization(state) }); + } + + this.drawnStates[state.clientID] = this.saveState(state); } } private deleteObjects(states: any[]): void { for (const state of states) { if (state.clientID in this.svgTexts) { - this.svgTexts[state.clientID].remove(); - delete this.svgTexts[state.clientID]; + this.deleteText(state.clientID); } - this.svgShapes[state.clientID].fire('remove'); - this.svgShapes[state.clientID].off('click'); - this.svgShapes[state.clientID].off('remove'); - this.svgShapes[state.clientID].remove(); - delete this.drawnStates[state.clientID]; - delete this.svgShapes[state.clientID]; + if (state.shapeType === 'skeleton') { + this.deleteObjects(state.elements); + } + + if (state.clientID in this.svgShapes) { + this.svgShapes[state.clientID].fire('remove'); + this.svgShapes[state.clientID].off('click'); + this.svgShapes[state.clientID].off('remove'); + this.svgShapes[state.clientID].remove(); + delete this.svgShapes[state.clientID]; + } + + if (state.clientID in this.drawnStates) { + delete this.drawnStates[state.clientID]; + } } } @@ -1804,6 +1925,8 @@ export class CanvasViewImpl implements CanvasView, Listener { // TODO: Use enums after typification cvat-core if (state.shapeType === 'rectangle') { this.svgShapes[state.clientID] = this.addRect(translatedPoints, state); + } else if (state.shapeType === 'skeleton') { + this.svgShapes[state.clientID] = this.addSkeleton(state); } else { const stringified = this.stringifyToCanvas(translatedPoints); @@ -1836,10 +1959,10 @@ export class CanvasViewImpl implements CanvasView, Listener { if (displayAllText) { this.svgTexts[state.clientID] = this.addText(state); - this.updateTextPosition(this.svgTexts[state.clientID], this.svgShapes[state.clientID]); + this.updateTextPosition(this.svgTexts[state.clientID]); } - this.saveState(state); + this.drawnStates[state.clientID] = this.saveState(state); } } @@ -1918,8 +2041,7 @@ export class CanvasViewImpl implements CanvasView, Listener { // TODO: Hide text only if it is hidden by settings const text = this.svgTexts[clientID]; if (text && !displayAllText) { - text.remove(); - delete this.svgTexts[clientID]; + this.deleteText(clientID); } this.sortObjects(); @@ -1970,7 +2092,7 @@ export class CanvasViewImpl implements CanvasView, Listener { text = this.addText(state); this.svgTexts[state.clientID] = text; } - this.updateTextPosition(text, shape); + this.updateTextPosition(text); if (this.stateIsLocked(state)) { return; @@ -1997,7 +2119,7 @@ export class CanvasViewImpl implements CanvasView, Listener { const showText = (): void => { if (text) { text.removeClass('cvat_canvas_hidden'); - this.updateTextPosition(text, shape); + this.updateTextPosition(text); } }; @@ -2037,6 +2159,7 @@ export class CanvasViewImpl implements CanvasView, Listener { } points = this.translateFromCanvas(points); + this.onEditDone(state, points); this.canvas.dispatchEvent( new CustomEvent('canvas.dragshape', { bubbles: false, @@ -2046,7 +2169,6 @@ export class CanvasViewImpl implements CanvasView, Listener { }, }), ); - this.onEditDone(state, points); } }); } @@ -2128,7 +2250,7 @@ export class CanvasViewImpl implements CanvasView, Listener { points = this.translatePointsFromRotatedShape(shape, points); } - // points = this.translateFromCanvas(points); + this.onEditDone(state, this.translateFromCanvas(points), rotation); this.canvas.dispatchEvent( new CustomEvent('canvas.resizeshape', { bubbles: false, @@ -2138,7 +2260,6 @@ export class CanvasViewImpl implements CanvasView, Listener { }, }), ); - this.onEditDone(state, this.translateFromCanvas(points), rotation); } }); @@ -2179,13 +2300,21 @@ export class CanvasViewImpl implements CanvasView, Listener { } // Update text position after corresponding box has been moved, resized, etc. - private updateTextPosition(text: SVG.Text, shape: SVG.Shape): void { + private updateTextPosition( + text: SVG.Text, + options: { rotation?: { angle: number, cx: number, cy: number } } = {}, + ): void { + const clientID = text.attr('data-client-id'); + if (!Number.isInteger(clientID)) return; + const shape = this.svgShapes[clientID]; + if (!shape) return; + if (text.node.style.display === 'none') return; // wrong transformation matrix const { textFontSize, textPosition } = this.configuration; text.untransform(); text.style({ 'font-size': `${textFontSize}px` }); - const { rotation } = shape.transform(); + const rotation = options.rotation?.angle || shape.transform().rotation; // Find the best place for a text let [clientX, clientY, clientCX, clientCY]: number[] = [0, 0, 0, 0]; @@ -2246,8 +2375,8 @@ export class CanvasViewImpl implements CanvasView, Listener { const [x, y, rotX, rotY]: number[] = translateToSVG(this.text, [ clientX + (textPosition === 'auto' ? consts.TEXT_MARGIN : 0), clientY + (textPosition === 'auto' ? consts.TEXT_MARGIN : 0), - clientCX, - clientCY, + options.rotation?.cx || clientCX, + options.rotation?.cy || clientCY, ]); const textBBox = ((text.node as any) as SVGTextElement).getBBox(); @@ -2258,8 +2387,24 @@ export class CanvasViewImpl implements CanvasView, Listener { text.move(x, y); } + let childOptions = {}; if (rotation) { text.rotate(rotation, rotX, rotY); + childOptions = { + rotation: { + angle: rotation, + cx: clientCX, + cy: clientCY, + }, + }; + } + + if (clientID in this.drawnStates && this.drawnStates[clientID].shapeType === 'skeleton') { + this.drawnStates[clientID].elements.forEach((element: DrawnState) => { + if (element.clientID in this.svgTexts) { + this.updateTextPosition(this.svgTexts[element.clientID], childOptions); + } + }); } for (const tspan of (text.lines() as any).members) { @@ -2267,15 +2412,27 @@ export class CanvasViewImpl implements CanvasView, Listener { } } - private addText(state: any): SVG.Text { + private deleteText(clientID: number): void { + if (clientID in this.svgTexts) { + this.svgTexts[clientID].remove(); + delete this.svgTexts[clientID]; + } + + if (clientID in this.drawnStates && this.drawnStates[clientID].shapeType === 'skeleton') { + this.drawnStates[clientID].elements.forEach((element) => { + this.deleteText(element.clientID); + }); + } + } + + private addText(state: any, options: { textContent?: string } = {}): SVG.Text { const { undefinedAttrValue } = this.configuration; - const content = this.configuration.textContent; + const content = options.textContent || this.configuration.textContent; const withID = content.includes('id'); const withAttr = content.includes('attributes'); const withLabel = content.includes('label'); const withSource = content.includes('source'); const withDescriptions = content.includes('descriptions'); - const textFontSize = this.configuration.textFontSize || 12; const { label, clientID, attributes, source, descriptions, @@ -2285,6 +2442,19 @@ export class CanvasViewImpl implements CanvasView, Listener { return acc; }, {}); + if (state.shapeType === 'skeleton') { + state.elements.forEach((element: any) => { + if (!(element.clientID in this.svgTexts)) { + this.svgTexts[element.clientID] = this.addText(element, { + textContent: [ + ...(withLabel ? ['label'] : []), + ...(withAttr ? ['attributes'] : []), + ].join(',') || ' ', + }); + } + }); + } + return this.adoptedText .text((block): void => { block.tspan(`${withLabel ? label.name : ''} ${withID ? clientID : ''} ${withSource ? `(${source})` : ''}`).style({ @@ -2316,6 +2486,7 @@ export class CanvasViewImpl implements CanvasView, Listener { } }) .move(0, 0) + .attr({ 'data-client-id': state.clientID }) .style({ 'font-size': textFontSize }) .addClass('cvat_canvas_text'); } @@ -2329,14 +2500,11 @@ export class CanvasViewImpl implements CanvasView, Listener { clientID: state.clientID, 'color-rendering': 'optimizeQuality', id: `cvat_canvas_shape_${state.clientID}`, - fill: state.color, 'shape-rendering': 'geometricprecision', - stroke: state.color, 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, 'data-z-order': state.zOrder, - }) - .move(xtl, ytl) - .addClass('cvat_canvas_shape'); + ...this.getShapeColorization(state), + }).move(xtl, ytl).addClass('cvat_canvas_shape'); if (state.rotation) { rect.rotate(state.rotation); @@ -2360,13 +2528,11 @@ export class CanvasViewImpl implements CanvasView, Listener { clientID: state.clientID, 'color-rendering': 'optimizeQuality', id: `cvat_canvas_shape_${state.clientID}`, - fill: state.color, 'shape-rendering': 'geometricprecision', - stroke: state.color, 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, 'data-z-order': state.zOrder, - }) - .addClass('cvat_canvas_shape'); + ...this.getShapeColorization(state), + }).addClass('cvat_canvas_shape'); if (state.occluded) { polygon.addClass('cvat_canvas_shape_occluded'); @@ -2386,13 +2552,11 @@ export class CanvasViewImpl implements CanvasView, Listener { clientID: state.clientID, 'color-rendering': 'optimizeQuality', id: `cvat_canvas_shape_${state.clientID}`, - fill: state.color, 'shape-rendering': 'geometricprecision', - stroke: state.color, 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, 'data-z-order': state.zOrder, - }) - .addClass('cvat_canvas_shape'); + ...this.getShapeColorization(state), + }).addClass('cvat_canvas_shape'); if (state.occluded) { polyline.addClass('cvat_canvas_shape_occluded'); @@ -2413,13 +2577,11 @@ export class CanvasViewImpl implements CanvasView, Listener { clientID: state.clientID, 'color-rendering': 'optimizeQuality', id: `cvat_canvas_shape_${state.clientID}`, - fill: state.color, 'shape-rendering': 'geometricprecision', - stroke: state.color, 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, 'data-z-order': state.zOrder, - }) - .addClass('cvat_canvas_shape'); + ...this.getShapeColorization(state), + }).addClass('cvat_canvas_shape'); if (state.occluded) { cube.addClass('cvat_canvas_shape_occluded'); @@ -2432,6 +2594,476 @@ export class CanvasViewImpl implements CanvasView, Listener { return cube; } + private addSkeleton(state: any): any { + const skeleton = (this.adoptedContent as any) + .group() + .attr({ + clientID: state.clientID, + 'color-rendering': 'optimizeQuality', + id: `cvat_canvas_shape_${state.clientID}`, + 'shape-rendering': 'geometricprecision', + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + 'data-z-order': state.zOrder, + ...this.getShapeColorization(state), + }).addClass('cvat_canvas_shape') as SVG.G; + + const SVGElement = makeSVGFromTemplate(state.label.structure.svg); + + let xtl = Number.MAX_SAFE_INTEGER; + let ytl = Number.MAX_SAFE_INTEGER; + let xbr = Number.MIN_SAFE_INTEGER; + let ybr = Number.MIN_SAFE_INTEGER; + + const svgElements: Record = {}; + const templateElements = Array.from(SVGElement.children()).filter((el: SVG.Element) => el.type === 'circle'); + for (let i = 0; i < state.elements.length; i++) { + const element = state.elements[i]; + if (element.shapeType === 'points') { + const points: number[] = element.points as number[]; + const [cx, cy] = this.translateToCanvas(points); + + if (!element.outside) { + xtl = Math.min(xtl, cx); + ytl = Math.min(ytl, cy); + xbr = Math.max(xbr, cx); + ybr = Math.max(ybr, cy); + } + + const templateElement = templateElements.find((el: SVG.Circle) => el.attr('data-label-id') === element.label.id); + const circle = skeleton.circle() + .center(cx, cy) + .attr({ + id: `cvat_canvas_shape_${element.clientID}`, + r: this.configuration.controlPointsSize / this.geometry.scale, + 'color-rendering': 'optimizeQuality', + 'shape-rendering': 'geometricprecision', + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + 'data-node-id': templateElement.attr('data-node-id'), + 'data-element-id': templateElement.attr('data-element-id'), + 'data-label-id': templateElement.attr('data-label-id'), + 'data-client-id': element.clientID, + ...this.getShapeColorization(element, { parentState: state }), + }).style({ + cursor: 'default', + }); + this.svgShapes[element.clientID] = circle; + if (element.occluded) { + circle.addClass('cvat_canvas_shape_occluded'); + } + + if (element.hidden || element.outside || this.isInnerHidden(element.clientID)) { + circle.addClass('cvat_canvas_hidden'); + } + + const mouseover = (e: MouseEvent): void => { + const locked = this.drawnStates[state.clientID].lock; + if (!locked && !e.ctrlKey) { + circle.attr({ + 'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale, + }); + + const [x, y] = translateToSVG(this.content, [e.clientX, e.clientY]); + const event: CustomEvent = new CustomEvent('canvas.moved', { + bubbles: false, + cancelable: true, + detail: { + x: x - this.geometry.offset, + y: y - this.geometry.offset, + activatedElementID: element.clientID, + states: this.controller.objects, + }, + }); + + this.canvas.dispatchEvent(event); + } + }; + + const mouseleave = (): void => { + circle.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }); + }; + + const click = (e: MouseEvent): void => { + e.stopPropagation(); + this.canvas.dispatchEvent( + new CustomEvent('canvas.clicked', { + bubbles: false, + cancelable: true, + detail: { + state: element, + }, + }), + ); + }; + + circle.on('mouseover', mouseover); + circle.on('mouseleave', mouseleave); + circle.on('click', click); + circle.on('remove', () => { + circle.off('remove'); + circle.off('mouseover', mouseover); + circle.off('mouseleave', mouseleave); + circle.off('click', click); + }); + + svgElements[element.clientID] = circle; + } + } + + xtl -= consts.SKELETON_RECT_MARGIN; + ytl -= consts.SKELETON_RECT_MARGIN; + xbr += consts.SKELETON_RECT_MARGIN; + ybr += consts.SKELETON_RECT_MARGIN; + + skeleton.on('remove', () => { + Object.values(svgElements).forEach((element) => element.fire('remove')); + skeleton.off('remove'); + }); + + const wrappingRect = skeleton.rect(xbr - xtl, ybr - ytl).move(xtl, ytl).attr({ + fill: 'inherit', + 'fill-opacity': 0, + 'color-rendering': 'optimizeQuality', + 'shape-rendering': 'geometricprecision', + stroke: 'inherit', + 'stroke-width': 'inherit', + 'data-xtl': xtl, + 'data-ytl': ytl, + 'data-xbr': xbr, + 'data-ybr': ybr, + }).addClass('cvat_canvas_skeleton_wrapping_rect'); + + skeleton.node.prepend(wrappingRect.node); + setupSkeletonEdges(skeleton, SVGElement); + + if (state.occluded) { + skeleton.addClass('cvat_canvas_shape_occluded'); + } + + if (state.hidden || state.outside || this.isInnerHidden(state.clientID)) { + skeleton.addClass('cvat_canvas_hidden'); + } + + (skeleton as any).selectize = (enabled: boolean) => { + this.selectize(enabled, wrappingRect); + const handler = wrappingRect.remember('_selectHandler'); + if (enabled && handler) { + this.adoptedContent.node.append(handler.nested.node); + handler.nested.attr('fill', skeleton.attr('fill')); + } + + return skeleton; + }; + + (skeleton as any).draggable = (enabled = true) => { + const textList = [ + state.clientID, ...state.elements.map((element: any): number => element.clientID), + ].map((clientID: number) => this.svgTexts[clientID]).filter((text: SVG.Text | undefined) => ( + typeof text !== 'undefined' + )); + + const hideText = (): void => { + textList.forEach((text: SVG.Text) => { + text.addClass('cvat_canvas_hidden'); + }); + }; + + const showText = (): void => { + textList.forEach((text: SVG.Text) => { + text.removeClass('cvat_canvas_hidden'); + this.updateTextPosition(text); + }); + }; + + if (enabled) { + (wrappingRect as any).draggable() + .on('dragstart', (): void => { + this.mode = Mode.DRAG; + hideText(); + skeleton.on('remove.drag', (): void => { + this.mode = Mode.IDLE; + showText(); + // disable internal drag events of SVG.js + window.dispatchEvent(new MouseEvent('mouseup')); + skeleton.off('remove.drag'); + }); + }) + .on('dragmove', (e: CustomEvent): void => { + // skeleton elements itself are not updated yet, need to run as macrotask + setTimeout(() => { + const { instance } = e.target as any; + const [x, y] = [instance.x(), instance.y()]; + const prevXtl = +wrappingRect.attr('data-xtl'); + const prevYtl = +wrappingRect.attr('data-ytl'); + + for (const child of skeleton.children()) { + if (child.type === 'circle') { + const childClientID = child.attr('data-client-id'); + if (state.elements.find((el: any) => el.clientID === childClientID).lock || false) { + continue; + } + child.center( + child.cx() - prevXtl + x, + child.cy() - prevYtl + y, + ); + } + } + + wrappingRect.attr('data-xtl', x); + wrappingRect.attr('data-ytl', y); + wrappingRect.attr('data-xbr', x + instance.width()); + wrappingRect.attr('data-ybr', y + instance.height()); + + setupSkeletonEdges(skeleton, SVGElement); + }); + }) + .on('dragend', (e: CustomEvent): void => { + setTimeout(() => { + skeleton.off('remove.drag'); + this.mode = Mode.IDLE; + showText(); + const p1 = e.detail.handler.startPoints.point; + const p2 = e.detail.p; + const delta = 1; + const dx2 = (p1.x - p2.x) ** 2; + const dy2 = (p1.y - p2.y) ** 2; + if (Math.sqrt(dx2 + dy2) >= delta) { + state.elements.forEach((element: any) => { + const elementShape = skeleton.children() + .find((child: SVG.Shape) => ( + child.id() === `cvat_canvas_shape_${element.clientID}` + )); + + if (elementShape) { + const points = readPointsFromShape(elementShape); + element.points = this.translateFromCanvas(points); + } + }); + + this.canvas.dispatchEvent( + new CustomEvent('canvas.dragshape', { + bubbles: false, + cancelable: true, + detail: { + id: state.clientID, + }, + }), + ); + this.onEditDone(state, state.points); + } + }); + }); + } else { + (wrappingRect as any).off('dragstart'); + (wrappingRect as any).off('dragend'); + (wrappingRect as any).draggable(false); + } + + return skeleton; + }; + + (skeleton as any).resize = (action: any) => { + const textList = [ + state.clientID, ...state.elements.map((element: any): number => element.clientID), + ].map((clientID: number) => this.svgTexts[clientID]).filter((text: SVG.Text | undefined) => ( + typeof text !== 'undefined' + )); + + const hideText = (): void => { + textList.forEach((text: SVG.Text) => { + text.addClass('cvat_canvas_hidden'); + }); + }; + + const showText = (): void => { + textList.forEach((text: SVG.Text) => { + text.removeClass('cvat_canvas_hidden'); + this.updateTextPosition(text); + }); + }; + + Object.entries(svgElements).forEach(([key, element]) => { + const clientID = +key; + const elementState = state.elements + .find((_element: any) => _element.clientID === clientID); + const text = this.svgTexts[clientID]; + const hideElementText = (): void => { + if (text) { + text.addClass('cvat_canvas_hidden'); + } + }; + + const showElementText = (): void => { + if (text) { + text.removeClass('cvat_canvas_hidden'); + this.updateTextPosition(text); + } + }; + + if (action !== 'stop' && !elementState.lock) { + (element as any).draggable() + .on('dragstart', (): void => { + this.mode = Mode.RESIZE; + hideElementText(); + element.on('remove.drag', (): void => { + this.mode = Mode.IDLE; + // disable internal drag events of SVG.js + window.dispatchEvent(new MouseEvent('mouseup')); + element.off('remove.drag'); + }); + }) + .on('dragmove', (): void => { + // element itself is not updated yet, need to run as macrotask + setTimeout(() => { + setupSkeletonEdges(skeleton, SVGElement); + }); + }) + .on('dragend', (e: CustomEvent): void => { + setTimeout(() => { + element.off('remove.drag'); + this.mode = Mode.IDLE; + const p1 = e.detail.handler.startPoints.point; + const p2 = e.detail.p; + const delta = 1; + const dx2 = (p1.x - p2.x) ** 2; + const dy2 = (p1.y - p2.y) ** 2; + if (Math.sqrt(dx2 + dy2) >= delta) { + const elementShape = skeleton.children() + .find((child: SVG.Shape) => child.id() === `cvat_canvas_shape_${clientID}`); + + if (elementShape) { + const points = readPointsFromShape(elementShape); + this.canvas.dispatchEvent( + new CustomEvent('canvas.resizeshape', { + bubbles: false, + cancelable: true, + detail: { + id: elementState.clientID, + }, + }), + ); + this.onEditDone(elementState, this.translateFromCanvas(points)); + } + } + + showElementText(); + }); + }); + } else { + (element as any).off('dragstart'); + (element as any).off('dragend'); + (element as any).draggable(false); + } + }); + + let resized = false; + if (action !== 'stop') { + (wrappingRect as any).resize(action).on('resizestart', (): void => { + this.mode = Mode.RESIZE; + resized = false; + hideText(); + (wrappingRect as any).on('remove.resize', () => { + this.mode = Mode.IDLE; + showText(); + + // disable internal resize events of SVG.js + window.dispatchEvent(new MouseEvent('mouseup')); + this.mode = Mode.IDLE; + }); + }).on('resizing', (e: CustomEvent): void => { + setTimeout(() => { + const { instance } = e.target as any; + + // rotate skeleton instead of wrapping bounding box + const { rotation } = wrappingRect.transform(); + skeleton.rotate(rotation); + + const [x, y] = [instance.x(), instance.y()]; + const prevXtl = +wrappingRect.attr('data-xtl'); + const prevYtl = +wrappingRect.attr('data-ytl'); + const prevXbr = +wrappingRect.attr('data-xbr'); + const prevYbr = +wrappingRect.attr('data-ybr'); + + if (prevXbr - prevXtl < 0.1) return; + if (prevYbr - prevYtl < 0.1) return; + + for (const child of skeleton.children()) { + if (child.type === 'circle') { + const childClientID = child.attr('data-client-id'); + if (state.elements.find((el: any) => el.clientID === childClientID).lock || false) { + continue; + } + const offsetX = (child.cx() - prevXtl) / (prevXbr - prevXtl); + const offsetY = (child.cy() - prevYtl) / (prevYbr - prevYtl); + child.center(offsetX * instance.width() + x, offsetY * instance.height() + y); + } + } + + wrappingRect.attr('data-xtl', x); + wrappingRect.attr('data-ytl', y); + wrappingRect.attr('data-xbr', x + instance.width()); + wrappingRect.attr('data-ybr', y + instance.height()); + + resized = true; + setupSkeletonEdges(skeleton, SVGElement); + }); + }).on('resizedone', (): void => { + setTimeout(() => { + let { rotation } = skeleton.transform(); + // be sure, that rotation in range [0; 360] + while (rotation < 0) rotation += 360; + rotation %= 360; + showText(); + this.mode = Mode.IDLE; + (wrappingRect as any).off('remove.resize'); + if (resized) { + if (rotation) { + this.onEditDone(state, state.points, rotation); + } else { + const points: number[] = []; + + state.elements.forEach((element: any) => { + const elementShape = skeleton.children() + .find((child: SVG.Shape) => ( + child.id() === `cvat_canvas_shape_${element.clientID}` + )); + + if (elementShape) { + points.push(...this.translateFromCanvas( + readPointsFromShape(elementShape), + )); + } + }); + + this.onEditDone(state, points, rotation); + } + + this.canvas.dispatchEvent( + new CustomEvent('canvas.resizeshape', { + bubbles: false, + cancelable: true, + detail: { + id: state.clientID, + }, + }), + ); + } + }); + }); + } else if (action === 'stop') { + (wrappingRect as any).off('resizestart'); + (wrappingRect as any).off('resizing'); + (wrappingRect as any).off('resizedone'); + (wrappingRect as any).resize('stop'); + } + + return skeleton; + }; + + return skeleton; + } + private setupPoints(basicPolyline: SVG.PolyLine, state: any): any { this.selectize(true, basicPolyline); @@ -2467,11 +3099,10 @@ export class CanvasViewImpl implements CanvasView, Listener { clientID: state.clientID, 'color-rendering': 'optimizeQuality', id: `cvat_canvas_shape_${state.clientID}`, - fill: state.color, 'shape-rendering': 'geometricprecision', - stroke: state.color, 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, 'data-z-order': state.zOrder, + ...this.getShapeColorization(state), }) .center(cx, cy) .addClass('cvat_canvas_shape'); @@ -2499,9 +3130,8 @@ export class CanvasViewImpl implements CanvasView, Listener { 'pointer-events': 'none', 'shape-rendering': 'geometricprecision', 'stroke-width': 0, - fill: state.color, // to right fill property when call SVG.Shape::clone() - }) - .style({ + ...this.getShapeColorization(state), + }).style({ opacity: 0, }); diff --git a/cvat-canvas/src/typescript/consts.ts b/cvat-canvas/src/typescript/consts.ts index 74fa4c26..96ff9805 100644 --- a/cvat-canvas/src/typescript/consts.ts +++ b/cvat-canvas/src/typescript/consts.ts @@ -4,7 +4,7 @@ const BASE_STROKE_WIDTH = 1.25; const BASE_GRID_WIDTH = 2; -const BASE_POINT_SIZE = 5; +const BASE_POINT_SIZE = 4; const TEXT_MARGIN = 10; const AREA_THRESHOLD = 9; const SIZE_THRESHOLD = 3; @@ -19,8 +19,13 @@ const ARROW_PATH = 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 ' + const BASE_PATTERN_SIZE = 5; const SNAP_TO_ANGLE_RESIZE_DEFAULT = 0.1; const SNAP_TO_ANGLE_RESIZE_SHIFT = 15; -const DEFAULT_SHAPE_TEXT_SIZE = 12; const MINIMUM_TEXT_FONT_SIZE = 8; +const SKELETON_RECT_MARGIN = 20; + +const DEFAULT_SHAPE_TEXT_SIZE = 12; +const DEFAULT_SHAPE_TEXT_CONTENT = 'id,label,attributes,source,descriptions'; +const DEFAULT_SHAPE_TEXT_POSITION: 'auto' | 'center' = 'auto'; +const DEFAULT_UNDEFINED_ATTR_VALUE = '__undefined__'; export default { BASE_STROKE_WIDTH, @@ -40,5 +45,9 @@ export default { SNAP_TO_ANGLE_RESIZE_DEFAULT, SNAP_TO_ANGLE_RESIZE_SHIFT, DEFAULT_SHAPE_TEXT_SIZE, + DEFAULT_SHAPE_TEXT_CONTENT, + DEFAULT_SHAPE_TEXT_POSITION, + DEFAULT_UNDEFINED_ATTR_VALUE, MINIMUM_TEXT_FONT_SIZE, + SKELETON_RECT_MARGIN, }; diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index b57976f9..1ac7cade 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -17,6 +17,11 @@ import { Point, readPointsFromShape, clamp, + translateToCanvas, + computeWrappingBox, + makeSVGFromTemplate, + setupSkeletonEdges, + translateFromCanvas, } from './shared'; import Crosshair from './crosshair'; import consts from './consts'; @@ -83,9 +88,11 @@ export class DrawHandlerImpl implements DrawHandler { private crosshair: Crosshair; private drawData: DrawData; private geometry: Geometry; - private configuration: Configuration; private autoborderHandler: AutoborderHandler; private autobordersEnabled: boolean; + private controlPointsSize: number; + private selectedShapeOpacity: number; + private outlinedBorders: string; // we should use any instead of SVG.Shape because svg plugins cannot change declared interface // so, methods like draw() just undefined for SVG.Shape, but nevertheless they exist @@ -348,26 +355,26 @@ export class DrawHandlerImpl implements DrawHandler { this.canvas.off('mousedown.draw'); this.canvas.off('mousemove.draw'); - if (this.pointsGroup) { - this.pointsGroup.remove(); - this.pointsGroup = null; - } - // Draw plugin in some cases isn't activated // For example when draw from initialState // Or when no drawn points, but we call cancel() drawing // We check if it is activated with remember function if (this.drawInstance.remember('_paintHandler')) { - if ( - ['polygon', 'polyline', 'points'].includes(this.drawData.shapeType) || + if (['polygon', 'polyline', 'points'].includes(this.drawData.shapeType) || (this.drawData.shapeType === 'cuboid' && - this.drawData.cuboidDrawingMethod === CuboidDrawingMethod.CORNER_POINTS) - ) { + this.drawData.cuboidDrawingMethod === CuboidDrawingMethod.CORNER_POINTS)) { // Check for unsaved drawn shapes this.drawInstance.draw('done'); } // Clear drawing this.drawInstance.draw('stop'); + } else if (this.drawInstance && this.drawData.shapeType === 'ellipse' && !this.drawData.initialState) { + this.drawInstance.fire('drawstop'); + } + + if (this.pointsGroup) { + this.pointsGroup.remove(); + this.pointsGroup = null; } this.drawInstance.off(); @@ -417,7 +424,8 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - 'fill-opacity': this.configuration.creationOpacity, + 'fill-opacity': this.selectedShapeOpacity, + stroke: this.outlinedBorders, }); } @@ -426,7 +434,8 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - 'fill-opacity': this.configuration.creationOpacity, + 'fill-opacity': this.selectedShapeOpacity, + stroke: this.outlinedBorders, }); const initialPoint: { @@ -442,21 +451,7 @@ export class DrawHandlerImpl implements DrawHandler { const translated = translateToSVG(this.canvas.node as any as SVGSVGElement, [e.clientX, e.clientY]); [initialPoint.x, initialPoint.y] = translated; } else { - const points = this.getFinalEllipseCoordinates(readPointsFromShape(this.drawInstance), false); - const { shapeType, redraw: clientID } = this.drawData; - this.release(); - - if (this.canceled) return; - if (checkConstraint('ellipse', points)) { - this.onDrawDone( - { - clientID, - shapeType, - points, - }, - Date.now() - this.startTimestamp, - ); - } + this.drawInstance.fire('drawstop'); } }); @@ -472,6 +467,25 @@ export class DrawHandlerImpl implements DrawHandler { this.shapeSizeElement.update(this.drawInstance); } }); + + this.drawInstance.on('drawstop', () => { + this.drawInstance.off('drawstop'); + const points = this.getFinalEllipseCoordinates(readPointsFromShape(this.drawInstance), false); + const { shapeType, redraw: clientID } = this.drawData; + this.release(); + + if (this.canceled) return; + if (checkConstraint('ellipse', points)) { + this.onDrawDone( + { + clientID, + shapeType, + points, + }, + Date.now() - this.startTimestamp, + ); + } + }); } private drawBoxBy4Points(): void { @@ -612,7 +626,8 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - 'fill-opacity': this.configuration.creationOpacity, + 'fill-opacity': this.selectedShapeOpacity, + stroke: this.outlinedBorders, }); this.drawPolyshape(); @@ -628,6 +643,7 @@ export class DrawHandlerImpl implements DrawHandler { .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, 'fill-opacity': 0, + stroke: this.outlinedBorders, }); this.drawPolyshape(); @@ -651,6 +667,7 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + stroke: this.outlinedBorders, }); this.drawPolyshape(); } @@ -681,7 +698,131 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - 'fill-opacity': this.configuration.creationOpacity, + 'fill-opacity': this.selectedShapeOpacity, + stroke: this.outlinedBorders, + }); + } + + private drawSkeleton(): void { + this.drawInstance = this.canvas.rect().attr({ + stroke: this.outlinedBorders, + }); + this.pointsGroup = makeSVGFromTemplate(this.drawData.skeletonSVG); + this.canvas.add(this.pointsGroup); + this.pointsGroup.attr('stroke-width', consts.BASE_STROKE_WIDTH / this.geometry.scale); + this.pointsGroup.attr('stroke', this.outlinedBorders); + + let minX = Number.MAX_SAFE_INTEGER; + let minY = Number.MAX_SAFE_INTEGER; + let maxX = 0; + let maxY = 0; + + this.pointsGroup.children().forEach((child: SVG.Element): void => { + const cx = child.cx(); + const cy = child.cy(); + minX = Math.min(cx, minX); + minY = Math.min(cy, minY); + maxX = Math.max(cx, maxX); + maxY = Math.max(cy, maxY); + }); + + this.drawInstance + .on('drawstop', (e: Event): void => { + const points = readPointsFromShape((e.target as any as { instance: SVG.Rect }).instance); + const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(points, true); + const elements: any[] = []; + Array.from(this.pointsGroup.node.children).forEach((child: Element) => { + if (child.tagName === 'circle') { + const cx = +(child.getAttribute('cx') as string) + xtl; + const cy = +(child.getAttribute('cy') as string) + ytl; + const label = +child.getAttribute('data-label-id'); + elements.push({ + shapeType: 'points', + points: [cx, cy], + labelID: label, + }); + } + }); + + const { shapeType, redraw: clientID } = this.drawData; + this.release(); + + if (this.canceled) return; + if (checkConstraint('rectangle', [xtl, ytl, xbr, ybr])) { + this.onDrawDone({ + clientID, + shapeType, + elements, + }, + Date.now() - this.startTimestamp); + } + }) + .on('drawupdate', (): void => { + const x = this.drawInstance.x(); + const y = this.drawInstance.y(); + const width = this.drawInstance.width(); + const height = this.drawInstance.height(); + this.pointsGroup.style({ + transform: `translate(${x}px, ${y}px)`, + }); + + /* eslint-disable-next-line no-unsanitized/property */ + this.pointsGroup.node.innerHTML = this.drawData.skeletonSVG; + Array.from(this.pointsGroup.node.children).forEach((child: Element) => { + const dataType = child.getAttribute('data-type'); + if (child.tagName === 'circle' && dataType && dataType.includes('element')) { + child.setAttribute('r', `${this.controlPointsSize / this.geometry.scale}`); + let cx = +(child.getAttribute('cx') as string); + let cy = +(child.getAttribute('cy') as string); + const cxOffset = (cx - minX) / (maxX - minX); + const cyOffset = (cy - minY) / (maxY - minY); + cx = Number.isNaN(cxOffset) ? 0.5 * width : cxOffset * width; + cy = Number.isNaN(cyOffset) ? 0.5 * height : cyOffset * height; + child.setAttribute('cx', `${cx}`); + child.setAttribute('cy', `${cy}`); + } + }); + + Array.from(this.pointsGroup.node.children).forEach((child: Element) => { + const dataType = child.getAttribute('data-type'); + if (child.tagName === 'line' && dataType && dataType.includes('edge')) { + child.setAttribute('stroke-width', 'inherit'); + child.setAttribute('stroke', 'inherit'); + const dataNodeFrom = child.getAttribute('data-node-from'); + const dataNodeTo = child.getAttribute('data-node-to'); + if (dataNodeFrom && dataNodeTo) { + const from = this.pointsGroup.node.querySelector(`[data-node-id="${dataNodeFrom}"]`); + const to = this.pointsGroup.node.querySelector(`[data-node-id="${dataNodeTo}"]`); + + if (from && to) { + const x1 = from.getAttribute('cx'); + const y1 = from.getAttribute('cy'); + const x2 = to.getAttribute('cx'); + const y2 = to.getAttribute('cy'); + + if (x1 && y1 && x2 && y2) { + child.setAttribute('x1', x1); + child.setAttribute('y1', y1); + child.setAttribute('x2', x2); + child.setAttribute('y2', y2); + } + } + } + let cx = +(child.getAttribute('cx') as string); + let cy = +(child.getAttribute('cy') as string); + const cxOffset = cx / 100; + const cyOffset = cy / 100; + cx = cxOffset * width; + cy = cyOffset * height; + child.setAttribute('cx', `${cx}`); + child.setAttribute('cy', `${cy}`); + } + }); + }) + .addClass('cvat_canvas_shape_drawing') + .attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + 'fill-opacity': this.selectedShapeOpacity, }); } @@ -721,19 +862,18 @@ export class DrawHandlerImpl implements DrawHandler { // Common settings for rectangle and polyshapes private pasteShape(): void { - function moveShape(shape: SVG.Shape, x: number, y: number): void { - const bbox = shape.bbox(); + const moveShape = (shape: SVG.Shape, x: number, y: number): void => { const { rotation } = shape.transform(); shape.untransform(); - shape.move(x - bbox.width / 2, y - bbox.height / 2); + shape.center(x, y); shape.rotate(rotation); - } + }; const { x: initialX, y: initialY } = this.cursorPosition; moveShape(this.drawInstance, initialX, initialY); this.canvas.on('mousemove.draw', (): void => { - const { x, y } = this.cursorPosition; // was computer in another callback + const { x, y } = this.cursorPosition; // was computed in another callback moveShape(this.drawInstance, x, y); }); } @@ -741,11 +881,12 @@ export class DrawHandlerImpl implements DrawHandler { private pasteBox(box: BBox, rotation: number): void { this.drawInstance = (this.canvas as any) .rect(box.width, box.height) - .move(box.x, box.y) + .center(box.x, box.y) .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - 'fill-opacity': this.configuration.creationOpacity, + 'fill-opacity': this.selectedShapeOpacity, + stroke: this.outlinedBorders, }).rotate(rotation); this.pasteShape(); @@ -782,7 +923,8 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - 'fill-opacity': this.configuration.creationOpacity, + 'fill-opacity': this.selectedShapeOpacity, + stroke: this.outlinedBorders, }).rotate(rotation); this.pasteShape(); @@ -820,7 +962,8 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - 'fill-opacity': this.configuration.creationOpacity, + 'fill-opacity': this.selectedShapeOpacity, + stroke: this.outlinedBorders, }); this.pasteShape(); this.pastePolyshape(); @@ -832,6 +975,7 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + stroke: this.outlinedBorders, }); this.pasteShape(); this.pastePolyshape(); @@ -843,26 +987,107 @@ export class DrawHandlerImpl implements DrawHandler { .addClass('cvat_canvas_shape_drawing') .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - 'face-stroke': 'black', - 'fill-opacity': this.configuration.creationOpacity, + 'face-stroke': this.outlinedBorders, + 'fill-opacity': this.selectedShapeOpacity, + stroke: this.outlinedBorders, }); this.pasteShape(); this.pastePolyshape(); } + private pasteSkeleton(box: BBox, elements: any[]): void { + const { offset } = this.geometry; + let [xtl, ytl] = [box.x, box.y]; + + this.pasteBox(box, 0); + this.pointsGroup = makeSVGFromTemplate(this.drawData.skeletonSVG); + this.pointsGroup.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + stroke: this.outlinedBorders, + }); + this.canvas.add(this.pointsGroup); + + this.pointsGroup.children().forEach((child: SVG.Element): void => { + const dataType = child.attr('data-type'); + if (child.node.tagName === 'circle' && dataType && dataType.includes('element')) { + child.attr('r', `${this.controlPointsSize / this.geometry.scale}`); + const labelID = +child.attr('data-label-id'); + const element = elements.find((_element: any): boolean => _element.label.id === labelID); + if (element) { + const points = translateToCanvas(offset, element.points); + child.center(points[0], points[1]); + } + } + }); + + this.drawInstance.off('done').on('done', (e: CustomEvent) => { + const result = { + shapeType: this.drawData.initialState.shapeType, + objectType: this.drawData.initialState.objectType, + elements: this.drawData.initialState.elements.map((element: any) => ({ + shapeType: element.shapeType, + outside: element.outside, + occluded: element.occluded, + label: element.label, + attributes: element.attributes, + points: (() => { + const circle = this.pointsGroup.children() + .find((child: SVG.Element) => child.attr('data-label-id') === element.label.id); + const points = translateFromCanvas(this.geometry.offset, [circle.cx(), circle.cy()]); + return points; + })(), + })), + occluded: this.drawData.initialState.occluded, + attributes: { ...this.drawData.initialState.attributes }, + label: this.drawData.initialState.label, + color: this.drawData.initialState.color, + rotation: this.drawData.initialState.rotation, + }; + + if (!e.detail.originalEvent.ctrlKey) { + this.release(); + } + + this.onDrawDone( + result, + Date.now() - this.startTimestamp, + e.detail.originalEvent.ctrlKey, + ); + }); + + this.canvas.on('mousemove.draw', (): void => { + const [newXtl, newYtl] = [ + this.drawInstance.x(), this.drawInstance.y(), + this.drawInstance.width(), this.drawInstance.height(), + ]; + const [xDiff, yDiff] = [newXtl - xtl, newYtl - ytl]; + xtl = newXtl; + ytl = newYtl; + this.pointsGroup.children().forEach((child: SVG.Element): void => { + const dataType = child.attr('data-type'); + if (child.node.tagName === 'circle' && dataType && dataType.includes('element')) { + const [cx, cy] = [child.cx(), child.cy()]; + child.center(cx + xDiff, cy + yDiff); + } + }); + this.pointsGroup.untransform(); + setupSkeletonEdges(this.pointsGroup, this.pointsGroup); + }); + } + private pastePoints(initialPoints: string): void { - function moveShape(shape: SVG.PolyLine, group: SVG.G, x: number, y: number, scale: number): void { + const moveShape = (shape: SVG.PolyLine, group: SVG.G, x: number, y: number, scale: number): void => { const bbox = shape.bbox(); shape.move(x - bbox.width / 2, y - bbox.height / 2); const points = shape.attr('points').split(' '); - const radius = consts.BASE_POINT_SIZE / scale; + const radius = this.controlPointsSize / scale; group.children().forEach((child: SVG.Element, idx: number): void => { const [px, py] = points[idx].split(','); child.move(px - radius / 2, py - radius / 2); }); - } + }; const { x: initialX, y: initialY } = this.cursorPosition; this.pointsGroup = this.canvas.group(); @@ -873,7 +1098,7 @@ export class DrawHandlerImpl implements DrawHandler { let numOfPoints = initialPoints.split(' ').length; while (numOfPoints) { numOfPoints--; - const radius = consts.BASE_POINT_SIZE / this.geometry.scale; + const radius = this.controlPointsSize / this.geometry.scale; const stroke = consts.POINTS_STROKE_WIDTH / this.geometry.scale; this.pointsGroup.circle().fill('white').stroke('black').attr({ r: radius, @@ -919,10 +1144,7 @@ export class DrawHandlerImpl implements DrawHandler { if (this.drawData.initialState) { const { offset } = this.geometry; if (this.drawData.shapeType === 'rectangle') { - const [xtl, ytl, xbr, ybr] = this.drawData.initialState.points.map( - (coord: number): number => coord + offset, - ); - + const [xtl, ytl, xbr, ybr] = translateToCanvas(offset, this.drawData.initialState.points); this.pasteBox({ x: xtl, y: ytl, @@ -930,13 +1152,15 @@ export class DrawHandlerImpl implements DrawHandler { height: ybr - ytl, }, this.drawData.initialState.rotation); } else if (this.drawData.shapeType === 'ellipse') { - const [cx, cy, rightX, topY] = this.drawData.initialState.points.map( - (coord: number): number => coord + offset, - ); - + const [cx, cy, rightX, topY] = translateToCanvas(offset, this.drawData.initialState.points); this.pasteEllipse([cx, cy, rightX - cx, cy - topY], this.drawData.initialState.rotation); + } else if (this.drawData.shapeType === 'skeleton') { + const box = computeWrappingBox( + translateToCanvas(offset, this.drawData.initialState.points), consts.SKELETON_RECT_MARGIN, + ); + this.pasteSkeleton(box, this.drawData.initialState.elements); } else { - const points = this.drawData.initialState.points.map((coord: number): number => coord + offset); + const points = translateToCanvas(offset, this.drawData.initialState.points); const stringifiedPoints = stringifyPoints(points); if (this.drawData.shapeType === 'polygon') { @@ -975,6 +1199,8 @@ export class DrawHandlerImpl implements DrawHandler { this.drawCuboid(); this.shapeSizeElement = displayShapeSize(this.canvas, this.text); } + } else if (this.drawData.shapeType === 'skeleton') { + this.drawSkeleton(); } if (this.drawData.shapeType !== 'ellipse') { @@ -995,6 +1221,9 @@ export class DrawHandlerImpl implements DrawHandler { configuration: Configuration, ) { this.autoborderHandler = autoborderHandler; + this.controlPointsSize = configuration.controlPointsSize; + this.selectedShapeOpacity = configuration.selectedShapeOpacity; + this.outlinedBorders = configuration.outlinedBorders || 'black'; this.autobordersEnabled = false; this.startTimestamp = Date.now(); this.onDrawDone = onDrawDone; @@ -1004,7 +1233,6 @@ export class DrawHandlerImpl implements DrawHandler { this.canceled = false; this.drawData = null; this.geometry = geometry; - this.configuration = configuration; this.crosshair = new Crosshair(); this.drawInstance = null; this.pointsGroup = null; @@ -1023,7 +1251,9 @@ export class DrawHandlerImpl implements DrawHandler { } public configurate(configuration: Configuration): void { - this.configuration = configuration; + this.controlPointsSize = configuration.controlPointsSize; + this.selectedShapeOpacity = configuration.selectedShapeOpacity; + this.outlinedBorders = configuration.outlinedBorders || 'black'; const isFillableRect = this.drawData && this.drawData.shapeType === 'rectangle' && @@ -1034,17 +1264,23 @@ export class DrawHandlerImpl implements DrawHandler { const isFilalblePolygon = this.drawData && this.drawData.shapeType === 'polygon'; if (this.drawInstance && (isFillableRect || isFillableCuboid || isFilalblePolygon)) { - this.drawInstance.fill({ opacity: configuration.creationOpacity }); + this.drawInstance.fill({ opacity: configuration.selectedShapeOpacity }); } - if (typeof configuration.autoborders === 'boolean') { - this.autobordersEnabled = configuration.autoborders; - if (this.drawInstance && !this.drawData.initialState) { - if (this.autobordersEnabled) { - this.autoborderHandler.autoborder(true, this.drawInstance, this.drawData.redraw); - } else { - this.autoborderHandler.autoborder(false); - } + if (this.drawInstance && this.drawInstance.attr('stroke')) { + this.drawInstance.attr('stroke', this.outlinedBorders); + } + + if (this.pointsGroup && this.pointsGroup.attr('stroke')) { + this.pointsGroup.attr('stroke', this.outlinedBorders); + } + + this.autobordersEnabled = configuration.autoborders; + if (this.drawInstance && !this.drawData.initialState) { + if (this.autobordersEnabled) { + this.autoborderHandler.autoborder(true, this.drawInstance, this.drawData.redraw); + } else { + this.autoborderHandler.autoborder(false); } } } @@ -1061,10 +1297,14 @@ export class DrawHandlerImpl implements DrawHandler { } if (this.pointsGroup) { + this.pointsGroup.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }); + for (const point of this.pointsGroup.children()) { point.attr({ 'stroke-width': consts.POINTS_STROKE_WIDTH / geometry.scale, - r: consts.BASE_POINT_SIZE / geometry.scale, + r: this.controlPointsSize / geometry.scale, }); } } @@ -1079,7 +1319,7 @@ export class DrawHandlerImpl implements DrawHandler { for (const point of (paintHandler as any).set.members) { point.attr('stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`); - point.attr('r', `${consts.BASE_POINT_SIZE / geometry.scale}`); + point.attr('r', `${this.controlPointsSize / geometry.scale}`); } } } diff --git a/cvat-canvas/src/typescript/editHandler.ts b/cvat-canvas/src/typescript/editHandler.ts index 1911a000..38ce843d 100644 --- a/cvat-canvas/src/typescript/editHandler.ts +++ b/cvat-canvas/src/typescript/editHandler.ts @@ -26,8 +26,10 @@ export class EditHandlerImpl implements EditHandler { private editedShape: SVG.Shape; private editLine: SVG.PolyLine; private clones: SVG.Polygon[]; + private controlPointsSize: number; private autobordersEnabled: boolean; private intelligentCutEnabled: boolean; + private outlinedBorders: string; private setupTrailingPoint(circle: SVG.Circle): void { const head = this.editedShape.attr('points').split(' ').slice(0, this.editData.pointID).join(' '); @@ -112,16 +114,15 @@ export class EditHandlerImpl implements EditHandler { }); } - const strokeColor = this.editedShape.attr('stroke'); (this.editLine as any) .addClass('cvat_canvas_shape_drawing') .style({ 'pointer-events': 'none', 'fill-opacity': 0, - stroke: strokeColor, }) .attr({ 'data-origin-client-id': this.editData.state.clientID, + stroke: this.editedShape.attr('stroke'), }) .on('drawstart drawpoint', (e: CustomEvent): void => { this.transform(this.geometry); @@ -299,7 +300,7 @@ export class EditHandlerImpl implements EditHandler { if (enabled) { (this.editedShape as any).selectize(true, { deepSelect: true, - pointSize: (2 * consts.BASE_POINT_SIZE) / getGeometry().scale, + pointSize: (2 * this.controlPointsSize) / getGeometry().scale, rotationPoint: false, pointType(cx: number, cy: number): SVG.Circle { const circle: SVG.Circle = this.nested @@ -365,7 +366,9 @@ export class EditHandlerImpl implements EditHandler { } private initEditing(): void { - this.editedShape = this.canvas.select(`#cvat_canvas_shape_${this.editData.state.clientID}`).first().clone(); + this.editedShape = this.canvas + .select(`#cvat_canvas_shape_${this.editData.state.clientID}`).first() + .clone().attr('stroke', this.outlinedBorders); this.setupPoints(true); this.startEdit(); // draw points for this with selected and start editing till another point is clicked @@ -387,6 +390,8 @@ export class EditHandlerImpl implements EditHandler { this.autoborderHandler = autoborderHandler; this.autobordersEnabled = false; this.intelligentCutEnabled = false; + this.controlPointsSize = consts.BASE_POINT_SIZE; + this.outlinedBorders = 'black'; this.onEditDone = onEditDone; this.canvas = canvas; this.editData = null; @@ -416,20 +421,23 @@ export class EditHandlerImpl implements EditHandler { } public configurate(configuration: Configuration): void { - if (typeof configuration.autoborders === 'boolean') { - this.autobordersEnabled = configuration.autoborders; - if (this.editLine) { - if (this.autobordersEnabled) { - this.autoborderHandler.autoborder(true, this.editLine, this.editData.state.clientID); - } else { - this.autoborderHandler.autoborder(false); - } - } + this.autobordersEnabled = configuration.autoborders; + this.outlinedBorders = configuration.outlinedBorders || 'black'; + + if (this.editedShape) { + this.editedShape.attr('stroke', this.outlinedBorders); } - if (typeof configuration.intelligentPolygonCrop === 'boolean') { - this.intelligentCutEnabled = configuration.intelligentPolygonCrop; + if (this.editLine) { + this.editLine.attr('stroke', this.outlinedBorders); + if (this.autobordersEnabled) { + this.autoborderHandler.autoborder(true, this.editLine, this.editData.state.clientID); + } else { + this.autoborderHandler.autoborder(false); + } } + this.controlPointsSize = configuration.controlPointsSize || consts.BASE_POINT_SIZE; + this.intelligentCutEnabled = configuration.intelligentPolygonCrop; } public transform(geometry: Geometry): void { @@ -453,7 +461,7 @@ export class EditHandlerImpl implements EditHandler { for (const point of (paintHandler as any).set.members) { point.attr('stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`); - point.attr('r', `${consts.BASE_POINT_SIZE / geometry.scale}`); + point.attr('r', `${this.controlPointsSize / geometry.scale}`); } } } diff --git a/cvat-canvas/src/typescript/interactionHandler.ts b/cvat-canvas/src/typescript/interactionHandler.ts index 998d4353..443e9038 100644 --- a/cvat-canvas/src/typescript/interactionHandler.ts +++ b/cvat-canvas/src/typescript/interactionHandler.ts @@ -23,7 +23,6 @@ export interface InteractionHandler { export class InteractionHandlerImpl implements InteractionHandler { private onInteraction: (shapes: InteractionResult[] | null, shapesUpdated?: boolean, isDone?: boolean) => void; - private configuration: Configuration; private geometry: Geometry; private canvas: SVG.Container; private interactionData: InteractionData; @@ -37,6 +36,8 @@ export class InteractionHandlerImpl implements InteractionHandler { private intermediateShape: PropType; private drawnIntermediateShape: SVG.Shape; private thresholdWasModified: boolean; + private controlPointsSize: number; + private selectedShapeOpacity: number; private prepareResult(): InteractionResult[] { return this.interactionShapes.map( @@ -111,7 +112,7 @@ export class InteractionHandlerImpl implements InteractionHandler { if (!this.isWithinThreshold(cx, cy)) return; this.currentInteractionShape = this.canvas - .circle((consts.BASE_POINT_SIZE * 2) / this.geometry.scale) + .circle((this.controlPointsSize * 2) / this.geometry.scale) .center(cx, cy) .fill('white') .stroke(e.button === 0 ? 'green' : 'red') @@ -137,7 +138,7 @@ export class InteractionHandlerImpl implements InteractionHandler { self.addClass('cvat_canvas_removable_interaction_point'); self.attr({ 'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale, - r: (consts.BASE_POINT_SIZE * 1.5) / this.geometry.scale, + r: (this.controlPointsSize * 1.5) / this.geometry.scale, }); self.on('mousedown', (_e: MouseEvent): void => { @@ -162,7 +163,7 @@ export class InteractionHandlerImpl implements InteractionHandler { self.removeClass('cvat_canvas_removable_interaction_point'); self.attr({ 'stroke-width': consts.POINTS_STROKE_WIDTH / this.geometry.scale, - r: consts.BASE_POINT_SIZE / this.geometry.scale, + r: this.controlPointsSize / this.geometry.scale, }); self.off('mousedown'); @@ -205,7 +206,7 @@ export class InteractionHandlerImpl implements InteractionHandler { .attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, }) - .fill({ opacity: this.configuration.creationOpacity, color: 'white' }); + .fill({ opacity: this.selectedShapeOpacity, color: 'white' }); } private initInteraction(): void { @@ -300,7 +301,7 @@ export class InteractionHandlerImpl implements InteractionHandler { 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, stroke: erroredShape ? 'red' : 'black', }) - .fill({ opacity: this.configuration.creationOpacity, color: 'white' }) + .fill({ opacity: this.selectedShapeOpacity, color: 'white' }) .addClass('cvat_canvas_interact_intermediate_shape'); this.selectize(true, this.drawnIntermediateShape, erroredShape); } else { @@ -317,7 +318,7 @@ export class InteractionHandlerImpl implements InteractionHandler { if (value) { (shape as any).selectize(value, { deepSelect: true, - pointSize: consts.BASE_POINT_SIZE / self.geometry.scale, + pointSize: this.controlPointsSize / self.geometry.scale, rotationPoint: false, classPoints: 'cvat_canvas_interact_intermediate_shape_point', pointType(cx: number, cy: number): SVG.Circle { @@ -399,7 +400,6 @@ export class InteractionHandlerImpl implements InteractionHandler { onInteraction(shapes, shapesUpdated, isDone, this.threshold ? this.thresholdRectSize / 2 : null); }; this.canvas = canvas; - this.configuration = configuration; this.geometry = geometry; this.shapesWereUpdated = false; this.interactionShapes = []; @@ -410,6 +410,8 @@ export class InteractionHandlerImpl implements InteractionHandler { this.thresholdRectSize = 300; this.intermediateShape = null; this.drawnIntermediateShape = null; + this.controlPointsSize = configuration.controlPointsSize; + this.selectedShapeOpacity = configuration.selectedShapeOpacity; this.cursorPosition = { x: 0, y: 0, @@ -477,10 +479,10 @@ export class InteractionHandlerImpl implements InteractionHandler { for (const shape of shapesToBeScaled) { if (shape.type === 'circle') { if (shape.hasClass('cvat_canvas_removable_interaction_point')) { - (shape as SVG.Circle).radius((consts.BASE_POINT_SIZE * 1.5) / this.geometry.scale); + (shape as SVG.Circle).radius((this.controlPointsSize * 1.5) / this.geometry.scale); shape.attr('stroke-width', consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale); } else { - (shape as SVG.Circle).radius(consts.BASE_POINT_SIZE / this.geometry.scale); + (shape as SVG.Circle).radius(this.controlPointsSize / this.geometry.scale); shape.attr('stroke-width', consts.POINTS_STROKE_WIDTH / this.geometry.scale); } } else { @@ -490,7 +492,7 @@ export class InteractionHandlerImpl implements InteractionHandler { for (const element of window.document.getElementsByClassName('cvat_canvas_interact_intermediate_shape_point')) { element.setAttribute('stroke-width', `${consts.POINTS_STROKE_WIDTH / (2 * this.geometry.scale)}`); - element.setAttribute('r', `${consts.BASE_POINT_SIZE / this.geometry.scale}`); + element.setAttribute('r', `${this.controlPointsSize / this.geometry.scale}`); } if (this.drawnIntermediateShape) { @@ -520,21 +522,23 @@ export class InteractionHandlerImpl implements InteractionHandler { } public configurate(configuration: Configuration): void { - this.configuration = configuration; + this.controlPointsSize = configuration.controlPointsSize; + this.selectedShapeOpacity = configuration.selectedShapeOpacity; + if (this.drawnIntermediateShape) { this.drawnIntermediateShape.fill({ - opacity: configuration.creationOpacity, + opacity: configuration.selectedShapeOpacity, }); } // when interactRectangle if (this.currentInteractionShape && this.currentInteractionShape.type === 'rect') { - this.currentInteractionShape.fill({ opacity: configuration.creationOpacity }); + this.currentInteractionShape.fill({ opacity: configuration.selectedShapeOpacity }); } // when interactPoints with startwithbbox if (this.interactionShapes[0] && this.interactionShapes[0].type === 'rect') { - this.interactionShapes[0].fill({ opacity: configuration.creationOpacity }); + this.interactionShapes[0].fill({ opacity: configuration.selectedShapeOpacity }); } } diff --git a/cvat-canvas/src/typescript/shared.ts b/cvat-canvas/src/typescript/shared.ts index 3d1ea0d2..b8274108 100644 --- a/cvat-canvas/src/typescript/shared.ts +++ b/cvat-canvas/src/typescript/shared.ts @@ -52,6 +52,9 @@ export interface DrawnState { updated: number; frame: number; label: any; + group: any; + color: string; + elements: DrawnState[] | null; } // Translate point array from the canvas coordinate system @@ -192,11 +195,13 @@ export function readPointsFromShape(shape: SVG.Shape): number[] { let points = null; if (shape.type === 'ellipse') { const [rx, ry] = [+shape.attr('rx'), +shape.attr('ry')]; - const [cx, cy] = [+shape.attr('cx'), +shape.attr('cy')]; + const [cx, cy] = [shape.cx(), shape.cy()]; points = `${cx},${cy} ${cx + rx},${cy - ry}`; } else if (shape.type === 'rect') { points = `${shape.attr('x')},${shape.attr('y')} ` + `${shape.attr('x') + shape.attr('width')},${shape.attr('y') + shape.attr('height')}`; + } else if (shape.type === 'circle') { + points = `${shape.cx()},${shape.cy()}`; } else { points = shape.attr('points'); } @@ -239,4 +244,121 @@ export function translateFromCanvas(offset: number, points: number[]): number[] return points.map((coord: number): number => coord - offset); } +export function computeWrappingBox(points: number[], margin = 0): Box & BBox { + let xtl = Number.MAX_SAFE_INTEGER; + let ytl = Number.MAX_SAFE_INTEGER; + let xbr = Number.MIN_SAFE_INTEGER; + let ybr = Number.MIN_SAFE_INTEGER; + + for (let i = 0; i < points.length; i += 2) { + const [x, y] = [points[i], points[i + 1]]; + xtl = Math.min(xtl, x); + ytl = Math.min(ytl, y); + xbr = Math.max(xbr, x); + ybr = Math.max(ybr, y); + } + + const box = { + xtl: xtl - margin, + ytl: ytl - margin, + xbr: xbr + margin, + ybr: ybr + margin, + }; + + return { + ...box, + x: box.xtl, + y: box.ytl, + width: box.xbr - box.xtl, + height: box.ybr - box.ytl, + }; +} + +export function getSkeletonEdgeCoordinates(edge: SVG.Line): { + x1: number, y1: number, x2: number, y2: number +} { + let x1 = 0; + let y1 = 0; + let x2 = 0; + let y2 = 0; + + const parent = edge.parent() as any as SVG.G; + if (parent.type !== 'g') { + throw new Error('Edge parent must be a group'); + } + + const dataNodeFrom = edge.attr('data-node-from'); + const dataNodeTo = edge.attr('data-node-to'); + const nodeFrom = parent.children() + .find((element: SVG.Element): boolean => element.attr('data-node-id') === dataNodeFrom); + const nodeTo = parent.children() + .find((element: SVG.Element): boolean => element.attr('data-node-id') === dataNodeTo); + + if (!nodeFrom || !nodeTo) { + throw new Error(`Edge's nodeFrom ${dataNodeFrom} or nodeTo ${dataNodeTo} do not to refer to any node`); + } + + x1 = nodeFrom.cx(); + y1 = nodeFrom.cy(); + x2 = nodeTo.cx(); + y2 = nodeTo.cy(); + + if (nodeFrom.hasClass('cvat_canvas_hidden') || nodeTo.hasClass('cvat_canvas_hidden')) { + edge.addClass('cvat_canvas_hidden'); + } else { + edge.removeClass('cvat_canvas_hidden'); + } + + if (nodeFrom.hasClass('cvat_canvas_shape_occluded') || nodeTo.hasClass('cvat_canvas_shape_occluded')) { + edge.addClass('cvat_canvas_shape_occluded'); + } + + if ([x1, y1, x2, y2].some((coord: number): boolean => typeof coord !== 'number')) { + throw new Error(`Edge coordinates must be numbers, got [${x1}, ${y1}, ${x2}, ${y2}]`); + } + + return { + x1, y1, x2, y2, + }; +} + +export function makeSVGFromTemplate(template: string): SVG.G { + const SVGElement = new SVG.G(); + /* eslint-disable-next-line no-unsanitized/property */ + SVGElement.node.innerHTML = template; + return SVGElement; +} + +export function setupSkeletonEdges(skeleton: SVG.G, referenceSVG: SVG.G): void { + for (const child of referenceSVG.children()) { + // search for all edges on template + const dataType = child.attr('data-type'); + if (child.type === 'line' && dataType === 'edge') { + const dataNodeFrom = child.attr('data-node-from'); + const dataNodeTo = child.attr('data-node-to'); + if (!Number.isInteger(dataNodeFrom) || !Number.isInteger(dataNodeTo)) { + throw new Error(`Edge nodeFrom and nodeTo must be numbers, got ${dataNodeFrom}, ${dataNodeTo}`); + } + + // try to find the same edge on the skeleton + let edge = skeleton.children().find((_child: SVG.Element) => ( + _child.attr('data-node-from') === dataNodeFrom && _child.attr('data-node-to') === dataNodeTo + )) as SVG.Line; + + // if not found, lets create it + if (!edge) { + edge = skeleton.line(0, 0, 0, 0).attr({ + 'data-node-from': dataNodeFrom, + 'data-node-to': dataNodeTo, + 'stroke-width': 'inherit', + }).addClass('cvat_canvas_skeleton_edge') as SVG.Line; + } + + skeleton.node.prepend(edge.node); + const points = getSkeletonEdgeCoordinates(edge); + edge.attr({ ...points, 'stroke-width': 'inherit' }); + } + } +} + export type PropType = T[Prop]; diff --git a/cvat-canvas3d/src/typescript/canvas3dView.ts b/cvat-canvas3d/src/typescript/canvas3dView.ts index a1c76d26..e875e420 100644 --- a/cvat-canvas3d/src/typescript/canvas3dView.ts +++ b/cvat-canvas3d/src/typescript/canvas3dView.ts @@ -287,7 +287,6 @@ export class Canvas3dViewImpl implements Canvas3dView, Listener { (_state: any): boolean => _state.clientID === Number(intersects[0].object.name), ); if (item.length !== 0) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.model.data.groupData.grouped = this.model.data.groupData.grouped.filter( (_state: any): boolean => _state.clientID !== Number(intersects[0].object.name), diff --git a/cvat-core/jest.config.js b/cvat-core/jest.config.js index 4f78879d..cc38eb8f 100644 --- a/cvat-core/jest.config.js +++ b/cvat-core/jest.config.js @@ -5,6 +5,7 @@ const { defaults } = require('jest-config'); module.exports = { + preset: 'ts-jest', coverageDirectory: 'reports/coverage', coverageReporters: ['json', ['lcov', { projectRoot: '../' }]], moduleFileExtensions: [...defaults.moduleFileExtensions, 'ts', 'tsx'], @@ -12,4 +13,9 @@ module.exports = { testMatch: ['**/tests/**/*.js'], testPathIgnorePatterns: ['/node_modules/', '/tests/mocks/*'], automock: false, + globals: { + 'ts-jest': { + diagnostics: false, + }, + }, }; diff --git a/cvat-core/package.json b/cvat-core/package.json index 7cffacf7..3663df18 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "5.1.0", + "version": "6.0.0", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "src/api.ts", "scripts": { @@ -23,7 +23,8 @@ "coveralls": "^3.0.5", "jest": "^26.6.3", "jest-junit": "^6.4.0", - "jsdoc": "^3.6.6" + "jsdoc": "^3.6.6", + "ts-jest": "26" }, "dependencies": { "axios": "^0.27.2", diff --git a/cvat-core/src/annotation-formats.ts b/cvat-core/src/annotation-formats.ts index 893b64a9..f8af7f3c 100644 --- a/cvat-core/src/annotation-formats.ts +++ b/cvat-core/src/annotation-formats.ts @@ -2,188 +2,209 @@ // // SPDX-License-Identifier: MIT -(() => { - /** - * Class representing an annotation loader - * @memberof module:API.cvat.classes - * @hideconstructor - */ - class Loader { - constructor(initialData) { - const data = { - name: initialData.name, - format: initialData.ext, - version: initialData.version, - enabled: initialData.enabled, - dimension: initialData.dimension, - }; +interface RawLoaderData { + name: string; + ext: string; + version: string; + enabled: boolean; + dimension: '2d' | '3d'; +} - Object.defineProperties(this, { - name: { - /** - * @name name - * @type {string} - * @memberof module:API.cvat.classes.Loader - * @readonly - * @instance - */ - get: () => data.name, - }, - format: { - /** - * @name format - * @type {string} - * @memberof module:API.cvat.classes.Loader - * @readonly - * @instance - */ - get: () => data.format, - }, - version: { - /** - * @name version - * @type {string} - * @memberof module:API.cvat.classes.Loader - * @readonly - * @instance - */ - get: () => data.version, - }, - enabled: { - /** - * @name enabled - * @type {string} - * @memberof module:API.cvat.classes.Loader - * @readonly - * @instance - */ - get: () => data.enabled, - }, - dimension: { - /** - * @name dimension - * @type {string} - * @memberof module:API.cvat.enums.DimensionType - * @readonly - * @instance - */ - get: () => data.dimension, - }, - }); - } - } +/** + * Class representing an annotation loader + * @memberof module:API.cvat.classes + * @hideconstructor +*/ +export class Loader { + public name: string; + public format: string; + public version: string; + public enabled: boolean; + public dimension: '2d' | '3d'; - /** - * Class representing an annotation dumper - * @memberof module:API.cvat.classes - * @hideconstructor - */ - class Dumper { - constructor(initialData) { - const data = { - name: initialData.name, - format: initialData.ext, - version: initialData.version, - enabled: initialData.enabled, - dimension: initialData.dimension, - }; + constructor(initialData: RawLoaderData) { + const data = { + name: initialData.name, + format: initialData.ext, + version: initialData.version, + enabled: initialData.enabled, + dimension: initialData.dimension, + }; - Object.defineProperties(this, { - name: { - /** - * @name name - * @type {string} - * @memberof module:API.cvat.classes.Dumper - * @readonly - * @instance - */ - get: () => data.name, - }, - format: { - /** - * @name format - * @type {string} - * @memberof module:API.cvat.classes.Dumper - * @readonly - * @instance - */ - get: () => data.format, - }, - version: { - /** - * @name version - * @type {string} - * @memberof module:API.cvat.classes.Dumper - * @readonly - * @instance - */ - get: () => data.version, - }, - enabled: { - /** - * @name enabled - * @type {string} - * @memberof module:API.cvat.classes.Loader - * @readonly - * @instance - */ - get: () => data.enabled, - }, - dimension: { - /** - * @name dimension - * @type {string} - * @memberof module:API.cvat.enums.DimensionType - * @readonly - * @instance - */ - get: () => data.dimension, - }, - }); - } + Object.defineProperties(this, { + name: { + /** + * @name name + * @type {string} + * @memberof module:API.cvat.classes.Loader + * @readonly + * @instance + */ + get: () => data.name, + }, + format: { + /** + * @name format + * @type {string} + * @memberof module:API.cvat.classes.Loader + * @readonly + * @instance + */ + get: () => data.format, + }, + version: { + /** + * @name version + * @type {string} + * @memberof module:API.cvat.classes.Loader + * @readonly + * @instance + */ + get: () => data.version, + }, + enabled: { + /** + * @name enabled + * @type {boolean} + * @memberof module:API.cvat.classes.Loader + * @readonly + * @instance + */ + get: () => data.enabled, + }, + dimension: { + /** + * @name dimension + * @type {module:API.cvat.enums.DimensionType} + * @memberof module:API.cvat.classes.Loader + * @readonly + * @instance + */ + get: () => data.dimension, + }, + }); } +} + +type RawDumperData = RawLoaderData; - /** - * Class representing an annotation format - * @memberof module:API.cvat.classes - * @hideconstructor - */ - class AnnotationFormats { - constructor(initialData) { - const data = { - exporters: initialData.exporters.map((el) => new Dumper(el)), - importers: initialData.importers.map((el) => new Loader(el)), - }; +/** + * Class representing an annotation dumper + * @memberof module:API.cvat.classes + * @hideconstructor + */ +export class Dumper { + public name: string; + public format: string; + public version: string; + public enabled: boolean; + public dimension: '2d' | '3d'; - // Now all fields are readonly - Object.defineProperties(this, { - loaders: { - /** - * @name loaders - * @type {module:API.cvat.classes.Loader[]} - * @memberof module:API.cvat.classes.AnnotationFormats - * @readonly - * @instance - */ - get: () => [...data.importers], - }, - dumpers: { - /** - * @name dumpers - * @type {module:API.cvat.classes.Dumper[]} - * @memberof module:API.cvat.classes.AnnotationFormats - * @readonly - * @instance - */ - get: () => [...data.exporters], - }, - }); - } + constructor(initialData: RawDumperData) { + const data = { + name: initialData.name, + format: initialData.ext, + version: initialData.version, + enabled: initialData.enabled, + dimension: initialData.dimension, + }; + + Object.defineProperties(this, { + name: { + /** + * @name name + * @type {string} + * @memberof module:API.cvat.classes.Dumper + * @readonly + * @instance + */ + get: () => data.name, + }, + format: { + /** + * @name format + * @type {string} + * @memberof module:API.cvat.classes.Dumper + * @readonly + * @instance + */ + get: () => data.format, + }, + version: { + /** + * @name version + * @type {string} + * @memberof module:API.cvat.classes.Dumper + * @readonly + * @instance + */ + get: () => data.version, + }, + enabled: { + /** + * @name enabled + * @type {boolean} + * @memberof module:API.cvat.classes.Dumper + * @readonly + * @instance + */ + get: () => data.enabled, + }, + dimension: { + /** + * @name dimension + * @type {module:API.cvat.enums.DimensionType} + * @memberof module:API.cvat.classes.Dumper + * @readonly + * @instance + */ + get: () => data.dimension, + }, + }); } +} + +interface AnnotationFormatRawData { + importers: RawLoaderData[]; + exporters: RawDumperData[]; +} - module.exports = { - AnnotationFormats, - Loader, - Dumper, - }; -})(); +/** + * Class representing an annotation format + * @memberof module:API.cvat.classes + * @hideconstructor + */ +export class AnnotationFormats { + public loaders: Loader[]; + public dumpers: Dumper[]; + + constructor(initialData: AnnotationFormatRawData) { + const data = { + exporters: initialData.exporters.map((el) => new Dumper(el)), + importers: initialData.importers.map((el) => new Loader(el)), + }; + + Object.defineProperties(this, { + loaders: { + /** + * @name loaders + * @type {module:API.cvat.classes.Loader[]} + * @memberof module:API.cvat.classes.AnnotationFormats + * @readonly + * @instance + */ + get: () => [...data.importers], + }, + dumpers: { + /** + * @name dumpers + * @type {module:API.cvat.classes.Dumper[]} + * @memberof module:API.cvat.classes.AnnotationFormats + * @readonly + * @instance + */ + get: () => [...data.exporters], + }, + }); + } +} diff --git a/cvat-core/src/annotations-collection.ts b/cvat-core/src/annotations-collection.ts index 31892dc1..c831598e 100644 --- a/cvat-core/src/annotations-collection.ts +++ b/cvat-core/src/annotations-collection.ts @@ -4,100 +4,22 @@ (() => { const { - RectangleShape, - PolygonShape, - PolylineShape, - PointsShape, - EllipseShape, - CuboidShape, - RectangleTrack, - PolygonTrack, - PolylineTrack, - PointsTrack, - EllipseTrack, - CuboidTrack, + shapeFactory, + trackFactory, Track, Shape, Tag, - objectStateFactory, } = require('./annotations-objects'); - const AnnotationsFilter = require('./annotations-filter'); + const AnnotationsFilter = require('./annotations-filter').default; const { checkObjectType } = require('./common'); const Statistics = require('./statistics'); const { Label } = require('./labels'); - const { DataError, ArgumentError, ScriptingError } = require('./exceptions'); + const { ArgumentError, ScriptingError } = require('./exceptions'); + const ObjectState = require('./object-state').default; const { - HistoryActions, ObjectShape, ObjectType, colors, + HistoryActions, ShapeType, ObjectType, colors, Source, } = require('./enums'); - const ObjectState = require('./object-state'); - - function shapeFactory(shapeData, clientID, injection) { - const { type } = shapeData; - const color = colors[clientID % colors.length]; - - let shapeModel = null; - switch (type) { - case 'rectangle': - shapeModel = new RectangleShape(shapeData, clientID, color, injection); - break; - case 'polygon': - shapeModel = new PolygonShape(shapeData, clientID, color, injection); - break; - case 'polyline': - shapeModel = new PolylineShape(shapeData, clientID, color, injection); - break; - case 'points': - shapeModel = new PointsShape(shapeData, clientID, color, injection); - break; - case 'ellipse': - shapeModel = new EllipseShape(shapeData, clientID, color, injection); - break; - case 'cuboid': - shapeModel = new CuboidShape(shapeData, clientID, color, injection); - break; - default: - throw new DataError(`An unexpected type of shape "${type}"`); - } - - return shapeModel; - } - - function trackFactory(trackData, clientID, injection) { - if (trackData.shapes.length) { - const { type } = trackData.shapes[0]; - const color = colors[clientID % colors.length]; - - let trackModel = null; - switch (type) { - case 'rectangle': - trackModel = new RectangleTrack(trackData, clientID, color, injection); - break; - case 'polygon': - trackModel = new PolygonTrack(trackData, clientID, color, injection); - break; - case 'polyline': - trackModel = new PolylineTrack(trackData, clientID, color, injection); - break; - case 'points': - trackModel = new PointsTrack(trackData, clientID, color, injection); - break; - case 'ellipse': - trackModel = new EllipseTrack(trackData, clientID, color, injection); - break; - case 'cuboid': - trackModel = new CuboidTrack(trackData, clientID, color, injection); - break; - default: - throw new DataError(`An unexpected type of track "${type}"`); - } - - return trackModel; - } - - console.warn('The track without any shapes had been found. It was ignored.'); - return null; - } class Collection { constructor(data) { @@ -107,6 +29,10 @@ this.labels = data.labels.reduce((labelAccumulator, label) => { labelAccumulator[label.id] = label; + (label?.structure?.sublabels || []).forEach((sublabel) => { + labelAccumulator[sublabel.id] = sublabel; + }); + return labelAccumulator; }, {}); @@ -126,6 +52,7 @@ groups: this.groups, frameMeta: this.frameMeta, history: this.history, + nextClientID: () => ++this.count, groupColors: {}, }; } @@ -202,10 +129,7 @@ const tags = this.tags[frame] || []; const objects = [].concat(tracks, shapes, tags); - const visible = { - models: [], - data: [], - }; + const visible = []; for (const object of objects) { if (object.removed) { @@ -213,21 +137,19 @@ } const stateData = object.get(frame); - if (!allTracks && stateData.outside && !stateData.keyframe) { + if (stateData.outside && !stateData.keyframe && !allTracks && object instanceof Track) { continue; } - visible.models.push(object); - visible.data.push(stateData); + visible.push(stateData); } const objectStates = []; - const filtered = this.annotationsFilter.filter(visible.data, filters); + const filtered = this.annotationsFilter.filter(visible, filters); - visible.data.forEach((stateData, idx) => { + visible.forEach((stateData, idx) => { if (!filters.length || filtered.includes(stateData.clientID)) { - const model = visible.models[idx]; - const objectState = objectStateFactory.call(model, frame, stateData); + const objectState = new ObjectState(stateData); objectStates.push(objectState); } }); @@ -255,7 +177,7 @@ throw new ArgumentError(`Unknown label for the task: ${label.id}`); } - if (!Object.values(ObjectShape).includes(shapeType)) { + if (!Object.values(ShapeType).includes(shapeType)) { throw new ArgumentError(`Got unknown shapeType "${shapeType}"`); } @@ -288,10 +210,14 @@ keyframes[object.frame] = { type: shapeType, frame: object.frame, - points: [...object.points], + points: object.shapeType === ShapeType.SKELETON ? undefined : [...object.points], + elements: object.shapeType === ShapeType.SKELETON ? object.elements.map((el) => { + const { id, clientID, ...rest } = el.toJSON(); + return rest; + }) : undefined, occluded: object.occluded, rotation: object.rotation, - zOrder: object.zOrder, + z_order: object.zOrder, outside: false, attributes: Object.keys(object.attributes).reduce((accumulator, attrID) => { // We save only mutable attributes inside a keyframe @@ -311,13 +237,24 @@ keyframes[object.frame + 1] = JSON.parse(JSON.stringify(keyframes[object.frame])); keyframes[object.frame + 1].outside = true; keyframes[object.frame + 1].frame++; + keyframes[object.frame + 1].attributes = []; + (keyframes[object.frame + 1].elements || []).forEach((el) => { + el.outside = keyframes[object.frame + 1].outside; + el.frame = keyframes[object.frame + 1].frame; + }); } } else if (object instanceof Track) { // If this object is track, iterate through all its // keyframes and push copies to new keyframes const attributes = {}; // id:value - for (const keyframe of Object.keys(object.shapes)) { - const shape = object.shapes[keyframe]; + const trackShapes = object.shapes; + const exportedShapes = object.shapeType === ShapeType.SKELETON ? + object.prepareShapesForServer().reduce((acc, val) => { + acc[val.frame] = val; + return acc; + }, {}) : {}; + for (const keyframe of Object.keys(trackShapes)) { + const shape = trackShapes[keyframe]; // Frame already saved and it is not outside if (keyframe in keyframes && !keyframes[keyframe].outside) { // This shape is outside and non-outside shape already exists @@ -341,11 +278,16 @@ keyframes[keyframe] = { type: shapeType, frame: +keyframe, - points: [...shape.points], + points: object.shapeType === ShapeType.SKELETON ? undefined : [...shape.points], + elements: object.shapeType === ShapeType.SKELETON ? + exportedShapes[keyframe].elements.map((el) => { + const { id, ...rest } = el; + return rest; + }) : undefined, rotation: shape.rotation, occluded: shape.occluded, outside: shape.outside, - zOrder: shape.zOrder, + z_order: shape.zOrder, attributes: updatedAttributes ? Object.keys(attributes).reduce((accumulator, attrID) => { accumulator.push({ spec_id: +attrID, @@ -451,13 +393,30 @@ const exported = object.toJSON(); const position = { type: objectState.shapeType, - points: [...objectState.points], + points: objectState.shapeType === ShapeType.SKELETON ? undefined : [...objectState.points], + elements: objectState.shapeType === ShapeType.SKELETON ? objectState.elements.map((el: ObjectState) => { + const elementAttributes = el.attributes; + return { + attributes: Object.keys(elementAttributes).reduce((acc, attrID) => { + acc.push({ + spec_id: +attrID, + value: elementAttributes[attrID], + }); + return acc; + }, []), + label_id: el.label.id, + occluded: el.occluded, + outside: el.outside, + points: [...el.points], + type: el.shapeType, + }; + }) : undefined, rotation: objectState.rotation, occluded: objectState.occluded, outside: objectState.outside, - zOrder: objectState.zOrder, + z_order: objectState.zOrder, attributes: Object.keys(objectState.attributes).reduce((accumulator, attrID) => { - if (!labelAttributes[attrID].mutable) { + if (labelAttributes[attrID].mutable) { accumulator.push({ spec_id: +attrID, value: objectState.attributes[attrID], @@ -475,14 +434,19 @@ label_id: exported.label_id, attributes: exported.attributes, shapes: [], + source: Source.MANUAL, }; const next = JSON.parse(JSON.stringify(prev)); next.frame = frame; - next.shapes.push(JSON.parse(JSON.stringify(position))); + exported.shapes.map((shape) => { delete shape.id; + (shape.elements || []).forEach((element) => { + delete element.id; + }); + if (shape.frame < frame) { prev.shapes.push(JSON.parse(JSON.stringify(shape))); } else if (shape.frame > frame) { @@ -499,6 +463,9 @@ prev.shapes[prev.shapes.length - 2].frame -= 1; } prev.shapes[prev.shapes.length - 1].outside = true; + (prev.shapes[prev.shapes.length - 1].elements || []).forEach((el) => { + el.outside = true; + }); let clientID = ++this.count; const prevTrack = trackFactory(prev, clientID, this.injection); @@ -546,6 +513,7 @@ const undoGroups = objectsForGroup.map((object) => object.group); for (const object of objectsForGroup) { object.group = groupIdx; + object.updated = Date.now(); } const redoGroups = objectsForGroup.map((object) => object.group); @@ -554,11 +522,13 @@ () => { objectsForGroup.forEach((object, idx) => { object.group = undoGroups[idx]; + object.updated = Date.now(); }); }, () => { objectsForGroup.forEach((object, idx) => { object.group = redoGroups[idx]; + object.updated = Date.now(); }); }, objectsForGroup.map((object) => object.clientID), @@ -579,8 +549,17 @@ tracks.forEach((track) => { if (track.frame <= endframe) { if (delTrackKeyframesOnly) { - for (const keyframe in track.shapes) { - if (keyframe >= startframe && keyframe <= endframe) { delete track.shapes[keyframe]; } + for (const keyframe of Object.keys(track.shapes)) { + if (+keyframe >= startframe && +keyframe <= endframe) { + delete track.shapes[keyframe]; + (track.elements || []).forEach((element) => { + if (keyframe in element.shapes) { + delete element.shapes[keyframe]; + element.updated = Date.now(); + } + }); + track.updated = Date.now(); + } } } else if (track.frame >= startframe) { const index = tracks.indexOf(track); @@ -593,7 +572,7 @@ this.shapes = {}; this.tags = {}; this.tracks = []; - this.objects = {}; // by id + this.objects = {}; this.count = 0; this.flush = true; @@ -606,42 +585,74 @@ statistics() { const labels = {}; - const skeleton = { - rectangle: { - shape: 0, - track: 0, - }, - polygon: { - shape: 0, - track: 0, - }, - polyline: { - shape: 0, - track: 0, - }, - points: { - shape: 0, - track: 0, - }, - ellipse: { - shape: 0, - track: 0, - }, - cuboid: { - shape: 0, - track: 0, - }, - tags: 0, + const shapes = ['rectangle', 'polygon', 'polyline', 'points', 'ellipse', 'cuboid', 'skeleton']; + const body = { + ...(shapes.reduce((acc, val) => ({ + ...acc, + [val]: { shape: 0, track: 0 }, + }), {})), + + tag: 0, manually: 0, interpolated: 0, total: 0, }; - const total = JSON.parse(JSON.stringify(skeleton)); - for (const label of Object.values(this.labels)) { - const { name } = label; - labels[name] = JSON.parse(JSON.stringify(skeleton)); - } + const sep = '{{cvat.skeleton.lbl.sep}}'; + const fillBody = (spec, prefix = ''): void => { + const pref = prefix ? `${prefix}${sep}` : ''; + for (const label of spec) { + const { name } = label; + labels[`${pref}${name}`] = JSON.parse(JSON.stringify(body)); + + if (label?.structure?.sublabels) { + fillBody(label.structure.sublabels, `${pref}${name}`); + } + } + }; + + const total = JSON.parse(JSON.stringify(body)); + fillBody(Object.values(this.labels).filter((label) => !label.hasParent)); + + const scanTrack = (track, prefix = ''): void => { + const pref = prefix ? `${prefix}${sep}` : ''; + const label = `${pref}${track.label.name}`; + labels[label][track.shapeType].track++; + const keyframes = Object.keys(track.shapes) + .sort((a, b) => +a - +b) + .map((el) => +el); + + let prevKeyframe = keyframes[0]; + let visible = false; + for (const keyframe of keyframes) { + if (visible) { + const interpolated = keyframe - prevKeyframe - 1; + labels[label].interpolated += interpolated; + labels[label].total += interpolated; + } + visible = !track.shapes[keyframe].outside; + prevKeyframe = keyframe; + + if (visible) { + labels[label].manually++; + labels[label].total++; + } + } + + let lastKey = keyframes[keyframes.length - 1]; + if (track.shapeType === ShapeType.SKELETON) { + track.elements.forEach((element) => { + scanTrack(element, label); + lastKey = Math.max(lastKey, ...Object.keys(element.shapes).map((key) => +key)); + }); + } + + if (lastKey !== this.stopFrame && !track.get(lastKey).outside) { + const interpolated = this.stopFrame - lastKey; + labels[label].interpolated += interpolated; + labels[label].total += interpolated; + } + }; for (const object of Object.values(this.objects)) { if (object.removed) { @@ -659,59 +670,37 @@ throw new ScriptingError(`Unexpected object type: "${objectType}"`); } - const label = object.label.name; + const { name: label } = object.label; if (objectType === 'tag') { - labels[label].tags++; + labels[label].tag++; labels[label].manually++; labels[label].total++; + } else if (objectType === 'track') { + scanTrack(object); } else { const { shapeType } = object; - labels[label][shapeType][objectType]++; - - if (objectType === 'track') { - const keyframes = Object.keys(object.shapes) - .sort((a, b) => +a - +b) - .map((el) => +el); - - let prevKeyframe = keyframes[0]; - let visible = false; - - for (const keyframe of keyframes) { - if (visible) { - const interpolated = keyframe - prevKeyframe - 1; - labels[label].interpolated += interpolated; - labels[label].total += interpolated; - } - visible = !object.shapes[keyframe].outside; - prevKeyframe = keyframe; - - if (visible) { - labels[label].manually++; - labels[label].total++; - } - } - - const lastKey = keyframes[keyframes.length - 1]; - if (lastKey !== this.stopFrame && !object.shapes[lastKey].outside) { - const interpolated = this.stopFrame - lastKey; - labels[label].interpolated += interpolated; - labels[label].total += interpolated; - } - } else { - labels[label].manually++; - labels[label].total++; + labels[label][shapeType].shape++; + labels[label].manually++; + labels[label].total++; + if (shapeType === ShapeType.SKELETON) { + object.elements.forEach((element) => { + const combinedName = [label, element.label.name].join(sep); + labels[combinedName][element.shapeType].shape++; + labels[combinedName].manually++; + labels[combinedName].total++; + }); } } } for (const label of Object.keys(labels)) { - for (const key of Object.keys(labels[label])) { - if (typeof labels[label][key] === 'object') { - for (const objectType of Object.keys(labels[label][key])) { - total[key][objectType] += labels[label][key][objectType]; + for (const shapeType of Object.keys(labels[label])) { + if (typeof labels[label][shapeType] === 'object') { + for (const objectType of Object.keys(labels[label][shapeType])) { + total[shapeType][objectType] += labels[label][shapeType][objectType]; } } else { - total[key] += labels[label][key]; + total[shapeType] += labels[label][shapeType]; } } } @@ -744,7 +733,7 @@ for (const state of objectStates) { checkObjectType('object state', state, null, ObjectState); - checkObjectType('state client ID', state.clientID, 'undefined', null); + checkObjectType('state client ID', state.clientID, null, null); checkObjectType('state frame', state.frame, 'integer', null); checkObjectType('state rotation', state.rotation || 0, 'number', null); checkObjectType('state attributes', state.attributes, null, Object); @@ -775,9 +764,9 @@ checkObjectType('point coordinate', coord, 'number', null); } - if (!Object.values(ObjectShape).includes(state.shapeType)) { + if (!Object.values(ShapeType).includes(state.shapeType)) { throw new ArgumentError( - `Object shape must be one of: ${JSON.stringify(Object.values(ObjectShape))}`, + `Object shape must be one of: ${JSON.stringify(Object.values(ShapeType))}`, ); } @@ -794,6 +783,18 @@ type: state.shapeType, z_order: state.zOrder, source: state.source, + elements: state.shapeType === 'skeleton' ? state.elements.map((element) => ({ + attributes: [], + frame: element.frame, + group: 0, + label_id: element.label.id, + points: [...element.points], + rotation: 0, + type: element.shapeType, + z_order: 0, + outside: element.outside || false, + occluded: element.occluded || false, + })) : undefined, }); } else if (state.objectType === 'track') { constructed.tracks.push({ @@ -807,7 +808,7 @@ { attributes: attributes.filter((attr) => labelAttributes[attr.spec_id].mutable), frame: state.frame, - occluded: state.occluded || false, + occluded: false, outside: false, points: [...state.points], rotation: state.rotation || 0, @@ -815,6 +816,33 @@ z_order: state.zOrder, }, ], + elements: state.shapeType === 'skeleton' ? state.elements.map((element) => { + const elementAttrValues = Object.keys(state.attributes) + .reduce(convertAttributes.bind(state), []); + const elementAttributes = element.label.attributes.reduce((accumulator, attribute) => { + accumulator[attribute.id] = attribute; + return accumulator; + }, {}); + + return ({ + attributes: elementAttrValues + .filter((attr) => !elementAttributes[attr.spec_id].mutable), + frame: state.frame, + group: 0, + label_id: element.label.id, + shapes: [{ + frame: state.frame, + type: element.shapeType, + points: [...element.points], + zOrder: state.zOrder, + outside: element.outside || false, + occluded: element.occluded || false, + rotation: element.rotation || 0, + attributes: elementAttrValues + .filter((attr) => !elementAttributes[attr.spec_id].mutable), + }], + }); + }) : undefined, }); } else { throw new ArgumentError( diff --git a/cvat-core/src/annotations-filter.ts b/cvat-core/src/annotations-filter.ts index 0e37a45b..72e21de2 100644 --- a/cvat-core/src/annotations-filter.ts +++ b/cvat-core/src/annotations-filter.ts @@ -2,14 +2,14 @@ // // SPDX-License-Identifier: MIT -const jsonLogic = require('json-logic-js'); -const { AttributeType, ObjectType } = require('./enums'); +import jsonLogic from 'json-logic-js'; +import { AttributeType, ObjectType } from './enums'; -function adjustName(name) { +function adjustName(name): string { return name.replace(/\./g, '\u2219'); } -class AnnotationsFilter { +export default class AnnotationsFilter { _convertObjects(statesData) { const objects = statesData.map((state) => { const labelAttributes = state.label.attributes.reduce((acc, attr) => { @@ -24,7 +24,11 @@ class AnnotationsFilter { let [width, height] = [null, null]; if (state.objectType !== ObjectType.TAG) { - state.points.forEach((coord, idx) => { + const points = state.points || state.elements.reduce((acc, val) => { + acc.push(val.points); + return acc; + }, []).flat(); + points.forEach((coord, idx) => { if (idx % 2) { // y ytl = Math.min(ytl, coord); @@ -75,5 +79,3 @@ class AnnotationsFilter { .filter((_, index) => jsonLogic.apply(filters[0], converted[index])); } } - -module.exports = AnnotationsFilter; diff --git a/cvat-core/src/annotations-history.ts b/cvat-core/src/annotations-history.ts index 879782e4..5d7fecf2 100644 --- a/cvat-core/src/annotations-history.ts +++ b/cvat-core/src/annotations-history.ts @@ -2,26 +2,40 @@ // // SPDX-License-Identifier: MIT +import { HistoryActions } from './enums'; + const MAX_HISTORY_LENGTH = 128; -class AnnotationHistory { +interface ActionItem { + action: HistoryActions; + undo: Function; + redo: Function; + clientIDs: number[]; + frame: number; +} + +export default class AnnotationHistory { + private frozen: boolean; + private _undo: ActionItem[]; + private _redo: ActionItem[]; + constructor() { this.frozen = false; this.clear(); } - freeze(frozen) { + public freeze(frozen: boolean): void { this.frozen = frozen; } - get() { + public get(): { undo: [HistoryActions, number][], redo: [HistoryActions, number][] } { return { undo: this._undo.map((undo) => [undo.action, undo.frame]), redo: this._redo.map((redo) => [redo.action, redo.frame]), }; } - do(action, undo, redo, clientIDs, frame) { + public do(action: HistoryActions, undo: Function, redo: Function, clientIDs: number[], frame: number): void { if (this.frozen) return; const actionItem = { clientIDs, @@ -36,7 +50,7 @@ class AnnotationHistory { this._redo = []; } - async undo(count) { + public async undo(count: number): Promise { const affectedObjects = []; for (let i = 0; i < count; i++) { const action = this._undo.pop(); @@ -52,7 +66,7 @@ class AnnotationHistory { return affectedObjects; } - async redo(count) { + public async redo(count: number): Promise { const affectedObjects = []; for (let i = 0; i < count; i++) { const action = this._redo.pop(); @@ -68,10 +82,8 @@ class AnnotationHistory { return affectedObjects; } - clear() { + public clear(): void { this._undo = []; this._redo = []; } } - -module.exports = AnnotationHistory; diff --git a/cvat-core/src/annotations-objects.ts b/cvat-core/src/annotations-objects.ts index 1cf33a31..880eeff1 100644 --- a/cvat-core/src/annotations-objects.ts +++ b/cvat-core/src/annotations-objects.ts @@ -2,2179 +2,3062 @@ // // SPDX-License-Identifier: MIT -(() => { - const ObjectState = require('./object-state'); - const { checkObjectType } = require('./common'); - const { - colors, Source, ObjectShape, ObjectType, AttributeType, HistoryActions, - } = require('./enums'); - - const { DataError, ArgumentError, ScriptingError } = require('./exceptions'); +import ObjectState from './object-state'; +import { checkObjectType, clamp } from './common'; +import { DataError, ArgumentError, ScriptingError } from './exceptions'; +import { Label, Attribute } from './labels'; +import { + colors, Source, ShapeType, ObjectType, AttributeType, HistoryActions, +} from './enums'; +import AnnotationHistory from './annotations-history'; + +const defaultGroupColor = '#E0E0E0'; + +function checkNumberOfPoints(shapeType: ShapeType, points: number[]): void { + if (shapeType === ShapeType.RECTANGLE) { + if (points.length / 2 !== 2) { + throw new DataError(`Rectangle must have 2 points, but got ${points.length / 2}`); + } + } else if (shapeType === ShapeType.POLYGON) { + if (points.length / 2 < 3) { + throw new DataError(`Polygon must have at least 3 points, but got ${points.length / 2}`); + } + } else if (shapeType === ShapeType.POLYLINE) { + if (points.length / 2 < 2) { + throw new DataError(`Polyline must have at least 2 points, but got ${points.length / 2}`); + } + } else if (shapeType === ShapeType.POINTS) { + if (points.length / 2 < 1) { + throw new DataError(`Points must have at least 1 points, but got ${points.length / 2}`); + } + } else if (shapeType === ShapeType.CUBOID) { + if (points.length / 2 !== 8) { + throw new DataError(`Cuboid must have 8 points, but got ${points.length / 2}`); + } + } else if (shapeType === ShapeType.ELLIPSE) { + if (points.length / 2 !== 2) { + throw new DataError(`Ellipse must have 1 point, rx and ry but got ${points.toString()}`); + } + } else { + throw new ArgumentError(`Unknown value of shapeType has been received ${shapeType}`); + } +} + +function attrsAsAnObject(attributes: Attribute[]): Record { + return attributes.reduce((accumulator, value) => { + accumulator[value.id] = value; + return accumulator; + }, {}); +} + +function findAngleDiff(rightAngle: number, leftAngle: number): number { + let angleDiff = rightAngle - leftAngle; + angleDiff = ((angleDiff + 180) % 360) - 180; + if (Math.abs(angleDiff) >= 180) { + // if the main arc is bigger than 180, go another arc + // to find it, just substract absolute value from 360 and inverse sign + angleDiff = 360 - Math.abs(angleDiff) * Math.sign(angleDiff) * -1; + } + return angleDiff; +} - const { Label } = require('./labels'); +function checkShapeArea(shapeType: ShapeType, points: number[]): boolean { + const MIN_SHAPE_LENGTH = 3; + const MIN_SHAPE_AREA = 9; - const defaultGroupColor = '#E0E0E0'; + if (shapeType === ShapeType.POINTS) { + return true; + } - // Called with the Annotation context - function objectStateFactory(frame, data) { - const objectState = new ObjectState(data); + if (shapeType === ShapeType.ELLIPSE) { + const [cx, cy, rightX, topY] = points; + const [rx, ry] = [rightX - cx, cy - topY]; + return rx * ry * Math.PI > MIN_SHAPE_AREA; + } - // eslint-disable-next-line no-underscore-dangle - objectState.__internal = { - save: this.save.bind(this, frame, objectState), - delete: this.delete.bind(this), - context: this, - }; + let xmin = Number.MAX_SAFE_INTEGER; + let xmax = Number.MIN_SAFE_INTEGER; + let ymin = Number.MAX_SAFE_INTEGER; + let ymax = Number.MIN_SAFE_INTEGER; - return objectState; + for (let i = 0; i < points.length - 1; i += 2) { + xmin = Math.min(xmin, points[i]); + xmax = Math.max(xmax, points[i]); + ymin = Math.min(ymin, points[i + 1]); + ymax = Math.max(ymax, points[i + 1]); } - function checkNumberOfPoints(shapeType, points) { - if (shapeType === ObjectShape.RECTANGLE) { - if (points.length / 2 !== 2) { - throw new DataError(`Rectangle must have 2 points, but got ${points.length / 2}`); - } - } else if (shapeType === ObjectShape.POLYGON) { - if (points.length / 2 < 3) { - throw new DataError(`Polygon must have at least 3 points, but got ${points.length / 2}`); - } - } else if (shapeType === ObjectShape.POLYLINE) { - if (points.length / 2 < 2) { - throw new DataError(`Polyline must have at least 2 points, but got ${points.length / 2}`); - } - } else if (shapeType === ObjectShape.POINTS) { - if (points.length / 2 < 1) { - throw new DataError(`Points must have at least 1 points, but got ${points.length / 2}`); - } - } else if (shapeType === ObjectShape.CUBOID) { - if (points.length / 2 !== 8) { - throw new DataError(`Cuboid must have 8 points, but got ${points.length / 2}`); - } - } else if (shapeType === ObjectShape.ELLIPSE) { - if (points.length / 2 !== 2) { - throw new DataError(`Ellipse must have 1 point, rx and ry but got ${points.toString()}`); - } - } else { - throw new ArgumentError(`Unknown value of shapeType has been received ${shapeType}`); - } + if (shapeType === ShapeType.POLYLINE) { + const length = Math.max(xmax - xmin, ymax - ymin); + return length >= MIN_SHAPE_LENGTH; } - function findAngleDiff(rightAngle, leftAngle) { - let angleDiff = rightAngle - leftAngle; - angleDiff = ((angleDiff + 180) % 360) - 180; - if (Math.abs(angleDiff) >= 180) { - // if the main arc is bigger than 180, go another arc - // to find it, just substract absolute value from 360 and inverse sign - angleDiff = 360 - Math.abs(angleDiff) * Math.sign(angleDiff) * -1; - } - return angleDiff; + const area = (xmax - xmin) * (ymax - ymin); + return area >= MIN_SHAPE_AREA; +} + +function rotatePoint(x: number, y: number, angle: number, cx = 0, cy = 0): number[] { + const sin = Math.sin((angle * Math.PI) / 180); + const cos = Math.cos((angle * Math.PI) / 180); + const rotX = (x - cx) * cos - (y - cy) * sin + cx; + const rotY = (y - cy) * cos + (x - cx) * sin + cy; + return [rotX, rotY]; +} + +function computeWrappingBox(points: number[], margin = 0): { + xtl: number; + ytl: number; + xbr: number; + ybr: number; + x: number; + y: number; + width: number; + height: number; +} { + let xtl = Number.MAX_SAFE_INTEGER; + let ytl = Number.MAX_SAFE_INTEGER; + let xbr = Number.MIN_SAFE_INTEGER; + let ybr = Number.MIN_SAFE_INTEGER; + + for (let i = 0; i < points.length; i += 2) { + const [x, y] = [points[i], points[i + 1]]; + xtl = Math.min(xtl, x); + ytl = Math.min(ytl, y); + xbr = Math.max(xbr, x); + ybr = Math.max(ybr, y); } - function checkShapeArea(shapeType, points) { - const MIN_SHAPE_LENGTH = 3; - const MIN_SHAPE_AREA = 9; - - if (shapeType === ObjectShape.POINTS) { - return true; - } - - if (shapeType === ObjectShape.ELLIPSE) { - const [cx, cy, rightX, topY] = points; - const [rx, ry] = [rightX - cx, cy - topY]; - return rx * ry * Math.PI > MIN_SHAPE_AREA; - } - - let xmin = Number.MAX_SAFE_INTEGER; - let xmax = Number.MIN_SAFE_INTEGER; - let ymin = Number.MAX_SAFE_INTEGER; - let ymax = Number.MIN_SAFE_INTEGER; + const box = { + xtl: xtl - margin, + ytl: ytl - margin, + xbr: xbr + margin, + ybr: ybr + margin, + }; - for (let i = 0; i < points.length - 1; i += 2) { - xmin = Math.min(xmin, points[i]); - xmax = Math.max(xmax, points[i]); - ymin = Math.min(ymin, points[i + 1]); - ymax = Math.max(ymax, points[i + 1]); - } + return { + ...box, + x: box.xtl, + y: box.ytl, + width: box.xbr - box.xtl, + height: box.ybr - box.ytl, + }; +} - if (shapeType === ObjectShape.POLYLINE) { - const length = Math.max(xmax - xmin, ymax - ymin); - return length >= MIN_SHAPE_LENGTH; - } +function validateAttributeValue(value: string, attr: Attribute): boolean { + const { values } = attr; + const type = attr.inputType; - const area = (xmax - xmin) * (ymax - ymin); - return area >= MIN_SHAPE_AREA; + if (typeof value !== 'string') { + throw new ArgumentError(`Attribute value is expected to be string, but got ${typeof value}`); } - function rotatePoint(x, y, angle, cx = 0, cy = 0) { - const sin = Math.sin((angle * Math.PI) / 180); - const cos = Math.cos((angle * Math.PI) / 180); - const rotX = (x - cx) * cos - (y - cy) * sin + cx; - const rotY = (y - cy) * cos + (x - cx) * sin + cy; - return [rotX, rotY]; + if (type === AttributeType.NUMBER) { + return +value >= +values[0] && +value <= +values[1]; } - function fitPoints(shapeType, points, rotation, maxX, maxY) { - checkObjectType('rotation', rotation, 'number', null); - points.forEach((coordinate) => checkObjectType('coordinate', coordinate, 'number', null)); - - if (shapeType === ObjectShape.CUBOID || shapeType === ObjectShape.ELLIPSE || !!rotation) { - // cuboids and rotated bounding boxes cannot be fitted - return points; - } - - const fittedPoints = []; - - for (let i = 0; i < points.length - 1; i += 2) { - const x = points[i]; - const y = points[i + 1]; - const clampedX = Math.clamp(x, 0, maxX); - const clampedY = Math.clamp(y, 0, maxY); - fittedPoints.push(clampedX, clampedY); - } - - return fittedPoints; + if (type === AttributeType.CHECKBOX) { + return ['true', 'false'].includes(value.toLowerCase()); } - function validateAttributeValue(value, attr) { - const { values } = attr; - const type = attr.inputType; - - if (typeof value !== 'string') { - throw new ArgumentError(`Attribute value is expected to be string, but got ${typeof value}`); - } - - if (type === AttributeType.NUMBER) { - return +value >= +values[0] && +value <= +values[1]; - } - - if (type === AttributeType.CHECKBOX) { - return ['true', 'false'].includes(value.toLowerCase()); - } + if (type === AttributeType.TEXT) { + return true; + } - if (type === AttributeType.TEXT) { - return true; - } + return values.includes(value); +} + +function copyShape(state: TrackedShape, data: Partial = {}): TrackedShape { + return { + rotation: state.rotation, + zOrder: state.zOrder, + points: state.points, + occluded: state.occluded, + outside: state.outside, + attributes: {}, + ...data, + }; +} - return values.includes(value); - } +interface AnnotationInjection { + labels: Label[]; + groups: { max: number }; + frameMeta: { + deleted_frames: Record; + }; + history: AnnotationHistory; + groupColors: Record; + parentID?: number; + readOnlyFields?: string[]; + nextClientID: () => number; +} + +class Annotation { + public clientID: number; + protected taskLabels: Label[]; + protected history: any; + protected groupColors: Record; + protected serverID: number | null; + protected parentID: number | null; + protected group: number; + public label: Label; + protected frame: number; + protected removed: boolean; + protected lock: boolean; + protected readOnlyFields: string[]; + protected color: string; + protected source: Source; + public updated: number; + protected attributes: Record; + protected groupObject: { + color: string; + readonly id: number; + }; - class Annotation { - constructor(data, clientID, color, injection) { - this.taskLabels = injection.labels; - this.history = injection.history; - this.groupColors = injection.groupColors; - this.clientID = clientID; - this.serverID = data.id; - this.group = data.group; - this.label = this.taskLabels[data.label_id]; - this.frame = data.frame; - this.removed = false; - this.lock = false; - this.color = color; - this.source = data.source; - this.updated = Date.now(); - this.attributes = data.attributes.reduce((attributeAccumulator, attr) => { - attributeAccumulator[attr.spec_id] = attr.value; - return attributeAccumulator; - }, {}); - this.groupObject = Object.defineProperties( - {}, - { - color: { - get: () => { - if (this.group) { - return this.groupColors[this.group] || colors[this.group % colors.length]; - } - return defaultGroupColor; - }, - set: (newColor) => { - if (this.group && typeof newColor === 'string' && /^#[0-9A-F]{6}$/i.test(newColor)) { - this.groupColors[this.group] = newColor; - } - }, + constructor(data, clientID: number, color: string, injection: AnnotationInjection) { + this.taskLabels = injection.labels; + this.history = injection.history; + this.groupColors = injection.groupColors; + this.clientID = clientID; + this.serverID = data.id || null; + this.parentID = injection.parentID || null; + this.group = data.group; + this.label = this.taskLabels[data.label_id]; + this.frame = data.frame; + this.removed = false; + this.lock = false; + this.readOnlyFields = injection.readOnlyFields || []; + this.color = color; + this.source = data.source; + this.updated = Date.now(); + this.attributes = data.attributes.reduce((attributeAccumulator, attr) => { + attributeAccumulator[attr.spec_id] = attr.value; + return attributeAccumulator; + }, {}); + this.groupObject = Object.defineProperties( + {}, { + color: { + get: () => { + if (this.group) { + return this.groupColors[this.group] || colors[this.group % colors.length]; + } + return defaultGroupColor; }, - id: { - get: () => this.group, + set: (newColor) => { + if (this.group && typeof newColor === 'string' && /^#[0-9A-F]{6}$/i.test(newColor)) { + this.groupColors[this.group] = newColor; + this.updated = Date.now(); + } }, }, - ); - this.appendDefaultAttributes(this.label); - - injection.groups.max = Math.max(injection.groups.max, this.group); - } + id: { + get: () => this.group, + }, + }, + ) as Annotation['groupObject']; - _saveLock(lock, frame) { - const undoLock = this.lock; - const redoLock = lock; + this.appendDefaultAttributes(this.label); + injection.groups.max = Math.max(injection.groups.max, this.group); + } - this.history.do( - HistoryActions.CHANGED_LOCK, - () => { - this.lock = undoLock; - this.updated = Date.now(); - }, - () => { - this.lock = redoLock; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + _withContext(frame: number) { + return { + __internal: { + save: this.save.bind(this, frame), + delete: this.delete.bind(this), + }, + }; + } - this.lock = lock; - } + _saveLock(lock: boolean, frame: number): void { + const undoLock = this.lock; + const redoLock = lock; - _saveColor(color, frame) { - const undoColor = this.color; - const redoColor = color; + this.history.do( + HistoryActions.CHANGED_LOCK, + () => { + this.lock = undoLock; + this.updated = Date.now(); + }, + () => { + this.lock = redoLock; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - this.history.do( - HistoryActions.CHANGED_COLOR, - () => { - this.color = undoColor; - this.updated = Date.now(); - }, - () => { - this.color = redoColor; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + this.lock = lock; + } - this.color = color; - } + _saveColor(color: string, frame: number): void { + const undoColor = this.color; + const redoColor = color; - _saveHidden(hidden, frame) { - const undoHidden = this.hidden; - const redoHidden = hidden; + this.history.do( + HistoryActions.CHANGED_COLOR, + () => { + this.color = undoColor; + this.updated = Date.now(); + }, + () => { + this.color = redoColor; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - this.history.do( - HistoryActions.CHANGED_HIDDEN, - () => { - this.hidden = undoHidden; - this.updated = Date.now(); - }, - () => { - this.hidden = redoHidden; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + this.color = color; + } - this.hidden = hidden; - } - - _saveLabel(label, frame) { - const undoLabel = this.label; - const redoLabel = label; - const undoAttributes = { ...this.attributes }; - this.label = label; - this.attributes = {}; - this.appendDefaultAttributes(label); - - // Try to keep old attributes if name matches and old value is still valid - for (const attribute of redoLabel.attributes) { - for (const oldAttribute of undoLabel.attributes) { - if ( - attribute.name === oldAttribute.name && - validateAttributeValue(undoAttributes[oldAttribute.id], attribute) - ) { - this.attributes[attribute.id] = undoAttributes[oldAttribute.id]; - } + _saveLabel(label: Label, frame: number): void { + const undoLabel = this.label; + const redoLabel = label; + const undoAttributes = { ...this.attributes }; + this.label = label; + this.attributes = {}; + this.appendDefaultAttributes(label); + + // Try to keep old attributes if name matches and old value is still valid + for (const attribute of redoLabel.attributes) { + for (const oldAttribute of undoLabel.attributes) { + if ( + attribute.name === oldAttribute.name && + validateAttributeValue(undoAttributes[oldAttribute.id], attribute) + ) { + this.attributes[attribute.id] = undoAttributes[oldAttribute.id]; } } - const redoAttributes = { ...this.attributes }; - - this.history.do( - HistoryActions.CHANGED_LABEL, - () => { - this.label = undoLabel; - this.attributes = undoAttributes; - this.updated = Date.now(); - }, - () => { - this.label = redoLabel; - this.attributes = redoAttributes; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); } + const redoAttributes = { ...this.attributes }; - _saveAttributes(attributes, frame) { - const undoAttributes = { ...this.attributes }; - - for (const attrID of Object.keys(attributes)) { - this.attributes[attrID] = attributes[attrID]; - } + this.history.do( + HistoryActions.CHANGED_LABEL, + () => { + this.label = undoLabel; + this.attributes = undoAttributes; + this.updated = Date.now(); + }, + () => { + this.label = redoLabel; + this.attributes = redoAttributes; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); + } - const redoAttributes = { ...this.attributes }; + _saveAttributes(attributes: Record, frame: number): void { + const undoAttributes = { ...this.attributes }; - this.history.do( - HistoryActions.CHANGED_ATTRIBUTES, - () => { - this.attributes = undoAttributes; - this.updated = Date.now(); - }, - () => { - this.attributes = redoAttributes; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + for (const attrID of Object.keys(attributes)) { + this.attributes[attrID] = attributes[attrID]; } - _validateStateBeforeSave(frame, data, updated) { - let fittedPoints = []; + const redoAttributes = { ...this.attributes }; - if (updated.label) { - checkObjectType('label', data.label, null, Label); - } + this.history.do( + HistoryActions.CHANGED_ATTRIBUTES, + () => { + this.attributes = undoAttributes; + this.updated = Date.now(); + }, + () => { + this.attributes = redoAttributes; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); + } - const labelAttributes = data.label.attributes.reduce((accumulator, value) => { - accumulator[value.id] = value; - return accumulator; - }, {}); - - if (updated.attributes) { - for (const attrID of Object.keys(data.attributes)) { - const value = data.attributes[attrID]; - if (attrID in labelAttributes) { - if (!validateAttributeValue(value, labelAttributes[attrID])) { - throw new ArgumentError( - `Trying to save an attribute attribute with id ${attrID} and invalid value ${value}`, - ); - } - } else { + _validateStateBeforeSave(data: ObjectState, updated: ObjectState['updateFlags']): void { + if (updated.label) { + checkObjectType('label', data.label, null, Label); + } + + const labelAttributes = attrsAsAnObject(data.label.attributes); + if (updated.attributes) { + for (const attrID of Object.keys(data.attributes)) { + const value = data.attributes[attrID]; + if (attrID in labelAttributes) { + if (!validateAttributeValue(value, labelAttributes[attrID])) { throw new ArgumentError( - `The label of the shape doesn't have the attribute with id ${attrID} and value ${value}`, + `Trying to save an attribute attribute with id ${attrID} and invalid value ${value}`, ); } - } - } - - if (updated.descriptions) { - if (!Array.isArray(data.descriptions) || data.descriptions.some((desc) => typeof desc !== 'string')) { + } else { throw new ArgumentError( - `Descriptions are expected to be an array of strings but got ${data.descriptions}`, + `The label of the shape doesn't have the attribute with id ${attrID} and value ${value}`, ); } } + } - if (updated.points) { - checkObjectType('points', data.points, null, Array); - checkNumberOfPoints(this.shapeType, data.points); - // cut points - const { width, height, filename } = this.frameMeta[frame]; - fittedPoints = fitPoints(this.shapeType, data.points, data.rotation, width, height); - let check = true; - if (filename && filename.slice(filename.length - 3) === 'pcd') { - check = false; - } - if (check) { - if (!checkShapeArea(this.shapeType, fittedPoints)) { - fittedPoints = []; - } - } - } - - if (updated.occluded) { - checkObjectType('occluded', data.occluded, 'boolean', null); - } - - if (updated.outside) { - checkObjectType('outside', data.outside, 'boolean', null); - } - - if (updated.zOrder) { - checkObjectType('zOrder', data.zOrder, 'integer', null); - } - - if (updated.lock) { - checkObjectType('lock', data.lock, 'boolean', null); - } - - if (updated.pinned) { - checkObjectType('pinned', data.pinned, 'boolean', null); + if (updated.descriptions) { + if (!Array.isArray(data.descriptions) || data.descriptions.some((desc) => typeof desc !== 'string')) { + throw new ArgumentError( + `Descriptions are expected to be an array of strings but got ${data.descriptions}`, + ); } + } - if (updated.color) { - checkObjectType('color', data.color, 'string', null); - if (!/^#[0-9A-F]{6}$/i.test(data.color)) { - throw new ArgumentError(`Got invalid color value: "${data.color}"`); - } - } + if (updated.occluded) { + checkObjectType('occluded', data.occluded, 'boolean', null); + } - if (updated.hidden) { - checkObjectType('hidden', data.hidden, 'boolean', null); - } + if (updated.outside) { + checkObjectType('outside', data.outside, 'boolean', null); + } - if (updated.keyframe) { - checkObjectType('keyframe', data.keyframe, 'boolean', null); - if (!this.shapes || (Object.keys(this.shapes).length === 1 && !data.keyframe)) { - throw new ArgumentError( - 'Can not remove the latest keyframe of an object. Consider removing the object instead', - ); - } - } + if (updated.zOrder) { + checkObjectType('zOrder', data.zOrder, 'integer', null); + } - return fittedPoints; + if (updated.lock) { + checkObjectType('lock', data.lock, 'boolean', null); } - appendDefaultAttributes(label) { - const labelAttributes = label.attributes; - for (const attribute of labelAttributes) { - if (!(attribute.id in this.attributes)) { - this.attributes[attribute.id] = attribute.defaultValue; - } - } + if (updated.pinned) { + checkObjectType('pinned', data.pinned, 'boolean', null); } - updateTimestamp(updated) { - const anyChanges = Object.keys(updated).some((key) => !!updated[key]); - if (anyChanges) { - this.updated = Date.now(); + if (updated.color) { + checkObjectType('color', data.color, 'string', null); + if (!/^#[0-9A-F]{6}$/i.test(data.color)) { + throw new ArgumentError(`Got invalid color value: "${data.color}"`); } } - delete(frame, force) { - if (!this.lock || force) { - this.removed = true; + if (updated.hidden) { + checkObjectType('hidden', data.hidden, 'boolean', null); + } - this.history.do( - HistoryActions.REMOVED_OBJECT, - () => { - this.serverID = undefined; - this.removed = false; - this.updated = Date.now(); - }, - () => { - this.removed = true; - this.updated = Date.now(); - }, - [this.clientID], - frame, + if (updated.keyframe) { + checkObjectType('keyframe', data.keyframe, 'boolean', null); + if (Object.keys(this.shapes).length === 1 && data.frame in this.shapes && !data.keyframe) { + throw new ArgumentError( + `Can not remove the latest keyframe of an object "${data.label.name}".` + + 'Consider removing the object instead', ); } - - return this.removed; } } - class Drawn extends Annotation { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.frameMeta = injection.frameMeta; - this.descriptions = data.descriptions || []; - this.hidden = false; - this.pinned = true; - this.shapeType = null; + appendDefaultAttributes(label: Label): void { + const labelAttributes = label.attributes; + for (const attribute of labelAttributes) { + if (!(attribute.id in this.attributes)) { + this.attributes[attribute.id] = attribute.defaultValue; + } } + } - _saveDescriptions(descriptions) { - this.descriptions = [...descriptions]; + updateTimestamp(updated: ObjectState['updateFlags']): void { + const anyChanges = Object.keys(updated).some((key) => !!updated[key]); + if (anyChanges) { + this.updated = Date.now(); } + } - _savePinned(pinned, frame) { - const undoPinned = this.pinned; - const redoPinned = pinned; + delete(frame: number, force: boolean): boolean { + if (!this.lock || force) { + this.removed = true; this.history.do( - HistoryActions.CHANGED_PINNED, + HistoryActions.REMOVED_OBJECT, () => { - this.pinned = undoPinned; + this.serverID = undefined; + this.removed = false; this.updated = Date.now(); }, () => { - this.pinned = redoPinned; + this.removed = true; this.updated = Date.now(); }, [this.clientID], frame, ); - - this.pinned = pinned; } - save() { - throw new ScriptingError('Is not implemented'); - } + return this.removed; + } - get() { - throw new ScriptingError('Is not implemented'); - } + save(): void { + throw new ScriptingError('Is not implemented'); + } - toJSON() { - throw new ScriptingError('Is not implemented'); - } + get(): void { + throw new ScriptingError('Is not implemented'); } - class Shape extends Drawn { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.points = data.points; - this.rotation = data.rotation || 0; - this.occluded = data.occluded; - this.zOrder = data.z_order; - } + toJSON(): void { + throw new ScriptingError('Is not implemented'); + } +} + +class Drawn extends Annotation { + protected frameMeta: AnnotationInjection['frameMeta']; + protected descriptions: string[]; + protected hidden: boolean; + protected pinned: boolean; + protected shapeType: ShapeType; + + constructor(data, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.frameMeta = injection.frameMeta; + this.descriptions = data.descriptions || []; + this.hidden = false; + this.pinned = true; + this.shapeType = null; + } - // Method is used to export data to the server - toJSON() { - return { - type: this.shapeType, - clientID: this.clientID, - occluded: this.occluded, - z_order: this.zOrder, - points: [...this.points], - rotation: this.rotation, - attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { - attributeAccumulator.push({ - spec_id: attrId, - value: this.attributes[attrId], - }); + _saveDescriptions(descriptions: string[]): void { + this.descriptions = [...descriptions]; + } - return attributeAccumulator; - }, []), - id: this.serverID, - frame: this.frame, - label_id: this.label.id, - group: this.group, - source: this.source, - }; - } + _savePinned(pinned: boolean, frame: number): void { + const undoPinned = this.pinned; + const redoPinned = pinned; - // Method is used to construct ObjectState objects - get(frame) { - if (frame !== this.frame) { - throw new ScriptingError('Got frame is not equal to the frame of the shape'); - } + this.history.do( + HistoryActions.CHANGED_PINNED, + () => { + this.pinned = undoPinned; + this.updated = Date.now(); + }, + () => { + this.pinned = redoPinned; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - return { - objectType: ObjectType.SHAPE, - shapeType: this.shapeType, - clientID: this.clientID, - serverID: this.serverID, - occluded: this.occluded, - lock: this.lock, - zOrder: this.zOrder, - points: [...this.points], - rotation: this.rotation, - attributes: { ...this.attributes }, - descriptions: [...this.descriptions], - label: this.label, - group: this.groupObject, - color: this.color, - hidden: this.hidden, - updated: this.updated, - pinned: this.pinned, - frame, - source: this.source, - }; - } + this.pinned = pinned; + } - _savePoints(points, rotation, frame) { - const undoPoints = this.points; - const undoRotation = this.rotation; - const redoPoints = points; - const redoRotation = rotation; - const undoSource = this.source; - const redoSource = Source.MANUAL; + _saveHidden(hidden: boolean, frame: number): void { + const undoHidden = this.hidden; + const redoHidden = hidden; - this.history.do( - HistoryActions.CHANGED_POINTS, - () => { - this.points = undoPoints; - this.source = undoSource; - this.rotation = undoRotation; - this.updated = Date.now(); - }, - () => { - this.points = redoPoints; - this.source = redoSource; - this.rotation = redoRotation; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + this.history.do( + HistoryActions.CHANGED_HIDDEN, + () => { + this.hidden = undoHidden; + this.updated = Date.now(); + }, + () => { + this.hidden = redoHidden; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - this.source = Source.MANUAL; - this.points = points; - this.rotation = rotation; - } + this.hidden = hidden; + } - _saveOccluded(occluded, frame) { - const undoOccluded = this.occluded; - const redoOccluded = occluded; - const undoSource = this.source; - const redoSource = Source.MANUAL; + _fitPoints(points: number[], rotation: number, maxX: number, maxY: number): number[] { + const { shapeType, parentID } = this; + checkObjectType('rotation', rotation, 'number', null); + points.forEach((coordinate) => checkObjectType('coordinate', coordinate, 'number', null)); - this.history.do( - HistoryActions.CHANGED_OCCLUDED, - () => { - this.occluded = undoOccluded; - this.source = undoSource; - this.updated = Date.now(); - }, - () => { - this.occluded = redoOccluded; - this.source = redoSource; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + if (parentID !== null || shapeType === ShapeType.CUBOID || + shapeType === ShapeType.ELLIPSE || !!rotation) { + // cuboids and rotated bounding boxes cannot be fitted + return points; + } + + const fittedPoints = []; - this.source = Source.MANUAL; - this.occluded = occluded; + for (let i = 0; i < points.length - 1; i += 2) { + const x = points[i]; + const y = points[i + 1]; + const clampedX = clamp(x, 0, maxX); + const clampedY = clamp(y, 0, maxY); + fittedPoints.push(clampedX, clampedY); } - _saveZOrder(zOrder, frame) { - const undoZOrder = this.zOrder; - const redoZOrder = zOrder; - const undoSource = this.source; - const redoSource = Source.MANUAL; + return fittedPoints; + } - this.history.do( - HistoryActions.CHANGED_ZORDER, - () => { - this.zOrder = undoZOrder; - this.source = undoSource; - this.updated = Date.now(); - }, - () => { - this.zOrder = redoZOrder; - this.source = redoSource; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + protected _validateStateBeforeSave(frame: number, data: ObjectState, updated: ObjectState['updateFlags']): number[] { + /* eslint-disable-next-line no-underscore-dangle */ + Annotation.prototype._validateStateBeforeSave.call(this, data, updated); + + let fittedPoints = []; + if (updated.points) { + checkObjectType('points', data.points, null, Array); + checkNumberOfPoints(this.shapeType, data.points); + // cut points + const { width, height, filename } = this.frameMeta[frame]; + fittedPoints = this._fitPoints(data.points, data.rotation, width, height); + let check = true; + if (filename && filename.slice(filename.length - 3) === 'pcd') { + check = false; + } + if (check) { + if (!checkShapeArea(this.shapeType, fittedPoints)) { + fittedPoints = []; + } + } + } - this.source = Source.MANUAL; - this.zOrder = zOrder; + return fittedPoints; + } +} + +interface RawShapeData { + id?: number; + clientID?: number; + label_id: number; + group: number; + frame: number; + source: Source; + attributes: { spec_id: number; value: string }[]; + elements: { + id?: number; + attributes: RawTrackData['attributes']; + label_id: number; + occluded: boolean; + outside: boolean; + points: number[]; + type: ShapeType; + }[]; + occluded: boolean; + outside?: boolean; // only for skeleton elements + points?: number[]; + rotation: number; + z_order: number; + type: ShapeType; +} + +export class Shape extends Drawn { + protected points: number[]; + protected rotation: number; + protected occluded: boolean; + protected outside: boolean; + protected zOrder: number; + + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.points = data.points; + this.rotation = data.rotation || 0; + this.occluded = data.occluded; + this.outside = data.outside; + this.zOrder = data.z_order; + } + + // Method is used to export data to the server + toJSON(): RawShapeData { + const result: RawShapeData = { + type: this.shapeType, + clientID: this.clientID, + occluded: this.occluded, + z_order: this.zOrder, + points: [...this.points], + rotation: this.rotation, + attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { + attributeAccumulator.push({ + spec_id: +attrId, + value: this.attributes[attrId], + }); + + return attributeAccumulator; + }, []), + elements: [], + frame: this.frame, + label_id: this.label.id, + group: this.group, + source: this.source, + }; + + if (this.serverID !== null) { + result.id = this.serverID; } - save(frame, data) { - if (frame !== this.frame) { - throw new ScriptingError('Got frame is not equal to the frame of the shape'); - } + if (typeof this.outside !== 'undefined') { + result.outside = this.outside; + } - if (this.lock && data.lock) { - return objectStateFactory.call(this, frame, this.get(frame)); - } + return result; + } - const updated = data.updateFlags; - const fittedPoints = this._validateStateBeforeSave(frame, data, updated); - const { rotation } = data; + get(frame) { + if (frame !== this.frame) { + throw new ScriptingError('Received frame is not equal to the frame of the shape'); + } + + const result = { + objectType: ObjectType.SHAPE, + shapeType: this.shapeType, + clientID: this.clientID, + serverID: this.serverID, + parentID: this.parentID, + occluded: this.occluded, + lock: this.lock, + zOrder: this.zOrder, + points: [...this.points], + rotation: this.rotation, + attributes: { ...this.attributes }, + descriptions: [...this.descriptions], + label: this.label, + group: this.groupObject, + color: this.color, + hidden: this.hidden, + updated: this.updated, + pinned: this.pinned, + frame, + source: this.source, + ...this._withContext(frame), + }; - // Now when all fields are validated, we can apply them - if (updated.label) { - this._saveLabel(data.label, frame); - } + if (typeof this.outside !== 'undefined') { + result.outside = this.outside; + } - if (updated.attributes) { - this._saveAttributes(data.attributes, frame); - } + return result; + } - if (updated.descriptions) { - this._saveDescriptions(data.descriptions); - } + _saveRotation(rotation: number, frame: number): void { + const undoRotation = this.rotation; + const redoRotation = rotation; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + + this.history.do( + HistoryActions.CHANGED_ROTATION, + () => { + this.source = undoSource; + this.rotation = undoRotation; + this.updated = Date.now(); + }, + () => { + this.source = redoSource; + this.rotation = redoRotation; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - if (updated.points && fittedPoints.length) { - this._savePoints(fittedPoints, rotation, frame); - } + this.source = redoSource; + this.rotation = redoRotation; + } - if (updated.occluded) { - this._saveOccluded(data.occluded, frame); - } + _savePoints(points: number[], frame: number): void { + const undoPoints = this.points; + const redoPoints = points; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + + this.history.do( + HistoryActions.CHANGED_POINTS, + () => { + this.points = undoPoints; + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + this.points = redoPoints; + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - if (updated.zOrder) { - this._saveZOrder(data.zOrder, frame); - } + this.source = redoSource; + this.points = redoPoints; + } - if (updated.lock) { - this._saveLock(data.lock, frame); - } + _saveOccluded(occluded: boolean, frame: number): void { + const undoOccluded = this.occluded; + const redoOccluded = occluded; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + + this.history.do( + HistoryActions.CHANGED_OCCLUDED, + () => { + this.occluded = undoOccluded; + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + this.occluded = redoOccluded; + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - if (updated.pinned) { - this._savePinned(data.pinned, frame); - } + this.source = redoSource; + this.occluded = redoOccluded; + } - if (updated.color) { - this._saveColor(data.color, frame); - } + _saveOutside(outside: boolean, frame: number): void { + const undoOutside = this.outside; + const redoOutside = outside; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + + this.history.do( + HistoryActions.CHANGED_OCCLUDED, + () => { + this.outside = undoOutside; + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + this.occluded = redoOutside; + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - if (updated.hidden) { - this._saveHidden(data.hidden, frame); - } + this.source = redoSource; + this.outside = redoOutside; + } - this.updateTimestamp(updated); - updated.reset(); + _saveZOrder(zOrder: number, frame: number): void { + const undoZOrder = this.zOrder; + const redoZOrder = zOrder; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + + this.history.do( + HistoryActions.CHANGED_ZORDER, + () => { + this.zOrder = undoZOrder; + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + this.zOrder = redoZOrder; + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); - return objectStateFactory.call(this, frame, this.get(frame)); - } + this.source = redoSource; + this.zOrder = redoZOrder; } - class Track extends Drawn { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapes = data.shapes.reduce((shapeAccumulator, value) => { - shapeAccumulator[value.frame] = { - serverID: value.id, - occluded: value.occluded, - zOrder: value.z_order, - points: value.points, - outside: value.outside, - rotation: value.rotation || 0, - attributes: value.attributes.reduce((attributeAccumulator, attr) => { - attributeAccumulator[attr.spec_id] = attr.value; - return attributeAccumulator; - }, {}), - }; + save(frame: number, data: ObjectState): ObjectState { + if (frame !== this.frame) { + throw new ScriptingError('Received frame is not equal to the frame of the shape'); + } - return shapeAccumulator; - }, {}); + if (this.lock && data.lock) { + return new ObjectState(this.get(frame)); } - // Method is used to export data to the server - toJSON() { - const labelAttributes = this.label.attributes.reduce((accumulator, attribute) => { - accumulator[attribute.id] = attribute; - return accumulator; - }, {}); + const updated = data.updateFlags; + for (const readOnlyField of this.readOnlyFields) { + updated[readOnlyField] = false; + } - return { - clientID: this.clientID, - id: this.serverID, - frame: this.frame, - label_id: this.label.id, - group: this.group, - source: this.source, - attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { - if (!labelAttributes[attrId].mutable) { - attributeAccumulator.push({ - spec_id: attrId, - value: this.attributes[attrId], - }); - } + const fittedPoints = this._validateStateBeforeSave(frame, data, updated); + const { rotation } = data; - return attributeAccumulator; - }, []), - shapes: Object.keys(this.shapes).reduce((shapesAccumulator, frame) => { - shapesAccumulator.push({ - type: this.shapeType, - occluded: this.shapes[frame].occluded, - z_order: this.shapes[frame].zOrder, - points: [...this.shapes[frame].points], - rotation: this.shapes[frame].rotation, - outside: this.shapes[frame].outside, - attributes: Object.keys(this.shapes[frame].attributes).reduce( - (attributeAccumulator, attrId) => { - if (labelAttributes[attrId].mutable) { - attributeAccumulator.push({ - spec_id: attrId, - value: this.shapes[frame].attributes[attrId], - }); - } - - return attributeAccumulator; - }, - [], - ), - id: this.shapes[frame].serverID, - frame: +frame, - }); + // Now when all fields are validated, we can apply them + if (updated.label) { + this._saveLabel(data.label, frame); + } - return shapesAccumulator; - }, []), - }; + if (updated.attributes) { + this._saveAttributes(data.attributes, frame); } - // Method is used to construct ObjectState objects - get(frame) { - const { - prev, next, first, last, - } = this.boundedKeyframes(frame); + if (updated.descriptions) { + this._saveDescriptions(data.descriptions); + } - return { - ...this.getPosition(frame, prev, next), - attributes: this.getAttributes(frame), - descriptions: [...this.descriptions], - group: this.groupObject, - objectType: ObjectType.TRACK, - shapeType: this.shapeType, - clientID: this.clientID, - serverID: this.serverID, - lock: this.lock, - color: this.color, - hidden: this.hidden, - updated: this.updated, - label: this.label, - pinned: this.pinned, - keyframes: { - prev, - next, - first, - last, - }, - frame, - source: this.source, - }; + if (updated.rotation) { + this._saveRotation(rotation, frame); } - boundedKeyframes(targetFrame) { - const frames = Object.keys(this.shapes).map((frame) => +frame); - let lDiff = Number.MAX_SAFE_INTEGER; - let rDiff = Number.MAX_SAFE_INTEGER; - let first = Number.MAX_SAFE_INTEGER; - let last = Number.MIN_SAFE_INTEGER; + if (updated.points && fittedPoints.length) { + this._savePoints(fittedPoints, frame); + } - for (const frame of frames) { - if (frame in this.frameMeta.deleted_frames) { - continue; - } + if (updated.occluded) { + this._saveOccluded(data.occluded, frame); + } - if (frame < first) { - first = frame; - } - if (frame > last) { - last = frame; + if (updated.outside) { + this._saveOutside(data.outside, frame); + } + + if (updated.zOrder) { + this._saveZOrder(data.zOrder, frame); + } + + if (updated.lock) { + this._saveLock(data.lock, frame); + } + + if (updated.pinned) { + this._savePinned(data.pinned, frame); + } + + if (updated.color) { + this._saveColor(data.color, frame); + } + + if (updated.hidden) { + this._saveHidden(data.hidden, frame); + } + + this.updateTimestamp(updated); + updated.reset(); + + return new ObjectState(this.get(frame)); + } +} + +interface RawTrackData { + id?: number; + clientID?: number; + label_id: number; + group: number; + frame: number; + source: Source; + attributes: { spec_id: number; value: string }[]; + shapes: { + attributes: RawTrackData['attributes']; + id?: number; + points?: number[]; + frame: number; + occluded: boolean; + outside: boolean; + rotation: number; + type: ShapeType; + z_order: number; + }[]; + elements?: RawTrackData[]; +} + +interface TrackedShape { + serverID?: number; + occluded: boolean; + outside: boolean; + rotation: number; + zOrder: number; + points?: number[]; + attributes: Record; +} + +export class Track extends Drawn { + public shapes: Record; + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapes = data.shapes.reduce((shapeAccumulator, value) => { + shapeAccumulator[value.frame] = { + serverID: value.id, + occluded: value.occluded, + zOrder: value.z_order, + points: value.points, + outside: value.outside, + rotation: value.rotation || 0, + attributes: value.attributes.reduce((attributeAccumulator, attr) => { + attributeAccumulator[attr.spec_id] = attr.value; + return attributeAccumulator; + }, {}), + }; + + return shapeAccumulator; + }, {}); + } + + // Method is used to export data to the server + toJSON(): RawTrackData { + const labelAttributes = attrsAsAnObject(this.label.attributes); + + const result: RawTrackData = { + clientID: this.clientID, + label_id: this.label.id, + frame: this.frame, + group: this.group, + source: this.source, + elements: [], + attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { + if (!labelAttributes[attrId].mutable) { + attributeAccumulator.push({ + spec_id: +attrId, + value: this.attributes[attrId], + }); } - const diff = Math.abs(targetFrame - frame); + return attributeAccumulator; + }, []), + shapes: Object.keys(this.shapes).reduce((shapesAccumulator, frame) => { + shapesAccumulator.push({ + type: this.shapeType, + occluded: this.shapes[frame].occluded, + z_order: this.shapes[frame].zOrder, + rotation: this.shapes[frame].rotation, + outside: this.shapes[frame].outside, + attributes: Object.keys(this.shapes[frame].attributes).reduce( + (attributeAccumulator, attrId) => { + if (labelAttributes[attrId].mutable) { + attributeAccumulator.push({ + spec_id: +attrId, + value: this.shapes[frame].attributes[attrId], + }); + } + + return attributeAccumulator; + }, + [], + ), + id: this.shapes[frame].serverID, + frame: +frame, + }); - if (frame < targetFrame && diff < lDiff) { - lDiff = diff; - } else if (frame > targetFrame && diff < rDiff) { - rDiff = diff; + if (this.shapes[frame].points) { + shapesAccumulator[shapesAccumulator.length - 1].points = [...this.shapes[frame].points]; } - } - const prev = lDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame - lDiff; - const next = rDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame + rDiff; + return shapesAccumulator; + }, []), + }; - return { + if (this.serverID !== null) { + result.id = this.serverID; + } + + return result; + } + + get(frame: number) { + const { + prev, next, first, last, + } = this.boundedKeyframes(frame); + + return { + ...this.getPosition(frame, prev, next), + attributes: this.getAttributes(frame), + descriptions: [...this.descriptions], + group: this.groupObject, + objectType: ObjectType.TRACK, + shapeType: this.shapeType, + clientID: this.clientID, + serverID: this.serverID, + parentID: this.parentID, + lock: this.lock, + color: this.color, + hidden: this.hidden, + updated: this.updated, + label: this.label, + pinned: this.pinned, + keyframes: { prev, next, first, last, - }; + }, + frame, + source: this.source, + ...this._withContext(frame), + }; + } + + boundedKeyframes(targetFrame: number): ObjectState['keyframes'] { + const frames = Object.keys(this.shapes).map((frame) => +frame); + let lDiff = Number.MAX_SAFE_INTEGER; + let rDiff = Number.MAX_SAFE_INTEGER; + let first = Number.MAX_SAFE_INTEGER; + let last = Number.MIN_SAFE_INTEGER; + + for (const frame of frames) { + if (frame in this.frameMeta.deleted_frames) { + continue; + } + + if (frame < first) { + first = frame; + } + if (frame > last) { + last = frame; + } + + const diff = Math.abs(targetFrame - frame); + + if (frame < targetFrame && diff < lDiff) { + lDiff = diff; + } else if (frame > targetFrame && diff < rDiff) { + rDiff = diff; + } } - getAttributes(targetFrame) { - const result = {}; + const prev = lDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame - lDiff; + const next = rDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame + rDiff; - // First of all copy all unmutable attributes - for (const attrID in this.attributes) { - if (Object.prototype.hasOwnProperty.call(this.attributes, attrID)) { - result[attrID] = this.attributes[attrID]; - } + return { + prev, + next, + first, + last, + }; + } + + getAttributes(targetFrame: number): Record { + const result = {}; + + // First of all copy all unmutable attributes + for (const attrID in this.attributes) { + if (Object.prototype.hasOwnProperty.call(this.attributes, attrID)) { + result[attrID] = this.attributes[attrID]; } + } - // Secondly get latest mutable attributes up to target frame - const frames = Object.keys(this.shapes).sort((a, b) => +a - +b); - for (const frame of frames) { - if (frame <= targetFrame) { - const { attributes } = this.shapes[frame]; + // Secondly get latest mutable attributes up to target frame + const frames = Object.keys(this.shapes).sort((a, b) => +a - +b); + for (const frame of frames) { + if (+frame <= targetFrame) { + const { attributes } = this.shapes[frame]; - for (const attrID in attributes) { - if (Object.prototype.hasOwnProperty.call(attributes, attrID)) { - result[attrID] = attributes[attrID]; - } + for (const attrID in attributes) { + if (Object.prototype.hasOwnProperty.call(attributes, attrID)) { + result[attrID] = attributes[attrID]; } } } + } + + return result; + } + + _saveLabel(label: Label, frame: number): void { + const undoLabel = this.label; + const redoLabel = label; + const undoAttributes = { + unmutable: { ...this.attributes }, + mutable: Object.keys(this.shapes).map((key) => ({ + frame: +key, + attributes: { ...this.shapes[key].attributes }, + })), + }; - return result; + this.label = label; + this.attributes = {}; + for (const shape of Object.values(this.shapes)) { + shape.attributes = {}; } + this.appendDefaultAttributes(label); - _saveLabel(label, frame) { - const undoLabel = this.label; - const redoLabel = label; - const undoAttributes = { - unmutable: { ...this.attributes }, - mutable: Object.keys(this.shapes).map((key) => ({ - frame: +key, - attributes: { ...this.shapes[key].attributes }, - })), - }; + const redoAttributes = { + unmutable: { ...this.attributes }, + mutable: Object.keys(this.shapes).map((key) => ({ + frame: +key, + attributes: { ...this.shapes[key].attributes }, + })), + }; + + this.history.do( + HistoryActions.CHANGED_LABEL, + () => { + this.label = undoLabel; + this.attributes = undoAttributes.unmutable; + for (const mutable of undoAttributes.mutable) { + this.shapes[mutable.frame].attributes = mutable.attributes; + } + this.updated = Date.now(); + }, + () => { + this.label = redoLabel; + this.attributes = redoAttributes.unmutable; + for (const mutable of redoAttributes.mutable) { + this.shapes[mutable.frame].attributes = mutable.attributes; + } + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); + } + + _saveAttributes(attributes: Record, frame: number): void { + const current = this.get(frame); + const labelAttributes = attrsAsAnObject(this.label.attributes); + + const wasKeyframe = frame in this.shapes; + const undoAttributes = this.attributes; + const undoShape = wasKeyframe ? this.shapes[frame] : undefined; + + let mutableAttributesUpdated = false; + const redoAttributes = { ...this.attributes }; + for (const attrID of Object.keys(attributes)) { + if (!labelAttributes[attrID].mutable) { + redoAttributes[attrID] = attributes[attrID]; + } else if (attributes[attrID] !== current.attributes[attrID]) { + mutableAttributesUpdated = mutableAttributesUpdated || + // not keyframe yet + !(frame in this.shapes) || + // keyframe, but without this attrID + !(attrID in this.shapes[frame].attributes) || + // keyframe with attrID, but with another value + this.shapes[frame].attributes[attrID] !== attributes[attrID]; + } + } + let redoShape; + if (mutableAttributesUpdated) { + if (wasKeyframe) { + redoShape = { + ...this.shapes[frame], + attributes: { + ...this.shapes[frame].attributes, + }, + }; + } else { + redoShape = { + frame, + zOrder: current.zOrder, + points: current.points, + outside: current.outside, + occluded: current.occluded, + attributes: {}, + }; + } + } - this.label = label; - this.attributes = {}; - for (const shape of Object.values(this.shapes)) { - shape.attributes = {}; + for (const attrID of Object.keys(attributes)) { + if (labelAttributes[attrID].mutable && attributes[attrID] !== current.attributes[attrID]) { + redoShape.attributes[attrID] = attributes[attrID]; } - this.appendDefaultAttributes(label); - - const redoAttributes = { - unmutable: { ...this.attributes }, - mutable: Object.keys(this.shapes).map((key) => ({ - frame: +key, - attributes: { ...this.shapes[key].attributes }, - })), - }; + } - this.history.do( - HistoryActions.CHANGED_LABEL, - () => { - this.label = undoLabel; - this.attributes = undoAttributes.unmutable; - for (const mutable of undoAttributes.mutable) { - this.shapes[mutable.frame].attributes = mutable.attributes; - } - this.updated = Date.now(); - }, - () => { - this.label = redoLabel; - this.attributes = redoAttributes.unmutable; - for (const mutable of redoAttributes.mutable) { - this.shapes[mutable.frame].attributes = mutable.attributes; - } - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + this.attributes = redoAttributes; + if (redoShape) { + this.shapes[frame] = redoShape; } - _saveAttributes(attributes, frame) { - const current = this.get(frame); - const labelAttributes = this.label.attributes.reduce((accumulator, value) => { - accumulator[value.id] = value; - return accumulator; - }, {}); - - const wasKeyframe = frame in this.shapes; - const undoAttributes = this.attributes; - const undoShape = wasKeyframe ? this.shapes[frame] : undefined; - - let mutableAttributesUpdated = false; - const redoAttributes = { ...this.attributes }; - for (const attrID of Object.keys(attributes)) { - if (!labelAttributes[attrID].mutable) { - redoAttributes[attrID] = attributes[attrID]; - } else if (attributes[attrID] !== current.attributes[attrID]) { - mutableAttributesUpdated = mutableAttributesUpdated || - // not keyframe yet - !(frame in this.shapes) || - // keyframe, but without this attrID - !(attrID in this.shapes[frame].attributes) || - // keyframe with attrID, but with another value - this.shapes[frame].attributes[attrID] !== attributes[attrID]; + this.history.do( + HistoryActions.CHANGED_ATTRIBUTES, + () => { + this.attributes = undoAttributes; + if (undoShape) { + this.shapes[frame] = undoShape; + } else if (redoShape) { + delete this.shapes[frame]; } - } - let redoShape; - if (mutableAttributesUpdated) { - if (wasKeyframe) { - redoShape = { - ...this.shapes[frame], - attributes: { - ...this.shapes[frame].attributes, - }, - }; - } else { - redoShape = { - frame, - zOrder: current.zOrder, - points: current.points, - outside: current.outside, - occluded: current.occluded, - attributes: {}, - }; + this.updated = Date.now(); + }, + () => { + this.attributes = redoAttributes; + if (redoShape) { + this.shapes[frame] = redoShape; } - } + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); + } - for (const attrID of Object.keys(attributes)) { - if (labelAttributes[attrID].mutable && attributes[attrID] !== current.attributes[attrID]) { - redoShape.attributes[attrID] = attributes[attrID]; + _appendShapeActionToHistory(actionType, frame, undoShape, redoShape, undoSource, redoSource) { + this.history.do( + actionType, + () => { + if (!undoShape) { + delete this.shapes[frame]; + } else { + this.shapes[frame] = undoShape; } - } + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + if (!redoShape) { + delete this.shapes[frame]; + } else { + this.shapes[frame] = redoShape; + } + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID], + frame, + ); + } - this.attributes = redoAttributes; - if (redoShape) { - this.shapes[frame] = redoShape; - } + _saveRotation(rotation: number, frame: number): void { + const wasKeyframe = frame in this.shapes; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + const undoShape = wasKeyframe ? this.shapes[frame] : undefined; + const redoShape = wasKeyframe ? + { ...this.shapes[frame], rotation } : copyShape(this.get(frame), { rotation }); + + this.shapes[frame] = redoShape; + this.source = redoSource; + this._appendShapeActionToHistory( + HistoryActions.CHANGED_ROTATION, + frame, + undoShape, + redoShape, + undoSource, + redoSource, + ); + } - this.history.do( - HistoryActions.CHANGED_ATTRIBUTES, - () => { - this.attributes = undoAttributes; - if (undoShape) { - this.shapes[frame] = undoShape; - } else if (redoShape) { - delete this.shapes[frame]; - } - this.updated = Date.now(); - }, - () => { - this.attributes = redoAttributes; - if (redoShape) { - this.shapes[frame] = redoShape; - } - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + _savePoints(points: number[], frame: number): void { + const wasKeyframe = frame in this.shapes; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + const undoShape = wasKeyframe ? this.shapes[frame] : undefined; + const redoShape = wasKeyframe ? + { ...this.shapes[frame], points } : copyShape(this.get(frame), { points }); + + this.shapes[frame] = redoShape; + this.source = redoSource; + this._appendShapeActionToHistory( + HistoryActions.CHANGED_POINTS, + frame, + undoShape, + redoShape, + undoSource, + redoSource, + ); + } + + _saveOutside(frame: number, outside: boolean): void { + const wasKeyframe = frame in this.shapes; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + const undoShape = wasKeyframe ? this.shapes[frame] : undefined; + const redoShape = wasKeyframe ? + { ...this.shapes[frame], outside } : + copyShape(this.get(frame), { outside }); + + this.shapes[frame] = redoShape; + this.source = redoSource; + this._appendShapeActionToHistory( + HistoryActions.CHANGED_OUTSIDE, + frame, + undoShape, + redoShape, + undoSource, + redoSource, + ); + } + + _saveOccluded(occluded: boolean, frame: number): void { + const wasKeyframe = frame in this.shapes; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + const undoShape = wasKeyframe ? this.shapes[frame] : undefined; + const redoShape = wasKeyframe ? + { ...this.shapes[frame], occluded } : + copyShape(this.get(frame), { occluded }); + + this.shapes[frame] = redoShape; + this.source = redoSource; + this._appendShapeActionToHistory( + HistoryActions.CHANGED_OCCLUDED, + frame, + undoShape, + redoShape, + undoSource, + redoSource, + ); + } + + _saveZOrder(zOrder: number, frame: number): void { + const wasKeyframe = frame in this.shapes; + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + const undoShape = wasKeyframe ? this.shapes[frame] : undefined; + const redoShape = wasKeyframe ? + { ...this.shapes[frame], zOrder } : + copyShape(this.get(frame), { zOrder }); + + this.shapes[frame] = redoShape; + this.source = redoSource; + this._appendShapeActionToHistory( + HistoryActions.CHANGED_ZORDER, + frame, + undoShape, + redoShape, + undoSource, + redoSource, + ); + } + + _saveKeyframe(frame: number, keyframe: boolean): void { + const wasKeyframe = frame in this.shapes; + + if ((keyframe && wasKeyframe) || (!keyframe && !wasKeyframe)) { + return; } - _appendShapeActionToHistory(actionType, frame, undoShape, redoShape, undoSource, redoSource) { - this.history.do( - actionType, - () => { - if (!undoShape) { - delete this.shapes[frame]; - } else { - this.shapes[frame] = undoShape; - } - this.source = undoSource; - this.updated = Date.now(); - }, - () => { - if (!redoShape) { - delete this.shapes[frame]; - } else { - this.shapes[frame] = redoShape; - } - this.source = redoSource; - this.updated = Date.now(); - }, - [this.clientID], - frame, - ); + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + const undoShape = wasKeyframe ? this.shapes[frame] : undefined; + const redoShape = keyframe ? copyShape(this.get(frame)) : undefined; + + this.source = redoSource; + if (redoShape) { + this.shapes[frame] = redoShape; + } else { + delete this.shapes[frame]; } - _savePoints(points, rotation, frame) { - const current = this.get(frame); - const wasKeyframe = frame in this.shapes; - const undoSource = this.source; - const redoSource = Source.MANUAL; - const undoShape = wasKeyframe ? this.shapes[frame] : undefined; - const redoShape = wasKeyframe ? { ...this.shapes[frame], points, rotation } : { - frame, - points, - rotation, - zOrder: current.zOrder, - outside: current.outside, - occluded: current.occluded, - attributes: {}, + this._appendShapeActionToHistory( + HistoryActions.CHANGED_KEYFRAME, + frame, + undoShape, + redoShape, + undoSource, + redoSource, + ); + } + + save(frame, data) { + if (this.lock && data.lock) { + return new ObjectState(this.get(frame)); + } + + const updated = data.updateFlags; + for (const readOnlyField of this.readOnlyFields) { + updated[readOnlyField] = false; + } + + const fittedPoints = this._validateStateBeforeSave(frame, data, updated); + const { rotation } = data; + + if (updated.label) { + this._saveLabel(data.label, frame); + } + + if (updated.lock) { + this._saveLock(data.lock, frame); + } + + if (updated.pinned) { + this._savePinned(data.pinned, frame); + } + + if (updated.color) { + this._saveColor(data.color, frame); + } + + if (updated.hidden) { + this._saveHidden(data.hidden, frame); + } + + if (updated.points && fittedPoints.length) { + this._savePoints(fittedPoints, frame); + } + + if (updated.rotation) { + this._saveRotation(rotation, frame); + } + + if (updated.outside) { + this._saveOutside(frame, data.outside); + } + + if (updated.occluded) { + this._saveOccluded(data.occluded, frame); + } + + if (updated.zOrder) { + this._saveZOrder(data.zOrder, frame); + } + + if (updated.attributes) { + this._saveAttributes(data.attributes, frame); + } + + if (updated.descriptions) { + this._saveDescriptions(data.descriptions); + } + + if (updated.keyframe) { + this._saveKeyframe(frame, data.keyframe); + } + + this.updateTimestamp(updated); + updated.reset(); + + return new ObjectState(this.get(frame)); + } + + interpolatePosition(): {} { + throw new ScriptingError('Not implemented'); + } + + getPosition(targetFrame: number, leftKeyframe: number | null, rightFrame: number | null) { + const leftFrame = targetFrame in this.shapes ? targetFrame : leftKeyframe; + const rightPosition = Number.isInteger(rightFrame) ? this.shapes[rightFrame] : null; + const leftPosition = Number.isInteger(leftFrame) ? this.shapes[leftFrame] : null; + + if (leftPosition && rightPosition) { + return { + ...this.interpolatePosition( + leftPosition, + rightPosition, + (targetFrame - leftFrame) / (rightFrame - leftFrame), + ), + keyframe: targetFrame in this.shapes, }; + } - this.shapes[frame] = redoShape; - this.source = Source.MANUAL; - this._appendShapeActionToHistory( - HistoryActions.CHANGED_POINTS, - frame, - undoShape, - redoShape, - undoSource, - redoSource, - ); + const singlePosition = leftPosition || rightPosition; + if (singlePosition) { + return { + points: [...singlePosition.points], + rotation: singlePosition.rotation, + occluded: singlePosition.occluded, + zOrder: singlePosition.zOrder, + keyframe: targetFrame in this.shapes, + outside: singlePosition === rightPosition ? true : singlePosition.outside, + }; } - _saveOutside(frame, outside) { - const current = this.get(frame); - const wasKeyframe = frame in this.shapes; - const undoSource = this.source; - const redoSource = Source.MANUAL; - const undoShape = wasKeyframe ? this.shapes[frame] : undefined; - const redoShape = wasKeyframe ? - { ...this.shapes[frame], outside } : - { - frame, - outside, - rotation: current.rotation, - zOrder: current.zOrder, - points: current.points, - occluded: current.occluded, - attributes: {}, - }; + throw new DataError( + 'No one left position or right position was found. ' + + `Interpolation impossible. Client ID: ${this.clientID}`, + ); + } +} + +interface RawTagData { + id?: number; + clientID?: number; + label_id: number; + frame: number; + group: number; + source: Source; + attributes: { spec_id: number; value: string }[]; +} + +export class Tag extends Annotation { + // Method is used to export data to the server + toJSON(): RawTagData { + const result: RawTagData = { + clientID: this.clientID, + frame: this.frame, + label_id: this.label.id, + source: this.source, + group: 0, // TODO: why server requires group for tags? + attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { + attributeAccumulator.push({ + spec_id: +attrId, + value: this.attributes[attrId], + }); - this.shapes[frame] = redoShape; - this.source = Source.MANUAL; - this._appendShapeActionToHistory( - HistoryActions.CHANGED_OUTSIDE, - frame, - undoShape, - redoShape, - undoSource, - redoSource, - ); + return attributeAccumulator; + }, []), + }; + + if (this.serverID !== null) { + result.id = this.serverID; } - _saveOccluded(occluded, frame) { - const current = this.get(frame); - const wasKeyframe = frame in this.shapes; - const undoSource = this.source; - const redoSource = Source.MANUAL; - const undoShape = wasKeyframe ? this.shapes[frame] : undefined; - const redoShape = wasKeyframe ? - { ...this.shapes[frame], occluded } : - { - frame, - occluded, - rotation: current.rotation, - zOrder: current.zOrder, - points: current.points, - outside: current.outside, - attributes: {}, - }; + return result; + } - this.shapes[frame] = redoShape; - this.source = Source.MANUAL; - this._appendShapeActionToHistory( - HistoryActions.CHANGED_OCCLUDED, - frame, - undoShape, - redoShape, - undoSource, - redoSource, - ); + get(frame: number) { + if (frame !== this.frame) { + throw new ScriptingError('Received frame is not equal to the frame of the shape'); + } + + return { + objectType: ObjectType.TAG, + clientID: this.clientID, + serverID: this.serverID, + lock: this.lock, + attributes: { ...this.attributes }, + label: this.label, + group: this.groupObject, + color: this.color, + updated: this.updated, + frame, + source: this.source, + ...this._withContext(frame), + }; + } + + save(frame: number, data: ObjectState): ObjectState { + if (frame !== this.frame) { + throw new ScriptingError('Received frame is not equal to the frame of the tag'); } - _saveZOrder(zOrder, frame) { - const current = this.get(frame); - const wasKeyframe = frame in this.shapes; - const undoSource = this.source; - const redoSource = Source.MANUAL; - const undoShape = wasKeyframe ? this.shapes[frame] : undefined; - const redoShape = wasKeyframe ? - { ...this.shapes[frame], zOrder } : - { - frame, - zOrder, - rotation: current.rotation, - occluded: current.occluded, - points: current.points, - outside: current.outside, - attributes: {}, - }; + if (this.lock && data.lock) { + return new ObjectState(this.get(frame)); + } - this.shapes[frame] = redoShape; - this.source = Source.MANUAL; - this._appendShapeActionToHistory( - HistoryActions.CHANGED_ZORDER, - frame, - undoShape, - redoShape, - undoSource, - redoSource, - ); + const updated = data.updateFlags; + for (const readOnlyField of this.readOnlyFields) { + updated[readOnlyField] = false; + } + + this._validateStateBeforeSave(data, updated); + + // Now when all fields are validated, we can apply them + if (updated.label) { + this._saveLabel(data.label, frame); + } + + if (updated.attributes) { + this._saveAttributes(data.attributes, frame); } - _saveKeyframe(frame, keyframe) { - const current = this.get(frame); - const wasKeyframe = frame in this.shapes; + if (updated.lock) { + this._saveLock(data.lock, frame); + } + + if (updated.color) { + this._saveColor(data.color, frame); + } + + this.updateTimestamp(updated); + updated.reset(); + + return new ObjectState(this.get(frame)); + } +} + +export class RectangleShape extends Shape { + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.RECTANGLE; + this.pinned = false; + checkNumberOfPoints(this.shapeType, this.points); + } + + static distance(points: number[], x: number, y: number, angle: number): number { + const [xtl, ytl, xbr, ybr] = points; + const cx = xtl + (xbr - xtl) / 2; + const cy = ytl + (ybr - ytl) / 2; + const [rotX, rotY] = rotatePoint(x, y, -angle, cx, cy); - if ((keyframe && wasKeyframe) || (!keyframe && !wasKeyframe)) { - return; + if (!(rotX >= xtl && rotX <= xbr && rotY >= ytl && rotY <= ybr)) { + // Cursor is outside of a box + return null; + } + + // The shortest distance from point to an edge + return Math.min.apply(null, [rotX - xtl, rotY - ytl, xbr - rotX, ybr - rotY]); + } +} + +export class EllipseShape extends Shape { + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.ELLIPSE; + this.pinned = false; + checkNumberOfPoints(this.shapeType, this.points); + } + + static distance(points: number[], x: number, y: number, angle: number): number { + const [cx, cy, rightX, topY] = points; + const [rx, ry] = [rightX - cx, cy - topY]; + const [rotX, rotY] = rotatePoint(x, y, -angle, cx, cy); + // https://math.stackexchange.com/questions/76457/check-if-a-point-is-within-an-ellipse + const pointWithinEllipse = (_x, _y) => ( + ((_x - cx) ** 2) / rx ** 2) + (((_y - cy) ** 2) / ry ** 2 + ) <= 1; + + if (!pointWithinEllipse(rotX, rotY)) { + // Cursor is outside of an ellipse + return null; + } + + if (Math.abs(x - cx) < Number.EPSILON && Math.abs(y - cy) < Number.EPSILON) { + // cursor is near to the center, just return minimum of height, width + return Math.min(rx, ry); + } + + // ellipse equation is x^2/rx^2 + y^2/ry^2 = 1 + // from this equation: + // x^2 = ((rx * ry)^2 - (y * rx)^2) / ry^2 + // y^2 = ((rx * ry)^2 - (x * ry)^2) / rx^2 + + // we have one point inside the ellipse, let's build two lines (horizontal and vertical) through the point + // and find their interception with ellipse + const x2Equation = (_y) => (((rx * ry) ** 2) - ((_y * rx) ** 2)) / (ry ** 2); + const y2Equation = (_x) => (((rx * ry) ** 2) - ((_x * ry) ** 2)) / (rx ** 2); + + // shift x,y to the ellipse coordinate system to compute equation correctly + // y axis is inverted + const [shiftedX, shiftedY] = [x - cx, cy - y]; + const [x1, x2] = [Math.sqrt(x2Equation(shiftedY)), -Math.sqrt(x2Equation(shiftedY))]; + const [y1, y2] = [Math.sqrt(y2Equation(shiftedX)), -Math.sqrt(y2Equation(shiftedX))]; + + // found two points on ellipse edge + const ellipseP1X = shiftedX >= 0 ? x1 : x2; // ellipseP1Y is shiftedY + const ellipseP2Y = shiftedY >= 0 ? y1 : y2; // ellipseP1X is shiftedX + + // found diffs between two points on edges and target point + const diff1X = ellipseP1X - shiftedX; + const diff2Y = ellipseP2Y - shiftedY; + + // return minimum, get absolute value because we need distance, not diff + return Math.min(Math.abs(diff1X), Math.abs(diff2Y)); + } +} + +class PolyShape extends Shape { + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.rotation = 0; // is not supported + } +} + +export class PolygonShape extends PolyShape { + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.POLYGON; + checkNumberOfPoints(this.shapeType, this.points); + } + + static distance(points: number[], x: number, y: number): number { + function position(x1, y1, x2, y2): number { + return (x2 - x1) * (y - y1) - (x - x1) * (y2 - y1); + } + + let wn = 0; + const distances = []; + + for (let i = 0, j = points.length - 2; i < points.length - 1; j = i, i += 2) { + // Current point + const x1 = points[j]; + const y1 = points[j + 1]; + + // Next point + const x2 = points[i]; + const y2 = points[i + 1]; + + // Check if a point is inside a polygon + // with a winding numbers algorithm + // https://en.wikipedia.org/wiki/Point_in_polygon#Winding_number_algorithm + if (y1 <= y) { + if (y2 > y) { + if (position(x1, y1, x2, y2) > 0) { + wn++; + } + } + } else if (y2 <= y) { + if (position(x1, y1, x2, y2) < 0) { + wn--; + } } - const undoSource = this.source; - const redoSource = Source.MANUAL; - const undoShape = wasKeyframe ? this.shapes[frame] : undefined; - const redoShape = keyframe ? - { - frame, - rotation: current.rotation, - zOrder: current.zOrder, - points: current.points, - outside: current.outside, - occluded: current.occluded, - attributes: {}, - source: current.source, - } : - undefined; + // Find the shortest distance from point to an edge + // Get an equation of a line in general + const aCoef = y1 - y2; + const bCoef = x2 - x1; - this.source = Source.MANUAL; - if (redoShape) { - this.shapes[frame] = redoShape; + // Vector (aCoef, bCoef) is a perpendicular to line + // Now find the point where two lines + // (edge and its perpendicular through the point (x,y)) are cross + const xCross = x - aCoef; + const yCross = y - bCoef; + + if ((xCross - x1) * (x2 - xCross) >= 0 && (yCross - y1) * (y2 - yCross) >= 0) { + // Cross point is on segment between p1(x1,y1) and p2(x2,y2) + distances.push(Math.sqrt((x - xCross) ** 2 + (y - yCross) ** 2)); } else { - delete this.shapes[frame]; + distances.push( + Math.min( + Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2), + Math.sqrt((x2 - x) ** 2 + (y2 - y) ** 2), + ), + ); } + } - this._appendShapeActionToHistory( - HistoryActions.CHANGED_KEYFRAME, - frame, - undoShape, - redoShape, - undoSource, - redoSource, - ); + if (wn !== 0) { + return Math.min.apply(null, distances); } - save(frame, data) { - if (this.lock && data.lock) { - return objectStateFactory.call(this, frame, this.get(frame)); - } + return null; + } +} - const updated = data.updateFlags; - const fittedPoints = this._validateStateBeforeSave(frame, data, updated); - const { rotation } = data; +export class PolylineShape extends PolyShape { + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.POLYLINE; + checkNumberOfPoints(this.shapeType, this.points); + } - if (updated.label) { - this._saveLabel(data.label, frame); + static distance(points: number[], x: number, y: number): number { + const distances = []; + for (let i = 0; i < points.length - 2; i += 2) { + // Current point + const x1 = points[i]; + const y1 = points[i + 1]; + + // Next point + const x2 = points[i + 2]; + const y2 = points[i + 3]; + + // Find the shortest distance from point to an edge + if ((x - x1) * (x2 - x) >= 0 && (y - y1) * (y2 - y) >= 0) { + // Find the length of a perpendicular + // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line + distances.push( + Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / + Math.sqrt((y2 - y1) ** 2 + (x2 - x1) ** 2), + ); + } else { + // The link below works for lines (which have infinite length) + // There is a case when perpendicular doesn't cross the edge + // In this case we don't use the computed distance + // Instead we use just distance to the nearest point + distances.push( + Math.min( + Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2), + Math.sqrt((x2 - x) ** 2 + (y2 - y) ** 2), + ), + ); } + } - if (updated.lock) { - this._saveLock(data.lock, frame); - } + return Math.min.apply(null, distances); + } +} - if (updated.pinned) { - this._savePinned(data.pinned, frame); - } +export class PointsShape extends PolyShape { + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.POINTS; + checkNumberOfPoints(this.shapeType, this.points); + } - if (updated.color) { - this._saveColor(data.color, frame); - } + static distance(points: number[], x: number, y: number): number { + const distances = []; + for (let i = 0; i < points.length; i += 2) { + const x1 = points[i]; + const y1 = points[i + 1]; - if (updated.hidden) { - this._saveHidden(data.hidden, frame); - } + distances.push(Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2)); + } - if (updated.points && fittedPoints.length) { - this._savePoints(fittedPoints, rotation, frame); - } + return Math.min.apply(null, distances); + } +} + +export class CuboidShape extends Shape { + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.rotation = 0; + this.shapeType = ShapeType.CUBOID; + this.pinned = false; + checkNumberOfPoints(this.shapeType, this.points); + } - if (updated.outside) { - this._saveOutside(frame, data.outside); - } + static makeHull(geoPoints) { + // Returns the convex hull, assuming that each points[i] <= points[i + 1]. + function makeHullPresorted(points) { + if (points.length <= 1) return points.slice(); - if (updated.occluded) { - this._saveOccluded(data.occluded, frame); - } + // Andrew's monotone chain algorithm. Positive y coordinates correspond to 'up' + // as per the mathematical convention, instead of 'down' as per the computer + // graphics convention. This doesn't affect the correctness of the result. - if (updated.zOrder) { - this._saveZOrder(data.zOrder, frame); + const upperHull = []; + for (let i = 0; i < points.length; i += 1) { + const p = points[`${i}`]; + while (upperHull.length >= 2) { + const q = upperHull[upperHull.length - 1]; + const r = upperHull[upperHull.length - 2]; + if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) upperHull.pop(); + else break; + } + upperHull.push(p); + } + upperHull.pop(); + + const lowerHull = []; + for (let i = points.length - 1; i >= 0; i -= 1) { + const p = points[`${i}`]; + while (lowerHull.length >= 2) { + const q = lowerHull[lowerHull.length - 1]; + const r = lowerHull[lowerHull.length - 2]; + if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) lowerHull.pop(); + else break; + } + lowerHull.push(p); } + lowerHull.pop(); - if (updated.attributes) { - this._saveAttributes(data.attributes, frame); - } + if ( + upperHull.length === 1 && + lowerHull.length === 1 && + upperHull[0].x === lowerHull[0].x && + upperHull[0].y === lowerHull[0].y + ) return upperHull; + return upperHull.concat(lowerHull); + } - if (updated.descriptions) { - this._saveDescriptions(data.descriptions); - } + function POINT_COMPARATOR(a, b) { + if (a.x < b.x) return -1; + if (a.x > b.x) return +1; + if (a.y < b.y) return -1; + if (a.y > b.y) return +1; + return 0; + } + + const newPoints = geoPoints.slice(); + newPoints.sort(POINT_COMPARATOR); + return makeHullPresorted(newPoints); + } + + static contain(shapePoints, x, y) { + function isLeft(P0, P1, P2) { + return (P1.x - P0.x) * (P2.y - P0.y) - (P2.x - P0.x) * (P1.y - P0.y); + } + const points = CuboidShape.makeHull(shapePoints); + let wn = 0; + for (let i = 0; i < points.length; i += 1) { + const p1 = points[`${i}`]; + const p2 = points[i + 1] || points[0]; - if (updated.keyframe) { - this._saveKeyframe(frame, data.keyframe); + if (p1.y <= y) { + if (p2.y > y) { + if (isLeft(p1, p2, { x, y }) > 0) { + wn += 1; + } + } + } else if (p2.y < y) { + if (isLeft(p1, p2, { x, y }) < 0) { + wn -= 1; + } } + } + + return wn !== 0; + } - this.updateTimestamp(updated); - updated.reset(); + static distance(actualPoints: number[], x: number, y: number): number { + const points = []; - return objectStateFactory.call(this, frame, this.get(frame)); + for (let i = 0; i < 16; i += 2) { + points.push({ x: actualPoints[i], y: actualPoints[i + 1] }); } - getPosition(targetFrame, leftKeyframe, rightFrame) { - const leftFrame = targetFrame in this.shapes ? targetFrame : leftKeyframe; - const rightPosition = Number.isInteger(rightFrame) ? this.shapes[rightFrame] : null; - const leftPosition = Number.isInteger(leftFrame) ? this.shapes[leftFrame] : null; + if (!CuboidShape.contain(points, x, y)) return null; - if (leftPosition && rightPosition) { - return { - ...this.interpolatePosition( - leftPosition, - rightPosition, - (targetFrame - leftFrame) / (rightFrame - leftFrame), - ), - keyframe: targetFrame in this.shapes, - }; - } + let minDistance = Number.MAX_SAFE_INTEGER; + for (let i = 0; i < points.length; i += 1) { + const p1 = points[`${i}`]; + const p2 = points[i + 1] || points[0]; - if (leftPosition) { - return { - points: [...leftPosition.points], - rotation: leftPosition.rotation, - occluded: leftPosition.occluded, - outside: leftPosition.outside, - zOrder: leftPosition.zOrder, - keyframe: targetFrame in this.shapes, - }; - } + // perpendicular from point to straight length + const distance = Math.abs((p2.y - p1.y) * x - (p2.x - p1.x) * y + p2.x * p1.y - p2.y * p1.x) / + Math.sqrt((p2.y - p1.y) ** 2 + (p2.x - p1.x) ** 2); - if (rightPosition) { - return { - points: [...rightPosition.points], - rotation: rightPosition.rotation, - occluded: rightPosition.occluded, - zOrder: rightPosition.zOrder, - keyframe: targetFrame in this.shapes, - outside: true, - }; + // check if perpendicular belongs to the straight segment + const a = (p1.x - x) ** 2 + (p1.y - y) ** 2; + const b = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + const c = (p2.x - x) ** 2 + (p2.y - y) ** 2; + if (distance < minDistance && a + b - c >= 0 && c + b - a >= 0) { + minDistance = distance; } - - throw new DataError( - 'No one left position or right position was found. ' + - `Interpolation impossible. Client ID: ${this.clientID}`, - ); } + return minDistance; + } +} + +export class SkeletonShape extends Shape { + private elements: Shape[]; + + constructor(data: RawShapeData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.SKELETON; + this.pinned = false; + this.rotation = 0; + this.occluded = false; + this.points = undefined; + this.readOnlyFields = ['points', 'label', 'occluded']; + + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + this.elements = data.elements.map((element) => shapeFactory({ + ...element, + group: this.group, + z_order: this.zOrder, + source: this.source, + rotation: 0, + frame: data.frame, + elements: [], + }, injection.nextClientID(), { + ...injection, + parentID: this.clientID, + readOnlyFields: ['group', 'zOrder', 'source', 'rotation'], + })) as any as Shape[]; } - class Tag extends Annotation { - // Method is used to export data to the server - toJSON() { - return { - clientID: this.clientID, - id: this.serverID, - frame: this.frame, - label_id: this.label.id, - group: this.group, - source: this.source, - attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { - attributeAccumulator.push({ - spec_id: attrId, - value: this.attributes[attrId], - }); + static distance(points: number[], x: number, y: number): number { + const distances = []; + let xtl = Number.MAX_SAFE_INTEGER; + let ytl = Number.MAX_SAFE_INTEGER; + let xbr = 0; + let ybr = 0; - return attributeAccumulator; - }, []), - }; + const MARGIN = 20; + for (let i = 0; i < points.length; i += 2) { + const x1 = points[i]; + const y1 = points[i + 1]; + xtl = Math.min(x1, xtl); + ytl = Math.min(y1, ytl); + xbr = Math.max(x1, xbr); + ybr = Math.max(y1, ybr); + + distances.push(Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2)); } - // Method is used to construct ObjectState objects - get(frame) { - if (frame !== this.frame) { - throw new ScriptingError('Got frame is not equal to the frame of the shape'); - } + xtl -= MARGIN; + xbr += MARGIN; + ytl -= MARGIN; + ybr += MARGIN; - return { - objectType: ObjectType.TAG, - clientID: this.clientID, - serverID: this.serverID, - lock: this.lock, - attributes: { ...this.attributes }, - label: this.label, - group: this.groupObject, - color: this.color, - updated: this.updated, - frame, - source: this.source, - }; + if (!(x >= xtl && x <= xbr && y >= ytl && y <= ybr)) { + // Cursor is outside of a box + return null; } - save(frame, data) { - if (frame !== this.frame) { - throw new ScriptingError('Got frame is not equal to the frame of the tag'); - } + // The shortest distance from point to an edge + return Math.min.apply(null, [x - xtl, y - ytl, xbr - x, ybr - y]); + } - if (this.lock && data.lock) { - return objectStateFactory.call(this, frame, this.get(frame)); - } + // Method is used to export data to the server + toJSON(): RawShapeData { + const elements = this.elements.map((element) => ({ + ...element.toJSON(), + outside: element.outside, + points: [...element.points], + source: this.source, + group: this.group, + z_order: this.zOrder, + rotation: 0, + })); + + const result: RawShapeData = { + type: this.shapeType, + clientID: this.clientID, + occluded: elements.every((el) => el.occluded), + outside: elements.every((el) => el.outside), + z_order: this.zOrder, + points: this.points, + rotation: 0, + attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { + attributeAccumulator.push({ + spec_id: +attrId, + value: this.attributes[attrId], + }); - const updated = data.updateFlags; - this._validateStateBeforeSave(frame, data, updated); + return attributeAccumulator; + }, []), + elements, + frame: this.frame, + label_id: this.label.id, + group: this.group, + source: this.source, + }; - // Now when all fields are validated, we can apply them - if (updated.label) { - this._saveLabel(data.label, frame); - } + if (this.serverID !== null) { + result.id = this.serverID; + } - if (updated.attributes) { - this._saveAttributes(data.attributes, frame); - } + return result; + } - if (updated.lock) { - this._saveLock(data.lock, frame); - } + get(frame) { + if (frame !== this.frame) { + throw new ScriptingError('Received frame is not equal to the frame of the shape'); + } + + const elements = this.elements.map((element) => ({ + ...element.get(frame), + source: this.source, + group: this.groupObject, + zOrder: this.zOrder, + rotation: 0, + })); + + return { + objectType: ObjectType.SHAPE, + shapeType: this.shapeType, + clientID: this.clientID, + serverID: this.serverID, + points: this.points, + zOrder: this.zOrder, + rotation: 0, + attributes: { ...this.attributes }, + descriptions: [...this.descriptions], + elements, + label: this.label, + group: this.groupObject, + color: this.color, + updated: Math.max(this.updated, ...this.elements.map((element) => element.updated)), + pinned: this.pinned, + outside: elements.every((el) => el.outside), + occluded: elements.every((el) => el.occluded), + lock: elements.every((el) => el.lock), + hidden: elements.every((el) => el.hidden), + frame, + source: this.source, + ...this._withContext(frame), + }; + } - if (updated.color) { - this._saveColor(data.color, frame); - } + _saveRotation(rotation, frame) { + const undoSkeletonPoints = this.elements.map((element) => element.points); + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; - this.updateTimestamp(updated); - updated.reset(); + const bbox = computeWrappingBox(undoSkeletonPoints.flat()); + const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; + for (const element of this.elements) { + const { points } = element; + const rotatedPoints = []; + for (let i = 0; i < points.length; i += 2) { + const [x, y] = [points[i], points[i + 1]]; + rotatedPoints.push(...rotatePoint(x, y, rotation, cx, cy)); + } - return objectStateFactory.call(this, frame, this.get(frame)); + element.points = rotatedPoints; } + this.source = redoSource; + + const redoSkeletonPoints = this.elements.map((element) => element.points); + this.history.do( + HistoryActions.CHANGED_ROTATION, + () => { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].points = undoSkeletonPoints[i]; + this.elements[i].updated = Date.now(); + } + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].points = redoSkeletonPoints[i]; + this.elements[i].updated = Date.now(); + } + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID, ...this.elements.map((element) => element.clientID)], + frame, + ); } - class RectangleShape extends Shape { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.RECTANGLE; - this.pinned = false; - checkNumberOfPoints(this.shapeType, this.points); + save(frame, data) { + if (this.lock && data.lock) { + return new ObjectState(this.get(frame)); } - static distance(points, x, y, angle) { - const [xtl, ytl, xbr, ybr] = points; - const cx = xtl + (xbr - xtl) / 2; - const cy = ytl + (ybr - ytl) / 2; - const [rotX, rotY] = rotatePoint(x, y, -angle, cx, cy); + const updateElements = (affectedElements, action, property: 'points' | 'occluded' | 'hidden' | 'lock') => { + const undoSkeletonProperties = this.elements.map((element) => element[property]); + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + + try { + this.history.freeze(true); + affectedElements.forEach((element, idx) => { + const annotationContext = this.elements[idx]; + annotationContext.save(frame, element); + }); + } finally { + this.history.freeze(false); + } + + const redoSkeletonProperties = this.elements.map((element) => element[property]); + + this.history.do( + action, + () => { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i][property] = undoSkeletonProperties[i]; + this.elements[i].updated = Date.now(); + } + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i][property] = redoSkeletonProperties[i]; + this.elements[i].updated = Date.now(); + } + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID, ...affectedElements.map((element) => element.clientID)], + frame, + ); + }; + + const updatedPoints = data.elements.filter((el) => el.updateFlags.points); + const updatedOccluded = data.elements.filter((el) => el.updateFlags.occluded); + const updatedHidden = data.elements.filter((el) => el.updateFlags.hidden); + const updatedLock = data.elements.filter((el) => el.updateFlags.lock); - if (!(rotX >= xtl && rotX <= xbr && rotY >= ytl && rotY <= ybr)) { - // Cursor is outside of a box - return null; - } + updatedOccluded.forEach((el) => { el.updateFlags.oсcluded = false; }); + updatedHidden.forEach((el) => { el.updateFlags.hidden = false; }); + updatedLock.forEach((el) => { el.updateFlags.lock = false; }); - // The shortest distance from point to an edge - return Math.min.apply(null, [rotX - xtl, rotY - ytl, xbr - rotX, ybr - rotY]); + if (updatedPoints.length) { + updateElements(updatedPoints, HistoryActions.CHANGED_POINTS, 'points'); } - } - class EllipseShape extends Shape { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.ELLIPSE; - this.pinned = false; - checkNumberOfPoints(this.shapeType, this.points); + if (updatedOccluded.length) { + updatedOccluded.forEach((el) => { el.updateFlags.oсcluded = true; }); + updateElements(updatedOccluded, HistoryActions.CHANGED_OCCLUDED, 'occluded'); } - static distance(points, x, y, angle) { - const [cx, cy, rightX, topY] = points; - const [rx, ry] = [rightX - cx, cy - topY]; - const [rotX, rotY] = rotatePoint(x, y, -angle, cx, cy); - // https://math.stackexchange.com/questions/76457/check-if-a-point-is-within-an-ellipse - const pointWithinEllipse = (_x, _y) => ( - ((_x - cx) ** 2) / rx ** 2) + (((_y - cy) ** 2) / ry ** 2 - ) <= 1; - - if (!pointWithinEllipse(rotX, rotY)) { - // Cursor is outside of an ellipse - return null; - } + if (updatedHidden.length) { + updatedHidden.forEach((el) => { el.updateFlags.hidden = true; }); + updateElements(updatedHidden, HistoryActions.CHANGED_OUTSIDE, 'hidden'); + } - if (Math.abs(x - cx) < Number.EPSILON && Math.abs(y - cy) < Number.EPSILON) { - // cursor is near to the center, just return minimum of height, width - return Math.min(rx, ry); - } + if (updatedLock.length) { + updatedLock.forEach((el) => { el.updateFlags.lock = true; }); + updateElements(updatedLock, HistoryActions.CHANGED_LOCK, 'lock'); + } - // ellipse equation is x^2/rx^2 + y^2/ry^2 = 1 - // from this equation: - // x^2 = ((rx * ry)^2 - (y * rx)^2) / ry^2 - // y^2 = ((rx * ry)^2 - (x * ry)^2) / rx^2 + const result = Shape.prototype.save.call(this, frame, data); + return result; + } - // we have one point inside the ellipse, let's build two lines (horizontal and vertical) through the point - // and find their interception with ellipse - const x2Equation = (_y) => (((rx * ry) ** 2) - ((_y * rx) ** 2)) / (ry ** 2); - const y2Equation = (_x) => (((rx * ry) ** 2) - ((_x * ry) ** 2)) / (rx ** 2); + get occluded() { + return this.elements.every((element) => element.occluded); + } - // shift x,y to the ellipse coordinate system to compute equation correctly - // y axis is inverted - const [shiftedX, shiftedY] = [x - cx, cy - y]; - const [x1, x2] = [Math.sqrt(x2Equation(shiftedY)), -Math.sqrt(x2Equation(shiftedY))]; - const [y1, y2] = [Math.sqrt(y2Equation(shiftedX)), -Math.sqrt(y2Equation(shiftedX))]; + set occluded(_) { + // stub + } - // found two points on ellipse edge - const ellipseP1X = shiftedX >= 0 ? x1 : x2; // ellipseP1Y is shiftedY - const ellipseP2Y = shiftedY >= 0 ? y1 : y2; // ellipseP1X is shiftedX + get lock() { + return this.elements.every((element) => element.lock); + } - // found diffs between two points on edges and target point - const diff1X = ellipseP1X - shiftedX; - const diff2Y = ellipseP2Y - shiftedY; + set lock(_) { + // stub + } +} - // return minimum, get absolute value because we need distance, not diff - return Math.min(Math.abs(diff1X), Math.abs(diff2Y)); +export class RectangleTrack extends Track { + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.RECTANGLE; + this.pinned = false; + for (const shape of Object.values(this.shapes)) { + checkNumberOfPoints(this.shapeType, shape.points); } } - class PolyShape extends Shape { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.rotation = 0; // is not supported - } + interpolatePosition(leftPosition, rightPosition, offset) { + const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point); + return { + points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset), + rotation: + (leftPosition.rotation + findAngleDiff( + rightPosition.rotation, leftPosition.rotation, + ) * offset + 360) % 360, + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, + }; } +} - class PolygonShape extends PolyShape { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.POLYGON; - checkNumberOfPoints(this.shapeType, this.points); +export class EllipseTrack extends Track { + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.ELLIPSE; + this.pinned = false; + for (const shape of Object.values(this.shapes)) { + checkNumberOfPoints(this.shapeType, shape.points); } + } - static distance(points, x, y) { - function position(x1, y1, x2, y2) { - return (x2 - x1) * (y - y1) - (x - x1) * (y2 - y1); - } + interpolatePosition(leftPosition, rightPosition, offset) { + const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point); + + return { + points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset), + rotation: + (leftPosition.rotation + findAngleDiff( + rightPosition.rotation, leftPosition.rotation, + ) * offset + 360) % 360, + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, + }; + } +} - let wn = 0; - const distances = []; - - for (let i = 0, j = points.length - 2; i < points.length - 1; j = i, i += 2) { - // Current point - const x1 = points[j]; - const y1 = points[j + 1]; - - // Next point - const x2 = points[i]; - const y2 = points[i + 1]; - - // Check if a point is inside a polygon - // with a winding numbers algorithm - // https://en.wikipedia.org/wiki/Point_in_polygon#Winding_number_algorithm - if (y1 <= y) { - if (y2 > y) { - if (position(x1, y1, x2, y2) > 0) { - wn++; - } - } - } else if (y2 <= y) { - if (position(x1, y1, x2, y2) < 0) { - wn--; - } - } +class PolyTrack extends Track { + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + for (const shape of Object.values(this.shapes)) { + shape.rotation = 0; // is not supported + } + } - // Find the shortest distance from point to an edge - // Get an equation of a line in general - const aCoef = y1 - y2; - const bCoef = x2 - x1; + interpolatePosition(leftPosition, rightPosition, offset) { + if (offset === 0) { + return { + points: [...leftPosition.points], + rotation: leftPosition.rotation, + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, + }; + } - // Vector (aCoef, bCoef) is a perpendicular to line - // Now find the point where two lines - // (edge and its perpendicular through the point (x,y)) are cross - const xCross = x - aCoef; - const yCross = y - bCoef; + function toArray(points) { + return points.reduce((acc, val) => { + acc.push(val.x, val.y); + return acc; + }, []); + } - if ((xCross - x1) * (x2 - xCross) >= 0 && (yCross - y1) * (y2 - yCross) >= 0) { - // Cross point is on segment between p1(x1,y1) and p2(x2,y2) - distances.push(Math.sqrt((x - xCross) ** 2 + (y - yCross) ** 2)); - } else { - distances.push( - Math.min( - Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2), - Math.sqrt((x2 - x) ** 2 + (y2 - y) ** 2), - ), - ); + function toPoints(array) { + return array.reduce((acc, _, index) => { + if (index % 2) { + acc.push({ + x: array[index - 1], + y: array[index], + }); } - } - - if (wn !== 0) { - return Math.min.apply(null, distances); - } - return null; + return acc; + }, []); } - } - class PolylineShape extends PolyShape { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.POLYLINE; - checkNumberOfPoints(this.shapeType, this.points); + function curveLength(points) { + return points.slice(1).reduce((acc, _, index) => { + const dx = points[index + 1].x - points[index].x; + const dy = points[index + 1].y - points[index].y; + return acc + Math.sqrt(dx ** 2 + dy ** 2); + }, 0); } - static distance(points, x, y) { - const distances = []; - for (let i = 0; i < points.length - 2; i += 2) { - // Current point - const x1 = points[i]; - const y1 = points[i + 1]; + function curveToOffsetVec(points, length) { + const offsetVector = [0]; // with initial value + let accumulatedLength = 0; - // Next point - const x2 = points[i + 2]; - const y2 = points[i + 3]; + points.slice(1).forEach((_, index) => { + const dx = points[index + 1].x - points[index].x; + const dy = points[index + 1].y - points[index].y; + accumulatedLength += Math.sqrt(dx ** 2 + dy ** 2); + offsetVector.push(accumulatedLength / length); + }); - // Find the shortest distance from point to an edge - if ((x - x1) * (x2 - x) >= 0 && (y - y1) * (y2 - y) >= 0) { - // Find the length of a perpendicular - // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line - distances.push( - Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / - Math.sqrt((y2 - y1) ** 2 + (x2 - x1) ** 2), - ); - } else { - // The link below works for lines (which have infinite length) - // There is a case when perpendicular doesn't cross the edge - // In this case we don't use the computed distance - // Instead we use just distance to the nearest point - distances.push( - Math.min( - Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2), - Math.sqrt((x2 - x) ** 2 + (y2 - y) ** 2), - ), - ); + return offsetVector; + } + + function findNearestPair(value, curve) { + let minimum = [0, Math.abs(value - curve[0])]; + for (let i = 1; i < curve.length; i++) { + const distance = Math.abs(value - curve[i]); + if (distance < minimum[1]) { + minimum = [i, distance]; } } - return Math.min.apply(null, distances); + return minimum[0]; } - } - class PointsShape extends PolyShape { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.POINTS; - checkNumberOfPoints(this.shapeType, this.points); + function matchLeftRight(leftCurve, rightCurve) { + const matching = {}; + for (let i = 0; i < leftCurve.length; i++) { + matching[i] = [findNearestPair(leftCurve[i], rightCurve)]; + } + + return matching; } - static distance(points, x, y) { - const distances = []; - for (let i = 0; i < points.length; i += 2) { - const x1 = points[i]; - const y1 = points[i + 1]; + function matchRightLeft(leftCurve, rightCurve, leftRightMatching) { + const matchedRightPoints = Object.values(leftRightMatching).flat(); + const unmatchedRightPoints = rightCurve + .map((_, index) => index) + .filter((index) => !matchedRightPoints.includes(index)); + const updatedMatching = { ...leftRightMatching }; - distances.push(Math.sqrt((x1 - x) ** 2 + (y1 - y) ** 2)); + for (const rightPoint of unmatchedRightPoints) { + const leftPoint = findNearestPair(rightCurve[rightPoint], leftCurve); + updatedMatching[leftPoint].push(rightPoint); } - return Math.min.apply(null, distances); - } - } + for (const key of Object.keys(updatedMatching)) { + const sortedRightIndexes = updatedMatching[key].sort((a, b) => a - b); + updatedMatching[key] = sortedRightIndexes; + } - class CuboidShape extends Shape { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.rotation = 0; - this.shapeType = ObjectShape.CUBOID; - this.pinned = false; - checkNumberOfPoints(this.shapeType, this.points); + return updatedMatching; } - static makeHull(geoPoints) { - // Returns the convex hull, assuming that each points[i] <= points[i + 1]. - function makeHullPresorted(points) { - if (points.length <= 1) return points.slice(); + function reduceInterpolation(interpolatedPoints, matching, leftPoints, rightPoints) { + function averagePoint(points) { + let sumX = 0; + let sumY = 0; + for (const point of points) { + sumX += point.x; + sumY += point.y; + } + + return { + x: sumX / points.length, + y: sumY / points.length, + }; + } + + function computeDistance(point1, point2) { + return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2); + } - // Andrew's monotone chain algorithm. Positive y coordinates correspond to 'up' - // as per the mathematical convention, instead of 'down' as per the computer - // graphics convention. This doesn't affect the correctness of the result. + function minimizeSegment(baseLength, N, startInterpolated, stopInterpolated) { + const threshold = baseLength / (2 * N); + const minimized = [interpolatedPoints[startInterpolated]]; + let latestPushed = startInterpolated; + for (let i = startInterpolated + 1; i < stopInterpolated; i++) { + const distance = computeDistance(interpolatedPoints[latestPushed], interpolatedPoints[i]); - const upperHull = []; - for (let i = 0; i < points.length; i += 1) { - const p = points[`${i}`]; - while (upperHull.length >= 2) { - const q = upperHull[upperHull.length - 1]; - const r = upperHull[upperHull.length - 2]; - if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) upperHull.pop(); - else break; + if (distance >= threshold) { + minimized.push(interpolatedPoints[i]); + latestPushed = i; } - upperHull.push(p); } - upperHull.pop(); - - const lowerHull = []; - for (let i = points.length - 1; i >= 0; i -= 1) { - const p = points[`${i}`]; - while (lowerHull.length >= 2) { - const q = lowerHull[lowerHull.length - 1]; - const r = lowerHull[lowerHull.length - 2]; - if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) lowerHull.pop(); - else break; + + minimized.push(interpolatedPoints[stopInterpolated]); + + if (minimized.length === 2) { + const distance = computeDistance( + interpolatedPoints[startInterpolated], + interpolatedPoints[stopInterpolated], + ); + + if (distance < threshold) { + return [averagePoint(minimized)]; } - lowerHull.push(p); } - lowerHull.pop(); - if ( - upperHull.length === 1 && - lowerHull.length === 1 && - upperHull[0].x === lowerHull[0].x && - upperHull[0].y === lowerHull[0].y - ) return upperHull; - return upperHull.concat(lowerHull); + return minimized; } - function POINT_COMPARATOR(a, b) { - if (a.x < b.x) return -1; - if (a.x > b.x) return +1; - if (a.y < b.y) return -1; - if (a.y > b.y) return +1; - return 0; + const reduced = []; + const interpolatedIndexes = {}; + let accumulated = 0; + for (let i = 0; i < leftPoints.length; i++) { + // eslint-disable-next-line + interpolatedIndexes[i] = matching[i].map(() => accumulated++); } - const newPoints = geoPoints.slice(); - newPoints.sort(POINT_COMPARATOR); - return makeHullPresorted(newPoints); - } + function leftSegment(start, stop) { + const startInterpolated = interpolatedIndexes[start][0]; + const stopInterpolated = interpolatedIndexes[stop][0]; + + if (startInterpolated === stopInterpolated) { + reduced.push(interpolatedPoints[startInterpolated]); + return; + } - static contain(shapePoints, x, y) { - function isLeft(P0, P1, P2) { - return (P1.x - P0.x) * (P2.y - P0.y) - (P2.x - P0.x) * (P1.y - P0.y); + const baseLength = curveLength(leftPoints.slice(start, stop + 1)); + const N = stop - start + 1; + + reduced.push(...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated)); } - const points = CuboidShape.makeHull(shapePoints); - let wn = 0; - for (let i = 0; i < points.length; i += 1) { - const p1 = points[`${i}`]; - const p2 = points[i + 1] || points[0]; - if (p1.y <= y) { - if (p2.y > y) { - if (isLeft(p1, p2, { x, y }) > 0) { - wn += 1; + function rightSegment(leftPoint) { + const start = matching[leftPoint][0]; + const [stop] = matching[leftPoint].slice(-1); + const startInterpolated = interpolatedIndexes[leftPoint][0]; + const [stopInterpolated] = interpolatedIndexes[leftPoint].slice(-1); + const baseLength = curveLength(rightPoints.slice(start, stop + 1)); + const N = stop - start + 1; + + reduced.push(...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated)); + } + + let previousOpened = null; + for (let i = 0; i < leftPoints.length; i++) { + if (matching[i].length === 1) { + // check if left segment is opened + if (previousOpened !== null) { + // check if we should continue the left segment + if (matching[i][0] === matching[previousOpened][0]) { + continue; + } else { + // left segment found + const start = previousOpened; + const stop = i - 1; + leftSegment(start, stop); + + // start next left segment + previousOpened = i; } + } else { + // start next left segment + previousOpened = i; } - } else if (p2.y < y) { - if (isLeft(p1, p2, { x, y }) < 0) { - wn -= 1; + } else { + // check if left segment is opened + if (previousOpened !== null) { + // left segment found + const start = previousOpened; + const stop = i - 1; + leftSegment(start, stop); + + previousOpened = null; } + + // right segment found + rightSegment(i); } } - return wn !== 0; - } + // check if there is an opened segment + if (previousOpened !== null) { + leftSegment(previousOpened, leftPoints.length - 1); + } - static distance(actualPoints, x, y) { - const points = []; + return reduced; + } - for (let i = 0; i < 16; i += 2) { - points.push({ x: actualPoints[i], y: actualPoints[i + 1] }); - } + // the algorithm below is based on fact that both left and right + // polyshapes have the same start point and the same draw direction + const leftPoints = toPoints(leftPosition.points); + const rightPoints = toPoints(rightPosition.points); + const leftOffsetVec = curveToOffsetVec(leftPoints, curveLength(leftPoints)); + const rightOffsetVec = curveToOffsetVec(rightPoints, curveLength(rightPoints)); - if (!CuboidShape.contain(points, x, y)) return null; + const matching = matchLeftRight(leftOffsetVec, rightOffsetVec); + const completedMatching = matchRightLeft(leftOffsetVec, rightOffsetVec, matching); - let minDistance = Number.MAX_SAFE_INTEGER; - for (let i = 0; i < points.length; i += 1) { - const p1 = points[`${i}`]; - const p2 = points[i + 1] || points[0]; - - // perpendicular from point to straight length - const distance = Math.abs((p2.y - p1.y) * x - (p2.x - p1.x) * y + p2.x * p1.y - p2.y * p1.x) / - Math.sqrt((p2.y - p1.y) ** 2 + (p2.x - p1.x) ** 2); - - // check if perpendicular belongs to the straight segment - const a = (p1.x - x) ** 2 + (p1.y - y) ** 2; - const b = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; - const c = (p2.x - x) ** 2 + (p2.y - y) ** 2; - if (distance < minDistance && a + b - c >= 0 && c + b - a >= 0) { - minDistance = distance; + const interpolatedPoints = Object.keys(completedMatching) + .map((leftPointIdx) => +leftPointIdx) + .sort((a, b) => a - b) + .reduce((acc, leftPointIdx) => { + const leftPoint = leftPoints[leftPointIdx]; + for (const rightPointIdx of completedMatching[leftPointIdx]) { + const rightPoint = rightPoints[rightPointIdx]; + acc.push({ + x: leftPoint.x + (rightPoint.x - leftPoint.x) * offset, + y: leftPoint.y + (rightPoint.y - leftPoint.y) * offset, + }); } - } - return minDistance; - } + + return acc; + }, []); + + const reducedPoints = reduceInterpolation(interpolatedPoints, completedMatching, leftPoints, rightPoints); + + return { + points: toArray(reducedPoints), + rotation: leftPosition.rotation, + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, + }; } +} - class RectangleTrack extends Track { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.RECTANGLE; - this.pinned = false; - for (const shape of Object.values(this.shapes)) { - checkNumberOfPoints(this.shapeType, shape.points); - } +export class PolygonTrack extends PolyTrack { + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.POLYGON; + for (const shape of Object.values(this.shapes)) { + checkNumberOfPoints(this.shapeType, shape.points); } + } - interpolatePosition(leftPosition, rightPosition, offset) { - const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point); - return { - points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset), - rotation: - (leftPosition.rotation + findAngleDiff( - rightPosition.rotation, leftPosition.rotation, - ) * offset + 360) % 360, - occluded: leftPosition.occluded, - outside: leftPosition.outside, - zOrder: leftPosition.zOrder, - }; - } + interpolatePosition(leftPosition, rightPosition, offset) { + const copyLeft = { + ...leftPosition, + points: [...leftPosition.points, leftPosition.points[0], leftPosition.points[1]], + }; + + const copyRight = { + ...rightPosition, + points: [...rightPosition.points, rightPosition.points[0], rightPosition.points[1]], + }; + + const result = PolyTrack.prototype.interpolatePosition.call(this, copyLeft, copyRight, offset); + + return { + ...result, + points: result.points.slice(0, -2), + }; } +} - class EllipseTrack extends Track { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.ELLIPSE; - this.pinned = false; - for (const shape of Object.values(this.shapes)) { - checkNumberOfPoints(this.shapeType, shape.points); - } +export class PolylineTrack extends PolyTrack { + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.POLYLINE; + for (const shape of Object.values(this.shapes)) { + checkNumberOfPoints(this.shapeType, shape.points); } + } +} - interpolatePosition(leftPosition, rightPosition, offset) { - const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point); +export class PointsTrack extends PolyTrack { + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.POINTS; + for (const shape of Object.values(this.shapes)) { + checkNumberOfPoints(this.shapeType, shape.points); + } + } + interpolatePosition(leftPosition, rightPosition, offset) { + // interpolate only when one point in both left and right positions + if (leftPosition.points.length === 2 && rightPosition.points.length === 2) { return { - points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset), - rotation: - (leftPosition.rotation + findAngleDiff( - rightPosition.rotation, leftPosition.rotation, - ) * offset + 360) % 360, + points: leftPosition.points.map( + (value, index) => value + (rightPosition.points[index] - value) * offset, + ), + rotation: leftPosition.rotation, occluded: leftPosition.occluded, outside: leftPosition.outside, zOrder: leftPosition.zOrder, }; } + + return { + points: [...leftPosition.points], + rotation: leftPosition.rotation, + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, + }; } +} - class PolyTrack extends Track { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - for (const shape of Object.values(this.shapes)) { - shape.rotation = 0; // is not supported - } +export class CuboidTrack extends Track { + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.CUBOID; + this.pinned = false; + for (const shape of Object.values(this.shapes)) { + checkNumberOfPoints(this.shapeType, shape.points); + shape.rotation = 0; // is not supported } + } - interpolatePosition(leftPosition, rightPosition, offset) { - if (offset === 0) { - return { - points: [...leftPosition.points], - rotation: leftPosition.rotation, - occluded: leftPosition.occluded, - outside: leftPosition.outside, - zOrder: leftPosition.zOrder, - }; - } - - function toArray(points) { - return points.reduce((acc, val) => { - acc.push(val.x, val.y); - return acc; - }, []); - } + interpolatePosition(leftPosition, rightPosition, offset) { + const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point); - function toPoints(array) { - return array.reduce((acc, _, index) => { - if (index % 2) { - acc.push({ - x: array[index - 1], - y: array[index], - }); - } + return { + points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset), + rotation: leftPosition.rotation, + occluded: leftPosition.occluded, + outside: leftPosition.outside, + zOrder: leftPosition.zOrder, + }; + } +} + +export class SkeletonTrack extends Track { + private elements: Track[]; + + constructor(data: RawTrackData, clientID: number, color: string, injection: AnnotationInjection) { + super(data, clientID, color, injection); + this.shapeType = ShapeType.SKELETON; + this.readOnlyFields = ['points', 'label', 'occluded', 'outside']; + this.pinned = false; + this.elements = data.elements.map((element: RawTrackData['elements'][0]) => ( + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + trackFactory({ + ...element, + group: this.group, + source: this.source, + }, injection.nextClientID(), { + ...injection, + parentID: this.clientID, + readOnlyFields: ['group', 'zOrder', 'source', 'rotation'], + }) + + // todo z_order: this.zOrder, + )).sort((a: Annotation, b: Annotation) => a.label.id - b.label.id) as any as Track[]; + } - return acc; - }, []); - } + _saveRotation(rotation: number, frame: number): void { + const undoSkeletonShapes = this.elements.map((element) => element.shapes[frame]); + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; - function curveLength(points) { - return points.slice(1).reduce((acc, _, index) => { - const dx = points[index + 1].x - points[index].x; - const dy = points[index + 1].y - points[index].y; - return acc + Math.sqrt(dx ** 2 + dy ** 2); - }, 0); - } + const elementsData = this.elements.map((element) => element.get(frame)); + const skeletonPoints = elementsData.map((data) => data.points); + const bbox = computeWrappingBox(skeletonPoints.flat()); + const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; - function curveToOffsetVec(points, length) { - const offsetVector = [0]; // with initial value - let accumulatedLength = 0; + for (let i = 0; i < this.elements.length; i++) { + const element = this.elements[i]; + const { points } = elementsData[i]; - points.slice(1).forEach((_, index) => { - const dx = points[index + 1].x - points[index].x; - const dy = points[index + 1].y - points[index].y; - accumulatedLength += Math.sqrt(dx ** 2 + dy ** 2); - offsetVector.push(accumulatedLength / length); - }); + const rotatedPoints = []; + for (let j = 0; j < points.length; j += 2) { + const [x, y] = [points[j], points[j + 1]]; + rotatedPoints.push(...rotatePoint(x, y, rotation, cx, cy)); + } - return offsetVector; + if (undoSkeletonShapes[i]) { + element.shapes[frame] = { + ...undoSkeletonShapes[i], + points: rotatedPoints, + }; + } else { + element.shapes[frame] = { + ...copyShape(elementsData[i]), + points: rotatedPoints, + }; } + } + this.source = redoSource; - function findNearestPair(value, curve) { - let minimum = [0, Math.abs(value - curve[0])]; - for (let i = 1; i < curve.length; i++) { - const distance = Math.abs(value - curve[i]); - if (distance < minimum[1]) { - minimum = [i, distance]; + const redoSkeletonShapes = this.elements.map((element) => element.shapes[frame]); + this.history.do( + HistoryActions.CHANGED_ROTATION, + () => { + for (let i = 0; i < this.elements.length; i++) { + const element = this.elements[i]; + if (undoSkeletonShapes[i]) { + element.shapes[frame] = undoSkeletonShapes[i]; + } else { + delete element.shapes[frame]; } + this.updated = Date.now(); } - - return minimum[0]; - } - - function matchLeftRight(leftCurve, rightCurve) { - const matching = {}; - for (let i = 0; i < leftCurve.length; i++) { - matching[i] = [findNearestPair(leftCurve[i], rightCurve)]; + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + for (let i = 0; i < this.elements.length; i++) { + const element = this.elements[i]; + element.shapes[frame] = redoSkeletonShapes[i]; + this.updated = Date.now(); } + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID, ...this.elements.map((element) => element.clientID)], + frame, + ); + } - return matching; - } + // Method is used to export data to the server + toJSON(): RawTrackData { + const result: RawTrackData = Track.prototype.toJSON.call(this); + result.elements = this.elements.map((el) => el.toJSON()); + return result; + } - function matchRightLeft(leftCurve, rightCurve, leftRightMatching) { - const matchedRightPoints = Object.values(leftRightMatching).flat(); - const unmatchedRightPoints = rightCurve - .map((_, index) => index) - .filter((index) => !matchedRightPoints.includes(index)); - const updatedMatching = { ...leftRightMatching }; + get(frame: number) { + const { prev, next } = this.boundedKeyframes(frame); + const position = this.getPosition(frame, prev, next); + const elements = this.elements.map((element) => ({ + ...element.get(frame), + source: this.source, + group: this.groupObject, + zOrder: position.zOrder, + rotation: 0, + })); + + return { + ...position, + keyframe: position.keyframe || elements.some((el) => el.keyframe), + attributes: this.getAttributes(frame), + descriptions: [...this.descriptions], + group: this.groupObject, + objectType: ObjectType.TRACK, + shapeType: this.shapeType, + clientID: this.clientID, + serverID: this.serverID, + color: this.color, + updated: Math.max(this.updated, ...this.elements.map((element) => element.updated)), + label: this.label, + pinned: this.pinned, + keyframes: this.deepBoundedKeyframes(frame), + elements, + frame, + source: this.source, + outside: elements.every((el) => el.outside), + occluded: elements.every((el) => el.occluded), + lock: elements.every((el) => el.lock), + hidden: elements.every((el) => el.hidden), + ...this._withContext(frame), + }; + } - for (const rightPoint of unmatchedRightPoints) { - const leftPoint = findNearestPair(rightCurve[rightPoint], leftCurve); - updatedMatching[leftPoint].push(rightPoint); - } + // finds keyframes considering keyframes of nested elements + deepBoundedKeyframes(targetFrame: number): ObjectState['keyframes'] { + const boundedKeyframes = Track.prototype.boundedKeyframes.call(this, targetFrame); - for (const key of Object.keys(updatedMatching)) { - const sortedRightIndexes = updatedMatching[key].sort((a, b) => a - b); - updatedMatching[key] = sortedRightIndexes; - } + for (const element of this.elements) { + const keyframes = element.boundedKeyframes(targetFrame); + if (keyframes.prev !== null) { + boundedKeyframes.prev = boundedKeyframes.prev === null || keyframes.prev > boundedKeyframes.prev ? + keyframes.prev : boundedKeyframes.prev; + } - return updatedMatching; + if (keyframes.next !== null) { + boundedKeyframes.next = boundedKeyframes.next === null || keyframes.next < boundedKeyframes.next ? + keyframes.next : boundedKeyframes.next; } - function reduceInterpolation(interpolatedPoints, matching, leftPoints, rightPoints) { - function averagePoint(points) { - let sumX = 0; - let sumY = 0; - for (const point of points) { - sumX += point.x; - sumY += point.y; - } + if (keyframes.first !== null) { + boundedKeyframes.first = + boundedKeyframes.first === null || keyframes.first < boundedKeyframes.first ? + keyframes.first : boundedKeyframes.first; + } - return { - x: sumX / points.length, - y: sumY / points.length, - }; - } + if (keyframes.last !== null) { + boundedKeyframes.last = boundedKeyframes.last === null || keyframes.last > boundedKeyframes.last ? + keyframes.last : boundedKeyframes.last; + } + } - function computeDistance(point1, point2) { - return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2); - } + return boundedKeyframes; + } - function minimizeSegment(baseLength, N, startInterpolated, stopInterpolated) { - const threshold = baseLength / (2 * N); - const minimized = [interpolatedPoints[startInterpolated]]; - let latestPushed = startInterpolated; - for (let i = startInterpolated + 1; i < stopInterpolated; i++) { - const distance = computeDistance(interpolatedPoints[latestPushed], interpolatedPoints[i]); + save(frame: number, data: ObjectState): ObjectState { + if (this.lock && data.lock) { + return new ObjectState(this.get(frame)); + } - if (distance >= threshold) { - minimized.push(interpolatedPoints[i]); - latestPushed = i; - } + const updateElements = (affectedElements, action, property: 'hidden' | 'lock' | null = null): void => { + const undoSkeletonProperties = this.elements.map((element) => element[property] || null); + const undoSkeletonShapes = this.elements.map((element) => element.shapes[frame]); + const undoSource = this.source; + const redoSource = this.readOnlyFields.includes('source') ? this.source : Source.MANUAL; + + const errors = []; + try { + this.history.freeze(true); + affectedElements.forEach((element, idx) => { + try { + const annotationContext = this.elements[idx]; + annotationContext.save(frame, element); + } catch (error: any) { + errors.push(error); } + }); + } finally { + this.history.freeze(false); + } - minimized.push(interpolatedPoints[stopInterpolated]); - - if (minimized.length === 2) { - const distance = computeDistance( - interpolatedPoints[startInterpolated], - interpolatedPoints[stopInterpolated], - ); + const redoSkeletonProperties = this.elements.map((element) => element[property] || null); + const redoSkeletonShapes = this.elements.map((element) => element.shapes[frame]); - if (distance < threshold) { - return [averagePoint(minimized)]; + this.history.do( + action, + () => { + for (let i = 0; i < this.elements.length; i++) { + if (property) { + this.elements[i][property] = undoSkeletonProperties[i]; + } if (undoSkeletonShapes[i]) { + this.elements[i].shapes[frame] = undoSkeletonShapes[i]; + } else if (redoSkeletonShapes[i]) { + delete this.elements[i].shapes[frame]; } + this.elements[i].updated = Date.now(); } - - return minimized; - } - - const reduced = []; - const interpolatedIndexes = {}; - let accumulated = 0; - for (let i = 0; i < leftPoints.length; i++) { - // eslint-disable-next-line - interpolatedIndexes[i] = matching[i].map(() => accumulated++); - } - - function leftSegment(start, stop) { - const startInterpolated = interpolatedIndexes[start][0]; - const stopInterpolated = interpolatedIndexes[stop][0]; - - if (startInterpolated === stopInterpolated) { - reduced.push(interpolatedPoints[startInterpolated]); - return; - } - - const baseLength = curveLength(leftPoints.slice(start, stop + 1)); - const N = stop - start + 1; - - reduced.push(...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated)); - } - - function rightSegment(leftPoint) { - const start = matching[leftPoint][0]; - const [stop] = matching[leftPoint].slice(-1); - const startInterpolated = interpolatedIndexes[leftPoint][0]; - const [stopInterpolated] = interpolatedIndexes[leftPoint].slice(-1); - const baseLength = curveLength(rightPoints.slice(start, stop + 1)); - const N = stop - start + 1; - - reduced.push(...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated)); - } - - let previousOpened = null; - for (let i = 0; i < leftPoints.length; i++) { - if (matching[i].length === 1) { - // check if left segment is opened - if (previousOpened !== null) { - // check if we should continue the left segment - if (matching[i][0] === matching[previousOpened][0]) { - continue; - } else { - // left segment found - const start = previousOpened; - const stop = i - 1; - leftSegment(start, stop); - - // start next left segment - previousOpened = i; - } - } else { - // start next left segment - previousOpened = i; - } - } else { - // check if left segment is opened - if (previousOpened !== null) { - // left segment found - const start = previousOpened; - const stop = i - 1; - leftSegment(start, stop); - - previousOpened = null; + this.source = undoSource; + this.updated = Date.now(); + }, + () => { + for (let i = 0; i < this.elements.length; i++) { + if (property) { + this.elements[i][property] = redoSkeletonProperties[i]; + } else if (redoSkeletonShapes[i]) { + this.elements[i].shapes[frame] = redoSkeletonShapes[i]; + } else if (undoSkeletonShapes[i]) { + delete this.elements[i].shapes[frame]; } - - // right segment found - rightSegment(i); + this.elements[i].updated = Date.now(); } - } - - // check if there is an opened segment - if (previousOpened !== null) { - leftSegment(previousOpened, leftPoints.length - 1); - } + this.source = redoSource; + this.updated = Date.now(); + }, + [this.clientID, ...affectedElements.map((element) => element.clientID)], + frame, + ); - return reduced; + if (errors.length) { + throw new Error(`Several errors occured during saving skeleton:\n ${errors.join(';\n')}`); } + }; - // the algorithm below is based on fact that both left and right - // polyshapes have the same start point and the same draw direction - const leftPoints = toPoints(leftPosition.points); - const rightPoints = toPoints(rightPosition.points); - const leftOffsetVec = curveToOffsetVec(leftPoints, curveLength(leftPoints)); - const rightOffsetVec = curveToOffsetVec(rightPoints, curveLength(rightPoints)); - - const matching = matchLeftRight(leftOffsetVec, rightOffsetVec); - const completedMatching = matchRightLeft(leftOffsetVec, rightOffsetVec, matching); - - const interpolatedPoints = Object.keys(completedMatching) - .map((leftPointIdx) => +leftPointIdx) - .sort((a, b) => a - b) - .reduce((acc, leftPointIdx) => { - const leftPoint = leftPoints[leftPointIdx]; - for (const rightPointIdx of completedMatching[leftPointIdx]) { - const rightPoint = rightPoints[rightPointIdx]; - acc.push({ - x: leftPoint.x + (rightPoint.x - leftPoint.x) * offset, - y: leftPoint.y + (rightPoint.y - leftPoint.y) * offset, - }); - } - - return acc; - }, []); + const updatedPoints = data.elements.filter((el) => el.updateFlags.points); + const updatedOccluded = data.elements.filter((el) => el.updateFlags.occluded); + const updatedOutside = data.elements.filter((el) => el.updateFlags.outside); + const updatedKeyframe = data.elements.filter((el) => el.updateFlags.keyframe); + const updatedHidden = data.elements.filter((el) => el.updateFlags.hidden); + const updatedLock = data.elements.filter((el) => el.updateFlags.lock); - const reducedPoints = reduceInterpolation(interpolatedPoints, completedMatching, leftPoints, rightPoints); + updatedOccluded.forEach((el) => { el.updateFlags.occluded = false; }); + updatedOutside.forEach((el) => { el.updateFlags.outside = false; }); + updatedKeyframe.forEach((el) => { el.updateFlags.keyframe = false; }); + updatedHidden.forEach((el) => { el.updateFlags.hidden = false; }); + updatedLock.forEach((el) => { el.updateFlags.lock = false; }); - return { - points: toArray(reducedPoints), - rotation: leftPosition.rotation, - occluded: leftPosition.occluded, - outside: leftPosition.outside, - zOrder: leftPosition.zOrder, - }; + if (updatedPoints.length) { + updateElements(updatedPoints, HistoryActions.CHANGED_POINTS); } - } - class PolygonTrack extends PolyTrack { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.POLYGON; - for (const shape of Object.values(this.shapes)) { - checkNumberOfPoints(this.shapeType, shape.points); - } + if (updatedOccluded.length) { + updatedOccluded.forEach((el) => { el.updateFlags.occluded = true; }); + updateElements(updatedOccluded, HistoryActions.CHANGED_OCCLUDED); } - interpolatePosition(leftPosition, rightPosition, offset) { - const copyLeft = { - ...leftPosition, - points: [...leftPosition.points, leftPosition.points[0], leftPosition.points[1]], - }; - - const copyRight = { - ...rightPosition, - points: [...rightPosition.points, rightPosition.points[0], rightPosition.points[1]], - }; - - const result = PolyTrack.prototype.interpolatePosition.call(this, copyLeft, copyRight, offset); + if (updatedOutside.length) { + updatedOutside.forEach((el) => { el.updateFlags.outside = true; }); + updateElements(updatedOutside, HistoryActions.CHANGED_OUTSIDE); + } - return { - ...result, - points: result.points.slice(0, -2), - }; + if (updatedKeyframe.length) { + updatedKeyframe.forEach((el) => { el.updateFlags.keyframe = true; }); + // todo: fix extra undo/redo change + this._validateStateBeforeSave(frame, data, data.updateFlags); + this._saveKeyframe(frame, data.keyframe); + data.updateFlags.keyframe = false; + updateElements(updatedKeyframe, HistoryActions.CHANGED_KEYFRAME); } - } - class PolylineTrack extends PolyTrack { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.POLYLINE; - for (const shape of Object.values(this.shapes)) { - checkNumberOfPoints(this.shapeType, shape.points); - } + if (updatedHidden.length) { + updatedHidden.forEach((el) => { el.updateFlags.hidden = true; }); + updateElements(updatedHidden, HistoryActions.CHANGED_HIDDEN, 'hidden'); } - } - class PointsTrack extends PolyTrack { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.POINTS; - for (const shape of Object.values(this.shapes)) { - checkNumberOfPoints(this.shapeType, shape.points); - } + if (updatedLock.length) { + updatedLock.forEach((el) => { el.updateFlags.lock = true; }); + updateElements(updatedLock, HistoryActions.CHANGED_LOCK, 'lock'); } - interpolatePosition(leftPosition, rightPosition, offset) { - // interpolate only when one point in both left and right positions - if (leftPosition.points.length === 2 && rightPosition.points.length === 2) { - return { - points: leftPosition.points.map( - (value, index) => value + (rightPosition.points[index] - value) * offset, - ), - rotation: leftPosition.rotation, - occluded: leftPosition.occluded, - outside: leftPosition.outside, - zOrder: leftPosition.zOrder, - }; - } + const result = Track.prototype.save.call(this, frame, data); + return result; + } + + getPosition(targetFrame: number, leftKeyframe: number | null, rightKeyframe: number | null) { + const leftFrame = targetFrame in this.shapes ? targetFrame : leftKeyframe; + const rightPosition = Number.isInteger(rightKeyframe) ? this.shapes[rightKeyframe] : null; + const leftPosition = Number.isInteger(leftFrame) ? this.shapes[leftFrame] : null; + if (leftPosition && rightPosition) { return { - points: [...leftPosition.points], - rotation: leftPosition.rotation, + rotation: 0, occluded: leftPosition.occluded, outside: leftPosition.outside, zOrder: leftPosition.zOrder, + keyframe: targetFrame in this.shapes, }; } - } - - class CuboidTrack extends Track { - constructor(data, clientID, color, injection) { - super(data, clientID, color, injection); - this.shapeType = ObjectShape.CUBOID; - this.pinned = false; - for (const shape of Object.values(this.shapes)) { - checkNumberOfPoints(this.shapeType, shape.points); - shape.rotation = 0; // is not supported - } - } - - interpolatePosition(leftPosition, rightPosition, offset) { - const positionOffset = leftPosition.points.map((point, index) => rightPosition.points[index] - point); + const singlePosition = leftPosition || rightPosition; + if (singlePosition) { return { - points: leftPosition.points.map((point, index) => point + positionOffset[index] * offset), - rotation: leftPosition.rotation, - occluded: leftPosition.occluded, - outside: leftPosition.outside, - zOrder: leftPosition.zOrder, + points: undefined, + rotation: 0, + occluded: singlePosition.occluded, + zOrder: singlePosition.zOrder, + keyframe: targetFrame in this.shapes, + outside: singlePosition === rightPosition ? true : singlePosition.outside, }; } + + throw new DataError( + 'No one left position or right position was found. ' + + `Interpolation impossible. Client ID: ${this.clientID}`, + ); + } +} + +Object.defineProperty(RectangleTrack, 'distance', { value: RectangleShape.distance }); +Object.defineProperty(PolygonTrack, 'distance', { value: PolygonShape.distance }); +Object.defineProperty(PolylineTrack, 'distance', { value: PolylineShape.distance }); +Object.defineProperty(PointsTrack, 'distance', { value: PointsShape.distance }); +Object.defineProperty(EllipseTrack, 'distance', { value: EllipseShape.distance }); +Object.defineProperty(CuboidTrack, 'distance', { value: CuboidShape.distance }); +Object.defineProperty(SkeletonTrack, 'distance', { value: SkeletonShape.distance }); + +export function shapeFactory(data: RawShapeData, clientID: number, injection: AnnotationInjection): Annotation { + const { type } = data; + const color = colors[clientID % colors.length]; + + let shapeModel = null; + switch (type) { + case ShapeType.RECTANGLE: + shapeModel = new RectangleShape(data, clientID, color, injection); + break; + case ShapeType.POLYGON: + shapeModel = new PolygonShape(data, clientID, color, injection); + break; + case ShapeType.POLYLINE: + shapeModel = new PolylineShape(data, clientID, color, injection); + break; + case ShapeType.POINTS: + shapeModel = new PointsShape(data, clientID, color, injection); + break; + case ShapeType.ELLIPSE: + shapeModel = new EllipseShape(data, clientID, color, injection); + break; + case ShapeType.CUBOID: + shapeModel = new CuboidShape(data, clientID, color, injection); + break; + case ShapeType.SKELETON: + shapeModel = new SkeletonShape(data, clientID, color, injection); + break; + default: + throw new DataError(`An unexpected type of shape "${type}"`); } - RectangleTrack.distance = RectangleShape.distance; - PolygonTrack.distance = PolygonShape.distance; - PolylineTrack.distance = PolylineShape.distance; - PointsTrack.distance = PointsShape.distance; - EllipseTrack.distance = EllipseShape.distance; - CuboidTrack.distance = CuboidShape.distance; - - module.exports = { - RectangleShape, - PolygonShape, - PolylineShape, - PointsShape, - EllipseShape, - CuboidShape, - RectangleTrack, - PolygonTrack, - PolylineTrack, - PointsTrack, - EllipseTrack, - CuboidTrack, - Track, - Shape, - Tag, - objectStateFactory, - }; -})(); + return shapeModel; +} + +export function trackFactory(trackData: RawTrackData, clientID: number, injection: AnnotationInjection): Annotation { + if (trackData.shapes.length) { + const { type } = trackData.shapes[0]; + const color = colors[clientID % colors.length]; + + let trackModel = null; + switch (type) { + case ShapeType.RECTANGLE: + trackModel = new RectangleTrack(trackData, clientID, color, injection); + break; + case ShapeType.POLYGON: + trackModel = new PolygonTrack(trackData, clientID, color, injection); + break; + case ShapeType.POLYLINE: + trackModel = new PolylineTrack(trackData, clientID, color, injection); + break; + case ShapeType.POINTS: + trackModel = new PointsTrack(trackData, clientID, color, injection); + break; + case ShapeType.ELLIPSE: + trackModel = new EllipseTrack(trackData, clientID, color, injection); + break; + case ShapeType.CUBOID: + trackModel = new CuboidTrack(trackData, clientID, color, injection); + break; + case ShapeType.SKELETON: + trackModel = new SkeletonTrack(trackData, clientID, color, injection); + break; + default: + throw new DataError(`An unexpected type of track "${type}"`); + } + + return trackModel; + } + + console.warn('The track without any shapes had been found. It was ignored.'); + return null; +} diff --git a/cvat-core/src/annotations-saver.ts b/cvat-core/src/annotations-saver.ts index 84db9333..d1adbf75 100644 --- a/cvat-core/src/annotations-saver.ts +++ b/cvat-core/src/annotations-saver.ts @@ -103,6 +103,7 @@ 'rotation', 'type', 'shapes', + 'elements', 'attributes', 'value', 'spec_id', diff --git a/cvat-core/src/annotations.ts b/cvat-core/src/annotations.ts index eb79fe44..3b6d2836 100644 --- a/cvat-core/src/annotations.ts +++ b/cvat-core/src/annotations.ts @@ -6,7 +6,7 @@ const serverProxy = require('./server-proxy'); const Collection = require('./annotations-collection'); const AnnotationsSaver = require('./annotations-saver'); - const AnnotationsHistory = require('./annotations-history'); + const AnnotationsHistory = require('./annotations-history').default; const { checkObjectType } = require('./common'); const { Project } = require('./project'); const { Task, Job } = require('./session'); diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index ecbd57d6..ed5c6490 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -5,7 +5,7 @@ const config = require('./config'); (() => { - const PluginRegistry = require('./plugins'); + const PluginRegistry = require('./plugins').default; const serverProxy = require('./server-proxy'); const lambdaManager = require('./lambda-manager'); const { diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 45a54961..77f1eb00 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -8,10 +8,10 @@ */ function build() { - const PluginRegistry = require('./plugins'); + const PluginRegistry = require('./plugins').default; const loggerStorage = require('./logger-storage'); const Log = require('./log'); - const ObjectState = require('./object-state'); + const ObjectState = require('./object-state').default; const Statistics = require('./statistics'); const Comment = require('./comment'); const Issue = require('./issue'); @@ -880,11 +880,6 @@ function build() { cvat.organizations = Object.freeze(cvat.organizations); const implementAPI = require('./api-implementation'); - - Math.clamp = function clamp(value, min, max) { - return Math.min(Math.max(value, min), max); - }; - const implemented = Object.freeze(implementAPI(cvat)); return implemented; } diff --git a/cvat-core/src/cloud-storage.ts b/cvat-core/src/cloud-storage.ts index 67e8d829..ab2c70d8 100644 --- a/cvat-core/src/cloud-storage.ts +++ b/cvat-core/src/cloud-storage.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT (() => { - const PluginRegistry = require('./plugins'); + const PluginRegistry = require('./plugins').default; const serverProxy = require('./server-proxy'); const { isBrowser, isNode } = require('browser-or-node'); const { ArgumentError } = require('./exceptions'); diff --git a/cvat-core/src/common.ts b/cvat-core/src/common.ts index e0b2b2b8..36c7aaed 100644 --- a/cvat-core/src/common.ts +++ b/cvat-core/src/common.ts @@ -2,130 +2,121 @@ // // SPDX-License-Identifier: MIT -(() => { - const { ArgumentError } = require('./exceptions'); +import { ArgumentError } from './exceptions'; - function isBoolean(value) { - return typeof value === 'boolean'; - } +export function isBoolean(value): boolean { + return typeof value === 'boolean'; +} - function isInteger(value) { - return typeof value === 'number' && Number.isInteger(value); - } +export function isInteger(value): boolean { + return typeof value === 'number' && Number.isInteger(value); +} - // Called with specific Enum context - function isEnum(value) { - for (const key in this) { - if (Object.prototype.hasOwnProperty.call(this, key)) { - if (this[key] === value) { - return true; - } +// Called with specific Enum context +export function isEnum(value): boolean { + for (const key in this) { + if (Object.prototype.hasOwnProperty.call(this, key)) { + if (this[key] === value) { + return true; } } - - return false; } - function isString(value) { - return typeof value === 'string'; - } + return false; +} - function checkFilter(filter, fields) { - for (const prop in filter) { - if (Object.prototype.hasOwnProperty.call(filter, prop)) { - if (!(prop in fields)) { - throw new ArgumentError(`Unsupported filter property has been received: "${prop}"`); - } else if (!fields[prop](filter[prop])) { - throw new ArgumentError(`Received filter property "${prop}" does not satisfy API`); - } +export function isString(value): boolean { + return typeof value === 'string'; +} + +export function checkFilter(filter, fields): void { + for (const prop in filter) { + if (Object.prototype.hasOwnProperty.call(filter, prop)) { + if (!(prop in fields)) { + throw new ArgumentError(`Unsupported filter property has been received: "${prop}"`); + } else if (!fields[prop](filter[prop])) { + throw new ArgumentError(`Received filter property "${prop}" does not satisfy API`); } } } +} - 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); +export function checkExclusiveFields(obj, exclusive, ignore): void { + 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) { - // specific case for integers which aren't native type in JS - if (type === 'integer' && Number.isInteger(value)) { - return true; - } - - throw new ArgumentError(`"${name}" is expected to be "${type}", but "${typeof value}" has been got.`); +export function checkObjectType(name, value, type, instance): boolean { + if (type) { + if (typeof value !== type) { + // specific case for integers which aren't native type in JS + if (type === 'integer' && Number.isInteger(value)) { + return true; } - } else if (instance) { - if (!(value instanceof instance)) { - if (value !== undefined) { - throw new ArgumentError( - `"${name}" is expected to be ${instance.name}, but ` + - `"${value.constructor.name}" has been got`, - ); - } - throw new ArgumentError(`"${name}" is expected to be ${instance.name}, but "undefined" has been got.`); - } + throw new ArgumentError(`"${name}" is expected to be "${type}", but "${typeof value}" has been got.`); } + } else if (instance) { + if (!(value instanceof instance)) { + if (value !== undefined) { + throw new ArgumentError( + `"${name}" is expected to be ${instance.name}, but ` + + `"${value.constructor.name}" has been got`, + ); + } - return true; + throw new ArgumentError(`"${name}" is expected to be ${instance.name}, but "undefined" has been got.`); + } } - class FieldUpdateTrigger { - constructor() { - let updatedFlags = {}; + return true; +} + +export class FieldUpdateTrigger { + constructor() { + let updatedFlags = {}; - Object.defineProperties( - this, - Object.freeze({ - reset: { - value: () => { - updatedFlags = {}; - }, + Object.defineProperties( + this, + Object.freeze({ + reset: { + value: () => { + updatedFlags = {}; }, - update: { - value: (name) => { - updatedFlags[name] = true; - }, + }, + update: { + value: (name) => { + updatedFlags[name] = true; }, - getUpdated: { - value: (data, propMap = {}) => { - const result = {}; - for (const updatedField of Object.keys(updatedFlags)) { - result[propMap[updatedField] || updatedField] = data[updatedField]; - } - return result; - }, + }, + getUpdated: { + value: (data, propMap = {}) => { + const result = {}; + for (const updatedField of Object.keys(updatedFlags)) { + result[propMap[updatedField] || updatedField] = data[updatedField]; + } + return result; }, - }), - ); - } + }, + }), + ); } +} - module.exports = { - isBoolean, - isInteger, - isEnum, - isString, - checkFilter, - checkObjectType, - checkExclusiveFields, - FieldUpdateTrigger, - }; -})(); +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/cvat-core/src/config.ts b/cvat-core/src/config.ts index 1fbcbf9d..70dfbaac 100644 --- a/cvat-core/src/config.ts +++ b/cvat-core/src/config.ts @@ -2,10 +2,12 @@ // // SPDX-License-Identifier: MIT -module.exports = { +const config = { backendAPI: '/api', proxy: false, organizationID: null, origin: '', uploadChunkSize: 100, }; + +export default config; diff --git a/cvat-core/src/enums.ts b/cvat-core/src/enums.ts index 2be9bfa9..8bee70be 100644 --- a/cvat-core/src/enums.ts +++ b/cvat-core/src/enums.ts @@ -1,450 +1,425 @@ // Copyright (C) 2019-2022 Intel Corporation // -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier = MIT -(() => { - /** - * Share files types - * @enum {string} - * @name ShareFileType - * @memberof module:API.cvat.enums - * @property {string} DIR 'DIR' - * @property {string} REG 'REG' - * @readonly - */ - const ShareFileType = Object.freeze({ - DIR: 'DIR', - REG: 'REG', - }); +/** + * Share files types + * @enum {string} + * @name ShareFileType + * @memberof module:API.cvat.enums + * @property {string} DIR 'DIR' + * @property {string} REG 'REG' + * @readonly +*/ +export enum ShareFileType { + DIR = 'DIR', + REG = 'REG', +} - /** - * Task statuses - * @enum {string} - * @name TaskStatus - * @memberof module:API.cvat.enums - * @property {string} ANNOTATION 'annotation' - * @property {string} VALIDATION 'validation' - * @property {string} COMPLETED 'completed' - * @readonly - */ - const TaskStatus = Object.freeze({ - ANNOTATION: 'annotation', - VALIDATION: 'validation', - COMPLETED: 'completed', - }); +/** + * Task statuses + * @enum {string} + * @name TaskStatus + * @memberof module:API.cvat.enums + * @property {string} ANNOTATION 'annotation' + * @property {string} VALIDATION 'validation' + * @property {string} COMPLETED 'completed' + * @readonly +*/ +export enum TaskStatus { + ANNOTATION = 'annotation', + VALIDATION = 'validation', + COMPLETED = 'completed', +} - /** - * Job stages - * @enum {string} - * @name JobStage - * @memberof module:API.cvat.enums - * @property {string} ANNOTATION 'annotation' - * @property {string} VALIDATION 'validation' - * @property {string} ACCEPTANCE 'acceptance' - * @readonly - */ - const JobStage = Object.freeze({ - ANNOTATION: 'annotation', - VALIDATION: 'validation', - ACCEPTANCE: 'acceptance', - }); +/** + * Job stages + * @enum {string} + * @name JobStage + * @memberof module:API.cvat.enums + * @property {string} ANNOTATION 'annotation' + * @property {string} VALIDATION 'validation' + * @property {string} ACCEPTANCE 'acceptance' + * @readonly +*/ +export enum JobStage { + ANNOTATION = 'annotation', + VALIDATION = 'validation', + ACCEPTANCE = 'acceptance', +} - /** - * Job states - * @enum {string} - * @name JobState - * @memberof module:API.cvat.enums - * @property {string} NEW 'new' - * @property {string} IN_PROGRESS 'in progress' - * @property {string} COMPLETED 'completed' - * @property {string} REJECTED 'rejected' - * @readonly - */ - const JobState = Object.freeze({ - NEW: 'new', - IN_PROGRESS: 'in progress', - COMPLETED: 'completed', - REJECTED: 'rejected', - }); +/** + * Job states + * @enum {string} + * @name JobState + * @memberof module:API.cvat.enums + * @property {string} NEW 'new' + * @property {string} IN_PROGRESS 'in progress' + * @property {string} COMPLETED 'completed' + * @property {string} REJECTED 'rejected' + * @readonly +*/ +export enum JobState { + NEW = 'new', + IN_PROGRESS = 'in progress', + COMPLETED = 'completed', + REJECTED = 'rejected', +} - /** - * Task dimension - * @enum - * @name DimensionType - * @memberof module:API.cvat.enums - * @property {string} DIMENSION_2D '2d' - * @property {string} DIMENSION_3D '3d' - * @readonly - */ - const DimensionType = Object.freeze({ - DIMENSION_2D: '2d', - DIMENSION_3D: '3d', - }); +/** + * Task dimension + * @enum + * @name DimensionType + * @memberof module:API.cvat.enums + * @property {string} DIMENSION_2D '2d' + * @property {string} DIMENSION_3D '3d' + * @readonly +*/ +export enum DimensionType { + DIMENSION_2D = '2d', + DIMENSION_3D = '3d', +} - /** - * List of RQ statuses - * @enum {string} - * @name RQStatus - * @memberof module:API.cvat.enums - * @property {string} QUEUED 'queued' - * @property {string} STARTED 'started' - * @property {string} FINISHED 'finished' - * @property {string} FAILED 'failed' - * @property {string} UNKNOWN 'unknown' - * @readonly - */ - const RQStatus = Object.freeze({ - QUEUED: 'queued', - STARTED: 'started', - FINISHED: 'finished', - FAILED: 'failed', - UNKNOWN: 'unknown', - }); +/** + * List of RQ statuses + * @enum {string} + * @name RQStatus + * @memberof module:API.cvat.enums + * @property {string} QUEUED 'queued' + * @property {string} STARTED 'started' + * @property {string} FINISHED 'finished' + * @property {string} FAILED 'failed' + * @property {string} UNKNOWN 'unknown' + * @readonly +*/ +export enum RQStatus { + QUEUED = 'queued', + STARTED = 'started', + FINISHED = 'finished', + FAILED = 'failed', + UNKNOWN = 'unknown', +} - /** - * Task modes - * @enum {string} - * @name TaskMode - * @memberof module:API.cvat.enums - * @property {string} ANNOTATION 'annotation' - * @property {string} INTERPOLATION 'interpolation' - * @readonly - */ - const TaskMode = Object.freeze({ - ANNOTATION: 'annotation', - INTERPOLATION: 'interpolation', - }); +/** + * Task modes + * @enum {string} + * @name TaskMode + * @memberof module:API.cvat.enums + * @property {string} ANNOTATION 'annotation' + * @property {string} INTERPOLATION 'interpolation' + * @readonly +*/ +export enum TaskMode { + ANNOTATION = 'annotation', + INTERPOLATION = 'interpolation', +} - /** - * Attribute types - * @enum {string} - * @name AttributeType - * @memberof module:API.cvat.enums - * @property {string} CHECKBOX 'checkbox' - * @property {string} SELECT 'select' - * @property {string} RADIO 'radio' - * @property {string} NUMBER 'number' - * @property {string} TEXT 'text' - * @readonly - */ - const AttributeType = Object.freeze({ - CHECKBOX: 'checkbox', - RADIO: 'radio', - SELECT: 'select', - NUMBER: 'number', - TEXT: 'text', - }); +/** + * Attribute types + * @enum {string} + * @name AttributeType + * @memberof module:API.cvat.enums + * @property {string} CHECKBOX 'checkbox' + * @property {string} SELECT 'select' + * @property {string} RADIO 'radio' + * @property {string} NUMBER 'number' + * @property {string} TEXT 'text' + * @readonly +*/ +export enum AttributeType { + CHECKBOX = 'checkbox', + RADIO = 'radio', + SELECT = 'select', + NUMBER = 'number', + TEXT = 'text', +} - /** - * Object types - * @enum {string} - * @name ObjectType - * @memberof module:API.cvat.enums - * @property {string} TAG 'tag' - * @property {string} SHAPE 'shape' - * @property {string} TRACK 'track' - * @readonly - */ - const ObjectType = Object.freeze({ - TAG: 'tag', - SHAPE: 'shape', - TRACK: 'track', - }); +/** + * Object types + * @enum {string} + * @name ObjectType + * @memberof module:API.cvat.enums + * @property {string} TAG 'tag' + * @property {string} SHAPE 'shape' + * @property {string} TRACK 'track' + * @readonly +*/ +export enum ObjectType { + TAG = 'tag', + SHAPE = 'shape', + TRACK = 'track', +} - /** - * Object shapes - * @enum {string} - * @name ObjectShape - * @memberof module:API.cvat.enums - * @property {string} RECTANGLE 'rectangle' - * @property {string} POLYGON 'polygon' - * @property {string} POLYLINE 'polyline' - * @property {string} POINTS 'points' - * @property {string} CUBOID 'cuboid' - * @readonly - */ - const ObjectShape = Object.freeze({ - RECTANGLE: 'rectangle', - POLYGON: 'polygon', - POLYLINE: 'polyline', - POINTS: 'points', - ELLIPSE: 'ellipse', - CUBOID: 'cuboid', - }); +/** + * Object shapes + * @enum {string} + * @name ShapeType + * @memberof module:API.cvat.enums + * @property {string} RECTANGLE 'rectangle' + * @property {string} POLYGON 'polygon' + * @property {string} POLYLINE 'polyline' + * @property {string} POINTS 'points' + * @property {string} CUBOID 'cuboid' + * @property {string} SKELETON 'skeleton' + * @readonly +*/ +export enum ShapeType { + RECTANGLE = 'rectangle', + POLYGON = 'polygon', + POLYLINE = 'polyline', + POINTS = 'points', + ELLIPSE = 'ellipse', + CUBOID = 'cuboid', + SKELETON = 'skeleton', +} - /** - * Annotation type - * @enum {string} - * @name Source - * @memberof module:API.cvat.enums - * @property {string} MANUAL 'manual' - * @property {string} AUTO 'auto' - * @readonly - */ - const Source = Object.freeze({ - MANUAL: 'manual', - AUTO: 'auto', - }); +/** + * Annotation type + * @enum {string} + * @name Source + * @memberof module:API.cvat.enums + * @property {string} MANUAL 'manual' + * @property {string} AUTO 'auto' + * @readonly +*/ +export enum Source { + MANUAL = 'manual', + AUTO = 'auto', +} - /** - * Logger event types - * @enum {string} - * @name LogType - * @memberof module:API.cvat.enums - * @property {string} loadJob Load job - * @property {string} saveJob Save job - * @property {string} restoreJob Restore job - * @property {string} uploadAnnotations Upload annotations - * @property {string} sendUserActivity Send user activity - * @property {string} sendException Send exception - * @property {string} sendTaskInfo Send task info +/** + * Logger event types + * @enum {string} + * @name LogType + * @memberof module:API.cvat.enums + * @property {string} loadJob Load job + * @property {string} saveJob Save job + * @property {string} restoreJob Restore job + * @property {string} uploadAnnotations Upload annotations + * @property {string} sendUserActivity Send user activity + * @property {string} sendException Send exception + * @property {string} sendTaskInfo Send task info + * @property {string} drawObject Draw object + * @property {string} pasteObject Paste object + * @property {string} copyObject Copy object + * @property {string} propagateObject Propagate object + * @property {string} dragObject Drag object + * @property {string} resizeObject Resize object + * @property {string} deleteObject Delete object + * @property {string} lockObject Lock object + * @property {string} mergeObjects Merge objects + * @property {string} changeAttribute Change attribute + * @property {string} changeLabel Change label + * @property {string} changeFrame Change frame + * @property {string} moveImage Move image + * @property {string} zoomImage Zoom image + * @property {string} fitImage Fit image + * @property {string} rotateImage Rotate image + * @property {string} undoAction Undo action + * @property {string} redoAction Redo action + * @property {string} pressShortcut Press shortcut + * @property {string} debugInfo Debug info + * @readonly +*/ +export enum LogType { + loadJob = 'Load job', + saveJob = 'Save job', + restoreJob = 'Restore job', + uploadAnnotations = 'Upload annotations', + sendUserActivity = 'Send user activity', + sendException = 'Send exception', + sendTaskInfo = 'Send task info', - * @property {string} drawObject Draw object - * @property {string} pasteObject Paste object - * @property {string} copyObject Copy object - * @property {string} propagateObject Propagate object - * @property {string} dragObject Drag object - * @property {string} resizeObject Resize object - * @property {string} deleteObject Delete object - * @property {string} lockObject Lock object - * @property {string} mergeObjects Merge objects - * @property {string} changeAttribute Change attribute - * @property {string} changeLabel Change label + drawObject = 'Draw object', + pasteObject = 'Paste object', + copyObject = 'Copy object', + propagateObject = 'Propagate object', + dragObject = 'Drag object', + resizeObject = 'Resize object', + deleteObject = 'Delete object', + lockObject = 'Lock object', + mergeObjects = 'Merge objects', + changeAttribute = 'Change attribute', + changeLabel = 'Change label', - * @property {string} changeFrame Change frame - * @property {string} moveImage Move image - * @property {string} zoomImage Zoom image - * @property {string} fitImage Fit image - * @property {string} rotateImage Rotate image + changeFrame = 'Change frame', + moveImage = 'Move image', + zoomImage = 'Zoom image', + fitImage = 'Fit image', + rotateImage = 'Rotate image', - * @property {string} undoAction Undo action - * @property {string} redoAction Redo action + undoAction = 'Undo action', + redoAction = 'Redo action', - * @property {string} pressShortcut Press shortcut - * @property {string} debugInfo Debug info - * @readonly - */ - const LogType = Object.freeze({ - loadJob: 'Load job', - saveJob: 'Save job', - restoreJob: 'Restore job', - uploadAnnotations: 'Upload annotations', - sendUserActivity: 'Send user activity', - sendException: 'Send exception', - sendTaskInfo: 'Send task info', + pressShortcut = 'Press shortcut', + debugInfo = 'Debug info', +} - drawObject: 'Draw object', - pasteObject: 'Paste object', - copyObject: 'Copy object', - propagateObject: 'Propagate object', - dragObject: 'Drag object', - resizeObject: 'Resize object', - deleteObject: 'Delete object', - lockObject: 'Lock object', - mergeObjects: 'Merge objects', - changeAttribute: 'Change attribute', - changeLabel: 'Change label', +/** + * Types of actions with annotations + * @enum {string} + * @name HistoryActions + * @memberof module:API.cvat.enums + * @property {string} CHANGED_LABEL Changed label + * @property {string} CHANGED_ATTRIBUTES Changed attributes + * @property {string} CHANGED_POINTS Changed points + * @property {string} CHANGED_OUTSIDE Changed outside + * @property {string} CHANGED_OCCLUDED Changed occluded + * @property {string} CHANGED_ZORDER Changed z-order + * @property {string} CHANGED_LOCK Changed lock + * @property {string} CHANGED_COLOR Changed color + * @property {string} CHANGED_HIDDEN Changed hidden + * @property {string} CHANGED_SOURCE Changed source + * @property {string} MERGED_OBJECTS Merged objects + * @property {string} SPLITTED_TRACK Splitted track + * @property {string} GROUPED_OBJECTS Grouped objects + * @property {string} CREATED_OBJECTS Created objects + * @property {string} REMOVED_OBJECT Removed object + * @property {string} REMOVED_FRAME Removed frame + * @property {string} RESTORED_FRAME Restored frame + * @readonly +*/ +export enum HistoryActions { + CHANGED_LABEL = 'Changed label', + CHANGED_ATTRIBUTES = 'Changed attributes', + CHANGED_POINTS = 'Changed points', + CHANGED_ROTATION = 'Object rotated', + CHANGED_OUTSIDE = 'Changed outside', + CHANGED_OCCLUDED = 'Changed occluded', + CHANGED_ZORDER = 'Changed z-order', + CHANGED_KEYFRAME = 'Changed keyframe', + CHANGED_LOCK = 'Changed lock', + CHANGED_PINNED = 'Changed pinned', + CHANGED_COLOR = 'Changed color', + CHANGED_HIDDEN = 'Changed hidden', + CHANGED_SOURCE = 'Changed source', + MERGED_OBJECTS = 'Merged objects', + SPLITTED_TRACK = 'Splitted track', + GROUPED_OBJECTS = 'Grouped objects', + CREATED_OBJECTS = 'Created objects', + REMOVED_OBJECT = 'Removed object', + REMOVED_FRAME = 'Removed frame', + RESTORED_FRAME = 'Restored frame', +} - changeFrame: 'Change frame', - moveImage: 'Move image', - zoomImage: 'Zoom image', - fitImage: 'Fit image', - rotateImage: 'Rotate image', +/** + * Enum string values. + * @name ModelType + * @memberof module:API.cvat.enums + * @enum {string} +*/ +export enum ModelType { + DETECTOR = 'detector', + INTERACTOR = 'interactor', + TRACKER = 'tracker', +} - undoAction: 'Undo action', - redoAction: 'Redo action', +/** + * Array of hex colors + * @name colors + * @memberof module:API.cvat.enums + * @type {string[]} + * @readonly +*/ +export const colors = [ + '#33ddff', + '#fa3253', + '#34d1b7', + '#ff007c', + '#ff6037', + '#ddff33', + '#24b353', + '#b83df5', + '#66ff66', + '#32b7fa', + '#ffcc33', + '#83e070', + '#fafa37', + '#5986b3', + '#8c78f0', + '#ff6a4d', + '#f078f0', + '#2a7dd1', + '#b25050', + '#cc3366', + '#cc9933', + '#aaf0d1', + '#ff00cc', + '#3df53d', + '#fa32b7', + '#fa7dbb', + '#ff355e', + '#f59331', + '#3d3df5', + '#733380', +]; - pressShortcut: 'Press shortcut', - debugInfo: 'Debug info', - }); +/** + * Types of cloud storage providers + * @enum {string} + * @name CloudStorageProviderType + * @memberof module:API.cvat.enums + * @property {string} AWS_S3 'AWS_S3_BUCKET' + * @property {string} AZURE 'AZURE_CONTAINER' + * @property {string} GOOGLE_CLOUD_STORAGE 'GOOGLE_CLOUD_STORAGE' + * @readonly +*/ +export enum CloudStorageProviderType { + AWS_S3_BUCKET = 'AWS_S3_BUCKET', + AZURE_CONTAINER = 'AZURE_CONTAINER', + GOOGLE_CLOUD_STORAGE = 'GOOGLE_CLOUD_STORAGE', +} - /** - * Types of actions with annotations - * @enum {string} - * @name HistoryActions - * @memberof module:API.cvat.enums - * @property {string} CHANGED_LABEL Changed label - * @property {string} CHANGED_ATTRIBUTES Changed attributes - * @property {string} CHANGED_POINTS Changed points - * @property {string} CHANGED_OUTSIDE Changed outside - * @property {string} CHANGED_OCCLUDED Changed occluded - * @property {string} CHANGED_ZORDER Changed z-order - * @property {string} CHANGED_LOCK Changed lock - * @property {string} CHANGED_COLOR Changed color - * @property {string} CHANGED_HIDDEN Changed hidden - * @property {string} CHANGED_SOURCE Changed source - * @property {string} MERGED_OBJECTS Merged objects - * @property {string} SPLITTED_TRACK Splitted track - * @property {string} GROUPED_OBJECTS Grouped objects - * @property {string} CREATED_OBJECTS Created objects - * @property {string} REMOVED_OBJECT Removed object - * @property {string} REMOVED_FRAME Removed frame - * @property {string} RESTORED_FRAME Restored frame - * @readonly - */ - const HistoryActions = Object.freeze({ - CHANGED_LABEL: 'Changed label', - CHANGED_ATTRIBUTES: 'Changed attributes', - CHANGED_POINTS: 'Changed points', - CHANGED_OUTSIDE: 'Changed outside', - CHANGED_OCCLUDED: 'Changed occluded', - CHANGED_ZORDER: 'Changed z-order', - CHANGED_KEYFRAME: 'Changed keyframe', - CHANGED_LOCK: 'Changed lock', - CHANGED_PINNED: 'Changed pinned', - CHANGED_COLOR: 'Changed color', - CHANGED_HIDDEN: 'Changed hidden', - CHANGED_SOURCE: 'Changed source', - MERGED_OBJECTS: 'Merged objects', - SPLITTED_TRACK: 'Splitted track', - GROUPED_OBJECTS: 'Grouped objects', - CREATED_OBJECTS: 'Created objects', - REMOVED_OBJECT: 'Removed object', - REMOVED_FRAME: 'Removed frame', - RESTORED_FRAME: 'Restored frame', - }); +/** + * Types of cloud storage credentials + * @enum {string} + * @name CloudStorageCredentialsType + * @memberof module:API.cvat.enums + * @property {string} KEY_SECRET_KEY_PAIR 'KEY_SECRET_KEY_PAIR' + * @property {string} ACCOUNT_NAME_TOKEN_PAIR 'ACCOUNT_NAME_TOKEN_PAIR' + * @property {string} ANONYMOUS_ACCESS 'ANONYMOUS_ACCESS' + * @property {string} KEY_FILE_PATH 'KEY_FILE_PATH' + * @readonly + */ +export enum CloudStorageCredentialsType { + KEY_SECRET_KEY_PAIR = 'KEY_SECRET_KEY_PAIR', + ACCOUNT_NAME_TOKEN_PAIR = 'ACCOUNT_NAME_TOKEN_PAIR', + ANONYMOUS_ACCESS = 'ANONYMOUS_ACCESS', + KEY_FILE_PATH = 'KEY_FILE_PATH', +} - /** - * Enum string values. - * @name ModelType - * @memberof module:API.cvat.enums - * @enum {string} - */ - const ModelType = { - DETECTOR: 'detector', - INTERACTOR: 'interactor', - TRACKER: 'tracker', - }; +/** + * Task statuses + * @enum {string} + * @name MembershipRole + * @memberof module:API.cvat.enums + * @property {string} WORKER 'worker' + * @property {string} SUPERVISOR 'supervisor' + * @property {string} MAINTAINER 'maintainer' + * @property {string} OWNER 'owner' + * @readonly +*/ +export enum MembershipRole { + WORKER = 'worker', + SUPERVISOR = 'supervisor', + MAINTAINER = 'maintainer', + OWNER = 'owner', +} - /** - * Array of hex colors - * @name colors - * @memberof module:API.cvat.enums - * @type {string[]} - * @readonly - */ - const colors = [ - '#33ddff', - '#fa3253', - '#34d1b7', - '#ff007c', - '#ff6037', - '#ddff33', - '#24b353', - '#b83df5', - '#66ff66', - '#32b7fa', - '#ffcc33', - '#83e070', - '#fafa37', - '#5986b3', - '#8c78f0', - '#ff6a4d', - '#f078f0', - '#2a7dd1', - '#b25050', - '#cc3366', - '#cc9933', - '#aaf0d1', - '#ff00cc', - '#3df53d', - '#fa32b7', - '#fa7dbb', - '#ff355e', - '#f59331', - '#3d3df5', - '#733380', - ]; - - /** - * Types of cloud storage providers - * @enum {string} - * @name CloudStorageProviderType - * @memberof module:API.cvat.enums - * @property {string} AWS_S3 'AWS_S3_BUCKET' - * @property {string} AZURE 'AZURE_CONTAINER' - * @property {string} GOOGLE_CLOUD_STORAGE 'GOOGLE_CLOUD_STORAGE' - * @readonly - */ - const CloudStorageProviderType = Object.freeze({ - AWS_S3_BUCKET: 'AWS_S3_BUCKET', - AZURE_CONTAINER: 'AZURE_CONTAINER', - GOOGLE_CLOUD_STORAGE: 'GOOGLE_CLOUD_STORAGE', - }); - - /** - * Types of cloud storage credentials - * @enum {string} - * @name CloudStorageCredentialsType - * @memberof module:API.cvat.enums - * @property {string} KEY_SECRET_KEY_PAIR 'KEY_SECRET_KEY_PAIR' - * @property {string} ACCOUNT_NAME_TOKEN_PAIR 'ACCOUNT_NAME_TOKEN_PAIR' - * @property {string} ANONYMOUS_ACCESS 'ANONYMOUS_ACCESS' - * @property {string} KEY_FILE_PATH 'KEY_FILE_PATH' - * @readonly - */ - const CloudStorageCredentialsType = Object.freeze({ - KEY_SECRET_KEY_PAIR: 'KEY_SECRET_KEY_PAIR', - ACCOUNT_NAME_TOKEN_PAIR: 'ACCOUNT_NAME_TOKEN_PAIR', - ANONYMOUS_ACCESS: 'ANONYMOUS_ACCESS', - KEY_FILE_PATH: 'KEY_FILE_PATH', - }); - - /** - * Task statuses - * @enum {string} - * @name MembershipRole - * @memberof module:API.cvat.enums - * @property {string} WORKER 'worker' - * @property {string} SUPERVISOR 'supervisor' - * @property {string} MAINTAINER 'maintainer' - * @property {string} OWNER 'owner' - * @readonly - */ - const MembershipRole = Object.freeze({ - WORKER: 'worker', - SUPERVISOR: 'supervisor', - MAINTAINER: 'maintainer', - OWNER: 'owner', - }); - - /** - * Sorting methods - * @enum {string} - * @name SortingMethod - * @memberof module:API.cvat.enums - * @property {string} LEXICOGRAPHICAL 'lexicographical' - * @property {string} NATURAL 'natural' - * @property {string} PREDEFINED 'predefined' - * @property {string} RANDOM 'random' - * @readonly - */ - const SortingMethod = Object.freeze({ - LEXICOGRAPHICAL: 'lexicographical', - NATURAL: 'natural', - PREDEFINED: 'predefined', - RANDOM: 'random', - }); - - module.exports = { - ShareFileType, - TaskStatus, - JobStage, - JobState, - TaskMode, - AttributeType, - ObjectType, - ObjectShape, - LogType, - ModelType, - HistoryActions, - RQStatus, - colors, - Source, - DimensionType, - CloudStorageProviderType, - CloudStorageCredentialsType, - MembershipRole, - SortingMethod, - }; -})(); +/** + * Sorting methods + * @enum {string} + * @name SortingMethod + * @memberof module:API.cvat.enums + * @property {string} LEXICOGRAPHICAL 'lexicographical' + * @property {string} NATURAL 'natural' + * @property {string} PREDEFINED 'predefined' + * @property {string} RANDOM 'random' + * @readonly +*/ +export enum SortingMethod { + LEXICOGRAPHICAL = 'lexicographical', + NATURAL = 'natural', + PREDEFINED = 'predefined', + RANDOM = 'random', +} diff --git a/cvat-core/src/exceptions.ts b/cvat-core/src/exceptions.ts index 81456068..db306115 100644 --- a/cvat-core/src/exceptions.ts +++ b/cvat-core/src/exceptions.ts @@ -2,268 +2,254 @@ // // SPDX-License-Identifier: MIT -(() => { - const Platform = require('platform'); - const ErrorStackParser = require('error-stack-parser'); - const config = require('./config'); +import Platform from 'platform'; +import ErrorStackParser from 'error-stack-parser'; +// import config from './config'; + +/** + * Base exception class + * @memberof module:API.cvat.exceptions + * @extends Error + * @ignore + */ +export class Exception extends Error { + private readonly time: string; + private readonly system: string; + private readonly client: string; + private readonly info: string; + private readonly filename: string; + private readonly line: number; + private readonly column: number; /** - * Base exception class - * @memberof module:API.cvat.exceptions - * @extends Error - * @ignore + * @param {string} message - Exception message */ - class Exception extends Error { - /** - * @param {string} message - Exception message - */ - constructor(message) { - super(message); - - const time = new Date().toISOString(); - const system = Platform.os.toString(); - const client = `${Platform.name} ${Platform.version}`; - const info = ErrorStackParser.parse(this)[0]; - const filename = `${info.fileName}`; - const line = info.lineNumber; - const column = info.columnNumber; - const { jobID, taskID, clientID } = config; + constructor(message) { + super(message); + const time = new Date().toISOString(); + const system = Platform.os.toString(); + const client = `${Platform.name} ${Platform.version}`; + const info = ErrorStackParser.parse(this)[0]; + const filename = `${info.fileName}`; + const line = info.lineNumber; + const column = info.columnNumber; - const projID = undefined; // wasn't implemented + // TODO: NOT IMPLEMENTED? + // const { + // jobID, taskID, clientID, projID, + // } = config; - Object.defineProperties( - this, - Object.freeze({ - system: { - /** - * @name system - * @type {string} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => system, - }, - client: { - /** - * @name client - * @type {string} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => client, - }, - time: { - /** - * @name time - * @type {string} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => time, - }, - jobID: { - /** - * @name jobID - * @type {number} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => jobID, - }, - taskID: { - /** - * @name taskID - * @type {number} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => taskID, - }, - projID: { - /** - * @name projID - * @type {number} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => projID, - }, - clientID: { - /** - * @name clientID - * @type {number} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => clientID, - }, - filename: { - /** - * @name filename - * @type {string} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => filename, - }, - line: { - /** - * @name line - * @type {number} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => line, - }, - column: { - /** - * @name column - * @type {number} - * @memberof module:API.cvat.exceptions.Exception - * @readonly - * @instance - */ - get: () => column, - }, - }), - ); - } + Object.defineProperties( + this, + Object.freeze({ + system: { + /** + * @name system + * @type {string} + * @memberof module:API.cvat.exceptions.Exception + * @readonly + * @instance + */ + get: () => system, + }, + client: { + /** + * @name client + * @type {string} + * @memberof module:API.cvat.exceptions.Exception + * @readonly + * @instance + */ + get: () => client, + }, + time: { + /** + * @name time + * @type {string} + * @memberof module:API.cvat.exceptions.Exception + * @readonly + * @instance + */ + get: () => time, + }, + // jobID: { + // /** + // * @name jobID + // * @type {number} + // * @memberof module:API.cvat.exceptions.Exception + // * @readonly + // * @instance + // */ + // get: () => jobID, + // }, + // taskID: { + // /** + // * @name taskID + // * @type {number} + // * @memberof module:API.cvat.exceptions.Exception + // * @readonly + // * @instance + // */ + // get: () => taskID, + // }, + // projID: { + // /** + // * @name projID + // * @type {number} + // * @memberof module:API.cvat.exceptions.Exception + // * @readonly + // * @instance + // */ + // get: () => projID, + // }, + // clientID: { + // /** + // * @name clientID + // * @type {number} + // * @memberof module:API.cvat.exceptions.Exception + // * @readonly + // * @instance + // */ + // get: () => clientID, + // }, + filename: { + /** + * @name filename + * @type {string} + * @memberof module:API.cvat.exceptions.Exception + * @readonly + * @instance + */ + get: () => filename, + }, + line: { + /** + * @name line + * @type {number} + * @memberof module:API.cvat.exceptions.Exception + * @readonly + * @instance + */ + get: () => line, + }, + column: { + /** + * @name column + * @type {number} + * @memberof module:API.cvat.exceptions.Exception + * @readonly + * @instance + */ + get: () => column, + }, + }), + ); + } - /** - * Save an exception on a server - * @name save - * @method - * @memberof Exception - * @instance - * @async - */ - async save() { - const exceptionObject = { - system: this.system, - client: this.client, - time: this.time, - job_id: this.jobID, - task_id: this.taskID, - proj_id: this.projID, - client_id: this.clientID, - message: this.message, - filename: this.filename, - line: this.line, - column: this.column, - stack: this.stack, - }; + /** + * Save an exception on a server + * @name save + * @method + * @memberof Exception + * @instance + * @async + */ + async save(): Promise { + const exceptionObject = { + system: this.system, + client: this.client, + time: this.time, + // job_id: this.jobID, + // task_id: this.taskID, + // proj_id: this.projID, + // client_id: this.clientID, + message: this.message, + filename: this.filename, + line: this.line, + column: this.column, + stack: this.stack, + }; - try { - const serverProxy = require('./server-proxy'); - await serverProxy.server.exception(exceptionObject); - } catch (exception) { - // add event - } + try { + const serverProxy = require('./server-proxy'); + await serverProxy.server.exception(exceptionObject); + } catch (exception) { + // add event } } +} +/** + * Exceptions are referred with arguments data + * @memberof module:API.cvat.exceptions + * @extends module:API.cvat.exceptions.Exception + */ +export class ArgumentError extends Exception { /** - * Exceptions are referred with arguments data - * @memberof module:API.cvat.exceptions - * @extends module:API.cvat.exceptions.Exception + * @param {string} message - Exception message */ - class ArgumentError extends Exception { - /** - * @param {string} message - Exception message - */ - constructor(message) { - super(message); - } - } +} +/** + * Unexpected problems with data which are not connected with a user input + * @memberof module:API.cvat.exceptions + * @extends module:API.cvat.exceptions.Exception + */ +export class DataError extends Exception { /** - * Unexpected problems with data which are not connected with a user input - * @memberof module:API.cvat.exceptions - * @extends module:API.cvat.exceptions.Exception + * @param {string} message - Exception message */ - class DataError extends Exception { - /** - * @param {string} message - Exception message - */ - constructor(message) { - super(message); - } - } +} +/** + * Unexpected situations in code + * @memberof module:API.cvat.exceptions + * @extends module:API.cvat.exceptions.Exception + */ +export class ScriptingError extends Exception { /** - * Unexpected situations in code - * @memberof module:API.cvat.exceptions - * @extends module:API.cvat.exceptions.Exception + * @param {string} message - Exception message */ - class ScriptingError extends Exception { - /** - * @param {string} message - Exception message - */ - constructor(message) { - super(message); - } - } +} +/** + * Plugin-referred exceptions + * @memberof module:API.cvat.exceptions + * @extends module:API.cvat.exceptions.Exception + */ +export class PluginError extends Exception { /** - * Plugin-referred exceptions - * @memberof module:API.cvat.exceptions - * @extends module:API.cvat.exceptions.Exception + * @param {string} message - Exception message */ - class PluginError extends Exception { - /** - * @param {string} message - Exception message - */ - constructor(message) { - super(message); - } - } +} +/** + * Exceptions in interaction with a server + * @memberof module:API.cvat.exceptions + * @extends module:API.cvat.exceptions.Exception + */ +export class ServerError extends Exception { /** - * Exceptions in interaction with a server - * @memberof module:API.cvat.exceptions - * @extends module:API.cvat.exceptions.Exception + * @param {string} message - Exception message + * @param {(string|number)} code - Response code */ - class ServerError extends Exception { - /** - * @param {string} message - Exception message - * @param {(string|number)} code - Response code - */ - constructor(message, code) { - super(message); + constructor(message, code) { + super(message); - Object.defineProperties( - this, - Object.freeze({ - /** - * @name code - * @type {(string|number)} - * @memberof module:API.cvat.exceptions.ServerError - * @readonly - * @instance - */ - code: { - get: () => code, - }, - }), - ); - } + Object.defineProperties( + this, + Object.freeze({ + /** + * @name code + * @type {(string|number)} + * @memberof module:API.cvat.exceptions.ServerError + * @readonly + * @instance + */ + code: { + get: () => code, + }, + }), + ); } - - module.exports = { - Exception, - ArgumentError, - DataError, - ScriptingError, - PluginError, - ServerError, - }; -})(); +} diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 023d3590..4dfd9ae0 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -4,7 +4,7 @@ (() => { const cvatData = require('cvat-data'); - const PluginRegistry = require('./plugins'); + const PluginRegistry = require('./plugins').default; const serverProxy = require('./server-proxy'); const { isBrowser, isNode } = require('browser-or-node'); const { Exception, ArgumentError, DataError } = require('./exceptions'); diff --git a/cvat-core/src/issue.ts b/cvat-core/src/issue.ts index 13e6e3d6..795d5b7a 100644 --- a/cvat-core/src/issue.ts +++ b/cvat-core/src/issue.ts @@ -4,7 +4,7 @@ const quickhull = require('quickhull'); -const PluginRegistry = require('./plugins'); +const PluginRegistry = require('./plugins').default; const Comment = require('./comment'); const User = require('./user'); const { ArgumentError } = require('./exceptions'); diff --git a/cvat-core/src/labels.ts b/cvat-core/src/labels.ts index bf9ed85c..5ffe7a18 100644 --- a/cvat-core/src/labels.ts +++ b/cvat-core/src/labels.ts @@ -2,244 +2,355 @@ // // SPDX-License-Identifier: MIT -(() => { - const { AttributeType } = require('./enums'); - const { ArgumentError } = require('./exceptions'); - - /** - * Class representing an attribute - * @memberof module:API.cvat.classes - * @hideconstructor - */ - class Attribute { - constructor(initialData) { - const data = { - id: undefined, - default_value: undefined, - input_type: undefined, - mutable: undefined, - name: undefined, - values: undefined, - }; - - for (const key in data) { - if (Object.prototype.hasOwnProperty.call(data, key)) { - if (Object.prototype.hasOwnProperty.call(initialData, key)) { - if (Array.isArray(initialData[key])) { - data[key] = [...initialData[key]]; - } else { - data[key] = initialData[key]; - } +import { ShapeType, AttributeType } from './enums'; +import { ArgumentError } from './exceptions'; + +type AttrInputType = 'select' | 'radio' | 'checkbox' | 'number' | 'text'; + +export interface RawAttribute { + name: string; + mutable: boolean; + input_type: AttrInputType; + default_value: string; + values: string[]; + id?: number; +} + +/** + * Class representing an attribute + * @memberof module:API.cvat.classes + * @hideconstructor + */ +export class Attribute { + public id?: number; + public defaultValue: string; + public inputType: AttrInputType; + public mutable: boolean; + public name: string; + public values: string[]; + + constructor(initialData: RawAttribute) { + const data = { + id: undefined, + default_value: undefined, + input_type: undefined, + mutable: undefined, + name: undefined, + values: undefined, + }; + + for (const key in data) { + if (Object.prototype.hasOwnProperty.call(data, key)) { + if (Object.prototype.hasOwnProperty.call(initialData, key)) { + if (Array.isArray(initialData[key])) { + data[key] = [...initialData[key]]; + } else { + data[key] = initialData[key]; } } } + } - if (!Object.values(AttributeType).includes(data.input_type)) { - throw new ArgumentError(`Got invalid attribute type ${data.input_type}`); - } - - Object.defineProperties( - this, - Object.freeze({ - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.Attribute - * @readonly - * @instance - */ - id: { - get: () => data.id, - }, - /** - * @name defaultValue - * @type {(string|integer|boolean)} - * @memberof module:API.cvat.classes.Attribute - * @readonly - * @instance - */ - defaultValue: { - get: () => data.default_value, - }, - /** - * @name inputType - * @type {module:API.cvat.enums.AttributeType} - * @memberof module:API.cvat.classes.Attribute - * @readonly - * @instance - */ - inputType: { - get: () => data.input_type, - }, - /** - * @name mutable - * @type {boolean} - * @memberof module:API.cvat.classes.Attribute - * @readonly - * @instance - */ - mutable: { - get: () => data.mutable, - }, - /** - * @name name - * @type {string} - * @memberof module:API.cvat.classes.Attribute - * @readonly - * @instance - */ - name: { - get: () => data.name, - }, - /** - * @name values - * @type {(string[]|integer[]|boolean[])} - * @memberof module:API.cvat.classes.Attribute - * @readonly - * @instance - */ - values: { - get: () => [...data.values], - }, - }), - ); + if (!Object.values(AttributeType).includes(data.input_type)) { + throw new ArgumentError(`Got invalid attribute type ${data.input_type}`); } - toJSON() { - const object = { - name: this.name, - mutable: this.mutable, - input_type: this.inputType, - default_value: this.defaultValue, - values: this.values, - }; - - if (typeof this.id !== 'undefined') { - object.id = this.id; - } + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {number} + * @memberof module:API.cvat.classes.Attribute + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * @name defaultValue + * @type {string} + * @memberof module:API.cvat.classes.Attribute + * @readonly + * @instance + */ + defaultValue: { + get: () => data.default_value, + }, + /** + * @name inputType + * @type {module:API.cvat.enums.AttributeType} + * @memberof module:API.cvat.classes.Attribute + * @readonly + * @instance + */ + inputType: { + get: () => data.input_type, + }, + /** + * @name mutable + * @type {boolean} + * @memberof module:API.cvat.classes.Attribute + * @readonly + * @instance + */ + mutable: { + get: () => data.mutable, + }, + /** + * @name name + * @type {string} + * @memberof module:API.cvat.classes.Attribute + * @readonly + * @instance + */ + name: { + get: () => data.name, + }, + /** + * @name values + * @type {string[]} + * @memberof module:API.cvat.classes.Attribute + * @readonly + * @instance + */ + values: { + get: () => [...data.values], + }, + }), + ); + } + + toJSON(): RawAttribute { + const object: RawAttribute = { + name: this.name, + mutable: this.mutable, + input_type: this.inputType, + default_value: this.defaultValue, + values: this.values, + }; - return object; + if (typeof this.id !== 'undefined') { + object.id = this.id; } + + return object; } +} - /** - * Class representing a label - * @memberof module:API.cvat.classes - * @hideconstructor - */ - class Label { - constructor(initialData) { - const data = { - id: undefined, - name: undefined, - color: undefined, - deleted: false, - }; - - for (const key in data) { - if (Object.prototype.hasOwnProperty.call(data, key)) { - if (Object.prototype.hasOwnProperty.call(initialData, key)) { - data[key] = initialData[key]; - } - } +type LabelType = 'rectangle' | 'polygon' | 'polyline' | 'points' | 'ellipse' | 'cuboid' | 'skeleton' | 'any'; +export interface RawLabel { + id?: number; + name: string; + color?: string; + type: LabelType; + svg?: string; + sublabels?: RawLabel[]; + has_parent?: boolean; + deleted?: boolean; + attributes: RawAttribute[]; +} + +/** + * Class representing a label + * @memberof module:API.cvat.classes + * @hideconstructor + */ +export class Label { + public name: string; + public readonly id?: number; + public readonly color?: string; + public readonly attributes: Attribute[]; + public readonly type: LabelType; + public structure: { + sublabels: Label[]; + svg: string; + } | null; + public deleted: boolean; + public readonly hasParent?: boolean; + + constructor(initialData: RawLabel) { + const data = { + id: undefined, + name: undefined, + color: undefined, + type: undefined, + structure: undefined, + has_parent: false, + deleted: false, + svg: undefined, + elements: undefined, + sublabels: undefined, + attributes: [], + }; + + for (const key of Object.keys(data)) { + if (Object.prototype.hasOwnProperty.call(initialData, key)) { + data[key] = initialData[key]; } + } - data.attributes = []; + data.attributes = []; - if ( - Object.prototype.hasOwnProperty.call(initialData, 'attributes') && - Array.isArray(initialData.attributes) - ) { - for (const attrData of initialData.attributes) { - data.attributes.push(new Attribute(attrData)); - } + if ( + Object.prototype.hasOwnProperty.call(initialData, 'attributes') && + Array.isArray(initialData.attributes) + ) { + for (const attrData of initialData.attributes) { + data.attributes.push(new Attribute(attrData)); } + } - Object.defineProperties( - this, - Object.freeze({ - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.Label - * @readonly - * @instance - */ - id: { - get: () => data.id, - }, - /** - * @name name - * @type {string} - * @memberof module:API.cvat.classes.Label - * @instance - */ - name: { - get: () => data.name, - set: (name) => { - if (typeof name !== 'string') { - throw new ArgumentError(`Name must be a string, but ${typeof name} was given`); - } - data.name = name; - }, + if (data.type === 'skeleton') { + data.sublabels = data.sublabels.map((internalLabel) => new Label({ ...internalLabel, has_parent: true })); + } + + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {number} + * @memberof module:API.cvat.classes.Label + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * @name name + * @type {string} + * @memberof module:API.cvat.classes.Label + * @instance + */ + name: { + get: () => data.name, + set: (name) => { + if (typeof name !== 'string') { + throw new ArgumentError(`Name must be a string, but ${typeof name} was given`); + } + data.name = name; }, - /** - * @name color - * @type {string} - * @memberof module:API.cvat.classes.Label - * @instance - */ - color: { - get: () => data.color, - set: (color) => { - if (typeof color === 'string' && color.match(/^#[0-9a-f]{6}$|^$/)) { - data.color = color; - } else { - throw new ArgumentError('Trying to set wrong color format'); - } - }, + }, + /** + * @name color + * @type {string} + * @memberof module:API.cvat.classes.Label + * @instance + */ + color: { + get: () => data.color, + set: (color) => { + if (typeof color === 'string' && color.match(/^#[0-9a-f]{6}$|^$/)) { + data.color = color; + } else { + throw new ArgumentError('Trying to set wrong color format'); + } }, - /** - * @name attributes - * @type {module:API.cvat.classes.Attribute[]} - * @memberof module:API.cvat.classes.Label - * @readonly - * @instance - */ - attributes: { - get: () => [...data.attributes], + }, + /** + * @name attributes + * @type {module:API.cvat.classes.Attribute[]} + * @memberof module:API.cvat.classes.Label + * @readonly + * @instance + */ + attributes: { + get: () => [...data.attributes], + }, + /** + * @typedef {Object} SkeletonStructure + * @property {module:API.cvat.classes.Label[]} sublabels A list of labels the skeleton includes + * @property {Object[]} svg An SVG representation of the skeleton + * A type of a file + * @global + */ + /** + * @name type + * @type {string | undefined} + * @memberof module:API.cvat.classes.Label + * @readonly + * @instance + */ + type: { + get: () => data.type, + }, + /** + * @name type + * @type {SkeletonStructure | undefined} + * @memberof module:API.cvat.classes.Label + * @readonly + * @instance + */ + structure: { + get: () => { + if (data.type === ShapeType.SKELETON) { + return { + svg: data.svg, + sublabels: [...data.sublabels], + }; + } + + return null; }, - deleted: { - get: () => data.deleted, - set: (value) => { - data.deleted = value; - }, + }, + /** + * @name deleted + * @type {boolean} + * @memberof module:API.cvat.classes.Label + * @instance + */ + deleted: { + get: () => data.deleted, + set: (value) => { + data.deleted = value; }, - }), - ); + }, + /** + * @name hasParent + * @type {boolean} + * @memberof module:API.cvat.classes.Label + * @readonly + * @instance + */ + hasParent: { + get: () => data.has_parent, + }, + }), + ); + } + + toJSON(): RawLabel { + const object: RawLabel = { + name: this.name, + attributes: [...this.attributes.map((el) => el.toJSON())], + type: this.type, + }; + + if (typeof this.color !== 'undefined') { + object.color = this.color; } - toJSON() { - const object = { - name: this.name, - attributes: [...this.attributes.map((el) => el.toJSON())], - color: this.color, - }; + if (typeof this.id !== 'undefined') { + object.id = this.id; + } - if (typeof this.id !== 'undefined') { - object.id = this.id; - } + if (this.deleted) { + object.deleted = this.deleted; + } - if (this.deleted) { - object.deleted = this.deleted; - } + if (this.type) { + object.type = this.type; + } - return object; + const { structure } = this; + if (structure) { + object.svg = structure.svg; + object.sublabels = structure.sublabels.map((internalLabel) => internalLabel.toJSON()); } - } - module.exports = { - Attribute, - Label, - }; -})(); + return object; + } +} diff --git a/cvat-core/src/log.ts b/cvat-core/src/log.ts index 634b3fa8..ef06a3ac 100644 --- a/cvat-core/src/log.ts +++ b/cvat-core/src/log.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT const { detect } = require('detect-browser'); -const PluginRegistry = require('./plugins'); +const PluginRegistry = require('./plugins').default; const { ArgumentError } = require('./exceptions'); const { LogType } = require('./enums'); diff --git a/cvat-core/src/logger-storage.ts b/cvat-core/src/logger-storage.ts index d73dcf0d..530219f9 100644 --- a/cvat-core/src/logger-storage.ts +++ b/cvat-core/src/logger-storage.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -const PluginRegistry = require('./plugins'); +const PluginRegistry = require('./plugins').default; const serverProxy = require('./server-proxy'); const logFactory = require('./log'); const { ArgumentError } = require('./exceptions'); @@ -10,7 +10,7 @@ const { LogType } = require('./enums'); const WORKING_TIME_THRESHOLD = 100000; // ms, 1.66 min -function sleep(ms) { +function sleep(ms): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); diff --git a/cvat-core/src/object-state.ts b/cvat-core/src/object-state.ts index 4313872b..0814c8d9 100644 --- a/cvat-core/src/object-state.ts +++ b/cvat-core/src/object-state.ts @@ -2,503 +2,734 @@ // // SPDX-License-Identifier: MIT -const { Source } = require('./enums'); +import { Source, ShapeType, ObjectType } from './enums'; +import PluginRegistry from './plugins'; +import { ArgumentError } from './exceptions'; +import { Label } from './labels'; +import { isEnum } from './common'; -(() => { - const PluginRegistry = require('./plugins'); - const { ArgumentError } = require('./exceptions'); +interface UpdateFlags { + label: boolean; + attributes: boolean; + description: boolean; + points: boolean; + rotation: boolean; + outside: boolean; + occluded: boolean; + keyframe: boolean; + zOrder: boolean; + pinned: boolean; + lock: boolean; + color: boolean; + hidden: boolean; + descriptions: boolean; + reset: () => void; +} + +interface SerializedData { + objectType: ObjectType; + label: Label; + frame: number; + + shapeType?: ShapeType; + clientID?: number; + serverID?: number; + parentID?: number; + lock?: boolean; + hidden?: boolean; + pinned?: boolean; + attributes?: Record; + group?: { color: string; id: number; }; + color?: string; + updated?: number; + source?: Source; + zOrder?: number; + points?: number[]; + occluded?: boolean; + outside?: boolean; + keyframe?: boolean; + rotation?: number; + descriptions?: string[]; + keyframes?: { + prev: number | null; + next: number | null; + first: number | null; + last: number | null; + }; + elements?: SerializedData[]; + __internal: { + save: (objectState: ObjectState) => ObjectState; + delete: (frame: number, force: boolean) => boolean; + }; +} + +/** + * Class representing a state of an object on a specific frame + * @memberof module:API.cvat.classes +*/ +export default class ObjectState { + private readonly __internal: { + save: (objectState: ObjectState) => ObjectState; + delete: (frame: number, force: boolean) => boolean; + }; + + public readonly updateFlags: UpdateFlags; + public readonly frame: number; + public readonly objectType: ObjectType; + public readonly shapeType: ShapeType; + public readonly source: Source; + public readonly clientID: number | null; + public readonly serverID: number | null; + public readonly parentID: number | null; + public readonly updated: number; + public readonly group: { color: string; id: number; } | null; + public readonly keyframes: { + first: number | null; + prev: number | null; + next: number | null; + last: number | null; + } | null; + public label: Label; + public color: string; + public hidden: boolean; + public pinned: boolean; + public points: number[] | null; + public rotation: number | null; + public zOrder: number; + public outside: boolean; + public occluded: boolean; + public keyframe: boolean; + public lock: boolean; + public attributes: Record; + public descriptions: string[]; + public elements: ObjectState[]; /** - * Class representing a state of an object on a specific frame - * @memberof module:API.cvat.classes + * @param {Object} serialized - is an dictionary which contains + * initial information about an ObjectState; + *
Necessary fields: objectType, shapeType, frame, updated, group + *
Optional fields: keyframes, clientID, serverID, parentID + *
Optional fields which can be set later: points, zOrder, outside, + * occluded, hidden, attributes, lock, label, color, keyframe, source */ - class ObjectState { - /** - * @param {Object} serialized - is an dictionary which contains - * initial information about an ObjectState; - *
Necessary fields: objectType, shapeType, frame, updated, group - *
Optional fields: keyframes, clientID, serverID - *
Optional fields which can be set later: points, zOrder, outside, - * occluded, hidden, attributes, lock, label, color, keyframe, source - */ - constructor(serialized) { - const data = { - label: null, - attributes: {}, - descriptions: [], - - points: null, - rotation: null, - outside: null, - occluded: null, - keyframe: null, - - zOrder: null, - lock: null, - color: null, - hidden: null, - pinned: null, - source: Source.MANUAL, - keyframes: serialized.keyframes, - group: serialized.group, - updated: serialized.updated, - - clientID: serialized.clientID, - serverID: serialized.serverID, - - frame: serialized.frame, - objectType: serialized.objectType, - shapeType: serialized.shapeType, - updateFlags: {}, - }; - - // Shows whether any properties updated since last reset() or interpolation - Object.defineProperty(data.updateFlags, 'reset', { - value: function reset() { - this.label = false; - this.attributes = false; - this.descriptions = false; - - this.points = false; - this.outside = false; - this.occluded = false; - this.keyframe = false; - - this.zOrder = false; - this.pinned = false; - this.lock = false; - this.color = false; - this.hidden = false; - - return reset; + constructor(serialized: SerializedData) { + if (!isEnum.call(ObjectType, serialized.objectType)) { + throw new ArgumentError( + `ObjectState must be provided its objectType, got wrong value ${serialized.objectType}`, + ); + } + + if (!(serialized.label instanceof Label)) { + throw new ArgumentError( + `ObjectState must be provided correct Label, got wrong value ${serialized.label}`, + ); + } + + if (!Number.isInteger(serialized.frame)) { + throw new ArgumentError( + `ObjectState must be provided correct frame, got wrong value ${serialized.frame}`, + ); + } + + const updateFlags: UpdateFlags = {} as UpdateFlags; + // Shows whether any properties updated since the object initialization + Object.defineProperty(updateFlags, 'reset', { + value: function reset() { + this.label = false; + this.attributes = false; + this.descriptions = false; + + this.points = false; + this.rotation = false; + this.outside = false; + this.occluded = false; + this.keyframe = false; + + this.zOrder = false; + this.pinned = false; + this.lock = false; + this.color = false; + this.hidden = false; + this.descriptions = false; + + return reset; + }, + writable: false, + enumerable: false, + }); + + const data = { + label: serialized.label, + attributes: {}, + descriptions: [], + elements: Array.isArray(serialized.elements) ? + serialized.elements.map((element) => new ObjectState(element)) : null, + + points: null, + rotation: null, + outside: false, + occluded: false, + keyframe: true, + + zOrder: 0, + lock: serialized.lock || false, + color: '#000000', + hidden: false, + pinned: false, + source: Source.MANUAL, + keyframes: serialized.keyframes || null, + group: serialized.group || null, + updated: serialized.updated || Date.now(), + + clientID: serialized.clientID || null, + serverID: serialized.serverID || null, + parentID: serialized.parentID || null, + + frame: serialized.frame, + objectType: serialized.objectType, + shapeType: serialized.shapeType || null, + updateFlags, + }; + + Object.defineProperties( + this, + Object.freeze({ + // Internal property. We don't need document it. + updateFlags: { + get: () => data.updateFlags, }, - writable: false, - enumerable: false, - }); - - Object.defineProperties( - this, - Object.freeze({ - // Internal property. We don't need document it. - updateFlags: { - get: () => data.updateFlags, - }, - frame: { - /** - * @name frame - * @type {number} - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - */ - get: () => data.frame, - }, - objectType: { - /** - * @name objectType - * @type {module:API.cvat.enums.ObjectType} - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - */ - get: () => data.objectType, - }, - shapeType: { - /** - * @name shapeType - * @type {module:API.cvat.enums.ObjectShape} - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - */ - get: () => data.shapeType, - }, - source: { - /** - * @name source - * @type {module:API.cvat.enums.Source} - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - */ - get: () => data.source, - }, - clientID: { - /** - * @name clientID - * @type {number} - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - */ - get: () => data.clientID, - }, - serverID: { - /** - * @name serverID - * @type {number} - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - */ - get: () => data.serverID, + frame: { + /** + * @name frame + * @type {number} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.frame, + }, + objectType: { + /** + * @name objectType + * @type {module:API.cvat.enums.ObjectType} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.objectType, + }, + shapeType: { + /** + * @name shapeType + * @type {module:API.cvat.enums.ShapeType} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.shapeType, + }, + source: { + /** + * @name source + * @type {module:API.cvat.enums.Source} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.source, + }, + clientID: { + /** + * @name clientID + * @type {number} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.clientID, + }, + serverID: { + /** + * @name serverID + * @type {number} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.serverID, + }, + parentID: { + /** + * @name parentID + * @type {number | null} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.parentID, + }, + label: { + /** + * @name shape + * @type {module:API.cvat.classes.Label} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => data.label, + set: (labelInstance) => { + data.updateFlags.label = true; + data.label = labelInstance; }, - label: { - /** - * @name shape - * @type {module:API.cvat.classes.Label} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => data.label, - set: (labelInstance) => { - data.updateFlags.label = true; - data.label = labelInstance; - }, + }, + color: { + /** + * @name color + * @type {string} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => data.color, + set: (color) => { + data.updateFlags.color = true; + data.color = color; }, - color: { - /** - * @name color - * @type {string} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => data.color, - set: (color) => { - data.updateFlags.color = true; - data.color = color; - }, + }, + hidden: { + /** + * @name hidden + * @type {boolean} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => { + if (data.shapeType === ShapeType.SKELETON) { + return data.elements.every((element: ObjectState) => element.hidden); + } + + return data.hidden; }, - hidden: { - /** - * @name hidden - * @type {boolean} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => data.hidden, - set: (hidden) => { + set: (hidden) => { + if (data.shapeType === ShapeType.SKELETON) { + data.elements.forEach((element: ObjectState) => { + element.hidden = hidden; + }); + } else { data.updateFlags.hidden = true; data.hidden = hidden; - }, + } }, - points: { - /** - * @name points - * @type {number[]} - * @memberof module:API.cvat.classes.ObjectState - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - */ - get: () => data.points, - set: (points) => { - if (Array.isArray(points)) { - data.updateFlags.points = true; - data.points = [...points]; - } else { - throw new ArgumentError( - 'Points are expected to be an array ' + - `but got ${ - typeof points === 'object' ? points.constructor.name : typeof points - }`, - ); - } - }, + }, + points: { + /** + * @name points + * @type {number[]} + * @memberof module:API.cvat.classes.ObjectState + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + */ + get: () => { + if (data.shapeType === ShapeType.SKELETON) { + return data.elements.map((element) => element.points).flat(); + } + + if (Array.isArray(data.points)) { + return [...data.points]; + } + + return []; }, - rotation: { - /** - * @name rotation - * @description angle measured by degrees - * @type {number} - * @memberof module:API.cvat.classes.ObjectState - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - */ - get: () => data.rotation, - set: (rotation) => { - if (typeof rotation === 'number') { - data.updateFlags.points = true; - data.rotation = rotation; - } else { - throw new ArgumentError( - `Rotation is expected to be a number, but got ${ - typeof rotation === 'object' ? rotation.constructor.name : typeof points + set: (points) => { + if (!Array.isArray(points) || points.some((coord) => typeof coord !== 'number')) { + throw new ArgumentError( + 'Points are expected to be an array of numbers ' + + `but got ${ + typeof points === 'object' ? points.constructor.name : typeof points }`, + ); + } + + if (data.shapeType === ShapeType.SKELETON) { + const { points: currentPoints } = this; + if (points.length !== currentPoints.length) { + throw new ArgumentError( + 'Tried to set wrong number of points for a skeleton' + + `(${points.length} vs ${currentPoints.length}})`, ); } - }, + + const copy = points; + for (const element of this.elements) { + element.points = copy.splice(0, element.points.length); + } + } else { + data.updateFlags.points = true; + } + + data.points = [...points]; }, - group: { - /** - * Object with short group info { color, id } - * @name group - * @type {object} - * @memberof module:API.cvat.classes.ObjectState - * @instance - * @readonly - */ - get: () => data.group, + }, + rotation: { + /** + * @name rotation + * @description angle measured by degrees + * @type {number} + * @memberof module:API.cvat.classes.ObjectState + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + */ + get: () => data.rotation, + set: (rotation) => { + if (typeof rotation === 'number') { + if (rotation === data.rotation) return; + data.updateFlags.rotation = true; + data.rotation = rotation; + } else { + throw new ArgumentError( + `Rotation is expected to be a number, but got ${ + typeof rotation === 'object' ? rotation.constructor.name : typeof rotation + }`, + ); + } }, - zOrder: { - /** - * @name zOrder - * @type {integer | null} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => data.zOrder, - set: (zOrder) => { - data.updateFlags.zOrder = true; - data.zOrder = zOrder; - }, + }, + group: { + /** + * Object with short group info { color, id } + * @name group + * @type {object} + * @memberof module:API.cvat.classes.ObjectState + * @instance + * @readonly + */ + get: () => data.group, + }, + zOrder: { + /** + * @name zOrder + * @type {integer | null} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => data.zOrder, + set: (zOrder) => { + data.updateFlags.zOrder = true; + data.zOrder = zOrder; }, - outside: { - /** - * @name outside - * @type {boolean} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => data.outside, - set: (outside) => { - data.updateFlags.outside = true; + }, + outside: { + /** + * @name outside + * @type {boolean} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => { + if (data.shapeType === ShapeType.SKELETON) { + return data.elements.every((el) => el.outside); + } + return data.outside; + }, + set: (outside) => { + if (data.shapeType === ShapeType.SKELETON) { + for (const element of this.elements) { + element.outside = outside; + } + } else { data.outside = outside; - }, + data.updateFlags.outside = true; + } }, - keyframe: { - /** - * @name keyframe - * @type {boolean} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => data.keyframe, - set: (keyframe) => { - data.updateFlags.keyframe = true; - data.keyframe = keyframe; - }, + }, + keyframe: { + /** + * @name keyframe + * @type {boolean} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => { + if (data.shapeType === ShapeType.SKELETON) { + return data.keyframe || data.elements.some((el) => el.keyframe); + } + + return data.keyframe; }, - keyframes: { - /** - * Object of keyframes { first, prev, next, last } - * @name keyframes - * @type {object | null} - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - */ - get: () => { - if (typeof data.keyframes === 'object') { - return { ...data.keyframes }; + set: (keyframe) => { + if (data.shapeType === ShapeType.SKELETON) { + for (const element of this.elements) { + element.keyframe = keyframe; } + } - return null; - }, + data.updateFlags.keyframe = true; + data.keyframe = keyframe; }, - occluded: { - /** - * @name occluded - * @type {boolean} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => data.occluded, - set: (occluded) => { - data.updateFlags.occluded = true; + }, + keyframes: { + /** + * Object of keyframes { first, prev, next, last } + * @name keyframes + * @type {object | null} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => { + if (typeof data.keyframes === 'object') { + return { ...data.keyframes }; + } + + return null; + }, + }, + occluded: { + /** + * @name occluded + * @type {boolean} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => { + if (data.shapeType === ShapeType.SKELETON) { + return data.elements.every((el) => el.occluded); + } + return data.occluded; + }, + set: (occluded) => { + if (data.shapeType === ShapeType.SKELETON) { + for (const element of this.elements) { + element.occluded = occluded; + } + } else { data.occluded = occluded; - }, + data.updateFlags.occluded = true; + } }, - lock: { - /** - * @name lock - * @type {boolean} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => data.lock, - set: (lock) => { + }, + lock: { + /** + * @name lock + * @type {boolean} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => { + if (data.shapeType === ShapeType.SKELETON) { + return data.elements.every((el) => el.lock); + } + return data.lock; + }, + set: (lock) => { + if (data.shapeType === ShapeType.SKELETON) { + for (const element of this.elements) { + element.lock = lock; + } + } else { data.updateFlags.lock = true; data.lock = lock; - }, + } }, - pinned: { - /** - * @name pinned - * @type {boolean | null} - * @memberof module:API.cvat.classes.ObjectState - * @instance - */ - get: () => { - if (typeof data.pinned === 'boolean') { - return data.pinned; - } + }, + pinned: { + /** + * @name pinned + * @type {boolean | null} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => { + if (typeof data.pinned === 'boolean') { + return data.pinned; + } - return null; - }, - set: (pinned) => { - data.updateFlags.pinned = true; - data.pinned = pinned; - }, + return null; }, - updated: { - /** - * Timestamp of the latest updated of the object - * @name updated - * @type {number} - * @memberof module:API.cvat.classes.ObjectState - * @instance - * @readonly - */ - get: () => data.updated, + set: (pinned) => { + data.updateFlags.pinned = true; + data.pinned = pinned; }, - attributes: { - /** - * Object is id:value pairs where "id" is an integer - * attribute identifier and "value" is an attribute value - * @name attributes - * @type {Object} - * @memberof module:API.cvat.classes.ObjectState - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - */ - get: () => data.attributes, - set: (attributes) => { - if (typeof attributes !== 'object') { - throw new ArgumentError( - 'Attributes are expected to be an object ' + - `but got ${ - typeof attributes === 'object' ? - attributes.constructor.name : - typeof attributes - }`, - ); - } + }, + updated: { + /** + * Timestamp of the latest updated of the object + * @name updated + * @type {number} + * @memberof module:API.cvat.classes.ObjectState + * @instance + * @readonly + */ + get: () => data.updated, + }, + attributes: { + /** + * Object is id:value pairs where "id" is an integer + * attribute identifier and "value" is an attribute value + * @name attributes + * @type {Object} + * @memberof module:API.cvat.classes.ObjectState + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + */ + get: () => data.attributes, + set: (attributes) => { + if (typeof attributes !== 'object') { + throw new ArgumentError( + 'Attributes are expected to be an object ' + + `but got ${ + typeof attributes === 'object' ? + attributes.constructor.name : + typeof attributes + }`, + ); + } - for (const attrID of Object.keys(attributes)) { - data.updateFlags.attributes = true; - data.attributes[attrID] = attributes[attrID]; - } - }, + for (const attrID of Object.keys(attributes)) { + data.updateFlags.attributes = true; + data.attributes[attrID] = attributes[attrID]; + } }, - descriptions: { - /** - * Additional text information displayed on canvas - * @name descripttions - * @type {string[]} - * @memberof module:API.cvat.classes.ObjectState - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - */ - get: () => [...data.descriptions], - set: (descriptions) => { - if ( - !Array.isArray(descriptions) || - descriptions.some((description) => typeof description !== 'string') - ) { - throw new ArgumentError( - `Descriptions are expected to be an array of strings but got ${data.descriptions}`, - ); - } + }, + descriptions: { + /** + * Additional text information displayed on canvas + * @name descripttions + * @type {string[]} + * @memberof module:API.cvat.classes.ObjectState + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + */ + get: () => [...data.descriptions], + set: (descriptions) => { + if ( + !Array.isArray(descriptions) || + descriptions.some((description) => typeof description !== 'string') + ) { + throw new ArgumentError( + `Descriptions are expected to be an array of strings but got ${data.descriptions}`, + ); + } - data.updateFlags.descriptions = true; - data.descriptions = [...descriptions]; - }, + data.updateFlags.descriptions = true; + data.descriptions = [...descriptions]; }, - }), - ); + }, + elements: { + /** + * Returns a list of object states for compound objects (like skeletons) + * @name elements + * @type {string[]} + * @memberof module:API.cvat.classes.ObjectState + * @throws {module:API.cvat.exceptions.ArgumentError} + * @readonly + * @instance + */ + get: () => { + if (data.elements) { + return [...data.elements]; + } + return []; + }, + }, + }), + ); - this.label = serialized.label; - this.lock = serialized.lock; + if ([Source.MANUAL, Source.AUTO].includes(serialized.source)) { + data.source = serialized.source; + } + if (typeof serialized.zOrder === 'number') { + data.zOrder = serialized.zOrder; + } + if (typeof serialized.occluded === 'boolean') { + data.occluded = serialized.occluded; + } + if (typeof serialized.outside === 'boolean') { + data.outside = serialized.outside; + } + if (typeof serialized.keyframe === 'boolean') { + data.keyframe = serialized.keyframe; + } + if (typeof serialized.pinned === 'boolean') { + data.pinned = serialized.pinned; + } + if (typeof serialized.hidden === 'boolean') { + data.hidden = serialized.hidden; + } + if (typeof serialized.color === 'string') { + data.color = serialized.color; + } + if (typeof serialized.rotation === 'number') { + data.rotation = serialized.rotation; + } + if (Array.isArray(serialized.points)) { + data.points = serialized.points; + } + if ( + Array.isArray(serialized.descriptions) && + serialized.descriptions.every((desc) => typeof desc === 'string') + ) { + data.descriptions = serialized.descriptions; + } + if (typeof serialized.attributes === 'object') { + data.attributes = serialized.attributes; + } - if ([Source.MANUAL, Source.AUTO].includes(serialized.source)) { - data.source = serialized.source; - } - if (typeof serialized.zOrder === 'number') { - this.zOrder = serialized.zOrder; - } - if (typeof serialized.occluded === 'boolean') { - this.occluded = serialized.occluded; - } - if (typeof serialized.outside === 'boolean') { - this.outside = serialized.outside; - } - if (typeof serialized.keyframe === 'boolean') { - this.keyframe = serialized.keyframe; - } - if (typeof serialized.pinned === 'boolean') { - this.pinned = serialized.pinned; - } - if (typeof serialized.hidden === 'boolean') { - this.hidden = serialized.hidden; - } - if (typeof serialized.color === 'string') { - this.color = serialized.color; - } - if (typeof serialized.rotation === 'number') { - this.rotation = serialized.rotation; - } - if (Array.isArray(serialized.points)) { - this.points = serialized.points; - } - if ( - Array.isArray(serialized.descriptions) && - serialized.descriptions.every((desc) => typeof desc === 'string') - ) { - this.descriptions = serialized.descriptions; - } - if (typeof serialized.attributes === 'object') { - this.attributes = serialized.attributes; - } + data.updateFlags.reset(); - data.updateFlags.reset(); + /* eslint-disable-next-line no-underscore-dangle */ + if (serialized.__internal) { + /* eslint-disable-next-line no-underscore-dangle */ + this.__internal = serialized.__internal; } + } - /** - * Method saves/updates an object state in a collection - * @method save - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @returns {module:API.cvat.classes.ObjectState} updated state of an object - */ - async save() { - const result = await PluginRegistry.apiWrapper.call(this, ObjectState.prototype.save); - return result; - } + /** + * Method saves/updates an object state in a collection + * @method save + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @returns {module:API.cvat.classes.ObjectState} updated state of an object + */ + async save(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, ObjectState.prototype.save); + return result; + } - /** - * Method deletes an object from a collection - * @method delete - * @memberof module:API.cvat.classes.ObjectState - * @readonly - * @instance - * @param {integer} frame current frame number - * @param {boolean} [force=false] delete object even if it is locked - * @async - * @returns {boolean} true if object has been deleted - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - async delete(frame, force = false) { - const result = await PluginRegistry.apiWrapper.call(this, ObjectState.prototype.delete, frame, force); - return result; - } + /** + * Method deletes an object from a collection + * @method delete + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + * @param {integer} frame current frame number + * @param {boolean} [force=false] delete object even if it is locked + * @async + * @returns {boolean} true if object has been deleted + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + async delete(frame, force = false): Promise { + const result = await PluginRegistry.apiWrapper.call(this, ObjectState.prototype.delete, frame, force); + return result; } +} - // Updates element in collection which contains it - ObjectState.prototype.save.implementation = function () { +Object.defineProperty(ObjectState.prototype.save, 'implementation', { + value: function save(): ObjectState { if (this.__internal && this.__internal.save) { - return this.__internal.save(); + return this.__internal.save(this); } return this; - }; + }, + writable: false, +}); - // Delete element from a collection which contains it - ObjectState.prototype.delete.implementation = function (frame, force) { +Object.defineProperty(ObjectState.prototype.delete, 'implementation', { + value: function remove(frame: number, force: boolean): boolean { if (this.__internal && this.__internal.delete) { if (!Number.isInteger(+frame) || +frame < 0) { throw new ArgumentError('Frame argument must be a non negative integer'); @@ -508,7 +739,6 @@ const { Source } = require('./enums'); } return false; - }; - - module.exports = ObjectState; -})(); + }, + writable: false, +}); diff --git a/cvat-core/src/organization.ts b/cvat-core/src/organization.ts index e116415f..4fcf129b 100644 --- a/cvat-core/src/organization.ts +++ b/cvat-core/src/organization.ts @@ -6,7 +6,7 @@ const { checkObjectType, isEnum } = require('./common'); const config = require('./config'); const { MembershipRole } = require('./enums'); const { ArgumentError, ServerError } = require('./exceptions'); -const PluginRegistry = require('./plugins'); +const PluginRegistry = require('./plugins').default; const serverProxy = require('./server-proxy'); const User = require('./user'); diff --git a/cvat-core/src/plugins.ts b/cvat-core/src/plugins.ts index 351768f7..1a676a73 100644 --- a/cvat-core/src/plugins.ts +++ b/cvat-core/src/plugins.ts @@ -2,107 +2,103 @@ // // SPDX-License-Identifier: MIT -(() => { - const { PluginError } = require('./exceptions'); - - const plugins = []; - class PluginRegistry { - static async apiWrapper(wrappedFunc, ...args) { - // I have to optimize the wrapper - const pluginList = await PluginRegistry.list(); - for (const plugin of pluginList) { - const pluginDecorators = plugin.functions.filter((obj) => obj.callback === wrappedFunc)[0]; - if (pluginDecorators && pluginDecorators.enter) { - try { - await pluginDecorators.enter.call(this, plugin, ...args); - } catch (exception) { - if (exception instanceof PluginError) { - throw exception; - } else { - throw new PluginError(`Exception in plugin ${plugin.name}: ${exception.toString()}`); - } +import { PluginError } from './exceptions'; + +const plugins = []; +export default class PluginRegistry { + static async apiWrapper(wrappedFunc, ...args) { + // I have to optimize the wrapper + const pluginList = await PluginRegistry.list(); + for (const plugin of pluginList) { + const pluginDecorators = plugin.functions.filter((obj) => obj.callback === wrappedFunc)[0]; + if (pluginDecorators && pluginDecorators.enter) { + try { + await pluginDecorators.enter.call(this, plugin, ...args); + } catch (exception) { + if (exception instanceof PluginError) { + throw exception; + } else { + throw new PluginError(`Exception in plugin ${plugin.name}: ${exception.toString()}`); } } } + } - let result = await wrappedFunc.implementation.call(this, ...args); - - for (const plugin of pluginList) { - const pluginDecorators = plugin.functions.filter((obj) => obj.callback === wrappedFunc)[0]; - if (pluginDecorators && pluginDecorators.leave) { - try { - result = await pluginDecorators.leave.call(this, plugin, result, ...args); - } catch (exception) { - if (exception instanceof PluginError) { - throw exception; - } else { - throw new PluginError(`Exception in plugin ${plugin.name}: ${exception.toString()}`); - } + let result = await wrappedFunc.implementation.call(this, ...args); + + for (const plugin of pluginList) { + const pluginDecorators = plugin.functions.filter((obj) => obj.callback === wrappedFunc)[0]; + if (pluginDecorators && pluginDecorators.leave) { + try { + result = await pluginDecorators.leave.call(this, plugin, result, ...args); + } catch (exception) { + if (exception instanceof PluginError) { + throw exception; + } else { + throw new PluginError(`Exception in plugin ${plugin.name}: ${exception.toString()}`); } } } - - return result; } - // Called with cvat context - static async register(plug) { - const functions = []; + return result; + } - if (typeof plug !== 'object') { - throw new PluginError(`Plugin should be an object, but got "${typeof plug}"`); - } + // Called with cvat context + static async register(plug) { + const functions = []; - if (!('name' in plug) || typeof plug.name !== 'string') { - throw new PluginError('Plugin must contain a "name" field and it must be a string'); - } + if (typeof plug !== 'object') { + throw new PluginError(`Plugin should be an object, but got "${typeof plug}"`); + } - if (!('description' in plug) || typeof plug.description !== 'string') { - throw new PluginError('Plugin must contain a "description" field and it must be a string'); - } + if (!('name' in plug) || typeof plug.name !== 'string') { + throw new PluginError('Plugin must contain a "name" field and it must be a string'); + } - if ('functions' in plug) { - throw new PluginError('Plugin must not contain a "functions" field'); - } + if (!('description' in plug) || typeof plug.description !== 'string') { + throw new PluginError('Plugin must contain a "description" field and it must be a string'); + } + + if ('functions' in plug) { + throw new PluginError('Plugin must not contain a "functions" field'); + } - function traverse(plugin, api) { - const decorator = {}; - for (const key in plugin) { - if (Object.prototype.hasOwnProperty.call(plugin, key)) { - if (typeof plugin[key] === 'object') { - if (Object.prototype.hasOwnProperty.call(api, key)) { - traverse(plugin[key], api[key]); - } - } else if ( - ['enter', 'leave'].includes(key) && - typeof api === 'function' && - typeof (plugin[key] === 'function') - ) { - decorator.callback = api; - decorator[key] = plugin[key]; + function traverse(plugin, api) { + const decorator = {}; + for (const key in plugin) { + if (Object.prototype.hasOwnProperty.call(plugin, key)) { + if (typeof plugin[key] === 'object') { + if (Object.prototype.hasOwnProperty.call(api, key)) { + traverse(plugin[key], api[key]); } + } else if ( + ['enter', 'leave'].includes(key) && + typeof api === 'function' && + typeof (plugin[key] === 'function') + ) { + decorator.callback = api; + decorator[key] = plugin[key]; } } - - if (Object.keys(decorator).length) { - functions.push(decorator); - } } - traverse(plug, { cvat: this }); + if (Object.keys(decorator).length) { + functions.push(decorator); + } + } - Object.defineProperty(plug, 'functions', { - value: functions, - writable: false, - }); + traverse(plug, { cvat: this }); - plugins.push(plug); - } + Object.defineProperty(plug, 'functions', { + value: functions, + writable: false, + }); - static async list() { - return plugins; - } + plugins.push(plug); } - module.exports = PluginRegistry; -})(); + static async list() { + return plugins; + } +} diff --git a/cvat-core/src/project.ts b/cvat-core/src/project.ts index 94214d58..01e63e3a 100644 --- a/cvat-core/src/project.ts +++ b/cvat-core/src/project.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT (() => { - const PluginRegistry = require('./plugins'); + const PluginRegistry = require('./plugins').default; const { ArgumentError } = require('./exceptions'); const { Label } = require('./labels'); const User = require('./user'); @@ -48,10 +48,8 @@ data.labels = []; if (Array.isArray(initialData.labels)) { - for (const label of initialData.labels) { - const classInstance = new Label(label); - data.labels.push(classInstance); - } + data.labels = initialData.labels + .map((labelData) => new Label(labelData)).filter((label) => !label.hasParent); } if (typeof initialData.training_project === 'object') { diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 8bcda669..04ab37ad 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT (() => { - const PluginRegistry = require('./plugins'); + const PluginRegistry = require('./plugins').default; const loggerStorage = require('./logger-storage'); const serverProxy = require('./server-proxy'); const { @@ -830,7 +830,7 @@ } return new Label(labelData); - }); + }).filter((label) => !label.hasParent); } else { throw new Error('Job labels must be an array'); } @@ -1226,10 +1226,8 @@ }); if (Array.isArray(initialData.labels)) { - for (const label of initialData.labels) { - const classInstance = new Label(label); - data.labels.push(classInstance); - } + data.labels = initialData.labels + .map((labelData) => new Label(labelData)).filter((label) => !label.hasParent); } if (Array.isArray(initialData.segments)) { diff --git a/cvat-core/src/statistics.ts b/cvat-core/src/statistics.ts index fa85e8ae..50ff8d76 100644 --- a/cvat-core/src/statistics.ts +++ b/cvat-core/src/statistics.ts @@ -14,38 +14,42 @@ this, Object.freeze({ /** - * Statistics by labels with a structure: + * Statistics collected by labels, has the following structure: * @example * { * label: { - * boxes: { - * tracks: 10, - * shapes: 11, + * rectangle: { + * track: 10, + * shape: 11, * }, - * polygons: { - * tracks: 13, - * shapes: 14, + * polygon: { + * track: 13, + * shape: 14, * }, - * polylines: { - * tracks: 16, - * shapes: 17, + * polyline: { + * track: 16, + * shape: 17, * }, * points: { - * tracks: 19, - * shapes: 20, + * track: 19, + * shape: 20, * }, * ellipse: { - * tracks: 13, - * shapes: 15, + * track: 13, + * shape: 15, * }, - * cuboids: { - * tracks: 21, - * shapes: 22, + * cuboid: { + * track: 21, + * shape: 22, * }, - * tags: 66, - * manually: 186, + * skeleton: { + * track: 21, + * shape: 22, + * }, + * tag: 66, + * manually: 207, * interpolated: 500, - * total: 608, + * total: 630, * } * } * @name label @@ -58,22 +62,22 @@ get: () => JSON.parse(JSON.stringify(label)), }, /** - * Total statistics (covers all labels) with a structure: + * Total objects statistics (within all the labels), has the following structure: * @example * { - * boxes: { - * tracks: 10, - * shapes: 11, - * }, - * polygons: { - * tracks: 13, - * shapes: 14, - * }, - * polylines: { + * rectangle: { + * tracks: 10, + * shapes: 11, + * }, + * polygon: { + * tracks: 13, + * shapes: 14, + * }, + * polyline: { * tracks: 16, * shapes: 17, * }, - * points: { + * point: { * tracks: 19, * shapes: 20, * }, @@ -81,11 +85,15 @@ * tracks: 13, * shapes: 15, * }, - * cuboids: { + * cuboid: { + * tracks: 21, + * shapes: 22, + * }, + * skeleton: { * tracks: 21, * shapes: 22, * }, - * tags: 66, + * tag: 66, * manually: 186, * interpolated: 500, * total: 608, diff --git a/cvat-core/tests/api/annotations.js b/cvat-core/tests/api/annotations.js index 1fd5888e..47ba508b 100644 --- a/cvat-core/tests/api/annotations.js +++ b/cvat-core/tests/api/annotations.js @@ -119,7 +119,7 @@ describe('Feature: put annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 1, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: task.labels[0], @@ -141,7 +141,7 @@ describe('Feature: put annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 5, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.RECTANGLE, + shapeType: window.cvat.enums.ShapeType.RECTANGLE, points: [0, 0, 100, 100], occluded: false, label: job.labels[0], @@ -163,7 +163,7 @@ describe('Feature: put annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 5, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.ELLIPSE, + shapeType: window.cvat.enums.ShapeType.ELLIPSE, points: [500, 500, 800, 100], occluded: true, label: job.labels[0], @@ -185,7 +185,7 @@ describe('Feature: put annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 1, objectType: window.cvat.enums.ObjectType.TRACK, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: task.labels[0], @@ -207,7 +207,7 @@ describe('Feature: put annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 5, objectType: window.cvat.enums.ObjectType.TRACK, - shapeType: window.cvat.enums.ObjectShape.RECTANGLE, + shapeType: window.cvat.enums.ShapeType.RECTANGLE, points: [0, 0, 100, 100], occluded: false, label: job.labels[0], @@ -224,16 +224,14 @@ describe('Feature: put annotations', () => { test('put object without objectType to a task', async () => { const task = (await window.cvat.tasks.get({ id: 101 }))[0]; await task.annotations.clear(true); - const state = new window.cvat.classes.ObjectState({ + expect(() => new window.cvat.classes.ObjectState({ frame: 1, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: task.labels[0], zOrder: 0, - }); - - expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.ArgumentError); + })).toThrow(window.cvat.exceptions.ArgumentError); }); test('put shape with bad attributes to a task', async () => { @@ -242,7 +240,7 @@ describe('Feature: put annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 1, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], attributes: { 'bad key': 55 }, occluded: true, @@ -259,7 +257,7 @@ describe('Feature: put annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 1, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], attributes: { 'bad key': 55 }, occluded: true, @@ -272,7 +270,7 @@ describe('Feature: put annotations', () => { const state1 = new window.cvat.classes.ObjectState({ frame: 1, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], attributes: { 'bad key': 55 }, occluded: true, @@ -289,20 +287,19 @@ describe('Feature: put annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 1, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, occluded: true, - points: [], label: task.labels[0], zOrder: 0, }); - await expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.DataError); + expect(() => state.points = ['150,50 250,30']).toThrow(window.cvat.exceptions.ArgumentError); delete state.points; - await expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.DataError); + expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.DataError); - state.points = ['150,50 250,30']; - expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.ArgumentError); + state.points = []; + expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.DataError); }); test('put shape without type to a task', async () => { @@ -323,38 +320,35 @@ describe('Feature: put annotations', () => { test('put shape without label and with bad label to a task', async () => { const task = (await window.cvat.tasks.get({ id: 101 }))[0]; await task.annotations.clear(true); - const state = new window.cvat.classes.ObjectState({ + const state = { frame: 1, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, zOrder: 0, - }); - - await expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.ArgumentError); - - state.label = 'bad label'; - await expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.ArgumentError); + }; - state.label = {}; - await expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.ArgumentError); + expect(() => new window.cvat.classes.ObjectState(state)) + .toThrow(window.cvat.exceptions.ArgumentError); + expect(() => new window.cvat.classes.ObjectState({ ...state, label: 'bad label' })) + .toThrow(window.cvat.exceptions.ArgumentError); + expect(() => new window.cvat.classes.ObjectState({ ...state, label: {} })) + .toThrow(window.cvat.exceptions.ArgumentError); }); test('put shape with bad frame to a task', async () => { const task = (await window.cvat.tasks.get({ id: 101 }))[0]; await task.annotations.clear(true); - const state = new window.cvat.classes.ObjectState({ + expect(() => new window.cvat.classes.ObjectState({ frame: '5', objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: task.labels[0], zOrder: 0, - }); - - expect(task.annotations.put([state])).rejects.toThrow(window.cvat.exceptions.ArgumentError); + })).toThrow(window.cvat.exceptions.ArgumentError); }); }); @@ -389,7 +383,7 @@ describe('Feature: save annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 0, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: task.labels[0], @@ -411,7 +405,7 @@ describe('Feature: save annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 0, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: task.labels[0], @@ -457,7 +451,7 @@ describe('Feature: save annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 0, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: job.labels[0], @@ -574,7 +568,7 @@ describe('Feature: merge annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 0, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: task.labels[0], @@ -602,7 +596,7 @@ describe('Feature: merge annotations', () => { const task = (await window.cvat.tasks.get({ id: 100 }))[0]; const annotations0 = await task.annotations.get(0); const annotations1 = (await task.annotations.get(1)).filter( - (state) => state.shapeType === window.cvat.enums.ObjectShape.POLYGON, + (state) => state.shapeType === window.cvat.enums.ShapeType.POLYGON, ); const states = [annotations0[0], annotations1[0]]; @@ -691,7 +685,7 @@ describe('Feature: group annotations', () => { const state = new window.cvat.classes.ObjectState({ frame: 0, objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.POLYGON, + shapeType: window.cvat.enums.ShapeType.POLYGON, points: [0, 0, 100, 0, 100, 50], occluded: true, label: task.labels[0], @@ -783,18 +777,18 @@ describe('Feature: select object', () => { const task = (await window.cvat.tasks.get({ id: 100 }))[0]; const annotations = await task.annotations.get(0); let result = await task.annotations.select(annotations, 1430, 765); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.RECTANGLE); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.RECTANGLE); result = await task.annotations.select(annotations, 1415, 765); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.POLYGON); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.POLYGON); expect(result.state.points).toHaveLength(10); result = await task.annotations.select(annotations, 1083, 543); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.POINTS); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.POINTS); expect(result.state.points).toHaveLength(16); result = await task.annotations.select(annotations, 613, 811); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.POLYGON); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.POLYGON); expect(result.state.points).toHaveLength(94); result = await task.annotations.select(annotations, 600, 900); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.CUBOID); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.CUBOID); expect(result.state.points).toHaveLength(16); }); @@ -802,16 +796,16 @@ describe('Feature: select object', () => { const job = (await window.cvat.jobs.get({ jobID: 100 }))[0]; const annotations = await job.annotations.get(0); let result = await job.annotations.select(annotations, 490, 540); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.RECTANGLE); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.RECTANGLE); result = await job.annotations.select(annotations, 430, 260); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.POLYLINE); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.POLYLINE); result = await job.annotations.select(annotations, 1473, 250); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.RECTANGLE); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.RECTANGLE); result = await job.annotations.select(annotations, 1490, 237); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.POLYGON); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.POLYGON); expect(result.state.points).toHaveLength(94); result = await job.annotations.select(annotations, 600, 900); - expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.CUBOID); + expect(result.state.shapeType).toBe(window.cvat.enums.ShapeType.CUBOID); expect(result.state.points).toHaveLength(16); }); diff --git a/cvat-core/tests/api/object-state.js b/cvat-core/tests/api/object-state.js index 32ef1973..977aaace 100644 --- a/cvat-core/tests/api/object-state.js +++ b/cvat-core/tests/api/object-state.js @@ -14,8 +14,9 @@ window.cvat = require('../../src/api'); describe('Feature: set attributes for an object state', () => { test('set a valid value', () => { const state = new window.cvat.classes.ObjectState({ + label: new window.cvat.classes.Label({ name: 'test label', id: 1, color: '#000000', attributes: [] }), objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.RECTANGLE, + shapeType: window.cvat.enums.ShapeType.RECTANGLE, frame: 5, }); @@ -30,8 +31,9 @@ describe('Feature: set attributes for an object state', () => { test('trying to set a bad value', () => { const state = new window.cvat.classes.ObjectState({ + label: new window.cvat.classes.Label({ name: 'test label', id: 1, color: '#000000', attributes: [] }), objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.RECTANGLE, + shapeType: window.cvat.enums.ShapeType.RECTANGLE, frame: 5, }); @@ -55,8 +57,9 @@ describe('Feature: set attributes for an object state', () => { describe('Feature: set points for an object state', () => { test('set a valid value', () => { const state = new window.cvat.classes.ObjectState({ + label: new window.cvat.classes.Label({ name: 'test label', id: 1, color: '#000000', attributes: [] }), objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.RECTANGLE, + shapeType: window.cvat.enums.ShapeType.RECTANGLE, frame: 5, }); @@ -67,8 +70,9 @@ describe('Feature: set points for an object state', () => { test('trying to set a bad value', () => { const state = new window.cvat.classes.ObjectState({ + label: new window.cvat.classes.Label({ name: 'test label', id: 1, color: '#000000', attributes: [] }), objectType: window.cvat.enums.ObjectType.SHAPE, - shapeType: window.cvat.enums.ObjectShape.RECTANGLE, + shapeType: window.cvat.enums.ShapeType.RECTANGLE, frame: 5, }); @@ -100,7 +104,7 @@ describe('Feature: save object from its state', () => { const annotations = await task.annotations.get(0); let state = annotations[0]; expect(state.objectType).toBe(window.cvat.enums.ObjectType.SHAPE); - expect(state.shapeType).toBe(window.cvat.enums.ObjectShape.RECTANGLE); + expect(state.shapeType).toBe(window.cvat.enums.ShapeType.RECTANGLE); state.points = [0, 0, 100, 100]; state.occluded = true; [, state.label] = task.labels; @@ -118,7 +122,7 @@ describe('Feature: save object from its state', () => { const annotations = await task.annotations.get(10); let state = annotations[1]; expect(state.objectType).toBe(window.cvat.enums.ObjectType.TRACK); - expect(state.shapeType).toBe(window.cvat.enums.ObjectShape.RECTANGLE); + expect(state.shapeType).toBe(window.cvat.enums.ShapeType.RECTANGLE); state.occluded = true; state.lock = true; @@ -163,12 +167,9 @@ describe('Feature: save object from its state', () => { state.occluded = 'false'; await expect(state.save()).rejects.toThrow(window.cvat.exceptions.ArgumentError); - const oldPoints = state.points; state.occluded = false; - state.points = ['100', '50', '100', {}]; - await expect(state.save()).rejects.toThrow(window.cvat.exceptions.ArgumentError); + expect(() => state.points = ['100', '50', '100', {}]).toThrow(window.cvat.exceptions.ArgumentError); - state.points = oldPoints; state.lock = 'true'; await expect(state.save()).rejects.toThrow(window.cvat.exceptions.ArgumentError); @@ -190,12 +191,9 @@ describe('Feature: save object from its state', () => { state.occluded = 'false'; await expect(state.save()).rejects.toThrow(window.cvat.exceptions.ArgumentError); - const oldPoints = state.points; state.occluded = false; - state.points = ['100', '50', '100', {}]; - await expect(state.save()).rejects.toThrow(window.cvat.exceptions.ArgumentError); + expect(() => state.points = ['100', '50', '100', {}]).toThrow(window.cvat.exceptions.ArgumentError); - state.points = oldPoints; state.lock = 'true'; await expect(state.save()).rejects.toThrow(window.cvat.exceptions.ArgumentError); diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 1b52e551..83d516c9 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.40.1", + "version": "1.41.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { @@ -33,7 +33,7 @@ "@types/react-share": "^3.0.3", "@types/redux-logger": "^3.0.9", "@types/resize-observer-browser": "^0.1.6", - "antd": "~4.21.6", + "antd": "~4.18.9", "copy-to-clipboard": "^3.3.1", "cvat-canvas": "file:../cvat-canvas", "cvat-canvas3d": "file:../cvat-canvas3d", diff --git a/cvat-ui/src/actions/about-actions.ts b/cvat-ui/src/actions/about-actions.ts index c65f89f7..eab65ca1 100644 --- a/cvat-ui/src/actions/about-actions.ts +++ b/cvat-ui/src/actions/about-actions.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; const core = getCore(); diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 52e21120..90bcc1cf 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -11,7 +11,7 @@ import { CanvasMode as Canvas3DMode } from 'cvat-canvas3d-wrapper'; import { RectDrawingMethod, CuboidDrawingMethod, Canvas, CanvasMode as Canvas2DMode, } from 'cvat-canvas-wrapper'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; import logger, { LogType } from 'cvat-logger'; import { getCVATStore } from 'cvat-store'; @@ -28,7 +28,7 @@ import { ShapeType, Task, Workspace, -} from 'reducers/interfaces'; +} from 'reducers'; import { updateJobAsync } from './tasks-actions'; import { switchToolsBlockerState } from './settings-actions'; @@ -109,7 +109,7 @@ async function jobInfoGenerator(job: any): Promise> { 'polyline count': total.polyline.shape + total.polyline.track, 'points count': total.points.shape + total.points.track, 'cuboids count': total.cuboid.shape + total.cuboid.track, - 'tag count': total.tags, + 'tag count': total.tag, }; } @@ -464,22 +464,26 @@ export function showFilters(visible: boolean): AnyAction { export function propagateObjectAsync(sessionInstance: any, objectState: any, from: number, to: number): ThunkAction { return async (dispatch: ActionCreator): Promise => { try { - const copy = { - attributes: objectState.attributes, - points: objectState.points, - occluded: objectState.occluded, - objectType: objectState.objectType !== ObjectType.TRACK ? objectState.objectType : ObjectType.SHAPE, - shapeType: objectState.shapeType, - label: objectState.label, - zOrder: objectState.zOrder, + const getCopyFromState = (_objectState: any): any => ({ + attributes: _objectState.attributes, + points: _objectState.shapeType === 'skeleton' ? null : _objectState.points, + occluded: _objectState.occluded, + objectType: _objectState.objectType !== ObjectType.TRACK ? _objectState.objectType : ObjectType.SHAPE, + shapeType: _objectState.shapeType, + label: _objectState.label, + zOrder: _objectState.zOrder, frame: from, - source: objectState.source, - }; + elements: _objectState.shapeType === 'skeleton' ? _objectState.elements + .map((element: any): any => getCopyFromState(element)) : [], + source: _objectState.source, + }); + const copy = getCopyFromState(objectState); await sessionInstance.logger.log(LogType.propagateObject, { count: to - from + 1 }); const states = []; for (let frame = from; frame <= to; frame++) { copy.frame = frame; + copy.elements.forEach((element: any) => { element.frame = frame; }); const newState = new cvat.classes.ObjectState(copy); states.push(newState); } @@ -585,11 +589,16 @@ export function copyShape(objectState: any): AnyAction { }; } -export function activateObject(activatedStateID: number | null, activatedAttributeID: number | null): AnyAction { +export function activateObject( + activatedStateID: number | null, + activatedElementID: number | null, + activatedAttributeID: number | null, +): AnyAction { return { type: AnnotationActionTypes.ACTIVATE_OBJECT, payload: { activatedStateID, + activatedElementID, activatedAttributeID, }, }; @@ -1263,11 +1272,18 @@ export function updateAnnotationsAsync(statesToUpdate: any[]): ThunkAction { try { if (statesToUpdate.some((state: any): boolean => state.updateFlags.zOrder)) { // deactivate object to visualize changes immediately (UX) - dispatch(activateObject(null, null)); + dispatch(activateObject(null, null, null)); } const promises = statesToUpdate.map((objectState: any): Promise => objectState.save()); const states = await Promise.all(promises); + + const withSkeletonElements = states.some((state: any) => state.parentID !== null); + if (withSkeletonElements) { + dispatch(fetchAnnotationsAsync()); + return; + } + const history = await jobInstance.actions.get(); const [minZ, maxZ] = computeZRange(states); @@ -1418,12 +1434,12 @@ export function changeGroupColorAsync(group: number, color: string): ThunkAction const groupStates = state.annotation.annotations.states.filter( (_state: any): boolean => _state.group.id === group, ); - if (groupStates.length) { - groupStates[0].group.color = color; - dispatch(updateAnnotationsAsync(groupStates)); - } else { - dispatch(updateAnnotationsAsync([])); + + for (const objectState of groupStates) { + objectState.group.color = color; } + + dispatch(updateAnnotationsAsync(groupStates)); }; } @@ -1511,7 +1527,7 @@ export function pasteShapeAsync(): ThunkAction { drawing: { activeInitialState: initialState }, } = getStore().getState().annotation; - if (initialState) { + if (initialState && canvasInstance) { let activeControl = ActiveControl.CURSOR; if (initialState.shapeType === ShapeType.RECTANGLE) { activeControl = ActiveControl.DRAW_RECTANGLE; @@ -1523,6 +1539,10 @@ export function pasteShapeAsync(): ThunkAction { activeControl = ActiveControl.DRAW_POLYLINE; } else if (initialState.shapeType === ShapeType.CUBOID) { activeControl = ActiveControl.DRAW_CUBOID; + } else if (initialState.shapeType === ShapeType.ELLIPSE) { + activeControl = ActiveControl.DRAW_ELLIPSE; + } else if (initialState.shapeType === ShapeType.SKELETON) { + activeControl = ActiveControl.DRAW_SKELETON; } dispatch({ @@ -1531,9 +1551,8 @@ export function pasteShapeAsync(): ThunkAction { activeControl, }, }); - if (canvasInstance instanceof Canvas) { - canvasInstance.cancel(); - } + canvasInstance.cancel(); + if (initialState.objectType === ObjectType.TAG) { const objectState = new cvat.classes.ObjectState({ objectType: ObjectType.TAG, @@ -1546,6 +1565,8 @@ export function pasteShapeAsync(): ThunkAction { canvasInstance.draw({ enabled: true, initialState, + ...(initialState.shapeType === ShapeType.SKELETON ? + { skeletonSVG: initialState.label.structure.svg } : {}), }); } } @@ -1614,6 +1635,8 @@ export function repeatDrawShapeAsync(): ThunkAction { activeControl = ActiveControl.DRAW_CUBOID; } else if (activeShapeType === ShapeType.ELLIPSE) { activeControl = ActiveControl.DRAW_ELLIPSE; + } else if (activeShapeType === ShapeType.SKELETON) { + activeControl = ActiveControl.DRAW_SKELETON; } dispatch({ @@ -1627,6 +1650,11 @@ export function repeatDrawShapeAsync(): ThunkAction { canvasInstance.cancel(); } + const [activeLabel] = labels.filter((label: any) => label.id === activeLabelID); + if (!activeLabel) { + throw new Error(`Label with ID ${activeLabelID}, was not found`); + } + if (activeObjectType === ObjectType.TAG) { const tags = states.filter((objectState: any): boolean => objectState.objectType === ObjectType.TAG); if (tags.every((objectState: any): boolean => objectState.label.id !== activeLabelID)) { @@ -1645,6 +1673,7 @@ export function repeatDrawShapeAsync(): ThunkAction { numberOfPoints: activeNumOfPoints, shapeType: activeShapeType, crosshair: [ShapeType.RECTANGLE, ShapeType.CUBOID, ShapeType.ELLIPSE].includes(activeShapeType), + skeletonSVG: activeShapeType === ShapeType.SKELETON ? activeLabel.structure.svg : undefined, }); } }; @@ -1671,6 +1700,8 @@ export function redrawShapeAsync(): ThunkAction { activeControl = ActiveControl.DRAW_POLYLINE; } else if (state.shapeType === ShapeType.CUBOID) { activeControl = ActiveControl.DRAW_CUBOID; + } else if (state.shapeType === ShapeType.SKELETON) { + activeControl = ActiveControl.DRAW_SKELETON; } dispatch({ diff --git a/cvat-ui/src/actions/auth-actions.ts b/cvat-ui/src/actions/auth-actions.ts index 17d9ffb0..bc908253 100644 --- a/cvat-ui/src/actions/auth-actions.ts +++ b/cvat-ui/src/actions/auth-actions.ts @@ -4,7 +4,7 @@ import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; import { UserConfirmation } from 'components/register-page/register-form'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; import isReachable from 'utils/url-checker'; const cvat = getCore(); diff --git a/cvat-ui/src/actions/boundaries-actions.ts b/cvat-ui/src/actions/boundaries-actions.ts index 31b0a2da..28b30327 100644 --- a/cvat-ui/src/actions/boundaries-actions.ts +++ b/cvat-ui/src/actions/boundaries-actions.ts @@ -5,7 +5,7 @@ import { ActionUnion, createAction, ThunkAction, ThunkDispatch, } from 'utils/redux'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; import { LogType } from 'cvat-logger'; import { computeZRange } from './annotation-actions'; diff --git a/cvat-ui/src/actions/cloud-storage-actions.ts b/cvat-ui/src/actions/cloud-storage-actions.ts index 051bf456..ef438afe 100644 --- a/cvat-ui/src/actions/cloud-storage-actions.ts +++ b/cvat-ui/src/actions/cloud-storage-actions.ts @@ -4,8 +4,8 @@ import { Dispatch, ActionCreator } from 'redux'; import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import getCore from 'cvat-core-wrapper'; -import { CloudStoragesQuery, CloudStorage, Indexable } from 'reducers/interfaces'; +import { getCore } from 'cvat-core-wrapper'; +import { CloudStoragesQuery, CloudStorage, Indexable } from 'reducers'; const cvat = getCore(); diff --git a/cvat-ui/src/actions/formats-actions.ts b/cvat-ui/src/actions/formats-actions.ts index 58e8f442..575d98dc 100644 --- a/cvat-ui/src/actions/formats-actions.ts +++ b/cvat-ui/src/actions/formats-actions.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; const cvat = getCore(); diff --git a/cvat-ui/src/actions/import-actions.ts b/cvat-ui/src/actions/import-actions.ts index a12bb31b..94fcd562 100644 --- a/cvat-ui/src/actions/import-actions.ts +++ b/cvat-ui/src/actions/import-actions.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import { createAction, ActionUnion, ThunkAction } from 'utils/redux'; -import { CombinedState } from 'reducers/interfaces'; +import { CombinedState } from 'reducers'; import { getProjectsAsync } from './projects-actions'; export enum ImportActionTypes { diff --git a/cvat-ui/src/actions/jobs-actions.ts b/cvat-ui/src/actions/jobs-actions.ts index 7c333e1a..5f982fb2 100644 --- a/cvat-ui/src/actions/jobs-actions.ts +++ b/cvat-ui/src/actions/jobs-actions.ts @@ -3,8 +3,8 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import getCore from 'cvat-core-wrapper'; -import { Indexable, JobsQuery } from 'reducers/interfaces'; +import { getCore } from 'cvat-core-wrapper'; +import { Indexable, JobsQuery } from 'reducers'; const cvat = getCore(); diff --git a/cvat-ui/src/actions/models-actions.ts b/cvat-ui/src/actions/models-actions.ts index a09414d2..dae9d451 100644 --- a/cvat-ui/src/actions/models-actions.ts +++ b/cvat-ui/src/actions/models-actions.ts @@ -3,8 +3,8 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import { Model, ActiveInference, RQStatus } from 'reducers/interfaces'; -import getCore from 'cvat-core-wrapper'; +import { Model, ActiveInference, RQStatus } from 'reducers'; +import { getCore } from 'cvat-core-wrapper'; export enum ModelsActionTypes { GET_MODELS = 'GET_MODELS', diff --git a/cvat-ui/src/actions/organization-actions.ts b/cvat-ui/src/actions/organization-actions.ts index c0a655ab..4a2c7293 100644 --- a/cvat-ui/src/actions/organization-actions.ts +++ b/cvat-ui/src/actions/organization-actions.ts @@ -4,7 +4,7 @@ import { Store } from 'antd/lib/form/interface'; import { User } from 'components/task-page/user-selector'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; const core = getCore(); diff --git a/cvat-ui/src/actions/plugins-actions.ts b/cvat-ui/src/actions/plugins-actions.ts index e5d18808..49006023 100644 --- a/cvat-ui/src/actions/plugins-actions.ts +++ b/cvat-ui/src/actions/plugins-actions.ts @@ -3,8 +3,8 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import { PluginsList } from 'reducers/interfaces'; -import getCore from '../cvat-core-wrapper'; +import { PluginsList } from 'reducers'; +import { getCore } from 'cvat-core-wrapper'; const core = getCore(); diff --git a/cvat-ui/src/actions/projects-actions.ts b/cvat-ui/src/actions/projects-actions.ts index 198c2044..f06da0f1 100644 --- a/cvat-ui/src/actions/projects-actions.ts +++ b/cvat-ui/src/actions/projects-actions.ts @@ -7,10 +7,10 @@ import { Dispatch, ActionCreator } from 'redux'; import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; import { ProjectsQuery, TasksQuery, CombinedState, Indexable, -} from 'reducers/interfaces'; +} from 'reducers'; import { getTasksAsync } from 'actions/tasks-actions'; import { getCVATStore } from 'cvat-store'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; const cvat = getCore(); diff --git a/cvat-ui/src/actions/review-actions.ts b/cvat-ui/src/actions/review-actions.ts index a9b2af7a..4db2dc32 100644 --- a/cvat-ui/src/actions/review-actions.ts +++ b/cvat-ui/src/actions/review-actions.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; const cvat = getCore(); diff --git a/cvat-ui/src/actions/settings-actions.ts b/cvat-ui/src/actions/settings-actions.ts index 40e2da00..70e980e5 100644 --- a/cvat-ui/src/actions/settings-actions.ts +++ b/cvat-ui/src/actions/settings-actions.ts @@ -5,7 +5,7 @@ import { AnyAction } from 'redux'; import { GridColor, ColorBy, SettingsState, ToolsBlockerState, -} from 'reducers/interfaces'; +} from 'reducers'; export enum SettingsActionTypes { SWITCH_ROTATE_ALL = 'SWITCH_ROTATE_ALL', @@ -24,6 +24,7 @@ export enum SettingsActionTypes { SWITCH_RESET_ZOOM = 'SWITCH_RESET_ZOOM', SWITCH_SMOOTH_IMAGE = 'SWITCH_SMOOTH_IMAGE', SWITCH_TEXT_FONT_SIZE = 'SWITCH_TEXT_FONT_SIZE', + SWITCH_CONTROL_POINTS_SIZE = 'SWITCH_CONTROL_POINTS_SIZE', SWITCH_TEXT_POSITION = 'SWITCH_TEXT_POSITION', SWITCH_TEXT_CONTENT = 'SWITCH_TEXT_CONTENT', CHANGE_BRIGHTNESS_LEVEL = 'CHANGE_BRIGHTNESS_LEVEL', @@ -190,6 +191,15 @@ export function switchTextFontSize(fontSize: number): AnyAction { }; } +export function switchControlPointsSize(pointsSize: number): AnyAction { + return { + type: SettingsActionTypes.SWITCH_CONTROL_POINTS_SIZE, + payload: { + controlPointsSize: pointsSize, + }, + }; +} + export function switchTextPosition(position: 'auto' | 'center'): AnyAction { return { type: SettingsActionTypes.SWITCH_TEXT_POSITION, @@ -199,11 +209,11 @@ export function switchTextPosition(position: 'auto' | 'center'): AnyAction { }; } -export function switchTextContent(textContent: string): AnyAction { +export function switchTextContent(textContent: string[]): AnyAction { return { type: SettingsActionTypes.SWITCH_TEXT_CONTENT, payload: { - textContent, + textContent: textContent.join(','), }, }; } diff --git a/cvat-ui/src/actions/share-actions.ts b/cvat-ui/src/actions/share-actions.ts index ab857ccd..25a4aed5 100644 --- a/cvat-ui/src/actions/share-actions.ts +++ b/cvat-ui/src/actions/share-actions.ts @@ -3,9 +3,9 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; -import { ShareFileInfo } from 'reducers/interfaces'; +import { ShareFileInfo } from 'reducers'; const core = getCore(); diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index bd86479a..cb966775 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -4,9 +4,9 @@ import { AnyAction, Dispatch, ActionCreator } from 'redux'; import { ThunkAction } from 'redux-thunk'; -import { TasksQuery, CombinedState, Indexable } from 'reducers/interfaces'; +import { TasksQuery, CombinedState, Indexable } from 'reducers'; import { getCVATStore } from 'cvat-store'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; import { getInferenceStatusAsync } from './models-actions'; const cvat = getCore(); diff --git a/cvat-ui/src/actions/useragreements-actions.ts b/cvat-ui/src/actions/useragreements-actions.ts index ae8feb71..279b7303 100644 --- a/cvat-ui/src/actions/useragreements-actions.ts +++ b/cvat-ui/src/actions/useragreements-actions.ts @@ -3,8 +3,8 @@ // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import getCore from 'cvat-core-wrapper'; -import { UserAgreement } from 'reducers/interfaces'; +import { getCore } from 'cvat-core-wrapper'; +import { UserAgreement } from 'reducers'; const core = getCore(); diff --git a/cvat-ui/src/assets/point-icon.svg b/cvat-ui/src/assets/point-icon.svg index de8b5b35..681ff260 100644 --- a/cvat-ui/src/assets/point-icon.svg +++ b/cvat-ui/src/assets/point-icon.svg @@ -1 +1,8 @@ - \ No newline at end of file + + + + + + + + diff --git a/cvat-ui/src/assets/rotate-icon (copy).svg b/cvat-ui/src/assets/rotate-icon (copy).svg new file mode 100644 index 00000000..ce7fd880 --- /dev/null +++ b/cvat-ui/src/assets/rotate-icon (copy).svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/cvat-ui/src/assets/skeleton-icon.svg b/cvat-ui/src/assets/skeleton-icon.svg new file mode 100644 index 00000000..fd1a6408 --- /dev/null +++ b/cvat-ui/src/assets/skeleton-icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index 20fb7acf..0282aedf 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -11,7 +11,7 @@ import { LoadingOutlined } from '@ant-design/icons'; import { MenuInfo } from 'rc-menu/lib/interface'; import LoadSubmenu from './load-submenu'; -import { DimensionType } from '../../reducers/interfaces'; +import { DimensionType } from '../../reducers'; interface Props { taskID: number; diff --git a/cvat-ui/src/components/actions-menu/load-submenu.tsx b/cvat-ui/src/components/actions-menu/load-submenu.tsx index 801ed15f..feda16b5 100644 --- a/cvat-ui/src/components/actions-menu/load-submenu.tsx +++ b/cvat-ui/src/components/actions-menu/load-submenu.tsx @@ -8,7 +8,7 @@ import Upload from 'antd/lib/upload'; import Button from 'antd/lib/button'; import Text from 'antd/lib/typography/Text'; import { UploadOutlined, LoadingOutlined } from '@ant-design/icons'; -import { DimensionType } from '../../reducers/interfaces'; +import { DimensionType } from '../../reducers'; interface Props { menuKey: string; diff --git a/cvat-ui/src/components/annotation-page/annotation-page.tsx b/cvat-ui/src/components/annotation-page/annotation-page.tsx index cf89f225..7cb79b70 100644 --- a/cvat-ui/src/components/annotation-page/annotation-page.tsx +++ b/cvat-ui/src/components/annotation-page/annotation-page.tsx @@ -17,7 +17,7 @@ import TagAnnotationWorkspace from 'components/annotation-page/tag-annotation-wo import FiltersModalComponent from 'components/annotation-page/top-bar/filters-modal'; import StatisticsModalComponent from 'components/annotation-page/top-bar/statistics-modal'; import AnnotationTopBarContainer from 'containers/annotation-page/top-bar/top-bar'; -import { Workspace } from 'reducers/interfaces'; +import { Workspace } from 'reducers'; import { usePrevious } from 'utils/hooks'; import './styles.scss'; import Button from 'antd/lib/button'; diff --git a/cvat-ui/src/components/annotation-page/appearance-block.tsx b/cvat-ui/src/components/annotation-page/appearance-block.tsx index 68642a69..3e0edf33 100644 --- a/cvat-ui/src/components/annotation-page/appearance-block.tsx +++ b/cvat-ui/src/components/annotation-page/appearance-block.tsx @@ -14,7 +14,7 @@ import Button from 'antd/lib/button'; import ColorPicker from 'components/annotation-page/standard-workspace/objects-side-bar/color-picker'; import { ColorizeIcon } from 'icons'; -import { ColorBy, CombinedState, DimensionType } from 'reducers/interfaces'; +import { ColorBy, CombinedState, DimensionType } from 'reducers'; import { collapseAppearance as collapseAppearanceAction } from 'actions/annotation-actions'; import { changeShapesColorBy as changeShapesColorByAction, diff --git a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx index 03a930ce..ce27851c 100644 --- a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx +++ b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx @@ -23,7 +23,7 @@ import { ThunkDispatch } from 'utils/redux'; import AppearanceBlock from 'components/annotation-page/appearance-block'; import ObjectButtonsContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-buttons'; import { adjustContextImagePosition } from 'components/annotation-page/standard-workspace/context-image/context-image'; -import { CombinedState, ObjectType } from 'reducers/interfaces'; +import { CombinedState, ObjectType } from 'reducers'; import AttributeEditor from './attribute-editor'; import AttributeSwitcher from './attribute-switcher'; import ObjectBasicsEditor from './object-basics-edtior'; @@ -84,7 +84,7 @@ function mapStateToProps(state: CombinedState): StateToProps { function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps { return { activateObject(clientID: number, attrID: number): void { - dispatch(activateObjectAction(clientID, attrID)); + dispatch(activateObjectAction(clientID, null, attrID)); }, updateAnnotations(states): void { dispatch(updateAnnotationsAsync(states)); diff --git a/cvat-ui/src/components/annotation-page/canvas/canvas-context-menu.tsx b/cvat-ui/src/components/annotation-page/canvas/canvas-context-menu.tsx index 9cf44456..a52aa2dd 100644 --- a/cvat-ui/src/components/annotation-page/canvas/canvas-context-menu.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/canvas-context-menu.tsx @@ -8,14 +8,16 @@ import Menu from 'antd/lib/menu'; // eslint-disable-next-line import/no-extraneous-dependencies import { MenuInfo } from 'rc-menu/lib/interface'; +import ObjectItemElementComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item-element'; import ObjectItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-item'; -import { Workspace } from 'reducers/interfaces'; +import { Workspace } from 'reducers'; import { rotatePoint } from 'utils/math'; import consts from 'consts'; interface Props { readonly: boolean; workspace: Workspace; + contextMenuParentID: number | null; contextMenuClientID: number | null; objectStates: any[]; visible: boolean; @@ -79,6 +81,7 @@ function ReviewContextMenu({ export default function CanvasContextMenu(props: Props): JSX.Element | null { const { contextMenuClientID, + contextMenuParentID, objectStates, visible, left, @@ -153,6 +156,20 @@ export default function CanvasContextMenu(props: Props): JSX.Element | null { ); } + if (Number.isInteger(contextMenuParentID)) { + return ReactDOM.createPortal( +
+ +
, + window.document.body, + ); + } + return ReactDOM.createPortal(
, window.document.body, diff --git a/cvat-ui/src/components/annotation-page/canvas/canvas-point-context-menu.tsx b/cvat-ui/src/components/annotation-page/canvas/canvas-point-context-menu.tsx index e1dbdbf8..0f2eebf8 100644 --- a/cvat-ui/src/components/annotation-page/canvas/canvas-point-context-menu.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/canvas-point-context-menu.tsx @@ -8,7 +8,7 @@ import Button from 'antd/lib/button'; import { DeleteOutlined, EnvironmentOutlined } from '@ant-design/icons'; import { connect } from 'react-redux'; -import { CombinedState, ContextMenuType } from 'reducers/interfaces'; +import { CombinedState, ContextMenuType } from 'reducers'; import { updateAnnotationsAsync, updateCanvasContextMenu } from 'actions/annotation-actions'; import CVATTooltip from 'components/common/cvat-tooltip'; diff --git a/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx index a691bb50..f49b8249 100644 --- a/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx @@ -11,11 +11,11 @@ import { PlusCircleOutlined, UpOutlined } from '@ant-design/icons'; import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; import { ColorBy, GridColor, ObjectType, ContextMenuType, Workspace, ShapeType, -} from 'reducers/interfaces'; +} from 'reducers'; import { LogType } from 'cvat-logger'; import { Canvas } from 'cvat-canvas-wrapper'; import { Canvas3d } from 'cvat-canvas3d-wrapper'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; import consts from 'consts'; import CVATTooltip from 'components/common/cvat-tooltip'; import FrameTags from 'components/annotation-page/tag-annotation-workspace/frame-tags'; @@ -31,6 +31,7 @@ interface Props { canvasInstance: Canvas | Canvas3d | null; jobInstance: any; activatedStateID: number | null; + activatedElementID: number | null; activatedAttributeID: number | null; annotations: any[]; frameData: any; @@ -61,6 +62,7 @@ interface Props { aamZoomMargin: number; showObjectsTextAlways: boolean; textFontSize: number; + controlPointsSize: number; textPosition: 'auto' | 'center'; textContent: string; showAllInterpolationTracks: boolean; @@ -85,7 +87,7 @@ interface Props { onMergeAnnotations(sessionInstance: any, frame: number, states: any[]): void; onGroupAnnotations(sessionInstance: any, frame: number, states: any[]): void; onSplitAnnotations(sessionInstance: any, frame: number, state: any): void; - onActivateObject(activatedStateID: number | null): void; + onActivateObject(activatedStateID: number | null, activatedElementID?: number | null): void; onUpdateContextMenu(visible: boolean, left: number, top: number, type: ContextMenuType, pointID?: number): void; onAddZLayer(): void; onSwitchZLayer(cur: number): void; @@ -110,10 +112,15 @@ export default class CanvasWrapperComponent extends React.PureComponent { workspace, showProjections, selectedOpacity, + opacity, smoothImage, textFontSize, + controlPointsSize, textPosition, textContent, + colorBy, + outlined, + outlineColor, } = this.props; const { canvasInstance } = this.props as { canvasInstance: Canvas }; @@ -123,14 +130,18 @@ export default class CanvasWrapperComponent extends React.PureComponent { wrapper.appendChild(canvasInstance.html()); canvasInstance.configure({ - smoothImage, - autoborders: automaticBordering, + forceDisableEditing: workspace === Workspace.REVIEW_WORKSPACE, undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE, displayAllText: showObjectsTextAlways, - forceDisableEditing: workspace === Workspace.REVIEW_WORKSPACE, - intelligentPolygonCrop, + autoborders: automaticBordering, showProjections, - creationOpacity: selectedOpacity, + intelligentPolygonCrop, + selectedShapeOpacity: selectedOpacity, + controlPointsSize, + shapeOpacity: opacity, + smoothImage, + colorBy, + outlinedBorders: outlined ? outlineColor || 'black' : false, textFontSize, textPosition, textContent, @@ -143,7 +154,6 @@ export default class CanvasWrapperComponent extends React.PureComponent { public componentDidUpdate(prevProps: Props): void { const { opacity, - colorBy, selectedOpacity, outlined, outlineColor, @@ -167,6 +177,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { frameFetching, showObjectsTextAlways, textFontSize, + controlPointsSize, textPosition, textContent, showAllInterpolationTracks, @@ -174,19 +185,26 @@ export default class CanvasWrapperComponent extends React.PureComponent { intelligentPolygonCrop, showProjections, canvasBackgroundColor, + colorBy, onFetchAnnotation, } = this.props; const { canvasInstance } = this.props as { canvasInstance: Canvas }; + if ( prevProps.showObjectsTextAlways !== showObjectsTextAlways || prevProps.automaticBordering !== automaticBordering || prevProps.showProjections !== showProjections || prevProps.intelligentPolygonCrop !== intelligentPolygonCrop || + prevProps.opacity !== opacity || prevProps.selectedOpacity !== selectedOpacity || prevProps.smoothImage !== smoothImage || prevProps.textFontSize !== textFontSize || + prevProps.controlPointsSize !== controlPointsSize || prevProps.textPosition !== textPosition || - prevProps.textContent !== textContent + prevProps.textContent !== textContent || + prevProps.colorBy !== colorBy || + prevProps.outlineColor !== outlineColor || + prevProps.outlined !== outlined ) { canvasInstance.configure({ undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE, @@ -194,9 +212,13 @@ export default class CanvasWrapperComponent extends React.PureComponent { autoborders: automaticBordering, showProjections, intelligentPolygonCrop, - creationOpacity: selectedOpacity, + selectedShapeOpacity: selectedOpacity, + shapeOpacity: opacity, smoothImage, + colorBy, + outlinedBorders: outlined ? outlineColor || 'black' : false, textFontSize, + controlPointsSize, textPosition, textContent, }); @@ -272,16 +294,6 @@ export default class CanvasWrapperComponent extends React.PureComponent { ); } - if ( - prevProps.opacity !== opacity || - prevProps.outlined !== outlined || - prevProps.outlineColor !== outlineColor || - prevProps.selectedOpacity !== selectedOpacity || - prevProps.colorBy !== colorBy - ) { - this.updateShapesView(); - } - if (prevProps.showBitmap !== showBitmap) { canvasInstance.bitmap(showBitmap); } @@ -386,9 +398,22 @@ export default class CanvasWrapperComponent extends React.PureComponent { state.objectType = state.objectType || activeObjectType; state.label = state.label || jobInstance.labels.filter((label: any) => label.id === activeLabelID)[0]; - state.occluded = state.occluded || false; state.frame = frame; state.rotation = state.rotation || 0; + state.occluded = state.occluded || false; + state.outside = state.outside || false; + if (state.shapeType === ShapeType.SKELETON && Array.isArray(state.elements)) { + state.elements.forEach((element: Record) => { + element.objectType = state.objectType; + element.label = element.label || state.label.structure + .sublabels.find((label: any) => label.id === element.labelID); + element.frame = state.frame; + element.rotation = 0; + element.occluded = element.occluded || false; + element.outside = element.outside || false; + }); + } + const objectState = new cvat.classes.ObjectState(state); onCreateAnnotations(jobInstance, frame, [objectState]); }; @@ -494,8 +519,14 @@ export default class CanvasWrapperComponent extends React.PureComponent { }; private onCanvasShapeClicked = (e: any): void => { - const { clientID } = e.detail.state; - const sidebarItem = window.document.getElementById(`cvat-objects-sidebar-state-item-${clientID}`); + const { clientID, parentID } = e.detail.state; + let sidebarItem = null; + if (Number.isInteger(parentID)) { + sidebarItem = window.document.getElementById(`cvat-objects-sidebar-state-item-element-${clientID}`); + } else { + sidebarItem = window.document.getElementById(`cvat-objects-sidebar-state-item-${clientID}`); + } + if (sidebarItem) { sidebarItem.scrollIntoView(); } @@ -515,7 +546,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { private onCanvasCursorMoved = async (event: any): Promise => { const { - jobInstance, activatedStateID, workspace, onActivateObject, + jobInstance, activatedStateID, activatedElementID, workspace, onActivateObject, } = this.props; if (![Workspace.STANDARD, Workspace.REVIEW_WORKSPACE].includes(workspace)) { @@ -525,14 +556,15 @@ export default class CanvasWrapperComponent extends React.PureComponent { const result = await jobInstance.annotations.select(event.detail.states, event.detail.x, event.detail.y); if (result && result.state) { - if (result.state.shapeType === 'polyline' || result.state.shapeType === 'points') { + if (['polyline', 'points'].includes(result.state.shapeType)) { if (result.distance > MAX_DISTANCE_TO_OPEN_SHAPE) { return; } } - if (activatedStateID !== result.state.clientID) { - onActivateObject(result.state.clientID); + const newActivatedElement = event.detail.activatedElementID || null; + if (activatedStateID !== result.state.clientID || activatedElementID !== newActivatedElement) { + onActivateObject(result.state.clientID, event.detail.activatedElementID || null); } } }; @@ -577,7 +609,6 @@ export default class CanvasWrapperComponent extends React.PureComponent { private onCanvasSetup = (): void => { const { onSetupCanvas } = this.props; onSetupCanvas(); - this.updateShapesView(); this.activateOnCanvas(); }; @@ -593,7 +624,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { const result = await jobInstance.annotations.select(e.detail.states, e.detail.x, e.detail.y); if (result && result.state) { - if (result.state.shapeType === 'polyline' || result.state.shapeType === 'points') { + if (['polyline', 'points'].includes(result.state.shapeType)) { if (result.distance > MAX_DISTANCE_TO_OPEN_SHAPE) { return; } @@ -607,7 +638,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { const { activatedStateID, onUpdateContextMenu, annotations } = this.props; const [state] = annotations.filter((el: any) => el.clientID === activatedStateID); - if (![ShapeType.CUBOID, ShapeType.RECTANGLE].includes(state.shapeType)) { + if (![ShapeType.CUBOID, ShapeType.RECTANGLE, ShapeType.ELLIPSE, ShapeType.SKELETON].includes(state.shapeType)) { onUpdateContextMenu( activatedStateID !== null, e.detail.mouseEvent.clientX, @@ -648,36 +679,6 @@ export default class CanvasWrapperComponent extends React.PureComponent { } } - private updateShapesView(): void { - const { - annotations, opacity, colorBy, outlined, outlineColor, - } = this.props; - - for (const state of annotations) { - let shapeColor = ''; - - if (colorBy === ColorBy.INSTANCE) { - shapeColor = state.color; - } else if (colorBy === ColorBy.GROUP) { - shapeColor = state.group.color; - } else if (colorBy === ColorBy.LABEL) { - shapeColor = state.label.color; - } - - // TODO: In this approach CVAT-UI know details of implementations CVAT-CANVAS (svg.js) - const shapeView = window.document.getElementById(`cvat_canvas_shape_${state.clientID}`); - if (shapeView) { - const handler = (shapeView as any).instance.remember('_selectHandler'); - if (handler && handler.nested) { - handler.nested.fill({ color: shapeColor }); - } - - (shapeView as any).instance.fill({ color: shapeColor, opacity }); - (shapeView as any).instance.stroke({ color: outlined ? outlineColor : shapeColor }); - } - } - } - private updateCanvas(): void { const { curZLayer, annotations, frameData, canvasInstance, diff --git a/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper3D.tsx b/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper3D.tsx index dbc019f3..1b8f57bb 100644 --- a/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper3D.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper3D.tsx @@ -12,7 +12,7 @@ import { import { ResizableBox } from 'react-resizable'; import { ColorBy, ContextMenuType, ObjectType, Workspace, -} from 'reducers/interfaces'; +} from 'reducers'; import { CameraAction, Canvas3d, ViewType, ViewsDOM, } from 'cvat-canvas3d-wrapper'; @@ -20,7 +20,7 @@ import { Canvas } from 'cvat-canvas-wrapper'; import ContextImage from 'components/annotation-page/standard-workspace/context-image/context-image'; import CVATTooltip from 'components/common/cvat-tooltip'; import { LogType } from 'cvat-logger'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; const cvat = getCore(); diff --git a/cvat-ui/src/components/annotation-page/canvas/image-setups-content.tsx b/cvat-ui/src/components/annotation-page/canvas/image-setups-content.tsx index 10de95fb..9c725458 100644 --- a/cvat-ui/src/components/annotation-page/canvas/image-setups-content.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/image-setups-content.tsx @@ -22,7 +22,7 @@ import { changeGridSize, } from 'actions/settings-actions'; import { clamp } from 'utils/math'; -import { GridColor, CombinedState, PlayerSettingsState } from 'reducers/interfaces'; +import { GridColor, CombinedState, PlayerSettingsState } from 'reducers'; const minGridSize = 5; const maxGridSize = 1000; diff --git a/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx index a930d7e9..af7f5cad 100644 --- a/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/controls-side-bar.tsx @@ -6,7 +6,7 @@ import React from 'react'; import Layout from 'antd/lib/layout'; import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; -import { ActiveControl, Rotation } from 'reducers/interfaces'; +import { ActiveControl, Rotation } from 'reducers'; import { Canvas } from 'cvat-canvas-wrapper'; import RotateControl from 'components/annotation-page/standard-workspace/controls-side-bar/rotate-control'; diff --git a/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/issue-control.tsx b/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/issue-control.tsx index 85100067..f0315d14 100644 --- a/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/issue-control.tsx +++ b/cvat-ui/src/components/annotation-page/review-workspace/controls-side-bar/issue-control.tsx @@ -5,7 +5,7 @@ import React from 'react'; import Icon from '@ant-design/icons'; -import { ActiveControl } from 'reducers/interfaces'; +import { ActiveControl } from 'reducers'; import { Canvas } from 'cvat-canvas-wrapper'; import { RectangleIcon } from 'icons'; import CVATTooltip from 'components/common/cvat-tooltip'; diff --git a/cvat-ui/src/components/annotation-page/review/issues-aggregator.tsx b/cvat-ui/src/components/annotation-page/review/issues-aggregator.tsx index 019c233b..55782936 100644 --- a/cvat-ui/src/components/annotation-page/review/issues-aggregator.tsx +++ b/cvat-ui/src/components/annotation-page/review/issues-aggregator.tsx @@ -6,7 +6,7 @@ import './styles.scss'; import React, { useState, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { CombinedState } from 'reducers/interfaces'; +import { CombinedState } from 'reducers'; import { Canvas } from 'cvat-canvas/src/typescript/canvas'; import { commentIssueAsync, resolveIssueAsync, reopenIssueAsync } from 'actions/review-actions'; diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/context-image/context-image.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/context-image/context-image.tsx index 45ae8f4b..229635a8 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/context-image/context-image.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/context-image/context-image.tsx @@ -9,7 +9,7 @@ import { QuestionCircleOutlined, ShrinkOutlined } from '@ant-design/icons'; import Spin from 'antd/lib/spin'; import Image from 'antd/lib/image'; -import { CombinedState } from 'reducers/interfaces'; +import { CombinedState } from 'reducers'; import { hideShowContextImage, getContextImageAsync } from 'actions/annotation-actions'; import CVATTooltip from 'components/common/cvat-tooltip'; 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 2348709b..44ca08c2 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 @@ -5,9 +5,12 @@ import React from 'react'; import Layout from 'antd/lib/layout'; -import { ActiveControl, Rotation } from 'reducers/interfaces'; +import { + ActiveControl, ObjectType, Rotation, ShapeType, +} from 'reducers'; import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; import { Canvas } from 'cvat-canvas-wrapper'; +import { Label } from 'components/labels-editor/common'; import ControlVisibilityObserver, { ExtraControlsControl } from './control-visibility-observer'; import RotateControl, { Props as RotateControlProps } from './rotate-control'; @@ -23,6 +26,7 @@ import DrawPolylineControl, { Props as DrawPolylineControlProps } from './draw-p import DrawPointsControl, { Props as DrawPointsControlProps } from './draw-points-control'; import DrawEllipseControl, { Props as DrawEllipseControlProps } from './draw-ellipse-control'; import DrawCuboidControl, { Props as DrawCuboidControlProps } from './draw-cuboid-control'; +import DrawSkeletonControl, { Props as DrawSkeletonControlProps } from './draw-skeleton-control'; import SetupTagControl, { Props as SetupTagControlProps } from './setup-tag-control'; import MergeControl, { Props as MergeControlProps } from './merge-control'; import GroupControl, { Props as GroupControlProps } from './group-control'; @@ -61,6 +65,7 @@ const ObservedDrawPolylineControl = ControlVisibilityObserver(DrawPointsControl); const ObservedDrawEllipseControl = ControlVisibilityObserver(DrawEllipseControl); const ObservedDrawCuboidControl = ControlVisibilityObserver(DrawCuboidControl); +const ObservedDrawSkeletonControl = ControlVisibilityObserver(DrawSkeletonControl); const ObservedSetupTagControl = ControlVisibilityObserver(SetupTagControl); const ObservedMergeControl = ControlVisibilityObserver(MergeControl); const ObservedGroupControl = ControlVisibilityObserver(GroupControl); @@ -85,6 +90,24 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { } = props; const controlsDisabled = !labels.length || frameData.deleted; + const withUnspecifiedType = labels.some((label: any) => label.type === 'any' && !label.hasParent); + let rectangleControlVisible = withUnspecifiedType; + let polygonControlVisible = withUnspecifiedType; + let polylineControlVisible = withUnspecifiedType; + let pointsControlVisible = withUnspecifiedType; + let ellipseControlVisible = withUnspecifiedType; + let cuboidControlVisible = withUnspecifiedType; + let tagControlVisible = withUnspecifiedType; + const skeletonControlVisible = labels.some((label: Label) => label.type === 'skeleton'); + labels.forEach((label: Label) => { + rectangleControlVisible = rectangleControlVisible || label.type === ShapeType.RECTANGLE; + polygonControlVisible = polygonControlVisible || label.type === ShapeType.POLYGON; + polylineControlVisible = polylineControlVisible || label.type === ShapeType.POLYLINE; + pointsControlVisible = pointsControlVisible || label.type === ShapeType.POINTS; + ellipseControlVisible = ellipseControlVisible || label.type === ShapeType.ELLIPSE; + cuboidControlVisible = cuboidControlVisible || label.type === ShapeType.CUBOID; + tagControlVisible = tagControlVisible || label.type === ObjectType.TAG; + }); const preventDefault = (event: KeyboardEvent | undefined): void => { if (event) { @@ -131,6 +154,8 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { ActiveControl.DRAW_POLYLINE, ActiveControl.DRAW_RECTANGLE, ActiveControl.DRAW_CUBOID, + ActiveControl.DRAW_ELLIPSE, + ActiveControl.DRAW_SKELETON, ActiveControl.AI_TOOLS, ActiveControl.OPENCV_TOOLS, ].includes(activeControl); @@ -227,38 +252,77 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
- - - - - - - - + { + rectangleControlVisible && ( + + ) + } + { + polygonControlVisible && ( + + ) + } + { + polylineControlVisible && ( + + ) + } + { + pointsControlVisible && ( + + ) + } + { + ellipseControlVisible && ( + + ) + } + { + cuboidControlVisible && ( + + ) + } + { + skeletonControlVisible && ( + + ) + } + { + tagControlVisible && ( + + ) + }
@@ -126,7 +126,7 @@ function DrawShapePopoverComponent(props: Props): JSX.Element { )} - {is2D && ![ShapeType.RECTANGLE, ShapeType.CUBOID, ShapeType.ELLIPSE].includes(shapeType) ? ( + {is2D && [ShapeType.POLYGON, ShapeType.POLYLINE, ShapeType.POINTS].includes(shapeType) ? ( Number of points: diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-skeleton-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-skeleton-control.tsx new file mode 100644 index 00000000..46aff72b --- /dev/null +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-skeleton-control.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import Popover from 'antd/lib/popover'; +import Icon from '@ant-design/icons'; + +import { Canvas } from 'cvat-canvas-wrapper'; +import { Canvas3d } from 'cvat-canvas3d-wrapper'; +import { ShapeType } from 'reducers'; + +import { SkeletonIcon } from 'icons'; + +import DrawShapePopoverContainer from 'containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover'; +import withVisibilityHandling from './handle-popover-visibility'; + +export interface Props { + canvasInstance: Canvas | Canvas3d; + isDrawing: boolean; + disabled: boolean; +} + +const CustomPopover = withVisibilityHandling(Popover, 'draw-skeleton'); +function DrawSkeletonControl(props: Props): JSX.Element { + const { canvasInstance, isDrawing, disabled } = props; + const dynamicPopoverProps = isDrawing ? { + overlayStyle: { + display: 'none', + }, + } : {}; + + const dynamicIconProps = isDrawing ? { + className: 'cvat-draw-skeleton-control cvat-active-canvas-control', + onClick: (): void => { + canvasInstance.draw({ enabled: false }); + }, + } : { + className: 'cvat-draw-skeleton-control', + }; + + return disabled ? ( + + ) : ( + } + > + + + ); +} + +export default React.memo(DrawSkeletonControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/group-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/group-control.tsx index 3a65cc96..2d2af456 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/group-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/group-control.tsx @@ -8,7 +8,7 @@ import Icon from '@ant-design/icons'; import { GroupIcon } from 'icons'; import { Canvas } from 'cvat-canvas-wrapper'; import { Canvas3d } from 'cvat-canvas3d-wrapper'; -import { ActiveControl, DimensionType } from 'reducers/interfaces'; +import { ActiveControl, DimensionType } from 'reducers'; import CVATTooltip from 'components/common/cvat-tooltip'; export interface Props { 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 6141ea20..eaa1e5f2 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 @@ -7,7 +7,7 @@ import Icon from '@ant-design/icons'; import { MergeIcon } from 'icons'; import { Canvas } from 'cvat-canvas-wrapper'; -import { ActiveControl } from 'reducers/interfaces'; +import { ActiveControl } from 'reducers'; import CVATTooltip from 'components/common/cvat-tooltip'; export interface Props { diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/move-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/move-control.tsx index 8583f7ca..9bb66103 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/move-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/move-control.tsx @@ -6,7 +6,7 @@ import React from 'react'; import Icon from '@ant-design/icons'; import { MoveIcon } from 'icons'; -import { ActiveControl } from 'reducers/interfaces'; +import { ActiveControl } from 'reducers'; import { Canvas } from 'cvat-canvas-wrapper'; import { Canvas3d } from 'cvat-canvas3d-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; 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 511a56cf..c3fbe9e7 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 @@ -17,12 +17,12 @@ import message from 'antd/lib/message'; import { OpenCVIcon } from 'icons'; import { Canvas, convertShapesForInteractor } from 'cvat-canvas-wrapper'; -import getCore from 'cvat-core-wrapper'; +import { getCore } from 'cvat-core-wrapper'; import openCVWrapper from 'utils/opencv-wrapper/opencv-wrapper'; import { IntelligentScissors } from 'utils/opencv-wrapper/intelligent-scissors'; import { CombinedState, ActiveControl, OpenCVTool, ObjectType, ShapeType, ToolsBlockerState, -} from 'reducers/interfaces'; +} from 'reducers'; import { interactWithCanvas, fetchAnnotationsAsync, diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/resize-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/resize-control.tsx index 9110a59b..8169039a 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/resize-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/resize-control.tsx @@ -6,7 +6,7 @@ import React from 'react'; import Icon from '@ant-design/icons'; import { ZoomIcon } from 'icons'; -import { ActiveControl } from 'reducers/interfaces'; +import { ActiveControl } from 'reducers'; import { Canvas } from 'cvat-canvas-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/rotate-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/rotate-control.tsx index 988b3f95..edbbfa6f 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/rotate-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/rotate-control.tsx @@ -7,7 +7,7 @@ import Icon from '@ant-design/icons'; import Popover from 'antd/lib/popover'; import { RotateIcon } from 'icons'; -import { Rotation } from 'reducers/interfaces'; +import { Rotation } from 'reducers'; import CVATTooltip from 'components/common/cvat-tooltip'; import withVisibilityHandling from './handle-popover-visibility'; diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/setup-tag-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/setup-tag-control.tsx index db233869..3b070ed0 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/setup-tag-control.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/setup-tag-control.tsx @@ -14,25 +14,17 @@ import withVisibilityHandling from './handle-popover-visibility'; export interface Props { canvasInstance: Canvas; - isDrawing: boolean; disabled?: boolean; } const CustomPopover = withVisibilityHandling(Popover, 'setup-tag'); function SetupTagControl(props: Props): JSX.Element { - const { isDrawing, disabled } = props; - const dynamicPopoverProps = isDrawing ? - { - overlayStyle: { - display: 'none', - }, - } : - {}; + const { disabled } = props; return disabled ? ( ) : ( - }> + }> ); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/setup-tag-popover.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/setup-tag-popover.tsx index fd45740d..513fbe2e 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/setup-tag-popover.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/setup-tag-popover.tsx @@ -13,10 +13,10 @@ import CVATTooltip from 'components/common/cvat-tooltip'; interface Props { labels: any[]; - selectedLabelID: number; + selectedLabelID: number | null; repeatShapeShortcut: string; onChangeLabel(value: string): void; - onSetup(labelID: number): void; + onSetup(): void; } function SetupTagPopover(props: Props): JSX.Element { @@ -44,13 +44,13 @@ function SetupTagPopover(props: Props): JSX.Element { labels={labels} value={selectedLabelID} onChange={onChangeLabel} - onEnterPress={() => onSetup(selectedLabelID)} + onEnterPress={() => onSetup()} />