diff --git a/.gitignore b/.gitignore index 00e9b3f9ddd8..23872063a325 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,9 @@ __pycache__ # Ignore development npm files node_modules +# Ignore npm logs file +npm-debug.log* +yarn-debug.log* +yarn-error.log* + .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb8a898aed6..ffd51e3a5ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Re-Identification algorithm to merging bounding boxes automatically to the new UI () - Methods ``import`` and ``export`` to import/export raw annotations for Job and Task in ``cvat-core`` () - Versioning of client packages (``cvat-core``, ``cvat-canvas``, ``cvat-ui``). Initial versions are set to 1.0.0 () +- Cuboids feature was migrated from old UI to new one. () ### Changed - diff --git a/cvat-canvas/src/scss/canvas.scss b/cvat-canvas/src/scss/canvas.scss index 537ebe68494d..152c91d13ad7 100644 --- a/cvat-canvas/src/scss/canvas.scss +++ b/cvat-canvas/src/scss/canvas.scss @@ -134,7 +134,8 @@ polyline.cvat_canvas_shape_splitting { cursor: nwse-resize; } -.svg_select_points_l:hover, .svg_select_points_r:hover { +.svg_select_points_l:hover, .svg_select_points_r:hover, +.svg_select_points_ew:hover { cursor: ew-resize; } diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index d07526c91bdf..bd93314bf3aa 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -50,6 +50,7 @@ export interface Configuration { autoborders?: boolean; displayAllText?: boolean; undefinedAttrValue?: string; + showProjections?: boolean; } export interface DrawData { @@ -527,6 +528,9 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.data.configuration.displayAllText = configuration.displayAllText; } + if (typeof (configuration.showProjections) !== 'undefined') { + this.data.configuration.showProjections = configuration.showProjections; + } if (typeof (configuration.autoborders) !== 'undefined') { this.data.configuration.autoborders = configuration.autoborders; } diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index ffffdce42c6f..63acbefa6c85 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -437,6 +437,10 @@ export class CanvasViewImpl implements CanvasView, Listener { .filter((_state: any): boolean => ( _state.clientID === self.activeElement.clientID )); + if (['cuboid', 'rectangle'].includes(state.shapeType)) { + e.preventDefault(); + return; + } if (e.ctrlKey) { const { points } = state; self.onEditDone( @@ -721,7 +725,10 @@ export class CanvasViewImpl implements CanvasView, Listener { public notify(model: CanvasModel & Master, reason: UpdateReasons): void { this.geometry = this.controller.geometry; if (reason === UpdateReasons.CONFIG_UPDATED) { + const { activeElement } = this; + this.deactivate(); this.configuration = model.configuration; + this.activate(activeElement); this.editHandler.configurate(this.configuration); this.drawHandler.configurate(this.configuration); @@ -939,26 +946,63 @@ export class CanvasViewImpl implements CanvasView, Listener { for (const state of states) { if (state.hidden || state.outside) continue; ctx.fillStyle = 'white'; - if (['rectangle', 'polygon'].includes(state.shapeType)) { - const points = state.shapeType === 'rectangle' ? [ - state.points[0], // xtl - state.points[1], // ytl - state.points[2], // xbr - state.points[1], // ytl - state.points[2], // xbr - state.points[3], // ybr - state.points[0], // xtl - state.points[3], // ybr - ] : state.points; + if (['rectangle', 'polygon', 'cuboid'].includes(state.shapeType)) { + let points = []; + if (state.shapeType === 'rectangle') { + points = [ + state.points[0], // xtl + state.points[1], // ytl + state.points[2], // xbr + state.points[1], // ytl + state.points[2], // xbr + state.points[3], // ybr + state.points[0], // xtl + state.points[3], // ybr + ]; + } else if (state.shapeType === 'cuboid') { + points = [ + state.points[0], + state.points[1], + state.points[4], + state.points[5], + state.points[8], + state.points[9], + state.points[12], + state.points[13], + ]; + } else { + points = [...state.points]; + } ctx.beginPath(); ctx.moveTo(points[0], points[1]); for (let i = 0; i < points.length; i += 2) { ctx.lineTo(points[i], points[i + 1]); } ctx.closePath(); + ctx.fill(); } - ctx.fill(); + if (state.shapeType === 'cuboid') { + for (let i = 0; i < 5; i++) { + const points = [ + state.points[(0 + i * 4) % 16], + state.points[(1 + i * 4) % 16], + state.points[(2 + i * 4) % 16], + state.points[(3 + i * 4) % 16], + state.points[(6 + i * 4) % 16], + state.points[(7 + i * 4) % 16], + state.points[(4 + i * 4) % 16], + state.points[(5 + i * 4) % 16], + ]; + ctx.beginPath(); + ctx.moveTo(points[0], points[1]); + for (let j = 0; j < points.length; j += 2) { + ctx.lineTo(points[j], points[j + 1]); + } + ctx.closePath(); + ctx.fill(); + } + } } } } @@ -1055,7 +1099,9 @@ export class CanvasViewImpl implements CanvasView, Listener { return `${acc}${val},`; }, '', ); - (shape as any).clear(); + if (state.shapeType !== 'cuboid') { + (shape as any).clear(); + } shape.attr('points', stringified); if (state.shapeType === 'points' && !isInvisible) { @@ -1116,6 +1162,9 @@ export class CanvasViewImpl implements CanvasView, Listener { } else if (state.shapeType === 'points') { this.svgShapes[state.clientID] = this .addPoints(stringified, state); + } else if (state.shapeType === 'cuboid') { + this.svgShapes[state.clientID] = this + .addCuboid(stringified, state); } } @@ -1202,6 +1251,10 @@ export class CanvasViewImpl implements CanvasView, Listener { this.selectize(false, shape); } + if (drawnState.shapeType === 'cuboid') { + (shape as any).attr('projections', false); + } + (shape as any).off('resizestart'); (shape as any).off('resizing'); (shape as any).off('resizedone'); @@ -1281,6 +1334,11 @@ export class CanvasViewImpl implements CanvasView, Listener { this.content.append(shape.node); } + const { showProjections } = this.configuration; + if (state.shapeType === 'cuboid' && showProjections) { + (shape as any).attr('projections', true); + } + if (!state.pinned) { shape.addClass('cvat_canvas_shape_draggable'); (shape as any).draggable().on('dragstart', (): void => { @@ -1548,6 +1606,30 @@ export class CanvasViewImpl implements CanvasView, Listener { return polyline; } + private addCuboid(points: string, state: any): any { + const cube = (this.adoptedContent as any).cube(points) + .fill(state.color).attr({ + clientID: state.clientID, + 'color-rendering': 'optimizeQuality', + id: `cvat_canvas_shape_${state.clientID}`, + fill: state.color, + 'shape-rendering': 'geometricprecision', + stroke: state.color, + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + 'data-z-order': state.zOrder, + }).addClass('cvat_canvas_shape'); + + if (state.occluded) { + cube.addClass('cvat_canvas_shape_occluded'); + } + + if (state.hidden || state.outside) { + cube.style('display', 'none'); + } + + return cube; + } + private setupPoints(basicPolyline: SVG.PolyLine, state: any): any { this.selectize(true, basicPolyline); diff --git a/cvat-canvas/src/typescript/consts.ts b/cvat-canvas/src/typescript/consts.ts index 23a36f11c7dc..2945b48d5f53 100644 --- a/cvat-canvas/src/typescript/consts.ts +++ b/cvat-canvas/src/typescript/consts.ts @@ -10,6 +10,7 @@ const AREA_THRESHOLD = 9; const SIZE_THRESHOLD = 3; const POINTS_STROKE_WIDTH = 1.5; const POINTS_SELECTED_STROKE_WIDTH = 4; +const MIN_EDGE_LENGTH = 3; const UNDEFINED_ATTRIBUTE_VALUE = '__undefined__'; export default { @@ -21,5 +22,6 @@ export default { SIZE_THRESHOLD, POINTS_STROKE_WIDTH, POINTS_SELECTED_STROKE_WIDTH, + MIN_EDGE_LENGTH, UNDEFINED_ATTRIBUTE_VALUE, }; diff --git a/cvat-canvas/src/typescript/cuboid.ts b/cvat-canvas/src/typescript/cuboid.ts new file mode 100644 index 000000000000..7bf04777841e --- /dev/null +++ b/cvat-canvas/src/typescript/cuboid.ts @@ -0,0 +1,494 @@ +/* eslint-disable func-names */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable curly */ +/* + * Copyright (C) 2020 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +import consts from './consts'; + +export interface Point { + x: number; + y: number; +} + +export enum Orientation { + LEFT = 'left', + RIGHT = 'right', +} + +function line(p1: Point, p2: Point): number[] { + const a = p1.y - p2.y; + const b = p2.x - p1.x; + const c = b * p1.y + a * p1.x; + return [a, b, c]; +} + +function intersection( + p1: Point, p2: Point, p3: Point, p4: Point, +): Point | null { + const L1 = line(p1, p2); + const L2 = line(p3, p4); + + const D = L1[0] * L2[1] - L1[1] * L2[0]; + const Dx = L1[2] * L2[1] - L1[1] * L2[2]; + const Dy = L1[0] * L2[2] - L1[2] * L2[0]; + + let x = null; + let y = null; + if (D !== 0) { + x = Dx / D; + y = Dy / D; + return { x, y }; + } + + return null; +} + +export class Equation { + private a: number; + private b: number; + private c: number; + + public constructor(p1: Point, p2: Point) { + this.a = p1.y - p2.y; + this.b = p2.x - p1.x; + this.c = this.b * p1.y + this.a * p1.x; + } + + // get the line equation in actual coordinates + public getY(x: number): number { + return (this.c - this.a * x) / this.b; + } +} + +export class Figure { + private indices: number[]; + private allPoints: Point[]; + + public constructor(indices: number[], points: Point[]) { + this.indices = indices; + this.allPoints = points; + } + + public get points(): Point[] { + const points = []; + for (const index of this.indices) { + points.push(this.allPoints[index]); + } + return points; + } + + // sets the point for a given edge, points must be given in + // array form in the same ordering as the getter + // if you only need to update a subset of the points, + // simply put null for the points you want to keep + public set points(newPoints) { + const oldPoints = this.allPoints; + for (let i = 0; i < newPoints.length; i += 1) { + if (newPoints[i] !== null) { + oldPoints[this.indices[i]] = { x: newPoints[i].x, y: newPoints[i].y }; + } + } + } +} + +export class Edge extends Figure { + public getEquation(): Equation { + return new Equation(this.points[0], this.points[1]); + } +} + +export class CuboidModel { + public points: Point[]; + private fr: Edge; + private fl: Edge; + private dr: Edge; + private dl: Edge; + private ft: Edge; + private rt: Edge; + private lt: Edge; + private dt: Edge; + private fb: Edge; + private rb: Edge; + private lb: Edge; + private db: Edge; + public edgeList: Edge[]; + private front: Figure; + private right: Figure; + private dorsal: Figure; + private left: Figure; + private top: Figure; + private bot: Figure; + public facesList: Figure[]; + public vpl: Point | null; + public vpr: Point | null; + public orientation: Orientation; + + public constructor(points?: Point[]) { + this.points = points; + this.initEdges(); + this.initFaces(); + this.updateVanishingPoints(false); + this.buildBackEdge(false); + this.updatePoints(); + this.updateOrientation(); + } + + public getPoints(): Point[] { + return this.points; + } + + public setPoints(points: (Point | null)[]): void { + points.forEach((point: Point | null, i: number): void => { + if (point !== null) { + this.points[i].x = point.x; + this.points[i].y = point.y; + } + }); + } + + public updateOrientation(): void { + if (this.dl.points[0].x > this.fl.points[0].x) { + this.orientation = Orientation.LEFT; + } else { + this.orientation = Orientation.RIGHT; + } + } + + public updatePoints(): void { + // making sure that the edges are vertical + this.fr.points[0].x = this.fr.points[1].x; + this.fl.points[0].x = this.fl.points[1].x; + this.dr.points[0].x = this.dr.points[1].x; + this.dl.points[0].x = this.dl.points[1].x; + } + + public computeSideEdgeConstraints(edge: any): any { + const midLength = this.fr.points[1].y - this.fr.points[0].y - 1; + + const minY = edge.points[1].y - midLength; + const maxY = edge.points[0].y + midLength; + + const y1 = edge.points[0].y; + const y2 = edge.points[1].y; + + const miny1 = y2 - midLength; + const maxy1 = y2 - consts.MIN_EDGE_LENGTH; + + const miny2 = y1 + consts.MIN_EDGE_LENGTH; + const maxy2 = y1 + midLength; + + return { + constraint: { + minY, + maxY, + }, + y1Range: { + max: maxy1, + min: miny1, + }, + y2Range: { + max: maxy2, + min: miny2, + }, + }; + } + + // boolean value parameter controls which edges should be used to recalculate vanishing points + private updateVanishingPoints(buildright: boolean): void { + let leftEdge = []; + let rightEdge = []; + let midEdge = []; + if (buildright) { + leftEdge = this.fr.points; + rightEdge = this.dl.points; + midEdge = this.fl.points; + } else { + leftEdge = this.fl.points; + rightEdge = this.dr.points; + midEdge = this.fr.points; + } + + this.vpl = intersection(leftEdge[0], midEdge[0], leftEdge[1], midEdge[1]); + this.vpr = intersection(rightEdge[0], midEdge[0], rightEdge[1], midEdge[1]); + if (this.vpl === null) { + // shift the edge slightly to avoid edge case + leftEdge[0].y -= 0.001; + leftEdge[0].x += 0.001; + leftEdge[1].x += 0.001; + this.vpl = intersection(leftEdge[0], midEdge[0], leftEdge[1], midEdge[1]); + } + if (this.vpr === null) { + // shift the edge slightly to avoid edge case + rightEdge[0].y -= 0.001; + rightEdge[0].x -= 0.001; + rightEdge[1].x -= 0.001; + this.vpr = intersection(leftEdge[0], midEdge[0], leftEdge[1], midEdge[1]); + } + } + + private initEdges(): void { + this.fl = new Edge([0, 1], this.points); + this.fr = new Edge([2, 3], this.points); + this.dr = new Edge([4, 5], this.points); + this.dl = new Edge([6, 7], this.points); + + this.ft = new Edge([0, 2], this.points); + this.lt = new Edge([0, 6], this.points); + this.rt = new Edge([2, 4], this.points); + this.dt = new Edge([6, 4], this.points); + + this.fb = new Edge([1, 3], this.points); + this.lb = new Edge([1, 7], this.points); + this.rb = new Edge([3, 5], this.points); + this.db = new Edge([7, 5], this.points); + + this.edgeList = [this.fl, this.fr, this.dl, this.dr, this.ft, this.lt, + this.rt, this.dt, this.fb, this.lb, this.rb, this.db]; + } + + private initFaces(): void { + this.front = new Figure([0, 1, 3, 2], this.points); + this.right = new Figure([2, 3, 5, 4], this.points); + this.dorsal = new Figure([4, 5, 7, 6], this.points); + this.left = new Figure([6, 7, 1, 0], this.points); + this.top = new Figure([0, 2, 4, 6], this.points); + this.bot = new Figure([1, 3, 5, 7], this.points); + + this.facesList = [this.front, this.right, this.dorsal, this.left]; + } + + private buildBackEdge(buildright: boolean): void { + this.updateVanishingPoints(buildright); + let leftPoints = []; + let rightPoints = []; + + let topIndex = 0; + let botIndex = 0; + + if (buildright) { + leftPoints = this.dl.points; + rightPoints = this.fr.points; + topIndex = 4; + botIndex = 5; + } else { + leftPoints = this.dr.points; + rightPoints = this.fl.points; + topIndex = 6; + botIndex = 7; + } + + const vpLeft = this.vpl; + const vpRight = this.vpr; + + let p1 = intersection(vpLeft, leftPoints[0], vpRight, rightPoints[0]); + let p2 = intersection(vpLeft, leftPoints[1], vpRight, rightPoints[1]); + + if (p1 === null) { + p1 = { x: p2.x, y: vpLeft.y }; + } else if (p2 === null) { + p2 = { x: p1.x, y: vpLeft.y }; + } + + this.points[topIndex] = { x: p1.x, y: p1.y }; + this.points[botIndex] = { x: p2.x, y: p2.y }; + + // Making sure that the vertical edges stay vertical + this.updatePoints(); + } +} + +function sortPointsClockwise(points: any[]): any[] { + points.sort((a, b): number => a.y - b.y); + // Get center y + const cy = (points[0].y + points[points.length - 1].y) / 2; + + // Sort from right to left + points.sort((a, b): number => b.x - a.x); + + // Get center x + const cx = (points[0].x + points[points.length - 1].x) / 2; + + // Center point + const center = { + x: cx, + y: cy, + }; + + // Starting angle used to reference other angles + let startAng: number | undefined; + points.forEach((point): void => { + let ang = Math.atan2(point.y - center.y, point.x - center.x); + if (!startAng) { + startAng = ang; + // ensure that all points are clockwise of the start point + } else if (ang < startAng) { + ang += Math.PI * 2; + } + // eslint-disable-next-line no-param-reassign + point.angle = ang; // add the angle to the point + }); + + // first sort clockwise + points.sort((a, b): number => a.angle - b.angle); + return points.reverse(); +} + +function setupCuboidPoints(points: Point[]): any[] { + let left; + let right; + let left2; + let right2; + let p1; + let p2; + let p3; + let p4; + + const height = Math.abs(points[0].x - points[1].x) + < Math.abs(points[1].x - points[2].x) + ? Math.abs(points[1].y - points[0].y) + : Math.abs(points[1].y - points[2].y); + + // seperate into left and right point + // we pick the first and third point because we know assume they will be on + // opposite corners + if (points[0].x < points[2].x) { + [left,, right] = points; + } else { + [right,, left] = points; + } + + // get other 2 points using the given height + if (left.y < right.y) { + left2 = { x: left.x, y: left.y + height }; + right2 = { x: right.x, y: right.y - height }; + } else { + left2 = { x: left.x, y: left.y - height }; + right2 = { x: right.x, y: right.y + height }; + } + + // get the vector for the last point relative to the previous point + const vec = { + x: points[3].x - points[2].x, + y: points[3].y - points[2].y, + }; + + if (left.y < left2.y) { + p1 = left; + p2 = left2; + } else { + p1 = left2; + p2 = left; + } + + if (right.y < right2.y) { + p3 = right; + p4 = right2; + } else { + p3 = right2; + p4 = right; + } + + const p5 = { x: p3.x + vec.x, y: p3.y + vec.y + 0.1 }; + const p6 = { x: p4.x + vec.x, y: p4.y + vec.y - 0.1 }; + const p7 = { x: p1.x + vec.x, y: p1.y + vec.y + 0.1 }; + const p8 = { x: p2.x + vec.x, y: p2.y + vec.y - 0.1 }; + + p1.y += 0.1; + return [p1, p2, p3, p4, p5, p6, p7, p8]; +} + +export function cuboidFrom4Points(flattenedPoints: any[]): any[] { + const points: Point[] = []; + for (let i = 0; i < 4; i++) { + const [x, y] = flattenedPoints.slice(i * 2, i * 2 + 2); + points.push({ x, y }); + } + const unsortedPlanePoints = points.slice(0, 3); + function rotate(array: any[], times: number): void{ + let t = times; + while (t--) { + const temp = array.shift(); + array.push(temp); + } + } + + const plane2 = { + p1: points[0], + p2: points[0], + p3: points[0], + p4: points[0], + }; + + // completing the plane + const vector = { + x: points[2].x - points[1].x, + y: points[2].y - points[1].y, + }; + + // sorting the first plane + unsortedPlanePoints.push({ + x: points[0].x + vector.x, + y: points[0].y + vector.y, + }); + const sortedPlanePoints = sortPointsClockwise(unsortedPlanePoints); + let leftIndex = 0; + for (let i = 0; i < 4; i++) { + leftIndex = sortedPlanePoints[i].x < sortedPlanePoints[leftIndex].x ? i : leftIndex; + } + rotate(sortedPlanePoints, leftIndex); + const plane1 = { + p1: sortedPlanePoints[0], + p2: sortedPlanePoints[1], + p3: sortedPlanePoints[2], + p4: sortedPlanePoints[3], + }; + + const vec = { + x: points[3].x - points[2].x, + y: points[3].y - points[2].y, + }; + // determine the orientation + const angle = Math.atan2(vec.y, vec.x); + + // making the other plane + plane2.p1 = { x: plane1.p1.x + vec.x, y: plane1.p1.y + vec.y }; + plane2.p2 = { x: plane1.p2.x + vec.x, y: plane1.p2.y + vec.y }; + plane2.p3 = { x: plane1.p3.x + vec.x, y: plane1.p3.y + vec.y }; + plane2.p4 = { x: plane1.p4.x + vec.x, y: plane1.p4.y + vec.y }; + + + let cuboidPoints; + // right + if (Math.abs(angle) < Math.PI / 2 - 0.1) { + cuboidPoints = setupCuboidPoints(points); + // left + } else if (Math.abs(angle) > Math.PI / 2 + 0.1) { + cuboidPoints = setupCuboidPoints(points); + // down + } else if (angle > 0) { + cuboidPoints = [ + plane1.p1, plane2.p1, plane1.p2, plane2.p2, + plane1.p3, plane2.p3, plane1.p4, plane2.p4, + ]; + cuboidPoints[0].y += 0.1; + cuboidPoints[4].y += 0.1; + // up + } else { + cuboidPoints = [ + plane2.p1, plane1.p1, plane2.p2, plane1.p2, + plane2.p3, plane1.p3, plane2.p4, plane1.p4, + ]; + cuboidPoints[0].y += 0.1; + cuboidPoints[4].y += 0.1; + } + + return cuboidPoints.reduce((arr: number[], point: any): number[] => { + arr.push(point.x); + arr.push(point.y); + return arr; + }, []); +} diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index 327d845fa7f5..8aa8b55101fb 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -24,6 +24,8 @@ import { Configuration, } from './canvasModel'; +import { cuboidFrom4Points } from './cuboid'; + export interface DrawHandler { configurate(configuration: Configuration): void; draw(drawData: DrawData, geometry: Geometry): void; @@ -105,6 +107,84 @@ export class DrawHandlerImpl implements DrawHandler { }; } + private getFinalCuboidCoordinates(targetPoints: number[]): { + points: number[]; + box: Box; + } { + const { offset } = this.geometry; + let points = targetPoints; + + const box = { + xtl: 0, + ytl: 0, + xbr: Number.MAX_SAFE_INTEGER, + ybr: Number.MAX_SAFE_INTEGER, + }; + + const frameWidth = this.geometry.image.width; + const frameHeight = this.geometry.image.height; + + const cuboidOffsets = []; + const minCuboidOffset = { + d: Number.MAX_SAFE_INTEGER, + dx: 0, + dy: 0, + }; + + for (let i = 0; i < points.length - 1; i += 2) { + const [x, y] = points.slice(i); + + if (x >= offset && x <= offset + frameWidth + && y >= offset && y <= offset + frameHeight) continue; + + let xOffset = 0; + let yOffset = 0; + + if (x < offset) { + xOffset = offset - x; + } else if (x > offset + frameWidth) { + xOffset = offset + frameWidth - x; + } + + if (y < offset) { + yOffset = offset - y; + } else if (y > offset + frameHeight) { + yOffset = offset + frameHeight - y; + } + + cuboidOffsets.push([xOffset, yOffset]); + } + + if (cuboidOffsets.length === points.length / 2) { + cuboidOffsets.forEach((offsetCoords: number[]): void => { + if (Math.sqrt((offsetCoords[0] ** 2) + (offsetCoords[1] ** 2)) + < minCuboidOffset.d) { + minCuboidOffset.d = Math.sqrt((offsetCoords[0] ** 2) + (offsetCoords[1] ** 2)); + [minCuboidOffset.dx, minCuboidOffset.dy] = offsetCoords; + } + }); + + points = points.map((coord: number, i: number): number => { + const finalCoord = coord + (i % 2 === 0 ? minCuboidOffset.dx : minCuboidOffset.dy); + + if (i % 2 === 0) { + box.xtl = Math.max(box.xtl, finalCoord); + box.xbr = Math.min(box.xbr, finalCoord); + } else { + box.ytl = Math.max(box.ytl, finalCoord); + box.ybr = Math.min(box.ybr, finalCoord); + } + + return finalCoord; + }); + } + + return { + points: points.map((coord: number): number => coord - offset), + box, + }; + } + private addCrosshair(): void { const { x, y } = this.cursorPosition; this.crosshair = { @@ -236,18 +316,17 @@ export class DrawHandlerImpl implements DrawHandler { } private drawPolyshape(): void { - let size = this.drawData.numberOfPoints; - const sizeDecrement = function sizeDecrement(): void { - if (!--size) { + let size = this.drawData.shapeType === 'cuboid' ? 4 : this.drawData.numberOfPoints; + + const sizeDecrement = (): void => { + if (--size === 0) { this.drawInstance.draw('done'); } - }.bind(this); + }; - if (this.drawData.numberOfPoints) { - this.drawInstance.on('drawstart', sizeDecrement); - this.drawInstance.on('drawpoint', sizeDecrement); - this.drawInstance.on('undopoint', (): number => size++); - } + this.drawInstance.on('drawstart', sizeDecrement); + this.drawInstance.on('drawpoint', sizeDecrement); + this.drawInstance.on('undopoint', (): number => size++); // Add ability to cancel the latest drawn point this.canvas.on('mousedown.draw', (e: MouseEvent): void => { @@ -299,8 +378,9 @@ export class DrawHandlerImpl implements DrawHandler { this.drawInstance.on('drawdone', (e: CustomEvent): void => { const targetPoints = pointsToArray((e.target as SVGElement).getAttribute('points')); - const { points, box } = this.getFinalPolyshapeCoordinates(targetPoints); const { shapeType } = this.drawData; + const { points, box } = shapeType === 'cuboid' ? this.getFinalCuboidCoordinates(targetPoints) + : this.getFinalPolyshapeCoordinates(targetPoints); this.release(); if (this.canceled) return; @@ -325,6 +405,13 @@ export class DrawHandlerImpl implements DrawHandler { shapeType, points, }, Date.now() - this.startTimestamp); + // TODO: think about correct constraign for cuboids + } else if (shapeType === 'cuboid' + && points.length === 4 * 2) { + this.onDrawDone({ + shapeType, + points: cuboidFrom4Points(points), + }, Date.now() - this.startTimestamp); } }); } @@ -364,6 +451,14 @@ export class DrawHandlerImpl implements DrawHandler { this.drawPolyshape(); } + private drawCuboid(): void { + this.drawInstance = (this.canvas as any).polyline() + .addClass('cvat_canvas_shape_drawing').attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }); + this.drawPolyshape(); + } + private pastePolyshape(): void { this.drawInstance.on('done', (e: CustomEvent): void => { const targetPoints = this.drawInstance @@ -371,7 +466,9 @@ export class DrawHandlerImpl implements DrawHandler { .split(/[,\s]/g) .map((coord: string): number => +coord); - const { points } = this.getFinalPolyshapeCoordinates(targetPoints); + const { points } = this.drawData.initialState.shapeType === 'cuboid' ? this.getFinalCuboidCoordinates(targetPoints) + : this.getFinalPolyshapeCoordinates(targetPoints); + if (!e.detail.originalEvent.ctrlKey) { this.release(); } @@ -450,6 +547,15 @@ export class DrawHandlerImpl implements DrawHandler { this.pastePolyshape(); } + private pasteCuboid(points: string): void { + this.drawInstance = (this.canvas as any).cube(points).addClass('cvat_canvas_shape_drawing').attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + 'face-stroke': 'black', + }); + this.pasteShape(); + this.pastePolyshape(); + } + private pastePoints(initialPoints: string): void { function moveShape( shape: SVG.PolyLine, @@ -550,6 +656,8 @@ export class DrawHandlerImpl implements DrawHandler { this.pastePolyline(stringifiedPoints); } else if (this.drawData.shapeType === 'points') { this.pastePoints(stringifiedPoints); + } else if (this.drawData.shapeType === 'cuboid') { + this.pasteCuboid(stringifiedPoints); } } this.setupPasteEvents(); @@ -570,6 +678,8 @@ export class DrawHandlerImpl implements DrawHandler { this.drawPolyline(); } else if (this.drawData.shapeType === 'points') { this.drawPoints(); + } else if (this.drawData.shapeType === 'cuboid') { + this.drawCuboid(); } this.setupDrawEvents(); } diff --git a/cvat-canvas/src/typescript/shared.ts b/cvat-canvas/src/typescript/shared.ts index f230b60d14c4..0a06c72d80af 100644 --- a/cvat-canvas/src/typescript/shared.ts +++ b/cvat-canvas/src/typescript/shared.ts @@ -25,6 +25,10 @@ export interface BBox { y: number; } +interface Point { + x: number; + y: number; +} export interface DrawnState { clientID: number; outside?: boolean; @@ -115,3 +119,26 @@ export function displayShapeSize( return shapeSize; } + +export function convertToArray(points: Point[]): number[][] { + const arr: number[][] = []; + points.forEach((point: Point): void => { + arr.push([point.x, point.y]); + }); + return arr; +} + +export function parsePoints(stringified: string): Point[] { + return stringified.trim().split(/\s/).map((point: string): Point => { + const [x, y] = point.split(',').map((coord: string): number => +coord); + return { x, y }; + }); +} + +export function stringifyPoints(points: Point[]): string { + return points.map((point: Point): string => `${point.x},${point.y}`).join(' '); +} + +export function clamp(x: number, min: number, max: number): number { + return Math.min(Math.max(x, min), max); +} diff --git a/cvat-canvas/src/typescript/svg.patch.ts b/cvat-canvas/src/typescript/svg.patch.ts index 642add621168..b16019dd7b7d 100644 --- a/cvat-canvas/src/typescript/svg.patch.ts +++ b/cvat-canvas/src/typescript/svg.patch.ts @@ -9,6 +9,16 @@ import 'svg.resize.js'; import 'svg.select.js'; import 'svg.draw.js'; +import consts from './consts'; +import { + Point, + Equation, + CuboidModel, + Orientation, + Edge, +} from './cuboid'; +import { parsePoints, stringifyPoints, clamp } from './shared'; + // Update constructor const originalDraw = SVG.Element.prototype.draw; SVG.Element.prototype.draw = function constructor(...args: any): any { @@ -181,3 +191,862 @@ SVG.Element.prototype.resize = function constructor(...args: any): any { for (const key of Object.keys(originalResize)) { SVG.Element.prototype.resize[key] = originalResize[key]; } + + +enum EdgeIndex { + FL = 1, + FR = 2, + DR = 3, + DL = 4, +} + +function getEdgeIndex(cuboidPoint: number): EdgeIndex { + switch (cuboidPoint) { + case 0: + case 1: + return EdgeIndex.FL; + case 2: + case 3: + return EdgeIndex.FR; + case 4: + case 5: + return EdgeIndex.DR; + default: + return EdgeIndex.DL; + } +} + +function getTopDown(edgeIndex: EdgeIndex): number[] { + switch (edgeIndex) { + case EdgeIndex.FL: + return [0, 1]; + case EdgeIndex.FR: + return [2, 3]; + case EdgeIndex.DR: + return [4, 5]; + default: + return [6, 7]; + } +} + +(SVG as any).Cube = SVG.invent({ + create: 'g', + inherit: SVG.G, + extend: { + constructorMethod(points: string) { + this.cuboidModel = new CuboidModel(parsePoints(points)); + this.setupFaces(); + this.setupEdges(); + this.setupProjections(); + this.hideProjections(); + + this._attr('points', points); + return this; + }, + + setupFaces() { + this.bot = this.polygon(this.cuboidModel.bot.points); + this.top = this.polygon(this.cuboidModel.top.points); + this.right = this.polygon(this.cuboidModel.right.points); + this.dorsal = this.polygon(this.cuboidModel.dorsal.points); + this.left = this.polygon(this.cuboidModel.left.points); + this.face = this.polygon(this.cuboidModel.front.points); + }, + + setupProjections() { + this.ftProj = this.line(this.updateProjectionLine(this.cuboidModel.ft.getEquation(), + this.cuboidModel.ft.points[0], this.cuboidModel.vpl)); + this.fbProj = this.line(this.updateProjectionLine(this.cuboidModel.fb.getEquation(), + this.cuboidModel.ft.points[0], this.cuboidModel.vpl)); + this.rtProj = this.line(this.updateProjectionLine(this.cuboidModel.rt.getEquation(), + this.cuboidModel.rt.points[1], this.cuboidModel.vpr)); + this.rbProj = this.line(this.updateProjectionLine(this.cuboidModel.rb.getEquation(), + this.cuboidModel.rb.points[1], this.cuboidModel.vpr)); + + this.ftProj.stroke({ color: '#C0C0C0' }); + this.fbProj.stroke({ color: '#C0C0C0' }); + this.rtProj.stroke({ color: '#C0C0C0' }); + this.rbProj.stroke({ color: '#C0C0C0' }); + }, + + setupEdges() { + this.frontLeftEdge = this.line(this.cuboidModel.fl.points); + this.frontRightEdge = this.line(this.cuboidModel.fr.points); + this.dorsalRightEdge = this.line(this.cuboidModel.dr.points); + this.dorsalLeftEdge = this.line(this.cuboidModel.dl.points); + + this.frontTopEdge = this.line(this.cuboidModel.ft.points); + this.rightTopEdge = this.line(this.cuboidModel.rt.points); + this.frontBotEdge = this.line(this.cuboidModel.fb.points); + this.rightBotEdge = this.line(this.cuboidModel.rb.points); + }, + + setupGrabPoints(circleType) { + const viewModel = this.cuboidModel; + const circle = typeof circleType === 'function' ? circleType : this.circle; + + this.flCenter = circle(0, 0).addClass('svg_select_points').addClass('svg_select_points_l'); + this.frCenter = circle(0, 0).addClass('svg_select_points').addClass('svg_select_points_r'); + this.ftCenter = circle(0, 0).addClass('svg_select_points').addClass('svg_select_points_t'); + this.fbCenter = circle(0, 0).addClass('svg_select_points').addClass('svg_select_points_b'); + + this.drCenter = circle(0, 0).addClass('svg_select_points').addClass('svg_select_points_ew'); + this.dlCenter = circle(0, 0).addClass('svg_select_points').addClass('svg_select_points_ew'); + + const grabPoints = this.getGrabPoints(); + const edges = this.getEdges(); + for (let i = 0; i < grabPoints.length; i += 1) { + const edge = edges[i]; + const cx = (edge.attr('x2') + edge.attr('x1')) / 2; + const cy = (edge.attr('y2') + edge.attr('y1')) / 2; + grabPoints[i].center(cx, cy); + } + + if (viewModel.orientation === Orientation.LEFT) { + this.dlCenter.hide(); + } else { + this.drCenter.hide(); + } + + + }, + + showProjections() { + if (this.projectionLineEnable) { + this.ftProj.show(); + this.fbProj.show(); + this.rtProj.show(); + this.rbProj.show(); + } + }, + + hideProjections() { + this.ftProj.hide(); + this.fbProj.hide(); + this.rtProj.hide(); + this.rbProj.hide(); + }, + + getEdges() { + const arr = []; + arr.push(this.frontLeftEdge); + arr.push(this.frontRightEdge); + arr.push(this.dorsalRightEdge); + arr.push(this.frontTopEdge); + arr.push(this.frontBotEdge); + arr.push(this.dorsalLeftEdge); + arr.push(this.rightTopEdge); + arr.push(this.rightBotEdge); + return arr; + }, + + getGrabPoints() { + const arr = []; + arr.push(this.flCenter); + arr.push(this.frCenter); + arr.push(this.drCenter); + arr.push(this.ftCenter); + arr.push(this.fbCenter); + arr.push(this.dlCenter); + return arr; + }, + + updateProjectionLine(equation: Equation, source: Point, direction: Point) { + const x1 = source.x; + const y1 = equation.getY(x1); + + const x2 = direction.x; + const y2 = equation.getY(x2); + return [[x1, y1], [x2, y2]]; + }, + + selectize(value: boolean, options: object) { + this.face.selectize(value, options); + + if (this.cuboidModel.orientation === Orientation.LEFT) { + this.dorsalLeftEdge.selectize(false, options); + this.dorsalRightEdge.selectize(value, options); + } else { + this.dorsalRightEdge.selectize(false, options); + this.dorsalLeftEdge.selectize(value, options); + } + + if (value === false) { + this.getGrabPoints().forEach((point) => {point && point.remove()}); + } else { + this.setupGrabPoints(this.face.remember('_selectHandler').drawPoint.bind( + {nested: this, options: this.face.remember('_selectHandler').options} + )); + + // setup proper classes for selection points for proper cursor + Array.from(this.face.remember('_selectHandler').nested.node.children) + .forEach((point: SVG.Circle, i: number) => { + point.classList.add(`svg_select_points_${['lt', 'lb', 'rb', 'rt'][i]}`) + }); + + if (this.cuboidModel.orientation === Orientation.LEFT) { + Array.from(this.dorsalRightEdge.remember('_selectHandler').nested.node.children) + .forEach((point: SVG.Circle, i: number) => { + point.classList.add(`svg_select_points_${['t', 'b'][i]}`); + point.ondblclick = this.resetPerspective.bind(this); + }); + } else { + Array.from(this.dorsalLeftEdge.remember('_selectHandler').nested.node.children) + .forEach((point: SVG.Circle, i: number) => { + point.classList.add(`svg_select_points_${['t', 'b'][i]}`); + point.ondblclick = this.resetPerspective.bind(this); + }); + } + + } + + return this; + }, + + resize(value?: string | object) { + this.face.resize(value); + + if (value === 'stop') { + this.dorsalRightEdge.resize(value); + this.dorsalLeftEdge.resize(value); + this.face.off('resizing').off('resizedone').off('resizestart'); + this.dorsalRightEdge.off('resizing').off('resizedone').off('resizestart'); + this.dorsalLeftEdge.off('resizing').off('resizedone').off('resizestart'); + + this.getGrabPoints().forEach((point: SVG.Element) => { + if (point) { + point.off('dragstart'); + point.off('dragmove'); + point.off('dragend'); + } + }) + + return; + } + + function getResizedPointIndex(event: CustomEvent): number { + const { target } = event.detail.event.detail.event; + const { parentElement } = target; + return Array + .from(parentElement.children) + .indexOf(target); + } + + let resizedCubePoint: null | number = null; + const accumulatedOffset: Point = { + x: 0, + y: 0, + }; + + this.face.on('resizestart', (event: CustomEvent) => { + accumulatedOffset.x = 0; + accumulatedOffset.y = 0; + const resizedFacePoint = getResizedPointIndex(event); + resizedCubePoint = [0, 1].includes(resizedFacePoint) ? resizedFacePoint + : 5 - resizedFacePoint; // 2,3 -> 3,2 + this.fire(new CustomEvent('resizestart', event)); + }).on('resizing', (event: CustomEvent) => { + let { dx, dy } = event.detail; + let dxPortion = dx - accumulatedOffset.x; + let dyPortion = dy - accumulatedOffset.y; + accumulatedOffset.x += dxPortion; + accumulatedOffset.y += dyPortion; + + const edge = getEdgeIndex(resizedCubePoint); + const [edgeTopIndex, edgeBottomIndex] = getTopDown(edge); + + let cuboidPoints = this.cuboidModel.getPoints(); + let x1 = cuboidPoints[edgeTopIndex].x + dxPortion; + let x2 = cuboidPoints[edgeBottomIndex].x + dxPortion; + if (edge === EdgeIndex.FL + && (cuboidPoints[2].x - (cuboidPoints[0].x + dxPortion) < consts.MIN_EDGE_LENGTH) + ) { + x1 = cuboidPoints[edgeTopIndex].x; + x2 = cuboidPoints[edgeBottomIndex].x; + } else if (edge === EdgeIndex.FR + && (cuboidPoints[2].x + dxPortion - cuboidPoints[0].x < consts.MIN_EDGE_LENGTH) + ) { + x1 = cuboidPoints[edgeTopIndex].x; + x2 = cuboidPoints[edgeBottomIndex].x; + } + const y1 = this.cuboidModel.ft.getEquation().getY(x1); + const y2 = this.cuboidModel.fb.getEquation().getY(x2); + const topPoint = { x: x1, y: y1 }; + const botPoint = { x: x2, y: y2 }; + if (edge === 1) { + this.cuboidModel.fl.points = [topPoint, botPoint]; + } else { + this.cuboidModel.fr.points = [topPoint, botPoint]; + } + this.updateViewAndVM(edge === EdgeIndex.FR); + + cuboidPoints = this.cuboidModel.getPoints(); + const midPointUp = { ...cuboidPoints[edgeTopIndex] }; + const midPointDown = { ...cuboidPoints[edgeBottomIndex] }; + (edgeTopIndex === resizedCubePoint ? midPointUp : midPointDown).y += dyPortion; + if (midPointDown.y - midPointUp.y > consts.MIN_EDGE_LENGTH) { + const topPoints = this.computeHeightFace(midPointUp, edge); + const bottomPoints = this.computeHeightFace(midPointDown, edge); + this.cuboidModel.top.points = topPoints; + this.cuboidModel.bot.points = bottomPoints; + this.updateViewAndVM(false); + } + + this.face.plot(this.cuboidModel.front.points); + this.fire(new CustomEvent('resizing', event)); + }).on('resizedone', (event: CustomEvent) => { + this.fire(new CustomEvent('resizedone', event)); + }); + + function computeSideEdgeConstraints(edge: Edge, fr: Edge) { + const midLength = fr.points[1].y - fr.points[0].y - 1; + + const minY = edge.points[1].y - midLength; + const maxY = edge.points[0].y + midLength; + + const y1 = edge.points[0].y; + const y2 = edge.points[1].y; + + const miny1 = y2 - midLength; + const maxy1 = y2 - consts.MIN_EDGE_LENGTH; + + const miny2 = y1 + consts.MIN_EDGE_LENGTH; + const maxy2 = y1 + midLength; + + return { + constraint: { + minY, + maxY, + }, + y1Range: { + max: maxy1, + min: miny1, + }, + y2Range: { + max: maxy2, + min: miny2, + }, + }; + } + + function setupDorsalEdge(edge: SVG.Line, orientation: Orientation) { + edge.on('resizestart', (event: CustomEvent) => { + accumulatedOffset.x = 0; + accumulatedOffset.y = 0; + resizedCubePoint = getResizedPointIndex(event) + (orientation === Orientation.LEFT ? 4 : 6); + this.fire(new CustomEvent('resizestart', event)); + }).on('resizing', (event: CustomEvent) => { + let { dy } = event.detail; + let dyPortion = dy - accumulatedOffset.y; + accumulatedOffset.y += dyPortion; + + const edge = getEdgeIndex(resizedCubePoint); + const [edgeTopIndex, edgeBottomIndex] = getTopDown(edge); + let cuboidPoints = this.cuboidModel.getPoints(); + + if (!event.detail.event.shiftKey) { + cuboidPoints = this.cuboidModel.getPoints(); + const midPointUp = { ...cuboidPoints[edgeTopIndex] }; + const midPointDown = { ...cuboidPoints[edgeBottomIndex] }; + (edgeTopIndex === resizedCubePoint ? midPointUp : midPointDown).y += dyPortion; + if (midPointDown.y - midPointUp.y > consts.MIN_EDGE_LENGTH) { + const topPoints = this.computeHeightFace(midPointUp, edge); + const bottomPoints = this.computeHeightFace(midPointDown, edge); + this.cuboidModel.top.points = topPoints; + this.cuboidModel.bot.points = bottomPoints; + } + } else { + const midPointUp = { ...cuboidPoints[edgeTopIndex] }; + const midPointDown = { ...cuboidPoints[edgeBottomIndex] }; + (edgeTopIndex === resizedCubePoint ? midPointUp : midPointDown).y += dyPortion; + const dorselEdge = (orientation === Orientation.LEFT ? this.cuboidModel.dr : this.cuboidModel.dl); + const constraints = computeSideEdgeConstraints(dorselEdge, this.cuboidModel.fr); + midPointUp.y = clamp(midPointUp.y, constraints.y1Range.min, constraints.y1Range.max); + midPointDown.y = clamp(midPointDown.y, constraints.y2Range.min, constraints.y2Range.max); + dorselEdge.points = [midPointUp, midPointDown]; + this.updateViewAndVM(edge === EdgeIndex.DL); + } + + + this.updateViewAndVM(false); + this.face.plot(this.cuboidModel.front.points); + this.fire(new CustomEvent('resizing', event)); + }).on('resizedone', (event: CustomEvent) => { + this.fire(new CustomEvent('resizedone', event)); + }); + } + + if (this.cuboidModel.orientation === Orientation.LEFT) { + this.dorsalRightEdge.resize(value); + setupDorsalEdge.call(this, this.dorsalRightEdge, this.cuboidModel.orientation); + } else { + this.dorsalLeftEdge.resize(value); + setupDorsalEdge.call(this, this.dorsalLeftEdge, this.cuboidModel.orientation); + } + + function horizontalEdgeControl(updatingFace, midX, midY) { + const leftPoints = this.updatedEdge( + this.cuboidModel.fl.points[0], + {x: midX, y: midY}, + this.cuboidModel.vpl, + ); + const rightPoints = this.updatedEdge( + this.cuboidModel.dr.points[0], + {x: midX, y: midY}, + this.cuboidModel.vpr, + ); + + updatingFace.points = [leftPoints, {x: midX, y: midY}, rightPoints, null]; + } + + + this.drCenter.draggable((x: number) => { + let xStatus; + if (this.drCenter.cx() < this.cuboidModel.fr.points[0].x) { + xStatus = x < this.cuboidModel.fr.points[0].x - consts.MIN_EDGE_LENGTH + && x > this.cuboidModel.vpr.x + consts.MIN_EDGE_LENGTH; + } else { + xStatus = x > this.cuboidModel.fr.points[0].x + consts.MIN_EDGE_LENGTH + && x < this.cuboidModel.vpr.x - consts.MIN_EDGE_LENGTH; + } + return { x: xStatus, y: this.drCenter.attr('y1') }; + }).on('dragstart', ((event: CustomEvent) => { + this.fire(new CustomEvent('resizestart', event)); + })).on('dragmove', (event: CustomEvent) => { + this.dorsalRightEdge.center(this.drCenter.cx(), this.drCenter.cy()); + + const x = this.dorsalRightEdge.attr('x1'); + const y1 = this.cuboidModel.rt.getEquation().getY(x); + const y2 = this.cuboidModel.rb.getEquation().getY(x); + const topPoint = { x, y: y1 }; + const botPoint = { x, y: y2 }; + + this.cuboidModel.dr.points = [topPoint, botPoint]; + this.updateViewAndVM(); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('resizedone', event)); + }); + + this.dlCenter.draggable((x: number) => { + let xStatus; + if (this.dlCenter.cx() < this.cuboidModel.fl.points[0].x) { + xStatus = x < this.cuboidModel.fl.points[0].x - consts.MIN_EDGE_LENGTH + && x > this.cuboidModel.vpr.x + consts.MIN_EDGE_LENGTH; + } else { + xStatus = x > this.cuboidModel.fl.points[0].x + consts.MIN_EDGE_LENGTH + && x < this.cuboidModel.vpr.x - consts.MIN_EDGE_LENGTH; + } + return { x: xStatus, y: this.dlCenter.attr('y1') }; + }).on('dragstart', ((event: CustomEvent) => { + this.fire(new CustomEvent('resizestart', event)); + })).on('dragmove', (event: CustomEvent) => { + this.dorsalLeftEdge.center(this.dlCenter.cx(), this.dlCenter.cy()); + + const x = this.dorsalLeftEdge.attr('x1'); + const y1 = this.cuboidModel.lt.getEquation().getY(x); + const y2 = this.cuboidModel.lb.getEquation().getY(x); + const topPoint = { x, y: y1 }; + const botPoint = { x, y: y2 }; + + this.cuboidModel.dl.points = [topPoint, botPoint]; + this.updateViewAndVM(true); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('resizedone', event)); + });; + + this.flCenter.draggable((x: number) => { + const vpX = this.flCenter.cx() - this.cuboidModel.vpl.x > 0 ? this.cuboidModel.vpl.x : 0; + return { x: x < this.cuboidModel.fr.points[0].x && x > vpX + consts.MIN_EDGE_LENGTH }; + }).on('dragstart', ((event: CustomEvent) => { + this.fire(new CustomEvent('resizestart', event)); + })).on('dragmove', (event: CustomEvent) => { + this.frontLeftEdge.center(this.flCenter.cx(), this.flCenter.cy()); + + const x = this.frontLeftEdge.attr('x1'); + const y1 = this.cuboidModel.ft.getEquation().getY(x); + const y2 = this.cuboidModel.fb.getEquation().getY(x); + const topPoint = { x, y: y1 }; + const botPoint = { x, y: y2 }; + + this.cuboidModel.fl.points = [topPoint, botPoint]; + this.updateViewAndVM(); + this.fire(new CustomEvent('resizing', event)); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('resizedone', event)); + }); + + this.frCenter.draggable((x: number) => { + return { x: x > this.cuboidModel.fl.points[0].x, y: this.frCenter.attr('y1') }; + }).on('dragstart', ((event: CustomEvent) => { + this.fire(new CustomEvent('resizestart', event)); + })).on('dragmove', (event: CustomEvent) => { + this.frontRightEdge.center(this.frCenter.cx(), this.frCenter.cy()); + + const x = this.frontRightEdge.attr('x1'); + const y1 = this.cuboidModel.ft.getEquation().getY(x); + const y2 = this.cuboidModel.fb.getEquation().getY(x); + const topPoint = { x, y: y1 }; + const botPoint = { x, y: y2 }; + + this.cuboidModel.fr.points = [topPoint, botPoint]; + this.updateViewAndVM(true); + this.fire(new CustomEvent('resizing', event)); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('resizedone', event)); + }); + + this.ftCenter.draggable((x: number, y: number) => { + return { x: x === this.ftCenter.cx(), y: y < this.fbCenter.cy() - consts.MIN_EDGE_LENGTH }; + }).on('dragstart', ((event: CustomEvent) => { + this.fire(new CustomEvent('resizestart', event)); + })).on('dragmove', (event: CustomEvent) => { + this.frontTopEdge.center(this.ftCenter.cx(), this.ftCenter.cy()); + horizontalEdgeControl.call(this, this.cuboidModel.top, this.frontTopEdge.attr('x2'), this.frontTopEdge.attr('y2')); + this.updateViewAndVM(); + this.fire(new CustomEvent('resizing', event)); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('resizedone', event)); + }); + + this.fbCenter.draggable((x: number, y: number) => { + return { x: x === this.fbCenter.cx(), y: y > this.ftCenter.cy() + consts.MIN_EDGE_LENGTH }; + }).on('dragstart', ((event: CustomEvent) => { + this.fire(new CustomEvent('resizestart', event)); + })).on('dragmove', (event: CustomEvent) => { + this.frontBotEdge.center(this.fbCenter.cx(), this.fbCenter.cy()); + horizontalEdgeControl.call(this, this.cuboidModel.bot, this.frontBotEdge.attr('x2'), this.frontBotEdge.attr('y2')); + this.updateViewAndVM(); + this.fire(new CustomEvent('resizing', event)); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('resizedone', event)); + }); + + + return this; + }, + + draggable(value: any, constraint: any) { + const { cuboidModel } = this; + const faces = [this.face, this.right, this.dorsal, this.left] + const accumulatedOffset: Point = { + x: 0, + y: 0, + }; + + if (value === false) { + faces.forEach((face: any) => { + face.draggable(false); + face.off('dragstart'); + face.off('dragmove'); + face.off('dragend'); + }) + return + } + + this.face.draggable().on('dragstart', (event: CustomEvent) => { + accumulatedOffset.x = 0; + accumulatedOffset.y = 0; + + this.fire(new CustomEvent('dragstart', event)); + }).on('dragmove', (event: CustomEvent) => { + const dx = event.detail.p.x - event.detail.handler.startPoints.point.x; + const dy = event.detail.p.y - event.detail.handler.startPoints.point.y; + let dxPortion = dx - accumulatedOffset.x; + let dyPortion = dy - accumulatedOffset.y; + accumulatedOffset.x += dxPortion; + accumulatedOffset.y += dyPortion; + + this.dmove(dxPortion, dyPortion); + + this.fire(new CustomEvent('dragmove', event)); + }).on('dragend', (event: CustomEvent) => { + + this.fire(new CustomEvent('dragend', event)); + }) + + this.left.draggable((x: number, y: number) => ({ + x: x < Math.min(cuboidModel.dr.points[0].x, + cuboidModel.fr.points[0].x) - consts.MIN_EDGE_LENGTH, y + })).on('dragstart', (event: CustomEvent) => { + this.fire(new CustomEvent('dragstart', event)); + }).on('dragmove', (event: CustomEvent) => { + this.cuboidModel.left.points = parsePoints(this.left.attr('points')); + this.updateViewAndVM(); + + this.fire(new CustomEvent('dragmove', event)); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('dragend', event)); + }); + + this.dorsal.draggable().on('dragstart', (event: CustomEvent) => { + this.fire(new CustomEvent('dragstart', event)); + }).on('dragmove', (event: CustomEvent) => { + this.cuboidModel.dorsal.points = parsePoints(this.dorsal.attr('points')); + this.updateViewAndVM(); + + this.fire(new CustomEvent('dragmove', event)); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('dragend', event)); + }); + + this.right.draggable((x: number, y: number) => ({ + x: x > Math.min(cuboidModel.dl.points[0].x, + cuboidModel.fl.points[0].x) + consts.MIN_EDGE_LENGTH, y + })).on('dragstart', (event: CustomEvent) => { + this.fire(new CustomEvent('dragstart', event)); + }).on('dragmove', (event: CustomEvent) => { + this.cuboidModel.right.points = parsePoints(this.right.attr('points')); + this.updateViewAndVM(true); + + this.fire(new CustomEvent('dragmove', event)); + }).on('dragend', (event: CustomEvent) => { + this.fire(new CustomEvent('dragend', event)); + }); + + return this; + }, + + _attr: SVG.Element.prototype.attr, + + attr(a: any, v: any, n: any) { + if ((a === 'fill' || a === 'stroke' || a === 'face-stroke') + && v !== undefined) { + this._attr(a, v, n); + this.paintOrientationLines(); + } else if (a === 'points' && typeof v === 'string') { + const points = parsePoints(v); + this.cuboidModel.setPoints(points); + this.updateViewAndVM(); + } else if (a === 'projections') { + this._attr(a, v, n); + if (v === true) { + this.ftProj.show(); + this.fbProj.show(); + this.rtProj.show(); + this.rbProj.show(); + } else { + this.ftProj.hide(); + this.fbProj.hide(); + this.rtProj.hide(); + this.rbProj.hide(); + } + } else if (a === 'stroke-width' && typeof v === "number") { + this._attr(a, v, n); + this.updateThickness(); + } else if (a === 'data-z-order' && typeof v !== 'undefined') { + this._attr(a, v, n); + [this.face, this.left, this.dorsal, this.right, ...this.getEdges(), ...this.getGrabPoints()] + .forEach((el) => {if (el) el.attr(a, v, n)}) + } else { + return this._attr(a ,v, n); + } + + return this; + }, + + updateThickness() { + const edges = [this.frontLeftEdge, this.frontRightEdge, this.frontTopEdge, this.frontBotEdge] + const width = this.attr('stroke-width'); + edges.forEach((edge: SVG.Element) => { + edge.attr('stroke-width', width * (this.strokeOffset || 1.75)); + }); + this.on('mouseover', () => { + edges.forEach((edge: SVG.Element) => { + this.strokeOffset = 2.5; + edge.attr('stroke-width', width * this.strokeOffset); + }) + }).on('mouseout', () => { + edges.forEach((edge: SVG.Element) => { + this.strokeOffset = 1.75; + edge.attr('stroke-width', width * this.strokeOffset); + }) + }); + }, + + paintOrientationLines() { + const fillColor = this.attr('fill'); + const strokeColor = this.attr('stroke'); + const selectedColor = this.attr('face-stroke') || '#b0bec5'; + this.frontTopEdge.stroke({ color: selectedColor }); + this.frontLeftEdge.stroke({ color: selectedColor }); + this.frontBotEdge.stroke({ color: selectedColor }); + this.frontRightEdge.stroke({ color: selectedColor }); + + this.rightTopEdge.stroke({ color: strokeColor }); + this.rightBotEdge.stroke({ color: strokeColor }); + this.dorsalRightEdge.stroke({ color: strokeColor }); + this.dorsalLeftEdge.stroke({ color: strokeColor }); + + this.bot.stroke({ color: strokeColor }) + .fill({ color: fillColor }); + this.top.stroke({ color: strokeColor }) + .fill({ color: fillColor }); + this.face.stroke({ color: strokeColor, width: 0 }) + .fill({ color: fillColor }); + this.right.stroke({ color: strokeColor }) + .fill({ color: fillColor }); + this.dorsal.stroke({ color: strokeColor }) + .fill({ color: fillColor }); + this.left.stroke({ color: strokeColor }) + .fill({ color: fillColor }); + }, + + dmove(dx: number, dy: number) { + this.cuboidModel.points.forEach((point: Point) => { + point.x += dx; + point.y += dy; + }); + + this.updateViewAndVM(); + }, + + x(x?: number) { + if (typeof x === 'number') { + const { x: xInitial } = this.bbox(); + this.dmove(x - xInitial, 0); + return this; + } else { + return this.bbox().x; + } + }, + + y(y?: number) { + if (typeof y === 'number') { + const { y: yInitial } = this.bbox(); + this.dmove(0, y - yInitial); + return this; + } else { + return this.bbox().y; + } + }, + + resetPerspective(){ + if (this.cuboidModel.orientation === Orientation.LEFT) { + const edgePoints = this.cuboidModel.dl.points; + const constraints = this.cuboidModel.computeSideEdgeConstraints(this.cuboidModel.dl); + edgePoints[0].y = constraints.y1Range.min; + this.cuboidModel.dl.points = [edgePoints[0],edgePoints[1]]; + this.updateViewAndVM(true); + } else { + const edgePoints = this.cuboidModel.dr.points; + const constraints = this.cuboidModel.computeSideEdgeConstraints(this.cuboidModel.dr); + edgePoints[0].y = constraints.y1Range.min; + this.cuboidModel.dr.points = [edgePoints[0],edgePoints[1]]; + this.updateViewAndVM(); + } + }, + + updateViewAndVM(build: boolean) { + this.cuboidModel.updateOrientation(); + this.cuboidModel.buildBackEdge(build); + this.updateView(); + + // to correct getting of points in resizedone, dragdone + this._attr('points', this.cuboidModel + .getPoints() + .reduce((acc: string, point: Point): string => `${acc} ${point.x},${point.y}`, '').trim()); + }, + + computeHeightFace(point: Point, index: number) { + switch (index) { + // fl + case 1: { + const p2 = this.updatedEdge(this.cuboidModel.fr.points[0], point, this.cuboidModel.vpl); + const p3 = this.updatedEdge(this.cuboidModel.dr.points[0], p2, this.cuboidModel.vpr); + const p4 = this.updatedEdge(this.cuboidModel.dl.points[0], point, this.cuboidModel.vpr); + return [point, p2, p3, p4]; + } + // fr + case 2: { + const p1 = this.updatedEdge(this.cuboidModel.fl.points[0], point, this.cuboidModel.vpl); + const p3 = this.updatedEdge(this.cuboidModel.dr.points[0], point, this.cuboidModel.vpr); + const p4 = this.updatedEdge(this.cuboidModel.dl.points[0], p3, this.cuboidModel.vpr); + return [p1, point, p3, p4]; + } + // dr + case 3: { + const p2 = this.updatedEdge(this.cuboidModel.dl.points[0], point, this.cuboidModel.vpl); + const p3 = this.updatedEdge(this.cuboidModel.fr.points[0], point, this.cuboidModel.vpr); + const p4 = this.updatedEdge(this.cuboidModel.fl.points[0], p2, this.cuboidModel.vpr); + return [p4, p3, point, p2]; + } + // dl + case 4: { + const p2 = this.updatedEdge(this.cuboidModel.dr.points[0], point, this.cuboidModel.vpl); + const p3 = this.updatedEdge(this.cuboidModel.fl.points[0], point, this.cuboidModel.vpr); + const p4 = this.updatedEdge(this.cuboidModel.fr.points[0], p2, this.cuboidModel.vpr); + return [p3, p4, p2, point]; + } + default: { + return [null, null, null, null]; + } + } + }, + + updatedEdge(target: Point, base: Point, pivot: Point) { + const targetX = target.x; + const line = new Equation(pivot, base); + const newY = line.getY(targetX); + return { x: targetX, y: newY }; + }, + + updateView() { + this.updateFaces(); + this.updateEdges(); + this.updateProjections(); + this.updateGrabPoints(); + }, + + updateFaces() { + const viewModel = this.cuboidModel; + + this.bot.plot(viewModel.bot.points); + this.top.plot(viewModel.top.points); + this.right.plot(viewModel.right.points); + this.dorsal.plot(viewModel.dorsal.points); + this.left.plot(viewModel.left.points); + this.face.plot(viewModel.front.points); + }, + + updateEdges() { + const viewModel = this.cuboidModel; + + this.frontLeftEdge.plot(viewModel.fl.points); + this.frontRightEdge.plot(viewModel.fr.points); + this.dorsalRightEdge.plot(viewModel.dr.points); + this.dorsalLeftEdge.plot(viewModel.dl.points); + + this.frontTopEdge.plot(viewModel.ft.points); + this.rightTopEdge.plot(viewModel.rt.points); + this.frontBotEdge.plot(viewModel.fb.points); + this.rightBotEdge.plot(viewModel.rb.points); + }, + + updateProjections() { + const viewModel = this.cuboidModel; + + this.ftProj.plot(this.updateProjectionLine(viewModel.ft.getEquation(), + viewModel.ft.points[0], viewModel.vpl)); + this.fbProj.plot(this.updateProjectionLine(viewModel.fb.getEquation(), + viewModel.ft.points[0], viewModel.vpl)); + this.rtProj.plot(this.updateProjectionLine(viewModel.rt.getEquation(), + viewModel.rt.points[1], viewModel.vpr)); + this.rbProj.plot(this.updateProjectionLine(viewModel.rb.getEquation(), + viewModel.rt.points[1], viewModel.vpr)); + }, + + updateGrabPoints() { + const centers = this.getGrabPoints(); + const edges = this.getEdges(); + for (let i = 0; i < centers.length; i += 1) { + const edge = edges[i]; + if (centers[i]) centers[i].center(edge.cx(), edge.cy()); + } + }, + }, + construct: { + cube(points: string) { + return this.put(new (SVG as any).Cube()).constructorMethod(points); + }, + }, +}); diff --git a/cvat-core/src/annotations-collection.js b/cvat-core/src/annotations-collection.js index e09e6f47c781..d3a7f4ac82a9 100644 --- a/cvat-core/src/annotations-collection.js +++ b/cvat-core/src/annotations-collection.js @@ -13,10 +13,12 @@ PolygonShape, PolylineShape, PointsShape, + CuboidShape, RectangleTrack, PolygonTrack, PolylineTrack, PointsTrack, + CuboidTrack, Track, Shape, Tag, @@ -58,6 +60,9 @@ case 'points': shapeModel = new PointsShape(shapeData, clientID, color, injection); break; + case 'cuboid': + shapeModel = new CuboidShape(shapeData, clientID, color, injection); + break; default: throw new DataError( `An unexpected type of shape "${type}"`, @@ -87,6 +92,9 @@ case 'points': trackModel = new PointsTrack(trackData, clientID, color, injection); break; + case 'cuboid': + trackModel = new CuboidTrack(trackData, clientID, color, injection); + break; default: throw new DataError( `An unexpected type of track "${type}"`, @@ -150,7 +158,6 @@ } for (const shape of data.shapes) { - if (shape.type === 'cuboid') continue; const clientID = ++this.count; const shapeModel = shapeFactory(shape, clientID, this.injection); this.shapes[shapeModel.frame] = this.shapes[shapeModel.frame] || []; @@ -592,6 +599,10 @@ shape: 0, track: 0, }, + cuboid: { + shape: 0, + track: 0, + }, tags: 0, manually: 0, interpolated: 0, diff --git a/cvat-core/src/annotations-objects.js b/cvat-core/src/annotations-objects.js index a6192dc03a7d..ecf876347f45 100644 --- a/cvat-core/src/annotations-objects.js +++ b/cvat-core/src/annotations-objects.js @@ -69,6 +69,12 @@ `Points must have at least 1 points, but got ${points.length / 2}`, ); } + } else if (shapeType === ObjectShape.CUBOID) { + if (points.length / 2 !== 8) { + throw new DataError( + `Points must have exact 8 points, but got ${points.length / 2}`, + ); + } } else { throw new ArgumentError( `Unknown value of shapeType has been recieved ${shapeType}`, @@ -109,6 +115,35 @@ return area >= MIN_SHAPE_AREA; } + function fitPoints(shapeType, points, maxX, maxY) { + const fittedPoints = []; + + for (let i = 0; i < points.length - 1; i += 2) { + const x = points[i]; + const y = points[i + 1]; + + checkObjectType('coordinate', x, 'number', null); + checkObjectType('coordinate', y, 'number', null); + + fittedPoints.push( + Math.clamp(x, 0, maxX), + Math.clamp(y, 0, maxY), + ); + } + + return shapeType === ObjectShape.CUBOID ? points : fittedPoints; + } + + function checkOutside(points, width, height) { + let inside = false; + for (let i = 0; i < points.length - 1; i += 2) { + const [x, y] = points.slice(i); + inside = inside || (x >= 0 && x <= width && y >= 0 && y <= height); + } + + return !inside; + } + function validateAttributeValue(value, attr) { const { values } = attr; const type = attr.inputType; @@ -296,20 +331,9 @@ checkNumberOfPoints(this.shapeType, data.points); // cut points const { width, height } = this.frameMeta[frame]; - for (let i = 0; i < data.points.length - 1; i += 2) { - const x = data.points[i]; - const y = data.points[i + 1]; + fittedPoints = fitPoints(this.shapeType, data.points, width, height); - checkObjectType('coordinate', x, 'number', null); - checkObjectType('coordinate', y, 'number', null); - - fittedPoints.push( - Math.clamp(x, 0, width), - Math.clamp(y, 0, height), - ); - } - - if (!checkShapeArea(this.shapeType, fittedPoints)) { + if ((!checkShapeArea(this.shapeType, fittedPoints)) || checkOutside(fittedPoints, width, height)) { fittedPoints = []; } } @@ -1378,6 +1402,127 @@ } } + class CuboidShape extends PolyShape { + constructor(data, clientID, color, injection) { + super(data, clientID, color, injection); + this.shapeType = ObjectShape.CUBOID; + this.pinned = false; + checkNumberOfPoints(this.shapeType, this.points); + } + + static makeHull(geoPoints) { + // Returns the convex hull, assuming that each points[i] <= points[i + 1]. + function makeHullPresorted(points) { + if (points.length <= 1) return points.slice(); + + // Andrew's monotone chain algorithm. Positive y coordinates correspond to 'up' + // as per the mathematical convention, instead of 'down' as per the computer + // graphics convention. This doesn't affect the correctness of the result. + + const upperHull = []; + for (let i = 0; i < points.length; i += 1) { + const p = points[`${i}`]; + while (upperHull.length >= 2) { + const q = upperHull[upperHull.length - 1]; + const r = upperHull[upperHull.length - 2]; + if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) upperHull.pop(); + else break; + } + upperHull.push(p); + } + upperHull.pop(); + + const lowerHull = []; + for (let i = points.length - 1; i >= 0; i -= 1) { + const p = points[`${i}`]; + while (lowerHull.length >= 2) { + const q = lowerHull[lowerHull.length - 1]; + const r = lowerHull[lowerHull.length - 2]; + if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) lowerHull.pop(); + else break; + } + lowerHull.push(p); + } + lowerHull.pop(); + + if (upperHull.length + === 1 && lowerHull.length + === 1 && upperHull[0].x + === lowerHull[0].x && upperHull[0].y + === lowerHull[0].y) return upperHull; + return upperHull.concat(lowerHull); + } + + function POINT_COMPARATOR(a, b) { + if (a.x < b.x) return -1; + if (a.x > b.x) return +1; + if (a.y < b.y) return -1; + if (a.y > b.y) return +1; + return 0; + } + + const newPoints = geoPoints.slice(); + newPoints.sort(POINT_COMPARATOR); + return makeHullPresorted(newPoints); + } + + static contain(points, x, y) { + function isLeft(P0, P1, P2) { + return ((P1.x - P0.x) * (P2.y - P0.y) - (P2.x - P0.x) * (P1.y - P0.y)); + } + points = CuboidShape.makeHull(points); + let wn = 0; + for (let i = 0; i < points.length; i += 1) { + const p1 = points[`${i}`]; + const p2 = points[i + 1] || points[0]; + + if (p1.y <= y) { + if (p2.y > y) { + if (isLeft(p1, p2, { x, y }) > 0) { + wn += 1; + } + } + } else if (p2.y < y) { + if (isLeft(p1, p2, { x, y }) < 0) { + wn -= 1; + } + } + } + + return wn !== 0; + } + + static distance(actualPoints, x, y) { + const points = []; + + for (let i = 0; i < 16; i += 2) { + points.push({ x: actualPoints[i], y: actualPoints[i + 1] }); + } + + if (!CuboidShape.contain(points, x, y)) return null; + + let minDistance = Number.MAX_SAFE_INTEGER; + for (let i = 0; i < points.length; i += 1) { + const p1 = points[`${i}`]; + const p2 = points[i + 1] || points[0]; + + // perpendicular from point to straight length + const distance = (Math.abs((p2.y - p1.y) * x + - (p2.x - p1.x) * y + p2.x * p1.y - p2.y * p1.x)) + / Math.sqrt(Math.pow(p2.y - p1.y, 2) + Math.pow(p2.x - p1.x, 2)); + + // check if perpendicular belongs to the straight segment + const a = Math.pow(p1.x - x, 2) + Math.pow(p1.y - y, 2); + const b = Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2); + const c = Math.pow(p2.x - x, 2) + Math.pow(p2.y - y, 2); + if (distance < minDistance && (a + b - c) >= 0 && (c + b - a) >= 0) { + minDistance = distance; + } + } + return minDistance; + } + } + class RectangleTrack extends Track { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); @@ -1389,20 +1534,15 @@ } interpolatePosition(leftPosition, rightPosition, offset) { - const positionOffset = [ - rightPosition.points[0] - leftPosition.points[0], - rightPosition.points[1] - leftPosition.points[1], - rightPosition.points[2] - leftPosition.points[2], - rightPosition.points[3] - leftPosition.points[3], - ]; - - return { // xtl, ytl, xbr, ybr - points: [ - leftPosition.points[0] + positionOffset[0] * offset, - leftPosition.points[1] + positionOffset[1] * offset, - leftPosition.points[2] + positionOffset[2] * offset, - leftPosition.points[3] + positionOffset[3] * offset, - ], + + const positionOffset = leftPosition.points.map((point, index) => ( + rightPosition.points[index] - point + )) + + return { + points: leftPosition.points.map((point ,index) => ( + point + positionOffset[index] * offset + )), occluded: leftPosition.occluded, outside: leftPosition.outside, zOrder: leftPosition.zOrder, @@ -1827,20 +1967,35 @@ } } + class CuboidTrack extends PolyTrack { + constructor(data, clientID, color, injection) { + super(data, clientID, color, injection); + this.shapeType = ObjectShape.CUBOID; + this.pinned = false; + for (const shape of Object.values(this.shapes)) { + checkNumberOfPoints(this.shapeType, shape.points); + } + } + } + RectangleTrack.distance = RectangleShape.distance; PolygonTrack.distance = PolygonShape.distance; PolylineTrack.distance = PolylineShape.distance; PointsTrack.distance = PointsShape.distance; + CuboidTrack.distance = CuboidShape.distance; + CuboidTrack.interpolatePosition = RectangleTrack.interpolatePosition; module.exports = { RectangleShape, PolygonShape, PolylineShape, PointsShape, + CuboidShape, RectangleTrack, PolygonTrack, PolylineTrack, PointsTrack, + CuboidTrack, Track, Shape, Tag, diff --git a/cvat-core/src/enums.js b/cvat-core/src/enums.js index feef4825bdb0..545eb1222b27 100644 --- a/cvat-core/src/enums.js +++ b/cvat-core/src/enums.js @@ -93,6 +93,7 @@ * @property {string} POLYGON 'polygon' * @property {string} POLYLINE 'polyline' * @property {string} POINTS 'points' + * @property {string} CUBOID 'cuboid' * @readonly */ const ObjectShape = Object.freeze({ @@ -100,6 +101,7 @@ POLYGON: 'polygon', POLYLINE: 'polyline', POINTS: 'points', + CUBOID: 'cuboid', }); /** diff --git a/cvat-core/src/statistics.js b/cvat-core/src/statistics.js index 36910eb7f14c..8e9974461f7b 100644 --- a/cvat-core/src/statistics.js +++ b/cvat-core/src/statistics.js @@ -34,6 +34,10 @@ * tracks: 19, * shapes: 20, * }, + * cuboids: { + * tracks: 21, + * shapes: 22, + * }, * tags: 66, * manually: 186, * interpolated: 500, @@ -69,6 +73,10 @@ * tracks: 19, * shapes: 20, * }, + * cuboids: { + * tracks: 21, + * shapes: 22, + * }, * tags: 66, * manually: 186, * interpolated: 500, diff --git a/cvat-core/tests/api/annotations.js b/cvat-core/tests/api/annotations.js index ae0572cfe38b..e02f3c5a6fbb 100644 --- a/cvat-core/tests/api/annotations.js +++ b/cvat-core/tests/api/annotations.js @@ -25,7 +25,7 @@ describe('Feature: get annotations', () => { const task = (await window.cvat.tasks.get({ id: 100 }))[0]; const annotations = await task.annotations.get(0); expect(Array.isArray(annotations)).toBeTruthy(); - expect(annotations).toHaveLength(11); + expect(annotations).toHaveLength(12); for (const state of annotations) { expect(state).toBeInstanceOf(window.cvat.classes.ObjectState); } @@ -692,7 +692,7 @@ describe('Feature: get statistics', () => { await task.annotations.clear(true); const statistics = await task.annotations.statistics(); expect(statistics).toBeInstanceOf(window.cvat.classes.Statistics); - expect(statistics.total.total).toBe(29); + expect(statistics.total.total).toBe(30); }); test('get statistics from a job', async () => { @@ -719,6 +719,9 @@ describe('Feature: select object', () => { result = await task.annotations.select(annotations, 613, 811); expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.POLYGON); expect(result.state.points.length).toBe(94); + result = await task.annotations.select(annotations, 600, 900); + expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.CUBOID); + expect(result.state.points.length).toBe(16); }); test('select object in a job', async () => { @@ -733,6 +736,9 @@ describe('Feature: select object', () => { result = await job.annotations.select(annotations, 1490, 237); expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.POLYGON); expect(result.state.points.length).toBe(94); + result = await job.annotations.select(annotations, 600, 900); + expect(result.state.shapeType).toBe(window.cvat.enums.ObjectShape.CUBOID); + expect(result.state.points.length).toBe(16); }); test('trying to select from not object states', async () => { diff --git a/cvat-core/tests/mocks/dummy-data.mock.js b/cvat-core/tests/mocks/dummy-data.mock.js index b4a6ea223ca9..d5c65e9bade9 100644 --- a/cvat-core/tests/mocks/dummy-data.mock.js +++ b/cvat-core/tests/mocks/dummy-data.mock.js @@ -2514,6 +2514,35 @@ const taskAnnotationsDummyData = { "label_id": 2, "group": 0, "attributes": [] + }, { + "type": "cuboid", + "occluded": false, + "z_order":12, + "points": [ + 37.037109375, + 834.1583663313359, + 37.037109375, + 1005.6748046875, + 500.1052119006872, + 850.3421313142153, + 500.1052119006872, + 1021.9585696703798, + 600.6842465753452, + 763.1514501284273, + 600.6842465753452, + 934.6678884845915, + 137.82724152601259, + 747.0278858154179, + 137.82724152601259, + 918.4444406426646, + ], + "id": 137, + "frame": 0, + "label_id": 1, + "group": 0, + "attributes": [ + + ] }], "tracks":[] } diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 305916703caa..b88b93123a0d 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -98,12 +98,14 @@ async function jobInfoGenerator(job: any): Promise> { 'track count': total.rectangle.shape + total.rectangle.track + total.polygon.shape + total.polygon.track + total.polyline.shape + total.polyline.track - + total.points.shape + total.points.track, + + total.points.shape + total.points.track + + total.cuboid.shape + total.cuboid.track, 'object count': total.total, 'box count': total.rectangle.shape + total.rectangle.track, 'polygon count': total.polygon.shape + total.polygon.track, 'polyline count': total.polyline.shape + total.polyline.track, 'points count': total.points.shape + total.points.track, + 'cuboids count': total.cuboid.shape + total.cuboid.track, 'tag count': total.tags, }; } @@ -1059,6 +1061,8 @@ export function rememberObject( activeControl = ActiveControl.DRAW_POLYLINE; } else if (shapeType === ShapeType.POINTS) { activeControl = ActiveControl.DRAW_POINTS; + } else if (shapeType === ShapeType.CUBOID) { + activeControl = ActiveControl.DRAW_CUBOID; } return { @@ -1386,6 +1390,8 @@ export function pasteShapeAsync(): ThunkAction, {}, {}, AnyAction> activeControl = ActiveControl.DRAW_POLYGON; } else if (initialState.shapeType === ShapeType.POLYLINE) { activeControl = ActiveControl.DRAW_POLYLINE; + } else if (initialState.shapeType === ShapeType.CUBOID) { + activeControl = ActiveControl.DRAW_CUBOID; } dispatch({ @@ -1447,6 +1453,8 @@ export function repeatDrawShapeAsync(): ThunkAction, {}, {}, AnyAc activeControl = ActiveControl.DRAW_POLYGON; } else if (activeShapeType === ShapeType.POLYLINE) { activeControl = ActiveControl.DRAW_POLYLINE; + } else if (activeShapeType === ShapeType.CUBOID) { + activeControl = ActiveControl.DRAW_CUBOID; } dispatch({ diff --git a/cvat-ui/src/actions/settings-actions.ts b/cvat-ui/src/actions/settings-actions.ts index 9a1579678c32..d84434408e32 100644 --- a/cvat-ui/src/actions/settings-actions.ts +++ b/cvat-ui/src/actions/settings-actions.ts @@ -18,6 +18,7 @@ export enum SettingsActionTypes { CHANGE_SELECTED_SHAPES_OPACITY = 'CHANGE_SELECTED_SHAPES_OPACITY', CHANGE_SHAPES_COLOR_BY = 'CHANGE_SHAPES_COLOR_BY', CHANGE_SHAPES_BLACK_BORDERS = 'CHANGE_SHAPES_BLACK_BORDERS', + CHANGE_SHAPES_SHOW_PROJECTIONS = 'CHANGE_SHAPES_SHOW_PROJECTIONS', CHANGE_SHOW_UNLABELED_REGIONS = 'CHANGE_SHOW_UNLABELED_REGIONS', CHANGE_FRAME_STEP = 'CHANGE_FRAME_STEP', CHANGE_FRAME_SPEED = 'CHANGE_FRAME_SPEED', @@ -78,6 +79,15 @@ export function changeShowBitmap(showBitmap: boolean): AnyAction { }; } +export function changeShowProjections(showProjections: boolean): AnyAction { + return { + type: SettingsActionTypes.CHANGE_SHAPES_SHOW_PROJECTIONS, + payload: { + showProjections, + }, + }; +} + export function switchRotateAll(rotateAll: boolean): AnyAction { return { type: SettingsActionTypes.SWITCH_ROTATE_ALL, diff --git a/cvat-ui/src/assets/cube-icon.svg b/cvat-ui/src/assets/cube-icon.svg new file mode 100644 index 000000000000..991c585f654a --- /dev/null +++ b/cvat-ui/src/assets/cube-icon.svg @@ -0,0 +1 @@ + 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 aba0d14a9b81..bbf15c62e9cb 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 @@ -44,6 +44,7 @@ interface Props { selectedOpacity: number; blackBorders: boolean; showBitmap: boolean; + showProjections: boolean; grid: boolean; gridSize: number; gridColor: GridColor; @@ -145,15 +146,18 @@ export default class CanvasWrapperComponent extends React.PureComponent { frameFetching, showObjectsTextAlways, automaticBordering, + showProjections, } = this.props; if (prevProps.showObjectsTextAlways !== showObjectsTextAlways || prevProps.automaticBordering !== automaticBordering + || prevProps.showProjections !== showProjections ) { canvasInstance.configure({ undefinedAttrValue: consts.UNDEFINED_ATTRIBUTE_VALUE, displayAllText: showObjectsTextAlways, autoborders: automaticBordering, + showProjections, }); } @@ -540,7 +544,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { } = this.props; const [state] = annotations.filter((el: any) => (el.clientID === activatedStateID)); - if (state.shapeType !== ShapeType.RECTANGLE) { + if (![ShapeType.CUBOID, ShapeType.RECTANGLE].includes(state.shapeType)) { onUpdateContextMenu(activatedStateID !== null, e.detail.mouseEvent.clientX, e.detail.mouseEvent.clientY, ContextMenuType.CANVAS_SHAPE_POINT, e.detail.pointID); } 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 472f9b2184e4..0d82144d1a75 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 @@ -18,6 +18,7 @@ import DrawRectangleControl from './draw-rectangle-control'; import DrawPolygonControl from './draw-polygon-control'; import DrawPolylineControl from './draw-polyline-control'; import DrawPointsControl from './draw-points-control'; +import DrawCuboidControl from './draw-cuboid-control'; import SetupTagControl from './setup-tag-control'; import MergeControl from './merge-control'; import GroupControl from './group-control'; @@ -80,7 +81,8 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { SWITCH_DRAW_MODE: (event: KeyboardEvent | undefined) => { preventDefault(event); const drawing = [ActiveControl.DRAW_POINTS, ActiveControl.DRAW_POLYGON, - ActiveControl.DRAW_POLYLINE, ActiveControl.DRAW_RECTANGLE].includes(activeControl); + ActiveControl.DRAW_POLYLINE, ActiveControl.DRAW_RECTANGLE, + ActiveControl.DRAW_CUBOID].includes(activeControl); if (!drawing) { canvasInstance.cancel(); @@ -177,7 +179,10 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { canvasInstance={canvasInstance} isDrawing={activeControl === ActiveControl.DRAW_POINTS} /> - + { + canvasInstance.draw({ enabled: false }); + }, + } : {}; + + return ( + + )} + > + + + ); +} + +export default React.memo(DrawPolygonControl); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx index fb10a7e396ab..4b0d716b4121 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx @@ -48,7 +48,7 @@ function DrawShapePopoverComponent(props: Props): JSX.Element { } = props; const trackDisabled = shapeType === ShapeType.POLYGON || shapeType === ShapeType.POLYLINE - || (shapeType === ShapeType.POINTS && numberOfPoints !== 1); + || shapeType === ShapeType.CUBOID || (shapeType === ShapeType.POINTS && numberOfPoints !== 1); return (
@@ -85,7 +85,7 @@ function DrawShapePopoverComponent(props: Props): JSX.Element { shapeType === ShapeType.POLYGON && } { - shapeType === ShapeType.RECTANGLE ? ( + shapeType === ShapeType.RECTANGLE && ( <> @@ -115,7 +115,10 @@ function DrawShapePopoverComponent(props: Props): JSX.Element { - ) : ( + ) + } + { + shapeType !== ShapeType.RECTANGLE && shapeType !== ShapeType.CUBOID && ( Number of points: diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/appearance-block.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/appearance-block.tsx index 5bad50701c8b..d42d7a42d891 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/appearance-block.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/appearance-block.tsx @@ -18,6 +18,7 @@ interface Props { selectedOpacity: number; blackBorders: boolean; showBitmap: boolean; + showProjections: boolean; collapseAppearance(): void; changeShapesColorBy(event: RadioChangeEvent): void; @@ -25,6 +26,7 @@ interface Props { changeSelectedShapesOpacity(event: SliderValue): void; changeShapesBlackBorders(event: CheckboxChangeEvent): void; changeShowBitmap(event: CheckboxChangeEvent): void; + changeShowProjections(event: CheckboxChangeEvent): void; } function AppearanceBlock(props: Props): JSX.Element { @@ -35,12 +37,14 @@ function AppearanceBlock(props: Props): JSX.Element { selectedOpacity, blackBorders, showBitmap, + showProjections, collapseAppearance, changeShapesColorBy, changeShapesOpacity, changeSelectedShapesOpacity, changeShapesBlackBorders, changeShowBitmap, + changeShowProjections, } = props; return ( @@ -88,6 +92,12 @@ function AppearanceBlock(props: Props): JSX.Element { > Show bitmap + + Show projections +
diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx index 643cec6d8183..eb3194e3db7a 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx @@ -25,6 +25,7 @@ interface Props { selectedOpacity: number; blackBorders: boolean; showBitmap: boolean; + showProjections: boolean; collapseSidebar(): void; collapseAppearance(): void; @@ -34,6 +35,7 @@ interface Props { changeSelectedShapesOpacity(event: SliderValue): void; changeShapesBlackBorders(event: CheckboxChangeEvent): void; changeShowBitmap(event: CheckboxChangeEvent): void; + changeShowProjections(event: CheckboxChangeEvent): void; } function ObjectsSideBar(props: Props): JSX.Element { @@ -45,6 +47,7 @@ function ObjectsSideBar(props: Props): JSX.Element { selectedOpacity, blackBorders, showBitmap, + showProjections, collapseSidebar, collapseAppearance, changeShapesColorBy, @@ -52,6 +55,7 @@ function ObjectsSideBar(props: Props): JSX.Element { changeSelectedShapesOpacity, changeShapesBlackBorders, changeShowBitmap, + changeShowProjections, } = props; const appearanceProps = { @@ -62,12 +66,14 @@ function ObjectsSideBar(props: Props): JSX.Element { selectedOpacity, blackBorders, showBitmap, + showProjections, changeShapesColorBy, changeShapesOpacity, changeSelectedShapesOpacity, changeShapesBlackBorders, changeShowBitmap, + changeShowProjections, }; return ( 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 d27502a57ad7..f5c48061421b 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 @@ -65,6 +65,7 @@ interface StateToProps { selectedOpacity: number; blackBorders: boolean; showBitmap: boolean; + showProjections: boolean; grid: boolean; gridSize: number; gridColor: GridColor; @@ -180,6 +181,7 @@ function mapStateToProps(state: CombinedState): StateToProps { selectedOpacity, blackBorders, showBitmap, + showProjections, }, }, shortcuts: { @@ -204,6 +206,7 @@ function mapStateToProps(state: CombinedState): StateToProps { selectedOpacity, blackBorders, showBitmap, + showProjections, grid, gridSize, gridColor, diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx index 6324d9a34716..8633ae6c5c2b 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx @@ -121,7 +121,7 @@ class DrawShapePopoverContainer extends React.PureComponent { rectDrawingMethod, numberOfPoints, shapeType, - crosshair: shapeType === ShapeType.RECTANGLE, + crosshair: [ShapeType.RECTANGLE, ShapeType.CUBOID].includes(shapeType), }); onDrawStart(shapeType, selectedLabelID, diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx index 309700cc715f..906e0fc2835f 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar.tsx @@ -28,6 +28,7 @@ import { changeSelectedShapesOpacity as changeSelectedShapesOpacityAction, changeShapesBlackBorders as changeShapesBlackBordersAction, changeShowBitmap as changeShowUnlabeledRegionsAction, + changeShowProjections as changeShowProjectionsAction, } from 'actions/settings-actions'; @@ -39,6 +40,7 @@ interface StateToProps { selectedOpacity: number; blackBorders: boolean; showBitmap: boolean; + showProjections: boolean; } interface DispatchToProps { @@ -50,6 +52,7 @@ interface DispatchToProps { changeSelectedShapesOpacity(selectedShapesOpacity: number): void; changeShapesBlackBorders(blackBorders: boolean): void; changeShowBitmap(showBitmap: boolean): void; + changeShowProjections(showProjections: boolean): void; } function mapStateToProps(state: CombinedState): StateToProps { @@ -65,6 +68,7 @@ function mapStateToProps(state: CombinedState): StateToProps { selectedOpacity, blackBorders, showBitmap, + showProjections, }, }, } = state; @@ -77,6 +81,7 @@ function mapStateToProps(state: CombinedState): StateToProps { selectedOpacity, blackBorders, showBitmap, + showProjections, }; } @@ -140,6 +145,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { changeShowBitmap(showBitmap: boolean) { dispatch(changeShowUnlabeledRegionsAction(showBitmap)); }, + changeShowProjections(showProjections: boolean) { + dispatch(changeShowProjectionsAction(showProjections)); + }, }; } @@ -190,6 +198,11 @@ class ObjectsSideBarContainer extends React.PureComponent { changeShowBitmap(event.target.checked); }; + private changeShowProjections = (event: CheckboxChangeEvent): void => { + const { changeShowProjections } = this.props; + changeShowProjections(event.target.checked); + }; + public render(): JSX.Element { const { sidebarCollapsed, @@ -199,6 +212,7 @@ class ObjectsSideBarContainer extends React.PureComponent { selectedOpacity, blackBorders, showBitmap, + showProjections, collapseSidebar, collapseAppearance, } = this.props; @@ -212,6 +226,7 @@ class ObjectsSideBarContainer extends React.PureComponent { selectedOpacity={selectedOpacity} blackBorders={blackBorders} showBitmap={showBitmap} + showProjections={showProjections} collapseSidebar={collapseSidebar} collapseAppearance={collapseAppearance} changeShapesColorBy={this.changeShapesColorBy} @@ -219,6 +234,7 @@ class ObjectsSideBarContainer extends React.PureComponent { changeSelectedShapesOpacity={this.changeSelectedShapesOpacity} changeShapesBlackBorders={this.changeShapesBlackBorders} changeShowBitmap={this.changeShowBitmap} + changeShowProjections={this.changeShowProjections} /> ); } diff --git a/cvat-ui/src/icons.tsx b/cvat-ui/src/icons.tsx index c7cb9fb3d7af..8df3ebd45a73 100644 --- a/cvat-ui/src/icons.tsx +++ b/cvat-ui/src/icons.tsx @@ -39,6 +39,7 @@ import SVGObjectOutsideIcon from './assets/object-outside-icon.svg'; import SVGObjectInsideIcon from './assets/object-inside-icon.svg'; import SVGBackgroundIcon from './assets/background-icon.svg'; import SVGForegroundIcon from './assets/foreground-icon.svg'; +import SVGCubeIcon from './assets/cube-icon.svg'; export const CVATLogo = React.memo( (): JSX.Element => , @@ -145,3 +146,6 @@ export const BackgroundIcon = React.memo( export const ForegroundIcon = React.memo( (): JSX.Element => , ); +export const CubeIcon = React.memo( + (): JSX.Element => , +); diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 2b28dfc1c216..63d46154c9ea 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -262,6 +262,7 @@ export enum ActiveControl { DRAW_POLYGON = 'draw_polygon', DRAW_POLYLINE = 'draw_polyline', DRAW_POINTS = 'draw_points', + DRAW_CUBOID = 'draw_cuboid', MERGE = 'merge', GROUP = 'group', SPLIT = 'split', @@ -273,6 +274,7 @@ export enum ShapeType { POLYGON = 'polygon', POLYLINE = 'polyline', POINTS = 'points', + CUBOID = 'cuboid', } export enum ObjectType { @@ -441,6 +443,7 @@ export interface ShapesSettingsState { selectedOpacity: number; blackBorders: boolean; showBitmap: boolean; + showProjections: boolean; } export interface SettingsState { diff --git a/cvat-ui/src/reducers/settings-reducer.ts b/cvat-ui/src/reducers/settings-reducer.ts index f38902e09a03..68d393682607 100644 --- a/cvat-ui/src/reducers/settings-reducer.ts +++ b/cvat-ui/src/reducers/settings-reducer.ts @@ -23,6 +23,7 @@ const defaultState: SettingsState = { selectedOpacity: 30, blackBorders: false, showBitmap: false, + showProjections: false, }, workspace: { autoSave: false, @@ -130,6 +131,15 @@ export default (state = defaultState, action: AnyAction): SettingsState => { }, }; } + case SettingsActionTypes.CHANGE_SHAPES_SHOW_PROJECTIONS: { + return { + ...state, + shapes: { + ...state.shapes, + showProjections: action.payload.showProjections, + }, + }; + } case SettingsActionTypes.CHANGE_SHOW_UNLABELED_REGIONS: { return { ...state,