From 7f5bc4b7c9205b36af049b79e7cda35df3936ab6 Mon Sep 17 00:00:00 2001 From: Kirill Lakhov Date: Thu, 31 Oct 2024 13:03:16 +0300 Subject: [PATCH 01/30] Hide mask during editing (#8554) ### Motivation and context Added ability to hide a mask during editing ![hide-mask](https://github.com/user-attachments/assets/d8261e8e-4e83-47c3-9620-937e409ce2ba) ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [x] I have created a changelog fragment - ~~[ ] I have updated the documentation accordingly~~ - ~~[ ] I have added tests to cover my changes~~ - ~~[ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword))~~ - [x] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit ## Release Notes - **New Features** - Added the ability to hide edited objects in the canvas configuration. - Enhanced drawing functionality with visibility controls for shapes. - Introduced new methods for managing the state of edited annotations. - **Bug Fixes** - Improved handling of drawing actions based on visibility states. - **Documentation** - Updated interfaces and action types to reflect new features and state management capabilities. - **Chores** - Refined component logic to support new visibility state management. --------- Co-authored-by: Boris Sekachev --- .../20241018_142148_klakhov_hide_mask.md | 3 ++ cvat-canvas/package.json | 2 +- cvat-canvas/src/typescript/canvasModel.ts | 6 +++ cvat-canvas/src/typescript/drawHandler.ts | 40 +++++++++++++-- cvat-canvas/src/typescript/editHandler.ts | 2 +- cvat-canvas/src/typescript/masksHandler.ts | 43 +++++++++++++--- cvat-canvas/src/typescript/svg.patch.ts | 2 + cvat-ui/package.json | 2 +- cvat-ui/src/actions/annotation-actions.ts | 51 ++++++++++++++++++- .../canvas/views/canvas2d/brush-tools.tsx | 35 +++++++++---- .../canvas/views/canvas2d/canvas-wrapper.tsx | 27 ++++++++-- .../single-shape-sidebar.tsx | 31 ++++++++++- .../objects-side-bar/object-buttons.tsx | 29 ++++++++--- .../objects-side-bar/objects-list.tsx | 29 ++++++++++- cvat-ui/src/reducers/annotation-reducer.ts | 31 ++++++++++- cvat-ui/src/reducers/index.ts | 8 ++- tests/cypress/support/commands.js | 1 + 17 files changed, 301 insertions(+), 41 deletions(-) create mode 100644 changelog.d/20241018_142148_klakhov_hide_mask.md diff --git a/changelog.d/20241018_142148_klakhov_hide_mask.md b/changelog.d/20241018_142148_klakhov_hide_mask.md new file mode 100644 index 000000000000..4c79bfdacfa6 --- /dev/null +++ b/changelog.d/20241018_142148_klakhov_hide_mask.md @@ -0,0 +1,3 @@ +### Added + +- Feature to hide a mask during editing () diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index 2b24ff47e347..c89e7506854c 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.20.9", + "version": "2.20.10", "type": "module", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index 2c7a1f08d203..0ad62484c14c 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -96,6 +96,7 @@ export interface Configuration { controlPointsSize?: number; outlinedBorders?: string | false; resetZoom?: boolean; + hideEditedObject?: boolean; } export interface BrushTool { @@ -416,6 +417,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { textPosition: consts.DEFAULT_SHAPE_TEXT_POSITION, textContent: consts.DEFAULT_SHAPE_TEXT_CONTENT, undefinedAttrValue: consts.DEFAULT_UNDEFINED_ATTR_VALUE, + hideEditedObject: false, }, imageBitmap: false, image: null, @@ -981,6 +983,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.data.configuration.CSSImageFilter = configuration.CSSImageFilter; } + if (typeof configuration.hideEditedObject === 'boolean') { + this.data.configuration.hideEditedObject = configuration.hideEditedObject; + } + this.notify(UpdateReasons.CONFIG_UPDATED); } diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index b7e9cbb90130..77b674dec05e 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -5,7 +5,7 @@ import * as SVG from 'svg.js'; import 'svg.draw.js'; -import './svg.patch'; +import { CIRCLE_STROKE } from './svg.patch'; import { AutoborderHandler } from './autoborderHandler'; import { @@ -104,6 +104,7 @@ export class DrawHandlerImpl implements DrawHandler { private controlPointsSize: number; private selectedShapeOpacity: number; private outlinedBorders: string; + private isHidden: boolean; // we should use any instead of SVG.Shape because svg plugins cannot change declared interface // so, methods like draw() just undefined for SVG.Shape, but nevertheless they exist @@ -1276,6 +1277,7 @@ export class DrawHandlerImpl implements DrawHandler { this.selectedShapeOpacity = configuration.selectedShapeOpacity; this.outlinedBorders = configuration.outlinedBorders || 'black'; this.autobordersEnabled = false; + this.isHidden = false; this.startTimestamp = Date.now(); this.onDrawDoneDefault = onDrawDone; this.canvas = canvas; @@ -1301,10 +1303,28 @@ export class DrawHandlerImpl implements DrawHandler { }); } + private strokePoint(point: SVG.Element): void { + point.attr('stroke', this.isHidden ? 'none' : CIRCLE_STROKE); + point.fill({ opacity: this.isHidden ? 0 : 1 }); + } + + private updateHidden(value: boolean) { + this.isHidden = value; + + if (value) { + this.canvas.attr('pointer-events', 'none'); + } else { + this.canvas.attr('pointer-events', 'all'); + } + } + public configurate(configuration: Configuration): void { this.controlPointsSize = configuration.controlPointsSize; this.selectedShapeOpacity = configuration.selectedShapeOpacity; this.outlinedBorders = configuration.outlinedBorders || 'black'; + if (this.isHidden !== configuration.hideEditedObject) { + this.updateHidden(configuration.hideEditedObject); + } const isFillableRect = this.drawData && this.drawData.shapeType === 'rectangle' && @@ -1315,15 +1335,26 @@ export class DrawHandlerImpl implements DrawHandler { const isFilalblePolygon = this.drawData && this.drawData.shapeType === 'polygon'; if (this.drawInstance && (isFillableRect || isFillableCuboid || isFilalblePolygon)) { - this.drawInstance.fill({ opacity: configuration.selectedShapeOpacity }); + this.drawInstance.fill({ + opacity: configuration.hideEditedObject ? 0 : configuration.selectedShapeOpacity, + }); + } + + if (this.drawInstance && (isFilalblePolygon)) { + const paintHandler = this.drawInstance.remember('_paintHandler'); + if (paintHandler) { + for (const point of (paintHandler as any).set.members) { + this.strokePoint(point); + } + } } if (this.drawInstance && this.drawInstance.attr('stroke')) { - this.drawInstance.attr('stroke', this.outlinedBorders); + this.drawInstance.attr('stroke', configuration.hideEditedObject ? 'none' : this.outlinedBorders); } if (this.pointsGroup && this.pointsGroup.attr('stroke')) { - this.pointsGroup.attr('stroke', this.outlinedBorders); + this.pointsGroup.attr('stroke', configuration.hideEditedObject ? 'none' : this.outlinedBorders); } this.autobordersEnabled = configuration.autoborders; @@ -1369,6 +1400,7 @@ export class DrawHandlerImpl implements DrawHandler { const paintHandler = this.drawInstance.remember('_paintHandler'); for (const point of (paintHandler as any).set.members) { + this.strokePoint(point); point.attr('stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`); point.attr('r', `${this.controlPointsSize / geometry.scale}`); } diff --git a/cvat-canvas/src/typescript/editHandler.ts b/cvat-canvas/src/typescript/editHandler.ts index 567eea29c7de..84ecb1684ad4 100644 --- a/cvat-canvas/src/typescript/editHandler.ts +++ b/cvat-canvas/src/typescript/editHandler.ts @@ -472,7 +472,7 @@ export class EditHandlerImpl implements EditHandler { const paintHandler = this.editLine.remember('_paintHandler'); - for (const point of (paintHandler as any).set.members) { + for (const point of paintHandler.set.members) { point.attr('stroke-width', `${consts.POINTS_STROKE_WIDTH / geometry.scale}`); point.attr('r', `${this.controlPointsSize / geometry.scale}`); } diff --git a/cvat-canvas/src/typescript/masksHandler.ts b/cvat-canvas/src/typescript/masksHandler.ts index cdaa4d86d2fa..ca6e5e469a63 100644 --- a/cvat-canvas/src/typescript/masksHandler.ts +++ b/cvat-canvas/src/typescript/masksHandler.ts @@ -6,7 +6,7 @@ import { fabric } from 'fabric'; import debounce from 'lodash/debounce'; import { - DrawData, MasksEditData, Geometry, Configuration, BrushTool, ColorBy, + DrawData, MasksEditData, Geometry, Configuration, BrushTool, ColorBy, Position, } from './canvasModel'; import consts from './consts'; import { DrawHandler } from './drawHandler'; @@ -61,10 +61,11 @@ export class MasksHandlerImpl implements MasksHandler { private editData: MasksEditData | null; private colorBy: ColorBy; - private latestMousePos: { x: number; y: number; }; + private latestMousePos: Position; private startTimestamp: number; private geometry: Geometry; private drawingOpacity: number; + private isHidden: boolean; private keepDrawnPolygon(): void { const canvasWrapper = this.canvas.getElement().parentElement; @@ -217,12 +218,29 @@ export class MasksHandlerImpl implements MasksHandler { private imageDataFromCanvas(wrappingBBox: WrappingBBox): Uint8ClampedArray { const imageData = this.canvas.toCanvasElement() .getContext('2d').getImageData( - wrappingBBox.left, wrappingBBox.top, - wrappingBBox.right - wrappingBBox.left + 1, wrappingBBox.bottom - wrappingBBox.top + 1, + wrappingBBox.left, + wrappingBBox.top, + wrappingBBox.right - wrappingBBox.left + 1, + wrappingBBox.bottom - wrappingBBox.top + 1, ).data; return imageData; } + private updateHidden(value: boolean) { + this.isHidden = value; + + // Need to update style of upper canvas explicitly because update of default cursor is not applied immediately + // https://github.com/fabricjs/fabric.js/issues/1456 + const newOpacity = value ? '0' : ''; + const newCursor = value ? 'inherit' : 'none'; + this.canvas.getElement().parentElement.style.opacity = newOpacity; + const upperCanvas = this.canvas.getElement().parentElement.querySelector('.upper-canvas') as HTMLElement; + if (upperCanvas) { + upperCanvas.style.cursor = newCursor; + } + this.canvas.defaultCursor = newCursor; + } + private updateBrushTools(brushTool?: BrushTool, opts: Partial = {}): void { if (this.isPolygonDrawing) { // tool was switched from polygon to brush for example @@ -350,6 +368,7 @@ export class MasksHandlerImpl implements MasksHandler { this.editData = null; this.drawingOpacity = 0.5; this.brushMarker = null; + this.isHidden = false; this.colorBy = ColorBy.LABEL; this.onDrawDone = onDrawDone; this.onDrawRepeat = onDrawRepeat; @@ -452,7 +471,7 @@ export class MasksHandlerImpl implements MasksHandler { this.canvas.renderAll(); } - if (isMouseDown && !isBrushSizeChanging && ['brush', 'eraser'].includes(tool?.type)) { + if (isMouseDown && !this.isHidden && !isBrushSizeChanging && ['brush', 'eraser'].includes(tool?.type)) { const color = fabric.Color.fromHex(tool.color); color.setAlpha(tool.type === 'eraser' ? 1 : 0.5); @@ -530,6 +549,10 @@ export class MasksHandlerImpl implements MasksHandler { public configurate(configuration: Configuration): void { this.colorBy = configuration.colorBy; + + if (this.isHidden !== configuration.hideEditedObject) { + this.updateHidden(configuration.hideEditedObject); + } } public transform(geometry: Geometry): void { @@ -563,7 +586,10 @@ export class MasksHandlerImpl implements MasksHandler { const color = fabric.Color.fromHex(this.getStateColor(drawData.initialState)).getSource(); const [left, top, right, bottom] = points.slice(-4); const imageBitmap = expandChannels(color[0], color[1], color[2], points); - imageDataToDataURL(imageBitmap, right - left + 1, bottom - top + 1, + imageDataToDataURL( + imageBitmap, + right - left + 1, + bottom - top + 1, (dataURL: string) => new Promise((resolve) => { fabric.Image.fromURL(dataURL, (image: fabric.Image) => { try { @@ -654,7 +680,10 @@ export class MasksHandlerImpl implements MasksHandler { const color = fabric.Color.fromHex(this.getStateColor(editData.state)).getSource(); const [left, top, right, bottom] = points.slice(-4); const imageBitmap = expandChannels(color[0], color[1], color[2], points); - imageDataToDataURL(imageBitmap, right - left + 1, bottom - top + 1, + imageDataToDataURL( + imageBitmap, + right - left + 1, + bottom - top + 1, (dataURL: string) => new Promise((resolve) => { fabric.Image.fromURL(dataURL, (image: fabric.Image) => { try { diff --git a/cvat-canvas/src/typescript/svg.patch.ts b/cvat-canvas/src/typescript/svg.patch.ts index 40af155a956f..7b728b274335 100644 --- a/cvat-canvas/src/typescript/svg.patch.ts +++ b/cvat-canvas/src/typescript/svg.patch.ts @@ -86,6 +86,7 @@ SVG.Element.prototype.draw.extend( }), ); +export const CIRCLE_STROKE = '#000'; // Fix method drawCircles function drawCircles(): void { const array = this.el.array().valueOf(); @@ -109,6 +110,7 @@ function drawCircles(): void { .circle(5) .stroke({ width: 1, + color: CIRCLE_STROKE, }) .fill('#ccc') .center(p.x, p.y), diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 2c43904a3fb9..fe2e65f809b1 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.66.2", + "version": "1.66.3", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 670ace099e5a..68df5e9eb739 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -126,6 +126,8 @@ export enum AnnotationActionTypes { COLLAPSE_APPEARANCE = 'COLLAPSE_APPEARANCE', COLLAPSE_OBJECT_ITEMS = 'COLLAPSE_OBJECT_ITEMS', ACTIVATE_OBJECT = 'ACTIVATE_OBJECT', + UPDATE_EDITED_STATE = 'UPDATE_EDITED_STATE', + HIDE_ACTIVE_OBJECT = 'HIDE_ACTIVE_OBJECT', REMOVE_OBJECT = 'REMOVE_OBJECT', REMOVE_OBJECT_SUCCESS = 'REMOVE_OBJECT_SUCCESS', REMOVE_OBJECT_FAILED = 'REMOVE_OBJECT_FAILED', @@ -1320,7 +1322,7 @@ export function searchAnnotationsAsync( }; } -const ShapeTypeToControl: Record = { +export const ShapeTypeToControl: Record = { [ShapeType.RECTANGLE]: ActiveControl.DRAW_RECTANGLE, [ShapeType.POLYLINE]: ActiveControl.DRAW_POLYLINE, [ShapeType.POLYGON]: ActiveControl.DRAW_POLYGON, @@ -1608,3 +1610,50 @@ export function restoreFrameAsync(frame: number): ThunkAction { } }; } + +export function changeHideActiveObjectAsync(hide: boolean): ThunkAction { + return async (dispatch: ThunkDispatch, getState): Promise => { + const state = getState(); + const { instance: canvas } = state.annotation.canvas; + if (canvas) { + (canvas as Canvas).configure({ + hideEditedObject: hide, + }); + + const { objectState } = state.annotation.editing; + if (objectState) { + objectState.hidden = hide; + await dispatch(updateAnnotationsAsync([objectState])); + } + + dispatch({ + type: AnnotationActionTypes.HIDE_ACTIVE_OBJECT, + payload: { + hide, + }, + }); + } + }; +} + +export function updateEditedStateAsync(objectState: ObjectState | null): ThunkAction { + return async (dispatch: ThunkDispatch, getState): Promise => { + let newActiveObjectHidden = false; + if (objectState) { + newActiveObjectHidden = objectState.hidden; + } + + dispatch({ + type: AnnotationActionTypes.UPDATE_EDITED_STATE, + payload: { + objectState, + }, + }); + + const state = getState(); + const { activeObjectHidden } = state.annotation.canvas; + if (activeObjectHidden !== newActiveObjectHidden) { + dispatch(changeHideActiveObjectAsync(newActiveObjectHidden)); + } + }; +} diff --git a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/brush-tools.tsx b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/brush-tools.tsx index 6c140438c20e..b6a43ce20cf6 100644 --- a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/brush-tools.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/brush-tools.tsx @@ -6,9 +6,9 @@ import './brush-toolbox-styles.scss'; import React, { useCallback, useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; -import { useDispatch, useSelector } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import Button from 'antd/lib/button'; -import Icon, { VerticalAlignBottomOutlined } from '@ant-design/icons'; +import Icon, { EyeInvisibleFilled, EyeOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; import InputNumber from 'antd/lib/input-number'; import Select from 'antd/lib/select'; import notification from 'antd/lib/notification'; @@ -23,7 +23,7 @@ import { import CVATTooltip from 'components/common/cvat-tooltip'; import { CombinedState, ObjectType, ShapeType } from 'reducers'; import LabelSelector from 'components/label-selector/label-selector'; -import { rememberObject, updateCanvasBrushTools } from 'actions/annotation-actions'; +import { changeHideActiveObjectAsync, rememberObject, updateCanvasBrushTools } from 'actions/annotation-actions'; import { ShortcutScope } from 'utils/enums'; import GlobalHotKeys from 'utils/mousetrap-react'; import { subKeyMap } from 'utils/component-subkeymap'; @@ -71,12 +71,17 @@ registerComponentShortcuts(componentShortcuts); const MIN_BRUSH_SIZE = 1; function BrushTools(): React.ReactPortal | null { const dispatch = useDispatch(); - const defaultLabelID = useSelector((state: CombinedState) => state.annotation.drawing.activeLabelID); - const config = useSelector((state: CombinedState) => state.annotation.canvas.brushTools); - const canvasInstance = useSelector((state: CombinedState) => state.annotation.canvas.instance); - const labels = useSelector((state: CombinedState) => state.annotation.job.labels); - const { keyMap, normalizedKeyMap } = useSelector((state: CombinedState) => state.shortcuts); - const { visible } = config; + const { + defaultLabelID, visible, canvasInstance, labels, activeObjectHidden, keyMap, normalizedKeyMap, + } = useSelector((state: CombinedState) => ({ + defaultLabelID: state.annotation.drawing.activeLabelID, + visible: state.annotation.canvas.brushTools.visible, + canvasInstance: state.annotation.canvas.instance, + labels: state.annotation.job.labels, + activeObjectHidden: state.annotation.canvas.activeObjectHidden, + keyMap: state.shortcuts.keyMap, + normalizedKeyMap: state.shortcuts.normalizedKeyMap, + }), shallowEqual); const [editableState, setEditableState] = useState(null); const [currentTool, setCurrentTool] = useState<'brush' | 'eraser' | 'polygon-plus' | 'polygon-minus'>('brush'); @@ -103,6 +108,10 @@ function BrushTools(): React.ReactPortal | null { } }, [setCurrentTool, blockedTools['polygon-minus']]); + const hideMask = useCallback((hide: boolean) => { + dispatch(changeHideActiveObjectAsync(hide)); + }, []); + const handlers: Record void> = { ACTIVATE_BRUSH_TOOL_STANDARD_CONTROLS: setBrushTool, ACTIVATE_ERASER_TOOL_STANDARD_CONTROLS: setEraserTool, @@ -365,6 +374,14 @@ function BrushTools(): React.ReactPortal | null { icon={} onClick={() => setRemoveUnderlyingPixels(!removeUnderlyingPixels)} /> + +