diff --git a/CHANGELOG.md b/CHANGELOG.md index d401b709..8f1b8f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Google Cloud Storage support in UI () - Add project tasks paginations () - Add remove issue button () +- Options to change font size & position of text labels on the canvas () ### Changed - TDB diff --git a/cvat-canvas/package-lock.json b/cvat-canvas/package-lock.json index 56dfe858..4c6262e2 100644 --- a/cvat-canvas/package-lock.json +++ b/cvat-canvas/package-lock.json @@ -1,21 +1,35 @@ { "name": "cvat-canvas", - "version": "2.10.0", + "version": "2.10.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cvat-canvas", - "version": "2.10.0", + "version": "2.10.1", "license": "MIT", "dependencies": { + "@types/polylabel": "^1.0.5", + "polylabel": "^1.1.0", "svg.draggable.js": "2.2.2", "svg.draw.js": "^2.0.4", "svg.js": "2.7.1", "svg.resize.js": "1.4.3", "svg.select.js": "3.0.1" - }, - "devDependencies": {} + } + }, + "node_modules/@types/polylabel": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.0.5.tgz", + "integrity": "sha512-gnaNmo1OJiYNBFAZMZdqLZ3hKx2ee4ksAzqhKWBxuQ61PmhINHMcvIqsGmyCD1WFKCkwRt9NFhMSmKE6AgYY+w==" + }, + "node_modules/polylabel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-1.1.0.tgz", + "integrity": "sha512-bxaGcA40sL3d6M4hH72Z4NdLqxpXRsCFk8AITYg6x1rn1Ei3izf00UMLklerBZTO49aPA3CYrIwVulx2Bce2pA==", + "dependencies": { + "tinyqueue": "^2.0.3" + } }, "node_modules/svg.draggable.js": { "version": "2.2.2", @@ -77,9 +91,27 @@ "engines": { "node": ">= 0.8.0" } + }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==" } }, "dependencies": { + "@types/polylabel": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.0.5.tgz", + "integrity": "sha512-gnaNmo1OJiYNBFAZMZdqLZ3hKx2ee4ksAzqhKWBxuQ61PmhINHMcvIqsGmyCD1WFKCkwRt9NFhMSmKE6AgYY+w==" + }, + "polylabel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-1.1.0.tgz", + "integrity": "sha512-bxaGcA40sL3d6M4hH72Z4NdLqxpXRsCFk8AITYg6x1rn1Ei3izf00UMLklerBZTO49aPA3CYrIwVulx2Bce2pA==", + "requires": { + "tinyqueue": "^2.0.3" + } + }, "svg.draggable.js": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz", @@ -127,6 +159,11 @@ "requires": { "svg.js": "^2.6.5" } + }, + "tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==" } } } diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index 9b913e15..f186da67 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.10.0", + "version": "2.10.1", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", "scripts": { @@ -15,8 +15,9 @@ "not IE 11", "> 2%" ], - "devDependencies": {}, "dependencies": { + "@types/polylabel": "^1.0.5", + "polylabel": "^1.1.0", "svg.draggable.js": "2.2.2", "svg.draw.js": "^2.0.4", "svg.js": "2.7.1", diff --git a/cvat-canvas/src/scss/canvas.scss b/cvat-canvas/src/scss/canvas.scss index 7cf9242e..20fb2c69 100644 --- a/cvat-canvas/src/scss/canvas.scss +++ b/cvat-canvas/src/scss/canvas.scss @@ -37,7 +37,6 @@ polyline.cvat_shape_drawing_opacity { .cvat_canvas_text { font-weight: bold; - font-size: 1.2em; fill: white; cursor: default; font-family: Calibri, Candara, Segoe, 'Segoe UI', Optima, Arial, sans-serif; @@ -47,7 +46,6 @@ polyline.cvat_shape_drawing_opacity { } .cvat_canvas_text_description { - font-size: 14px; fill: yellow; font-style: oblique 40deg; } diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index 0f048a93..daf8f935 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -55,6 +55,8 @@ export interface Configuration { smoothImage?: boolean; autoborders?: boolean; displayAllText?: boolean; + textFontSize?: number; + textPosition?: 'auto' | 'center'; undefinedAttrValue?: string; showProjections?: boolean; forceDisableEditing?: boolean; @@ -647,6 +649,14 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.data.configuration.displayAllText = configuration.displayAllText; } + if (typeof configuration.textFontSize === 'number') { + this.data.configuration.textFontSize = configuration.textFontSize; + } + + if (['auto', 'center'].includes(configuration.textPosition)) { + this.data.configuration.textPosition = configuration.textPosition; + } + if (typeof configuration.showProjections === 'boolean') { this.data.configuration.showProjections = configuration.showProjections; } diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 6f9587c7..cb532a1f 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: MIT +import polylabel from 'polylabel'; import * as SVG from 'svg.js'; import 'svg.draggable.js'; @@ -683,11 +684,13 @@ export class CanvasViewImpl implements CanvasView, Listener { for (const state of deleted) { if (state.clientID in this.svgTexts) { this.svgTexts[state.clientID].remove(); + delete this.svgTexts[state.clientID]; } this.svgShapes[state.clientID].off('click.canvas'); this.svgShapes[state.clientID].remove(); delete this.drawnStates[state.clientID]; + delete this.svgShapes[state.clientID]; } this.addObjects(created); @@ -1175,7 +1178,6 @@ export class CanvasViewImpl implements CanvasView, Listener { for (const i in this.drawnStates) { if (!(i in this.svgTexts)) { this.svgTexts[i] = this.addText(this.drawnStates[i]); - this.updateTextPosition(this.svgTexts[i], this.svgShapes[i]); } } } else if (configuration.displayAllText === false && this.configuration.displayAllText) { @@ -1187,15 +1189,25 @@ export class CanvasViewImpl implements CanvasView, Listener { } } - if ('smoothImage' in configuration) { - if (configuration.smoothImage) { - this.background.classList.remove('cvat_canvas_pixelized'); - } else { - this.background.classList.add('cvat_canvas_pixelized'); - } + const updateTextPosition = configuration.displayAllText !== this.configuration.displayAllText || + configuration.textFontSize !== this.configuration.textFontSize || + configuration.textPosition !== this.configuration.textPosition; + + if (configuration.smoothImage === true) { + this.background.classList.remove('cvat_canvas_pixelized'); + } else if (configuration.smoothImage === false) { + this.background.classList.add('cvat_canvas_pixelized'); } this.configuration = configuration; + if (updateTextPosition) { + for (const i in this.drawnStates) { + if (i in this.svgTexts) { + this.updateTextPosition(this.svgTexts[i], this.svgShapes[i]); + } + } + } + this.activate(activeElement); this.editHandler.configurate(this.configuration); this.drawHandler.configurate(this.configuration); @@ -2059,45 +2071,82 @@ export class CanvasViewImpl implements CanvasView, Listener { // Update text position after corresponding box has been moved, resized, etc. private updateTextPosition(text: SVG.Text, shape: SVG.Shape): void { if (text.node.style.display === 'none') return; // wrong transformation matrix - const { rotation } = shape.transform(); - let box = (shape.node as any).getBBox(); - - // Translate the whole box to the client coordinate system - const [x1, y1, x2, y2]: number[] = translateFromSVG(this.content, [ - box.x, - box.y, - box.x + box.width, - box.y + box.height, - ]); + const textFontSize = this.configuration.textFontSize || consts.DEFAULT_SHAPE_TEXT_SIZE; + const textPosition = this.configuration.textPosition || 'auto'; - box = { - x: Math.min(x1, x2), - y: Math.min(y1, y2), - width: Math.max(x1, x2) - Math.min(x1, x2), - height: Math.max(y1, y2) - Math.min(y1, y2), - }; + text.untransform(); + text.style({ 'font-size': textFontSize }); + const { rotation } = shape.transform(); // Find the best place for a text - let [clientX, clientY]: number[] = [box.x + box.width, box.y]; - if ( - clientX + ((text.node as any) as SVGTextElement) - .getBBox().width + consts.TEXT_MARGIN > this.canvas.offsetWidth - ) { - [clientX, clientY] = [box.x, box.y]; + let [clientX, clientY, clientCX, clientCY]: number[] = [0, 0, 0, 0]; + if (textPosition === 'center') { + let cx = 0; + let cy = 0; + if (shape.type === 'rect') { + // for rectangle finding a center is simple + cx = +shape.attr('x') + +shape.attr('width') / 2; + cy = +shape.attr('y') + +shape.attr('height') / 2; + } else { + // for polyshapes we use special algorithm + const points = parsePoints(pointsToNumberArray(shape.attr('points'))); + [cx, cy] = polylabel([points.map((point) => [point.x, point.y])]); + } + + [clientX, clientY] = translateFromSVG(this.content, [cx, cy]); + // center is exactly clientX, clientY + clientCX = clientX; + clientCY = clientY; + } else { + let box = (shape.node as any).getBBox(); + + // Translate the whole box to the client coordinate system + const [x1, y1, x2, y2]: number[] = translateFromSVG(this.content, [ + box.x, + box.y, + box.x + box.width, + box.y + box.height, + ]); + + clientCX = x1 + (x2 - x1) / 2; + clientCY = y1 + (y2 - y1) / 2; + + box = { + x: Math.min(x1, x2), + y: Math.min(y1, y2), + width: Math.max(x1, x2) - Math.min(x1, x2), + height: Math.max(y1, y2) - Math.min(y1, y2), + }; + + // first try to put to the top right corner + [clientX, clientY] = [box.x + box.width, box.y]; + if ( + clientX + ((text.node as any) as SVGTextElement) + .getBBox().width + consts.TEXT_MARGIN > this.canvas.offsetWidth + ) { + // if out of visible area, try to put text to top left corner + [clientX, clientY] = [box.x, box.y]; + } } - // Translate back to text SVG - const [x, y, cx, cy]: number[] = translateToSVG(this.text, [ + // Translate found coordinates to text SVG + const [x, y, rotX, rotY]: number[] = translateToSVG(this.text, [ clientX + consts.TEXT_MARGIN, clientY + consts.TEXT_MARGIN, - x1 + (x2 - x1) / 2, - y1 + (y2 - y1) / 2, + clientCX, + clientCY, ]); + const textBBox = ((text.node as any) as SVGTextElement).getBBox(); // Finally draw a text - text.move(x, y); + if (textPosition === 'center') { + text.move(x - textBBox.width / 2, y - textBBox.height / 2); + } else { + text.move(x, y); + } + if (rotation) { - text.rotate(rotation, cx, cy); + text.rotate(rotation, rotX, rotY); } for (const tspan of (text.lines() as any).members) { @@ -2107,6 +2156,7 @@ export class CanvasViewImpl implements CanvasView, Listener { private addText(state: any): SVG.Text { const { undefinedAttrValue } = this.configuration; + const textFontSize = this.configuration.textFontSize || 12; const { label, clientID, attributes, source, descriptions, } = state; @@ -2117,7 +2167,9 @@ export class CanvasViewImpl implements CanvasView, Listener { return this.adoptedText .text((block): void => { - block.tspan(`${label.name} ${clientID} (${source})`).style('text-transform', 'uppercase'); + block.tspan(`${label.name} ${clientID} (${source})`).style({ + 'text-transform': 'uppercase', + }); for (const desc of descriptions) { block .tspan(`${desc}`) @@ -2140,6 +2192,7 @@ export class CanvasViewImpl implements CanvasView, Listener { } }) .move(0, 0) + .style({ 'font-size': textFontSize }) .addClass('cvat_canvas_text'); } diff --git a/cvat-canvas/src/typescript/consts.ts b/cvat-canvas/src/typescript/consts.ts index 3da37c21..a2b42f17 100644 --- a/cvat-canvas/src/typescript/consts.ts +++ b/cvat-canvas/src/typescript/consts.ts @@ -19,6 +19,7 @@ const ARROW_PATH = 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 ' + const BASE_PATTERN_SIZE = 5; const SNAP_TO_ANGLE_RESIZE_DEFAULT = 0.1; const SNAP_TO_ANGLE_RESIZE_SHIFT = 15; +const DEFAULT_SHAPE_TEXT_SIZE = 12; export default { BASE_STROKE_WIDTH, @@ -37,4 +38,5 @@ export default { BASE_PATTERN_SIZE, SNAP_TO_ANGLE_RESIZE_DEFAULT, SNAP_TO_ANGLE_RESIZE_SHIFT, + DEFAULT_SHAPE_TEXT_SIZE, }; diff --git a/cvat-ui/src/actions/settings-actions.ts b/cvat-ui/src/actions/settings-actions.ts index b68e29a5..d7d42097 100644 --- a/cvat-ui/src/actions/settings-actions.ts +++ b/cvat-ui/src/actions/settings-actions.ts @@ -23,6 +23,8 @@ export enum SettingsActionTypes { CHANGE_FRAME_SPEED = 'CHANGE_FRAME_SPEED', SWITCH_RESET_ZOOM = 'SWITCH_RESET_ZOOM', SWITCH_SMOOTH_IMAGE = 'SWITCH_SMOOTH_IMAGE', + SWITCH_TEXT_FONT_SIZE = 'SWITCH_TEXT_FONT_SIZE', + SWITCH_TEXT_POSITION = 'SWITCH_TEXT_POSITION', CHANGE_BRIGHTNESS_LEVEL = 'CHANGE_BRIGHTNESS_LEVEL', CHANGE_CONTRAST_LEVEL = 'CHANGE_CONTRAST_LEVEL', CHANGE_SATURATION_LEVEL = 'CHANGE_SATURATION_LEVEL', @@ -176,6 +178,24 @@ export function switchSmoothImage(enabled: boolean): AnyAction { }; } +export function switchTextFontSize(fontSize: number): AnyAction { + return { + type: SettingsActionTypes.SWITCH_TEXT_FONT_SIZE, + payload: { + fontSize, + }, + }; +} + +export function switchTextPosition(position: 'auto' | 'center'): AnyAction { + return { + type: SettingsActionTypes.SWITCH_TEXT_POSITION, + payload: { + position, + }, + }; +} + export function changeBrightnessLevel(level: number): AnyAction { return { type: SettingsActionTypes.CHANGE_BRIGHTNESS_LEVEL, diff --git a/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx index 632ad247..d6e53601 100644 --- a/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/canvas/canvas-wrapper.tsx @@ -60,6 +60,8 @@ interface Props { smoothImage: boolean; aamZoomMargin: number; showObjectsTextAlways: boolean; + textFontSize: number; + textPosition: 'auto' | 'center'; showAllInterpolationTracks: boolean; workspace: Workspace; automaticBordering: boolean; @@ -107,6 +109,8 @@ export default class CanvasWrapperComponent extends React.PureComponent { showProjections, selectedOpacity, smoothImage, + textFontSize, + textPosition, } = this.props; const { canvasInstance } = this.props as { canvasInstance: Canvas }; @@ -124,6 +128,8 @@ export default class CanvasWrapperComponent extends React.PureComponent { intelligentPolygonCrop, showProjections, creationOpacity: selectedOpacity, + textFontSize, + textPosition, }); this.initialSetup(); @@ -158,6 +164,8 @@ export default class CanvasWrapperComponent extends React.PureComponent { workspace, frameFetching, showObjectsTextAlways, + textFontSize, + textPosition, showAllInterpolationTracks, automaticBordering, intelligentPolygonCrop, @@ -172,7 +180,9 @@ export default class CanvasWrapperComponent extends React.PureComponent { prevProps.showProjections !== showProjections || prevProps.intelligentPolygonCrop !== intelligentPolygonCrop || prevProps.selectedOpacity !== selectedOpacity || - prevProps.smoothImage !== smoothImage + prevProps.smoothImage !== smoothImage || + prevProps.textFontSize !== textFontSize || + prevProps.textPosition !== textPosition ) { canvasInstance.configure({ undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE, @@ -182,6 +192,8 @@ export default class CanvasWrapperComponent extends React.PureComponent { intelligentPolygonCrop, creationOpacity: selectedOpacity, smoothImage, + textFontSize, + textPosition, }); } diff --git a/cvat-ui/src/components/header/settings-modal/styles.scss b/cvat-ui/src/components/header/settings-modal/styles.scss index a1a0dbc2..1b898e59 100644 --- a/cvat-ui/src/components/header/settings-modal/styles.scss +++ b/cvat-ui/src/components/header/settings-modal/styles.scss @@ -29,11 +29,12 @@ .cvat-workspace-settings-show-text-always, .cvat-workspace-settings-show-interpolated, .cvat-workspace-settings-approx-poly-threshold, -.cvat-workspace-settings-aam-zoom-margin { - margin-bottom: 25px; +.cvat-workspace-settings-aam-zoom-margin, +.cvat-workspace-settings-text-settings { + margin-bottom: $grid-unit-size * 3; > div:first-child { - margin-bottom: 10px; + margin-bottom: $grid-unit-size; } } @@ -48,7 +49,7 @@ .cvat-player-settings-canvas-background, .cvat-workspace-settings-aam-zoom-margin, .cvat-workspace-settings-auto-save-interval { - margin-bottom: 25px; + margin-bottom: $grid-unit-size * 3; } .cvat-player-settings-step, diff --git a/cvat-ui/src/components/header/settings-modal/workspace-settings.tsx b/cvat-ui/src/components/header/settings-modal/workspace-settings.tsx index 48299819..553bf0c1 100644 --- a/cvat-ui/src/components/header/settings-modal/workspace-settings.tsx +++ b/cvat-ui/src/components/header/settings-modal/workspace-settings.tsx @@ -15,6 +15,7 @@ import { marks, } from 'components/annotation-page/standard-workspace/controls-side-bar/approximation-accuracy'; import { clamp } from 'utils/math'; +import { Select } from 'antd'; interface Props { autoSave: boolean; @@ -25,6 +26,8 @@ interface Props { automaticBordering: boolean; intelligentPolygonCrop: boolean; defaultApproxPolyAccuracy: number; + textFontSize: number; + textPosition: 'center' | 'auto'; onSwitchAutoSave(enabled: boolean): void; onChangeAutoSaveInterval(interval: number): void; onChangeAAMZoomMargin(margin: number): void; @@ -33,6 +36,8 @@ interface Props { onSwitchShowingObjectsTextAlways(enabled: boolean): void; onSwitchAutomaticBordering(enabled: boolean): void; onSwitchIntelligentPolygonCrop(enabled: boolean): void; + onChangeTextFontSize(fontSize: number): void; + onChangeTextPosition(position: 'auto' | 'center'): void; } function WorkspaceSettingsComponent(props: Props): JSX.Element { @@ -45,6 +50,8 @@ function WorkspaceSettingsComponent(props: Props): JSX.Element { automaticBordering, intelligentPolygonCrop, defaultApproxPolyAccuracy, + textFontSize, + textPosition, onSwitchAutoSave, onChangeAutoSaveInterval, onChangeAAMZoomMargin, @@ -53,6 +60,8 @@ function WorkspaceSettingsComponent(props: Props): JSX.Element { onSwitchAutomaticBordering, onSwitchIntelligentPolygonCrop, onChangeDefaultApproxPolyAccuracy, + onChangeTextFontSize, + onChangeTextPosition, } = props; const minAutoSaveInterval = 1; @@ -128,6 +137,23 @@ function WorkspaceSettingsComponent(props: Props): JSX.Element { + + + Position of a text + + + Font size of a text + + + + + + + + { }, }; } + case SettingsActionTypes.SWITCH_TEXT_FONT_SIZE: { + return { + ...state, + workspace: { + ...state.workspace, + textFontSize: action.payload.fontSize, + }, + }; + } + case SettingsActionTypes.SWITCH_TEXT_POSITION: { + return { + ...state, + workspace: { + ...state.workspace, + textPosition: action.payload.position, + }, + }; + } case SettingsActionTypes.CHANGE_BRIGHTNESS_LEVEL: { return { ...state,