diff --git a/changelog.d/20241120_234543_sekachev.bs_updated_annotations_actions.md b/changelog.d/20241120_234543_sekachev.bs_updated_annotations_actions.md new file mode 100644 index 000000000000..f29d658fa5a2 --- /dev/null +++ b/changelog.d/20241120_234543_sekachev.bs_updated_annotations_actions.md @@ -0,0 +1,4 @@ +### Added + +- A user may undo or redo changes, made by an annotations actions using general approach (e.g. Ctrl+Z, Ctrl+Y) + () diff --git a/changelog.d/20241120_234732_sekachev.bs_updated_annotations_actions.md b/changelog.d/20241120_234732_sekachev.bs_updated_annotations_actions.md new file mode 100644 index 000000000000..a935397785cc --- /dev/null +++ b/changelog.d/20241120_234732_sekachev.bs_updated_annotations_actions.md @@ -0,0 +1,4 @@ +### Added + +- Basically, annotations actions now support any kinds of objects (shapes, tracks, tags) + () diff --git a/changelog.d/20241120_234852_sekachev.bs_updated_annotations_actions.md b/changelog.d/20241120_234852_sekachev.bs_updated_annotations_actions.md new file mode 100644 index 000000000000..802b137fa343 --- /dev/null +++ b/changelog.d/20241120_234852_sekachev.bs_updated_annotations_actions.md @@ -0,0 +1,4 @@ +### Added + +- A user may run annotations actions on a certain object (added corresponding object menu item) + () diff --git a/changelog.d/20241121_005939_sekachev.bs_updated_annotations_actions.md b/changelog.d/20241121_005939_sekachev.bs_updated_annotations_actions.md new file mode 100644 index 000000000000..16fbffde424f --- /dev/null +++ b/changelog.d/20241121_005939_sekachev.bs_updated_annotations_actions.md @@ -0,0 +1,4 @@ +### Added + +- A shortcut to open annotations actions modal for a currently selected object + () diff --git a/cvat-core/package.json b/cvat-core/package.json index a769b74bf78c..6b9039673812 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "15.2.1", + "version": "15.3.0", "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 deleted file mode 100644 index 43d3ef29a910..000000000000 --- a/cvat-core/src/annotations-actions.ts +++ /dev/null @@ -1,320 +0,0 @@ -// Copyright (C) 2023-2024 CVAT.ai Corporation -// -// SPDX-License-Identifier: MIT - -import { omit, range, throttle } from 'lodash'; -import { ArgumentError } from './exceptions'; -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; - frameData: { - width: number; - height: number; - number: number; - }; -} - -export interface SingleFrameActionOutput { - collection: Omit; -} - -export enum ActionParameterType { - SELECT = 'select', - 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( - sessionInstance: Job | Task, - parameters: Record, - ): Promise { - throw new Error('Method not implemented'); - } - - public async destroy(): Promise { - throw new Error('Method not implemented'); - } - - public async run(sessionInstance: Job | Task, input: SingleFrameActionInput): Promise { - throw new Error('Method not implemented'); - } - - public get name(): string { - throw new Error('Method not implemented'); - } - - public get parameters(): ActionParameters | null { - throw new Error('Method not implemented'); - } - - public get frameSelection(): FrameSelectionType { - return FrameSelectionType.SEGMENT; - } -} - -class RemoveFilteredShapes extends BaseSingleFrameAction { - public async init(): Promise { - // nothing to init - } - - public async destroy(): Promise { - // nothing to destroy - } - - public async run(): Promise { - return { collection: { shapes: [] } }; - } - - public get name(): string { - return 'Remove filtered shapes'; - } - - public get parameters(): ActionParameters | null { - return null; - } -} - -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: Job | Task, - { collection: { shapes }, frameData: { number } }, - ): Promise { - if (number === this.#targetFrame) { - return { collection: { shapes } }; - } - - const frameNumbers = instance instanceof Job ? await instance.frames.frameNumbers() : range(0, instance.size); - const propagatedShapes = propagateShapes(shapes, number, this.#targetFrame, frameNumbers); - 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 { - return [...registeredActions]; -} - -export async function registerAction(action: BaseSingleFrameAction): Promise { - if (!(action instanceof BaseSingleFrameAction)) { - throw new ArgumentError('Provided action is not instance of BaseSingleFrameAction'); - } - - const { name } = action; - if (registeredActions.map((_action) => _action.name).includes(name)) { - throw new ArgumentError(`Action name must be unique. Name "${name}" is already exists`); - } - - registeredActions.push(action); -} - -registerAction(new RemoveFilteredShapes()); -registerAction(new PropagateShapes()); - -async function runSingleFrameChain( - instance: Job | Task, - actionsChain: BaseSingleFrameAction[], - actionParameters: Record[], - frameFrom: number, - frameTo: number, - filters: string[], - onProgress: (message: string, progress: number) => void, - cancelled: () => boolean, -): Promise { - type IDsToHandle = { shapes: number[] }; - const event = await instance.logger.log(EventScope.annotationsAction, { - from: frameFrom, - to: frameTo, - chain: actionsChain.map((action) => action.name).join(' => '), - }, true); - - // if called too fast, it will freeze UI, so, add throttling here - const wrappedOnProgress = throttle(onProgress, 100, { leading: true, trailing: true }); - const showMessageWithPause = async (message: string, progress: number, duration: number): Promise => { - // wrapper that gives a chance to abort action - wrappedOnProgress(message, progress); - await new Promise((resolve) => setTimeout(resolve, duration)); - }; - - try { - await showMessageWithPause('Actions initialization', 0, 500); - if (cancelled()) { - return; - } - - await Promise.all(actionsChain.map((action, idx) => { - const declaredParameters = action.parameters; - if (!declaredParameters) { - return action.init(instance, {}); - } - - const setupValues = actionParameters[idx]; - const parameters = Object.entries(declaredParameters).reduce((acc, [name, { type, defaultValue }]) => { - if (type === ActionParameterType.NUMBER) { - acc[name] = +(Object.hasOwn(setupValues, name) ? setupValues[name] : defaultValue); - } else { - acc[name] = (Object.hasOwn(setupValues, name) ? setupValues[name] : defaultValue); - } - return acc; - }, {} as Record); - - return action.init(instance, parameters); - })); - - const exportedCollection = getCollection(instance).export(); - const handledCollection: SingleFrameActionInput['collection'] = { shapes: [] }; - const modifiedCollectionIDs: IDsToHandle = { shapes: [] }; - - // Iterate over frames - const totalFrames = frameTo - frameFrom + 1; - for (let frame = frameFrom; frame <= frameTo; frame++) { - const frameData = await Object.getPrototypeOf(instance).frames - .get.implementation.call(instance, frame); - - // Ignore deleted frames - if (!frameData.deleted) { - // Get annotations according to filter - const states: ObjectState[] = await getAnnotations(instance, frame, false, filters); - const frameCollectionIDs = states.reduce((acc, val) => { - if (val.objectType === ObjectType.SHAPE) { - acc.shapes.push(val.clientID as number); - } - return acc; - }, { shapes: [] }); - - // Pick frame collection according to filtered IDs - let frameCollection = { - shapes: exportedCollection.shapes.filter((shape) => frameCollectionIDs - .shapes.includes(shape.clientID as number)), - }; - - // Iterate over actions on each not deleted frame - for await (const action of actionsChain) { - ({ collection: frameCollection } = await action.run(instance, { - collection: frameCollection, - frameData: { - width: frameData.width, - height: frameData.height, - number: frameData.number, - }, - })); - } - - const progress = Math.ceil(+(((frame - frameFrom) / totalFrames) * 100)); - wrappedOnProgress('Actions are running', progress); - if (cancelled()) { - return; - } - - handledCollection.shapes.push(...frameCollection.shapes.map((shape) => omit(shape, 'id'))); - modifiedCollectionIDs.shapes.push(...frameCollectionIDs.shapes); - } - } - - await showMessageWithPause('Commiting handled objects', 100, 1500); - if (cancelled()) { - return; - } - - exportedCollection.shapes.forEach((shape) => { - if (Number.isInteger(shape.clientID) && !modifiedCollectionIDs.shapes.includes(shape.clientID as number)) { - handledCollection.shapes.push(shape); - } - }); - - await instance.annotations.clear(); - await instance.actions.clear(); - await instance.annotations.import({ - ...handledCollection, - tracks: exportedCollection.tracks, - tags: exportedCollection.tags, - }); - - event.close(); - } finally { - wrappedOnProgress('Finalizing', 100); - await Promise.all(actionsChain.map((action) => action.destroy())); - } -} - -export async function runActions( - instance: Job | Task, - actionsChain: BaseSingleFrameAction[], - actionParameters: Record[], - frameFrom: number, - frameTo: number, - filters: string[], - onProgress: (message: string, progress: number) => void, - cancelled: () => boolean, -): Promise { - // there will be another function for MultiFrameChains (actions handling tracks) - return runSingleFrameChain( - instance, - actionsChain, - actionParameters, - frameFrom, - frameTo, - filters, - onProgress, - cancelled, - ); -} diff --git a/cvat-core/src/annotations-actions/annotations-actions.ts b/cvat-core/src/annotations-actions/annotations-actions.ts new file mode 100644 index 000000000000..172b8cd88e3d --- /dev/null +++ b/cvat-core/src/annotations-actions/annotations-actions.ts @@ -0,0 +1,113 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import ObjectState from '../object-state'; +import { ArgumentError } from '../exceptions'; +import { Job, Task } from '../session'; +import { BaseAction } from './base-action'; +import { + BaseShapesAction, run as runShapesAction, call as callShapesAction, +} from './base-shapes-action'; +import { + BaseCollectionAction, run as runCollectionAction, call as callCollectionAction, +} from './base-collection-action'; + +import { RemoveFilteredShapes } from './remove-filtered-shapes'; +import { PropagateShapes } from './propagate-shapes'; + +const registeredActions: BaseAction[] = []; + +export async function listActions(): Promise { + return [...registeredActions]; +} + +export async function registerAction(action: BaseAction): Promise { + if (!(action instanceof BaseAction)) { + throw new ArgumentError('Provided action must inherit one of base classes'); + } + + const { name } = action; + if (registeredActions.map((_action) => _action.name).includes(name)) { + throw new ArgumentError(`Action name must be unique. Name "${name}" is already exists`); + } + + registeredActions.push(action); +} + +registerAction(new RemoveFilteredShapes()); +registerAction(new PropagateShapes()); + +export async function runAction( + instance: Job | Task, + action: BaseAction, + actionParameters: Record, + frameFrom: number, + frameTo: number, + filters: object[], + onProgress: (message: string, progress: number) => void, + cancelled: () => boolean, +): Promise { + if (action instanceof BaseShapesAction) { + return runShapesAction( + instance, + action, + actionParameters, + frameFrom, + frameTo, + filters, + onProgress, + cancelled, + ); + } + + if (action instanceof BaseCollectionAction) { + return runCollectionAction( + instance, + action, + actionParameters, + frameFrom, + filters, + onProgress, + cancelled, + ); + } + + return Promise.resolve(); +} + +export async function callAction( + instance: Job | Task, + action: BaseAction, + actionParameters: Record, + frame: number, + states: ObjectState[], + onProgress: (message: string, progress: number) => void, + cancelled: () => boolean, +): Promise { + if (action instanceof BaseShapesAction) { + return callShapesAction( + instance, + action, + actionParameters, + frame, + states, + onProgress, + cancelled, + ); + } + + if (action instanceof BaseCollectionAction) { + return callCollectionAction( + instance, + action, + actionParameters, + frame, + states, + onProgress, + cancelled, + ); + } + + return Promise.resolve(); +} diff --git a/cvat-core/src/annotations-actions/base-action.ts b/cvat-core/src/annotations-actions/base-action.ts new file mode 100644 index 000000000000..3246261d2c9a --- /dev/null +++ b/cvat-core/src/annotations-actions/base-action.ts @@ -0,0 +1,60 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { SerializedCollection } from 'server-response-types'; +import ObjectState from '../object-state'; +import { Job, Task } from '../session'; + +export enum ActionParameterType { + SELECT = 'select', + 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] +export type ActionParameters = Record string[]); + defaultValue: string | (({ instance }: { instance: Job | Task }) => string); +}>; + +export abstract class BaseAction { + public abstract init(sessionInstance: Job | Task, parameters: Record): Promise; + public abstract destroy(): Promise; + public abstract run(input: unknown): Promise; + public abstract applyFilter(input: unknown): unknown; + public abstract isApplicableForObject(objectState: ObjectState): boolean; + + public abstract get name(): string; + public abstract get parameters(): ActionParameters | null; +} + +export function prepareActionParameters(declared: ActionParameters, defined: object): Record { + if (!declared) { + return {}; + } + + return Object.entries(declared).reduce((acc, [name, { type, defaultValue }]) => { + if (type === ActionParameterType.NUMBER) { + acc[name] = +(Object.hasOwn(defined, name) ? defined[name] : defaultValue); + } else { + acc[name] = (Object.hasOwn(defined, name) ? defined[name] : defaultValue); + } + return acc; + }, {} as Record); +} + +export function validateClientIDs(collection: Partial) { + [].concat( + collection.shapes ?? [], + collection.tracks ?? [], + collection.tags ?? [], + ).forEach((object) => { + // clientID is required to correct collection filtering and commiting in annotations actions logic + if (typeof object.clientID !== 'number') { + throw new Error('ClientID is undefined when running annotations action, but required'); + } + }); +} diff --git a/cvat-core/src/annotations-actions/base-collection-action.ts b/cvat-core/src/annotations-actions/base-collection-action.ts new file mode 100644 index 000000000000..c48135694566 --- /dev/null +++ b/cvat-core/src/annotations-actions/base-collection-action.ts @@ -0,0 +1,178 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { throttle } from 'lodash'; + +import ObjectState from '../object-state'; +import AnnotationsFilter from '../annotations-filter'; +import { Job, Task } from '../session'; +import { + SerializedCollection, SerializedShape, + SerializedTag, SerializedTrack, +} from '../server-response-types'; +import { EventScope, ObjectType } from '../enums'; +import { getCollection } from '../annotations'; +import { BaseAction, prepareActionParameters, validateClientIDs } from './base-action'; + +export interface CollectionActionInput { + onProgress(message: string, percent: number): void; + cancelled(): boolean; + collection: Pick; + frameData: { + width: number; + height: number; + number: number; + }; +} + +export interface CollectionActionOutput { + created: CollectionActionInput['collection']; + deleted: CollectionActionInput['collection']; +} + +export abstract class BaseCollectionAction extends BaseAction { + public abstract run(input: CollectionActionInput): Promise; + public abstract applyFilter( + input: Pick, + ): CollectionActionInput['collection']; +} + +export async function run( + instance: Job | Task, + action: BaseCollectionAction, + actionParameters: Record, + frame: number, + filters: object[], + onProgress: (message: string, progress: number) => void, + cancelled: () => boolean, +): Promise { + const event = await instance.logger.log(EventScope.annotationsAction, { + from: frame, + to: frame, + name: action.name, + }, true); + + const wrappedOnProgress = throttle(onProgress, 100, { leading: true, trailing: true }); + const showMessageWithPause = async (message: string, progress: number, duration: number): Promise => { + // wrapper that gives a chance to abort action + wrappedOnProgress(message, progress); + await new Promise((resolve) => setTimeout(resolve, duration)); + }; + + try { + await showMessageWithPause('Action initialization', 0, 500); + if (cancelled()) { + return; + } + + await action.init(instance, prepareActionParameters(action.parameters, actionParameters)); + + const frameData = await Object.getPrototypeOf(instance).frames + .get.implementation.call(instance, frame); + const exportedCollection = getCollection(instance).export(); + + // Apply action filter first + const filteredByAction = action.applyFilter({ collection: exportedCollection, frameData }); + validateClientIDs(filteredByAction); + + let mapID2Obj = [].concat(filteredByAction.shapes, filteredByAction.tags, filteredByAction.tracks) + .reduce((acc, object) => { + acc[object.clientID as number] = object; + return acc; + }, {}); + + // Then apply user filter + const annotationsFilter = new AnnotationsFilter(); + const filteredCollectionIDs = annotationsFilter + .filterSerializedCollection(filteredByAction, instance.labels, filters); + const filteredByUser = { + shapes: filteredCollectionIDs.shapes.map((clientID) => mapID2Obj[clientID]), + tags: filteredCollectionIDs.tags.map((clientID) => mapID2Obj[clientID]), + tracks: filteredCollectionIDs.tracks.map((clientID) => mapID2Obj[clientID]), + }; + mapID2Obj = [].concat(filteredByUser.shapes, filteredByUser.tags, filteredByUser.tracks) + .reduce((acc, object) => { + acc[object.clientID as number] = object; + return acc; + }, {}); + + const { created, deleted } = await action.run({ + collection: filteredByUser, + frameData: { + width: frameData.width, + height: frameData.height, + number: frameData.number, + }, + onProgress: wrappedOnProgress, + cancelled, + }); + + await instance.annotations.commit(created, deleted, frame); + event.close(); + } finally { + wrappedOnProgress('Finalizing', 100); + await action.destroy(); + } +} + +export async function call( + instance: Job | Task, + action: BaseCollectionAction, + actionParameters: Record, + frame: number, + states: ObjectState[], + onProgress: (message: string, progress: number) => void, + cancelled: () => boolean, +): Promise { + const event = await instance.logger.log(EventScope.annotationsAction, { + from: frame, + to: frame, + name: action.name, + }, true); + + const throttledOnProgress = throttle(onProgress, 100, { leading: true, trailing: true }); + try { + await action.init(instance, prepareActionParameters(action.parameters, actionParameters)); + const exportedStates = await Promise.all(states.map((state) => state.export())); + const exportedCollection = exportedStates.reduce((acc, value, idx) => { + if (states[idx].objectType === ObjectType.SHAPE) { + acc.shapes.push(value as SerializedShape); + } + + if (states[idx].objectType === ObjectType.TAG) { + acc.tags.push(value as SerializedTag); + } + + if (states[idx].objectType === ObjectType.TRACK) { + acc.tracks.push(value as SerializedTrack); + } + + return acc; + }, { shapes: [], tags: [], tracks: [] }); + + const frameData = await Object.getPrototypeOf(instance).frames.get.implementation.call(instance, frame); + const filteredByAction = action.applyFilter({ collection: exportedCollection, frameData }); + validateClientIDs(filteredByAction); + + const processedCollection = await action.run({ + onProgress: throttledOnProgress, + cancelled, + collection: filteredByAction, + frameData: { + width: frameData.width, + height: frameData.height, + number: frameData.number, + }, + }); + + await instance.annotations.commit( + processedCollection.created, + processedCollection.deleted, + frame, + ); + event.close(); + } finally { + await action.destroy(); + } +} diff --git a/cvat-core/src/annotations-actions/base-shapes-action.ts b/cvat-core/src/annotations-actions/base-shapes-action.ts new file mode 100644 index 000000000000..80d2b4fee78b --- /dev/null +++ b/cvat-core/src/annotations-actions/base-shapes-action.ts @@ -0,0 +1,196 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { throttle } from 'lodash'; + +import ObjectState from '../object-state'; +import AnnotationsFilter from '../annotations-filter'; +import { Job, Task } from '../session'; +import { SerializedCollection, SerializedShape } from '../server-response-types'; +import { EventScope, ObjectType } from '../enums'; +import { getCollection } from '../annotations'; +import { BaseAction, prepareActionParameters, validateClientIDs } from './base-action'; + +export interface ShapesActionInput { + onProgress(message: string, percent: number): void; + cancelled(): boolean; + collection: Pick; + frameData: { + width: number; + height: number; + number: number; + }; +} + +export interface ShapesActionOutput { + created: ShapesActionInput['collection']; + deleted: ShapesActionInput['collection']; +} + +export abstract class BaseShapesAction extends BaseAction { + public abstract run(input: ShapesActionInput): Promise; + public abstract applyFilter( + input: Pick + ): ShapesActionInput['collection']; +} + +export async function run( + instance: Job | Task, + action: BaseShapesAction, + actionParameters: Record, + frameFrom: number, + frameTo: number, + filters: object[], + onProgress: (message: string, progress: number) => void, + cancelled: () => boolean, +): Promise { + const event = await instance.logger.log(EventScope.annotationsAction, { + from: frameFrom, + to: frameTo, + name: action.name, + }, true); + + const throttledOnProgress = throttle(onProgress, 100, { leading: true, trailing: true }); + const showMessageWithPause = async (message: string, progress: number, duration: number): Promise => { + // wrapper that gives a chance to abort action + throttledOnProgress(message, progress); + await new Promise((resolve) => setTimeout(resolve, duration)); + }; + + try { + await showMessageWithPause('Actions initialization', 0, 500); + if (cancelled()) { + return; + } + + await action.init(instance, prepareActionParameters(action.parameters, actionParameters)); + + const exportedCollection = getCollection(instance).export(); + validateClientIDs(exportedCollection); + + const annotationsFilter = new AnnotationsFilter(); + const filteredShapeIDs = annotationsFilter.filterSerializedCollection({ + shapes: exportedCollection.shapes, + tags: [], + tracks: [], + }, instance.labels, filters).shapes; + + const filteredShapesByFrame = exportedCollection.shapes.reduce((acc, shape) => { + if (shape.frame >= frameFrom && shape.frame <= frameTo && filteredShapeIDs.includes(shape.clientID)) { + acc[shape.frame] = acc[shape.frame] ?? []; + acc[shape.frame].push(shape); + } + return acc; + }, {} as Record); + + const totalUpdates = { created: { shapes: [] }, deleted: { shapes: [] } }; + // Iterate over frames + const totalFrames = frameTo - frameFrom + 1; + for (let frame = frameFrom; frame <= frameTo; frame++) { + const frameData = await Object.getPrototypeOf(instance).frames + .get.implementation.call(instance, frame); + + // Ignore deleted frames + if (!frameData.deleted) { + const frameShapes = filteredShapesByFrame[frame] ?? []; + if (!frameShapes.length) { + continue; + } + + // finally apply the own filter of the action + const filteredByAction = action.applyFilter({ + collection: { + shapes: frameShapes, + }, + frameData, + }); + validateClientIDs(filteredByAction); + + const { created, deleted } = await action.run({ + onProgress: throttledOnProgress, + cancelled, + collection: { shapes: filteredByAction.shapes }, + frameData: { + width: frameData.width, + height: frameData.height, + number: frameData.number, + }, + }); + + Array.prototype.push.apply(totalUpdates.created.shapes, created.shapes); + Array.prototype.push.apply(totalUpdates.deleted.shapes, deleted.shapes); + + const progress = Math.ceil(+(((frame - frameFrom) / totalFrames) * 100)); + throttledOnProgress('Actions are running', progress); + if (cancelled()) { + return; + } + } + } + + await showMessageWithPause('Commiting handled objects', 100, 1500); + if (cancelled()) { + return; + } + + await instance.annotations.commit( + { shapes: totalUpdates.created.shapes, tags: [], tracks: [] }, + { shapes: totalUpdates.deleted.shapes, tags: [], tracks: [] }, + frameFrom, + ); + + event.close(); + } finally { + throttledOnProgress('Finalizing', 100); + await action.destroy(); + } +} + +export async function call( + instance: Job | Task, + action: BaseShapesAction, + actionParameters: Record, + frame: number, + states: ObjectState[], + onProgress: (message: string, progress: number) => void, + cancelled: () => boolean, +): Promise { + const event = await instance.logger.log(EventScope.annotationsAction, { + from: frame, + to: frame, + name: action.name, + }, true); + + const throttledOnProgress = throttle(onProgress, 100, { leading: true, trailing: true }); + try { + await action.init(instance, prepareActionParameters(action.parameters, actionParameters)); + + const exported = await Promise.all(states.filter((state) => state.objectType === ObjectType.SHAPE) + .map((state) => state.export())) as SerializedShape[]; + const frameData = await Object.getPrototypeOf(instance).frames.get.implementation.call(instance, frame); + const filteredByAction = action.applyFilter({ collection: { shapes: exported }, frameData }); + validateClientIDs(filteredByAction); + + const processedCollection = await action.run({ + onProgress: throttledOnProgress, + cancelled, + collection: { shapes: filteredByAction.shapes }, + frameData: { + width: frameData.width, + height: frameData.height, + number: frameData.number, + }, + }); + + await instance.annotations.commit( + { shapes: processedCollection.created.shapes, tags: [], tracks: [] }, + { shapes: processedCollection.deleted.shapes, tags: [], tracks: [] }, + frame, + ); + + event.close(); + } finally { + await action.destroy(); + } +} diff --git a/cvat-core/src/annotations-actions/propagate-shapes.ts b/cvat-core/src/annotations-actions/propagate-shapes.ts new file mode 100644 index 000000000000..ee68b9600f4f --- /dev/null +++ b/cvat-core/src/annotations-actions/propagate-shapes.ts @@ -0,0 +1,85 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { range } from 'lodash'; + +import ObjectState from '../object-state'; +import { Job, Task } from '../session'; +import { SerializedShape } from '../server-response-types'; +import { propagateShapes } from '../object-utils'; +import { ObjectType } from '../enums'; + +import { ActionParameterType, ActionParameters } from './base-action'; +import { BaseCollectionAction, CollectionActionInput, CollectionActionOutput } from './base-collection-action'; + +export class PropagateShapes extends BaseCollectionAction { + #instance: Task | Job; + #targetFrame: number; + + public async init(instance: Job | Task, parameters): Promise { + this.#instance = instance; + this.#targetFrame = parameters['Target frame']; + } + + public async destroy(): Promise { + // nothing to destroy + } + + public async run(input: CollectionActionInput): Promise { + const { collection, frameData: { number } } = input; + if (number === this.#targetFrame) { + return { + created: { shapes: [], tags: [], tracks: [] }, + deleted: { shapes: [], tags: [], tracks: [] }, + }; + } + + const frameNumbers = this.#instance instanceof Job ? + await this.#instance.frames.frameNumbers() : range(0, this.#instance.size); + const propagatedShapes = propagateShapes( + collection.shapes, number, this.#targetFrame, frameNumbers, + ); + + return { + created: { shapes: propagatedShapes, tags: [], tracks: [] }, + deleted: { shapes: [], tags: [], tracks: [] }, + }; + } + + public applyFilter(input: CollectionActionInput): CollectionActionInput['collection'] { + return { + shapes: input.collection.shapes.filter((shape) => shape.frame === input.frameData.number), + tags: [], + tracks: [], + }; + } + + public isApplicableForObject(objectState: ObjectState): boolean { + return objectState.objectType === ObjectType.SHAPE; + } + + 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(); + }, + }, + }; + } +} diff --git a/cvat-core/src/annotations-actions/remove-filtered-shapes.ts b/cvat-core/src/annotations-actions/remove-filtered-shapes.ts new file mode 100644 index 000000000000..ab2a30964fad --- /dev/null +++ b/cvat-core/src/annotations-actions/remove-filtered-shapes.ts @@ -0,0 +1,41 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { BaseShapesAction, ShapesActionInput, ShapesActionOutput } from './base-shapes-action'; +import { ActionParameters } from './base-action'; + +export class RemoveFilteredShapes extends BaseShapesAction { + public async init(): Promise { + // nothing to init + } + + public async destroy(): Promise { + // nothing to destroy + } + + public async run(input: ShapesActionInput): Promise { + return { + created: { shapes: [] }, + deleted: input.collection, + }; + } + + public applyFilter(input: ShapesActionInput): ShapesActionInput['collection'] { + const { collection } = input; + return collection; + } + + public isApplicableForObject(): boolean { + // remove action does not make sense when running on one object + return false; + } + + public get name(): string { + return 'Remove filtered shapes'; + } + + public get parameters(): ActionParameters | null { + return null; + } +} diff --git a/cvat-core/src/annotations-collection.ts b/cvat-core/src/annotations-collection.ts index 14879e86bcd8..25496dfe69a7 100644 --- a/cvat-core/src/annotations-collection.ts +++ b/cvat-core/src/annotations-collection.ts @@ -157,9 +157,68 @@ export default class Collection { return result; } - public export(): Omit { + public commit( + appended: Omit, + removed: Omit, + frame: number, + ): { tags: Tag[]; shapes: Shape[]; tracks: Track[]; } { + const isCollectionConsistent = [].concat(removed.shapes, removed.tags, removed.tracks) + .every((object) => typeof object.clientID === 'number' && + Object.prototype.hasOwnProperty.call(this.objects, object.clientID)); + + if (!isCollectionConsistent) { + throw new ArgumentError('Objects required to be deleted were not found in the collection'); + } + + const removedCollection: (Shape | Tag | Track)[] = [].concat(removed.shapes, removed.tags, removed.tracks) + .map((object) => this.objects[object.clientID as number]); + + const imported = this.import(appended); + const appendedCollection = ([] as (Shape | Tag | Track)[]) + .concat(imported.shapes, imported.tags, imported.tracks); + if (!(appendedCollection.length > 0 || removedCollection.length > 0)) { + // nothing to commit + return; + } + + let prevRemoved = []; + removedCollection.forEach((collectionObject) => { + prevRemoved.push(collectionObject.removed); + collectionObject.removed = true; + }); + + this.history.do( + HistoryActions.COMMIT_ANNOTATIONS, + () => { + removedCollection.forEach((collectionObject, idx) => { + collectionObject.removed = prevRemoved[idx]; + }); + prevRemoved = []; + appendedCollection.forEach((collectionObject) => { + collectionObject.removed = true; + }); + }, + () => { + removedCollection.forEach((collectionObject) => { + prevRemoved.push(collectionObject.removed); + collectionObject.removed = true; + }); + appendedCollection.forEach((collectionObject) => { + collectionObject.removed = false; + }); + }, + [].concat( + removedCollection.map((object) => object.clientID), + appendedCollection.map((object) => object.clientID), + ), + frame, + ); + } + + public export(): Pick { const data = { - tracks: this.tracks.filter((track) => !track.removed).map((track) => track.toJSON() as SerializedTrack), + tracks: this.tracks.filter((track) => !track.removed) + .map((track) => track.toJSON() as SerializedTrack), shapes: Object.values(this.shapes) .reduce((accumulator, frameShapes) => { accumulator.push(...frameShapes); @@ -201,7 +260,7 @@ export default class Collection { } const objectStates = []; - const filtered = this.annotationsFilter.filter(visible, filters); + const filtered = this.annotationsFilter.filterSerializedObjectStates(visible, filters); visible.forEach((stateData) => { if (!filters.length || filtered.includes(stateData.clientID)) { @@ -1338,7 +1397,7 @@ export default class Collection { statesData.push(...tracks.map((track) => track.get(frame)).filter((state) => !state.outside)); // Filtering - const filtered = this.annotationsFilter.filter(statesData, annotationsFilters); + const filtered = this.annotationsFilter.filterSerializedObjectStates(statesData, annotationsFilters); if (filtered.length) { return frame; } diff --git a/cvat-core/src/annotations-filter.ts b/cvat-core/src/annotations-filter.ts index 58c9e82a63e5..fa7b8e739f5a 100644 --- a/cvat-core/src/annotations-filter.ts +++ b/cvat-core/src/annotations-filter.ts @@ -6,15 +6,74 @@ import jsonLogic from 'json-logic-js'; import { SerializedData } from './object-state'; import { AttributeType, ObjectType, ShapeType } from './enums'; +import { SerializedCollection } from './server-response-types'; +import { Attribute, Label } from './labels'; function adjustName(name): string { return name.replace(/\./g, '\u2219'); } +function getDimensions(points: number[], shapeType: ShapeType): { + width: number | null; + height: number | null; +} { + let [width, height]: (number | null)[] = [null, null]; + if (shapeType === ShapeType.MASK) { + const [xtl, ytl, xbr, ybr] = points.slice(-4); + [width, height] = [xbr - xtl + 1, ybr - ytl + 1]; + } else if (shapeType === ShapeType.ELLIPSE) { + const [cx, cy, rightX, topY] = points; + width = Math.abs(rightX - cx) * 2; + height = Math.abs(cy - topY) * 2; + } else { + let xtl = Number.MAX_SAFE_INTEGER; + let xbr = Number.MIN_SAFE_INTEGER; + let ytl = Number.MAX_SAFE_INTEGER; + let ybr = Number.MIN_SAFE_INTEGER; + + points.forEach((coord, idx) => { + if (idx % 2) { + // y + ytl = Math.min(ytl, coord); + ybr = Math.max(ybr, coord); + } else { + // x + xtl = Math.min(xtl, coord); + xbr = Math.max(xbr, coord); + } + }); + [width, height] = [xbr - xtl, ybr - ytl]; + } + + return { + width, + height, + }; +} + +function convertAttribute(id: number, value: string, attributesSpec: Record): [ + string, + number | boolean | string, +] { + const spec = attributesSpec[id]; + const name = adjustName(spec.name); + if (spec.inputType === AttributeType.NUMBER) { + return [name, +value]; + } + + if (spec.inputType === AttributeType.CHECKBOX) { + return [name, value === 'true']; + } + + return [name, value]; +} + +type ConvertedAttributes = Record; + interface ConvertedObjectData { width: number | null; height: number | null; - attr: Record>; + attr: Record; label: string; serverID: number; objectID: number; @@ -24,7 +83,7 @@ interface ConvertedObjectData { } export default class AnnotationsFilter { - _convertObjects(statesData: SerializedData[]): ConvertedObjectData[] { + private _convertSerializedObjectStates(statesData: SerializedData[]): ConvertedObjectData[] { const objects = statesData.map((state) => { const labelAttributes = state.label.attributes.reduce((acc, attr) => { acc[attr.id] = attr; @@ -33,50 +92,26 @@ export default class AnnotationsFilter { let [width, height]: (number | null)[] = [null, null]; if (state.objectType !== ObjectType.TAG) { - if (state.shapeType === ShapeType.MASK) { - const [xtl, ytl, xbr, ybr] = state.points.slice(-4); - [width, height] = [xbr - xtl + 1, ybr - ytl + 1]; - } else { - let xtl = Number.MAX_SAFE_INTEGER; - let xbr = Number.MIN_SAFE_INTEGER; - let ytl = Number.MAX_SAFE_INTEGER; - let ybr = Number.MIN_SAFE_INTEGER; - - 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); - ybr = Math.max(ybr, coord); - } else { - // x - xtl = Math.min(xtl, coord); - xbr = Math.max(xbr, coord); - } - }); - [width, height] = [xbr - xtl, ybr - ytl]; - } + const points = state.shapeType === ShapeType.SKELETON ? state.elements.reduce((acc, val) => { + acc.push(val.points); + return acc; + }, []).flat() : state.points; + + ({ width, height } = getDimensions(points, state.shapeType as ShapeType)); } - const attributes = Object.keys(state.attributes).reduce>((acc, key) => { - const attr = labelAttributes[key]; - let value = state.attributes[key]; - if (attr.inputType === AttributeType.NUMBER) { - value = +value; - } else if (attr.inputType === AttributeType.CHECKBOX) { - value = value === 'true'; - } - acc[adjustName(attr.name)] = value; + const attributes = Object.keys(state.attributes).reduce((acc, key) => { + const [name, value] = convertAttribute(+key, state.attributes[key], labelAttributes); + acc[name] = value; return acc; - }, {}); + }, {} as Record); return { width, height, - attr: Object.fromEntries([[adjustName(state.label.name), attributes]]), + attr: { + [adjustName(state.label.name)]: attributes, + }, label: state.label.name, serverID: state.serverID, objectID: state.clientID, @@ -89,11 +124,119 @@ export default class AnnotationsFilter { return objects; } - filter(statesData: SerializedData[], filters: object[]): number[] { - if (!filters.length) return statesData.map((stateData): number => stateData.clientID); - const converted = this._convertObjects(statesData); + private _convertSerializedCollection( + collection: Omit, + labelsSpec: Label[], + ): { shapes: ConvertedObjectData[]; tags: ConvertedObjectData[]; tracks: ConvertedObjectData[]; } { + const labelByID = labelsSpec.reduce>((acc, label) => ({ + [label.id]: label, + ...acc, + }), {}); + + const attributeById = labelsSpec.map((label) => label.attributes).flat().reduce((acc, attribute) => ({ + ...acc, + [attribute.id]: attribute, + }), {} as Record); + + const convertAttributes = ( + attributes: SerializedCollection['shapes'][0]['attributes'], + ): ConvertedAttributes => attributes.reduce((acc, { spec_id, value }) => { + const [name, adjustedValue] = convertAttribute(spec_id, value, attributeById); + acc[name] = adjustedValue; + return acc; + }, {} as Record); + + return { + shapes: collection.shapes.map((shape) => { + const label = labelByID[shape.label_id]; + const points = shape.type === ShapeType.SKELETON ? + shape.elements.map((el) => el.points).flat() : shape.points; + let [width, height]: (number | null)[] = [null, null]; + ({ width, height } = getDimensions(points, shape.type)); + + return { + width, + height, + attr: { + [adjustName(label.name)]: convertAttributes(shape.attributes), + }, + label: label.name, + serverID: shape.id ?? null, + type: ObjectType.SHAPE, + shape: shape.type, + occluded: shape.occluded, + objectID: shape.clientID ?? null, + }; + }), + tags: collection.tags.map((tag) => { + const label = labelByID[tag.label_id]; + + return { + width: null, + height: null, + attr: { + [adjustName(label.name)]: convertAttributes(tag.attributes), + }, + label: labelByID[tag.label_id]?.name ?? null, + serverID: tag.id ?? null, + type: ObjectType.SHAPE, + shape: null, + occluded: false, + objectID: tag.clientID ?? null, + }; + }), + tracks: collection.tracks.map((track) => { + const label = labelByID[track.label_id]; + + return { + width: null, + height: null, + attr: { + [adjustName(label.name)]: convertAttributes(track.attributes), + }, + label: labelByID[track.label_id]?.name ?? null, + serverID: track.id, + type: ObjectType.TRACK, + shape: track.shapes[0]?.type ?? null, + occluded: null, + objectID: track.clientID ?? null, + }; + }), + }; + } + + public filterSerializedObjectStates(statesData: SerializedData[], filters: object[]): number[] { + if (!filters.length) { + return statesData.map((stateData): number => stateData.clientID); + } + + const converted = this._convertSerializedObjectStates(statesData); return converted .map((state) => state.objectID) .filter((_, index) => jsonLogic.apply(filters[0], converted[index])); } + + public filterSerializedCollection( + collection: Omit, + labelsSpec: Label[], + filters: object[], + ): { shapes: number[]; tags: number[]; tracks: number[]; } { + if (!filters.length) { + return { + shapes: collection.shapes.map((shape) => shape.clientID), + tags: collection.tags.map((tag) => tag.clientID), + tracks: collection.tracks.map((track) => track.clientID), + }; + } + + const converted = this._convertSerializedCollection(collection, labelsSpec); + return { + shapes: converted.shapes.map((shape) => shape.objectID) + .filter((_, index) => jsonLogic.apply(filters[0], converted.shapes[index])), + tags: converted.tags.map((shape) => shape.objectID) + .filter((_, index) => jsonLogic.apply(filters[0], converted.tags[index])), + tracks: converted.tracks.map((shape) => shape.objectID) + .filter((_, index) => jsonLogic.apply(filters[0], converted.tracks[index])), + }; + } } diff --git a/cvat-core/src/annotations-history.ts b/cvat-core/src/annotations-history.ts index 748d55bcf93d..2e59db96ea1f 100644 --- a/cvat-core/src/annotations-history.ts +++ b/cvat-core/src/annotations-history.ts @@ -5,7 +5,7 @@ import { HistoryActions } from './enums'; -const MAX_HISTORY_LENGTH = 128; +const MAX_HISTORY_LENGTH = 32; interface ActionItem { action: HistoryActions; diff --git a/cvat-core/src/annotations-objects.ts b/cvat-core/src/annotations-objects.ts index defcf7dbbada..ab7e32de9784 100644 --- a/cvat-core/src/annotations-objects.ts +++ b/cvat-core/src/annotations-objects.ts @@ -150,17 +150,12 @@ class Annotation { injection.groups.max = Math.max(injection.groups.max, this.group); } - protected withContext(frame: number): { - __internal: { - save: (data: ObjectState) => ObjectState; - delete: Annotation['delete']; - }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected withContext(_: number): { + delete: Annotation['delete']; } { return { - __internal: { - save: (this as any).save.bind(this, frame), - delete: this.delete.bind(this), - }, + delete: this.delete.bind(this), }; } @@ -530,6 +525,17 @@ export class Shape extends Drawn { this.zOrder = data.z_order; } + protected withContext(frame: number): ReturnType & { + save: (data: ObjectState) => ObjectState; + export: () => SerializedShape; + } { + return { + ...super.withContext(frame), + save: this.save.bind(this, frame), + export: this.toJSON.bind(this) as () => SerializedShape, + }; + } + // Method is used to export data to the server public toJSON(): SerializedShape | SerializedShape['elements'][0] { const result: SerializedShape = { @@ -592,7 +598,7 @@ export class Shape extends Drawn { pinned: this.pinned, frame, source: this.source, - ...this.withContext(frame), + __internal: this.withContext(frame), }; if (typeof this.outside !== 'undefined') { @@ -838,6 +844,17 @@ export class Track extends Drawn { }, {}); } + protected withContext(frame: number): ReturnType & { + save: (data: ObjectState) => ObjectState; + export: () => SerializedTrack; + } { + return { + ...super.withContext(frame), + save: this.save.bind(this, frame), + export: this.toJSON.bind(this) as () => SerializedTrack, + }; + } + // Method is used to export data to the server public toJSON(): SerializedTrack | SerializedTrack['elements'][0] { const labelAttributes = attrsAsAnObject(this.label.attributes); @@ -931,7 +948,7 @@ export class Track extends Drawn { }, frame, source: this.source, - ...this.withContext(frame), + __internal: this.withContext(frame), }; } @@ -1405,6 +1422,17 @@ export class Track extends Drawn { } export class Tag extends Annotation { + protected withContext(frame: number): ReturnType & { + save: (data: ObjectState) => ObjectState; + export: () => SerializedTag; + } { + return { + ...super.withContext(frame), + save: this.save.bind(this, frame), + export: this.toJSON.bind(this) as () => SerializedTag, + }; + } + // Method is used to export data to the server public toJSON(): SerializedTag { const result: SerializedTag = { @@ -1451,7 +1479,7 @@ export class Tag extends Annotation { updated: this.updated, frame, source: this.source, - ...this.withContext(frame), + __internal: this.withContext(frame), }; } @@ -2022,7 +2050,7 @@ export class SkeletonShape extends Shape { hidden: elements.every((el) => el.hidden), frame, source: this.source, - ...this.withContext(frame), + __internal: this.withContext(frame), }; } @@ -3064,7 +3092,7 @@ export class SkeletonTrack extends Track { occluded: elements.every((el) => el.occluded), lock: elements.every((el) => el.lock), hidden: elements.every((el) => el.hidden), - ...this.withContext(frame), + __internal: this.withContext(frame), }; } diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index 0e9f400ad499..c9e53a2e1e0d 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -39,7 +39,9 @@ import QualityConflict, { ConflictSeverity } from './quality-conflict'; import QualitySettings from './quality-settings'; import { getFramesMeta } from './frames'; import AnalyticsReport from './analytics-report'; -import { listActions, registerAction, runActions } from './annotations-actions'; +import { + callAction, listActions, registerAction, runAction, +} from './annotations-actions/annotations-actions'; import { convertDescriptions, getServerAPISchema } from './server-schema'; import { JobType } from './enums'; import { PaginatedResource } from './core-types'; @@ -54,7 +56,8 @@ export default function implementAPI(cvat: CVATCore): CVATCore { implementationMixin(cvat.plugins.register, PluginRegistry.register.bind(cvat)); implementationMixin(cvat.actions.list, listActions); implementationMixin(cvat.actions.register, registerAction); - implementationMixin(cvat.actions.run, runActions); + implementationMixin(cvat.actions.run, runAction); + implementationMixin(cvat.actions.call, callAction); implementationMixin(cvat.lambda.list, lambdaManager.list.bind(lambdaManager)); implementationMixin(cvat.lambda.run, lambdaManager.run.bind(lambdaManager)); diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index ca33f431c43e..f4eb5d8b23fd 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -21,7 +21,9 @@ import CloudStorage from './cloud-storage'; import Organization from './organization'; import Webhook from './webhook'; import AnnotationGuide from './guide'; -import BaseSingleFrameAction from './annotations-actions'; +import { BaseAction } from './annotations-actions/base-action'; +import { BaseCollectionAction } from './annotations-actions/base-collection-action'; +import { BaseShapesAction } from './annotations-actions/base-shapes-action'; import QualityReport from './quality-report'; import QualityConflict from './quality-conflict'; import QualitySettings from './quality-settings'; @@ -191,14 +193,14 @@ function build(): CVATCore { const result = await PluginRegistry.apiWrapper(cvat.actions.list); return result; }, - async register(action: BaseSingleFrameAction) { + async register(action: BaseAction) { const result = await PluginRegistry.apiWrapper(cvat.actions.register, action); return result; }, async run( instance: Job | Task, - actionsChain: BaseSingleFrameAction[], - actionsParameters: Record[], + actions: BaseAction, + actionsParameters: Record, frameFrom: number, frameTo: number, filters: string[], @@ -211,7 +213,7 @@ function build(): CVATCore { const result = await PluginRegistry.apiWrapper( cvat.actions.run, instance, - actionsChain, + actions, actionsParameters, frameFrom, frameTo, @@ -221,6 +223,30 @@ function build(): CVATCore { ); return result; }, + async call( + instance: Job | Task, + actions: BaseAction, + actionsParameters: Record, + frame: number, + states: ObjectState[], + onProgress: ( + message: string, + progress: number, + ) => void, + cancelled: () => boolean, + ) { + const result = await PluginRegistry.apiWrapper( + cvat.actions.call, + instance, + actions, + actionsParameters, + frame, + states, + onProgress, + cancelled, + ); + return result; + }, }, lambda: { async list() { @@ -420,7 +446,8 @@ function build(): CVATCore { Organization, Webhook, AnnotationGuide, - BaseSingleFrameAction, + BaseShapesAction, + BaseCollectionAction, QualitySettings, AnalyticsReport, QualityConflict, diff --git a/cvat-core/src/enums.ts b/cvat-core/src/enums.ts index 1b291662d213..25fdf815fa20 100644 --- a/cvat-core/src/enums.ts +++ b/cvat-core/src/enums.ts @@ -148,6 +148,7 @@ export enum HistoryActions { REMOVED_OBJECT = 'Removed object', REMOVED_FRAME = 'Removed frame', RESTORED_FRAME = 'Restored frame', + COMMIT_ANNOTATIONS = 'Commit annotations', } export enum ModelKind { diff --git a/cvat-core/src/index.ts b/cvat-core/src/index.ts index 8a4c9e8bfb53..79ce8b305a9f 100644 --- a/cvat-core/src/index.ts +++ b/cvat-core/src/index.ts @@ -34,7 +34,14 @@ import AnalyticsReport from './analytics-report'; import AnnotationGuide from './guide'; import { JobValidationLayout, TaskValidationLayout } from './validation-layout'; import { Request } from './request'; -import BaseSingleFrameAction, { listActions, registerAction, runActions } from './annotations-actions'; +import { + runAction, + callAction, + listActions, + registerAction, +} from './annotations-actions/annotations-actions'; +import { BaseCollectionAction } from './annotations-actions/base-collection-action'; +import { BaseShapesAction } from './annotations-actions/base-shapes-action'; import { ArgumentError, DataError, Exception, ScriptingError, ServerError, } from './exceptions'; @@ -165,7 +172,8 @@ export default interface CVATCore { actions: { list: typeof listActions; register: typeof registerAction; - run: typeof runActions; + run: typeof runAction; + call: typeof callAction; }; logger: typeof logger; config: { @@ -209,7 +217,8 @@ export default interface CVATCore { Organization: typeof Organization; Webhook: typeof Webhook; AnnotationGuide: typeof AnnotationGuide; - BaseSingleFrameAction: typeof BaseSingleFrameAction; + BaseShapesAction: typeof BaseShapesAction; + BaseCollectionAction: typeof BaseCollectionAction; QualityReport: typeof QualityReport; QualityConflict: typeof QualityConflict; QualitySettings: typeof QualitySettings; diff --git a/cvat-core/src/object-state.ts b/cvat-core/src/object-state.ts index 9b35736a08a1..28993a0d114c 100644 --- a/cvat-core/src/object-state.ts +++ b/cvat-core/src/object-state.ts @@ -1,5 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation -// Copyright (C) 2022-2023 CVAT.ai Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -8,6 +8,7 @@ import PluginRegistry from './plugins'; import { ArgumentError } from './exceptions'; import { Label } from './labels'; import { isEnum } from './common'; +import { SerializedShape, SerializedTag, SerializedTrack } from './server-response-types'; interface UpdateFlags { label: boolean; @@ -516,10 +517,15 @@ export default class ObjectState { const result = await PluginRegistry.apiWrapper.call(this, ObjectState.prototype.delete, frame, force); return result; } + + async export(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, ObjectState.prototype.export); + return result; + } } Object.defineProperty(ObjectState.prototype.save, 'implementation', { - value: function save(): ObjectState { + value: function saveImplementation(): ObjectState { if (this.__internal && this.__internal.save) { return this.__internal.save(this); } @@ -529,8 +535,19 @@ Object.defineProperty(ObjectState.prototype.save, 'implementation', { writable: false, }); +Object.defineProperty(ObjectState.prototype.export, 'implementation', { + value: function exportImplementation(): ObjectState { + if (this.__internal && this.__internal.export) { + return this.__internal.export(this); + } + + return this; + }, + writable: false, +}); + Object.defineProperty(ObjectState.prototype.delete, 'implementation', { - value: function remove(frame: number, force: boolean): boolean { + value: function deleteImplementation(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'); diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 904899831abf..7ea9e326fb8b 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -519,6 +519,18 @@ export function implementJob(Job: typeof JobClass): typeof JobClass { }, }); + Object.defineProperty(Job.prototype.annotations.commit, 'implementation', { + value: function commitAnnotationsImplementation( + this: JobClass, + added: Parameters[0], + removed: Parameters[1], + frame: Parameters[2], + ): ReturnType { + getCollection(this).commit(added, removed, frame); + return Promise.resolve(); + }, + }); + Object.defineProperty(Job.prototype.annotations.upload, 'implementation', { value: async function uploadAnnotationsImplementation( this: JobClass, @@ -1208,6 +1220,18 @@ export function implementTask(Task: typeof TaskClass): typeof TaskClass { }, }); + Object.defineProperty(Task.prototype.annotations.commit, 'implementation', { + value: function commitAnnotationsImplementation( + this: TaskClass, + added: Parameters[0], + removed: Parameters[1], + frame: Parameters[2], + ): ReturnType { + getCollection(this).commit(added, removed, frame); + return Promise.resolve(); + }, + }); + Object.defineProperty(Task.prototype.annotations.exportDataset, 'implementation', { value: async function exportDatasetImplementation( this: TaskClass, diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index a2bc2008aef0..b3269ee78076 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -172,6 +172,17 @@ function buildDuplicatedAPI(prototype) { return result; }, + async commit(added, removed, frame) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.commit, + added, + removed, + frame, + ); + return result; + }, + async exportDataset( format: string, saveImages: boolean, @@ -332,7 +343,7 @@ export class Session { delTrackKeyframesOnly?: boolean; }) => Promise; save: ( - onUpdate ?: (message: string) => void, + onUpdate?: (message: string) => void, ) => Promise; search: ( frameFrom: number, @@ -361,6 +372,11 @@ export class Session { }>; import: (data: Omit) => Promise; export: () => Promise>; + commit: ( + added: Omit, + removed: Omit, + frame: number, + ) => Promise; statistics: () => Promise; hasUnsavedChanges: () => boolean; exportDataset: ( @@ -431,6 +447,7 @@ export class Session { select: Object.getPrototypeOf(this).annotations.select.bind(this), import: Object.getPrototypeOf(this).annotations.import.bind(this), export: Object.getPrototypeOf(this).annotations.export.bind(this), + commit: Object.getPrototypeOf(this).annotations.commit.bind(this), statistics: Object.getPrototypeOf(this).annotations.statistics.bind(this), hasUnsavedChanges: Object.getPrototypeOf(this).annotations.hasUnsavedChanges.bind(this), exportDataset: Object.getPrototypeOf(this).annotations.exportDataset.bind(this), diff --git a/cvat-ui/package.json b/cvat-ui/package.json index a74485fa107d..703718121cd1 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.66.4", + "version": "1.67.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { 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 27898da9fa2a..b5587f39ff99 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 @@ -7,6 +7,7 @@ import './styles.scss'; import React, { useEffect, useReducer, useRef, useState, } from 'react'; +import { createRoot } from 'react-dom/client'; import Button from 'antd/lib/button'; import { Col, Row } from 'antd/lib/grid'; import Progress from 'antd/lib/progress'; @@ -22,28 +23,27 @@ import { useIsMounted } from 'utils/hooks'; import { createAction, ActionUnion } from 'utils/redux'; import { getCVATStore } from 'cvat-store'; import { - BaseSingleFrameAction, FrameSelectionType, Job, getCore, + BaseCollectionAction, BaseAction, Job, getCore, + ObjectState, } from 'cvat-core-wrapper'; import { Canvas } from 'cvat-canvas-wrapper'; -import { fetchAnnotationsAsync, saveAnnotationsAsync } from 'actions/annotation-actions'; -import { switchAutoSave } from 'actions/settings-actions'; +import { fetchAnnotationsAsync } from 'actions/annotation-actions'; import { clamp } from 'utils/math'; const core = getCore(); interface State { - actions: BaseSingleFrameAction[]; - activeAction: BaseSingleFrameAction | null; + actions: BaseAction[]; + activeAction: BaseAction | null; fetching: boolean; progress: number | null; progressMessage: string | null; cancelled: boolean; - autoSaveEnabled: boolean; - jobHasBeenSaved: boolean; frameFrom: number; frameTo: number; actionParameters: Record; modalVisible: boolean; + targetObjectState?: ObjectState | null; } enum ReducerActionType { @@ -53,8 +53,6 @@ enum ReducerActionType { RESET_BEFORE_RUN = 'RESET_BEFORE_RUN', RESET_AFTER_RUN = 'RESET_AFTER_RUN', CANCEL_ACTION = 'CANCEL_ACTION', - SET_AUTOSAVE_DISABLED_FLAG = 'SET_AUTOSAVE_DISABLED_FLAG', - SET_JOB_WAS_SAVED_FLAG = 'SET_JOB_WAS_SAVED_FLAG', UPDATE_FRAME_FROM = 'UPDATE_FRAME_FROM', UPDATE_FRAME_TO = 'UPDATE_FRAME_TO', UPDATE_ACTION_PARAMETER = 'UPDATE_ACTION_PARAMETER', @@ -62,10 +60,10 @@ enum ReducerActionType { } export const reducerActions = { - setAnnotationsActions: (actions: BaseSingleFrameAction[]) => ( + setAnnotationsActions: (actions: BaseAction[]) => ( createAction(ReducerActionType.SET_ANNOTATIONS_ACTIONS, { actions }) ), - setActiveAnnotationsAction: (activeAction: BaseSingleFrameAction) => ( + setActiveAnnotationsAction: (activeAction: BaseAction) => ( createAction(ReducerActionType.SET_ACTIVE_ANNOTATIONS_ACTION, { activeAction }) ), updateProgress: (progress: number | null, progressMessage: string | null) => ( @@ -80,12 +78,6 @@ export const reducerActions = { cancelAction: () => ( createAction(ReducerActionType.CANCEL_ACTION) ), - setAutoSaveDisabledFlag: () => ( - createAction(ReducerActionType.SET_AUTOSAVE_DISABLED_FLAG) - ), - setJobSavedFlag: (jobHasBeenSaved: boolean) => ( - createAction(ReducerActionType.SET_JOB_WAS_SAVED_FLAG, { jobHasBeenSaved }) - ), updateFrameFrom: (frameFrom: number) => ( createAction(ReducerActionType.UPDATE_FRAME_FROM, { frameFrom }) ), @@ -100,19 +92,54 @@ export const reducerActions = { ), }; +const KEEP_LATEST = 5; +let lastSelectedActions: [string, Record][] = []; +function updateLatestActions(name: string, parameters: Record = {}): void { + const idx = lastSelectedActions.findIndex((el) => el[0] === name); + if (idx === -1) { + lastSelectedActions = [[name, parameters], ...lastSelectedActions]; + } else { + lastSelectedActions = [ + [name, parameters], + ...lastSelectedActions.slice(0, idx), + ...lastSelectedActions.slice(idx + 1), + ]; + } + + lastSelectedActions = lastSelectedActions.slice(-KEEP_LATEST); +} + const reducer = (state: State, action: ActionUnion): State => { if (action.type === ReducerActionType.SET_ANNOTATIONS_ACTIONS) { + const { actions } = action.payload; + const list = state.targetObjectState ? actions + .filter((_action) => _action.isApplicableForObject(state.targetObjectState as ObjectState)) : actions; + + let activeAction = null; + let activeActionParameters = {}; + for (const item of lastSelectedActions) { + const [actionName, actionParameters] = item; + const candidate = list.find((el) => el.name === actionName); + if (candidate) { + activeAction = candidate; + activeActionParameters = actionParameters; + break; + } + } + return { ...state, - actions: action.payload.actions, - activeAction: action.payload.actions[0] || null, - actionParameters: {}, + actions: list, + activeAction: activeAction ?? list[0] ?? null, + actionParameters: activeActionParameters, }; } if (action.type === ReducerActionType.SET_ACTIVE_ANNOTATIONS_ACTION) { - const { frameSelection } = action.payload.activeAction; - if (frameSelection === FrameSelectionType.CURRENT_FRAME) { + const { activeAction } = action.payload; + updateLatestActions(activeAction.name, {}); + + if (action.payload.activeAction instanceof BaseCollectionAction) { const storage = getCVATStore(); const currentFrame = storage.getState().annotation.player.frame.number; return { @@ -123,6 +150,7 @@ const reducer = (state: State, action: ActionUnion): Stat actionParameters: {}, }; } + return { ...state, activeAction: action.payload.activeAction, @@ -163,20 +191,6 @@ const reducer = (state: State, action: ActionUnion): Stat }; } - if (action.type === ReducerActionType.SET_AUTOSAVE_DISABLED_FLAG) { - return { - ...state, - autoSaveEnabled: false, - }; - } - - if (action.type === ReducerActionType.SET_JOB_WAS_SAVED_FLAG) { - return { - ...state, - jobHasBeenSaved: action.payload.jobHasBeenSaved, - }; - } - if (action.type === ReducerActionType.UPDATE_FRAME_FROM) { return { ...state, @@ -194,12 +208,16 @@ const reducer = (state: State, action: ActionUnion): Stat } if (action.type === ReducerActionType.UPDATE_ACTION_PARAMETER) { + const updatedActionParameters = { + ...state.actionParameters, + [action.payload.name]: action.payload.value, + }; + + updateLatestActions((state.activeAction as BaseAction).name, updatedActionParameters); + return { ...state, - actionParameters: { - ...state.actionParameters, - [action.payload.name]: action.payload.value, - }, + actionParameters: updatedActionParameters, }; } @@ -213,9 +231,9 @@ const reducer = (state: State, action: ActionUnion): Stat return state; }; -type Props = NonNullable[keyof BaseSingleFrameAction['parameters']]; +type ActionParameterProps = NonNullable[keyof BaseAction['parameters']]; -function ActionParameterComponent(props: Props & { onChange: (value: string) => void }): JSX.Element { +function ActionParameterComponent(props: ActionParameterProps & { onChange: (value: string) => void }): JSX.Element { const { defaultValue, type, values, onChange, } = props; @@ -262,8 +280,13 @@ function ActionParameterComponent(props: Props & { onChange: (value: string) => ); } -function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.Element { - const { onClose } = props; +interface Props { + onClose: () => void; + targetObjectState?: ObjectState; +} + +function AnnotationsActionsModalContent(props: Props): JSX.Element { + const { onClose, targetObjectState: defaultTargetObjectState } = props; const isMounted = useIsMounted(); const storage = getCVATStore(); const cancellationRef = useRef(false); @@ -276,29 +299,27 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El progress: null, progressMessage: null, cancelled: false, - autoSaveEnabled: storage.getState().settings.workspace.autoSave, - jobHasBeenSaved: true, frameFrom: jobInstance.startFrame, frameTo: jobInstance.stopFrame, actionParameters: {}, modalVisible: true, + targetObjectState: defaultTargetObjectState ?? null, }); useEffect(() => { - core.actions.list().then((list: BaseSingleFrameAction[]) => { + core.actions.list().then((list: BaseAction[]) => { if (isMounted()) { - dispatch(reducerActions.setJobSavedFlag(!jobInstance.annotations.hasUnsavedChanges())); dispatch(reducerActions.setAnnotationsActions(list)); } }); }, []); const { - actions, activeAction, fetching, autoSaveEnabled, jobHasBeenSaved, + actions, activeAction, fetching, targetObjectState, progress, progressMessage, frameFrom, frameTo, actionParameters, modalVisible, } = state; - const currentFrameAction = activeAction?.frameSelection === FrameSelectionType.CURRENT_FRAME; + const currentFrameAction = activeAction instanceof BaseCollectionAction || targetObjectState !== null; return ( void; }): JSX.El - Actions allow executing certain algorithms on - - - filtered - - - annotations. - It affects only the local browser state. - Once an action has finished, - it cannot be reverted. - You may reload the page to get annotations from the server. - It is strongly recommended to review the changes - before saving annotations to the server. - + targetObjectState ? ( + Selected action will be applied to the current object + ) : ( +
+ Actions allow executing certain algorithms on + + + filtered + + + annotations. +
+ ) )} type='info' showIcon /> - {!jobHasBeenSaved ? ( - - - Recommendation: - - - )} - type='warning' - showIcon - /> - - ) : null} - - {autoSaveEnabled ? ( - - - Recommendation: - - - )} - type='warning' - showIcon - /> - - ) : null} - - 1. Select action + Select action
@@ -406,7 +376,7 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El }} > {actions.map( - (annotationFunction: BaseSingleFrameAction): JSX.Element => ( + (annotationFunction: BaseAction): JSX.Element => ( void; }): JSX.El
- {activeAction ? ( + {activeAction && !currentFrameAction ? ( <> - 2. Specify frames to apply the action + Specify frames to apply the action
- { - 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, - ), - )); - } - }} - /> - - - ) - } + 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, + ), + )); + } + }} + />
@@ -534,7 +495,7 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El - 3. Setup action parameters + Setup action parameters
{Object.entries(activeAction.parameters) @@ -545,7 +506,7 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El onChange={(value: string) => { dispatch(reducerActions.updateActionParameter(name, value)); }} - defaultValue={defaultValue} + defaultValue={actionParameters[name] ?? defaultValue} type={type} values={values} /> @@ -593,28 +554,43 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El if (activeAction) { cancellationRef.current = false; dispatch(reducerActions.resetBeforeRun()); + const updateProgressWrapper = (_message: string, _progress: number): void => { + if (isMounted()) { + dispatch(reducerActions.updateProgress(_progress, _message)); + } + }; - core.actions.run( + const actionPromise = targetObjectState ? core.actions.call( + jobInstance, + activeAction, + actionParameters, + storage.getState().annotation.player.frame.number, + [targetObjectState], + updateProgressWrapper, + () => cancellationRef.current, + ) : core.actions.run( jobInstance, - [activeAction], - [actionParameters], + activeAction, + actionParameters, frameFrom, frameTo, storage.getState().annotation.annotations.filters, - (_message: string, _progress: number) => { - if (isMounted()) { - dispatch(reducerActions.updateProgress(_progress, _message)); - } - }, + updateProgressWrapper, () => cancellationRef.current, - ).then(() => { + ); + + actionPromise.then(() => { if (!cancellationRef.current) { canvasInstance.setup(frameData, []); storage.dispatch(fetchAnnotationsAsync()); } }).finally(() => { if (isMounted()) { - dispatch(reducerActions.resetAfterRun()); + if (targetObjectState !== null) { + onClose(); + } else { + dispatch(reducerActions.resetAfterRun()); + } } }).catch((error: unknown) => { if (error instanceof Error) { @@ -634,4 +610,19 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El ); } -export default React.memo(AnnotationsActionsModalContent); +const MemoizedAnnotationsActionsModalContent = React.memo(AnnotationsActionsModalContent); + +export function openAnnotationsActionModal(objectState?: ObjectState): void { + const div = window.document.createElement('div'); + window.document.body.append(div); + const root = createRoot(div); + root.render( + { + root.unmount(); + div.remove(); + }} + />, + ); +} diff --git a/cvat-ui/src/components/annotation-page/annotations-actions/styles.scss b/cvat-ui/src/components/annotation-page/annotations-actions/styles.scss index 787d5685ff37..b7eae1e50242 100644 --- a/cvat-ui/src/components/annotation-page/annotations-actions/styles.scss +++ b/cvat-ui/src/components/annotation-page/annotations-actions/styles.scss @@ -15,10 +15,6 @@ margin-top: $grid-unit-size * 2; } -.cvat-action-runner-info:not(:first-child) { - margin-top: $grid-unit-size * 2; -} - .cvat-action-runner-info { .ant-alert { text-align: justify; diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx index aee51de644c0..078da4b82669 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-basics.tsx @@ -37,6 +37,7 @@ interface Props { toForegroundShortcut: string; removeShortcut: string; sliceShortcut: string; + runAnnotationsActionShortcut: string; changeColor(color: string): void; changeLabel(label: any): void; copy(): void; @@ -47,6 +48,7 @@ interface Props { toBackground(): void; toForeground(): void; resetCuboidPerspective(): void; + runAnnotationAction(): void; edit(): void; slice(): void; } @@ -72,6 +74,7 @@ function ItemTopComponent(props: Props): JSX.Element { toForegroundShortcut, removeShortcut, sliceShortcut, + runAnnotationsActionShortcut, isGroundTruth, changeColor, changeLabel, @@ -83,6 +86,7 @@ function ItemTopComponent(props: Props): JSX.Element { toBackground, toForeground, resetCuboidPerspective, + runAnnotationAction, edit, slice, jobInstance, @@ -154,6 +158,7 @@ function ItemTopComponent(props: Props): JSX.Element { toForegroundShortcut, removeShortcut, sliceShortcut, + runAnnotationsActionShortcut, changeColor, copy, remove, @@ -166,6 +171,7 @@ function ItemTopComponent(props: Props): JSX.Element { setColorPickerVisible, edit, slice, + runAnnotationAction, })} > diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-menu.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-menu.tsx index 30b239d8187a..3a18f035f4a6 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-menu.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item-menu.tsx @@ -8,6 +8,7 @@ import Button from 'antd/lib/button'; import { MenuProps } from 'antd/lib/menu'; import Icon, { LinkOutlined, CopyOutlined, BlockOutlined, RetweetOutlined, DeleteOutlined, EditOutlined, + FunctionOutlined, } from '@ant-design/icons'; import { @@ -34,6 +35,7 @@ interface Props { toBackgroundShortcut: string; toForegroundShortcut: string; removeShortcut: string; + runAnnotationsActionShortcut: string; changeColor(value: string): void; copy(): void; remove(): void; @@ -46,6 +48,7 @@ interface Props { setColorPickerVisible(visible: boolean): void; edit(): void; slice(): void; + runAnnotationAction(): void; jobInstance: Job; } @@ -232,6 +235,23 @@ function RemoveItem(props: ItemProps): JSX.Element { ); } +function RunAnnotationActionItem(props: ItemProps): JSX.Element { + const { toolProps } = props; + const { runAnnotationsActionShortcut, runAnnotationAction } = toolProps; + return ( + + + + ); +} + export default function ItemMenu(props: Props): MenuProps { const { readonly, shapeType, objectType, colorBy, jobInstance, @@ -249,6 +269,7 @@ export default function ItemMenu(props: Props): MenuProps { REMOVE_ITEM = 'remove_item', EDIT_MASK = 'edit_mask', SLICE_ITEM = 'slice_item', + RUN_ANNOTATION_ACTION = 'run_annotation_action', } const is2D = jobInstance.dimension === DimensionType.DIMENSION_2D; @@ -326,6 +347,13 @@ export default function ItemMenu(props: Props): MenuProps { }); } + if (!readonly) { + items.push({ + key: MenuKeys.RUN_ANNOTATION_ACTION, + label: , + }); + } + return { items, selectable: false, diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index 30811abad1cd..7ae46a7a71a3 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -41,6 +41,7 @@ interface Props { changeLabel(label: any): void; changeColor(color: string): void; resetCuboidPerspective(): void; + runAnnotationAction(): void; edit(): void; slice(): void; } @@ -73,6 +74,7 @@ function ObjectItemComponent(props: Props): JSX.Element { changeLabel, changeColor, resetCuboidPerspective, + runAnnotationAction, edit, slice, jobInstance, @@ -121,6 +123,7 @@ function ObjectItemComponent(props: Props): JSX.Element { removeShortcut={normalizedKeyMap.DELETE_OBJECT_STANDARD_WORKSPACE} changeColorShortcut={normalizedKeyMap.CHANGE_OBJECT_COLOR} sliceShortcut={normalizedKeyMap.SWITCH_SLICE_MODE} + runAnnotationsActionShortcut={normalizedKeyMap.RUN_ANNOTATIONS_ACTION} changeLabel={changeLabel} changeColor={changeColor} copy={copy} @@ -133,6 +136,7 @@ function ObjectItemComponent(props: Props): JSX.Element { resetCuboidPerspective={resetCuboidPerspective} edit={edit} slice={slice} + runAnnotationAction={runAnnotationAction} /> {!!attributes.length && ( diff --git a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx index f845b30233df..522f5f978b74 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx @@ -6,7 +6,6 @@ import React, { useCallback, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { useHistory } from 'react-router'; -import { createRoot } from 'react-dom/client'; import Modal from 'antd/lib/modal'; import Text from 'antd/lib/typography/Text'; import InputNumber from 'antd/lib/input-number'; @@ -22,7 +21,7 @@ import { MainMenuIcon } from 'icons'; import { Job, JobState } from 'cvat-core-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; -import AnnotationsActionsModalContent from 'components/annotation-page/annotations-actions/annotations-actions-modal'; +import { openAnnotationsActionModal } from 'components/annotation-page/annotations-actions/annotations-actions-modal'; import { CombinedState } from 'reducers'; import { updateCurrentJobAsync, finishCurrentJobAsync, @@ -179,17 +178,7 @@ function AnnotationMenuComponent(): JSX.Element { key: Actions.RUN_ACTIONS, label: 'Run actions', onClick: () => { - const div = window.document.createElement('div'); - window.document.body.append(div); - const root = createRoot(div); - root.render( - { - root.unmount(); - div.remove(); - }} - />, - ); + openAnnotationsActionModal(); }, }); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index 9cbb75bd75f2..362455a29fbf 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -20,6 +20,7 @@ import { import { ActiveControl, CombinedState, ColorBy, ShapeType, } from 'reducers'; +import { openAnnotationsActionModal } from 'components/annotation-page/annotations-actions/annotations-actions-modal'; import ObjectStateItemComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item'; import { getColor } from 'components/annotation-page/standard-workspace/objects-side-bar/shared'; import openCVWrapper from 'utils/opencv-wrapper/opencv-wrapper'; @@ -376,6 +377,11 @@ class ObjectItemContainer extends React.PureComponent { } }; + private runAnnotationAction = (): void => { + const { objectState } = this.props; + openAnnotationsActionModal(objectState); + }; + private commit(): void { const { objectState, readonly, updateState } = this.props; if (!readonly) { @@ -426,6 +432,7 @@ class ObjectItemContainer extends React.PureComponent { edit={this.edit} slice={this.slice} resetCuboidPerspective={this.resetCuboidPerspective} + runAnnotationAction={this.runAnnotationAction} /> ); } diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index 16ccdc08bff7..5df7b556ff34 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -34,6 +34,7 @@ import { filterAnnotations } from 'utils/filter-annotations'; import { registerComponentShortcuts } from 'actions/shortcuts-actions'; import { ShortcutScope } from 'utils/enums'; import { subKeyMap } from 'utils/component-subkeymap'; +import { openAnnotationsActionModal } from 'components/annotation-page/annotations-actions/annotations-actions-modal'; interface OwnProps { readonly: boolean; @@ -148,6 +149,12 @@ const componentShortcuts = { sequences: ['ctrl+c'], scope: ShortcutScope.OBJECTS_SIDEBAR, }, + RUN_ANNOTATIONS_ACTION: { + name: 'Run annotations action', + description: 'Opens a dialog with annotations actions', + sequences: ['ctrl+e'], + scope: ShortcutScope.OBJECTS_SIDEBAR, + }, PROPAGATE_OBJECT: { name: 'Propagate object', description: 'Make a copy of the object on the following frames', @@ -588,6 +595,16 @@ class ObjectsListContainer extends React.PureComponent { copyShape(state); } }, + RUN_ANNOTATIONS_ACTION: () => { + const state = activatedState(true); + if (!readonly) { + if (state) { + openAnnotationsActionModal(state); + } else { + openAnnotationsActionModal(); + } + } + }, PROPAGATE_OBJECT: (event: KeyboardEvent | undefined) => { preventDefault(event); const state = activatedState(); diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index 52f71d6044bc..ba7b47fcfa54 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -26,8 +26,8 @@ import QualitySettings, { TargetMetric } from 'cvat-core/src/quality-settings'; import { FramesMetaData, FrameData } from 'cvat-core/src/frames'; import { ServerError, RequestError } from 'cvat-core/src/exceptions'; import { - ShapeType, LabelType, ModelKind, ModelProviders, - ModelReturnType, DimensionType, JobType, + ShapeType, ObjectType, LabelType, ModelKind, ModelProviders, + ModelReturnType, DimensionType, JobType, Source, JobStage, JobState, RQStatus, StorageLocation, } from 'cvat-core/src/enums'; import { Storage, StorageData } from 'cvat-core/src/storage'; @@ -41,7 +41,9 @@ 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, FrameSelectionType } from 'cvat-core/src/annotations-actions'; +import { BaseShapesAction } from 'cvat-core/src/annotations-actions/base-shapes-action'; +import { BaseCollectionAction } from 'cvat-core/src/annotations-actions/base-collection-action'; +import { ActionParameterType, BaseAction } from 'cvat-core/src/annotations-actions/base-action'; import { Request, RequestOperation } from 'cvat-core/src/request'; const cvat: CVATCore = _cvat; @@ -69,6 +71,8 @@ export { AnnotationGuide, Attribute, ShapeType, + Source, + ObjectType, LabelType, Storage, Webhook, @@ -89,7 +93,9 @@ export { JobStage, JobState, RQStatus, - BaseSingleFrameAction, + BaseAction, + BaseShapesAction, + BaseCollectionAction, QualityReport, QualityConflict, QualitySettings, @@ -105,7 +111,6 @@ export { Event, FrameData, ActionParameterType, - FrameSelectionType, Request, JobValidationLayout, TaskValidationLayout, diff --git a/serverless/deploy_cpu.sh b/serverless/deploy_cpu.sh index 03d6f17bad67..9f37ea020a6b 100755 --- a/serverless/deploy_cpu.sh +++ b/serverless/deploy_cpu.sh @@ -25,7 +25,10 @@ do echo "Deploying $func_rel_path function..." nuctl deploy --project-name cvat --path "$func_root" \ - --file "$func_config" --platform local + --file "$func_config" --platform local \ + --env CVAT_REDIS_HOST=$(echo ${CVAT_REDIS_INMEM_HOST:-cvat_redis_ondisk}) \ + --env CVAT_REDIS_PORT=$(echo ${CVAT_REDIS_INMEM_PORT:-6666}) \ + --env CVAT_REDIS_PASSWORD=$(echo ${CVAT_REDIS_INMEM_PASSWORD}) done nuctl get function --platform local diff --git a/serverless/deploy_gpu.sh b/serverless/deploy_gpu.sh index c813a8232ad4..9c8e1515b73b 100755 --- a/serverless/deploy_gpu.sh +++ b/serverless/deploy_gpu.sh @@ -17,7 +17,10 @@ do echo "Deploying $func_rel_path function..." nuctl deploy --project-name cvat --path "$func_root" \ - --file "$func_config" --platform local + --file "$func_config" --platform local \ + --env CVAT_REDIS_HOST=$(echo ${CVAT_REDIS_INMEM_HOST:-cvat_redis_ondisk}) \ + --env CVAT_REDIS_PORT=$(echo ${CVAT_REDIS_INMEM_PORT:-6666}) \ + --env CVAT_REDIS_PASSWORD=$(echo ${CVAT_REDIS_INMEM_PASSWORD}) done nuctl get function --platform local diff --git a/tests/cypress/e2e/features/annotations_actions.js b/tests/cypress/e2e/features/annotations_actions.js index cda91f9c33ba..55fe7542c680 100644 --- a/tests/cypress/e2e/features/annotations_actions.js +++ b/tests/cypress/e2e/features/annotations_actions.js @@ -86,47 +86,6 @@ context('Testing annotations actions workflow', () => { cy.closeAnnotationsActionsModal(); }); - - it('Recommendation to save the job appears if there are unsaved changes', () => { - cy.createRectangle({ - points: 'By 2 Points', - type: 'Shape', - labelName: taskPayload.labels[0].name, - firstX: 250, - firstY: 350, - secondX: 350, - secondY: 450, - }); - - cy.openAnnotationsActionsModal(); - cy.intercept(`/api/jobs/${jobID}/annotations?**action=create**`).as('createAnnotationsRequest'); - cy.get('.cvat-action-runner-save-job-recommendation').should('exist').and('be.visible').click(); - cy.wait('@createAnnotationsRequest').its('response.statusCode').should('equal', 200); - cy.get('.cvat-action-runner-save-job-recommendation').should('not.exist'); - - cy.closeAnnotationsActionsModal(); - }); - - it('Recommendation to disable automatic saving appears in modal if automatic saving is enabled', () => { - cy.openSettings(); - cy.contains('Workspace').click(); - cy.get('.cvat-workspace-settings-auto-save').within(() => { - cy.get('[type="checkbox"]').check(); - }); - cy.closeSettings(); - - cy.openAnnotationsActionsModal(); - cy.get('.cvat-action-runner-disable-autosave-recommendation').should('exist').and('be.visible').click(); - cy.get('.cvat-action-runner-disable-autosave-recommendation').should('not.exist'); - cy.closeAnnotationsActionsModal(); - - cy.openSettings(); - cy.contains('Workspace').click(); - cy.get('.cvat-workspace-settings-auto-save').within(() => { - cy.get('[type="checkbox"]').should('not.be.checked'); - }); - cy.closeSettings(); - }); }); describe('Test action: "Remove filtered shapes"', () => { @@ -374,7 +333,7 @@ context('Testing annotations actions workflow', () => { cy.goCheckFrameNumber(latestFrameNumber); cy.get('.cvat_canvas_shape').should('have.length', 1); - cy.saveJob('PUT', 200, 'saveJob'); + cy.saveJob('PATCH', 200, 'saveJob'); const exportAnnotation = { as: 'exportAnnotations', type: 'annotations',