diff --git a/cvat-canvas/src/typescript/canvas.ts b/cvat-canvas/src/typescript/canvas.ts index c53c9d52..eef1de40 100644 --- a/cvat-canvas/src/typescript/canvas.ts +++ b/cvat-canvas/src/typescript/canvas.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT import { + Mode, DrawData, MergeData, SplitData, @@ -51,6 +52,7 @@ interface Canvas { dragCanvas(enable: boolean): void; zoomCanvas(enable: boolean): void; + mode(): void; cancel(): void; } @@ -132,6 +134,10 @@ class CanvasImpl implements Canvas { this.model.select(objectState); } + public mode(): Mode { + return this.model.mode; + } + public cancel(): void { this.model.cancel(); } @@ -141,4 +147,5 @@ export { CanvasImpl as Canvas, CanvasVersion, RectDrawingMethod, + Mode as CanvasMode, }; diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 846ac454..8966a1da 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -472,21 +472,21 @@ export class CanvasViewImpl implements CanvasView, Listener { 'stroke-width': consts.POINTS_STROKE_WIDTH / self.geometry.scale, }); - circle.node.addEventListener('mouseenter', (): void => { + circle.on('mouseenter', (): void => { circle.attr({ 'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / self.geometry.scale, }); - circle.node.addEventListener('dblclick', dblClickHandler); + circle.on('dblclick', dblClickHandler); circle.addClass('cvat_canvas_selected_point'); }); - circle.node.addEventListener('mouseleave', (): void => { + circle.on('mouseleave', (): void => { circle.attr({ 'stroke-width': consts.POINTS_STROKE_WIDTH / self.geometry.scale, }); - circle.node.removeEventListener('dblclick', dblClickHandler); + circle.off('dblclick', dblClickHandler); circle.removeClass('cvat_canvas_selected_point'); }); @@ -632,7 +632,6 @@ export class CanvasViewImpl implements CanvasView, Listener { if (![Mode.ZOOM_CANVAS, Mode.GROUP].includes(this.mode) || event.which === 2) { self.controller.enableDrag(event.clientX, event.clientY); } - event.preventDefault(); } }); @@ -1340,13 +1339,18 @@ export class CanvasViewImpl implements CanvasView, Listener { private setupPoints(basicPolyline: SVG.PolyLine, state: any): any { this.selectize(true, basicPolyline); - const group = basicPolyline.remember('_selectHandler').nested + const group: SVG.G = basicPolyline.remember('_selectHandler').nested .addClass('cvat_canvas_shape').attr({ clientID: state.clientID, id: `cvat_canvas_shape_${state.clientID}`, 'data-z-order': state.zOrder, }); + group.on('click.canvas', (event: MouseEvent): void => { + // Need to redispatch the event on another element + basicPolyline.fire(new MouseEvent('click', event)); + }); + group.bbox = basicPolyline.bbox.bind(basicPolyline); group.clone = basicPolyline.clone.bind(basicPolyline); diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index 2413368f..60745f9f 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -34,6 +34,10 @@ export class DrawHandlerImpl implements DrawHandler { private onDrawDone: (data: object, continueDraw?: boolean) => void; private canvas: SVG.Container; private text: SVG.Container; + private cursorPosition: { + x: number; + y: number; + }; private crosshair: { x: SVG.Line; y: SVG.Line; @@ -96,12 +100,13 @@ export class DrawHandlerImpl implements DrawHandler { } private addCrosshair(): void { + const { x, y } = this.cursorPosition; this.crosshair = { - x: this.canvas.line(0, 0, this.canvas.node.clientWidth, 0).attr({ + x: this.canvas.line(0, y, this.canvas.node.clientWidth, y).attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * this.geometry.scale), zOrder: Number.MAX_SAFE_INTEGER, }).addClass('cvat_canvas_crosshair'), - y: this.canvas.line(0, 0, 0, this.canvas.node.clientHeight).attr({ + y: this.canvas.line(x, 0, x, this.canvas.node.clientHeight).attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * this.geometry.scale), zOrder: Number.MAX_SAFE_INTEGER, }).addClass('cvat_canvas_crosshair'), @@ -181,7 +186,6 @@ export class DrawHandlerImpl implements DrawHandler { this.shapeSizeElement.update(this.drawInstance); }).addClass('cvat_canvas_shape_drawing').attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, - z_order: Number.MAX_SAFE_INTEGER, }); } @@ -222,10 +226,6 @@ export class DrawHandlerImpl implements DrawHandler { } private drawPolyshape(): void { - this.drawInstance.attr({ - z_order: Number.MAX_SAFE_INTEGER, - }); - let size = this.drawData.numberOfPoints; const sizeDecrement = function sizeDecrement(): void { if (!--size) { @@ -371,18 +371,17 @@ export class DrawHandlerImpl implements DrawHandler { // Common settings for rectangle and polyshapes private pasteShape(): void { - this.drawInstance.attr({ - z_order: Number.MAX_SAFE_INTEGER, - }); + function moveShape(shape: SVG.Shape, x: number, y: number): void { + const bbox = shape.bbox(); + shape.move(x - bbox.width / 2, y - bbox.height / 2); + } - this.canvas.on('mousemove.draw', (e: MouseEvent): void => { - const [x, y] = translateToSVG( - this.canvas.node as any as SVGSVGElement, - [e.clientX, e.clientY], - ); + const { x: initialX, y: initialY } = this.cursorPosition; + moveShape(this.drawInstance, initialX, initialY); - const bbox = this.drawInstance.bbox(); - this.drawInstance.move(x - bbox.width / 2, y - bbox.height / 2); + this.canvas.on('mousemove.draw', (): void => { + const { x, y } = this.cursorPosition; // was computer in another callback + moveShape(this.drawInstance, x, y); }); } @@ -429,45 +428,53 @@ export class DrawHandlerImpl implements DrawHandler { this.pastePolyshape(); } - private pastePoints(points: string): void { - this.drawInstance = (this.canvas as any).polyline(points) + private pastePoints(initialPoints: string): void { + function moveShape( + shape: SVG.PolyLine, + group: SVG.G, + x: number, + y: number, + scale: number, + ): void { + const bbox = shape.bbox(); + shape.move(x - bbox.width / 2, y - bbox.height / 2); + + const points = shape.attr('points').split(' '); + const radius = consts.BASE_POINT_SIZE / scale; + + group.children().forEach((child: SVG.Element, idx: number): void => { + const [px, py] = points[idx].split(','); + child.move(px - radius / 2, py - radius / 2); + }); + } + + const { x: initialX, y: initialY } = this.cursorPosition; + this.pointsGroup = this.canvas.group(); + this.drawInstance = (this.canvas as any).polyline(initialPoints) .addClass('cvat_canvas_shape_drawing').style({ 'stroke-width': 0, }); - this.pointsGroup = this.canvas.group(); - for (const point of points.split(' ')) { + let numOfPoints = initialPoints.split(' ').length; + while (numOfPoints) { + numOfPoints--; const radius = consts.BASE_POINT_SIZE / this.geometry.scale; const stroke = consts.POINTS_STROKE_WIDTH / this.geometry.scale; - const [x, y] = point.split(',').map((coord: string): number => +coord); - this.pointsGroup.circle().move(x - radius / 2, y - radius / 2) - .fill('white').stroke('black').attr({ - r: radius, - 'stroke-width': stroke, - }); + this.pointsGroup.circle().fill('white').stroke('black').attr({ + r: radius, + 'stroke-width': stroke, + }); } - this.pointsGroup.attr({ - z_order: Number.MAX_SAFE_INTEGER, - }); + moveShape( + this.drawInstance, this.pointsGroup, initialX, initialY, this.geometry.scale, + ); - this.canvas.on('mousemove.draw', (e: MouseEvent): void => { - const [x, y] = translateToSVG( - this.canvas.node as any as SVGSVGElement, - [e.clientX, e.clientY], + this.canvas.on('mousemove.draw', (): void => { + const { x, y } = this.cursorPosition; // was computer in another callback + moveShape( + this.drawInstance, this.pointsGroup, x, y, this.geometry.scale, ); - - const bbox = this.drawInstance.bbox(); - this.drawInstance.move(x - bbox.width / 2, y - bbox.height / 2); - const radius = consts.BASE_POINT_SIZE / this.geometry.scale; - const newPoints = this.drawInstance.attr('points').split(' '); - if (this.pointsGroup) { - this.pointsGroup.children() - .forEach((child: SVG.Element, idx: number): void => { - const [px, py] = newPoints[idx].split(','); - child.move(px - radius / 2, py - radius / 2); - }); - } }); this.pastePolyshape(); @@ -593,23 +600,20 @@ export class DrawHandlerImpl implements DrawHandler { this.crosshair = null; this.drawInstance = null; this.pointsGroup = null; + this.cursorPosition = { + x: 0, + y: 0, + }; this.canvas.on('mousemove.crosshair', (e: MouseEvent): void => { + const [x, y] = translateToSVG( + this.canvas.node as any as SVGSVGElement, + [e.clientX, e.clientY], + ); + this.cursorPosition = { x, y }; if (this.crosshair) { - const [x, y] = translateToSVG( - this.canvas.node as any as SVGSVGElement, - [e.clientX, e.clientY], - ); - - this.crosshair.x.attr({ - y1: y, - y2: y, - }); - - this.crosshair.y.attr({ - x1: x, - x2: x, - }); + this.crosshair.x.attr({ y1: y, y2: y }); + this.crosshair.y.attr({ x1: x, x2: x }); } }); } diff --git a/cvat-canvas/src/typescript/editHandler.ts b/cvat-canvas/src/typescript/editHandler.ts index 7734bc89..defdb69e 100644 --- a/cvat-canvas/src/typescript/editHandler.ts +++ b/cvat-canvas/src/typescript/editHandler.ts @@ -84,7 +84,7 @@ export class EditHandlerImpl implements EditHandler { }).draw(dummyEvent, { snapToGrid: 0.1 }); if (this.editData.state.shapeType === 'points') { - this.editLine.style('stroke-width', 0); + this.editLine.attr('stroke-width', 0); (this.editLine as any).draw('undo'); } @@ -168,7 +168,7 @@ export class EditHandlerImpl implements EditHandler { for (const points of [firstPart, secondPart]) { this.clones.push(this.canvas.polygon(points.join(' ')) .attr('fill', this.editedShape.attr('fill')) - .style('fill-opacity', '0.5') + .attr('fill-opacity', '0.5') .addClass('cvat_canvas_shape')); } @@ -340,10 +340,16 @@ export class EditHandlerImpl implements EditHandler { public transform(geometry: Geometry): void { this.geometry = geometry; + if (this.editedShape) { + this.editedShape.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale, + }); + } + if (this.editLine) { (this.editLine as any).draw('transform'); if (this.editData.state.shapeType !== 'points') { - this.editLine.style({ + this.editLine.attr({ 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale, }); } @@ -351,7 +357,7 @@ export class EditHandlerImpl implements EditHandler { const paintHandler = this.editLine.remember('_paintHandler'); for (const point of (paintHandler as any).set.members) { - point.style( + point.attr( 'stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`, ); diff --git a/cvat-core/src/annotations-collection.js b/cvat-core/src/annotations-collection.js index 0c5b35fd..68b78b8c 100644 --- a/cvat-core/src/annotations-collection.js +++ b/cvat-core/src/annotations-collection.js @@ -864,7 +864,7 @@ : (frame) => frame - 1; for (let frame = frameFrom; predicate(frame); frame = update(frame)) { // First prepare all data for the frame - // Consider all shapes, tags, and tracks that have keyframe here + // Consider all shapes, tags, and not outside tracks that have keyframe here // In particular consider first and last frame as keyframes for all frames const statesData = [].concat( (frame in this.shapes ? this.shapes[frame] : []) @@ -878,7 +878,10 @@ || frame === frameFrom || frame === frameTo )); - statesData.push(...tracks.map((track) => track.get(frame))); + statesData.push( + ...tracks.map((track) => track.get(frame)) + .filter((state) => !state.outside), + ); // Nothing to filtering, go to the next iteration if (!statesData.length) { diff --git a/cvat-core/src/annotations-objects.js b/cvat-core/src/annotations-objects.js index f86e92ee..65a30b56 100644 --- a/cvat-core/src/annotations-objects.js +++ b/cvat-core/src/annotations-objects.js @@ -339,6 +339,11 @@ if (updated.keyframe) { checkObjectType('keyframe', data.keyframe, 'boolean', null); + if (!this.shapes || (Object.keys(this.shapes).length === 1 && !data.keyframe)) { + throw new ArgumentError( + 'Can not remove the latest keyframe of an object. Consider removing the object instead', + ); + } } return fittedPoints; @@ -964,7 +969,8 @@ const current = this.get(frame); const wasKeyframe = frame in this.shapes; - if ((keyframe && wasKeyframe) || (!keyframe && !wasKeyframe)) { + if ((keyframe && wasKeyframe) + || (!keyframe && !wasKeyframe)) { return; } @@ -1088,7 +1094,7 @@ throw new DataError( 'No one left position or right position was found. ' - + `Interpolation impossible. Client ID: ${this.id}`, + + `Interpolation impossible. Client ID: ${this.clientID}`, ); } } diff --git a/cvat-core/src/object-state.js b/cvat-core/src/object-state.js index 806a2909..e6a42f18 100644 --- a/cvat-core/src/object-state.js +++ b/cvat-core/src/object-state.js @@ -39,7 +39,7 @@ color: null, hidden: null, pinned: null, - keyframes: null, + keyframes: serialized.keyframes, group: serialized.group, updated: serialized.updated, diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index e3de1cb0..32643dd2 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -1343,7 +1343,7 @@ return annotationsData; }; - Job.prototype.annotations.search.implementation = async function (filters, frameFrom, frameTo) { + Job.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', @@ -1555,7 +1555,7 @@ return result; }; - Job.prototype.annotations.search.implementation = async function (filters, frameFrom, frameTo) { + 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', diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index 3ff854fe..828118cf 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "0.1.0", + "version": "0.5.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -9734,6 +9734,14 @@ "scheduler": "^0.17.0" } }, + "react-hotkeys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0.tgz", + "integrity": "sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==", + "requires": { + "prop-types": "^15.6.1" + } + }, "react-is": { "version": "16.11.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 9c25aa8f..60f6aa30 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -61,6 +61,7 @@ "prop-types": "^15.7.2", "react": "^16.9.0", "react-dom": "^16.9.0", + "react-hotkeys": "^2.0.0", "react-redux": "^7.1.1", "react-router": "^5.1.0", "react-router-dom": "^5.1.0", diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 9a265752..be23e67f 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -82,8 +82,10 @@ export enum AnnotationActionTypes { GROUP_OBJECTS = 'GROUP_OBJECTS', SPLIT_TRACK = 'SPLIT_TRACK', COPY_SHAPE = 'COPY_SHAPE', + PASTE_SHAPE = 'PASTE_SHAPE', EDIT_SHAPE = 'EDIT_SHAPE', DRAW_SHAPE = 'DRAW_SHAPE', + REPEAT_DRAW_SHAPE = 'REPEAT_DRAW_SHAPE', SHAPE_DRAWN = 'SHAPE_DRAWN', RESET_CANVAS = 'RESET_CANVAS', UPDATE_ANNOTATIONS_SUCCESS = 'UPDATE_ANNOTATIONS_SUCCESS', @@ -92,6 +94,8 @@ export enum AnnotationActionTypes { CREATE_ANNOTATIONS_FAILED = 'CREATE_ANNOTATIONS_FAILED', MERGE_ANNOTATIONS_SUCCESS = 'MERGE_ANNOTATIONS_SUCCESS', MERGE_ANNOTATIONS_FAILED = 'MERGE_ANNOTATIONS_FAILED', + RESET_ANNOTATIONS_GROUP = 'RESET_ANNOTATIONS_GROUP', + GROUP_ANNOTATIONS = 'GROUP_ANNOTATIONS', GROUP_ANNOTATIONS_SUCCESS = 'GROUP_ANNOTATIONS_SUCCESS', GROUP_ANNOTATIONS_FAILED = 'GROUP_ANNOTATIONS_FAILED', SPLIT_ANNOTATIONS_SUCCESS = 'SPLIT_ANNOTATIONS_SUCCESS', @@ -133,6 +137,7 @@ export enum AnnotationActionTypes { ROTATE_FRAME = 'ROTATE_FRAME', SWITCH_Z_LAYER = 'SWITCH_Z_LAYER', ADD_Z_LAYER = 'ADD_Z_LAYER', + SEARCH_ANNOTATIONS_FAILED = 'SEARCH_ANNOTATIONS_FAILED', } export function addZLayer(): AnyAction { @@ -179,10 +184,22 @@ ThunkAction, {}, {}, AnyAction> { } export function changeAnnotationsFilters(filters: string[]): AnyAction { + const state: CombinedState = getStore().getState(); + const { filtersHistory, filters: oldFilters } = state.annotation.annotations; + + filters.forEach((element: string) => { + if (!(filtersHistory.includes(element) || oldFilters.includes(element))) { + filtersHistory.push(element); + } + }); + + window.localStorage.setItem('filtersHistory', JSON.stringify(filtersHistory.slice(-10))); + return { type: AnnotationActionTypes.CHANGE_ANNOTATIONS_FILTERS, payload: { filters, + filtersHistory: filtersHistory.slice(-10), }, }; } @@ -914,6 +931,11 @@ export function updateAnnotationsAsync(sessionInstance: any, frame: number, stat ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { + if (statesToUpdate.some((state: any): boolean => state.updateFlags.zOrder)) { + // deactivate object to visualize changes immediately (UX) + dispatch(activateObject(null)); + } + const promises = statesToUpdate .map((objectState: any): Promise => objectState.save()); const states = await Promise.all(promises); @@ -1000,12 +1022,30 @@ ThunkAction, {}, {}, AnyAction> { }; } -export function groupAnnotationsAsync(sessionInstance: any, frame: number, statesToGroup: any[]): -ThunkAction, {}, {}, AnyAction> { +export function resetAnnotationsGroup(): AnyAction { + return { + type: AnnotationActionTypes.RESET_ANNOTATIONS_GROUP, + payload: {}, + }; +} + +export function groupAnnotationsAsync( + sessionInstance: any, + frame: number, + statesToGroup: any[], +): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters(); - await sessionInstance.annotations.group(statesToGroup); + const reset = getStore().getState().annotation.annotations.resetGroupFlag; + + // The action below set resetFlag to false + dispatch({ + type: AnnotationActionTypes.GROUP_ANNOTATIONS, + payload: {}, + }); + + await sessionInstance.annotations.group(statesToGroup, reset); const states = await sessionInstance.annotations .get(frame, showAllInterpolationTracks, filters); const history = await sessionInstance.actions.get(); @@ -1108,3 +1148,94 @@ export function changeGroupColorAsync( } }; } + +export function searchAnnotationsAsync( + sessionInstance: any, + frameFrom: number, + frameTo: number, +): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + try { + const { filters } = receiveAnnotationsParameters(); + const frame = await sessionInstance.annotations.search(filters, frameFrom, frameTo); + if (frame !== null) { + dispatch(changeFrameAsync(frame)); + } + } catch (error) { + dispatch({ + type: AnnotationActionTypes.SEARCH_ANNOTATIONS_FAILED, + payload: { + error, + }, + }); + } + }; +} + +export function pasteShapeAsync(): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + const initialState = getStore().getState().annotation.drawing.activeInitialState; + const { instance: canvasInstance } = getStore().getState().annotation.canvas; + + if (initialState) { + let activeControl = ActiveControl.DRAW_RECTANGLE; + if (initialState.shapeType === ShapeType.POINTS) { + activeControl = ActiveControl.DRAW_POINTS; + } else if (initialState.shapeType === ShapeType.POLYGON) { + activeControl = ActiveControl.DRAW_POLYGON; + } else if (initialState.shapeType === ShapeType.POLYLINE) { + activeControl = ActiveControl.DRAW_POLYLINE; + } + + dispatch({ + type: AnnotationActionTypes.PASTE_SHAPE, + payload: { + activeControl, + }, + }); + + canvasInstance.cancel(); + canvasInstance.draw({ + enabled: true, + initialState, + }); + } + }; +} + +export function repeatDrawShapeAsync(): ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator): Promise => { + const { + activeShapeType, + activeNumOfPoints, + activeRectDrawingMethod, + } = getStore().getState().annotation.drawing; + + const { instance: canvasInstance } = getStore().getState().annotation.canvas; + + let activeControl = ActiveControl.DRAW_RECTANGLE; + if (activeShapeType === ShapeType.POLYGON) { + activeControl = ActiveControl.DRAW_POLYGON; + } else if (activeShapeType === ShapeType.POLYLINE) { + activeControl = ActiveControl.DRAW_POLYLINE; + } else if (activeShapeType === ShapeType.POINTS) { + activeControl = ActiveControl.DRAW_POINTS; + } + + dispatch({ + type: AnnotationActionTypes.REPEAT_DRAW_SHAPE, + payload: { + activeControl, + }, + }); + + canvasInstance.cancel(); + canvasInstance.draw({ + enabled: true, + rectDrawingMethod: activeRectDrawingMethod, + numberOfPoints: activeNumOfPoints, + shapeType: activeShapeType, + crosshair: activeShapeType === ShapeType.RECTANGLE, + }); + }; +} diff --git a/cvat-ui/src/actions/models-actions.ts b/cvat-ui/src/actions/models-actions.ts index f5330cb6..c91ac39b 100644 --- a/cvat-ui/src/actions/models-actions.ts +++ b/cvat-ui/src/actions/models-actions.ts @@ -2,17 +2,20 @@ // // SPDX-License-Identifier: MIT -import { AnyAction, Dispatch, ActionCreator } from 'redux'; -import { ThunkAction } from 'redux-thunk'; - -import getCore from 'cvat-core'; -import { getCVATStore } from 'cvat-store'; +import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; import { Model, + ModelType, ModelFiles, ActiveInference, CombinedState, -} from '../reducers/interfaces'; +} from 'reducers/interfaces'; +import getCore from 'cvat-core'; + +export enum PreinstalledModels { + RCNN = 'RCNN Object Detector', + MaskRCNN = 'Mask RCNN Object Detector', +} export enum ModelsActionTypes { GET_MODELS = 'GET_MODELS', @@ -25,66 +28,101 @@ export enum ModelsActionTypes { CREATE_MODEL_SUCCESS = 'CREATE_MODEL_SUCCESS', CREATE_MODEL_FAILED = 'CREATE_MODEL_FAILED', CREATE_MODEL_STATUS_UPDATED = 'CREATE_MODEL_STATUS_UPDATED', - INFER_MODEL = 'INFER_MODEL', - INFER_MODEL_SUCCESS = 'INFER_MODEL_SUCCESS', - INFER_MODEL_FAILED = 'INFER_MODEL_FAILED', - FETCH_META_FAILED = 'FETCH_META_FAILED', - GET_INFERENCE_STATUS = 'GET_INFERENCE_STATUS', + START_INFERENCE_FAILED = 'START_INFERENCE_FAILED', GET_INFERENCE_STATUS_SUCCESS = 'GET_INFERENCE_STATUS_SUCCESS', GET_INFERENCE_STATUS_FAILED = 'GET_INFERENCE_STATUS_FAILED', + FETCH_META_FAILED = 'FETCH_META_FAILED', SHOW_RUN_MODEL_DIALOG = 'SHOW_RUN_MODEL_DIALOG', CLOSE_RUN_MODEL_DIALOG = 'CLOSE_RUN_MODEL_DIALOG', + CANCEL_INFERENCE_SUCCESS = 'CANCEL_INFERENCE_SUCCESS', + CANCEL_INFERENCE_FAILED = 'CANCEL_INFERENCE_FAILED', } -export enum PreinstalledModels { - RCNN = 'RCNN Object Detector', - MaskRCNN = 'Mask RCNN Object Detector', -} - -const core = getCore(); -const baseURL = core.config.backendAPI.slice(0, -7); - -function getModels(): AnyAction { - const action = { - type: ModelsActionTypes.GET_MODELS, - payload: {}, - }; - - return action; -} - -function getModelsSuccess(models: Model[]): AnyAction { - const action = { - type: ModelsActionTypes.GET_MODELS_SUCCESS, - payload: { +export const modelsActions = { + getModels: () => createAction(ModelsActionTypes.GET_MODELS), + getModelsSuccess: (models: Model[]) => createAction( + ModelsActionTypes.GET_MODELS_SUCCESS, { models, }, - }; - - return action; -} - -function getModelsFailed(error: any): AnyAction { - const action = { - type: ModelsActionTypes.GET_MODELS_FAILED, - payload: { + ), + getModelsFailed: (error: any) => createAction( + ModelsActionTypes.GET_MODELS_FAILED, { error, }, - }; + ), + deleteModelSuccess: (id: number) => createAction( + ModelsActionTypes.DELETE_MODEL_SUCCESS, { + id, + }, + ), + deleteModelFailed: (id: number, error: any) => createAction( + ModelsActionTypes.DELETE_MODEL_FAILED, { + error, id, + }, + ), + createModel: () => createAction(ModelsActionTypes.CREATE_MODEL), + createModelSuccess: () => createAction(ModelsActionTypes.CREATE_MODEL_SUCCESS), + createModelFailed: (error: any) => createAction( + ModelsActionTypes.CREATE_MODEL_FAILED, { + error, + }, + ), + createModelUpdateStatus: (status: string) => createAction( + ModelsActionTypes.CREATE_MODEL_STATUS_UPDATED, { + status, + }, + ), + fetchMetaFailed: (error: any) => createAction(ModelsActionTypes.FETCH_META_FAILED, { error }), + getInferenceStatusSuccess: (taskID: number, activeInference: ActiveInference) => createAction( + ModelsActionTypes.GET_INFERENCE_STATUS_SUCCESS, { + taskID, + activeInference, + }, + ), + getInferenceStatusFailed: (taskID: number, error: any) => createAction( + ModelsActionTypes.GET_INFERENCE_STATUS_FAILED, { + taskID, + error, + }, + ), + startInferenceFailed: (taskID: number, error: any) => createAction( + ModelsActionTypes.START_INFERENCE_FAILED, { + taskID, + error, + }, + ), + cancelInferenceSuccess: (taskID: number) => createAction( + ModelsActionTypes.CANCEL_INFERENCE_SUCCESS, { + taskID, + }, + ), + cancelInferenceFaild: (taskID: number, error: any) => createAction( + ModelsActionTypes.CANCEL_INFERENCE_FAILED, { + taskID, + error, + }, + ), + closeRunModelDialog: () => createAction(ModelsActionTypes.CLOSE_RUN_MODEL_DIALOG), + showRunModelDialog: (taskInstance: any) => createAction( + ModelsActionTypes.SHOW_RUN_MODEL_DIALOG, { + taskInstance, + }, + ), +}; - return action; -} +export type ModelsActions = ActionUnion; -export function getModelsAsync(): -ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { - const store = getCVATStore(); - const state: CombinedState = store.getState(); +const core = getCore(); +const baseURL = core.config.backendAPI.slice(0, -7); + +export function getModelsAsync(): ThunkAction { + return async (dispatch, getState): Promise => { + const state: CombinedState = getState(); const OpenVINO = state.plugins.list.AUTO_ANNOTATION; const RCNN = state.plugins.list.TF_ANNOTATION; const MaskRCNN = state.plugins.list.TF_SEGMENTATION; - dispatch(getModels()); + dispatch(modelsActions.getModels()); const models: Model[] = []; try { @@ -170,108 +208,31 @@ ThunkAction, {}, {}, AnyAction> { }); } } catch (error) { - dispatch(getModelsFailed(error)); + dispatch(modelsActions.getModelsFailed(error)); return; } - dispatch(getModelsSuccess(models)); + dispatch(modelsActions.getModelsSuccess(models)); }; } -function deleteModel(id: number): AnyAction { - const action = { - type: ModelsActionTypes.DELETE_MODEL, - payload: { - id, - }, - }; - - return action; -} - -function deleteModelSuccess(id: number): AnyAction { - const action = { - type: ModelsActionTypes.DELETE_MODEL_SUCCESS, - payload: { - id, - }, - }; - - return action; -} - -function deleteModelFailed(id: number, error: any): AnyAction { - const action = { - type: ModelsActionTypes.DELETE_MODEL_FAILED, - payload: { - error, - id, - }, - }; - - return action; -} - -export function deleteModelAsync(id: number): ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { - dispatch(deleteModel(id)); +export function deleteModelAsync(id: number): ThunkAction { + return async (dispatch): Promise => { try { await core.server.request(`${baseURL}/auto_annotation/delete/${id}`, { method: 'DELETE', }); } catch (error) { - dispatch(deleteModelFailed(id, error)); + dispatch(modelsActions.deleteModelFailed(id, error)); return; } - dispatch(deleteModelSuccess(id)); - }; -} - - -function createModel(): AnyAction { - const action = { - type: ModelsActionTypes.CREATE_MODEL, - payload: {}, - }; - - return action; -} - -function createModelSuccess(): AnyAction { - const action = { - type: ModelsActionTypes.CREATE_MODEL_SUCCESS, - payload: {}, + dispatch(modelsActions.deleteModelSuccess(id)); }; - - return action; } -function createModelFailed(error: any): AnyAction { - const action = { - type: ModelsActionTypes.CREATE_MODEL_FAILED, - payload: { - error, - }, - }; - - return action; -} - -function createModelUpdateStatus(status: string): AnyAction { - const action = { - type: ModelsActionTypes.CREATE_MODEL_STATUS_UPDATED, - payload: { - status, - }, - }; - - return action; -} - -export function createModelAsync(name: string, files: ModelFiles, global: boolean): -ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { +export function createModelAsync(name: string, files: ModelFiles, global: boolean): ThunkAction { + return async (dispatch): Promise => { async function checkCallback(id: string): Promise { try { const data = await core.server.request( @@ -282,30 +243,30 @@ ThunkAction, {}, {}, AnyAction> { switch (data.status) { case 'failed': - dispatch(createModelFailed( + dispatch(modelsActions.createModelFailed( `Checking request has returned the "${data.status}" status. Message: ${data.error}`, )); break; case 'unknown': - dispatch(createModelFailed( + dispatch(modelsActions.createModelFailed( `Checking request has returned the "${data.status}" status.`, )); break; case 'finished': - dispatch(createModelSuccess()); + dispatch(modelsActions.createModelSuccess()); break; default: if ('progress' in data) { - createModelUpdateStatus(data.progress); + modelsActions.createModelUpdateStatus(data.progress); } setTimeout(checkCallback.bind(null, id), 1000); } } catch (error) { - dispatch(createModelFailed(error)); + dispatch(modelsActions.createModelFailed(error)); } } - dispatch(createModel()); + dispatch(modelsActions.createModel()); const data = new FormData(); data.append('name', name); data.append('storage', typeof files.bin === 'string' ? 'shared' : 'local'); @@ -316,7 +277,7 @@ ThunkAction, {}, {}, AnyAction> { }, data); try { - dispatch(createModelUpdateStatus('Request is beign sent..')); + dispatch(modelsActions.createModelUpdateStatus('Request is beign sent..')); const response = await core.server.request( `${baseURL}/auto_annotation/create`, { method: 'POST', @@ -326,56 +287,19 @@ ThunkAction, {}, {}, AnyAction> { }, ); - dispatch(createModelUpdateStatus('Request is being processed..')); + dispatch(modelsActions.createModelUpdateStatus('Request is being processed..')); setTimeout(checkCallback.bind(null, response.id), 1000); } catch (error) { - dispatch(createModelFailed(error)); + dispatch(modelsActions.createModelFailed(error)); } }; } -function fetchMetaFailed(error: any): AnyAction { - const action = { - type: ModelsActionTypes.FETCH_META_FAILED, - payload: { - error, - }, - }; - - return action; -} - -function getInferenceStatusSuccess( - taskID: number, - activeInference: ActiveInference, -): AnyAction { - const action = { - type: ModelsActionTypes.GET_INFERENCE_STATUS_SUCCESS, - payload: { - taskID, - activeInference, - }, - }; - - return action; -} - -function getInferenceStatusFailed(taskID: number, error: any): AnyAction { - const action = { - type: ModelsActionTypes.GET_INFERENCE_STATUS_FAILED, - payload: { - taskID, - error, - }, - }; - - return action; -} - interface InferenceMeta { active: boolean; taskID: number; requestID: string; + modelType: ModelType; } const timers: any = {}; @@ -383,7 +307,8 @@ const timers: any = {}; async function timeoutCallback( url: string, taskID: number, - dispatch: ActionCreator, + modelType: ModelType, + dispatch: (action: ModelsActions) => void, ): Promise { try { delete timers[taskID]; @@ -396,11 +321,12 @@ async function timeoutCallback( status: response.status, progress: +response.progress || 0, error: response.error || response.stderr || '', + modelType, }; if (activeInference.status === 'unknown') { - dispatch(getInferenceStatusFailed( + dispatch(modelsActions.getInferenceStatusFailed( taskID, new Error( `Inference status for the task ${taskID} is unknown.`, @@ -411,7 +337,7 @@ async function timeoutCallback( } if (activeInference.status === 'failed') { - dispatch(getInferenceStatusFailed( + dispatch(modelsActions.getInferenceStatusFailed( taskID, new Error( `Inference status for the task ${taskID} is failed. ${activeInference.error}`, @@ -427,55 +353,67 @@ async function timeoutCallback( null, url, taskID, + modelType, dispatch, ), 3000, ); } - dispatch(getInferenceStatusSuccess(taskID, activeInference)); + dispatch(modelsActions.getInferenceStatusSuccess(taskID, activeInference)); } catch (error) { - dispatch(getInferenceStatusFailed(taskID, new Error( + dispatch(modelsActions.getInferenceStatusFailed(taskID, new Error( `Server request for the task ${taskID} was failed`, ))); } } function subscribe( - urlPath: string, inferenceMeta: InferenceMeta, - dispatch: ActionCreator, + dispatch: (action: ModelsActions) => void, ): void { if (!(inferenceMeta.taskID in timers)) { - const requestURL = `${baseURL}/${urlPath}/${inferenceMeta.requestID}`; + let requestURL = `${baseURL}`; + if (inferenceMeta.modelType === ModelType.OPENVINO) { + requestURL = `${requestURL}/auto_annotation/check`; + } else if (inferenceMeta.modelType === ModelType.RCNN) { + requestURL = `${requestURL}/tensorflow/annotation/check/task`; + } else if (inferenceMeta.modelType === ModelType.MASK_RCNN) { + requestURL = `${requestURL}/tensorflow/segmentation/check/task`; + } + requestURL = `${requestURL}/${inferenceMeta.requestID}`; timers[inferenceMeta.taskID] = setTimeout( timeoutCallback.bind( null, requestURL, inferenceMeta.taskID, + inferenceMeta.modelType, dispatch, ), ); } } -export function getInferenceStatusAsync(tasks: number[]): -ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { - function parse(response: any): InferenceMeta[] { +export function getInferenceStatusAsync(tasks: number[]): ThunkAction { + return async (dispatch, getState): Promise => { + function parse(response: any, modelType: ModelType): InferenceMeta[] { return Object.keys(response).map((key: string): InferenceMeta => ({ taskID: +key, requestID: response[key].rq_id || key, active: typeof (response[key].active) === 'undefined' ? ['queued', 'started'] .includes(response[key].status.toLowerCase()) : response[key].active, + modelType, })); } - const store = getCVATStore(); - const state: CombinedState = store.getState(); + const state: CombinedState = getState(); const OpenVINO = state.plugins.list.AUTO_ANNOTATION; const RCNN = state.plugins.list.TF_ANNOTATION; const MaskRCNN = state.plugins.list.TF_SEGMENTATION; + const dispatchCallback = (action: ModelsActions): void => { + dispatch(action); + }; + try { if (OpenVINO) { const response = await core.server.request( @@ -488,10 +426,10 @@ ThunkAction, {}, {}, AnyAction> { }, ); - parse(response.run) + parse(response.run, ModelType.OPENVINO) .filter((inferenceMeta: InferenceMeta): boolean => inferenceMeta.active) .forEach((inferenceMeta: InferenceMeta): void => { - subscribe('auto_annotation/check', inferenceMeta, dispatch); + subscribe(inferenceMeta, dispatchCallback); }); } @@ -506,10 +444,10 @@ ThunkAction, {}, {}, AnyAction> { }, ); - parse(response) + parse(response, ModelType.RCNN) .filter((inferenceMeta: InferenceMeta): boolean => inferenceMeta.active) .forEach((inferenceMeta: InferenceMeta): void => { - subscribe('tensorflow/annotation/check/task', inferenceMeta, dispatch); + subscribe(inferenceMeta, dispatchCallback); }); } @@ -524,60 +462,27 @@ ThunkAction, {}, {}, AnyAction> { }, ); - parse(response) + parse(response, ModelType.MASK_RCNN) .filter((inferenceMeta: InferenceMeta): boolean => inferenceMeta.active) .forEach((inferenceMeta: InferenceMeta): void => { - subscribe('tensorflow/segmentation/check/task', inferenceMeta, dispatch); + subscribe(inferenceMeta, dispatchCallback); }); } } catch (error) { - dispatch(fetchMetaFailed(error)); + dispatch(modelsActions.fetchMetaFailed(error)); } }; } - -function inferModel(): AnyAction { - const action = { - type: ModelsActionTypes.INFER_MODEL, - payload: {}, - }; - - return action; -} - -function inferModelSuccess(): AnyAction { - const action = { - type: ModelsActionTypes.INFER_MODEL_SUCCESS, - payload: {}, - }; - - return action; -} - -function inferModelFailed(error: any, taskID: number): AnyAction { - const action = { - type: ModelsActionTypes.INFER_MODEL_FAILED, - payload: { - taskID, - error, - }, - }; - - return action; -} - -export function inferModelAsync( +export function startInferenceAsync( taskInstance: any, model: Model, mapping: { [index: string]: string; }, cleanOut: boolean, -): ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { - dispatch(inferModel()); - +): ThunkAction { + return async (dispatch): Promise => { try { if (model.name === PreinstalledModels.RCNN) { await core.server.request( @@ -604,30 +509,39 @@ export function inferModelAsync( dispatch(getInferenceStatusAsync([taskInstance.id])); } catch (error) { - dispatch(inferModelFailed(error, taskInstance.id)); - return; + dispatch(modelsActions.startInferenceFailed(taskInstance.id, error)); } - - dispatch(inferModelSuccess()); }; } -export function closeRunModelDialog(): AnyAction { - const action = { - type: ModelsActionTypes.CLOSE_RUN_MODEL_DIALOG, - payload: {}, - }; +export function cancelInferenceAsync(taskID: number): ThunkAction { + return async (dispatch, getState): Promise => { + try { + const inference = getState().models.inferences[taskID]; + if (inference) { + if (inference.modelType === ModelType.OPENVINO) { + await core.server.request( + `${baseURL}/auto_annotation/cancel/${taskID}`, + ); + } else if (inference.modelType === ModelType.RCNN) { + await core.server.request( + `${baseURL}/tensorflow/annotation/cancel/task/${taskID}`, + ); + } else if (inference.modelType === ModelType.MASK_RCNN) { + await core.server.request( + `${baseURL}/tensorflow/segmentation/cancel/task/${taskID}`, + ); + } - return action; -} + if (timers[taskID]) { + clearTimeout(timers[taskID]); + delete timers[taskID]; + } + } -export function showRunModelDialog(taskInstance: any): AnyAction { - const action = { - type: ModelsActionTypes.SHOW_RUN_MODEL_DIALOG, - payload: { - taskInstance, - }, + dispatch(modelsActions.cancelInferenceSuccess(taskID)); + } catch (error) { + dispatch(modelsActions.cancelInferenceFaild(taskID, error)); + } }; - - return action; } diff --git a/cvat-ui/src/actions/shortcuts-actions.ts b/cvat-ui/src/actions/shortcuts-actions.ts new file mode 100644 index 00000000..86d83e6d --- /dev/null +++ b/cvat-ui/src/actions/shortcuts-actions.ts @@ -0,0 +1,11 @@ +import { ActionUnion, createAction } from 'utils/redux'; + +export enum ShortcutsActionsTypes { + SWITCH_SHORTCUT_DIALOG = 'SWITCH_SHORTCUT_DIALOG', +} + +export const shortcutsActions = { + switchShortcutsDialog: () => createAction(ShortcutsActionsTypes.SWITCH_SHORTCUT_DIALOG), +}; + +export type ShortcutsActions = ActionUnion; diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx index 32088df5..1b5d7508 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT import React from 'react'; +import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; import { Layout, @@ -12,17 +13,8 @@ import { } from 'antd'; import { SliderValue } from 'antd/lib//slider'; - -import { - ColorBy, - GridColor, - ObjectType, -} from 'reducers/interfaces'; - -import { - Canvas, -} from 'cvat-canvas'; - +import { ColorBy, GridColor, ObjectType } from 'reducers/interfaces'; +import { Canvas } from 'cvat-canvas'; import getCore from 'cvat-core'; const cvat = getCore(); @@ -75,6 +67,12 @@ interface Props { onUpdateContextMenu(visible: boolean, left: number, top: number): void; onAddZLayer(): void; onSwitchZLayer(cur: number): void; + onChangeBrightnessLevel(level: number): void; + onChangeContrastLevel(level: number): void; + onChangeSaturationLevel(level: number): void; + onChangeGridOpacity(opacity: number): void; + onChangeGridColor(color: GridColor): void; + onSwitchGrid(enabled: boolean): void; } export default class CanvasWrapperComponent extends React.PureComponent { @@ -109,6 +107,12 @@ export default class CanvasWrapperComponent extends React.PureComponent { activatedStateID, curZLayer, resetZoom, + grid, + gridOpacity, + gridColor, + brightnessLevel, + contrastLevel, + saturationLevel, } = this.props; if (prevProps.sidebarCollapsed !== sidebarCollapsed) { @@ -132,6 +136,31 @@ export default class CanvasWrapperComponent extends React.PureComponent { } } + if (gridOpacity !== prevProps.gridOpacity + || gridColor !== prevProps.gridColor + || grid !== prevProps.grid) { + const gridElement = window.document.getElementById('cvat_canvas_grid'); + const gridPattern = window.document.getElementById('cvat_canvas_grid_pattern'); + if (gridElement) { + gridElement.style.display = grid ? 'block' : 'none'; + } + if (gridPattern) { + gridPattern.style.stroke = gridColor.toLowerCase(); + gridPattern.style.opacity = `${gridOpacity / 100}`; + } + } + + if (brightnessLevel !== prevProps.brightnessLevel + || contrastLevel !== prevProps.contrastLevel + || saturationLevel !== prevProps.saturationLevel) { + const backgroundElement = window.document.getElementById('cvat_canvas_background'); + if (backgroundElement) { + backgroundElement.style.filter = `brightness(${brightnessLevel / 100})` + + `contrast(${contrastLevel / 100})` + + `saturate(${saturationLevel / 100})`; + } + } + if (prevProps.annotations !== annotations || prevProps.frameData !== frameData) { this.updateCanvas(); } @@ -360,7 +389,9 @@ export default class CanvasWrapperComponent extends React.PureComponent { // Filters const backgroundElement = window.document.getElementById('cvat_canvas_background'); if (backgroundElement) { - backgroundElement.style.filter = `brightness(${brightnessLevel / 100}) contrast(${contrastLevel / 100}) saturate(${saturationLevel / 100})`; + backgroundElement.style.filter = `brightness(${brightnessLevel / 100})` + + `contrast(${contrastLevel / 100})` + + `saturate(${saturationLevel / 100})`; } // Events @@ -374,6 +405,12 @@ export default class CanvasWrapperComponent extends React.PureComponent { } }); + canvasInstance.html().addEventListener('click', (): void => { + if (document.activeElement) { + (document.activeElement as HTMLElement).blur(); + } + }); + canvasInstance.html().addEventListener('contextmenu', (e: MouseEvent): void => { const { activatedStateID, @@ -488,10 +525,162 @@ export default class CanvasWrapperComponent extends React.PureComponent { minZLayer, onSwitchZLayer, onAddZLayer, + brightnessLevel, + contrastLevel, + saturationLevel, + grid, + gridColor, + gridOpacity, + onChangeBrightnessLevel, + onChangeSaturationLevel, + onChangeContrastLevel, + onChangeGridColor, + onChangeGridOpacity, + onSwitchGrid, } = this.props; + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const keyMap = { + INCREASE_BRIGHTNESS: { + name: 'Brightness+', + description: 'Increase brightness level for the image', + sequence: 'shift+b+=', + action: 'keypress', + }, + DECREASE_BRIGHTNESS: { + name: 'Brightness-', + description: 'Decrease brightness level for the image', + sequence: 'shift+b+-', + action: 'keydown', + }, + INCREASE_CONTRAST: { + name: 'Contrast+', + description: 'Increase contrast level for the image', + sequence: 'shift+c+=', + action: 'keydown', + }, + DECREASE_CONTRAST: { + name: 'Contrast-', + description: 'Decrease contrast level for the image', + sequence: 'shift+c+-', + action: 'keydown', + }, + INCREASE_SATURATION: { + name: 'Saturation+', + description: 'Increase saturation level for the image', + sequence: 'shift+s+=', + action: 'keydown', + }, + DECREASE_SATURATION: { + name: 'Saturation-', + description: 'Increase contrast level for the image', + sequence: 'shift+s+-', + action: 'keydown', + }, + INCREASE_GRID_OPACITY: { + name: 'Grid opacity+', + description: 'Make the grid more visible', + sequence: 'shift+g+=', + action: 'keydown', + }, + DECREASE_GRID_OPACITY: { + name: 'Grid opacity-', + description: 'Make the grid less visible', + sequences: 'shift+g+-', + action: 'keydown', + }, + CHANGE_GRID_COLOR: { + name: 'Grid color', + description: 'Set another color for the image grid', + sequence: 'shift+g+enter', + action: 'keydown', + }, + }; + + const step = 10; + const handlers = { + INCREASE_BRIGHTNESS: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const maxLevel = 200; + if (brightnessLevel < maxLevel) { + onChangeBrightnessLevel(Math.min(brightnessLevel + step, maxLevel)); + } + }, + DECREASE_BRIGHTNESS: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const minLevel = 50; + if (brightnessLevel > minLevel) { + onChangeBrightnessLevel(Math.max(brightnessLevel - step, minLevel)); + } + }, + INCREASE_CONTRAST: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const maxLevel = 200; + if (contrastLevel < maxLevel) { + onChangeContrastLevel(Math.min(contrastLevel + step, maxLevel)); + } + }, + DECREASE_CONTRAST: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const minLevel = 50; + if (contrastLevel > minLevel) { + onChangeContrastLevel(Math.max(contrastLevel - step, minLevel)); + } + }, + INCREASE_SATURATION: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const maxLevel = 300; + if (saturationLevel < maxLevel) { + onChangeSaturationLevel(Math.min(saturationLevel + step, maxLevel)); + } + }, + DECREASE_SATURATION: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const minLevel = 0; + if (saturationLevel > minLevel) { + onChangeSaturationLevel(Math.max(saturationLevel - step, minLevel)); + } + }, + INCREASE_GRID_OPACITY: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const maxLevel = 100; + if (!grid) { + onSwitchGrid(true); + } + + if (gridOpacity < maxLevel) { + onChangeGridOpacity(Math.min(gridOpacity + step, maxLevel)); + } + }, + DECREASE_GRID_OPACITY: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const minLevel = 0; + if (gridOpacity - step <= minLevel) { + onSwitchGrid(false); + } + + if (gridOpacity > minLevel) { + onChangeGridOpacity(Math.max(gridOpacity - step, minLevel)); + } + }, + CHANGE_GRID_COLOR: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const colors = [GridColor.Black, GridColor.Blue, + GridColor.Green, GridColor.Red, GridColor.White]; + const indexOf = colors.indexOf(gridColor) + 1; + const color = colors[indexOf >= colors.length ? 0 : indexOf]; + onChangeGridColor(color); + }, + }; + return ( + {/* This element doesn't have any props So, React isn't going to rerender it diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index 9483c148..bf151997 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT import React from 'react'; +import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; import { Layout, @@ -39,6 +40,9 @@ interface Props { groupObjects(enabled: boolean): void; splitTrack(enabled: boolean): void; rotateFrame(rotation: Rotation): void; + repeatDrawShape(): void; + pasteShape(): void; + resetGroup(): void; } export default function ControlsSideBarComponent(props: Props): JSX.Element { @@ -50,14 +54,140 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { groupObjects, splitTrack, rotateFrame, + repeatDrawShape, + pasteShape, + resetGroup, } = props; + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const keyMap = { + PASTE_SHAPE: { + name: 'Paste shape', + description: 'Paste a shape from internal CVAT clipboard', + sequence: 'ctrl+v', + action: 'keydown', + }, + SWITCH_DRAW_MODE: { + name: 'Draw mode', + description: 'Repeat the latest procedure of drawing with the same parameters', + sequence: 'n', + action: 'keydown', + }, + SWITCH_MERGE_MODE: { + name: 'Merge mode', + description: 'Activate or deactivate mode to merging shapes', + sequence: 'm', + action: 'keydown', + }, + SWITCH_GROUP_MODE: { + name: 'Group mode', + description: 'Activate or deactivate mode to grouping shapes', + sequence: 'g', + action: 'keydown', + }, + RESET_GROUP: { + name: 'Reset group', + description: 'Reset group for selected shapes (in group mode)', + sequence: 'shift+g', + action: 'keyup', + }, + CANCEL: { + name: 'Cancel', + description: 'Cancel any active canvas mode', + sequence: 'esc', + action: 'keydown', + }, + CLOCKWISE_ROTATION: { + name: 'Rotate clockwise', + description: 'Change image angle (add 90 degrees)', + sequence: 'ctrl+r', + action: 'keydown', + }, + ANTICLOCKWISE_ROTATION: { + name: 'Rotate anticlockwise', + description: 'Change image angle (substract 90 degrees)', + sequence: 'ctrl+shift+r', + action: 'keydown', + }, + }; + + const handlers = { + PASTE_SHAPE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + canvasInstance.cancel(); + pasteShape(); + }, + SWITCH_DRAW_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const drawing = [ActiveControl.DRAW_POINTS, ActiveControl.DRAW_POLYGON, + ActiveControl.DRAW_POLYLINE, ActiveControl.DRAW_RECTANGLE].includes(activeControl); + + if (!drawing) { + canvasInstance.cancel(); + // repeateDrawShapes gets all the latest parameters + // and calls canvasInstance.draw() with them + repeatDrawShape(); + } else { + canvasInstance.draw({ enabled: false }); + } + }, + SWITCH_MERGE_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const merging = activeControl === ActiveControl.MERGE; + if (!merging) { + canvasInstance.cancel(); + } + canvasInstance.merge({ enabled: !merging }); + mergeObjects(!merging); + }, + SWITCH_GROUP_MODE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const grouping = activeControl === ActiveControl.GROUP; + if (!grouping) { + canvasInstance.cancel(); + } + canvasInstance.group({ enabled: !grouping }); + groupObjects(!grouping); + }, + RESET_GROUP: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const grouping = activeControl === ActiveControl.GROUP; + if (!grouping) { + return; + } + resetGroup(); + canvasInstance.group({ enabled: false }); + groupObjects(false); + }, + CANCEL: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (activeControl !== ActiveControl.CURSOR) { + canvasInstance.cancel(); + } + }, + CLOCKWISE_ROTATION: (event: KeyboardEvent | undefined) => { + preventDefault(event); + rotateFrame(Rotation.CLOCKWISE90); + }, + ANTICLOCKWISE_ROTATION: (event: KeyboardEvent | undefined) => { + preventDefault(event); + rotateFrame(Rotation.ANTICLOCKWISE90); + }, + }; + return ( + + diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list-header.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list-header.tsx index 8bc8eba7..d7406ccd 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list-header.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list-header.tsx @@ -63,6 +63,7 @@ interface Props { statesCollapsed: boolean; statesOrdering: StatesOrdering; annotationsFilters: string[]; + annotationsFiltersHistory: string[]; changeStatesOrdering(value: StatesOrdering): void; changeAnnotationsFilters(value: SelectValue): void; lockAllStates(): void; @@ -76,6 +77,7 @@ interface Props { function ObjectListHeader(props: Props): JSX.Element { const { annotationsFilters, + annotationsFiltersHistory, statesHidden, statesLocked, statesCollapsed, @@ -105,9 +107,12 @@ function ObjectListHeader(props: Props): JSX.Element { Annotations filter )} - dropdownStyle={{ display: 'none' }} onChange={changeAnnotationsFilters} - /> + > + {annotationsFiltersHistory.map((element: string): JSX.Element => ( + {element} + ))} + diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index b5942646..e3af2e3c 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -18,6 +18,7 @@ interface Props { statesOrdering: StatesOrdering; sortedStatesID: number[]; annotationsFilters: string[]; + annotationsFiltersHistory: string[]; changeStatesOrdering(value: StatesOrdering): void; changeAnnotationsFilters(value: SelectValue): void; lockAllStates(): void; @@ -37,6 +38,7 @@ function ObjectListComponent(props: Props): JSX.Element { statesOrdering, sortedStatesID, annotationsFilters, + annotationsFiltersHistory, changeStatesOrdering, changeAnnotationsFilters, lockAllStates, @@ -63,6 +65,7 @@ function ObjectListComponent(props: Props): JSX.Element { expandAllStates={expandAllStates} hideAllStates={hideAllStates} showAllStates={showAllStates} + annotationsFiltersHistory={annotationsFiltersHistory} />
{ sortedStatesID.map((id: number): 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 dadfb597..397bf8d6 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 @@ -20,6 +20,7 @@ interface Props { startFrame: number; stopFrame: number; frameNumber: number; + inputFrameRef: React.RefObject; onSliderChange(value: SliderValue): void; onInputChange(value: number | undefined): void; onURLIconClick(): void; @@ -30,6 +31,7 @@ function PlayerNavigation(props: Props): JSX.Element { startFrame, stopFrame, frameNumber, + inputFrameRef, onSliderChange, onInputChange, onURLIconClick, @@ -69,6 +71,7 @@ function PlayerNavigation(props: Props): JSX.Element { value={frameNumber || 0} // https://stackoverflow.com/questions/38256332/in-react-whats-the-difference-between-onchange-and-oninput onChange={onInputChange} + ref={inputFrameRef} /> 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 2c51efb4..a3587203 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 @@ -8,6 +8,7 @@ import { Row, Col, Layout, + InputNumber, } from 'antd'; import { SliderValue } from 'antd/lib/slider'; @@ -22,6 +23,7 @@ interface Props { saving: boolean; savingStatuses: string[]; frameNumber: number; + inputFrameRef: React.RefObject; startFrame: number; stopFrame: number; undoAction?: string; @@ -42,7 +44,7 @@ interface Props { onRedoClick(): void; } -function AnnotationTopBarComponent(props: Props): JSX.Element { +export default function AnnotationTopBarComponent(props: Props): JSX.Element { const { saving, savingStatuses, @@ -50,6 +52,7 @@ function AnnotationTopBarComponent(props: Props): JSX.Element { redoAction, playing, frameNumber, + inputFrameRef, startFrame, stopFrame, showStatistics, @@ -96,6 +99,7 @@ function AnnotationTopBarComponent(props: Props): JSX.Element { startFrame={startFrame} stopFrame={stopFrame} frameNumber={frameNumber} + inputFrameRef={inputFrameRef} onSliderChange={onSliderChange} onInputChange={onInputChange} onURLIconClick={onURLIconClick} @@ -107,5 +111,3 @@ function AnnotationTopBarComponent(props: Props): JSX.Element { ); } - -export default React.memo(AnnotationTopBarComponent); diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index b696c048..91795797 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -5,18 +5,17 @@ import 'antd/dist/antd.less'; import '../styles.scss'; import React from 'react'; -import { BrowserRouter } from 'react-router-dom'; -import { - Switch, - Route, - Redirect, -} from 'react-router'; +import { Switch, Route, Redirect } from 'react-router'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { GlobalHotKeys, KeyMap, configure } from 'react-hotkeys'; + import { Spin, Layout, notification, } from 'antd'; +import ShorcutsDialog from 'components/shortcuts-dialog/shortcuts-dialog'; import SettingsPageContainer from 'containers/settings-page/settings-page'; import TasksPageContainer from 'containers/tasks-page/tasks-page'; import CreateTaskPageContainer from 'containers/create-task-page/create-task-page'; @@ -30,7 +29,7 @@ import HeaderContainer from 'containers/header/header'; import { NotificationsState } from 'reducers/interfaces'; -type CVATAppProps = { +interface CVATAppProps { loadFormats: () => void; loadUsers: () => void; loadAbout: () => void; @@ -38,6 +37,7 @@ type CVATAppProps = { initPlugins: () => void; resetErrors: () => void; resetMessages: () => void; + switchShortcutsDialog: () => void; userInitialized: boolean; pluginsInitialized: boolean; pluginsFetching: boolean; @@ -52,11 +52,12 @@ type CVATAppProps = { installedTFSegmentation: boolean; notifications: NotificationsState; user: any; -}; +} -export default class CVATApplication extends React.PureComponent { +class CVATApplication extends React.PureComponent { public componentDidMount(): void { const { verifyAuthorized } = this.props; + configure({ ignoreRepeatedEventsWhenKeyHeldDown: false }); verifyAuthorized(); } @@ -190,7 +191,9 @@ export default class CVATApplication extends React.PureComponent { installedAutoAnnotation, installedTFSegmentation, installedTFAnnotation, + switchShortcutsDialog, user, + history, } = this.props; const readyForRender = (userInitialized && user == null) @@ -200,13 +203,50 @@ export default class CVATApplication extends React.PureComponent { const withModels = installedAutoAnnotation || installedTFAnnotation || installedTFSegmentation; + const keyMap = { + SWITCH_SHORTCUTS: { + name: 'Show shortcuts', + description: 'Open/hide the list of available shortcuts', + sequence: 'f1', + action: 'keydown', + }, + OPEN_SETTINGS: { + name: 'Open settings', + description: 'Go to the settings page or go back', + sequence: 'f2', + action: 'keydown', + }, + }; + + const handlers = { + SWITCH_SHORTCUTS: (event: KeyboardEvent | undefined) => { + if (event) { + event.preventDefault(); + } + + switchShortcutsDialog(); + }, + OPEN_SETTINGS: (event: KeyboardEvent | undefined) => { + if (event) { + event.preventDefault(); + } + + if (history.location.pathname.endsWith('settings')) { + history.goBack(); + } else { + history.push('/settings'); + } + }, + }; + if (readyForRender) { if (user) { return ( - - - - + + + + + @@ -219,22 +259,20 @@ export default class CVATApplication extends React.PureComponent { && } - {/* eslint-disable-next-line */} - - - - + + {/* eslint-disable-next-line */} + + + ); } return ( - - - - - - - + + + + + ); } @@ -243,3 +281,5 @@ export default class CVATApplication extends React.PureComponent { ); } } + +export default withRouter(CVATApplication); diff --git a/cvat-ui/src/components/shortcuts-dialog/shortcuts-dialog.tsx b/cvat-ui/src/components/shortcuts-dialog/shortcuts-dialog.tsx new file mode 100644 index 00000000..c8a0ee7c --- /dev/null +++ b/cvat-ui/src/components/shortcuts-dialog/shortcuts-dialog.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { getApplicationKeyMap } from 'react-hotkeys'; +import { Modal, Table } from 'antd'; +import { connect } from 'react-redux'; + +import { shortcutsActions } from 'actions/shortcuts-actions'; +import { CombinedState } from 'reducers/interfaces'; + +interface StateToProps { + visible: boolean; +} + +interface DispatchToProps { + switchShortcutsDialog(): void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { + shortcuts: { + visibleShortcutsHelp: visible, + }, + } = state; + + return { + visible, + }; +} + +function mapDispatchToProps(dispatch: any): DispatchToProps { + return { + switchShortcutsDialog(): void { + dispatch(shortcutsActions.switchShortcutsDialog()); + }, + }; +} + +function ShorcutsDialog(props: StateToProps & DispatchToProps): JSX.Element | null { + const { visible, switchShortcutsDialog } = props; + const keyMap = getApplicationKeyMap(); + + const splitToRows = (data: string[]): JSX.Element[] => ( + data.map((item: string, id: number): JSX.Element => ( + // eslint-disable-next-line react/no-array-index-key + + {item} +
+
+ )) + ); + + const columns = [{ + title: 'Name', + dataIndex: 'name', + key: 'name', + }, { + title: 'Shortcut', + dataIndex: 'shortcut', + key: 'shortcut', + render: splitToRows, + }, { + title: 'Action', + dataIndex: 'action', + key: 'action', + render: splitToRows, + }, { + title: 'Description', + dataIndex: 'description', + key: 'description', + }]; + + const dataSource = Object.keys(keyMap).map((key: string, id: number) => ({ + key: id, + name: keyMap[key].name || key, + description: keyMap[key].description || '', + shortcut: keyMap[key].sequences.map((value) => value.sequence), + action: keyMap[key].sequences.map((value) => value.action || 'keydown'), + })); + + return ( + + + + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ShorcutsDialog); diff --git a/cvat-ui/src/components/tasks-page/styles.scss b/cvat-ui/src/components/tasks-page/styles.scss index a203faaa..b2fbc9be 100644 --- a/cvat-ui/src/components/tasks-page/styles.scss +++ b/cvat-ui/src/components/tasks-page/styles.scss @@ -87,37 +87,11 @@ padding-top: 20px; background: $background-color-1; - /* description */ - > div:nth-child(2) { - word-break: break-all; - max-height: 100%; - overflow: hidden; - } - - /* open, actions */ - div:nth-child(4) { - > div { - margin-right: 20px; - } - - /* actions */ - > div:nth-child(2) { - margin-right: 5px; - margin-top: 10px; - - > div { - display: flex; - align-items: center; - } - } - } - &:hover { border: 1px solid $border-color-hover; } } - .cvat-task-item-preview-wrapper { display: flex; justify-content: center; @@ -131,6 +105,12 @@ } } +.cvat-task-item-description { + word-break: break-all; + max-height: 100%; + overflow: hidden; +} + .cvat-task-progress { width: 100%; } @@ -159,6 +139,26 @@ margin-right: 5px; } +.close-auto-annotation-icon { + color: $danger-icon-color; + opacity: 0.7; + + &:hover { + opacity: 1; + } +} + +.cvat-item-open-task-actions { + margin-right: 5px; + margin-top: 10px; + display: flex; + align-items: center; +} + +.cvat-item-open-task-button { + margin-right: 20px; +} + #cvat-create-task-button { padding: 0 30px; } diff --git a/cvat-ui/src/components/tasks-page/task-item.tsx b/cvat-ui/src/components/tasks-page/task-item.tsx index 75b54c40..915544f6 100644 --- a/cvat-ui/src/components/tasks-page/task-item.tsx +++ b/cvat-ui/src/components/tasks-page/task-item.tsx @@ -14,6 +14,8 @@ import { Icon, Progress, Dropdown, + Tooltip, + Modal, } from 'antd'; import moment from 'moment'; @@ -28,6 +30,7 @@ export interface TaskItemProps { deleted: boolean; hidden: boolean; activeInference: ActiveInference | null; + cancelAutoAnnotation(): void; } class TaskItemComponent extends React.PureComponent { @@ -54,7 +57,7 @@ class TaskItemComponent extends React.PureComponent 70 ? '...' : ''}`; return ( - + {`#${id}: `}{name}
@@ -76,6 +79,7 @@ class TaskItemComponent extends React.PureComponentAutomatic annotation - - + + + + + { + Modal.confirm({ + title: 'You are going to cancel automatic annotation?', + content: 'Reached progress will be lost. Continue?', + okType: 'danger', + onOk() { + cancelAutoAnnotation(); + }, + }); + }} + /> + + )} @@ -164,6 +185,7 @@ class TaskItemComponent extends React.PureComponent + Actions}> diff --git a/cvat-ui/src/containers/actions-menu/actions-menu.tsx b/cvat-ui/src/containers/actions-menu/actions-menu.tsx index 36c72b66..77b1c6cd 100644 --- a/cvat-ui/src/containers/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/containers/actions-menu/actions-menu.tsx @@ -10,7 +10,7 @@ import { CombinedState, } from 'reducers/interfaces'; -import { showRunModelDialog } from 'actions/models-actions'; +import { modelsActions } from 'actions/models-actions'; import { dumpAnnotationsAsync, loadAnnotationsAsync, @@ -99,7 +99,7 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { dispatch(deleteTaskAsync(taskInstance)); }, openRunModelWindow: (taskInstance: any): void => { - dispatch(showRunModelDialog(taskInstance)); + dispatch(modelsActions.showRunModelDialog(taskInstance)); }, }; } diff --git a/cvat-ui/src/containers/annotation-page/annotation-page.tsx b/cvat-ui/src/containers/annotation-page/annotation-page.tsx index d40cb2c1..c66a9304 100644 --- a/cvat-ui/src/containers/annotation-page/annotation-page.tsx +++ b/cvat-ui/src/containers/annotation-page/annotation-page.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { RouteComponentProps } from 'react-router'; @@ -80,15 +79,10 @@ function mapDispatchToProps(dispatch: any, own: OwnProps): DispatchToProps { }; } -function AnnotationPageContainer(props: StateToProps & DispatchToProps): JSX.Element { - return ( - - ); -} export default withRouter( connect( mapStateToProps, mapDispatchToProps, - )(AnnotationPageContainer), + )(AnnotationPageComponent), ); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx index f2185a6d..0625bece 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -2,11 +2,9 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; import { connect } from 'react-redux'; import CanvasWrapperComponent from 'components/annotation-page/standard-workspace/canvas-wrapper'; - import { confirmCanvasReady, dragCanvas, @@ -28,6 +26,14 @@ import { addZLayer, switchZLayer, } from 'actions/annotation-actions'; +import { + switchGrid, + changeGridColor, + changeGridOpacity, + changeBrightnessLevel, + changeContrastLevel, + changeSaturationLevel, +} from 'actions/settings-actions'; import { ColorBy, GridColor, @@ -86,6 +92,12 @@ interface DispatchToProps { onUpdateContextMenu(visible: boolean, left: number, top: number): void; onAddZLayer(): void; onSwitchZLayer(cur: number): void; + onChangeBrightnessLevel(level: number): void; + onChangeContrastLevel(level: number): void; + onChangeSaturationLevel(level: number): void; + onChangeGridOpacity(opacity: number): void; + onChangeGridColor(color: GridColor): void; + onSwitchGrid(enabled: boolean): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -233,16 +245,28 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { onSwitchZLayer(cur: number): void { dispatch(switchZLayer(cur)); }, + onChangeBrightnessLevel(level: number): void { + dispatch(changeBrightnessLevel(level)); + }, + onChangeContrastLevel(level: number): void { + dispatch(changeContrastLevel(level)); + }, + onChangeSaturationLevel(level: number): void { + dispatch(changeSaturationLevel(level)); + }, + onChangeGridOpacity(opacity: number): void { + dispatch(changeGridOpacity(opacity)); + }, + onChangeGridColor(color: GridColor): void { + dispatch(changeGridColor(color)); + }, + onSwitchGrid(enabled: boolean): void { + dispatch(switchGrid(enabled)); + }, }; } -function CanvasWrapperContainer(props: StateToProps & DispatchToProps): JSX.Element { - return ( - - ); -} - export default connect( mapStateToProps, mapDispatchToProps, -)(CanvasWrapperContainer); +)(CanvasWrapperComponent); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index 17764789..1f0b1f31 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; import { connect } from 'react-redux'; import { Canvas } from 'cvat-canvas'; @@ -12,6 +11,9 @@ import { groupObjects, splitTrack, rotateCurrentFrame, + repeatDrawShapeAsync, + pasteShapeAsync, + resetAnnotationsGroup, } from 'actions/annotation-actions'; import ControlsSideBarComponent from 'components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar'; import { @@ -31,6 +33,9 @@ interface DispatchToProps { groupObjects(enabled: boolean): void; splitTrack(enabled: boolean): void; rotateFrame(angle: Rotation): void; + resetGroup(): void; + repeatDrawShape(): void; + pasteShape(): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -69,16 +74,19 @@ function dispatchToProps(dispatch: any): DispatchToProps { rotateFrame(rotation: Rotation): void { dispatch(rotateCurrentFrame(rotation)); }, + repeatDrawShape(): void { + dispatch(repeatDrawShapeAsync()); + }, + pasteShape(): void { + dispatch(pasteShapeAsync()); + }, + resetGroup(): void { + dispatch(resetAnnotationsGroup()); + }, }; } -function ControlsSideBarContainer(props: StateToProps & DispatchToProps): JSX.Element { - return ( - - ); -} - export default connect( mapStateToProps, dispatchToProps, -)(ControlsSideBarContainer); +)(ControlsSideBarComponent); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx index ef2783cb..2e70ed1b 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/label-item.tsx @@ -115,8 +115,11 @@ class LabelItemContainer extends React.PureComponent { let statesLocked = true; ownObjectStates.forEach((objectState: any) => { - statesHidden = statesHidden && objectState.hidden; - statesLocked = statesLocked && objectState.lock; + const { lock } = objectState; + if (!lock) { + statesHidden = statesHidden && objectState.hidden; + statesLocked = statesLocked && objectState.lock; + } }); return { diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/labels-list.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/labels-list.tsx index bffadae0..3494ba97 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/labels-list.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/labels-list.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; import { connect } from 'react-redux'; import LabelsListComponent from 'components/annotation-page/standard-workspace/objects-side-bar/labels-list'; @@ -29,12 +28,6 @@ function mapStateToProps(state: CombinedState): StateToProps { }; } -function LabelsListContainer(props: StateToProps): JSX.Element { - return ( - - ); -} - export default connect( mapStateToProps, -)(LabelsListContainer); +)(LabelsListComponent); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index 0723f438..63cdf5f1 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -22,6 +22,7 @@ import { copyShape as copyShapeAction, activateObject as activateObjectAction, propagateObject as propagateObjectAction, + pasteShapeAsync, } from 'actions/annotation-actions'; import ObjectStateItemComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item'; @@ -143,6 +144,7 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { }, copyShape(objectState: any): void { dispatch(copyShapeAction(objectState)); + dispatch(pasteShapeAsync()); }, propagateObject(objectState: any): void { dispatch(propagateObjectAction(objectState)); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index b1e2ed85..8bc18e0f 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { connect } from 'react-redux'; +import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; import { SelectValue } from 'antd/lib/select'; @@ -11,13 +12,18 @@ import ObjectsListComponent from 'components/annotation-page/standard-workspace/ import { updateAnnotationsAsync, fetchAnnotationsAsync, + removeObjectAsync, + changeFrameAsync, changeAnnotationsFilters as changeAnnotationsFiltersAction, collapseObjectItems, + copyShape as copyShapeAction, + propagateObject as propagateObjectAction, } from 'actions/annotation-actions'; import { CombinedState, StatesOrdering, + ObjectType, } from 'reducers/interfaces'; interface StateToProps { @@ -29,12 +35,20 @@ interface StateToProps { statesCollapsed: boolean; objectStates: any[]; annotationsFilters: string[]; + activatedStateID: number | null; + minZLayer: number; + maxZLayer: number; + annotationsFiltersHistory: string[]; } interface DispatchToProps { updateAnnotations(sessionInstance: any, frameNumber: number, states: any[]): void; changeAnnotationsFilters(sessionInstance: any, filters: string[]): void; collapseStates(states: any[], value: boolean): void; + removeObject: (sessionInstance: any, objectState: any, force: boolean) => void; + copyShape: (objectState: any) => void; + propagateObject: (objectState: any) => void; + changeFrame(frame: number): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -43,7 +57,13 @@ function mapStateToProps(state: CombinedState): StateToProps { annotations: { states: objectStates, filters: annotationsFilters, + filtersHistory: annotationsFiltersHistory, collapsed, + activatedStateID, + zLayer: { + min: minZLayer, + max: maxZLayer, + }, }, job: { instance: jobInstance, @@ -62,9 +82,11 @@ function mapStateToProps(state: CombinedState): StateToProps { let statesCollapsed = true; objectStates.forEach((objectState: any) => { - const { clientID } = objectState; - statesHidden = statesHidden && objectState.hidden; - statesLocked = statesLocked && objectState.lock; + const { clientID, lock } = objectState; + if (!lock) { + statesHidden = statesHidden && objectState.hidden; + statesLocked = statesLocked && objectState.lock; + } const stateCollapsed = clientID in collapsed ? collapsed[clientID] : true; statesCollapsed = statesCollapsed && stateCollapsed; }); @@ -78,6 +100,10 @@ function mapStateToProps(state: CombinedState): StateToProps { frameNumber, jobInstance, annotationsFilters, + activatedStateID, + minZLayer, + maxZLayer, + annotationsFiltersHistory, }; } @@ -96,6 +122,18 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { dispatch(changeAnnotationsFiltersAction(filters)); dispatch(fetchAnnotationsAsync(sessionInstance)); }, + removeObject(sessionInstance: any, objectState: any, force: boolean): void { + dispatch(removeObjectAsync(sessionInstance, objectState, force)); + }, + copyShape(objectState: any): void { + dispatch(copyShapeAction(objectState)); + }, + propagateObject(objectState: any): void { + dispatch(propagateObjectAction(objectState)); + }, + changeFrame(frame: number): void { + dispatch(changeFrameAsync(frame)); + }, }; } @@ -221,27 +259,263 @@ class ObjectsListContainer extends React.PureComponent { } public render(): JSX.Element { - const { annotationsFilters } = this.props; + const { + annotationsFilters, + statesHidden, + statesLocked, + activatedStateID, + objectStates, + frameNumber, + jobInstance, + updateAnnotations, + removeObject, + copyShape, + propagateObject, + changeFrame, + maxZLayer, + minZLayer, + annotationsFiltersHistory, + } = this.props; const { sortedStatesID, statesOrdering, } = this.state; + const keyMap = { + SWITCH_ALL_LOCK: { + name: 'Lock/unlock all objects', + description: 'Change locked state for all objects in the side bar', + sequence: 't+l', + action: 'keydown', + }, + SWITCH_LOCK: { + name: 'Lock/unlock an object', + description: 'Change locked state for an active object', + sequence: 'l', + action: 'keydown', + }, + SWITCH_ALL_HIDDEN: { + name: 'Hide/show all objects', + description: 'Change hidden state for objects in the side bar', + sequence: 't+h', + action: 'keydown', + }, + SWITCH_HIDDEN: { + name: 'Hide/show an object', + description: 'Change hidden state for an active object', + sequence: 'h', + action: 'keydown', + }, + SWITCH_OCCLUDED: { + name: 'Switch occluded', + description: 'Change occluded property for an active object', + sequences: ['q', '/'], + action: 'keydown', + }, + SWITCH_KEYFRAME: { + name: 'Switch keyframe', + description: 'Change keyframe property for an active track', + sequence: 'k', + action: 'keydown', + }, + SWITCH_OUTSIDE: { + name: 'Switch outside', + description: 'Change outside property for an active track', + sequence: 'o', + action: 'keydown', + }, + DELETE_OBJECT: { + name: 'Delete object', + description: 'Delete an active object. Use shift to force delete of locked objects', + sequences: ['del', 'shift+del'], + action: 'keydown', + }, + TO_BACKGROUND: { + name: 'To background', + description: 'Put an active object "farther" from the user (decrease z axis value)', + sequences: ['-', '_'], + action: 'keydown', + }, + TO_FOREGROUND: { + name: 'To foreground', + description: 'Put an active object "closer" to the user (increase z axis value)', + sequences: ['+', '='], + action: 'keydown', + }, + COPY_SHAPE: { + name: 'Copy shape', + description: 'Copy shape to CVAT internal clipboard', + sequence: 'ctrl+c', + action: 'keydown', + }, + PROPAGATE_OBJECT: { + name: 'Propagate object', + description: 'Make a copy of the object on the following frames', + sequence: 'ctrl+b', + action: 'keydown', + }, + NEXT_KEY_FRAME: { + name: 'Next keyframe', + description: 'Go to the next keyframe of an active track', + sequence: 'r', + action: 'keydown', + }, + PREV_KEY_FRAME: { + name: 'Previous keyframe', + description: 'Go to the previous keyframe of an active track', + sequence: 'e', + action: 'keydown', + }, + }; + + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const activatedStated = (): any | null => { + if (activatedStateID !== null) { + const [state] = objectStates + .filter((objectState: any): boolean => ( + objectState.clientID === activatedStateID + )); + + return state || null; + } + + return null; + }; + + const handlers = { + SWITCH_ALL_LOCK: (event: KeyboardEvent | undefined) => { + preventDefault(event); + this.lockAllStates(!statesLocked); + }, + SWITCH_LOCK: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state) { + state.lock = !state.lock; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + SWITCH_ALL_HIDDEN: (event: KeyboardEvent | undefined) => { + preventDefault(event); + this.hideAllStates(!statesHidden); + }, + SWITCH_HIDDEN: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state) { + state.hidden = !state.hidden; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + SWITCH_OCCLUDED: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + state.occluded = !state.occluded; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + SWITCH_KEYFRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType === ObjectType.TRACK) { + state.keyframe = !state.keyframe; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + SWITCH_OUTSIDE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType === ObjectType.TRACK) { + state.outside = !state.outside; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + DELETE_OBJECT: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state) { + removeObject(jobInstance, state, event ? event.shiftKey : false); + } + }, + TO_BACKGROUND: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + state.zOrder = minZLayer - 1; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + TO_FOREGROUND: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + state.zOrder = maxZLayer + 1; + updateAnnotations(jobInstance, frameNumber, [state]); + } + }, + COPY_SHAPE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + copyShape(state); + } + }, + PROPAGATE_OBJECT: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType !== ObjectType.TAG) { + propagateObject(state); + } + }, + NEXT_KEY_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType === ObjectType.TRACK) { + const frame = typeof (state.keyframes.next) === 'number' + ? state.keyframes.next : null; + if (frame !== null) { + changeFrame(frame); + } + } + }, + PREV_KEY_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + const state = activatedStated(); + if (state && state.objectType === ObjectType.TRACK) { + const frame = typeof (state.keyframes.prev) === 'number' + ? state.keyframes.prev : null; + if (frame !== null) { + changeFrame(frame); + } + } + }, + }; + return ( - + <> + + + ); } } diff --git a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx index c5758b56..8bcc1f59 100644 --- a/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/top-bar/top-bar.tsx @@ -8,7 +8,9 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router'; import { RouteComponentProps } from 'react-router-dom'; +import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; +import { InputNumber } from 'antd'; import { SliderValue } from 'antd/lib/slider'; import { @@ -19,6 +21,7 @@ import { showStatistics as showStatisticsAction, undoActionAsync, redoActionAsync, + searchAnnotationsAsync, } from 'actions/annotation-actions'; import AnnotationTopBarComponent from 'components/annotation-page/top-bar/top-bar'; @@ -47,6 +50,7 @@ interface DispatchToProps { showStatistics(sessionInstance: any): void; undo(sessionInstance: any, frameNumber: any): void; redo(sessionInstance: any, frameNumber: any): void; + searchAnnotations(sessionInstance: any, frameFrom: any, frameTo: any): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -123,14 +127,23 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { redo(sessionInstance: any, frameNumber: any): void { dispatch(redoActionAsync(sessionInstance, frameNumber)); }, + searchAnnotations(sessionInstance: any, frameFrom: any, frameTo: any): void { + dispatch(searchAnnotationsAsync(sessionInstance, frameFrom, frameTo)); + }, }; } type Props = StateToProps & DispatchToProps & RouteComponentProps; class AnnotationTopBarContainer extends React.PureComponent { + private inputFrameRef: React.RefObject; private autoSaveInterval: number | undefined; private unblock: any; + constructor(props: Props) { + super(props); + this.inputFrameRef = React.createRef(); + } + public componentDidMount(): void { const { autoSave, @@ -421,6 +434,7 @@ class AnnotationTopBarContainer extends React.PureComponent { playing, saving, savingStatuses, + jobInstance, jobInstance: { startFrame, stopFrame, @@ -428,33 +442,179 @@ class AnnotationTopBarContainer extends React.PureComponent { frameNumber, undoAction, redoAction, + searchAnnotations, + canvasIsReady, } = this.props; + const preventDefault = (event: KeyboardEvent | undefined): void => { + if (event) { + event.preventDefault(); + } + }; + + const keyMap = { + SAVE_JOB: { + name: 'Save the job', + description: 'Send all changes of annotations to the server', + sequence: 'ctrl+s', + action: 'keydown', + }, + UNDO: { + name: 'Undo action', + description: 'Cancel the latest action related with objects', + sequence: 'ctrl+z', + action: 'keydown', + }, + REDO: { + name: 'Redo action', + description: 'Cancel undo action', + sequences: ['ctrl+shift+z', 'ctrl+y'], + action: 'keydown', + }, + NEXT_FRAME: { + name: 'Next frame', + description: 'Go to the next frame', + sequence: 'f', + action: 'keydown', + }, + PREV_FRAME: { + name: 'Previous frame', + description: 'Go to the previous frame', + sequence: 'd', + action: 'keydown', + }, + FORWARD_FRAME: { + name: 'Forward frame', + description: 'Go forward with a step', + sequence: 'v', + action: 'keydown', + }, + BACKWARD_FRAME: { + name: 'Backward frame', + description: 'Go backward with a step', + sequence: 'c', + action: 'keydown', + }, + SEARCH_FORWARD: { + name: 'Search forward', + description: 'Search the next frame that satisfies to the filters', + sequence: 'right', + action: 'keydown', + }, + SEARCH_BACKWARD: { + name: 'Search backward', + description: 'Search the previous frame that satisfies to the filters', + sequence: 'left', + action: 'keydown', + }, + PLAY_PAUSE: { + name: 'Play/pause', + description: 'Start/stop automatic changing frames', + sequence: 'space', + action: 'keydown', + }, + FOCUS_INPUT_FRAME: { + name: 'Focus input frame', + description: 'Focus on the element to change the current frame', + sequences: ['`', '~'], + action: 'keydown', + }, + }; + + const handlers = { + UNDO: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (undoAction) { + this.undo(); + } + }, + REDO: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (redoAction) { + this.redo(); + } + }, + SAVE_JOB: (event: KeyboardEvent | undefined) => { + preventDefault(event); + this.onSaveAnnotation(); + }, + NEXT_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (canvasIsReady) { + this.onNextFrame(); + } + }, + PREV_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (canvasIsReady) { + this.onPrevFrame(); + } + }, + FORWARD_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (canvasIsReady) { + this.onForward(); + } + }, + BACKWARD_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (canvasIsReady) { + this.onBackward(); + } + }, + SEARCH_FORWARD: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (frameNumber + 1 <= stopFrame && canvasIsReady) { + searchAnnotations(jobInstance, frameNumber + 1, stopFrame); + } + }, + SEARCH_BACKWARD: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (frameNumber - 1 >= startFrame && canvasIsReady) { + searchAnnotations(jobInstance, frameNumber - 1, startFrame); + } + }, + PLAY_PAUSE: (event: KeyboardEvent | undefined) => { + preventDefault(event); + this.onSwitchPlay(); + }, + FOCUS_INPUT_FRAME: (event: KeyboardEvent | undefined) => { + preventDefault(event); + if (this.inputFrameRef.current) { + this.inputFrameRef.current.focus(); + } + }, + }; + return ( - + <> + + + ); } } diff --git a/cvat-ui/src/containers/model-runner-dialog/model-runner-dialog.tsx b/cvat-ui/src/containers/model-runner-dialog/model-runner-dialog.tsx index 5944ead7..218ba638 100644 --- a/cvat-ui/src/containers/model-runner-dialog/model-runner-dialog.tsx +++ b/cvat-ui/src/containers/model-runner-dialog/model-runner-dialog.tsx @@ -12,8 +12,8 @@ import { } from 'reducers/interfaces'; import { getModelsAsync, - inferModelAsync, - closeRunModelDialog, + startInferenceAsync, + modelsActions, } from 'actions/models-actions'; @@ -64,13 +64,13 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { }, cleanOut: boolean, ): void { - dispatch(inferModelAsync(taskInstance, model, mapping, cleanOut)); + dispatch(startInferenceAsync(taskInstance, model, mapping, cleanOut)); }, getModels(): void { dispatch(getModelsAsync()); }, closeDialog(): void { - dispatch(closeRunModelDialog()); + dispatch(modelsActions.closeRunModelDialog()); }, }); } diff --git a/cvat-ui/src/containers/tasks-page/task-item.tsx b/cvat-ui/src/containers/tasks-page/task-item.tsx index 6263a2af..59717a7b 100644 --- a/cvat-ui/src/containers/tasks-page/task-item.tsx +++ b/cvat-ui/src/containers/tasks-page/task-item.tsx @@ -13,9 +13,8 @@ import { import TaskItemComponent from 'components/tasks-page/task-item'; -import { - getTasksAsync, -} from 'actions/tasks-actions'; +import { getTasksAsync } from 'actions/tasks-actions'; +import { cancelInferenceAsync } from 'actions/models-actions'; interface StateToProps { deleted: boolean; @@ -26,7 +25,8 @@ interface StateToProps { } interface DispatchToProps { - getTasks: (query: TasksQuery) => void; + getTasks(query: TasksQuery): void; + cancelAutoAnnotation(): void; } interface OwnProps { @@ -48,23 +48,18 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { }; } -function mapDispatchToProps(dispatch: any): DispatchToProps { +function mapDispatchToProps(dispatch: any, own: OwnProps): DispatchToProps { return { - getTasks: (query: TasksQuery): void => { + getTasks(query: TasksQuery): void { dispatch(getTasksAsync(query)); }, + cancelAutoAnnotation(): void { + dispatch(cancelInferenceAsync(own.taskID)); + }, }; } -type TasksItemContainerProps = StateToProps & DispatchToProps & OwnProps; - -function TaskItemContainer(props: TasksItemContainerProps): JSX.Element { - return ( - - ); -} - export default connect( mapStateToProps, mapDispatchToProps, -)(TaskItemContainer); +)(TaskItemComponent); diff --git a/cvat-ui/src/cvat-canvas.ts b/cvat-ui/src/cvat-canvas.ts index 874637ad..6317c435 100644 --- a/cvat-ui/src/cvat-canvas.ts +++ b/cvat-ui/src/cvat-canvas.ts @@ -4,12 +4,14 @@ import { Canvas, + CanvasMode, CanvasVersion, RectDrawingMethod, } from '../../cvat-canvas/src/typescript/canvas'; export { Canvas, + CanvasMode, CanvasVersion, RectDrawingMethod, }; diff --git a/cvat-ui/src/index.tsx b/cvat-ui/src/index.tsx index 9dd8e219..468b5372 100644 --- a/cvat-ui/src/index.tsx +++ b/cvat-ui/src/index.tsx @@ -5,6 +5,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { connect, Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; import CVATApplication from './components/cvat-app'; @@ -16,6 +17,7 @@ import { getFormatsAsync } from './actions/formats-actions'; import { checkPluginsAsync } from './actions/plugins-actions'; import { getUsersAsync } from './actions/users-actions'; import { getAboutAsync } from './actions/about-actions'; +import { shortcutsActions } from './actions/shortcuts-actions'; import { resetErrors, resetMessages, @@ -54,6 +56,7 @@ interface DispatchToProps { initPlugins: () => void; resetErrors: () => void; resetMessages: () => void; + switchShortcutsDialog: () => void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -90,24 +93,21 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { loadAbout: (): void => dispatch(getAboutAsync()), resetErrors: (): void => dispatch(resetErrors()), resetMessages: (): void => dispatch(resetMessages()), + switchShortcutsDialog: (): void => dispatch(shortcutsActions.switchShortcutsDialog()), }; } -function reduxAppWrapper(props: StateToProps & DispatchToProps): JSX.Element { - return ( - - ); -} - const ReduxAppWrapper = connect( mapStateToProps, mapDispatchToProps, -)(reduxAppWrapper); +)(CVATApplication); ReactDOM.render( ( - + + + ), document.getElementById('root'), diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index 3a8776ae..fb4669c8 100644 --- a/cvat-ui/src/reducers/annotation-reducer.ts +++ b/cvat-ui/src/reducers/annotation-reducer.ts @@ -4,7 +4,7 @@ import { AnyAction } from 'redux'; -import { Canvas } from 'cvat-canvas'; +import { Canvas, CanvasMode } from 'cvat-canvas'; import { AnnotationActionTypes } from 'actions/annotation-actions'; import { AuthActionTypes } from 'actions/auth-actions'; import { @@ -61,6 +61,10 @@ const defaultState: AnnotationState = { collapsed: {}, states: [], filters: [], + filtersHistory: JSON.parse( + window.localStorage.getItem('filtersHistory') || '[]', + ), + resetGroupFlag: false, history: { undo: [], redo: [], @@ -442,6 +446,21 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.REPEAT_DRAW_SHAPE: { + const { activeControl } = action.payload; + + return { + ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, + canvas: { + ...state.canvas, + activeControl, + }, + }; + } case AnnotationActionTypes.MERGE_OBJECTS: { const { enabled } = action.payload; const activeControl = enabled @@ -577,6 +596,24 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.RESET_ANNOTATIONS_GROUP: { + return { + ...state, + annotations: { + ...state.annotations, + resetGroupFlag: true, + }, + }; + } + case AnnotationActionTypes.GROUP_ANNOTATIONS: { + return { + ...state, + annotations: { + ...state.annotations, + resetGroupFlag: false, + }, + }; + } case AnnotationActionTypes.GROUP_ANNOTATIONS_SUCCESS: { const { states, @@ -633,9 +670,17 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }; } case AnnotationActionTypes.ACTIVATE_OBJECT: { + const { activatedStateID } = action.payload; const { - activatedStateID, - } = action.payload; + canvas: { + activeControl, + instance, + }, + } = state; + + if (activeControl !== ActiveControl.CURSOR || instance.mode() !== CanvasMode.IDLE) { + return state; + } return { ...state, @@ -677,10 +722,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } - case AnnotationActionTypes.COPY_SHAPE: { - const { - activeControl, - } = action.payload; + case AnnotationActionTypes.PASTE_SHAPE: { + const { activeControl } = action.payload; return { ...state, @@ -694,6 +737,19 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.COPY_SHAPE: { + const { + objectState, + } = action.payload; + + return { + ...state, + drawing: { + ...state.drawing, + activeInitialState: objectState, + }, + }; + } case AnnotationActionTypes.EDIT_SHAPE: { const { enabled } = action.payload; const activeControl = enabled @@ -955,11 +1011,13 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }; } case AnnotationActionTypes.CHANGE_ANNOTATIONS_FILTERS: { - const { filters } = action.payload; + const { filters, filtersHistory } = action.payload; + return { ...state, annotations: { ...state.annotations, + filtersHistory, filters, }, }; diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 644d85d5..74e36d86 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -134,10 +134,17 @@ export enum RQStatus { failed = 'failed', } +export enum ModelType { + OPENVINO = 'openvino', + RCNN = 'rcnn', + MASK_RCNN = 'mask_rcnn', +} + export interface ActiveInference { status: RQStatus; progress: number; error: string; + modelType: ModelType; } export interface ModelsState { @@ -199,6 +206,7 @@ export interface NotificationsState { starting: null | ErrorState; deleting: null | ErrorState; fetching: null | ErrorState; + canceling: null | ErrorState; metaFetching: null | ErrorState; inferenceStatusFetching: null | ErrorState; }; @@ -221,6 +229,7 @@ export interface NotificationsState { fetchingAnnotations: null | ErrorState; undo: null | ErrorState; redo: null | ErrorState; + search: null | ErrorState; }; [index: string]: any; @@ -321,6 +330,7 @@ export interface AnnotationState { activeNumOfPoints?: number; activeLabelID: number; activeObjectType: ObjectType; + activeInitialState?: any; }; annotations: { selectedStatesID: number[]; @@ -328,6 +338,8 @@ export interface AnnotationState { collapsed: Record; states: any[]; filters: string[]; + filtersHistory: string[]; + resetGroupFlag: boolean; history: { undo: string[]; redo: string[]; @@ -414,6 +426,10 @@ export interface SettingsState { player: PlayerSettingsState; } +export interface ShortcutsState { + visibleShortcutsHelp: boolean; +} + export interface CombinedState { auth: AuthState; tasks: TasksState; @@ -426,4 +442,5 @@ export interface CombinedState { notifications: NotificationsState; annotation: AnnotationState; settings: SettingsState; + shortcuts: ShortcutsState; } diff --git a/cvat-ui/src/reducers/models-reducer.ts b/cvat-ui/src/reducers/models-reducer.ts index c8968f2b..b823e7b8 100644 --- a/cvat-ui/src/reducers/models-reducer.ts +++ b/cvat-ui/src/reducers/models-reducer.ts @@ -2,10 +2,8 @@ // // SPDX-License-Identifier: MIT -import { AnyAction } from 'redux'; - -import { ModelsActionTypes } from 'actions/models-actions'; -import { AuthActionTypes } from 'actions/auth-actions'; +import { ModelsActionTypes, ModelsActions } from 'actions/models-actions'; +import { AuthActionTypes, AuthActions } from 'actions/auth-actions'; import { ModelsState } from './interfaces'; const defaultState: ModelsState = { @@ -18,7 +16,7 @@ const defaultState: ModelsState = { inferences: {}, }; -export default function (state = defaultState, action: AnyAction): ModelsState { +export default function (state = defaultState, action: ModelsActions | AuthActions): ModelsState { switch (action.type) { case ModelsActionTypes.GET_MODELS: { return { @@ -90,7 +88,7 @@ export default function (state = defaultState, action: AnyAction): ModelsState { }; } case ModelsActionTypes.GET_INFERENCE_STATUS_SUCCESS: { - const inferences = { ...state.inferences }; + const { inferences } = state; if (action.payload.activeInference.status === 'finished') { delete inferences[action.payload.taskID]; } else { @@ -99,16 +97,25 @@ export default function (state = defaultState, action: AnyAction): ModelsState { return { ...state, - inferences, + inferences: { ...inferences }, }; } case ModelsActionTypes.GET_INFERENCE_STATUS_FAILED: { - const inferences = { ...state.inferences }; + const { inferences } = state; + delete inferences[action.payload.taskID]; + + return { + ...state, + inferences: { ...inferences }, + }; + } + case ModelsActionTypes.CANCEL_INFERENCE_SUCCESS: { + const { inferences } = state; delete inferences[action.payload.taskID]; return { ...state, - inferences, + inferences: { ...inferences }, }; } case AuthActionTypes.LOGOUT_SUCCESS: { diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index a5fe0e63..9ca0240d 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -50,6 +50,7 @@ const defaultState: NotificationsState = { starting: null, deleting: null, fetching: null, + canceling: null, metaFetching: null, inferenceStatusFetching: null, }, @@ -72,6 +73,7 @@ const defaultState: NotificationsState = { fetchingAnnotations: null, undo: null, redo: null, + search: null, }, }, messages: { @@ -432,7 +434,7 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } - case ModelsActionTypes.INFER_MODEL_FAILED: { + case ModelsActionTypes.START_INFERENCE_FAILED: { const { taskID } = action.payload; return { ...state, @@ -449,6 +451,23 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case ModelsActionTypes.CANCEL_INFERENCE_FAILED: { + const { taskID } = action.payload; + return { + ...state, + errors: { + ...state.errors, + models: { + ...state.errors.models, + canceling: { + message: 'Could not cancel model inference for the ' + + `task ${taskID}`, + reason: action.payload.error.toString(), + }, + }, + }, + }; + } case AnnotationActionTypes.GET_JOB_FAILED: { return { ...state, @@ -732,6 +751,21 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case AnnotationActionTypes.SEARCH_ANNOTATIONS_FAILED: { + return { + ...state, + errors: { + ...state.errors, + annotation: { + ...state.errors.annotation, + search: { + message: 'Could not execute search annotations', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } case NotificationsActionType.RESET_ERRORS: { return { ...state, diff --git a/cvat-ui/src/reducers/root-reducer.ts b/cvat-ui/src/reducers/root-reducer.ts index 6e65dcf4..337bfbc5 100644 --- a/cvat-ui/src/reducers/root-reducer.ts +++ b/cvat-ui/src/reducers/root-reducer.ts @@ -14,6 +14,7 @@ import modelsReducer from './models-reducer'; import notificationsReducer from './notifications-reducer'; import annotationReducer from './annotation-reducer'; import settingsReducer from './settings-reducer'; +import shortcutsReducer from './shortcuts-reducer'; export default function createRootReducer(): Reducer { return combineReducers({ @@ -28,5 +29,6 @@ export default function createRootReducer(): Reducer { notifications: notificationsReducer, annotation: annotationReducer, settings: settingsReducer, + shortcuts: shortcutsReducer, }); } diff --git a/cvat-ui/src/reducers/shortcuts-reducer.ts b/cvat-ui/src/reducers/shortcuts-reducer.ts new file mode 100644 index 00000000..92397b64 --- /dev/null +++ b/cvat-ui/src/reducers/shortcuts-reducer.ts @@ -0,0 +1,20 @@ +import { ShortcutsActions, ShortcutsActionsTypes } from 'actions/shortcuts-actions'; +import { ShortcutsState } from './interfaces'; + +const defaultState: ShortcutsState = { + visibleShortcutsHelp: false, +}; + +export default (state = defaultState, action: ShortcutsActions): ShortcutsState => { + switch (action.type) { + case ShortcutsActionsTypes.SWITCH_SHORTCUT_DIALOG: { + return { + ...state, + visibleShortcutsHelp: !state.visibleShortcutsHelp, + }; + } + default: { + return state; + } + } +}; diff --git a/cvat/apps/annotation/annotation.py b/cvat/apps/annotation/annotation.py index a592dd91..9c805154 100644 --- a/cvat/apps/annotation/annotation.py +++ b/cvat/apps/annotation/annotation.py @@ -120,6 +120,7 @@ class Annotation: self._MAX_ANNO_SIZE=30000 self._frame_info = {} self._frame_mapping = {} + self._frame_step = db_task.get_frame_step() db_labels = self._db_task.label_set.all().prefetch_related('attributespec_set').order_by('pk') @@ -270,7 +271,7 @@ class Annotation: def _export_tracked_shape(self, shape): return Annotation.TrackedShape( type=shape["type"], - frame=self._db_task.start_frame + shape["frame"] * self._db_task.get_frame_step(), + frame=self._db_task.start_frame + shape["frame"] * self._frame_step, points=shape["points"], occluded=shape["occluded"], outside=shape.get("outside", False), @@ -283,7 +284,7 @@ class Annotation: return Annotation.LabeledShape( type=shape["type"], label=self._get_label_name(shape["label_id"]), - frame=self._db_task.start_frame + shape["frame"] * self._db_task.get_frame_step(), + frame=self._db_task.start_frame + shape["frame"] * self._frame_step, points=shape["points"], occluded=shape["occluded"], z_order=shape.get("z_order", 0), @@ -293,7 +294,7 @@ class Annotation: def _export_tag(self, tag): return Annotation.Tag( - frame=self._db_task.start_frame + tag["frame"] * self._db_task.get_frame_step(), + frame=self._db_task.start_frame + tag["frame"] * self._frame_step, label=self._get_label_name(tag["label_id"]), group=tag.get("group", 0), attributes=self._export_attributes(tag["attributes"]), @@ -302,7 +303,7 @@ class Annotation: def group_by_frame(self): def _get_frame(annotations, shape): db_image = self._frame_info[shape["frame"]] - frame = self._db_task.start_frame + shape["frame"] * self._db_task.get_frame_step() + frame = self._db_task.start_frame + shape["frame"] * self._frame_step rpath = db_image['path'].split(os.path.sep) if len(rpath) != 1: rpath = os.path.sep.join(rpath[rpath.index(".upload")+1:]) @@ -359,6 +360,7 @@ class Annotation: def _import_tag(self, tag): _tag = tag._asdict() label_id = self._get_label_id(_tag.pop('label')) + _tag['frame'] = (int(_tag['frame']) - self._db_task.start_frame) // self._frame_step _tag['label_id'] = label_id _tag['attributes'] = [self._import_attribute(label_id, attrib) for attrib in _tag['attributes'] if self._get_attribute_id(label_id, attrib.name)] @@ -373,6 +375,7 @@ class Annotation: def _import_shape(self, shape): _shape = shape._asdict() label_id = self._get_label_id(_shape.pop('label')) + _shape['frame'] = (int(_shape['frame']) - self._db_task.start_frame) // self._frame_step _shape['label_id'] = label_id _shape['attributes'] = [self._import_attribute(label_id, attrib) for attrib in _shape['attributes'] if self._get_attribute_id(label_id, attrib.name)] @@ -381,11 +384,13 @@ class Annotation: def _import_track(self, track): _track = track._asdict() label_id = self._get_label_id(_track.pop('label')) - _track['frame'] = min(shape.frame for shape in _track['shapes']) + _track['frame'] = (min(int(shape.frame) for shape in _track['shapes']) - \ + self._db_task.start_frame) // self._frame_step _track['label_id'] = label_id _track['attributes'] = [] _track['shapes'] = [shape._asdict() for shape in _track['shapes']] for shape in _track['shapes']: + shape['frame'] = (int(shape['frame']) - self._db_task.start_frame) // self._frame_step _track['attributes'] = [self._import_attribute(label_id, attrib) for attrib in shape['attributes'] if self._get_immutable_attribute_id(label_id, attrib.name)] shape['attributes'] = [self._import_attribute(label_id, attrib) for attrib in shape['attributes'] @@ -431,6 +436,10 @@ class Annotation: def frame_info(self): return self._frame_info + @property + def frame_step(self): + return self._frame_step + @staticmethod def _get_filename(path): return os.path.splitext(os.path.basename(path))[0] diff --git a/cvat/apps/annotation/cvat.py b/cvat/apps/annotation/cvat.py index ff0b58dc..4e89f2a4 100644 --- a/cvat/apps/annotation/cvat.py +++ b/cvat/apps/annotation/cvat.py @@ -415,7 +415,7 @@ def dump_as_cvat_interpolation(file_object, annotations): outside=True, keyframe=True, z_order=shape.z_order, - frame=shape.frame + 1, + frame=shape.frame + annotations.frame_step, attributes=shape.attributes, ), ], @@ -466,7 +466,7 @@ def load(file_object, annotations): if el.tag == 'attribute' and attributes is not None: attributes.append(annotations.Attribute( name=el.attrib['name'], - value=el.text, + value=el.text or "", )) if el.tag in supported_shapes: if track is not None: diff --git a/cvat/apps/documentation/static/documentation/images/CuboidDrawing1.gif b/cvat/apps/documentation/static/documentation/images/CuboidDrawing1.gif new file mode 100644 index 00000000..42b31b06 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/CuboidDrawing1.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/CuboidDrawing2.gif b/cvat/apps/documentation/static/documentation/images/CuboidDrawing2.gif new file mode 100644 index 00000000..2cb55487 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/CuboidDrawing2.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/CuboidDrawing3.gif b/cvat/apps/documentation/static/documentation/images/CuboidDrawing3.gif new file mode 100644 index 00000000..533a6ae0 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/CuboidDrawing3.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/CuboidEditing1.gif b/cvat/apps/documentation/static/documentation/images/CuboidEditing1.gif new file mode 100644 index 00000000..6d241778 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/CuboidEditing1.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/CuboidEditing2.gif b/cvat/apps/documentation/static/documentation/images/CuboidEditing2.gif new file mode 100644 index 00000000..13adf27a Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/CuboidEditing2.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/EditingPerspective.gif b/cvat/apps/documentation/static/documentation/images/EditingPerspective.gif new file mode 100644 index 00000000..d836019a Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/EditingPerspective.gif differ diff --git a/cvat/apps/documentation/static/documentation/images/ResetPerspective.gif b/cvat/apps/documentation/static/documentation/images/ResetPerspective.gif new file mode 100644 index 00000000..3553bf34 Binary files /dev/null and b/cvat/apps/documentation/static/documentation/images/ResetPerspective.gif differ diff --git a/cvat/apps/documentation/user_guide.md b/cvat/apps/documentation/user_guide.md index d51c596f..f7d0d269 100644 --- a/cvat/apps/documentation/user_guide.md +++ b/cvat/apps/documentation/user_guide.md @@ -28,6 +28,7 @@ - [Annotation with box by 4 points](#annotation-with-box-by-4-points) - [Annotation with polygons](#annotation-with-polygons) - [Annotation with polylines](#annotation-with-polylines) + - [Annotation with cuboids](#annotation-with-cuboids) - [Annotation with points](#annotation-with-points) - [Points in annotation mode](#points-in-annotation-mode) - [Linear interpolation with one point](#linear-interpolation-with-one-point) @@ -1017,6 +1018,62 @@ automatically. You can adjust the polyline after it has been drawn. ![](static/documentation/images/image039.jpg) +## Annotation with cuboids + +It is used to annotate 3 dimensional objects such as cars, boxes, etc... +Currently the feature supports one point perspective and has the contraint +where the vertical edges are exactly parallel to the sides. + +### Creating the cuboid + +Before starting, you have to be sure that ``Cuboid`` is selected. + +Press ``N`` for entering drawing mode. There are many ways to draw a cuboid. +You may draw the cuboid by placing 4 points, after which the drawing completes automatically. +The first 3 points will represent a plane of the cuboid +while the last point represents the depth of that plane. +For the first 3 points, it is recomended to only draw the 2 closest side faces, +as well as the top and bottom face. + +A few examples: +![](static/documentation/images/CuboidDrawing1.gif) + +![](static/documentation/images/CuboidDrawing2.gif) + +![](static/documentation/images/CuboidDrawing3.gif) + +### Editing the cuboid + +The cuboid can be edited in multiple ways, by dragging points or by dragging certain faces. +First notice that there is a face that is painted with pink lines only, let us call it the front face. + +The cuboid can be moved by simply dragging the shape as normal. +The cuboid can be extended by dragging on the point in the middle of the edges. +The cuboid can also be extended up and down by dragging the point at the vertices. + +![](static/documentation/images/CuboidEditing1.gif) + +To draw with perpective effects it is assumed that the front face is the closest to the camera. +To begin simply drag the points on the vertices that are not on the pink/front face while holding ``Shift``. +The cuboid can then be edited as usual. + +![](static/documentation/images/EditingPerspective.gif) + +If you wish to reset perspective effects, you may right click on cuboid, +and select ``Reset Perspective`` to return to a regular cuboid. + +The location of the pink face can be swapped with the adjacent visible side face. +This is done by right clicking on the cuboid and selecting ``Switch Perspective Orientation``. +Note that this will also reset the perspective effects. + +![](static/documentation/images/ResetPerspective.gif) + +Certain faces of the cuboid can also be edited, +these faces are the left, right and dorsal faces, relative to the pink face. +Simply drag the faces to move them independently from the rest of the cuboid. + +![](static/documentation/images/CuboidEditing2.gif) + ## Annotation with points ### Points in annotation mode diff --git a/datumaro/datumaro/components/extractor.py b/datumaro/datumaro/components/extractor.py index 9813f290..bd5b39fa 100644 --- a/datumaro/datumaro/components/extractor.py +++ b/datumaro/datumaro/components/extractor.py @@ -200,14 +200,12 @@ class Mask(Annotation): return self._z_order def as_class_mask(self, label_id=None): - from datumaro.util.mask_tools import make_index_mask if label_id is None: label_id = self.label - return make_index_mask(self.image, label_id) + return self.image * label_id def as_instance_mask(self, instance_id): - from datumaro.util.mask_tools import make_index_mask - return make_index_mask(self.image, instance_id) + return self.image * instance_id def get_area(self): return np.count_nonzero(self.image) diff --git a/datumaro/datumaro/components/project.py b/datumaro/datumaro/components/project.py index 262710dc..ea184083 100644 --- a/datumaro/datumaro/components/project.py +++ b/datumaro/datumaro/components/project.py @@ -634,6 +634,8 @@ class ProjectDataset(Dataset): return self._sources def _save_branch_project(self, extractor, save_dir=None): + extractor = Dataset.from_extractors(extractor) # apply lazy transforms + # NOTE: probably this function should be in the ViewModel layer save_dir = osp.abspath(save_dir) if save_dir: diff --git a/datumaro/datumaro/plugins/coco_format/converter.py b/datumaro/datumaro/plugins/coco_format/converter.py index bdd43e61..39fe7b15 100644 --- a/datumaro/datumaro/plugins/coco_format/converter.py +++ b/datumaro/datumaro/plugins/coco_format/converter.py @@ -278,7 +278,10 @@ class _InstancesConverter(_TaskConverter): is_crowd = mask is not None if is_crowd: - segmentation = mask + segmentation = { + 'counts': list(int(c) for c in mask['counts']), + 'size': list(int(c) for c in mask['size']) + } else: segmentation = [list(map(float, p)) for p in polygons] diff --git a/datumaro/datumaro/plugins/datumaro_format/converter.py b/datumaro/datumaro/plugins/datumaro_format/converter.py index fe239fe6..cc860cba 100644 --- a/datumaro/datumaro/plugins/datumaro_format/converter.py +++ b/datumaro/datumaro/plugins/datumaro_format/converter.py @@ -6,16 +6,18 @@ # pylint: disable=no-self-use import json +import numpy as np import os import os.path as osp from datumaro.components.converter import Converter from datumaro.components.extractor import ( DEFAULT_SUBSET_NAME, Annotation, - Label, Mask, Points, Polygon, PolyLine, Bbox, Caption, + Label, Mask, RleMask, Points, Polygon, PolyLine, Bbox, Caption, LabelCategories, MaskCategories, PointsCategories ) from datumaro.util.image import save_image +import pycocotools.mask as mask_utils from datumaro.components.cli_plugin import CliPlugin from .format import DatumaroPath @@ -40,8 +42,6 @@ class _SubsetWriter: 'items': [], } - self._next_mask_id = 1 - @property def categories(self): return self._data['categories'] @@ -123,33 +123,22 @@ class _SubsetWriter: }) return converted - def _save_mask(self, mask): - mask_id = None - if mask is None: - return mask_id - - mask_id = self._next_mask_id - self._next_mask_id += 1 - - filename = '%d%s' % (mask_id, DatumaroPath.MASK_EXT) - masks_dir = osp.join(self._context._annotations_dir, - DatumaroPath.MASKS_DIR) - os.makedirs(masks_dir, exist_ok=True) - path = osp.join(masks_dir, filename) - save_image(path, mask) - return mask_id - def _convert_mask_object(self, obj): converted = self._convert_annotation(obj) - mask = obj.image - mask_id = None - if mask is not None: - mask_id = self._save_mask(mask) + if isinstance(obj, RleMask): + rle = obj.rle + else: + rle = mask_utils.encode( + np.require(obj.image, dtype=np.uint8, requirements='F')) converted.update({ 'label_id': _cast(obj.label, int), - 'mask_id': _cast(mask_id, int), + 'rle': { + # serialize as compressed COCO mask + 'counts': rle['counts'].decode('ascii'), + 'size': list(int(c) for c in rle['size']), + } }) return converted @@ -289,6 +278,7 @@ class _Converter: class DatumaroConverter(Converter, CliPlugin): @classmethod def build_cmdline_parser(cls, **kwargs): + parser = super().build_cmdline_parser(**kwargs) parser.add_argument('--save-images', action='store_true', help="Save images (default: %(default)s)") return parser diff --git a/datumaro/datumaro/plugins/datumaro_format/extractor.py b/datumaro/datumaro/plugins/datumaro_format/extractor.py index 361df4d8..4be7a778 100644 --- a/datumaro/datumaro/plugins/datumaro_format/extractor.py +++ b/datumaro/datumaro/plugins/datumaro_format/extractor.py @@ -4,16 +4,14 @@ # SPDX-License-Identifier: MIT import json -import logging as log import os.path as osp from datumaro.components.extractor import (SourceExtractor, DEFAULT_SUBSET_NAME, DatasetItem, - AnnotationType, Label, Mask, Points, Polygon, PolyLine, Bbox, Caption, + AnnotationType, Label, RleMask, Points, Polygon, PolyLine, Bbox, Caption, LabelCategories, MaskCategories, PointsCategories ) from datumaro.util.image import Image -from datumaro.util.mask_tools import lazy_mask from .format import DatumaroPath @@ -127,19 +125,9 @@ class DatumaroExtractor(SourceExtractor): elif ann_type == AnnotationType.mask: label_id = ann.get('label_id') - mask_id = str(ann.get('mask_id')) - - mask_path = osp.join(self._path, DatumaroPath.ANNOTATIONS_DIR, - DatumaroPath.MASKS_DIR, mask_id + DatumaroPath.MASK_EXT) - mask = None - - if osp.isfile(mask_path): - mask = lazy_mask(mask_path) - else: - log.warn("Not found mask image file '%s', skipped." % \ - mask_path) - - loaded.append(Mask(label=label_id, image=mask, + rle = ann['rle'] + rle['counts'] = rle['counts'].encode('ascii') + loaded.append(RleMask(rle=rle, label=label_id, id=ann_id, attributes=attributes, group=group)) elif ann_type == AnnotationType.polyline: diff --git a/datumaro/datumaro/plugins/transforms.py b/datumaro/datumaro/plugins/transforms.py index 47cbfcf3..78d9ecf3 100644 --- a/datumaro/datumaro/plugins/transforms.py +++ b/datumaro/datumaro/plugins/transforms.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: MIT +from enum import Enum import logging as log import os.path as osp import random @@ -10,7 +11,9 @@ import random import pycocotools.mask as mask_utils from datumaro.components.extractor import (Transform, AnnotationType, - RleMask, Polygon, Bbox) + RleMask, Polygon, Bbox, + LabelCategories, MaskCategories, PointsCategories +) from datumaro.components.cli_plugin import CliPlugin import datumaro.util.mask_tools as mask_tools from datumaro.util.annotation_tools import find_group_leader, find_instances @@ -46,7 +49,7 @@ class CropCoveredSegments(Transform, CliPlugin): segments.append(s.points) elif s.type == AnnotationType.mask: if isinstance(s, RleMask): - rle = s._rle + rle = s.rle else: rle = mask_tools.mask_to_rle(s.image) segments.append(rle) @@ -365,3 +368,116 @@ class IdFromImageName(Transform, CliPlugin): if item.has_image and item.image.filename: name = osp.splitext(item.image.filename)[0] return self.wrap_item(item, id=name) + +class RemapLabels(Transform, CliPlugin): + DefaultAction = Enum('DefaultAction', ['keep', 'delete']) + + @staticmethod + def _split_arg(s): + parts = s.split(':') + if len(parts) != 2: + import argparse + raise argparse.ArgumentTypeError() + return (parts[0], parts[1]) + + @classmethod + def build_cmdline_parser(cls, **kwargs): + parser = super().build_cmdline_parser(**kwargs) + parser.add_argument('-l', '--label', action='append', + type=cls._split_arg, dest='mapping', + help="Label in the form of: ':' (repeatable)") + parser.add_argument('--default', + choices=[a.name for a in cls.DefaultAction], + default=cls.DefaultAction.keep.name, + help="Action for unspecified labels") + return parser + + def __init__(self, extractor, mapping, default=None): + super().__init__(extractor) + + assert isinstance(default, (str, self.DefaultAction)) + if isinstance(default, str): + default = self.DefaultAction[default] + + assert isinstance(mapping, (dict, list)) + if isinstance(mapping, list): + mapping = dict(mapping) + + self._categories = {} + + src_label_cat = self._extractor.categories().get(AnnotationType.label) + if src_label_cat is not None: + self._make_label_id_map(src_label_cat, mapping, default) + + src_mask_cat = self._extractor.categories().get(AnnotationType.mask) + if src_mask_cat is not None: + assert src_label_cat is not None + dst_mask_cat = MaskCategories(attributes=src_mask_cat.attributes) + dst_mask_cat.colormap = { + id: src_mask_cat.colormap[id] + for id, _ in enumerate(src_label_cat.items) + if self._map_id(id) or id == 0 + } + self._categories[AnnotationType.mask] = dst_mask_cat + + src_points_cat = self._extractor.categories().get(AnnotationType.points) + if src_points_cat is not None: + assert src_label_cat is not None + dst_points_cat = PointsCategories(attributes=src_points_cat.attributes) + dst_points_cat.items = { + id: src_points_cat.items[id] + for id, item in enumerate(src_label_cat.items) + if self._map_id(id) or id == 0 + } + self._categories[AnnotationType.points] = dst_points_cat + + def _make_label_id_map(self, src_label_cat, label_mapping, default_action): + dst_label_cat = LabelCategories(attributes=src_label_cat.attributes) + id_mapping = {} + for src_index, src_label in enumerate(src_label_cat.items): + dst_label = label_mapping.get(src_label.name) + if not dst_label and default_action == self.DefaultAction.keep: + dst_label = src_label.name # keep unspecified as is + if not dst_label: + continue + + dst_index = dst_label_cat.find(dst_label)[0] + if dst_index is None: + dst_label_cat.add(dst_label, + src_label.parent, src_label.attributes) + dst_index = dst_label_cat.find(dst_label)[0] + id_mapping[src_index] = dst_index + + if log.getLogger().isEnabledFor(log.DEBUG): + log.debug("Label mapping:") + for src_id, src_label in enumerate(src_label_cat.items): + if id_mapping.get(src_id): + log.debug("#%s '%s' -> #%s '%s'", + src_id, src_label.name, id_mapping[src_id], + dst_label_cat.items[id_mapping[src_id]].name + ) + else: + log.debug("#%s '%s' -> ", src_id, src_label.name) + + self._map_id = lambda src_id: id_mapping.get(src_id, None) + self._categories[AnnotationType.label] = dst_label_cat + + def categories(self): + return self._categories + + def transform_item(self, item): + # TODO: provide non-inplace version + annotations = [] + for ann in item.annotations: + if ann.type in { AnnotationType.label, AnnotationType.mask, + AnnotationType.points, AnnotationType.polygon, + AnnotationType.polyline, AnnotationType.bbox + } and ann.label is not None: + conv_label = self._map_id(ann.label) + if conv_label is not None: + ann._label = conv_label + annotations.append(ann) + else: + annotations.append(ann) + item._annotations = annotations + return item \ No newline at end of file diff --git a/datumaro/datumaro/plugins/voc_format/converter.py b/datumaro/datumaro/plugins/voc_format/converter.py index 4b82b4f5..5467e52f 100644 --- a/datumaro/datumaro/plugins/voc_format/converter.py +++ b/datumaro/datumaro/plugins/voc_format/converter.py @@ -53,14 +53,13 @@ LabelmapType = Enum('LabelmapType', ['voc', 'source', 'guess']) class _Converter: def __init__(self, extractor, save_dir, tasks=None, apply_colormap=True, save_images=False, label_map=None): - assert tasks is None or isinstance(tasks, (VocTask, list)) + assert tasks is None or isinstance(tasks, (VocTask, list, set)) if tasks is None: - tasks = list(VocTask) + tasks = set(VocTask) elif isinstance(tasks, VocTask): - tasks = [tasks] + tasks = {tasks} else: - tasks = [t if t in VocTask else VocTask[t] for t in tasks] - + tasks = set(t if t in VocTask else VocTask[t] for t in tasks) self._tasks = tasks self._extractor = extractor @@ -259,10 +258,10 @@ class _Converter: if len(actions_elem) != 0: obj_elem.append(actions_elem) - if set(self._tasks) & set([None, + if self._tasks & {None, VocTask.detection, VocTask.person_layout, - VocTask.action_classification]): + VocTask.action_classification}: with open(osp.join(self._ann_dir, item.id + '.xml'), 'w') as f: f.write(ET.tostring(root_elem, encoding='unicode', pretty_print=True)) @@ -302,19 +301,19 @@ class _Converter: action_list[item.id] = None segm_list[item.id] = None - if set(self._tasks) & set([None, + if self._tasks & {None, VocTask.classification, VocTask.detection, VocTask.action_classification, - VocTask.person_layout]): + VocTask.person_layout}: self.save_clsdet_lists(subset_name, clsdet_list) - if set(self._tasks) & set([None, VocTask.classification]): + if self._tasks & {None, VocTask.classification}: self.save_class_lists(subset_name, class_lists) - if set(self._tasks) & set([None, VocTask.action_classification]): + if self._tasks & {None, VocTask.action_classification}: self.save_action_lists(subset_name, action_list) - if set(self._tasks) & set([None, VocTask.person_layout]): + if self._tasks & {None, VocTask.person_layout}: self.save_layout_lists(subset_name, layout_list) - if set(self._tasks) & set([None, VocTask.segmentation]): + if self._tasks & {None, VocTask.segmentation}: self.save_segm_lists(subset_name, segm_list) def save_action_lists(self, subset_name, action_list): diff --git a/datumaro/datumaro/util/mask_tools.py b/datumaro/datumaro/util/mask_tools.py index 84739240..dea22c8e 100644 --- a/datumaro/datumaro/util/mask_tools.py +++ b/datumaro/datumaro/util/mask_tools.py @@ -111,15 +111,20 @@ def load_mask(path, inverse_colormap=None): def lazy_mask(path, inverse_colormap=None): return lazy_image(path, lambda path: load_mask(path, inverse_colormap)) - def mask_to_rle(binary_mask): - counts = [] - for i, (value, elements) in enumerate( - groupby(binary_mask.ravel(order='F'))): - # decoding starts from 0 - if i == 0 and value == 1: - counts.append(0) - counts.append(len(list(elements))) + # walk in row-major order as COCO format specifies + bounded = binary_mask.ravel(order='F') + + # add borders to sequence + # find boundary positions for sequences and compute their lengths + difs = np.diff(bounded, prepend=[1 - bounded[0]], append=[1 - bounded[-1]]) + counts, = np.where(difs != 0) + + # start RLE encoding from 0 as COCO format specifies + if bounded[0] != 0: + counts = np.diff(counts, prepend=[0]) + else: + counts = np.diff(counts) return { 'counts': counts, @@ -267,7 +272,7 @@ def find_mask_bbox(mask): def merge_masks(masks): """ - Merges masks into one, mask order is resposible for z order. + Merges masks into one, mask order is responsible for z order. """ if not masks: return None diff --git a/datumaro/tests/test_masks.py b/datumaro/tests/test_masks.py index 274181ea..a4f54018 100644 --- a/datumaro/tests/test_masks.py +++ b/datumaro/tests/test_masks.py @@ -68,15 +68,7 @@ class PolygonConversionsTest(TestCase): self.assertTrue(np.array_equal(e_mask, c_mask), '#%s: %s\n%s\n' % (i, e_mask, c_mask)) - def test_mask_to_rle(self): - source_mask = np.array([ - [0, 1, 1, 1, 0, 1, 1, 1, 1, 0], - [0, 0, 1, 1, 0, 1, 0, 1, 0, 0], - [0, 0, 0, 1, 0, 1, 1, 0, 0, 0], - [0, 0, 0, 0, 0, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - ]) - + def _test_mask_to_rle(self, source_mask): rle_uncompressed = mask_tools.mask_to_rle(source_mask) from pycocotools import mask as mask_utils @@ -87,6 +79,43 @@ class PolygonConversionsTest(TestCase): self.assertTrue(np.array_equal(source_mask, resulting_mask), '%s\n%s\n' % (source_mask, resulting_mask)) + def test_mask_to_rle_multi(self): + cases = [ + np.array([ + [0, 1, 1, 1, 0, 1, 1, 1, 1, 0], + [0, 0, 1, 1, 0, 1, 0, 1, 0, 0], + [0, 0, 0, 1, 0, 1, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ]), + + np.array([ + [0] + ]), + np.array([ + [1] + ]), + + np.array([ + [1, 0, 0, 0, 0, 0, 0, 1, 0, 0], + [0, 0, 1, 1, 1, 0, 0, 0, 0, 0], + [1, 0, 1, 0, 1, 1, 1, 0, 0, 0], + [1, 1, 0, 1, 0, 1, 1, 1, 1, 0], + [1, 0, 1, 0, 1, 0, 0, 0, 0, 0], + [1, 0, 0, 1, 0, 0, 0, 1, 0, 1], + [1, 1, 0, 0, 1, 1, 0, 0, 0, 1], + [0, 0, 1, 0, 0, 0, 1, 1, 1, 1], + [1, 1, 0, 0, 0, 0, 0, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 1, 0, 1, 0], + [0, 1, 0, 1, 1, 1, 1, 1, 0, 0], + [0, 1, 0, 0, 0, 1, 0, 0, 1, 0], + [1, 1, 0, 1, 0, 0, 1, 1, 1, 1], + ]) + ] + + for case in cases: + self._test_mask_to_rle(case) + class ColormapOperationsTest(TestCase): def test_can_paint_mask(self): mask = np.zeros((1, 3), dtype=np.uint8) diff --git a/datumaro/tests/test_transforms.py b/datumaro/tests/test_transforms.py index b90581e8..58c677a2 100644 --- a/datumaro/tests/test_transforms.py +++ b/datumaro/tests/test_transforms.py @@ -3,10 +3,12 @@ import numpy as np from unittest import TestCase from datumaro.components.extractor import (Extractor, DatasetItem, - Mask, Polygon, PolyLine, Points, Bbox + Mask, Polygon, PolyLine, Points, Bbox, Label, + LabelCategories, MaskCategories, AnnotationType ) -from datumaro.util.test_utils import compare_datasets +import datumaro.util.mask_tools as mask_tools import datumaro.plugins.transforms as transforms +from datumaro.util.test_utils import compare_datasets class TransformsTest(TestCase): @@ -361,3 +363,95 @@ class TransformsTest(TestCase): ('train', -0.5), ('test', 1.5), ]) + + def test_remap_labels(self): + class SrcExtractor(Extractor): + def __iter__(self): + return iter([ + DatasetItem(id=1, annotations=[ + # Should be remapped + Label(1), + Bbox(1, 2, 3, 4, label=2), + Mask(image=np.array([1]), label=3), + + # Should be kept + Polygon([1, 1, 2, 2, 3, 4], label=4), + PolyLine([1, 3, 4, 2, 5, 6], label=None) + ]), + ]) + + def categories(self): + label_cat = LabelCategories() + label_cat.add('label0') + label_cat.add('label1') + label_cat.add('label2') + label_cat.add('label3') + label_cat.add('label4') + + mask_cat = MaskCategories( + colormap=mask_tools.generate_colormap(5)) + + return { + AnnotationType.label: label_cat, + AnnotationType.mask: mask_cat, + } + + class DstExtractor(Extractor): + def __iter__(self): + return iter([ + DatasetItem(id=1, annotations=[ + Label(1), + Bbox(1, 2, 3, 4, label=0), + Mask(image=np.array([1]), label=1), + + Polygon([1, 1, 2, 2, 3, 4], label=2), + PolyLine([1, 3, 4, 2, 5, 6], label=None) + ]), + ]) + + def categories(self): + label_cat = LabelCategories() + label_cat.add('label0') + label_cat.add('label9') + label_cat.add('label4') + + mask_cat = MaskCategories(colormap={ + k: v for k, v in mask_tools.generate_colormap(5).items() + if k in { 0, 1, 3, 4 } + }) + + return { + AnnotationType.label: label_cat, + AnnotationType.mask: mask_cat, + } + + actual = transforms.RemapLabels(SrcExtractor(), mapping={ + 'label1': 'label9', + 'label2': 'label0', + 'label3': 'label9', + }, default='keep') + + compare_datasets(self, DstExtractor(), actual) + + def test_remap_labels_delete_unspecified(self): + class SrcExtractor(Extractor): + def __iter__(self): + return iter([ DatasetItem(id=1, annotations=[ Label(0) ]) ]) + + def categories(self): + label_cat = LabelCategories() + label_cat.add('label0') + + return { AnnotationType.label: label_cat } + + class DstExtractor(Extractor): + def __iter__(self): + return iter([ DatasetItem(id=1, annotations=[]) ]) + + def categories(self): + return { AnnotationType.label: LabelCategories() } + + actual = transforms.RemapLabels(SrcExtractor(), + mapping={}, default='delete') + + compare_datasets(self, DstExtractor(), actual)