diff --git a/changelog.d/20240619_102529_klakhov_propagate_shapes_simultaneously.md b/changelog.d/20240619_102529_klakhov_propagate_shapes_simultaneously.md new file mode 100644 index 00000000000..8954074a1fe --- /dev/null +++ b/changelog.d/20240619_102529_klakhov_propagate_shapes_simultaneously.md @@ -0,0 +1,4 @@ +### Added + +- `Propagate shapes` action to create copies of visible shapes on multiple frames forward or backward + () diff --git a/cvat-core/package.json b/cvat-core/package.json index 88c181107ef..1de340e39f0 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "15.0.6", + "version": "15.0.7", "type": "module", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "src/api.ts", diff --git a/cvat-core/src/annotations-actions.ts b/cvat-core/src/annotations-actions.ts index dc4e21ba869..9e956421ae0 100644 --- a/cvat-core/src/annotations-actions.ts +++ b/cvat-core/src/annotations-actions.ts @@ -1,14 +1,15 @@ -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import { omit, throttle } from 'lodash'; import { ArgumentError } from './exceptions'; -import { SerializedCollection } from './server-response-types'; +import { SerializedCollection, SerializedShape } from './server-response-types'; import { Job, Task } from './session'; import { EventScope, ObjectType } from './enums'; import ObjectState from './object-state'; import { getAnnotations, getCollection } from './annotations'; +import { propagateShapes } from './object-utils'; export interface SingleFrameActionInput { collection: Omit; @@ -28,12 +29,20 @@ export enum ActionParameterType { NUMBER = 'number', } +// For SELECT values should be a list of possible options +// For NUMBER values should be a list with [min, max, step], +// or a callback ({ instance }: { instance: Job | Task }) => [min, max, step] type ActionParameters = Record string[]); + defaultValue: string | (({ instance }: { instance: Job | Task }) => string); }>; +export enum FrameSelectionType { + SEGMENT = 'segment', + CURRENT_FRAME = 'current_frame', +} + export default class BaseSingleFrameAction { /* eslint-disable @typescript-eslint/no-unused-vars */ public async init( @@ -58,6 +67,10 @@ export default class BaseSingleFrameAction { public get parameters(): ActionParameters | null { throw new Error('Method not implemented'); } + + public get frameSelection(): FrameSelectionType { + return FrameSelectionType.SEGMENT; + } } class RemoveFilteredShapes extends BaseSingleFrameAction { @@ -82,6 +95,57 @@ class RemoveFilteredShapes extends BaseSingleFrameAction { } } +class PropagateShapes extends BaseSingleFrameAction { + #targetFrame: number; + + public async init(instance, parameters): Promise { + this.#targetFrame = parameters['Target frame']; + } + + public async destroy(): Promise { + // nothing to destroy + } + + public async run( + instance, + { collection: { shapes }, frameData: { number } }, + ): Promise { + if (number === this.#targetFrame) { + return { collection: { shapes } }; + } + const propagatedShapes = propagateShapes(shapes, number, this.#targetFrame); + return { collection: { shapes: [...shapes, ...propagatedShapes] } }; + } + + public get name(): string { + return 'Propagate shapes'; + } + + public get parameters(): ActionParameters | null { + return { + 'Target frame': { + type: ActionParameterType.NUMBER, + values: ({ instance }) => { + if (instance instanceof Job) { + return [instance.startFrame, instance.stopFrame, 1].map((val) => val.toString()); + } + return [0, instance.size - 1, 1].map((val) => val.toString()); + }, + defaultValue: ({ instance }) => { + if (instance instanceof Job) { + return instance.stopFrame.toString(); + } + return (instance.size - 1).toString(); + }, + }, + }; + } + + public get frameSelection(): FrameSelectionType { + return FrameSelectionType.CURRENT_FRAME; + } +} + const registeredActions: BaseSingleFrameAction[] = []; export async function listActions(): Promise { @@ -102,6 +166,7 @@ export async function registerAction(action: BaseSingleFrameAction): Promise= width || top >= height || right >= width || bottom >= height) { + this.points = cropMask(this.points, width, height); + } [this.left, this.top, this.right, this.bottom] = this.points.splice(-4, 4); this.getMasksOnFrame = injection.getMasksOnFrame; this.pinned = true; diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 20e9ce8577f..d47cf8f4f6e 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -29,7 +29,7 @@ import { Exception, ArgumentError, DataError, ScriptingError, ServerError, } from './exceptions'; -import { mask2Rle, rle2Mask } from './object-utils'; +import { mask2Rle, rle2Mask, propagateShapes } from './object-utils'; import User from './user'; import pjson from '../package.json'; import config from './config'; @@ -397,6 +397,7 @@ function build(): CVATCore { utils: { mask2Rle, rle2Mask, + propagateShapes, }, }; diff --git a/cvat-core/src/index.ts b/cvat-core/src/index.ts index 402ea4a69d9..e83da172525 100644 --- a/cvat-core/src/index.ts +++ b/cvat-core/src/index.ts @@ -12,7 +12,7 @@ import { AnnotationFormats } from './annotation-formats'; import logger from './logger'; import * as enums from './enums'; import config from './config'; -import { mask2Rle, rle2Mask } from './object-utils'; +import { mask2Rle, rle2Mask, propagateShapes } from './object-utils'; import User from './user'; import Project from './project'; import { Job, Task } from './session'; @@ -201,5 +201,6 @@ export default interface CVATCore { utils: { mask2Rle: typeof mask2Rle; rle2Mask: typeof rle2Mask; + propagateShapes: typeof propagateShapes; }; } diff --git a/cvat-core/src/object-utils.ts b/cvat-core/src/object-utils.ts index 27c12ae36c1..acba23d46d0 100644 --- a/cvat-core/src/object-utils.ts +++ b/cvat-core/src/object-utils.ts @@ -4,7 +4,9 @@ import { DataError, ArgumentError } from './exceptions'; import { Attribute } from './labels'; -import { ShapeType, AttributeType } from './enums'; +import { ShapeType, AttributeType, ObjectType } from './enums'; +import { SerializedShape } from './server-response-types'; +import ObjectState, { SerializedData } from './object-state'; export function checkNumberOfPoints(shapeType: ShapeType, points: number[]): void { if (shapeType === ShapeType.RECTANGLE) { @@ -356,3 +358,62 @@ export function rle2Mask(rle: number[], width: number, height: number): number[] return decoded; } + +export function propagateShapes( + shapes: T[], from: number, to: number, +): T[] { + const getCopy = (shape: T): SerializedShape | SerializedData => { + if (shape instanceof ObjectState) { + return { + attributes: shape.attributes, + points: shape.shapeType === 'skeleton' ? null : shape.points, + occluded: shape.occluded, + objectType: shape.objectType !== ObjectType.TRACK ? shape.objectType : ObjectType.SHAPE, + shapeType: shape.shapeType, + label: shape.label, + zOrder: shape.zOrder, + rotation: shape.rotation, + frame: from, + elements: shape.shapeType === 'skeleton' ? shape.elements + .map((element: ObjectState): any => getCopy(element as T)) : [], + source: shape.source, + }; + } + return { + attributes: [...shape.attributes.map((attribute) => ({ ...attribute }))], + points: shape.type === 'skeleton' ? null : [...shape.points], + occluded: shape.occluded, + type: shape.type, + label_id: shape.label_id, + z_order: shape.z_order, + rotation: shape.rotation, + frame: from, + elements: shape.type === 'skeleton' ? shape.elements + .map((element: SerializedShape): SerializedShape => getCopy(element as T) as SerializedShape) : [], + source: shape.source, + group: 0, + outside: false, + }; + }; + + const states: T[] = []; + const sign = Math.sign(to - from); + for (let frame = from + sign; sign > 0 ? frame <= to : frame >= to; frame += sign) { + for (const shape of shapes) { + const copy = getCopy(shape); + + copy.frame = frame; + copy.elements?.forEach((element: Omit | SerializedData): void => { + element.frame = frame; + }); + + if (shape instanceof ObjectState) { + states.push(new ObjectState(copy as SerializedData) as T); + } else { + states.push(copy as T); + } + } + } + + return states; +} diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 95e3002352e..1a12cb29142 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.63.11", + "version": "1.63.12", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index fec634caec2..d8e9d103266 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -473,31 +473,8 @@ export function propagateObjectAsync(from: number, to: number): ThunkAction { throw new Error('There is not an activated object state to be propagated'); } - 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, - rotation: _objectState.rotation, - frame: from, - elements: _objectState.shapeType === 'skeleton' ? _objectState.elements - .map((element: any): any => getCopyFromState(element)) : [], - source: _objectState.source, - }); - - const copy = getCopyFromState(objectState); await sessionInstance.logger.log(EventScope.propagateObject, { count: Math.abs(to - from) }); - const states = []; - const sign = Math.sign(to - from); - for (let frame = from + sign; sign > 0 ? frame <= to : frame >= to; frame += sign) { - copy.frame = frame; - copy.elements.forEach((element: any) => { element.frame = frame; }); - const newState = new cvat.classes.ObjectState(copy); - states.push(newState); - } + const states = cvat.utils.propagateShapes([objectState], from, to); await sessionInstance.annotations.put(states); const history = await sessionInstance.actions.get(); diff --git a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx index f111fbc5e9a..f7799995cbb 100644 --- a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx +++ b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -19,7 +19,9 @@ import config from 'config'; import { useIsMounted } from 'utils/hooks'; import { createAction, ActionUnion } from 'utils/redux'; import { getCVATStore } from 'cvat-store'; -import { BaseSingleFrameAction, Job, getCore } from 'cvat-core-wrapper'; +import { + BaseSingleFrameAction, FrameSelectionType, Job, getCore, +} from 'cvat-core-wrapper'; import { Canvas } from 'cvat-canvas-wrapper'; import { fetchAnnotationsAsync, saveAnnotationsAsync } from 'actions/annotation-actions'; import { switchAutoSave } from 'actions/settings-actions'; @@ -108,6 +110,18 @@ const reducer = (state: State, action: ActionUnion): Stat } if (action.type === ReducerActionType.SET_ACTIVE_ANNOTATIONS_ACTION) { + const { frameSelection } = action.payload.activeAction; + if (frameSelection === FrameSelectionType.CURRENT_FRAME) { + const storage = getCVATStore(); + const currentFrame = storage.getState().annotation.player.frame.number; + return { + ...state, + frameFrom: currentFrame, + frameTo: currentFrame, + activeAction: action.payload.activeAction, + actionParameters: {}, + }; + } return { ...state, activeAction: action.payload.activeAction, @@ -204,23 +218,29 @@ function ActionParameterComponent(props: Props & { onChange: (value: string) => const { defaultValue, type, values, onChange, } = props; - const [value, setValue] = useState(defaultValue); + const store = getCVATStore(); + const job = store.getState().annotation.job.instance as Job; + const computedDefaultValue = typeof defaultValue === 'function' ? defaultValue({ instance: job }) : defaultValue; + const [value, setValue] = useState(computedDefaultValue); useEffect(() => { onChange(value); }, [value]); + const computedValues = typeof values === 'function' ? values({ instance: job }) : values; + if (type === 'select') { return ( ); } - const [min, max, step] = values.map((val) => +val); + const [min, max, step] = computedValues.map((val) => +val); + return ( void; }): JSX.El progress, progressMessage, frameFrom, frameTo, actionParameters, modalVisible, } = state; + const currentFrameAction = activeAction?.frameSelection === FrameSelectionType.CURRENT_FRAME; + return ( void; }): JSX.El
- Starting from frame - { - if (typeof value === 'number') { - dispatch(reducerActions.updateFrameFrom( - clamp(Math.round(value), (jobInstance as Job).startFrame, frameTo), - )); - } - }} - /> - up to frame - { - if (typeof value === 'number') { - dispatch(reducerActions.updateFrameTo( - clamp(Math.round(value), frameFrom, (jobInstance as Job).stopFrame), - )); - } - }} - /> + { + currentFrameAction ? ( + Running the action is only allowed on current frame + ) : ( + <> + Starting from frame + { + if (typeof value === 'number') { + dispatch(reducerActions.updateFrameFrom( + clamp( + Math.round(value), + jobInstance.startFrame, + frameTo, + ), + )); + } + }} + /> + up to frame + { + if (typeof value === 'number') { + dispatch(reducerActions.updateFrameTo( + clamp( + Math.round(value), + frameFrom, + jobInstance.stopFrame, + ), + )); + } + }} + /> + + + ) + } - - - - Or choose one of predefined options -
+ { + !currentFrameAction ? ( + + + + Or choose one of predefined options +
+ + + + + + + +
- - - - - - -
- + ) : null + } ) : null} diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index 3731013d305..ea72f3c6111 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -1,5 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -37,7 +37,7 @@ import AnalyticsReport, { AnalyticsEntryViewType, AnalyticsEntry } from 'cvat-co import { Dumper } from 'cvat-core/src/annotation-formats'; import { Event } from 'cvat-core/src/event'; import { APIWrapperEnterOptions } from 'cvat-core/src/plugins'; -import BaseSingleFrameAction, { ActionParameterType } from 'cvat-core/src/annotations-actions'; +import BaseSingleFrameAction, { ActionParameterType, FrameSelectionType } from 'cvat-core/src/annotations-actions'; const cvat: CVATCore = _cvat; @@ -96,6 +96,7 @@ export { Event, FrameData, ActionParameterType, + FrameSelectionType, }; export type { diff --git a/tests/cypress/e2e/features/annotations_actions.js b/tests/cypress/e2e/features/annotations_actions.js index 5feb240592c..c63c7e86057 100644 --- a/tests/cypress/e2e/features/annotations_actions.js +++ b/tests/cypress/e2e/features/annotations_actions.js @@ -274,6 +274,121 @@ context('Testing annotations actions workflow', () => { }); }); + describe('Test action: "Propagate shapes"', () => { + const ACTION_NAME = 'Propagate shapes'; + const FORMAT_NAME = 'Segmentation mask 1.1'; + + function checkFramesContainShapes(from, to, amount) { + frames.forEach((frame) => { + cy.goCheckFrameNumber(frame); + + if (frame >= from && frame <= to) { + cy.get('.cvat_canvas_shape').should('have.length', amount); + } else { + cy.get('.cvat_canvas_shape').should('have.length', 0); + } + }); + } + + before(() => { + const shapes = [{ + type: 'rectangle', + occluded: false, + outside: false, + z_order: 0, + points: [250, 350, 350, 450], + rotation: 0, + attributes: [], + elements: [], + frame: 0, + label_id: labels[0].id, + group: 0, + source: 'manual', + }, { + type: 'rectangle', + occluded: false, + outside: false, + z_order: 0, + points: [350, 450, 450, 550], + rotation: 0, + attributes: [], + elements: [], + frame: 0, + label_id: labels[1].id, + group: 0, + source: 'manual', + }]; + + cy.window().then((window) => { + window.cvat.server.request(`/api/jobs/${jobID}/annotations`, { + method: 'PUT', + data: { shapes }, + }); + }); + }); + + beforeEach(() => { + cy.visit(`/tasks/${taskID}/jobs/${jobID}`); + cy.get('.cvat-canvas-container').should('not.exist'); + cy.get('.cvat-canvas-container').should('exist').and('be.visible'); + }); + + it('Apply action on specific frames', () => { + const middleFrame = Math.round(latestFrameNumber / 2); + cy.openAnnotationsActionsModal(); + cy.selectAnnotationsAction(ACTION_NAME); + cy.setAnnotationActionParameter('Target frame', 'input', middleFrame); + cy.runAnnotationsAction(); + cy.waitAnnotationsAction(); + cy.closeAnnotationsActionsModal(); + checkFramesContainShapes(0, middleFrame, 2); + }); + + it('Apply action on current frame', () => { + cy.openAnnotationsActionsModal(); + cy.selectAnnotationsAction(ACTION_NAME); + cy.setAnnotationActionParameter('Target frame', 'input', 0); + cy.runAnnotationsAction(); + cy.waitAnnotationsAction(); + cy.closeAnnotationsActionsModal(); + checkFramesContainShapes(0, 0, 2); + }); + + it('Apply action on mask with different frame sizes. Mask is cropped. Segmentation mask export is available', () => { + // Default frame size is 800x800, but last frame is 500x500 + cy.goCheckFrameNumber(latestFrameNumber - 1); + cy.startMaskDrawing(); + cy.drawMask([{ + method: 'brush', + coordinates: [[620, 620], [700, 620], [700, 700], [620, 700]], + }]); + cy.finishMaskDrawing(); + + cy.openAnnotationsActionsModal(); + cy.selectAnnotationsAction(ACTION_NAME); + cy.runAnnotationsAction(); + cy.waitAnnotationsAction(); + cy.closeAnnotationsActionsModal(); + + cy.get('.cvat_canvas_shape').should('have.length', 1); + cy.goCheckFrameNumber(latestFrameNumber); + cy.get('.cvat_canvas_shape').should('have.length', 1); + + cy.saveJob('PUT', 200, 'saveJob'); + const exportAnnotation = { + as: 'exportAnnotations', + type: 'annotations', + format: FORMAT_NAME, + scrollList: true, + }; + cy.exportJob(exportAnnotation); + cy.getDownloadFileName().then((file) => { + cy.verifyDownload(file); + }); + cy.verifyNotification(); + }); + }); + after(() => { cy.logout(); cy.getAuthKey().then((response) => { diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index 316a196f23c..56f997a798c 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -1233,9 +1233,13 @@ Cypress.Commands.add('exportTask', ({ Cypress.Commands.add('exportJob', ({ type, format, archiveCustomName, targetStorage = null, useDefaultLocation = true, + scrollList = false, }) => { cy.interactMenu('Export job dataset'); cy.get('.cvat-modal-export-job').should('be.visible').find('.cvat-modal-export-select').click(); + if (scrollList) { + cy.contains('.cvat-modal-export-option-item', format).scrollIntoView(); + } cy.contains('.cvat-modal-export-option-item', format).should('be.visible').click(); cy.get('.cvat-modal-export-job').find('.cvat-modal-export-select').should('contain.text', format); if (type === 'dataset') { diff --git a/tests/cypress/support/commands_annotations_actions.js b/tests/cypress/support/commands_annotations_actions.js index 96dc8a9329f..99aac46302d 100644 --- a/tests/cypress/support/commands_annotations_actions.js +++ b/tests/cypress/support/commands_annotations_actions.js @@ -42,3 +42,14 @@ Cypress.Commands.add('selectAnnotationsAction', (name) => { Cypress.Commands.add('waitAnnotationsAction', () => { cy.get('.cvat-action-runner-progress').should('not.exist'); // wait until action ends }); + +Cypress.Commands.add('setAnnotationActionParameter', (parameterName, type, value) => { + if (type === 'input') { + cy.get('.cvat-action-runner-action-parameters').within(() => { + cy.contains('.cvat-action-runner-action-parameter', parameterName) + .get('input').clear(); + cy.contains('.cvat-action-runner-action-parameter', parameterName) + .get('input').type(value); + }); + } +}); diff --git a/tests/mounted_file_share/archive.zip b/tests/mounted_file_share/archive.zip index 674c343ef78..a1cc8eddaca 100644 Binary files a/tests/mounted_file_share/archive.zip and b/tests/mounted_file_share/archive.zip differ