diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index 75e5eab63a13..45ba18777610 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -8,7 +8,7 @@ body: options: - label: I searched the existing issues and did not find anything similar. required: true - - label: I read/searched [the docs](https://github.com/cvat-ai/cvat/tree/master#documentation) + - label: I read/searched [the docs](https://opencv.github.io/cvat/docs/) required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1e0827936bc5..ab7222ad3123 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -8,7 +8,7 @@ body: options: - label: I searched the existing issues and did not find anything similar. required: true - - label: I read/searched [the docs](https://github.com/cvat-ai/cvat/tree/master#documentation) + - label: I read/searched [the docs](https://opencv.github.io/cvat/docs/) required: true - type: textarea attributes: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5967fc679be8..5872e8e68ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +## \[2.11.1\] - 2024-03-05 + +### Added + +- Single shape annotation mode allowing to easily annotate scenarious where a user +only needs to draw one object on one image () + +### Fixed + +- Fixed a problem with Korean/Chinese characters in attribute annotation mode + () + +- Fixed incorrect working time calculation in the case where an event + occurred during another event + () + +- Fixed working time not being calculated for the first event in each batch + sent from the UI + () + +- Submit button is enabled while creating a ground truth job + () + ## \[2.11.0\] - 2024-02-23 diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index f66d9ea9d74a..97302daace22 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.19.1", + "version": "2.20.0", "type": "module", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", diff --git a/cvat-canvas/src/typescript/canvasController.ts b/cvat-canvas/src/typescript/canvasController.ts index 510145a64c4d..9acf58e95fc2 100644 --- a/cvat-canvas/src/typescript/canvasController.ts +++ b/cvat-canvas/src/typescript/canvasController.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 @@ -45,13 +45,6 @@ export interface CanvasController { zoom(x: number, y: number, direction: number): void; draw(drawData: DrawData): void; edit(editData: MasksEditData): void; - interact(interactionData: InteractionData): void; - merge(mergeData: MergeData): void; - split(splitData: SplitData): void; - group(groupData: GroupData): void; - join(joinData: JoinData): void; - slice(sliceData: SliceData): void; - selectRegion(enabled: boolean): void; enableDrag(x: number, y: number): void; drag(x: number, y: number): void; disableDrag(): void; @@ -107,34 +100,6 @@ export class CanvasControllerImpl implements CanvasController { this.model.edit(editData); } - public interact(interactionData: InteractionData): void { - this.model.interact(interactionData); - } - - public merge(mergeData: MergeData): void { - this.model.merge(mergeData); - } - - public split(splitData: SplitData): void { - this.model.split(splitData); - } - - public group(groupData: GroupData): void { - this.model.group(groupData); - } - - public join(joinData: JoinData): void { - this.model.join(joinData); - } - - public slice(sliceData: SliceData): void { - this.model.slice(sliceData); - } - - public selectRegion(enable: boolean): void { - this.model.selectRegion(enable); - } - public get geometry(): Geometry { return this.model.geometry; } diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 4582d87a84f7..d4ab384b69a5 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.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 @@ -232,6 +232,16 @@ export class CanvasViewImpl implements CanvasView, Listener { } } + private dispatchCanceledEvent(): void { + this.mode = Mode.IDLE; + const event: CustomEvent = new CustomEvent('canvas.canceled', { + bubbles: false, + cancelable: true, + }); + + this.canvas.dispatchEvent(event); + } + private onInteraction = ( shapes: InteractionResult[] | null, shapesUpdated = true, @@ -256,16 +266,7 @@ export class CanvasViewImpl implements CanvasView, Listener { } if (shapes === null || isDone) { - const event: CustomEvent = new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - }); - - this.canvas.dispatchEvent(event); - this.mode = Mode.IDLE; - this.controller.interact({ - enabled: false, - }); + this.dispatchCanceledEvent(); } }; @@ -290,13 +291,7 @@ export class CanvasViewImpl implements CanvasView, Listener { const [state] = this.controller.objects .filter((_state: any): boolean => _state.clientID === clientID); this.onEditDone(state, points); - - const event: CustomEvent = new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - }); - - this.canvas.dispatchEvent(event); + this.dispatchCanceledEvent(); return; } @@ -317,10 +312,7 @@ export class CanvasViewImpl implements CanvasView, Listener { this.canvas.dispatchEvent(event); } else if (!continueDraw) { - this.canvas.dispatchEvent(new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - })); + this.dispatchCanceledEvent(); } if (continueDraw) { @@ -335,7 +327,8 @@ export class CanvasViewImpl implements CanvasView, Listener { ); } else { // when draw stops from inside canvas (for example if use predefined number of points) - this.controller.draw({ enabled: false }); + this.mode = Mode.IDLE; + this.canvas.style.cursor = ''; } }; @@ -361,6 +354,7 @@ export class CanvasViewImpl implements CanvasView, Listener { private onEditDone = (state: any, points: number[], rotation?: number): void => { this.canvas.style.cursor = ''; + this.mode = Mode.IDLE; if (state && points) { const event: CustomEvent = new CustomEvent('canvas.edited', { bubbles: false, @@ -374,18 +368,12 @@ export class CanvasViewImpl implements CanvasView, Listener { this.canvas.dispatchEvent(event); } else { - const event: CustomEvent = new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - }); - - this.canvas.dispatchEvent(event); + this.dispatchCanceledEvent(); } for (const clientID of Object.keys(this.innerObjectsFlags.editHidden)) { this.setupInnerFlags(+clientID, 'editHidden', false); } - this.mode = Mode.IDLE; }; private onMergeDone = (objects: any[] | null, duration?: number): void => { @@ -399,18 +387,11 @@ export class CanvasViewImpl implements CanvasView, Listener { }, }); + this.mode = Mode.IDLE; this.canvas.dispatchEvent(event); } else { - const event: CustomEvent = new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - }); - - this.canvas.dispatchEvent(event); + this.dispatchCanceledEvent(); } - - this.controller.merge({ enabled: false }); - this.mode = Mode.IDLE; }; private onSplitDone = (object?: any, duration?: number): void => { @@ -425,23 +406,23 @@ export class CanvasViewImpl implements CanvasView, Listener { }, }); + this.canvas.style.cursor = ''; + this.mode = Mode.IDLE; + this.splitHandler.split({ enabled: false }); this.canvas.dispatchEvent(event); } else { - const event: CustomEvent = new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - }); - - this.canvas.dispatchEvent(event); + this.dispatchCanceledEvent(); } - - this.controller.split({ enabled: false }); - this.mode = Mode.IDLE; }; private onSelectDone = (objects?: any[], duration?: number): void => { + if (this.mode === Mode.JOIN) { + this.onMessage(null, 'join'); + } + if (objects && typeof duration !== 'undefined') { if (this.mode === Mode.GROUP && objects.length > 1) { + this.mode = Mode.IDLE; this.canvas.dispatchEvent(new CustomEvent('canvas.groupped', { bubbles: false, cancelable: true, @@ -451,6 +432,7 @@ export class CanvasViewImpl implements CanvasView, Listener { }, })); } else if (this.mode === Mode.JOIN && objects.length > 1) { + this.mode = Mode.IDLE; let [left, top, right, bottom] = objects[0].points.slice(-4); objects.forEach((state) => { const [curLeft, curTop, curRight, curBottom] = state.points.slice(-4); @@ -487,26 +469,14 @@ export class CanvasViewImpl implements CanvasView, Listener { }).catch(this.onError); } } else { - const event: CustomEvent = new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - }); - - this.canvas.dispatchEvent(event); + this.dispatchCanceledEvent(); } - - if (this.mode === Mode.GROUP) { - this.controller.group({ enabled: false }); - } else if (this.mode === Mode.JOIN) { - this.controller.join({ enabled: false }); - this.onMessage(null, 'join'); - } - - this.mode = Mode.IDLE; }; private onSliceDone = (state?: any, results?: number[][], duration?: number): void => { if (state && results && typeof duration !== 'undefined') { + this.mode = Mode.IDLE; + this.sliceHandler.slice({ enabled: false }); this.canvas.dispatchEvent(new CustomEvent('canvas.sliced', { bubbles: false, cancelable: true, @@ -517,40 +487,22 @@ export class CanvasViewImpl implements CanvasView, Listener { }, })); } else { - const event: CustomEvent = new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - }); - - this.canvas.dispatchEvent(event); + this.dispatchCanceledEvent(); } - - this.controller.slice({ enabled: false }); - this.mode = Mode.IDLE; }; private onRegionSelected = (points?: number[]): void => { if (points) { - const event: CustomEvent = new CustomEvent('canvas.regionselected', { + this.canvas.dispatchEvent(new CustomEvent('canvas.regionselected', { bubbles: false, cancelable: true, detail: { points, }, - }); - - this.canvas.dispatchEvent(event); + })); } else { - const event: CustomEvent = new CustomEvent('canvas.canceled', { - bubbles: false, - cancelable: true, - }); - - this.canvas.dispatchEvent(event); + this.dispatchCanceledEvent(); } - - this.controller.selectRegion(false); - this.mode = Mode.IDLE; }; private onFindObject = (e: MouseEvent): void => { @@ -1711,8 +1663,6 @@ export class CanvasViewImpl implements CanvasView, Listener { if (data.enabled) { this.canvas.style.cursor = 'copy'; this.mode = Mode.MERGE; - } else { - this.canvas.style.cursor = ''; } this.mergeHandler.merge(data); } else if (reason === UpdateReasons.SPLIT) { @@ -1720,10 +1670,8 @@ export class CanvasViewImpl implements CanvasView, Listener { if (data.enabled) { this.canvas.style.cursor = 'copy'; this.mode = Mode.SPLIT; - } else { - this.canvas.style.cursor = ''; + this.splitHandler.split(data); } - this.splitHandler.split(data); } else if ([UpdateReasons.JOIN, UpdateReasons.GROUP].includes(reason)) { let data: GroupData | JoinData = null; if (reason === UpdateReasons.GROUP) { @@ -1745,18 +1693,12 @@ export class CanvasViewImpl implements CanvasView, Listener { objectType: ['shape'], }); } - - if (data.enabled) { - this.canvas.style.cursor = 'copy'; - } else { - this.canvas.style.cursor = ''; - } } else if (reason === UpdateReasons.SLICE) { const data = this.controller.sliceData; if (data.enabled && this.mode === Mode.IDLE) { this.mode = Mode.SLICE; + this.sliceHandler.slice(data); } - this.sliceHandler.slice(data); } else if (reason === UpdateReasons.SELECT) { this.objectSelector.push(this.controller.selected); if (this.mode === Mode.MERGE) { @@ -1805,8 +1747,8 @@ export class CanvasViewImpl implements CanvasView, Listener { }), ); } - this.mode = Mode.IDLE; this.canvas.style.cursor = ''; + this.dispatchCanceledEvent(); } else if (reason === UpdateReasons.DATA_FAILED) { this.onError(model.exception, 'data fetching'); } else if (reason === UpdateReasons.DESTROY) { diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index 75127693b297..b7e9cbb90130 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -1,5 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -388,8 +388,11 @@ export class DrawHandlerImpl implements DrawHandler { } // Clear drawing this.drawInstance.draw('stop'); - } else if (this.drawInstance && this.drawData.shapeType === 'ellipse' && !this.drawData.initialState) { - this.drawInstance.fire('drawstop'); + } else { + this.onDrawDone(null); + if (this.drawInstance && this.drawData.shapeType === 'ellipse' && !this.drawData.initialState) { + this.drawInstance.fire('drawstop'); + } } if (this.pointsGroup) { @@ -409,8 +412,6 @@ export class DrawHandlerImpl implements DrawHandler { if (this.crosshair) { this.removeCrosshair(); } - - this.onDrawDone(null); } private initDrawing(): void { @@ -426,9 +427,12 @@ export class DrawHandlerImpl implements DrawHandler { const points = readPointsFromShape((e.target as any as { instance: SVG.Rect }).instance); const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(points, true); const { shapeType, redraw: clientID } = this.drawData; - this.release(); - if (this.canceled) return; + if (this.canceled) { + return; + } + + this.release(); if (checkConstraint('rectangle', [xtl, ytl, xbr, ybr])) { this.onDrawDone({ clientID, @@ -436,6 +440,8 @@ export class DrawHandlerImpl implements DrawHandler { points: [xtl, ytl, xbr, ybr], }, Date.now() - this.startTimestamp); + } else { + this.onDrawDone(null); } }) .on('drawupdate', (): void => { @@ -467,11 +473,13 @@ export class DrawHandlerImpl implements DrawHandler { }; this.canvas.on('mousedown.draw', (e: MouseEvent): void => { - if (initialPoint.x === null || initialPoint.y === null) { - const translated = translateToSVG(this.canvas.node as any as SVGSVGElement, [e.clientX, e.clientY]); - [initialPoint.x, initialPoint.y] = translated; - } else { - this.drawInstance.fire('drawstop'); + if (e.button === 0 && !e.altKey) { + if (initialPoint.x === null || initialPoint.y === null) { + const translated = translateToSVG(this.canvas.node as any as SVGSVGElement, [e.clientX, e.clientY]); + [initialPoint.x, initialPoint.y] = translated; + } else { + this.drawInstance.fire('drawstop'); + } } }); @@ -492,9 +500,12 @@ export class DrawHandlerImpl implements DrawHandler { this.drawInstance.off('drawstop'); const points = this.getFinalEllipseCoordinates(readPointsFromShape(this.drawInstance), false); const { shapeType, redraw: clientID } = this.drawData; - this.release(); - if (this.canceled) return; + if (this.canceled) { + return; + } + + this.release(); if (checkConstraint('ellipse', points)) { this.onDrawDone( { @@ -504,6 +515,8 @@ export class DrawHandlerImpl implements DrawHandler { }, Date.now() - this.startTimestamp, ); + } else { + this.onDrawDone(null); } }); } @@ -623,9 +636,12 @@ export class DrawHandlerImpl implements DrawHandler { const { points, box } = shapeType === 'cuboid' ? this.getFinalCuboidCoordinates(targetPoints) : this.getFinalPolyshapeCoordinates(targetPoints, true); - this.release(); - if (this.canceled) return; + if (this.canceled) { + return; + } + + this.release(); if (checkConstraint(shapeType, points, box)) { if (shapeType === 'cuboid') { this.onDrawDone( @@ -636,6 +652,8 @@ export class DrawHandlerImpl implements DrawHandler { } this.onDrawDone({ clientID, shapeType, points }, Date.now() - this.startTimestamp); + } else { + this.onDrawDone(null); } }); } @@ -699,9 +717,12 @@ export class DrawHandlerImpl implements DrawHandler { const points = readPointsFromShape((e.target as any as { instance: SVG.Rect }).instance); const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(points, true); const { shapeType, redraw: clientID } = this.drawData; - this.release(); - if (this.canceled) return; + if (this.canceled) { + return; + } + + this.release(); if (checkConstraint('cuboid', [xtl, ytl, xbr, ybr])) { const d = { x: (xbr - xtl) * 0.1, y: (ybr - ytl) * 0.1 }; this.onDrawDone({ @@ -710,6 +731,8 @@ export class DrawHandlerImpl implements DrawHandler { clientID, }, Date.now() - this.startTimestamp); + } else { + this.onDrawDone(null); } }) .on('drawupdate', (): void => { @@ -765,9 +788,12 @@ export class DrawHandlerImpl implements DrawHandler { }); const { shapeType, redraw: clientID } = this.drawData; - this.release(); - if (this.canceled) return; + if (this.canceled) { + return; + } + + this.release(); if (checkConstraint('skeleton', [xtl, ytl, xbr, ybr])) { this.onDrawDone({ clientID, @@ -775,6 +801,8 @@ export class DrawHandlerImpl implements DrawHandler { elements, }, Date.now() - this.startTimestamp); + } else { + this.onDrawDone(null); } }) .on('drawupdate', (): void => { diff --git a/cvat-canvas/src/typescript/masksHandler.ts b/cvat-canvas/src/typescript/masksHandler.ts index cd7aa58d69fe..7a12789ee276 100644 --- a/cvat-canvas/src/typescript/masksHandler.ts +++ b/cvat-canvas/src/typescript/masksHandler.ts @@ -138,7 +138,6 @@ export class MasksHandlerImpl implements MasksHandler { this.isInsertion = false; this.redraw = null; this.drawnObjects = this.createDrawnObjectsArray(); - this.onDrawDone(null); } private releaseEdit(): void { @@ -614,6 +613,8 @@ export class MasksHandlerImpl implements MasksHandler { ...(Number.isInteger(this.redraw) ? { clientID: this.redraw } : {}), }, Date.now() - this.startTimestamp, drawData.continue, this.drawData); } + } else { + this.onDrawDone(null); } } finally { this.releaseDraw(); @@ -627,6 +628,8 @@ export class MasksHandlerImpl implements MasksHandler { enabled: true, shapeType: 'mask', }; + + this.onDrawRepeat({ enabled: true, shapeType: 'mask' }); this.onDrawRepeat(newDrawData); return; } diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index 923a5c573f90..84eed943a020 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,3 +1,3 @@ -cvat-sdk~=2.11.0 +cvat-sdk~=2.11.1 Pillow>=10.1.0 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index e80dd8624369..fd36d7a74976 100644 --- a/cvat-cli/src/cvat_cli/version.py +++ b/cvat-cli/src/cvat_cli/version.py @@ -1 +1 @@ -VERSION = "2.11.0" +VERSION = "2.11.1" diff --git a/cvat-core/package.json b/cvat-core/package.json index 9e025040d04b..81a1937817f8 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "14.1.1", + "version": "15.0.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-collection.ts b/cvat-core/src/annotations-collection.ts index 86221e2bbefa..ed380838fa10 100644 --- a/cvat-core/src/annotations-collection.ts +++ b/cvat-core/src/annotations-collection.ts @@ -1208,17 +1208,30 @@ export default class Collection { }; } - searchEmpty(frameFrom: number, frameTo: number): number | null { + _searchEmpty( + frameFrom: number, + frameTo: number, + searchParameters: { + allowDeletedFrames: boolean; + }, + ): number | null { + const { allowDeletedFrames } = searchParameters; const sign = Math.sign(frameTo - frameFrom); const predicate = sign > 0 ? (frame) => frame <= frameTo : (frame) => frame >= frameTo; const update = sign > 0 ? (frame) => frame + 1 : (frame) => frame - 1; for (let frame = frameFrom; predicate(frame); frame = update(frame)) { + if (!allowDeletedFrames && this.frameMeta[frame].deleted) { + continue; + } + if (frame in this.shapes && this.shapes[frame].some((shape) => !shape.removed)) { continue; } + if (frame in this.tags && this.tags[frame].some((tag) => !tag.removed)) { continue; } + const filteredTracks = this.tracks.filter((track) => !track.removed); let found = false; for (const track of filteredTracks) { @@ -1241,14 +1254,58 @@ export default class Collection { return null; } - search(filters: string[], frameFrom: number, frameTo: number): number | null { - const sign = Math.sign(frameTo - frameFrom); - const filtersStr = JSON.stringify(filters); - const linearSearch = filtersStr.match(/"var":"width"/) || filtersStr.match(/"var":"height"/); + search( + frameFrom: number, + frameTo: number, + searchParameters: { + allowDeletedFrames: boolean; + annotationsFilters?: object[]; + generalFilters?: { + isEmptyFrame?: boolean; + }; + }, + ): number | null { + const { allowDeletedFrames } = searchParameters; + let { annotationsFilters } = searchParameters; + + if ('generalFilters' in searchParameters) { + // if we are looking for en empty frame, run a dedicated algorithm + if (searchParameters.generalFilters.isEmptyFrame) { + return this._searchEmpty(frameFrom, frameTo, { allowDeletedFrames }); + } + + // not empty frames corresponds to default behaviour of the function with empty annotation filters + annotationsFilters = []; + } + const sign = Math.sign(frameTo - frameFrom); const predicate = sign > 0 ? (frame) => frame <= frameTo : (frame) => frame >= frameTo; const update = sign > 0 ? (frame) => frame + 1 : (frame) => frame - 1; + + // if not looking for an emty frame nor frame with annotations, return the next frame + // check if deleted frames are allowed additionally + if (!annotationsFilters) { + let frame = frameFrom; + while (predicate(frame)) { + if (!allowDeletedFrames && this.frameMeta[frame].deleted) { + frame = update(frame); + continue; + } + + return frame; + } + + return null; + } + + const filtersStr = JSON.stringify(annotationsFilters); + const linearSearch = filtersStr.match(/"var":"width"/) || filtersStr.match(/"var":"height"/); + for (let frame = frameFrom; predicate(frame); frame = update(frame)) { + if (!allowDeletedFrames && this.frameMeta[frame].deleted) { + continue; + } + // First prepare all data for the frame // Consider all shapes, tags, and not outside tracks that have keyframe here // In particular consider first and last frame as keyframes for all tracks @@ -1267,13 +1324,8 @@ export default class Collection { .filter((track) => !track.removed); statesData.push(...tracks.map((track) => track.get(frame)).filter((state) => !state.outside)); - // Nothing to filtering, go to the next iteration - if (!statesData.length) { - continue; - } - // Filtering - const filtered = this.annotationsFilter.filter(statesData, filters); + const filtered = this.annotationsFilter.filter(statesData, annotationsFilters); if (filtered.length) { return frame; } diff --git a/cvat-core/src/annotations-filter.ts b/cvat-core/src/annotations-filter.ts index c47c15ff334b..58c9e82a63e5 100644 --- a/cvat-core/src/annotations-filter.ts +++ b/cvat-core/src/annotations-filter.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 @@ -89,7 +89,7 @@ export default class AnnotationsFilter { return objects; } - filter(statesData: SerializedData[], filters: string[]): number[] { + filter(statesData: SerializedData[], filters: object[]): number[] { if (!filters.length) return statesData.map((stateData): number => stateData.clientID); const converted = this._convertObjects(statesData); return converted diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 412cecde88d8..a6aa0a668238 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -1,5 +1,5 @@ // Copyright (C) 2021-2022 Intel Corporation -// Copyright (C) 2022-2023 CVAT.ai Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -612,7 +612,11 @@ export async function findFrame( const offset = filters.offset || 1; let meta; if (!frameDataCache[jobID]) { - meta = await serverProxy.frames.getMeta('job', jobID); + const serverMeta = await serverProxy.frames.getMeta('job', jobID); + meta = { + ...serverMeta, + deleted_frames: Object.fromEntries(serverMeta.deleted_frames.map((_frame) => [_frame, true])), + }; } else { meta = frameDataCache[jobID].meta; } diff --git a/cvat-core/src/logger.ts b/cvat-core/src/logger.ts index cfb3861e9462..d13b1d7a6fe3 100644 --- a/cvat-core/src/logger.ts +++ b/cvat-core/src/logger.ts @@ -34,6 +34,7 @@ type IgnoredRules = EventScope.zoomImage | EventScope.changeAttribute | EventSco class Logger { public clientID: string; public collection: Array; + public lastSentEvent: Event | null; public ignoreRules: Record; public isActiveChecker: (() => boolean) | null; public saving: boolean; @@ -42,6 +43,7 @@ class Logger { constructor() { this.clientID = Date.now().toString().substr(-6); this.collection = []; + this.lastSentEvent = null; this.isActiveChecker = null; this.saving = false; this.compressedScopes = [EventScope.changeFrame]; @@ -209,9 +211,12 @@ Object.defineProperties(Logger.prototype.save, { this.collection = []; await serverProxy.events.save({ events: collectionToSend.map((event) => event.dump()), + previous_event: this.lastSentEvent?.dump(), timestamp: new Date().toISOString(), }); + this.lastSentEvent = collectionToSend[collectionToSend.length - 1]; + for (const rule of Object.values(this.ignoreRules)) { rule.lastEvent = null; } diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 44e8639a8796..124424f78fb8 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -231,27 +231,19 @@ export function implementJob(Job) { return annotationsData; }; - Job.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) { - if (!Array.isArray(filters)) { - throw new ArgumentError('Filters must be an array'); - } - - if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { - throw new ArgumentError('The start and end frames both must be an integer'); + Job.prototype.annotations.search.implementation = function (frameFrom, frameTo, searchParameters) { + if ('annotationsFilters' in searchParameters && !Array.isArray(searchParameters.annotationsFilters)) { + throw new ArgumentError('Annotations filters must be an array'); } - if (frameFrom < this.startFrame || frameFrom > this.stopFrame) { - throw new ArgumentError('The start frame is out of the job'); + if ('generalFilters' in searchParameters && typeof searchParameters.generalFilters.isEmptyFrame !== 'boolean') { + throw new ArgumentError('General filter isEmptyFrame must be a boolean'); } - if (frameTo < this.startFrame || frameTo > this.stopFrame) { - throw new ArgumentError('The stop frame is out of the job'); + if ('annotationsFilters' in searchParameters && 'generalFilters' in searchParameters) { + throw new ArgumentError('Both annotations filters and general fiters could not be used together'); } - return getCollection(this).search(filters, frameFrom, frameTo); - }; - - Job.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) { if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { throw new ArgumentError('The start and end frames both must be an integer'); } @@ -264,7 +256,7 @@ export function implementJob(Job) { throw new ArgumentError('The stop frame is out of the job'); } - return getCollection(this).searchEmpty(frameFrom, frameTo); + return getCollection(this).search(frameFrom, frameTo, searchParameters); }; Job.prototype.annotations.save.implementation = async function (onUpdate) { @@ -682,7 +674,7 @@ export function implementTask(Task) { throw new ArgumentError(`Frame ${frame} does not exist in the task`); } - const result = await getAnnotations(this, frame, allTracks, filters, null); + const result = await getAnnotations(this, frame, allTracks, filters); const deletedFrames = await getDeletedFrames('task', this.id); if (frame in deletedFrames) { return []; @@ -691,27 +683,19 @@ export function implementTask(Task) { return result; }; - Task.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) { - if (!Array.isArray(filters) || filters.some((filter) => typeof filter !== 'string')) { - throw new ArgumentError('The filters argument must be an array of strings'); - } - - if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { - throw new ArgumentError('The start and end frames both must be an integer'); + Task.prototype.annotations.search.implementation = function (frameFrom, frameTo, searchParameters) { + if ('annotationsFilters' in searchParameters && !Array.isArray(searchParameters.annotationsFilters)) { + throw new ArgumentError('Annotations filters must be an array'); } - if (frameFrom < 0 || frameFrom >= this.size) { - throw new ArgumentError('The start frame is out of the task'); + if ('generalFilters' in searchParameters && typeof searchParameters.generalFilters.isEmptyFrame !== 'boolean') { + throw new ArgumentError('General filter isEmptyFrame must be a boolean'); } - if (frameTo < 0 || frameTo >= this.size) { - throw new ArgumentError('The stop frame is out of the task'); + if ('annotationsFilters' in searchParameters && 'generalFilters' in searchParameters) { + throw new ArgumentError('Both annotations filters and general fiters could not be used together'); } - return getCollection(this).search(filters, frameFrom, frameTo); - }; - - Task.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) { if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { throw new ArgumentError('The start and end frames both must be an integer'); } @@ -724,7 +708,7 @@ export function implementTask(Task) { throw new ArgumentError('The stop frame is out of the task'); } - return getCollection(this).searchEmpty(frameFrom, frameTo); + return getCollection(this).search(frameFrom, frameTo, searchParameters); }; Task.prototype.annotations.save.implementation = async function (onUpdate) { diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 8c9aea99aa69..e1e8f46c732a 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.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 @@ -82,23 +82,13 @@ function buildDuplicatedAPI(prototype) { return result; }, - async search(filters, frameFrom, frameTo) { + async search(frameFrom, frameTo, searchParameters) { const result = await PluginRegistry.apiWrapper.call( this, prototype.annotations.search, - filters, - frameFrom, - frameTo, - ); - return result; - }, - - async searchEmpty(frameFrom, frameTo) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.searchEmpty, frameFrom, frameTo, + searchParameters, ); return result; }, @@ -324,7 +314,6 @@ export class Session { slice: CallableFunction; clear: CallableFunction; search: CallableFunction; - searchEmpty: CallableFunction; upload: CallableFunction; select: CallableFunction; import: CallableFunction; @@ -377,7 +366,6 @@ export class Session { slice: Object.getPrototypeOf(this).annotations.slice.bind(this), clear: Object.getPrototypeOf(this).annotations.clear.bind(this), search: Object.getPrototypeOf(this).annotations.search.bind(this), - searchEmpty: Object.getPrototypeOf(this).annotations.searchEmpty.bind(this), upload: Object.getPrototypeOf(this).annotations.upload.bind(this), select: Object.getPrototypeOf(this).annotations.select.bind(this), import: Object.getPrototypeOf(this).annotations.import.bind(this), diff --git a/cvat-core/tests/api/annotations.cjs b/cvat-core/tests/api/annotations.cjs index fc3e9d00fea8..724ecfa06c95 100644 --- a/cvat-core/tests/api/annotations.cjs +++ b/cvat-core/tests/api/annotations.cjs @@ -1,5 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation -// Copyright (C) 2022-2023 CVAT.ai Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -956,29 +956,29 @@ describe('Feature: search frame', () => { test('applying different filters', async () => { const job = (await cvat.jobs.get({ jobID: 102 }))[0]; await job.annotations.clear(true); - let frame = await job.annotations.search(JSON.parse('[{"and":[{"==":[{"var":"type"},"tag"]}]}]'), 495, 994); + let frame = await job.annotations.search(495, 994, { annotationsFilters: JSON.parse('[{"and":[{"==":[{"var":"type"},"tag"]}]}]') }); expect(frame).toBe(500); - frame = await job.annotations.search(JSON.parse('[{"and":[{"==":[{"var":"type"},"tag"]},{"==":[{"var":"label"},"bicycle"]}]}]'), 495, 994); + frame = await job.annotations.search(495, 994, { annotationsFilters: JSON.parse('[{"and":[{"==":[{"var":"type"},"tag"]},{"==":[{"var":"label"},"bicycle"]}]}]') }); expect(frame).toBe(500); - frame = await job.annotations.search(JSON.parse('[{"and":[{"==":[{"var":"type"},"track"]},{"==":[{"var":"label"},"bicycle"]}]}]'), 495, 994); + frame = await job.annotations.search(495, 994, { annotationsFilters: JSON.parse('[{"and":[{"==":[{"var":"type"},"track"]},{"==":[{"var":"label"},"bicycle"]}]}]') }); expect(frame).toBe(null); - frame = await job.annotations.search(JSON.parse('[{"and":[{"==":[{"var":"type"},"shape"]},{"==":[{"var":"shape"},"rectangle"]}]}]'), 495, 994); + frame = await job.annotations.search(495, 994, { annotationsFilters: JSON.parse('[{"and":[{"==":[{"var":"type"},"shape"]},{"==":[{"var":"shape"},"rectangle"]}]}]') }); expect(frame).toBe(510); - frame = await job.annotations.search(JSON.parse('[{"and":[{"==":[{"var":"type"},"shape"]},{"==":[{"var":"shape"},"rectangle"]}]}]'), 511, 994); + frame = await job.annotations.search(511, 994, { annotationsFilters: JSON.parse('[{"and":[{"==":[{"var":"type"},"shape"]},{"==":[{"var":"shape"},"rectangle"]}]}]') }); expect(frame).toBe(null); - frame = await job.annotations.search(JSON.parse('[{"and":[{"==":[{"var":"type"},"shape"]},{"==":[{"var":"shape"},"polygon"]}]}]'), 511, 994); + frame = await job.annotations.search(511, 994, { annotationsFilters: JSON.parse('[{"and":[{"==":[{"var":"type"},"shape"]},{"==":[{"var":"shape"},"polygon"]}]}]') }); expect(frame).toBe(520); - frame = await job.annotations.search(JSON.parse('[{"and":[{"==":[{"var":"attr.motorcycle.model"},"some text for test"]}]}]'), 495, 994); + frame = await job.annotations.search(495, 994, { annotationsFilters: JSON.parse('[{"and":[{"==":[{"var":"attr.motorcycle.model"},"some text for test"]}]}]') }); expect(frame).toBe(520); - frame = await job.annotations.search(JSON.parse('[{"and":[{"==":[{"var":"attr.motorcycle.model"},"some text for test"]},{"==":[{"var":"shape"},"ellipse"]}]}]'), 495, 994); + frame = await job.annotations.search(495, 994, { annotationsFilters: JSON.parse('[{"and":[{"==":[{"var":"attr.motorcycle.model"},"some text for test"]},{"==":[{"var":"shape"},"ellipse"]}]}]') }); expect(frame).toBe(null); - frame = await job.annotations.search(JSON.parse('[{"and":[{"<=":[450,{"var":"width"},550]}]}]'), 540, 994); + frame = await job.annotations.search(540, 994, { annotationsFilters: JSON.parse('[{"and":[{"<=":[450,{"var":"width"},550]}]}]') }); expect(frame).toBe(563); - frame = await job.annotations.search(JSON.parse('[{"and":[{"<=":[450,{"var":"width"},550]}]}]'), 588, 994); + frame = await job.annotations.search(588, 994, { annotationsFilters: JSON.parse('[{"and":[{"<=":[450,{"var":"width"},550]}]}]') }); expect(frame).toBe(null); - frame = await job.annotations.search(JSON.parse('[{"and":[{">=":[{"var":"width"},500]},{"<=":[{"var":"height"},300]}]}]'), 540, 994); + frame = await job.annotations.search(540, 994, { annotationsFilters: JSON.parse('[{"and":[{">=":[{"var":"width"},500]},{"<=":[{"var":"height"},300]}]}]') }); expect(frame).toBe(575); }); }); diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index 0ce2af39efd0..0c0e04c70fe4 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.11.0" +VERSION="2.11.1" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 21fe7cd13727..2211771e9038 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.62.0", + "version": "1.63.1", "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 d07c62cad1d6..091fe1e80d55 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -23,6 +23,7 @@ import { CombinedState, ContextMenuType, FrameSpeed, + NavigationType, ObjectType, OpenCVTool, Rotation, @@ -175,7 +176,6 @@ export enum AnnotationActionTypes { SWITCH_Z_LAYER = 'SWITCH_Z_LAYER', ADD_Z_LAYER = 'ADD_Z_LAYER', SEARCH_ANNOTATIONS_FAILED = 'SEARCH_ANNOTATIONS_FAILED', - SEARCH_EMPTY_FRAME_FAILED = 'SEARCH_EMPTY_FRAME_FAILED', CHANGE_WORKSPACE = 'CHANGE_WORKSPACE', SAVE_LOGS_SUCCESS = 'SAVE_LOGS_SUCCESS', SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED', @@ -184,6 +184,7 @@ export enum AnnotationActionTypes { CANVAS_ERROR_OCCURRED = 'CANVAS_ERROR_OCCURRED', SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG = 'SET_FORCE_EXIT_ANNOTATION_PAGE_FLAG', SWITCH_NAVIGATION_BLOCKED = 'SWITCH_NAVIGATION_BLOCKED', + SET_NAVIGATION_TYPE = 'SET_NAVIGATION_TYPE', DELETE_FRAME = 'DELETE_FRAME', DELETE_FRAME_SUCCESS = 'DELETE_FRAME_SUCCESS', DELETE_FRAME_FAILED = 'DELETE_FRAME_FAILED', @@ -704,7 +705,9 @@ export function changeFrameAsync( Math.round(1000 / frameSpeed) - currentTime + (state.annotation.player.frame.changeTime as number), ); - const { states, maxZ, minZ } = await fetchAnnotations(toFrame); + const { + states, maxZ, minZ, history, + } = await fetchAnnotations(toFrame); dispatch({ type: AnnotationActionTypes.CHANGE_FRAME_SUCCESS, payload: { @@ -713,6 +716,7 @@ export function changeFrameAsync( filename: data.filename, relatedFiles: data.relatedFiles, states, + history, minZ, maxZ, curZ: maxZ, @@ -866,13 +870,18 @@ export function closeJob(): ThunkAction { } export function getJobAsync({ - taskID, jobID, initialFrame, initialFilters, initialOpenGuide, + taskID, jobID, initialFrame, initialFilters, queryParameters, }: { taskID: number; jobID: number; initialFrame: number | null; initialFilters: object[]; - initialOpenGuide: boolean; + queryParameters: { + initialOpenGuide: boolean; + initialWorkspace: Workspace | null; + defaultLabel: string | null; + defaultPointsCount: number | null; + } }): ThunkAction { return async (dispatch: ActionCreator, getState): Promise => { try { @@ -960,7 +969,7 @@ export function getJobAsync({ payload: { openTime, job, - initialOpenGuide, + queryParameters, groundTruthInstance: gtJob || null, groundTruthJobFramesMeta, issues, @@ -1237,7 +1246,14 @@ export function changeGroupColorAsync(group: number, color: string): ThunkAction }; } -export function searchAnnotationsAsync(sessionInstance: NonNullable, frameFrom: number, frameTo: number): ThunkAction { +export function searchAnnotationsAsync( + sessionInstance: NonNullable, + frameFrom: number, + frameTo: number, + generalFilters?: { + isEmptyFrame: boolean; + }, +): ThunkAction { return async (dispatch: ActionCreator, getState): Promise => { try { const { @@ -1249,18 +1265,17 @@ export function searchAnnotationsAsync(sessionInstance: NonNullable 0 ? frame < frameTo : frame > frameTo) { - frame = await sessionInstance.annotations.search(filters, frame + sign, frameTo); - } else { - frame = null; - } - } + const frame = await sessionInstance.annotations + .search( + frameFrom, + frameTo, + { + allowDeletedFrames: showDeletedFrames, + ...( + generalFilters ? { generalFilters } : { annotationsFilters: filters } + ), + }, + ); if (frame !== null) { dispatch(changeFrameAsync(frame)); } @@ -1275,41 +1290,6 @@ export function searchAnnotationsAsync(sessionInstance: NonNullable, frameFrom: number, frameTo: number): ThunkAction { - return async (dispatch: ActionCreator, getState): Promise => { - try { - const { - settings: { - player: { showDeletedFrames }, - }, - } = getState(); - - const sign = Math.sign(frameTo - frameFrom); - let frame = await sessionInstance.annotations.searchEmpty(frameFrom, frameTo); - while (frame !== null) { - const isDeleted = (await sessionInstance.frames.get(frame)).deleted; - if (!isDeleted || showDeletedFrames) { - break; - } else if (sign > 0 ? frame < frameTo : frame > frameTo) { - frame = await sessionInstance.annotations.searchEmpty(frame + sign, frameTo); - } else { - frame = null; - } - } - if (frame !== null) { - dispatch(changeFrameAsync(frame)); - } - } catch (error) { - dispatch({ - type: AnnotationActionTypes.SEARCH_EMPTY_FRAME_FAILED, - payload: { - error, - }, - }); - } - }; -} - const ShapeTypeToControl: Record = { [ShapeType.RECTANGLE]: ActiveControl.DRAW_RECTANGLE, [ShapeType.POLYLINE]: ActiveControl.DRAW_POLYLINE, @@ -1415,6 +1395,10 @@ export function repeatDrawShapeAsync(): ThunkAction { activeControl = ShapeTypeToControl[activeShapeType]; + if (canvasInstance instanceof Canvas) { + canvasInstance.cancel(); + } + dispatch({ type: AnnotationActionTypes.REPEAT_DRAW_SHAPE, payload: { @@ -1422,10 +1406,6 @@ export function repeatDrawShapeAsync(): ThunkAction { }, }); - if (canvasInstance instanceof Canvas) { - canvasInstance.cancel(); - } - const [activeLabel] = labels.filter((label: any) => label.id === activeLabelID); if (!activeLabel) { throw new Error(`Label with ID ${activeLabelID}, was not found`); @@ -1466,6 +1446,10 @@ export function redrawShapeAsync(): ThunkAction { const [state] = states.filter((_state: any): boolean => _state.clientID === activatedStateID); if (state && state.objectType !== ObjectType.TAG) { const activeControl = ShapeTypeToControl[state.shapeType as ShapeType] || ActiveControl.CURSOR; + if (canvasInstance instanceof Canvas) { + canvasInstance.cancel(); + } + dispatch({ type: AnnotationActionTypes.REPEAT_DRAW_SHAPE, payload: { @@ -1473,10 +1457,6 @@ export function redrawShapeAsync(): ThunkAction { }, }); - if (canvasInstance instanceof Canvas) { - canvasInstance.cancel(); - } - canvasInstance.draw({ skeletonSVG: state.shapeType === ShapeType.SKELETON ? state.label.structure.svg : undefined, enabled: true, @@ -1507,6 +1487,15 @@ export function switchNavigationBlocked(navigationBlocked: boolean): AnyAction { }; } +export function setNavigationType(navigationType: NavigationType): AnyAction { + return { + type: AnnotationActionTypes.SET_NAVIGATION_TYPE, + payload: { + navigationType, + }, + }; +} + export function deleteFrameAsync(frame: number): ThunkAction { return async (dispatch: ActionCreator): Promise => { const { jobInstance } = receiveAnnotationsParameters(); diff --git a/cvat-ui/src/components/annotation-page/annotation-page.tsx b/cvat-ui/src/components/annotation-page/annotation-page.tsx index 670fd3ce996b..46ef41ad5c2f 100644 --- a/cvat-ui/src/components/annotation-page/annotation-page.tsx +++ b/cvat-ui/src/components/annotation-page/annotation-page.tsx @@ -13,6 +13,7 @@ import Button from 'antd/lib/button'; import './styles.scss'; import AttributeAnnotationWorkspace from 'components/annotation-page/attribute-annotation-workspace/attribute-annotation-workspace'; +import SingleShapeWorkspace from 'components/annotation-page/single-shape-workspace/single-shape-workspace'; import ReviewAnnotationsWorkspace from 'components/annotation-page/review-workspace/review-workspace'; import StandardWorkspaceComponent from 'components/annotation-page/standard-workspace/standard-workspace'; import StandardWorkspace3DComponent from 'components/annotation-page/standard3D-workspace/standard3D-workspace'; @@ -146,9 +147,10 @@ export default function AnnotationPageComponent(props: Props): JSX.Element { {workspace === Workspace.STANDARD3D && } {workspace === Workspace.STANDARD && } - {workspace === Workspace.ATTRIBUTE_ANNOTATION && } - {workspace === Workspace.TAG_ANNOTATION && } - {workspace === Workspace.REVIEW_WORKSPACE && } + {workspace === Workspace.SINGLE_SHAPE && } + {workspace === Workspace.ATTRIBUTES && } + {workspace === Workspace.TAGS && } + {workspace === Workspace.REVIEW && } diff --git a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-editor.tsx b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-editor.tsx index 707a7a6c556c..da5e4d2079aa 100644 --- a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-editor.tsx +++ b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-editor.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -30,6 +31,7 @@ function renderInputElement(parameters: InputElementParameters): JSX.Element { const ref = useRef(null); const [selectionStart, setSelectionStart] = useState(currentValue.length); + const [localAttrValue, setAttributeValue] = useState(currentValue); useEffect(() => { const textArea = ref?.current?.resizableTextArea?.textArea; @@ -39,13 +41,28 @@ function renderInputElement(parameters: InputElementParameters): JSX.Element { } }, [currentValue]); + useEffect(() => { + // attribute value updated from inside the app (for example undo/redo) + if (currentValue !== localAttrValue) { + setAttributeValue(currentValue); + } + }, [currentValue]); + + useEffect(() => { + // wrap to internal use effect to avoid issues + // with chinese keyboard + if (localAttrValue !== currentValue) { + onChange(localAttrValue); + } + }, [localAttrValue]); + const renderCheckbox = (): JSX.Element => ( <> Checkbox:
onChange(event.target.checked ? 'true' : 'false')} - checked={currentValue === 'true'} + onChange={(event: CheckboxChangeEvent): void => setAttributeValue(event.target.checked ? 'true' : 'false')} + checked={localAttrValue === 'true'} />
@@ -56,9 +73,9 @@ function renderInputElement(parameters: InputElementParameters): JSX.Element { Values:
dispatch( + reducerActions.setActiveLabel(state.label as Label, labelType), + )} + > + + + + + + + + + + + + )} + + + { + (window.document.activeElement as HTMLInputElement)?.blur(); + dispatch(reducerActions.switchAutoNextFrame()); + }} + > + Automatically go to the next frame + + + + + + { + (window.document.activeElement as HTMLInputElement)?.blur(); + dispatch(reducerActions.switchAutoSaveOnFinish()); + }} + > + Automatically save when finish + + + + + + { + (window.document.activeElement as HTMLInputElement)?.blur(); + if (event.target.checked) { + appDispatch(setNavigationType(NavigationType.EMPTY)); + } else { + appDispatch(setNavigationType(NavigationType.REGULAR)); + } + }} + > + Navigate only empty frames + + + + { isPolylabel && ( + + + { + (window.document.activeElement as HTMLInputElement)?.blur(); + dispatch(reducerActions.switchCountOfPointsIsPredefined()); + }} + > + Predefined number of points + + + + )} + { isPolylabel && state.pointsCountIsPredefined ? ( + <> + + + Number of points + + + + + { + if (value !== null) { + dispatch(reducerActions.setPointsCount(value)); + } + }} + /> + + + + ) : null } + + ); +} + +export default React.memo(SingleShapeSidebar); diff --git a/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-workspace.tsx b/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-workspace.tsx new file mode 100644 index 000000000000..8f5efe603db5 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-workspace.tsx @@ -0,0 +1,19 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React from 'react'; +import Layout from 'antd/lib/layout'; + +import CanvasLayout from 'components/annotation-page/canvas/grid-layout/canvas-layout'; +import SingleShapeSidebar from './single-shape-sidebar/single-shape-sidebar'; + +export default function SingleShapeWorkspace(): JSX.Element { + return ( + + + + + ); +} diff --git a/cvat-ui/src/components/annotation-page/single-shape-workspace/styles.scss b/cvat-ui/src/components/annotation-page/single-shape-workspace/styles.scss new file mode 100644 index 000000000000..195dffd51814 --- /dev/null +++ b/cvat-ui/src/components/annotation-page/single-shape-workspace/styles.scss @@ -0,0 +1,76 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +@import 'base'; + +.cvat-single-shape-workspace { + height: 100%; +} + +.cvat-single-shape-annotation-sidebar { + padding: $grid-unit-size; + overflow: auto; + + .cvat-single-shape-annotation-sidebar-label-select, + .cvat-single-shape-annotation-sidebar-label-type-selector { + .ant-select { + width: $grid-unit-size * 20; + } + } + + .cvat-single-shape-annotation-sidebar-points-count-input { + .ant-input-number { + width: $grid-unit-size * 20; + } + } + + .cvat-single-shape-annotation-sidebar-label, + .cvat-single-shape-annotation-sidebar-label-type, + .cvat-single-shape-annotation-sidebar-points-count, + .cvat-single-shape-annotation-sidebar-label-select, + .cvat-single-shape-annotation-sidebar-points-count-input, + .cvat-single-shape-annotation-sidebar-navigate-empty-checkbox, + .cvat-single-shape-annotation-sidebar-predefined-pounts-count-checkbox, + .cvat-single-shape-annotation-sidebar-auto-save-checkbox { + margin-top: $grid-unit-size; + } + + .cvat-single-shape-annotation-sidebar-auto-next-frame-checkbox { + margin-top: $grid-unit-size * 3; + } + + .cvat-single-shape-annotation-sidebar-auto-next-frame-checkbox, + .cvat-single-shape-annotation-sidebar-auto-save-checkbox, + .cvat-single-shape-annotation-sidebar-navigate-empty-checkbox, + .cvat-single-shape-annotation-sidebar-predefined-pounts-count-checkbox { + user-select: none; + } + + .cvat-single-shape-annotation-sidebar-hint { + row-gap: 0; + text-align: center; + font-size: large; + margin-bottom: $grid-unit-size; + } + + .cvat-single-shape-annotation-sidebar-ux-hints { + ul { + padding-left: $grid-unit-size * 2; + } + } + + .cvat-single-shape-annotation-sidebar-skip-wrapper { + margin-bottom: $grid-unit-size; + + button { + width: $grid-unit-size * 35.5; + } + } + + .cvat-single-shape-annotation-sidebar-not-found-wrapper { + margin-top: $grid-unit-size * 3; + text-align: center; + flex-grow: 10; + } +} diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/issues-list.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/issues-list.tsx index 135568b82afa..463089d414ae 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/issues-list.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/issues-list.tsx @@ -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 @@ -115,7 +115,7 @@ export default function LabelsListComponent(): JSX.Element { { - workspace === Workspace.REVIEW_WORKSPACE ? ( + workspace === Workspace.REVIEW ? ( {!readonly && } - { workspace === Workspace.REVIEW_WORKSPACE && ( + { workspace === Workspace.REVIEW && ( )} diff --git a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/styles.scss b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/styles.scss index 713749ecde07..6436d7ac29ea 100644 --- a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/styles.scss +++ b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/styles.scss @@ -47,7 +47,7 @@ } } -.labels-tag-annotation-sidebar-not-found-wrapper { +.cvat-tag-annotation-sidebar-empty { margin-top: $grid-unit-size * 4; } diff --git a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-sidebar/tag-annotation-sidebar.tsx b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-sidebar/tag-annotation-sidebar.tsx index 1f820686654a..0ab291beca97 100644 --- a/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-sidebar/tag-annotation-sidebar.tsx +++ b/cvat-ui/src/components/annotation-page/tag-annotation-workspace/tag-annotation-sidebar/tag-annotation-sidebar.tsx @@ -1,5 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation -// Copyright (C) 2022-2023 CVAT.ai Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -240,7 +240,7 @@ function TagAnnotationSidebar(props: StateToProps & DispatchToProps): JSX.Elemen > {sidebarCollapsed ? : } - + Can't place tag on this frame. diff --git a/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx b/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx index f1f2105fdf26..5a5838c7d067 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/left-group.tsx @@ -16,6 +16,7 @@ import { MainMenuIcon, UndoIcon, RedoIcon } from 'icons'; import { ActiveControl, ToolsBlockerState } from 'reducers'; import CVATTooltip from 'components/common/cvat-tooltip'; import customizableComponents from 'components/customizable-components'; +import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; interface Props { saving: boolean; @@ -27,6 +28,7 @@ interface Props { switchToolsBlockerShortcut: string; toolsBlockerState: ToolsBlockerState; activeControl: ActiveControl; + keyMap: KeyMap; onSaveAnnotation(): void; onUndoClick(): void; onRedoClick(): void; @@ -37,6 +39,7 @@ interface Props { function LeftGroup(props: Props): JSX.Element { const { saving, + keyMap, undoAction, redoAction, undoShortcut, @@ -66,8 +69,29 @@ function LeftGroup(props: Props): JSX.Element { const shouldEnableToolsBlockerOnClick = [ActiveControl.OPENCV_TOOLS].includes(activeControl); const SaveButtonComponent = customizableComponents.SAVE_ANNOTATION_BUTTON; + const subKeyMap = { + UNDO: keyMap.UNDO, + REDO: keyMap.REDO, + }; + + const handlers = { + UNDO: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + if (undoAction) { + onUndoClick(); + } + }, + REDO: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + if (redoAction) { + onRedoClick(); + } + }, + }; + return ( <> + CVAT is saving your annotations, please wait diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx index a3bbc7201e29..3bf8137b1ee3 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-buttons.tsx @@ -1,14 +1,15 @@ -// Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2020-2024 Intel Corporation // // SPDX-License-Identifier: MIT -import React from 'react'; +import React, { CSSProperties } from 'react'; import { Col } from 'antd/lib/grid'; import Icon from '@ant-design/icons'; import Popover from 'antd/lib/popover'; import CVATTooltip from 'components/common/cvat-tooltip'; - +import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; +import { NavigationType, Workspace } from 'reducers'; import { FirstIcon, BackJumpIcon, @@ -31,8 +32,9 @@ interface Props { previousFrameShortcut: string; forwardShortcut: string; backwardShortcut: string; - prevButtonType: string; - nextButtonType: string; + keyMap: KeyMap; + workspace: Workspace; + navigationType: NavigationType; onSwitchPlay(): void; onPrevFrame(): void; onNextFrame(): void; @@ -40,8 +42,8 @@ interface Props { onBackward(): void; onFirstFrame(): void; onLastFrame(): void; - setPrevButton(type: 'regular' | 'filtered' | 'empty'): void; - setNextButton(type: 'regular' | 'filtered' | 'empty'): void; + onSearchAnnotations(direction: 'forward' | 'backward'): void; + setNavigationType(navigationType: NavigationType): void; } function PlayerButtons(props: Props): JSX.Element { @@ -52,8 +54,9 @@ function PlayerButtons(props: Props): JSX.Element { previousFrameShortcut, forwardShortcut, backwardShortcut, - prevButtonType, - nextButtonType, + keyMap, + navigationType, + workspace, onSwitchPlay, onPrevFrame, onNextFrame, @@ -61,10 +64,56 @@ function PlayerButtons(props: Props): JSX.Element { onBackward, onFirstFrame, onLastFrame, - setPrevButton, - setNextButton, + setNavigationType, + onSearchAnnotations, } = props; + const subKeyMap = { + NEXT_FRAME: keyMap.NEXT_FRAME, + PREV_FRAME: keyMap.PREV_FRAME, + ...(workspace !== Workspace.SINGLE_SHAPE ? { + FORWARD_FRAME: keyMap.FORWARD_FRAME, + BACKWARD_FRAME: keyMap.BACKWARD_FRAME, + SEARCH_FORWARD: keyMap.SEARCH_FORWARD, + SEARCH_BACKWARD: keyMap.SEARCH_BACKWARD, + PLAY_PAUSE: keyMap.PLAY_PAUSE, + FOCUS_INPUT_FRAME: keyMap.FOCUS_INPUT_FRAME, + } : {}), + }; + + const handlers = { + NEXT_FRAME: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + onNextFrame(); + }, + PREV_FRAME: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + onPrevFrame(); + }, + ...(workspace !== Workspace.SINGLE_SHAPE ? { + FORWARD_FRAME: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + onForward(); + }, + BACKWARD_FRAME: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + onBackward(); + }, + SEARCH_FORWARD: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + onSearchAnnotations('forward'); + }, + SEARCH_BACKWARD: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + onSearchAnnotations('backward'); + }, + PLAY_PAUSE: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + onSwitchPlay(); + }, + } : {}), + }; + const prevRegularText = 'Go back'; const prevFilteredText = 'Go back with a filter'; const prevEmptyText = 'Go back to an empty frame'; @@ -74,7 +123,7 @@ function PlayerButtons(props: Props): JSX.Element { let prevButton = ; let prevButtonTooltipMessage = prevRegularText; - if (prevButtonType === 'filtered') { + if (navigationType === NavigationType.FILTERED) { prevButton = ( ); prevButtonTooltipMessage = prevFilteredText; - } else if (prevButtonType === 'empty') { + } else if (navigationType === NavigationType.EMPTY) { prevButton = ( ); @@ -92,23 +141,39 @@ function PlayerButtons(props: Props): JSX.Element { let nextButton = ; let nextButtonTooltipMessage = nextRegularText; - if (nextButtonType === 'filtered') { + if (navigationType === NavigationType.FILTERED) { nextButton = ( ); nextButtonTooltipMessage = nextFilteredText; - } else if (nextButtonType === 'empty') { + } else if (navigationType === NavigationType.EMPTY) { nextButton = ; nextButtonTooltipMessage = nextEmptyText; } + const navIconStyle: CSSProperties = workspace === Workspace.SINGLE_SHAPE ? { + pointerEvents: 'none', + opacity: 0.5, + } : {}; + return ( + - + - + { - setPrevButton('regular'); - }} + onClick={() => setNavigationType(NavigationType.REGULAR)} /> { - setPrevButton('filtered'); - }} + onClick={() => setNavigationType(NavigationType.FILTERED)} /> { - setPrevButton('empty'); - }} + onClick={() => setNavigationType(NavigationType.EMPTY)} /> @@ -152,11 +211,21 @@ function PlayerButtons(props: Props): JSX.Element { {!playing ? ( - + ) : ( - + )} @@ -169,27 +238,21 @@ function PlayerButtons(props: Props): JSX.Element { { - setNextButton('regular'); - }} + onClick={() => setNavigationType(NavigationType.REGULAR)} /> { - setNextButton('filtered'); - }} + onClick={() => setNavigationType(NavigationType.FILTERED)} /> { - setNextButton('empty'); - }} + onClick={() => setNavigationType(NavigationType.EMPTY)} /> @@ -200,10 +263,20 @@ function PlayerButtons(props: Props): JSX.Element { - + - + ); diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx index 4a05b4444a1f..dfd4e24854e7 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx @@ -1,21 +1,23 @@ // Copyright (C) 2020-2022 Intel Corporation -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -import React, { useState, useEffect, useCallback } from 'react'; - +import React, { + useState, useEffect, useCallback, CSSProperties, +} from 'react'; import { Row, Col } from 'antd/lib/grid'; import Icon, { LinkOutlined, DeleteOutlined } from '@ant-design/icons'; import Slider from 'antd/lib/slider'; import InputNumber from 'antd/lib/input-number'; -import Input from 'antd/lib/input'; import Text from 'antd/lib/typography/Text'; import Modal from 'antd/lib/modal'; import { RestoreIcon } from 'icons'; import CVATTooltip from 'components/common/cvat-tooltip'; import { clamp } from 'utils/math'; +import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; +import { Workspace } from 'reducers'; interface Props { startFrame: number; @@ -28,7 +30,9 @@ interface Props { deleteFrameAvailable: boolean; deleteFrameShortcut: string; focusFrameInputShortcut: string; - inputFrameRef: React.RefObject; + inputFrameRef: React.RefObject; + keyMap: KeyMap; + workspace: Workspace; onSliderChange(value: number): void; onInputChange(value: number): void; onURLIconClick(): void; @@ -49,13 +53,15 @@ function PlayerNavigation(props: Props): JSX.Element { focusFrameInputShortcut, inputFrameRef, ranges, + keyMap, + workspace, + deleteFrameAvailable, onSliderChange, onInputChange, onURLIconClick, onDeleteFrame, onRestoreFrame, switchNavigationBlocked, - deleteFrameAvailable, } = props; const [frameInputValue, setFrameInputValue] = useState(frameNumber); @@ -85,18 +91,52 @@ function PlayerNavigation(props: Props): JSX.Element { }); } }, [playing, frameNumber]); + + const subKeyMap = { + DELETE_FRAME: keyMap.DELETE_FRAME, + FOCUS_INPUT_FRAME: keyMap.FOCUS_INPUT_FRAME, + }; + + const handlers = { + DELETE_FRAME: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + onDeleteFrame(); + }, + FOCUS_INPUT_FRAME: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + if (inputFrameRef.current) { + inputFrameRef.current.focus(); + } + }, + }; + + const deleteFrameIconStyle: CSSProperties = workspace === Workspace.SINGLE_SHAPE ? { + pointerEvents: 'none', + opacity: 0.5, + } : {}; + const deleteFrameIcon = !frameDeleted ? ( - + ) : ( - + ); return ( <> + { workspace !== Workspace.SINGLE_SHAPE && } @@ -105,7 +145,7 @@ function PlayerNavigation(props: Props): JSX.Element { min={startFrame} max={stopFrame} value={frameNumber || 0} - onChange={onSliderChange} + onChange={workspace !== Workspace.SINGLE_SHAPE ? onSliderChange : undefined} /> {!!ranges && ( @@ -148,6 +188,7 @@ function PlayerNavigation(props: Props): JSX.Element { ref={inputFrameRef} className='cvat-player-frame-selector' type='number' + disabled={workspace === Workspace.SINGLE_SHAPE} value={frameInputValue} onChange={(value: number | undefined | string | null) => { if (typeof value !== 'undefined' && value !== null) { diff --git a/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx index 97952cbbfc8a..7af232e3332e 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/top-bar.tsx @@ -4,13 +4,14 @@ // SPDX-License-Identifier: MIT import React from 'react'; -import Input from 'antd/lib/input'; import { Col, Row } from 'antd/lib/grid'; import { - ActiveControl, CombinedState, ToolsBlockerState, Workspace, + ActiveControl, CombinedState, NavigationType, ToolsBlockerState, Workspace, } from 'reducers'; +import { Job } from 'cvat-core-wrapper'; import { usePlugins } from 'utils/hooks'; +import { KeyMap } from 'utils/mousetrap-react'; import LeftGroup from './left-group'; import PlayerButtons from './player-buttons'; import PlayerNavigation from './player-navigation'; @@ -22,7 +23,7 @@ interface Props { frameNumber: number; frameFilename: string; frameDeleted: boolean; - inputFrameRef: React.RefObject
  • **Remove filtered shapes** - removes all shapes in alignment with the set-up filter. Doesn't work with tracks.
  • **Shapes converter: masks to polygons** - converts all masks to polygons.
  • **Shapes converter: masks to rectangles** - converts all masks to rectangles in alignment with the set-up filter.
  • **Shapes converter: polygon to masks** - converts all polygons to masks.
  • **Shapes converter: polygon to rectangles** - converts all polygons to rectangles.
  • **Shapes converter: rectangles to masks** - converts all rectangles to masks.
  • **Shapes converter: rectangles to polygons** - converts all rectangles to polygons.

  • **Note:** only **Remove filtered shapes** is available on the **Free** plan. | -| **Specify frames to run action** | Field where you can specify the frame range for the selected action. Enter the starting frame in the **Starting from frame:** field, and the ending frame in the **up to frame** field.

    If nothing is selected here or in **Choose one of the predefined options** section, the action will be applied to all fields. | -| **Choose one of the predefined options** | Predefined options to apply to frames. Selection here is mutually exclusive with **Specify frames to run action**.

    If nothing is selected here or in **Specify frames to run action** section, the action will be applied to all fields. | +| **Specify frames to run action** | Field where you can specify the frame range for the selected action. Enter the starting frame in the **Starting from frame:** field, and the ending frame in the **up to frame** field.

    If nothing is selected here or in **Choose one of the predefined options** section, the action will be applied to all fields. | +| **Choose one of the predefined options** | Predefined options to apply to frames. Selection here is mutually exclusive with **Specify frames to run action**.

    If nothing is selected here or in **Specify frames to run action** section, the action will be applied to all fields. | - - ## Convert shapes **Recommended Precautions Before Running Annotation Actions** - **Saving changes:** It is recommended to save all changes prior to initiating the annotation action. - If unsaved changes are detected, a prompt will advise to save these changes - to avoid any potential loss of data. + If unsaved changes are detected, a prompt will advise to save these changes + to avoid any potential loss of data. - **Disabу auto-save:** Prior to running the annotation action, disabling the auto-save feature -is advisable. A notification will suggest this action if auto-save is currently active. + is advisable. A notification will suggest this action if auto-save is currently active. - **Committing changes:** Changes applied during the annotation session -will not be committed to the server until the saving process is manually -initiated. This can be done either by the user or through the -auto-save feature, should it be enabled. + will not be committed to the server until the saving process is manually + initiated. This can be done either by the user or through the + auto-save feature, should it be enabled. To convert shapes, do the following: @@ -79,3 +78,11 @@ To convert shapes, do the following: ![](/images/shapes-coverter-action-run.jpg) > **Note:** Once the action is applied, it cannot be undone. + +## Convert shapes video tutorial + + + + + + diff --git a/site/content/en/docs/manual/advanced/annotation-with-ellipses.md b/site/content/en/docs/manual/advanced/annotation-with-ellipses.md index ff4765b1d753..528fb2227542 100644 --- a/site/content/en/docs/manual/advanced/annotation-with-ellipses.md +++ b/site/content/en/docs/manual/advanced/annotation-with-ellipses.md @@ -20,3 +20,13 @@ to complete the shape. You can rotate ellipses using a rotation point in the same way as [rectangles](/docs/manual/advanced/annotation-with-rectangles/#rotation-rectangle). + +## Annotation with ellipses video tutorial + + + + + + + + diff --git a/site/content/en/docs/manual/advanced/shape-grouping.md b/site/content/en/docs/manual/advanced/shape-grouping.md index 84d659c35758..9a5c3ac2821d 100644 --- a/site/content/en/docs/manual/advanced/shape-grouping.md +++ b/site/content/en/docs/manual/advanced/shape-grouping.md @@ -25,3 +25,11 @@ Shapes that don't have `group_id`, will be highlighted in white. ![](/images/image078_detrac.jpg) ![](/images/image077_detrac.jpg) + +## Shapes grouping video tutorial + + + + + + diff --git a/tests/cypress/e2e/actions_objects/test_annotations_saving.js b/tests/cypress/e2e/actions_objects/test_annotations_saving.js index cb677cb28043..e39a0ae5a6c5 100644 --- a/tests/cypress/e2e/actions_objects/test_annotations_saving.js +++ b/tests/cypress/e2e/actions_objects/test_annotations_saving.js @@ -33,13 +33,7 @@ context('Test annotations saving works correctly', () => { cy.get(`#cvat-objects-sidebar-state-item-${clientID}`).trigger('mouseover'); cy.get(`#cvat-objects-sidebar-state-item-${clientID}`).should('have.class', 'cvat-objects-sidebar-state-active-item'); cy.get('body').type(shortcut); - - cy.document().then((doc) => { - const tooltips = Array.from(doc.querySelectorAll('.ant-tooltip')); - if (tooltips.length > 0) { - cy.get('.ant-tooltip').invoke('hide'); - } - }); + cy.hideTooltips(); } before(() => { diff --git a/tests/cypress/e2e/features/single_object_annotation.js b/tests/cypress/e2e/features/single_object_annotation.js new file mode 100644 index 000000000000..61efc6f7045f --- /dev/null +++ b/tests/cypress/e2e/features/single_object_annotation.js @@ -0,0 +1,234 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +/// + +context('Single object annotation mode', { scrollBehavior: false }, () => { + const taskName = 'Single object annotation mode'; + const serverFiles = ['images/image_1.jpg', 'images/image_2.jpg', 'images/image_3.jpg']; + const frameCount = serverFiles.length; + + let taskID = null; + let jobID = null; + + const rectangleShape = [ + { x: 300, y: 100 }, + { x: 400, y: 400 }, + ]; + const polygonShape = [ + { x: 300, y: 100 }, + { x: 400, y: 400 }, + { x: 400, y: 250 }, + { x: 450, y: 350 }, + ]; + const polylineShape = [ + { x: 300, y: 100 }, + { x: 400, y: 400 }, + { x: 400, y: 250 }, + { x: 450, y: 350 }, + { x: 500, y: 450 }, + ]; + const pointsShape = [ + { x: 300, y: 100 }, + { x: 400, y: 400 }, + { x: 400, y: 250 }, + { x: 450, y: 350 }, + ]; + const ellipseShape = [ + { x: 300, y: 100 }, + { x: 400, y: 400 }, + ]; + const cuboidShape = [ + { x: 300, y: 100 }, + { x: 400, y: 400 }, + ]; + const maskActions = [{ + method: 'brush', + coordinates: [[300, 300], [700, 300], [700, 700], [300, 700]], + }]; + + function clickPoints(shape) { + shape.forEach((element) => { + cy.get('.cvat-canvas-container').click(element.x, element.y); + }); + } + + function checkFrameNum(frameNum) { + cy.get('.cvat-player-frame-selector').within(() => { + cy.get('input[role="spinbutton"]').should('have.value', frameNum); + }); + } + + function submitJob() { + cy.get('.cvat-single-shape-annotation-submit-job-modal').should('exist'); + cy.get('.cvat-single-shape-annotation-submit-job-modal').within(() => { cy.contains('Submit').click(); }); + + cy.intercept('PATCH', '/api/jobs/**').as('submitJob'); + cy.wait('@submitJob').its('response.statusCode').should('equal', 200); + + cy.get('.cvat-single-shape-annotation-submit-success-modal').should('exist'); + cy.get('.cvat-single-shape-annotation-submit-success-modal').within(() => { cy.contains('OK').click(); }); + } + + function checkSingleShapeModeOpened() { + cy.get('.cvat-workspace-selector').should('have.text', 'Single shape'); + cy.get('.cvat-canvas-controls-sidebar').should('not.exist'); + cy.get('.cvat-player-frame-selector input').should('be.disabled'); + + cy.get('.cvat-single-shape-annotation-sidebar-hint').should('exist'); + cy.get('.cvat-single-shape-annotation-sidebar-ux-hints').should('exist'); + } + + function openJob(params) { + cy.visit(`/tasks/${taskID}/jobs/${jobID}`, { + qs: { + defaultWorkspace: 'single_shape', + ...params, + }, + }); + cy.get('.cvat-canvas-container').should('exist').and('be.visible'); + } + + function drawObject(creatorFunction) { + checkSingleShapeModeOpened(); + + for (let frame = 0; frame < frameCount; frame++) { + checkFrameNum(frame); + creatorFunction(); + } + submitJob(); + } + + before(() => { + cy.visit('auth/login'); + cy.login(); + cy.headlessCreateTask({ + labels: [ + { name: 'rectangle_label', attributes: [], type: 'rectangle' }, + { name: 'polygon_label', attributes: [], type: 'polygon' }, + { name: 'polyline_label', attributes: [], type: 'polyline' }, + { name: 'points_label', attributes: [], type: 'points' }, + { name: 'ellipse_label', attributes: [], type: 'ellipse' }, + { name: 'cuboid_label', attributes: [], type: 'cuboid' }, + { name: 'mask_label', attributes: [], type: 'mask' }, + ], + name: taskName, + project_id: null, + source_storage: { location: 'local' }, + target_storage: { location: 'local' }, + }, { + server_files: serverFiles, + image_quality: 70, + use_zip_chunks: true, + use_cache: true, + sorting_method: 'lexicographical', + }).then((response) => { + taskID = response.taskID; + [jobID] = response.jobIDs; + }).then(() => { + cy.visit(`/tasks/${taskID}/jobs/${jobID}`); + cy.get('.cvat-canvas-container').should('exist').and('be.visible'); + }); + }); + + after(() => { + cy.logout(); + cy.getAuthKey().then((response) => { + const authKey = response.body.key; + cy.request({ + method: 'DELETE', + url: `/api/tasks/${taskID}`, + headers: { + Authorization: `Token ${authKey}`, + }, + }); + }); + }); + + describe('Tests basic features of single shape annotation mode', () => { + afterEach(() => { + cy.removeAnnotations(); + cy.saveJob(); + }); + + it('Check basic single shape annotation pipeline for polygon', () => { + openJob({ defaultLabel: 'polygon_label', defaultPointsCount: 4 }); + drawObject(() => clickPoints(polygonShape)); + }); + + it('Check basic single shape annotation pipeline for rectangle', () => { + openJob({ defaultLabel: 'rectangle_label' }); + drawObject(() => clickPoints(rectangleShape)); + }); + + it('Check basic single shape annotation pipeline for polyline', () => { + openJob({ defaultLabel: 'polyline_label', defaultPointsCount: 5 }); + drawObject(() => clickPoints(polylineShape)); + }); + + it('Check basic single shape annotation pipeline for ellipse', () => { + openJob({ defaultLabel: 'ellipse_label' }); + drawObject(() => clickPoints(ellipseShape)); + }); + + it('Check basic single shape annotation pipeline for points', () => { + openJob({ defaultLabel: 'points_label', defaultPointsCount: 4 }); + drawObject(() => clickPoints(pointsShape)); + }); + + it('Check basic single shape annotation pipeline for cuboid', () => { + openJob({ defaultLabel: 'cuboid_label' }); + drawObject(() => clickPoints(cuboidShape)); + }); + + it('Check basic single shape annotation pipeline for mask', () => { + openJob({ defaultLabel: 'mask_label' }); + cy.drawMask(maskActions); + cy.finishMaskDrawing(); + }); + }); + + describe('Tests advanced features of single shape annotation mode', () => { + it('Check single shape annotation mode controls', () => { + openJob({ defaultLabel: 'polygon_label', defaultPointsCount: 4 }); + checkSingleShapeModeOpened(); + + // Skip + cy.get('.cvat-single-shape-annotation-sidebar-skip-wrapper').within(() => { + cy.contains('Skip').click(); + }); + checkFrameNum(1); + + // Auto next frame - disabled + cy.get('.cvat-single-shape-annotation-sidebar-auto-next-frame-checkbox').within(() => { + cy.get('[type="checkbox"]').uncheck(); + }); + clickPoints(polygonShape); + checkFrameNum(1); + + // Auto save when finish - disabled + cy.get('.cvat-player-next-button-empty').click(); + cy.get('.cvat-single-shape-annotation-sidebar-auto-save-checkbox').within(() => { + cy.get('[type="checkbox"]').uncheck(); + }); + clickPoints(polygonShape); + cy.get('.cvat-single-shape-annotation-submit-job-modal').should('not.exist'); + + // Navigate only on empty frames + cy.get('.cvat-player-previous-button-empty').click(); + checkFrameNum(0); + cy.get('.cvat-player-next-button-empty').click(); + checkFrameNum(0); + cy.get('.cvat-single-shape-annotation-sidebar-navigate-empty-checkbox').within(() => { + cy.get('[type="checkbox"]').uncheck(); + }); + cy.get('.cvat-player-next-button').click(); + checkFrameNum(1); + cy.get('.cvat-player-next-button').click(); + checkFrameNum(2); + + cy.saveJob(); + }); + }); +}); diff --git a/tests/cypress/e2e/issues_prs/issue_2485_navigation_empty_frames.js b/tests/cypress/e2e/issues_prs/issue_2485_navigation_empty_frames.js index 56563f5d5d8d..b19bc49474a0 100644 --- a/tests/cypress/e2e/issues_prs/issue_2485_navigation_empty_frames.js +++ b/tests/cypress/e2e/issues_prs/issue_2485_navigation_empty_frames.js @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -20,74 +21,57 @@ context('Navigation to empty frames', () => { before(() => { cy.openTaskJob(taskName); - }); - - describe(`Testing issue "${issueId}"`, () => { - it('Go to 2nd frame. Create a shape.', () => { - cy.goCheckFrameNumber(2); - cy.createRectangle(createRectangleShape2Points); - }); - - it('Go to 4th frame. Create a shape.', () => { - cy.goCheckFrameNumber(4); - cy.createRectangle(createRectangleShape2Points); - }); - - it('Set a filter to see the created objects.', () => { - cy.addFiltersRule(0); - cy.setFilter({ - groupIndex: 0, - ruleIndex: 0, - field: 'Shape', - operator: '==', - value: 'rectangle', - submit: true, - }); - cy.get('#cvat_canvas_shape_2').should('exist'); + cy.goCheckFrameNumber(2); + cy.createRectangle(createRectangleShape2Points); + cy.goCheckFrameNumber(4); + cy.createRectangle(createRectangleShape2Points); + cy.addFiltersRule(0); + cy.setFilter({ + groupIndex: 0, + ruleIndex: 0, + field: 'Shape', + operator: '==', + value: 'rectangle', + submit: true, }); + cy.goCheckFrameNumber(3); + }); - it('Go to 3rd frame.', () => { - cy.goCheckFrameNumber(3); - }); + beforeEach(() => { + cy.wait(500); // wait while tooltips are opened + cy.hideTooltips(); + }); - it('Right click to navigation buttons: Previous, Next. Switch their mode to: Go next/previous with a filter.', () => { - cy.goCheckFrameNumber(3); - for (const i of ['previous', 'next']) { - cy.get(`.cvat-player-${i}-button`).rightclick(); - cy.get(`.cvat-player-${i}-filtered-inlined-button`).click(); - } - }); + describe(`Testing issue "${issueId}"`, () => { + it('Check navigation is corrent for filtered and empty frames', () => { + // set mode to only filtered + cy.get('.cvat-player-previous-button').rightclick(); + cy.get('.cvat-player-previous-filtered-inlined-button').click(); - it("Press go previous with a filter. CVAT get 2nd frame. Press again. Frame wasn't changed.", () => { + // Press go previous with a filter. CVAT get 2nd frame. Press again. Frame wasn't changed for (let i = 1; i <= 2; i++) { cy.get('.cvat-player-previous-button-filtered').click({ force: true }); cy.checkFrameNum(2); cy.get('#cvat_canvas_shape_1').should('exist'); } - }); - it("Press go next with a filter. CVAT get 4th frame. Press again. Frame wasn't changed.", () => { + // Press go next with a filter. CVAT get 4th frame. Press again. Frame wasn't changed for (let i = 1; i <= 2; i++) { cy.get('.cvat-player-next-button-filtered').click({ force: true }); cy.checkFrameNum(4); cy.get('#cvat_canvas_shape_2').should('exist'); } - }); - it('Change navigation buttons mode to "Go next/previous to an empty frame".', () => { - for (const i of ['previous', 'next']) { - cy.get(`.cvat-player-${i}-button-filtered`).rightclick({ force: true }); - cy.get(`.cvat-player-${i}-empty-inlined-button`).click({ force: true }); - } - }); + // set mode to only empty + cy.get('.cvat-player-next-button-filtered').rightclick(); + cy.get('.cvat-player-next-empty-inlined-button').click(); - it('Go previous to an empty frame. CVAT get 3rd frame.', () => { + // Go previous to an empty frame. CVAT get 3rd frame cy.get('.cvat-player-previous-button-empty').click({ force: true }); cy.checkFrameNum(3); cy.get('.cvat_canvas_shape').should('not.exist'); - }); - it('Go next to an empty frame. CVAT get 5th frame.', () => { + // Go next to an empty frame. CVAT get 5th frame cy.get('.cvat-player-next-button-empty').click({ force: true }); cy.checkFrameNum(5); cy.get('.cvat_canvas_shape').should('not.exist'); diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index 0a492fece5af..385a23c7012e 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -1515,6 +1515,15 @@ Cypress.Commands.add('interactAnnotationObjectMenu', (parentSelector, button) => }); }); +Cypress.Commands.add('hideTooltips', () => { + cy.document().then((doc) => { + const tooltips = Array.from(doc.querySelectorAll('.ant-tooltip')); + if (tooltips.length > 0) { + cy.get('.ant-tooltip').invoke('hide'); + } + }); +}); + Cypress.Commands.overwrite('visit', (orig, url, options) => { orig(url, options); cy.closeModalUnsupportedPlatform();