diff --git a/changelog.d/20240219_101835_boris_single_object_mode.md b/changelog.d/20240219_101835_boris_single_object_mode.md new file mode 100644 index 000000000000..8d6706187b27 --- /dev/null +++ b/changelog.d/20240219_101835_boris_single_object_mode.md @@ -0,0 +1,4 @@ +### Added + +- Single shape annotation mode allowing to easily annotate scenarious where a user +only needs to draw one object on one image () 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-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/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-ui/package.json b/cvat-ui/package.json index 21fe7cd13727..b8d4b1e5259c 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.0", "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/canvas/views/canvas2d/brush-tools.tsx b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/brush-tools.tsx index 0b7883ada510..142967634002 100644 --- a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/brush-tools.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/brush-tools.tsx @@ -132,6 +132,10 @@ function BrushTools(): React.ReactPortal | null { const { offsetTop, offsetLeft } = canvasContainer.parentElement as HTMLElement; setTopLeft([offsetTop, offsetLeft]); } + + return () => { + dispatch(updateCanvasBrushTools({ visible: false })); + }; }, []); useEffect(() => { diff --git a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-context-menu.tsx b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-context-menu.tsx index 4c57555d330f..0f4011964825 100644 --- a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-context-menu.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-context-menu.tsx @@ -130,7 +130,7 @@ export default function CanvasContextMenu(props: Props): JSX.Element | null { } const copyObject = state?.isGroundTruth ? state : null; - if (workspace === Workspace.REVIEW_WORKSPACE) { + if (workspace === Workspace.REVIEW) { const conflict = frameConflicts .find((qualityConflict: QualityConflict) => qualityConflict.annotationConflicts.some( (annotationConflict: AnnotationConflict) => ( diff --git a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx index 79295c1ea594..ae8d44f7b3ba 100644 --- a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx @@ -372,7 +372,7 @@ class CanvasWrapperComponent extends React.PureComponent { wrapper.appendChild(canvasInstance.html()); canvasInstance.configure({ - forceDisableEditing: workspace === Workspace.REVIEW_WORKSPACE, + forceDisableEditing: workspace === Workspace.REVIEW, undefinedAttrValue: config.UNDEFINED_ATTRIBUTE_VALUE, displayAllText: showObjectsTextAlways, autoborders: automaticBordering, @@ -548,11 +548,11 @@ class CanvasWrapperComponent extends React.PureComponent { } if (prevProps.workspace !== workspace) { - if (workspace === Workspace.REVIEW_WORKSPACE) { + if (workspace === Workspace.REVIEW) { canvasInstance.configure({ forceDisableEditing: true, }); - } else if (prevProps.workspace === Workspace.REVIEW_WORKSPACE) { + } else if (prevProps.workspace === Workspace.REVIEW) { canvasInstance.configure({ forceDisableEditing: false, }); @@ -730,7 +730,7 @@ class CanvasWrapperComponent extends React.PureComponent { const { workspace, activatedStateID, onActivateObject } = this.props; if ((e.target as HTMLElement).tagName === 'svg' && e.button !== 2) { - if (activatedStateID !== null && workspace !== Workspace.ATTRIBUTE_ANNOTATION) { + if (activatedStateID !== null && workspace !== Workspace.ATTRIBUTES) { onActivateObject(null, null); } } @@ -796,7 +796,7 @@ class CanvasWrapperComponent extends React.PureComponent { jobInstance, activatedStateID, activatedElementID, workspace, onActivateObject, } = this.props; - if (![Workspace.STANDARD, Workspace.REVIEW_WORKSPACE].includes(workspace)) { + if (![Workspace.STANDARD, Workspace.REVIEW].includes(workspace)) { return; } @@ -904,7 +904,7 @@ class CanvasWrapperComponent extends React.PureComponent { if (activatedStateID !== null) { const [activatedState] = annotations.filter((state: any): boolean => state.clientID === activatedStateID); - if (workspace === Workspace.ATTRIBUTE_ANNOTATION) { + if (workspace === Workspace.ATTRIBUTES) { if (activatedState.objectType !== ObjectType.TAG) { canvasInstance.focus(activatedStateID, aamZoomMargin); } else { @@ -914,7 +914,7 @@ class CanvasWrapperComponent extends React.PureComponent { if (activatedState && activatedState.objectType !== ObjectType.TAG) { canvasInstance.activate(activatedStateID, activatedAttributeID); } - } else if (workspace === Workspace.ATTRIBUTE_ANNOTATION) { + } else if (workspace === Workspace.ATTRIBUTES) { canvasInstance.fit(); } } diff --git a/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-sidebar/single-shape-sidebar.tsx b/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-sidebar/single-shape-sidebar.tsx new file mode 100644 index 000000000000..fd02f84e5d3b --- /dev/null +++ b/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-sidebar/single-shape-sidebar.tsx @@ -0,0 +1,531 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { + shallowEqual, useDispatch, useSelector, useStore, +} from 'react-redux'; +import React, { + useCallback, useEffect, useReducer, useRef, +} from 'react'; +import Layout, { SiderProps } from 'antd/lib/layout'; +import { Row, Col } from 'antd/lib/grid'; +import Text from 'antd/lib/typography/Text'; +import Checkbox, { CheckboxChangeEvent } from 'antd/lib/checkbox'; +import InputNumber from 'antd/lib/input-number'; +import Select from 'antd/lib/select'; +import Alert from 'antd/lib/alert'; +import Modal from 'antd/lib/modal'; +import Button from 'antd/lib/button'; + +import { CombinedState, NavigationType } from 'reducers'; +import { Canvas, CanvasMode } from 'cvat-canvas-wrapper'; +import { + Job, JobState, Label, LabelType, +} from 'cvat-core-wrapper'; +import { ActionUnion, createAction } from 'utils/redux'; +import { changeFrameAsync, saveAnnotationsAsync, setNavigationType } from 'actions/annotation-actions'; +import LabelSelector from 'components/label-selector/label-selector'; +import GlobalHotKeys from 'utils/mousetrap-react'; + +enum ReducerActionType { + SWITCH_AUTO_NEXT_FRAME = 'SWITCH_AUTO_NEXT_FRAME', + SWITCH_AUTOSAVE_ON_FINISH = 'SWITCH_AUTOSAVE_ON_FINISH', + SWITCH_COUNT_OF_POINTS_IS_PREDEFINED = 'SWITCH_COUNT_OF_POINTS_IS_PREDEFINED', + SET_ACTIVE_LABEL = 'SET_ACTIVE_LABEL', + SET_POINTS_COUNT = 'SET_POINTS_COUNT', +} + +export const reducerActions = { + switchAutoNextFrame: () => ( + createAction(ReducerActionType.SWITCH_AUTO_NEXT_FRAME) + ), + switchAutoSaveOnFinish: () => ( + createAction(ReducerActionType.SWITCH_AUTOSAVE_ON_FINISH) + ), + switchCountOfPointsIsPredefined: () => ( + createAction(ReducerActionType.SWITCH_COUNT_OF_POINTS_IS_PREDEFINED) + ), + setActiveLabel: (label: Label, type?: LabelType) => ( + createAction(ReducerActionType.SET_ACTIVE_LABEL, { + label, + labelType: type || label.type, + }) + ), + setPointsCount: (pointsCount: number) => ( + createAction(ReducerActionType.SET_POINTS_COUNT, { pointsCount }) + ), +}; + +interface State { + autoNextFrame: boolean; + saveOnFinish: boolean; + pointsCountIsPredefined: boolean; + pointsCount: number; + labels: Label[]; + label: Label | null; + labelType: LabelType; + initialNavigationType: NavigationType; +} + +const reducer = (state: State, action: ActionUnion): State => { + const getMinimalPoints = (labelType: LabelType): number => { + let minimalPoints = 3; + if (labelType === LabelType.POLYLINE) { + minimalPoints = 2; + } else if (labelType === LabelType.POINTS) { + minimalPoints = 1; + } + + return minimalPoints; + }; + + if (action.type === ReducerActionType.SWITCH_AUTO_NEXT_FRAME) { + return { + ...state, + autoNextFrame: !state.autoNextFrame, + }; + } + + if (action.type === ReducerActionType.SWITCH_AUTOSAVE_ON_FINISH) { + return { + ...state, + saveOnFinish: !state.saveOnFinish, + }; + } + + if (action.type === ReducerActionType.SWITCH_COUNT_OF_POINTS_IS_PREDEFINED) { + return { + ...state, + pointsCountIsPredefined: !state.pointsCountIsPredefined, + }; + } + + if (action.type === ReducerActionType.SET_ACTIVE_LABEL) { + return { + ...state, + label: action.payload.label, + labelType: action.payload.labelType, + pointsCount: Math.max(state.pointsCount, getMinimalPoints(action.payload.labelType)), + }; + } + + if (action.type === ReducerActionType.SET_POINTS_COUNT) { + return { + ...state, + pointsCount: Math.max(action.payload.pointsCount, getMinimalPoints(state.labelType)), + }; + } + + return state; +}; + +function cancelCurrentCanvasOp(state: CombinedState): void { + const canvas = state.annotation.canvas.instance as Canvas; + if (canvas.mode() !== CanvasMode.IDLE) { + canvas.cancel(); + } +} + +function SingleShapeSidebar(): JSX.Element { + const appDispatch = useDispatch(); + const store = useStore(); + const { + isCanvasReady, + jobInstance, + frame, + normalizedKeyMap, + keyMap, + defaultLabel, + defaultPointsCount, + navigationType, + } = useSelector((_state: CombinedState) => ({ + isCanvasReady: _state.annotation.canvas.ready, + jobInstance: _state.annotation.job.instance as Job, + frame: _state.annotation.player.frame.number, + keyMap: _state.shortcuts.keyMap, + normalizedKeyMap: _state.shortcuts.normalizedKeyMap, + defaultLabel: _state.annotation.job.queryParameters.defaultLabel, + defaultPointsCount: _state.annotation.job.queryParameters.defaultPointsCount, + navigationType: _state.annotation.player.navigationType, + }), shallowEqual); + + const [state, dispatch] = useReducer(reducer, { + autoNextFrame: true, + saveOnFinish: true, + pointsCountIsPredefined: true, + pointsCount: defaultPointsCount || 1, + labels: jobInstance.labels.filter((label) => label.type !== LabelType.TAG && label.type !== LabelType.SKELETON), + label: null, + labelType: LabelType.ANY, + initialNavigationType: navigationType, + }); + + const savingRef = useRef(false); + const nextFrame = useCallback((): void => { + let promise = Promise.resolve(null); + if (frame < jobInstance.stopFrame) { + promise = jobInstance.annotations.search(frame + 1, jobInstance.stopFrame, { + allowDeletedFrames: false, + ...(navigationType === NavigationType.EMPTY ? { + generalFilters: { + isEmptyFrame: true, + }, + } : {}), + }); + } + + promise.then((foundFrame: number | null) => { + if (typeof foundFrame === 'number') { + appDispatch(changeFrameAsync(foundFrame)); + } else if (state.saveOnFinish && !savingRef.current) { + Modal.confirm({ + title: 'You finished the job', + content: 'Please, confirm further action', + cancelText: 'Stay on the page', + okText: 'Submit results', + onOk: () => { + function reset(): void { + savingRef.current = false; + } + + function showSubmittedInfo(): void { + Modal.info({ + closable: false, + title: 'Annotations submitted', + content: 'You may close the window', + }); + } + + savingRef.current = true; + if (jobInstance.annotations.hasUnsavedChanges()) { + appDispatch(saveAnnotationsAsync(() => { + jobInstance.state = JobState.COMPLETED; + jobInstance.save().then(showSubmittedInfo).finally(reset); + })).catch(reset); + } else { + jobInstance.state = JobState.COMPLETED; + jobInstance.save().then(showSubmittedInfo).finally(reset); + } + }, + }); + } + }); + }, [state.saveOnFinish, frame, jobInstance, navigationType]); + + const canvasInitializerRef = useRef<() => void | null>(() => {}); + canvasInitializerRef.current = (): void => { + const canvas = store.getState().annotation.canvas.instance as Canvas; + if (isCanvasReady && canvas.mode() !== CanvasMode.DRAW && state.label && state.labelType !== LabelType.ANY) { + canvas.draw({ + enabled: true, + shapeType: state.labelType, + numberOfPoints: state.pointsCountIsPredefined ? state.pointsCount : undefined, + crosshair: true, + }); + } + }; + + useEffect(() => { + const canvas = store.getState().annotation.canvas.instance as Canvas; + const onDrawDone = (): void => { + setTimeout(() => { + if (state.autoNextFrame) { + nextFrame(); + } else { + canvasInitializerRef.current(); + } + }, 100); + }; + + const onCancel = (): void => { + // canvas.drawn should be triggered after canvas.cancel + // event in a usual scenario (when user drawn something) + // but there are some cases when only canvas.cancel is triggered (e.g. when drawn shape was not correct) + // in this case need to re-run drawing process + setTimeout(() => { + canvasInitializerRef.current(); + }); + }; + + (canvas as Canvas).html().addEventListener('canvas.drawn', onDrawDone); + (canvas as Canvas).html().addEventListener('canvas.canceled', onCancel); + return (() => { + // should stay prior mount useEffect to remove event handlers before final cancel() is called + + (canvas as Canvas).html().removeEventListener('canvas.drawn', onDrawDone); + (canvas as Canvas).html().removeEventListener('canvas.canceled', onCancel); + }); + }, [nextFrame, state.autoNextFrame, state.saveOnFinish]); + + useEffect(() => { + const labelInstance = (defaultLabel ? jobInstance.labels + .find((_label) => _label.name === defaultLabel) : state.labels[0] || null); + if (labelInstance) { + dispatch(reducerActions.setActiveLabel(labelInstance)); + } + + appDispatch(setNavigationType(NavigationType.EMPTY)); + cancelCurrentCanvasOp(store.getState()); + return () => { + appDispatch(setNavigationType(state.initialNavigationType)); + cancelCurrentCanvasOp(store.getState()); + }; + }, []); + + useEffect(() => { + cancelCurrentCanvasOp(store.getState()); + canvasInitializerRef.current(); + }, [isCanvasReady, state.label, state.labelType, state.pointsCount, state.pointsCountIsPredefined]); + + let message = ''; + if (state.labelType === LabelType.POINTS) { + message = `${state.pointsCount === 1 ? 'one point' : `${state.pointsCount} points`}`; + } else { + message = `${state.labelType === LabelType.ELLIPSE ? 'an ellipse' : `a ${state.labelType}`}`; + } + + const siderProps: SiderProps = { + className: 'cvat-single-shape-annotation-sidebar', + theme: 'light', + width: 300, + collapsedWidth: 0, + reverseArrow: true, + collapsible: true, + trigger: null, + }; + + const subKeyMap = { + CANCEL: keyMap.CANCEL, + SWITCH_DRAW_MODE: keyMap.SWITCH_DRAW_MODE, + }; + + const handlers = { + CANCEL: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + (store.getState().annotation.canvas.instance as Canvas).cancel(); + }, + SWITCH_DRAW_MODE: (event: KeyboardEvent | undefined) => { + event?.preventDefault(); + const canvas = store.getState().annotation.canvas.instance as Canvas; + canvas.draw({ enabled: false }); + }, + }; + + if (!state.labels.length) { + return ( + +
+ No available labels found +
+
+ ); + } + + const isPolylabel = [LabelType.POINTS, LabelType.POLYGON, LabelType.POLYLINE].includes(state.labelType); + const withLabelsSelector = state.labels.length > 1; + const withLabelTypeSelector = state.label && state.label.type === 'any'; + + return ( + + + { state.label !== null && state.labelType !== LabelType.ANY && ( + + + + Annotate + {` ${(state.label as Label).name} `} + on the image, using + {` ${message} `} + + )} + /> + + + + + + +
  • + Click + {' Skip '} + if there is nothing to annotate +
  • +
  • + Hold + {' [Alt] '} + button to avoid drawing on click +
  • +
  • + Press + {` ${normalizedKeyMap.UNDO} `} + to undo a created object +
  • + { (!isPolylabel || !state.pointsCountIsPredefined || state.pointsCount > 1) && ( +
  • + Press + {` ${normalizedKeyMap.CANCEL} `} + to reset drawing process +
  • + ) } + + { (isPolylabel && (!state.pointsCountIsPredefined || state.pointsCount > 1)) && ( +
  • + Press + {` ${normalizedKeyMap.SWITCH_DRAW_MODE} `} + to finish drawing process +
  • + ) } + + )} + /> + +
    + )} + { withLabelsSelector && ( + <> + + + Label selector + + + + + dispatch(reducerActions.setActiveLabel(label))} + /> + + + + )} + { withLabelTypeSelector && ( + <> + + + Label type selector + + + + + {LabelType.RECTANGLE} + + + + + + + + + + + )} + + + { + (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