diff --git a/.vscode/settings.json b/.vscode/settings.json index 47941676..4cb0fb70 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "python.pythonPath": "python", + "python.pythonPath": ".env/bin/python", "eslint.enable": true, "eslint.validate": [ "javascript", diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md index f68c7b88..f61c3f87 100644 --- a/cvat-canvas/README.md +++ b/cvat-canvas/README.md @@ -26,7 +26,7 @@ Canvas is created by using constructor: ```js const { Canvas } = require('./canvas'); - const canvas = new Canvas(); + const canvas = new Canvas(ObjectStateClass); ``` - Canvas has transparent background @@ -45,6 +45,14 @@ Canvas itself handles: All methods are sync. ```ts + interface DrawData { + enabled: boolean; + shapeType?: string; + numberOfPoints?: number; + initialState?: any; + crosshair?: boolean; + } + html(): HTMLDivElement; setup(frameData: FrameData, objectStates: ObjectState): void; activate(clientID: number, attributeID?: number): void; @@ -53,25 +61,27 @@ All methods are sync. fit(): void; grid(stepX: number, stepY: number): void; - draw(enabled?: boolean, shapeType?: string, numberOfPoints?: number, initialState?: any): void | ObjectState; - split(enabled?: boolean): void | ObjectState; - group(enabled?: boolean): void | ObjectState; - merge(enabled?: boolean): void | ObjectState; + draw(drawData: DrawData): void; + split(enabled?: boolean): void; + group(enabled?: boolean): void; + merge(enabled?: boolean): void; cancel(): any; ``` ### CSS Classes/IDs -- Each drawn object (tag, shape, track) has id ```cvat_canvas_object_{objectState.id}``` +- All drawn objects (shapes, tracks) have an id ```cvat_canvas_object_{objectState.id}``` - Drawn shapes and tracks have classes ```cvat_canvas_shape```, ```cvat_canvas_shape_activated```, ```cvat_canvas_shape_grouping```, ```cvat_canvas_shape_merging```, ```cvat_canvas_shape_drawing``` -- Tags has a class ```cvat_canvas_tag``` +- Drawn texts have the class ```cvat_canvas_text``` +- Tags have the class ```cvat_canvas_tag``` - Canvas image has ID ```cvat_canvas_image``` - Grid on the canvas has ID ```cvat_canvas_grid_pattern``` +- Crosshair during a draw has class ```cvat_canvas_crosshair``` ### Events @@ -86,6 +96,7 @@ Standard JS events are used. - canvas.splitted => ObjectState - canvas.groupped => [ObjectState] - canvas.merged => [ObjectState] + - canvas.canceled ``` ## States @@ -94,7 +105,7 @@ Standard JS events are used. ## API Reaction -| | FREE | GROUPING | SPLITTING | DRAWING | MERGING | EDITING | +| | IDLE | GROUPING | SPLITTING | DRAWING | MERGING | EDITING | |------------|------|----------|-----------|---------|---------|---------| | html() | + | + | + | + | + | + | | setup() | + | + | + | + | + | - | diff --git a/cvat-canvas/dist/canvas.css b/cvat-canvas/dist/canvas.css index 040d834c..f34d991e 100644 --- a/cvat-canvas/dist/canvas.css +++ b/cvat-canvas/dist/canvas.css @@ -12,6 +12,21 @@ polyline.cvat_canvas_shape { stroke-opacity: 1; } +.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; + text-shadow: 0px 0px 4px black; + user-select: none; + pointer-events: none; +} + +.cvat_canvas_crosshair { + stroke: red; +} + .cvat_canvas_shape_activated { } @@ -25,7 +40,10 @@ polyline.cvat_canvas_shape { } .cvat_canvas_shape_drawing { - + fill-opacity: 0.1; + stroke-opacity: 1; + fill: white; + stroke: black; } .svg_select_boundingRect { @@ -35,26 +53,18 @@ polyline.cvat_canvas_shape { #cvat_canvas_wrapper { width: 100%; - height: 80%; - border: 1px black solid; + height: 93%; border-radius: 5px; - background-color: #B0C4DE; + background-color: white; overflow: hidden; position: relative; } -#cvat_canvas_rotation_wrapper { - width: 100%; - height: 100%; - position: relative; -} - #cvat_canvas_loading_animation { z-index: 1; position: absolute; width: 100%; height: 100%; - transform-origin: top left; } #cvat_canvas_loading_circle { @@ -68,7 +78,6 @@ polyline.cvat_canvas_shape { #cvat_canvas_text_content { position: absolute; z-index: 3; - transform-origin: center center; pointer-events: none; width: 100%; height: 100%; @@ -79,15 +88,14 @@ polyline.cvat_canvas_shape { position: absolute; z-index: 0; background-repeat: no-repeat; - transform-origin: top left; width: 100%; height: 100%; + box-shadow: 2px 2px 5px 0px rgba(0,0,0,0.75); } #cvat_canvas_grid { position: absolute; z-index: 2; - transform-origin: top left; pointer-events: none; width: 100%; height: 100%; @@ -103,7 +111,6 @@ polyline.cvat_canvas_shape { position: absolute; z-index: 2; outline: 10px solid black; - transform-origin: top left; width: 100%; height: 100%; } diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index a8ad99f8..de8eea6b 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -10,10 +10,11 @@ "author": "Intel", "license": "MIT", "dependencies": { - "svg.draggable.js": "^2.2.2", - "svg.js": "^2.7.1", - "svg.resize.js": "^1.4.3", - "svg.select.js": "^3.0.1" + "svg.draggable.js": "2.2.2", + "svg.draw.js": "^2.0.3", + "svg.js": "2.7.1", + "svg.resize.js": "1.4.3", + "svg.select.js": "3.0.1" }, "devDependencies": { "@babel/cli": "^7.5.5", diff --git a/cvat-canvas/src/canvas.ts b/cvat-canvas/src/canvas.ts index 3b4d9dd5..14782a1b 100644 --- a/cvat-canvas/src/canvas.ts +++ b/cvat-canvas/src/canvas.ts @@ -3,9 +3,22 @@ * SPDX-License-Identifier: MIT */ -import { CanvasController, CanvasControllerImpl } from './canvasController'; -import { CanvasModel, CanvasModelImpl, Rotation } from './canvasModel'; -import { CanvasView, CanvasViewImpl } from './canvasView'; +import { + CanvasModel, + CanvasModelImpl, + Rotation, + DrawData, +} from './canvasModel'; + +import { + CanvasController, + CanvasControllerImpl, +} from './canvasController'; + +import { + CanvasView, + CanvasViewImpl, +} from './canvasView'; interface Canvas { html(): HTMLDivElement; @@ -16,10 +29,10 @@ interface Canvas { fit(): void; grid(stepX: number, stepY: number): void; - draw(enabled?: boolean, shapeType?: string, numberOfPoints?: number, initialState?: any): any; - split(enabled?: boolean): any; - group(enabled?: boolean): any; - merge(enabled?: boolean): any; + draw(drawData: DrawData): void; + split(enabled?: boolean): void; + group(enabled?: boolean): void; + merge(enabled?: boolean): void; cancel(): void; } @@ -29,8 +42,8 @@ class CanvasImpl implements Canvas { private controller: CanvasController; private view: CanvasView; - public constructor() { - this.model = new CanvasModelImpl(); + public constructor(ObjectStateClass: any) { + this.model = new CanvasModelImpl(ObjectStateClass); this.controller = new CanvasControllerImpl(this.model); this.view = new CanvasViewImpl(this.model, this.controller); } @@ -63,21 +76,20 @@ class CanvasImpl implements Canvas { this.model.grid(stepX, stepY); } - public draw(enabled: boolean = false, shapeType: string = '', - numberOfPoints: number = 0, initialState: any = null): any { - return this.model.draw(enabled, shapeType, numberOfPoints, initialState); + public draw(drawData: DrawData): void { + this.model.draw(drawData); } - public split(enabled: boolean = false): any { - return this.model.split(enabled); + public split(enabled: boolean = false): void { + this.model.split(enabled); } - public group(enabled: boolean = false): any { - return this.model.group(enabled); + public group(enabled: boolean = false): void { + this.model.group(enabled); } - public merge(enabled: boolean = false): any { - return this.model.merge(enabled); + public merge(enabled: boolean = false): void { + this.model.merge(enabled); } public cancel(): void { diff --git a/cvat-canvas/src/canvasController.ts b/cvat-canvas/src/canvasController.ts index 834bd203..824eb238 100644 --- a/cvat-canvas/src/canvasController.ts +++ b/cvat-canvas/src/canvasController.ts @@ -7,15 +7,21 @@ import { CanvasModel, Geometry, Position, - Size, + FocusData, + ActiveElement, + DrawData, } from './canvasModel'; export interface CanvasController { - readonly geometry: Geometry; readonly objects: any[]; - canvasSize: Size; + readonly focusData: FocusData; + readonly activeElement: ActiveElement; + readonly objectStateClass: any; + readonly drawData: DrawData; + geometry: Geometry; zoom(x: number, y: number, direction: number): void; + draw(drawData: DrawData): void; enableDrag(x: number, y: number): void; drag(x: number, y: number): void; disableDrag(): void; @@ -64,19 +70,35 @@ export class CanvasControllerImpl implements CanvasController { this.isDragging = false; } + public draw(drawData: DrawData): void { + this.model.draw(drawData); + } + public get geometry(): Geometry { return this.model.geometry; } + public set geometry(geometry: Geometry) { + this.model.geometry = geometry; + } + public get objects(): any[] { return this.model.objects; } - public set canvasSize(value: Size) { - this.model.canvasSize = value; + public get focusData(): FocusData { + return this.model.focusData; + } + + public get activeElement(): ActiveElement { + return this.model.activeElement; + } + + public get objectStateClass(): any { + return this.model.objectStateClass; } - public get canvasSize(): Size { - return this.model.canvasSize; + public get drawData(): DrawData { + return this.model.drawData; } } diff --git a/cvat-canvas/src/canvasModel.ts b/cvat-canvas/src/canvasModel.ts index 4be46bf2..23128eb1 100644 --- a/cvat-canvas/src/canvasModel.ts +++ b/cvat-canvas/src/canvasModel.ts @@ -21,6 +21,7 @@ export interface Position { export interface Geometry { image: Size; canvas: Size; + grid: Size; top: number; left: number; scale: number; @@ -28,6 +29,24 @@ export interface Geometry { angle: number; } +export interface FocusData { + clientID: number; + padding: number; +} + +export interface ActiveElement { + clientID: number; + attributeID: number; +} + +export interface DrawData { + enabled: boolean; + shapeType?: string; + numberOfPoints?: number; + initialState?: any; + crosshair?: boolean; +} + export enum FrameZoom { MIN = 0.1, MAX = 10, @@ -44,14 +63,21 @@ export enum UpdateReasons { ZOOM = 'zoom', FIT = 'fit', MOVE = 'move', + GRID = 'grid', + FOCUS = 'focus', + ACTIVATE = 'activate', + DRAW = 'draw', } export interface CanvasModel extends MasterImpl { readonly image: string; readonly objects: any[]; + readonly gridSize: Size; + readonly focusData: FocusData; + readonly activeElement: ActiveElement; + readonly objectStateClass: any; + readonly drawData: DrawData; geometry: Geometry; - imageSize: Size; - canvasSize: Size; zoom(x: number, y: number, direction: number): void; move(topOffset: number, leftOffset: number): void; @@ -63,45 +89,69 @@ export interface CanvasModel extends MasterImpl { fit(): void; grid(stepX: number, stepY: number): void; - draw(enabled: boolean, shapeType: string, numberOfPoints: number, initialState: any): any; - split(enabled: boolean): any; - group(enabled: boolean): any; - merge(enabled: boolean): any; + draw(drawData: DrawData): void; + split(enabled: boolean): void; + group(enabled: boolean): void; + merge(enabled: boolean): void; cancel(): void; } export class CanvasModelImpl extends MasterImpl implements CanvasModel { private data: { - image: string; - objects: any[]; - imageSize: Size; + ObjectStateClass: any; + activeElement: ActiveElement; + angle: number; canvasSize: Size; + drawData: DrawData; + image: string; imageOffset: number; - scale: number; - top: number; + imageSize: Size; + focusData: FocusData; + gridSize: Size; left: number; - angle: number; + objects: any[]; rememberAngle: boolean; + scale: number; + top: number; }; - public constructor() { + public constructor(ObjectStateClass: any) { super(); this.data = { + activeElement: { + clientID: null, + attributeID: null, + }, angle: 0, canvasSize: { height: 0, width: 0, }, + drawData: { + enabled: false, + shapeType: null, + numberOfPoints: null, + initialState: null, + }, image: '', imageOffset: 0, imageSize: { height: 0, width: 0, }, + focusData: { + clientID: 0, + padding: 0, + }, + gridSize: { + height: 100, + width: 100, + }, left: 0, objects: [], + ObjectStateClass, rememberAngle: false, scale: 1, top: 0, @@ -112,8 +162,22 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { const oldScale: number = this.data.scale; const newScale: number = direction > 0 ? oldScale * 6 / 5 : oldScale * 5 / 6; this.data.scale = Math.min(Math.max(newScale, FrameZoom.MIN), FrameZoom.MAX); - this.data.left += (x * (oldScale / this.data.scale - 1)) * this.data.scale; - this.data.top += (y * (oldScale / this.data.scale - 1)) * this.data.scale; + + const { angle } = this.data; + + const mutiplier = Math.sin(angle * Math.PI / 180) + Math.cos(angle * Math.PI / 180); + if ((angle / 90) % 2) { + // 90, 270, .. + this.data.top += mutiplier * ((x - this.data.imageSize.width / 2) + * (oldScale / this.data.scale - 1)) * this.data.scale; + this.data.left -= mutiplier * ((y - this.data.imageSize.height / 2) + * (oldScale / this.data.scale - 1)) * this.data.scale; + } else { + this.data.left += mutiplier * ((x - this.data.imageSize.width / 2) + * (oldScale / this.data.scale - 1)) * this.data.scale; + this.data.top += mutiplier * ((y - this.data.imageSize.height / 2) + * (oldScale / this.data.scale - 1)) * this.data.scale; + } this.notify(UpdateReasons.ZOOM); } @@ -152,7 +216,12 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { } public activate(clientID: number, attributeID: number): void { - console.log(clientID, attributeID); + this.data.activeElement = { + clientID, + attributeID, + }; + + this.notify(UpdateReasons.ACTIVATE); } public rotate(rotation: Rotation, remember: boolean = false): void { @@ -168,7 +237,12 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { } public focus(clientID: number, padding: number): void { - console.log(clientID, padding); + this.data.focusData = { + clientID, + padding, + }; + + this.notify(UpdateReasons.FOCUS); } public fit(): void { @@ -192,26 +266,38 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { FrameZoom.MAX, ); - this.data.top = (this.data.canvasSize.height - - this.data.imageSize.height * this.data.scale) / 2; - this.data.left = (this.data.canvasSize.width - - this.data.imageSize.width * this.data.scale) / 2; + this.data.top = (this.data.canvasSize.height / 2 - this.data.imageSize.height / 2); + this.data.left = (this.data.canvasSize.width / 2 - this.data.imageSize.width / 2); this.notify(UpdateReasons.FIT); } public grid(stepX: number, stepY: number): void { - console.log(stepX, stepY); + this.data.gridSize = { + height: stepY, + width: stepX, + }; + + this.notify(UpdateReasons.GRID); } - public draw(enabled: boolean, shapeType: string, - numberOfPoints: number, initialState: any): any { - return { - enabled, - initialState, - numberOfPoints, - shapeType, - }; + public draw(drawData: DrawData): void { + if (drawData.enabled) { + if (this.data.drawData.enabled) { + throw new Error('Drawing has been already started'); + } else if (!drawData.shapeType) { + throw new Error('A shape type is not specified'); + } else if (typeof (drawData.numberOfPoints) !== 'undefined') { + if (drawData.shapeType === 'polygon' && drawData.numberOfPoints < 3) { + throw new Error('A polygon consists of at least 3 points'); + } else if (drawData.shapeType === 'polyline' && drawData.numberOfPoints < 2) { + throw new Error('A polyline consists of at least 2 points'); + } + } + } + + this.data.drawData = Object.assign({}, drawData); + this.notify(UpdateReasons.DRAW); } public split(enabled: boolean): any { @@ -233,14 +319,9 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { public get geometry(): Geometry { return { angle: this.data.angle, - canvas: { - height: this.data.canvasSize.height, - width: this.data.canvasSize.width, - }, - image: { - height: this.data.imageSize.height, - width: this.data.imageSize.width, - }, + canvas: Object.assign({}, this.data.canvasSize), + image: Object.assign({}, this.data.imageSize), + grid: Object.assign({}, this.data.gridSize), left: this.data.left, offset: this.data.imageOffset, scale: this.data.scale, @@ -248,6 +329,22 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { }; } + public set geometry(geometry: Geometry) { + this.data.angle = geometry.angle; + this.data.canvasSize = Object.assign({}, geometry.canvas); + this.data.imageSize = Object.assign({}, geometry.image); + this.data.gridSize = Object.assign({}, geometry.grid); + this.data.left = geometry.left; + this.data.top = geometry.top; + this.data.imageOffset = geometry.offset; + this.data.scale = geometry.scale; + + this.data.imageOffset = Math.floor(Math.max( + this.data.canvasSize.height / FrameZoom.MIN, + this.data.canvasSize.width / FrameZoom.MIN, + )); + } + public get image(): string { return this.data.image; } @@ -256,36 +353,23 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { return this.data.objects; } - public set imageSize(value: Size) { - this.data.imageSize = { - height: value.height, - width: value.width, - }; + public get gridSize(): Size { + return Object.assign({}, this.data.gridSize); } - public get imageSize(): Size { - return { - height: this.data.imageSize.height, - width: this.data.imageSize.width, - }; + public get focusData(): FocusData { + return Object.assign({}, this.data.focusData); } - public set canvasSize(value: Size) { - this.data.canvasSize = { - height: value.height, - width: value.width, - }; + public get activeElement(): ActiveElement { + return Object.assign({}, this.data.activeElement); + } - this.data.imageOffset = Math.floor(Math.max( - this.data.canvasSize.height / FrameZoom.MIN, - this.data.canvasSize.width / FrameZoom.MIN, - )); + public get objectStateClass(): any { + return this.data.ObjectStateClass; } - public get canvasSize(): Size { - return { - height: this.data.canvasSize.height, - width: this.data.canvasSize.width, - }; + public get drawData(): DrawData { + return Object.assign({}, this.data.drawData); } } diff --git a/cvat-canvas/src/canvasView.ts b/cvat-canvas/src/canvasView.ts index 2b5be0fe..8f04cf4c 100644 --- a/cvat-canvas/src/canvasView.ts +++ b/cvat-canvas/src/canvasView.ts @@ -14,25 +14,39 @@ import 'svg.resize.js'; import 'svg.select.js'; import { CanvasController } from './canvasController'; -import { CanvasModel, Geometry, UpdateReasons } from './canvasModel'; import { Listener, Master } from './master'; +import { DrawHandler, DrawHandlerImpl } from './drawHandler'; +import { translateToSVG, translateFromSVG } from './shared'; +import consts from './consts'; +import { + CanvasModel, + Geometry, + Size, + UpdateReasons, + FocusData, + FrameZoom, + ActiveElement, + DrawData, +} from './canvasModel'; export interface CanvasView { html(): HTMLDivElement; } -function translateToSVG(svg: SVGSVGElement, points: number[]): number[] { - const output = []; - const transformationMatrix = svg.getScreenCTM().inverse(); - let pt = svg.createSVGPoint(); - for (let i = 0; i < points.length; i += 2) { - [pt.x] = points; - [, pt.y] = points; - pt = pt.matrixTransform(transformationMatrix); - output.push(pt.x, pt.y); - } - return output; +interface ShapeDict { + [index: number]: SVG.Shape; +} + +interface TextDict { + [index: number]: SVG.Text; +} + +enum Mode { + IDLE = 'idle', + DRAG = 'drag', + RESIZE = 'resize', + DRAW = 'draw', } function darker(color: string, percentage: number): string { @@ -57,21 +71,52 @@ export class CanvasViewImpl implements CanvasView, Listener { private grid: SVGSVGElement; private content: SVGSVGElement; private adoptedContent: SVG.Container; - private rotationWrapper: HTMLDivElement; private canvas: HTMLDivElement; private gridPath: SVGPathElement; + private gridPattern: SVGPatternElement; private controller: CanvasController; - private svgShapes: SVG.Shape[]; - private svgTexts: SVG.Text[]; - private readonly BASE_STROKE_WIDTH: number; - private readonly BASE_POINT_SIZE: number; + private svgShapes: ShapeDict; + private svgTexts: TextDict; + private drawHandler: DrawHandler; + private activeElement: { + state: any; + attributeID: number; + }; + + private mode: Mode; + + private onDrawDone(data: Record): void { + if (data) { + const event: CustomEvent = new CustomEvent('canvas.drawn', { + bubbles: false, + cancelable: true, + detail: { + // eslint-disable-next-line new-cap + state: new this.controller.objectStateClass(data), + }, + }); + + this.canvas.dispatchEvent(event); + } else { + const event: CustomEvent = new CustomEvent('canvas.canceled', { + bubbles: false, + cancelable: true, + }); + + this.canvas.dispatchEvent(event); + } + + this.controller.draw({ + enabled: false, + }); + } public constructor(model: CanvasModel & Master, controller: CanvasController) { this.controller = controller; - this.BASE_STROKE_WIDTH = 2.5; - this.BASE_POINT_SIZE = 7; - this.svgShapes = []; - this.svgTexts = []; + this.svgShapes = {}; + this.svgTexts = {}; + this.activeElement = null; + this.mode = Mode.IDLE; // Create HTML elements this.loadingAnimation = window.document @@ -82,18 +127,17 @@ export class CanvasViewImpl implements CanvasView, Listener { this.grid = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.gridPath = window.document.createElementNS('http://www.w3.org/2000/svg', 'path'); + this.gridPattern = window.document.createElementNS('http://www.w3.org/2000/svg', 'pattern'); this.content = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.adoptedContent = (SVG.adopt((this.content as any as HTMLElement)) as SVG.Container); - this.rotationWrapper = window.document.createElement('div'); + this.drawHandler = new DrawHandlerImpl(this.onDrawDone.bind(this), this.adoptedContent); this.canvas = window.document.createElement('div'); const loadingCircle: SVGCircleElement = window.document .createElementNS('http://www.w3.org/2000/svg', 'circle'); const gridDefs: SVGDefsElement = window.document .createElementNS('http://www.w3.org/2000/svg', 'defs'); - const gridPattern: SVGPatternElement = window.document - .createElementNS('http://www.w3.org/2000/svg', 'pattern'); const gridRect: SVGRectElement = window.document .createElementNS('http://www.w3.org/2000/svg', 'rect'); @@ -110,10 +154,10 @@ export class CanvasViewImpl implements CanvasView, Listener { this.gridPath.setAttribute('d', 'M 1000 0 L 0 0 0 1000'); this.gridPath.setAttribute('fill', 'none'); this.gridPath.setAttribute('stroke-width', '1.5'); - gridPattern.setAttribute('id', 'cvat_canvas_grid_pattern'); - gridPattern.setAttribute('width', '100'); - gridPattern.setAttribute('height', '100'); - gridPattern.setAttribute('patternUnits', 'userSpaceOnUse'); + this.gridPattern.setAttribute('id', 'cvat_canvas_grid_pattern'); + this.gridPattern.setAttribute('width', '100'); + this.gridPattern.setAttribute('height', '100'); + this.gridPattern.setAttribute('patternUnits', 'userSpaceOnUse'); gridRect.setAttribute('width', '100%'); gridRect.setAttribute('height', '100%'); gridRect.setAttribute('fill', 'url(#cvat_canvas_grid_pattern)'); @@ -124,7 +168,6 @@ export class CanvasViewImpl implements CanvasView, Listener { this.content.setAttribute('id', 'cvat_canvas_content'); // Setup wrappers - this.rotationWrapper.setAttribute('id', 'cvat_canvas_rotation_wrapper'); this.canvas.setAttribute('id', 'cvat_canvas_wrapper'); // Unite created HTML elements together @@ -132,46 +175,52 @@ export class CanvasViewImpl implements CanvasView, Listener { this.grid.appendChild(gridDefs); this.grid.appendChild(gridRect); - gridDefs.appendChild(gridPattern); - gridPattern.appendChild(this.gridPath); + gridDefs.appendChild(this.gridPattern); + this.gridPattern.appendChild(this.gridPath); - this.rotationWrapper.appendChild(this.loadingAnimation); - this.rotationWrapper.appendChild(this.text); - this.rotationWrapper.appendChild(this.background); - this.rotationWrapper.appendChild(this.grid); - this.rotationWrapper.appendChild(this.content); + this.canvas.appendChild(this.loadingAnimation); + this.canvas.appendChild(this.text); + this.canvas.appendChild(this.background); + this.canvas.appendChild(this.grid); + this.canvas.appendChild(this.content); - this.canvas.appendChild(this.rotationWrapper); // A little hack to get size after first mounting // http://www.backalleycoder.com/2012/04/25/i-want-a-damnodeinserted/ const self = this; const canvasFirstMounted = (event: AnimationEvent): void => { if (event.animationName === 'loadingAnimation') { - self.controller.canvasSize = { - height: self.rotationWrapper.clientHeight, - width: self.rotationWrapper.clientWidth, + const { geometry } = this.controller; + geometry.canvas = { + height: self.canvas.clientHeight, + width: self.canvas.clientWidth, }; - self.rotationWrapper.removeEventListener('animationstart', canvasFirstMounted); + this.controller.geometry = geometry; + self.canvas.removeEventListener('animationstart', canvasFirstMounted); } }; this.canvas.addEventListener('animationstart', canvasFirstMounted); + this.content.addEventListener('dblclick', (): void => { self.controller.fit(); }); this.content.addEventListener('mousedown', (event): void => { - self.controller.enableDrag(event.clientX, event.clientY); + if ((event.which === 1 && this.mode === Mode.IDLE) || (event.which === 2)) { + self.controller.enableDrag(event.clientX, event.clientY); + } }); this.content.addEventListener('mousemove', (event): void => { self.controller.drag(event.clientX, event.clientY); }); - window.document.addEventListener('mouseup', (): void => { - self.controller.disableDrag(); + window.document.addEventListener('mouseup', (event): void => { + if (event.which === 1 || event.which === 2) { + self.controller.disableDrag(); + } }); this.content.addEventListener('wheel', (event): void => { @@ -180,6 +229,24 @@ export class CanvasViewImpl implements CanvasView, Listener { event.preventDefault(); }); + this.content.addEventListener('mousemove', (e): void => { + if (this.mode !== Mode.IDLE) return; + + const [x, y] = translateToSVG(this.background, [e.clientX, e.clientY]); + const event: CustomEvent = new CustomEvent('canvas.moved', { + bubbles: false, + cancelable: true, + detail: { + x, + y, + objects: this.controller.objects, + }, + }); + + this.canvas.dispatchEvent(event); + }); + + this.content.oncontextmenu = (): boolean => false; model.subscribe(this); } @@ -187,20 +254,21 @@ export class CanvasViewImpl implements CanvasView, Listener { function transform(geometry: Geometry): void { // Transform canvas for (const obj of [this.background, this.grid, this.loadingAnimation, this.content]) { - obj.style.transform = `scale(${geometry.scale})`; + obj.style.transform = `scale(${geometry.scale}) rotate(${geometry.angle}deg)`; } - this.rotationWrapper.style.transform = `rotate(${geometry.angle}deg)`; + // Transform grid + this.gridPath.setAttribute('stroke-width', `${consts.BASE_STROKE_WIDTH / (2 * geometry.scale)}px`); - // Transform all shapes + // Transform all shape points for (const element of window.document.getElementsByClassName('svg_select_points')) { element.setAttribute( 'stroke-width', - `${this.BASE_STROKE_WIDTH / (3 * geometry.scale)}`, + `${consts.BASE_STROKE_WIDTH / (3 * geometry.scale)}`, ); element.setAttribute( 'r', - `${this.BASE_POINT_SIZE / (2 * geometry.scale)}`, + `${consts.BASE_POINT_SIZE / (2 * geometry.scale)}`, ); } @@ -212,13 +280,31 @@ export class CanvasViewImpl implements CanvasView, Listener { ); } - for (const object of this.svgShapes) { - if (object.attr('stroke-width')) { - object.attr({ - 'stroke-width': this.BASE_STROKE_WIDTH / (geometry.scale), - }); + // Transform all drawn shapes + for (const key in this.svgShapes) { + if (Object.prototype.hasOwnProperty.call(this.svgShapes, key)) { + const object = this.svgShapes[key]; + if (object.attr('stroke-width')) { + object.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / (geometry.scale), + }); + } + } + } + + // Transform all text + for (const key in this.svgShapes) { + if (Object.prototype.hasOwnProperty.call(this.svgShapes, key) + && Object.prototype.hasOwnProperty.call(this.svgTexts, key)) { + this.updateTextPosition( + this.svgTexts[key], + this.svgShapes[key], + ); } } + + // Transform handlers + this.drawHandler.transform(geometry); } function resize(geometry: Geometry): void { @@ -240,11 +326,60 @@ export class CanvasViewImpl implements CanvasView, Listener { } for (const obj of [this.content, this.text]) { - obj.style.top = `${geometry.top - geometry.offset * geometry.scale}px`; - obj.style.left = `${geometry.left - geometry.offset * geometry.scale}px`; + obj.style.top = `${geometry.top - geometry.offset}px`; + obj.style.left = `${geometry.left - geometry.offset}px`; + } + + // Transform handlers + this.drawHandler.transform(geometry); + } + + function computeFocus(focusData: FocusData, geometry: Geometry): void { + // This computation cann't be done in the model because of lack of data + const object = this.svgShapes[focusData.clientID]; + if (!object) { + return; + } + + // First of all, compute and apply scale + + let scale = null; + const bbox: SVG.BBox = object.node.getBBox(); + if ((geometry.angle / 90) % 2) { + // 90, 270, .. + scale = Math.min(Math.max(Math.min( + geometry.canvas.width / bbox.height, + geometry.canvas.height / bbox.width, + ), FrameZoom.MIN), FrameZoom.MAX); + } else { + scale = Math.min(Math.max(Math.min( + geometry.canvas.width / bbox.width, + geometry.canvas.height / bbox.height, + ), FrameZoom.MIN), FrameZoom.MAX); } - this.content.style.transform = `scale(${geometry.scale})`; + transform.call(this, Object.assign({}, geometry, { + scale, + })); + + const [x, y] = translateFromSVG(this.content, [ + bbox.x + bbox.width / 2, + bbox.y + bbox.height / 2, + ]); + + const [cx, cy] = [ + this.canvas.clientWidth / 2 + this.canvas.offsetLeft, + this.canvas.clientHeight / 2 + this.canvas.offsetTop, + ]; + + const dragged = Object.assign({}, geometry, { + top: geometry.top + cy - y, + left: geometry.left + cx - x, + scale, + }); + + this.controller.geometry = dragged; + move.call(this, dragged); } function setupObjects(objects: any[], geometry: Geometry): void { @@ -268,17 +403,33 @@ export class CanvasViewImpl implements CanvasView, Listener { move.call(this, geometry); resize.call(this, geometry); transform.call(this, geometry); - const event: Event = new Event('canvas.setup'); - this.canvas.dispatchEvent(event); } } else if (reason === UpdateReasons.ZOOM || reason === UpdateReasons.FIT) { move.call(this, geometry); - resize.call(this, geometry); transform.call(this, geometry); } else if (reason === UpdateReasons.MOVE) { move.call(this, geometry); } else if (reason === UpdateReasons.OBJECTS) { setupObjects.call(this, this.controller.objects, geometry); + const event: CustomEvent = new CustomEvent('canvas.setup'); + this.canvas.dispatchEvent(event); + } else if (reason === UpdateReasons.GRID) { + const size: Size = geometry.grid; + this.gridPattern.setAttribute('width', `${size.width}`); + this.gridPattern.setAttribute('height', `${size.height}`); + } else if (reason === UpdateReasons.FOCUS) { + computeFocus.call(this, this.controller.focusData, geometry); + } else if (reason === UpdateReasons.ACTIVATE) { + this.activate(geometry, this.controller.activeElement); + } else if (reason === UpdateReasons.DRAW) { + const data: DrawData = this.controller.drawData; + if (data.enabled) { + this.mode = Mode.DRAW; + this.deactivate(); + } else { + this.mode = Mode.IDLE; + } + this.drawHandler.draw(data, geometry); } } @@ -286,12 +437,12 @@ export class CanvasViewImpl implements CanvasView, Listener { return this.canvas; } - private addObjects(ctm: SVGMatrix, objects: any[], geometry: Geometry): void { - for (const object of objects) { - if (object.objectType === 'tag') { - this.addTag(object, geometry); + private addObjects(ctm: SVGMatrix, states: any[], geometry: Geometry): void { + for (const state of states) { + if (state.objectType === 'tag') { + this.addTag(state, geometry); } else { - const points: number[] = (object.points as number[]); + const points: number[] = (state.points as number[]); const translatedPoints: number[] = []; for (let i = 0; i <= points.length - 1; i += 2) { let point: SVGPoint = this.background.createSVGPoint(); @@ -302,8 +453,9 @@ export class CanvasViewImpl implements CanvasView, Listener { } // TODO: Use enums after typification cvat-core - if (object.shapeType === 'rectangle') { - this.svgShapes.push(this.addRect(translatedPoints, object, geometry)); + if (state.shapeType === 'rectangle') { + this.svgShapes[state.clientID] = this + .addRect(translatedPoints, state, geometry); } else { const stringified = translatedPoints.reduce( (acc: string, val: number, idx: number): string => { @@ -315,34 +467,57 @@ export class CanvasViewImpl implements CanvasView, Listener { }, '', ); - if (object.shapeType === 'polygon') { - this.svgShapes.push(this.addPolygon(stringified, object, geometry)); - } else if (object.shapeType === 'polyline') { - this.svgShapes.push(this.addPolyline(stringified, object, geometry)); - } else if (object.shapeType === 'points') { - this.svgShapes.push(this.addPoints(stringified, object, geometry)); + if (state.shapeType === 'polygon') { + this.svgShapes[state.clientID] = this + .addPolygon(stringified, state, geometry); + } else if (state.shapeType === 'polyline') { + this.svgShapes[state.clientID] = this + .addPolyline(stringified, state, geometry); + } else if (state.shapeType === 'points') { + this.svgShapes[state.clientID] = this + .addPoints(stringified, state, geometry); } } - // TODO: add text here if need + // TODO: Use enums after typification cvat-core + if (state.visibility === 'all') { + this.svgTexts[state.clientID] = this.addText(state); + this.updateTextPosition( + this.svgTexts[state.clientID], + this.svgShapes[state.clientID], + ); + } } } - - this.activate(geometry); } - private activate(geometry: Geometry): void { - for (const shape of this.svgShapes) { - const self = this; - (shape as any).draggable().on('dragstart', (): void => { - console.log('hello'); - }).on('dragend', (): void => { - console.log('hello'); - }); + private deactivate(): void { + if (this.activeElement) { + const { state } = this.activeElement; + const shape = this.svgShapes[this.activeElement.state.clientID]; + (shape as any).draggable(false); + + if (state.shapeType !== 'points') { + this.selectize(false, shape, null); + } + + (shape as any).resize(false); + + // Hide text only if it is hidden by settings + const text = this.svgTexts[state.clientID]; + if (text && state.visibility === 'shape') { + text.remove(); + delete this.svgTexts[state.clientID]; + } + this.activeElement = null; + } + } - (shape as any).selectize({ + private selectize(value: boolean, shape: SVG.Element, geometry: Geometry): void { + if (value) { + (shape as any).selectize(value, { deepSelect: true, - pointSize: this.BASE_POINT_SIZE / geometry.scale, + pointSize: consts.BASE_POINT_SIZE / geometry.scale, rotationPoint: false, pointType(cx: number, cy: number): SVG.Circle { const circle: SVG.Circle = this.nested @@ -351,7 +526,7 @@ export class CanvasViewImpl implements CanvasView, Listener { .fill(shape.node.getAttribute('fill')) .center(cx, cy) .attr({ - 'stroke-width': self.BASE_STROKE_WIDTH / (3 * geometry.scale), + 'stroke-width': consts.BASE_STROKE_WIDTH / (3 * geometry.scale), }); circle.node.addEventListener('mouseenter', (): void => { @@ -372,12 +547,140 @@ export class CanvasViewImpl implements CanvasView, Listener { return circle; }, - }).resize(); + }); + } else { + (shape as any).selectize(false, { + deepSelect: true, + }); + } + } + + private activate(geometry: Geometry, activeElement: ActiveElement): void { + // Check if other element have been already activated + if (this.activeElement) { + // Check if it is the same element + if (this.activeElement.state.clientID === activeElement.clientID) { + return; + } + + // Deactivate previous element + this.deactivate(); + } + + const state = this.controller.objects + .filter((el): boolean => el.clientID === activeElement.clientID)[0]; + this.activeElement = { + attributeID: activeElement.attributeID, + state, + }; + + const shape = this.svgShapes[activeElement.clientID]; + let text = this.svgTexts[activeElement.clientID]; + // Draw text if it's hidden by default + if (!text && state.visibility === 'shape') { + text = this.addText(state); + this.svgTexts[state.clientID] = text; + this.updateTextPosition( + text, + shape, + ); + } + + const self = this; + this.content.append(shape.node); + (shape as any).draggable().on('dragstart', (): void => { + this.mode = Mode.DRAG; + if (text) { + text.addClass('cvat_canvas_hidden'); + } + }).on('dragend', (): void => { + this.mode = Mode.IDLE; + if (text) { + text.removeClass('cvat_canvas_hidden'); + self.updateTextPosition( + text, + shape, + ); + } + }); + + if (state.shapeType !== 'points') { + this.selectize(true, shape, geometry); + } + + (shape as any).resize().on('resizestart', (): void => { + this.mode = Mode.RESIZE; + if (text) { + text.addClass('cvat_canvas_hidden'); + } + }).on('resizedone', (): void => { + this.mode = Mode.IDLE; + if (text) { + text.removeClass('cvat_canvas_hidden'); + self.updateTextPosition( + text, + shape, + ); + } + }); + } + + // Update text position after corresponding box has been moved, resized, etc. + private updateTextPosition(text: SVG.Text, shape: SVG.Shape): void { + 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, + ]); + + 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), + }; + + // 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]); + } + + // Translate back to text SVG + const [x, y]: number[] = translateToSVG(this.text, [ + clientX + consts.TEXT_MARGIN, + clientY, + ]); + + // Finally draw a text + text.move(x, y); + for (const tspan of (text.lines() as any).members) { + tspan.attr('x', text.attr('x')); } + } - // add selectable - // add draggable - // add resizable + private addText(state: any): SVG.Text { + const { label, clientID, attributes } = state; + const attrNames = label.attributes.reduce((acc: any, val: any): void => { + acc[val.id] = val.name; + return acc; + }, {}); + + return this.adoptedText.text((block): void => { + block.tspan(`${label.name} ${clientID}`).style('text-transform', 'uppercase'); + for (const attrID of Object.keys(attributes)) { + block.tspan(`${attrNames[attrID]}: ${attributes[attrID]}`).attr({ + attrID, + dy: '1em', + x: 0, + }); + } + }).move(0, 0).addClass('cvat_canvas_text'); } private addRect(points: number[], state: any, geometry: Geometry): SVG.Rect { @@ -389,7 +692,7 @@ export class CanvasViewImpl implements CanvasView, Listener { fill: state.color, 'shape-rendering': 'geometricprecision', stroke: darker(state.color, 50), - 'stroke-width': this.BASE_STROKE_WIDTH / geometry.scale, + 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale, zOrder: state.zOrder, }).move(xtl, ytl) .addClass('cvat_canvas_shape'); @@ -402,7 +705,7 @@ export class CanvasViewImpl implements CanvasView, Listener { fill: state.color, 'shape-rendering': 'geometricprecision', stroke: darker(state.color, 50), - 'stroke-width': this.BASE_STROKE_WIDTH / geometry.scale, + 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale, zOrder: state.zOrder, }).addClass('cvat_canvas_shape'); } @@ -414,22 +717,24 @@ export class CanvasViewImpl implements CanvasView, Listener { fill: state.color, 'shape-rendering': 'geometricprecision', stroke: darker(state.color, 50), - 'stroke-width': this.BASE_STROKE_WIDTH / geometry.scale, + 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale, zOrder: state.zOrder, }).addClass('cvat_canvas_shape'); } - private addPoints(points: string, state: any, geometry: Geometry): SVG.Polygon { - return this.adoptedContent.polygon(points).attr({ + private addPoints(points: string, state: any, geometry: Geometry): SVG.PolyLine { + const shape = this.adoptedContent.polyline(points).attr({ clientID: state.clientID, 'color-rendering': 'optimizeQuality', fill: state.color, - opacity: 0, 'shape-rendering': 'geometricprecision', - stroke: darker(state.color, 50), - 'stroke-width': this.BASE_STROKE_WIDTH / geometry.scale, zOrder: state.zOrder, }).addClass('cvat_canvas_shape'); + + this.selectize(true, shape, geometry); + shape.attr('fill', 'none'); + + return shape; } private addTag(state: any, geometry: Geometry): void { diff --git a/cvat-canvas/src/consts.ts b/cvat-canvas/src/consts.ts new file mode 100644 index 00000000..b13497fa --- /dev/null +++ b/cvat-canvas/src/consts.ts @@ -0,0 +1,18 @@ +/* +* Copyright (C) 2019 Intel Corporation +* SPDX-License-Identifier: MIT +*/ + +const BASE_STROKE_WIDTH = 2; +const BASE_POINT_SIZE = 8; +const TEXT_MARGIN = 10; +const AREA_THRESHOLD = 9; +const SIZE_THRESHOLD = 3; + +export default { + BASE_STROKE_WIDTH, + BASE_POINT_SIZE, + TEXT_MARGIN, + AREA_THRESHOLD, + SIZE_THRESHOLD, +}; diff --git a/cvat-canvas/src/drawHandler.ts b/cvat-canvas/src/drawHandler.ts new file mode 100644 index 00000000..7eae5f45 --- /dev/null +++ b/cvat-canvas/src/drawHandler.ts @@ -0,0 +1,349 @@ +/* +* Copyright (C) 2019 Intel Corporation +* SPDX-License-Identifier: MIT +*/ + +import * as SVG from 'svg.js'; +import consts from './consts'; +import 'svg.draw.js'; +import './svg.patch'; + +import { + DrawData, + Geometry, +} from './canvasModel'; + +import { + translateToSVG, + translateFromSVG, +} from './shared'; + +export interface DrawHandler { + draw(drawData: DrawData, geometry: Geometry): void; +} + +export class DrawHandlerImpl implements DrawHandler { + private onDrawDone: any; // callback is used to notify about creating new shape + private canvas: SVG.Container; + private crosshair: { + x: SVG.Line; + y: SVG.Line; + }; + private drawData: DrawData; + private geometry: Geometry; + private drawInstance: any; + + + private addCrosshair(): void { + this.crosshair = { + x: this.canvas.line(0, 0, this.canvas.node.clientWidth, 0).attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * this.geometry.scale), + zOrder: Number.MAX_SAFE_INTEGER, + }).addClass('cvat_canvas_crosshair'), + y: this.canvas.line(0, 0, 0, this.canvas.node.clientHeight).attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * this.geometry.scale), + zOrder: Number.MAX_SAFE_INTEGER, + }).addClass('cvat_canvas_crosshair'), + }; + } + + private removeCrosshair(): void { + this.crosshair.x.remove(); + this.crosshair.y.remove(); + this.crosshair = null; + } + + private initDrawing(): void { + if (this.drawData.crosshair) { + this.addCrosshair(); + } + } + + private closeDrawing(): void { + if (this.crosshair) { + this.removeCrosshair(); + } + + if (this.drawInstance) { + if (this.drawData.shapeType === 'rectangle') { + this.drawInstance.draw('cancel'); + } else { + this.drawInstance.draw('done'); + } + this.drawInstance.remove(); + this.drawInstance = null; + } + } + + private drawBox(): void { + this.drawInstance = this.canvas.rect(); + this.drawInstance.draw({ + snapToGrid: 0.1, + }).addClass('cvat_canvas_shape_drawing').attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }).on('drawstop', (e: Event): void => { + const frameWidth = this.geometry.image.width; + const frameHeight = this.geometry.image.height; + const bbox = (e.target as SVGRectElement).getBBox(); + + let [xtl, ytl, xbr, ybr] = translateFromSVG( + this.canvas.node as any as SVGSVGElement, + [bbox.x, bbox.y, bbox.x + bbox.width, bbox.y + bbox.height], + ); + + xtl = Math.min(Math.max(xtl, 0), frameWidth); + xbr = Math.min(Math.max(xbr, 0), frameWidth); + ytl = Math.min(Math.max(ytl, 0), frameHeight); + ybr = Math.min(Math.max(ybr, 0), frameHeight); + + if ((xbr - xtl) * (ybr - ytl) >= consts.AREA_THRESHOLD) { + this.onDrawDone({ + points: [xtl, ytl, xbr, ybr], + }); + } else { + this.onDrawDone(null); + } + }); + } + + private drawPolyshape(): void { + let size = this.drawData.numberOfPoints; + const sizeDecrement = function sizeDecrement(): void { + if (!--size) { + this.drawInstance.draw('done'); + } + }.bind(this); + + const sizeIncrement = function sizeIncrement(): void { + size++; + }; + + if (this.drawData.numberOfPoints) { + this.drawInstance.on('drawstart', sizeDecrement); + this.drawInstance.on('drawpoint', sizeDecrement); + this.drawInstance.on('undopoint', sizeIncrement); + } + + // Add ability to cancel the latest drawn point + const handleUndo = function handleUndo(e: MouseEvent): void { + if (e.which === 3) { + e.stopPropagation(); + e.preventDefault(); + this.drawInstance.draw('undo'); + } + }.bind(this); + this.canvas.node.addEventListener('mousedown', handleUndo); + + // Add ability to draw shapes by sliding + // We need to remember last drawn point + // to implementation of slide drawing + const lastDrawnPoint: { + x: number; + y: number; + } = { + x: null, + y: null, + }; + + const handleSlide = function handleSlide(e: MouseEvent): void { + // TODO: Use enumeration after typification cvat-core + if (e.shiftKey && ['polygon', 'polyline'].includes(this.drawData.shapeType)) { + if (lastDrawnPoint.x === null || lastDrawnPoint.y === null) { + this.drawInstance.draw('point', e); + } else { + const deltaTreshold = 15; + const delta = Math.sqrt( + ((e.clientX - lastDrawnPoint.x) ** 2) + + ((e.clientY - lastDrawnPoint.y) ** 2), + ); + if (delta > deltaTreshold) { + this.drawInstance.draw('point', e); + } + } + } + }.bind(this); + this.canvas.node.addEventListener('mousemove', handleSlide); + + // We need scale just drawn points + const self = this; + this.drawInstance.on('drawstart drawpoint', (e: CustomEvent): void => { + self.transform(self.geometry); + lastDrawnPoint.x = e.detail.event.clientX; + lastDrawnPoint.y = e.detail.event.clientY; + }); + + this.drawInstance.on('drawstop', (): void => { + self.canvas.node.removeEventListener('mousedown', handleUndo); + self.canvas.node.removeEventListener('mousemove', handleSlide); + }); + + this.drawInstance.on('drawdone', (e: CustomEvent): void => { + const points = translateFromSVG( + this.canvas.node as any as SVGSVGElement, + (e.target as SVGElement) + .getAttribute('points') + .split(/[,\s]/g) + .map((coord): number => +coord), + ); + + const bbox = { + xtl: Number.MAX_SAFE_INTEGER, + ytl: Number.MAX_SAFE_INTEGER, + xbr: Number.MAX_SAFE_INTEGER, + ybr: Number.MAX_SAFE_INTEGER, + }; + + const frameWidth = this.geometry.image.width; + const frameHeight = this.geometry.image.height; + for (let i = 0; i < points.length - 1; i += 2) { + points[i] = Math.min(Math.max(points[i], 0), frameWidth); + points[i + 1] = Math.min(Math.max(points[i + 1], 0), frameHeight); + + bbox.xtl = Math.min(bbox.xtl, points[i]); + bbox.ytl = Math.min(bbox.ytl, points[i + 1]); + bbox.xbr = Math.max(bbox.xbr, points[i]); + bbox.ybr = Math.max(bbox.ybr, points[i + 1]); + } + + if (this.drawData.shapeType === 'polygon' + && ((bbox.xbr - bbox.xtl) * (bbox.ybr - bbox.ytl) >= consts.AREA_THRESHOLD)) { + this.onDrawDone({ + points, + }); + } else if (this.drawData.shapeType === 'polyline' + && ((bbox.xbr - bbox.xtl) >= consts.SIZE_THRESHOLD + || (bbox.ybr - bbox.ytl) >= consts.SIZE_THRESHOLD)) { + this.onDrawDone({ + points, + }); + } else if (this.drawData.shapeType === 'points') { + this.onDrawDone({ + points, + }); + } else { + this.onDrawDone(null); + } + }); + } + + private drawPolygon(): void { + this.drawInstance = (this.canvas as any).polygon().draw({ + snapToGrid: 0.1, + }).addClass('cvat_canvas_shape_drawing').style({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }); + + this.drawPolyshape(); + } + + private drawPolyline(): void { + this.drawInstance = (this.canvas as any).polyline().draw({ + snapToGrid: 0.1, + }).addClass('cvat_canvas_shape_drawing').style({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + 'fill-opacity': 0, + }); + + this.drawPolyshape(); + } + + private drawPoints(): void { + this.drawInstance = (this.canvas as any).polygon().draw({ + snapToGrid: 0.1, + }).addClass('cvat_canvas_shape_drawing').style({ + 'stroke-width': 0, + opacity: 0, + }); + + this.drawPolyshape(); + } + + private startDraw(): void { + // TODO: Use enums after typification cvat-core + if (this.drawData.shapeType === 'rectangle') { + this.drawBox(); + } else if (this.drawData.shapeType === 'polygon') { + this.drawPolygon(); + } else if (this.drawData.shapeType === 'polyline') { + this.drawPolyline(); + } else if (this.drawData.shapeType === 'points') { + this.drawPoints(); + } + } + + public constructor(onDrawDone: any, canvas: SVG.Container) { + this.onDrawDone = onDrawDone; + this.canvas = canvas; + this.drawData = null; + this.geometry = null; + this.crosshair = null; + this.drawInstance = null; + + this.canvas.node.addEventListener('mousemove', (e): void => { + if (this.crosshair) { + const [x, y] = translateToSVG( + this.canvas.node as any as SVGSVGElement, + [e.clientX, e.clientY], + ); + + this.crosshair.x.attr({ + y1: y, + y2: y, + }); + + this.crosshair.y.attr({ + x1: x, + x2: x, + }); + } + }); + } + + public transform(geometry: Geometry): void { + this.geometry = geometry; + + if (this.crosshair) { + this.crosshair.x.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * geometry.scale), + }); + this.crosshair.y.attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * geometry.scale), + }); + } + + if (this.drawInstance) { + this.drawInstance.draw('transform'); + this.drawInstance.style({ + 'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale, + }); + + const PaintHandler = Object.values(this.drawInstance.memory())[0]; + + for (const point of (PaintHandler as any).set.members) { + point.style( + 'stroke-width', + `${consts.BASE_STROKE_WIDTH / (3 * geometry.scale)}`, + ); + point.attr( + 'r', + `${consts.BASE_POINT_SIZE / (2 * geometry.scale)}`, + ); + } + } + } + + public draw(drawData: DrawData, geometry: Geometry): void { + this.geometry = geometry; + + if (drawData.enabled) { + this.drawData = drawData; + this.initDrawing(); + this.startDraw(); + } else { + this.closeDrawing(); + this.drawData = drawData; + } + } +} + +// TODO: handle initial state diff --git a/cvat-canvas/src/shared.ts b/cvat-canvas/src/shared.ts new file mode 100644 index 00000000..2b391817 --- /dev/null +++ b/cvat-canvas/src/shared.ts @@ -0,0 +1,36 @@ +/* +* Copyright (C) 2019 Intel Corporation +* SPDX-License-Identifier: MIT +*/ + +// Translate point array from the client coordinate system +// to a coordinate system of a canvas +export function translateFromSVG(svg: SVGSVGElement, points: number[]): number[] { + const output = []; + const transformationMatrix = svg.getScreenCTM(); + let pt = svg.createSVGPoint(); + for (let i = 0; i < points.length - 1; i += 2) { + pt.x = points[i]; + pt.y = points[i + 1]; + pt = pt.matrixTransform(transformationMatrix); + output.push(pt.x, pt.y); + } + + return output; +} + +// Translate point array from a coordinate system of a canvas +// to the client coordinate system +export function translateToSVG(svg: SVGSVGElement, points: number[]): number[] { + const output = []; + const transformationMatrix = svg.getScreenCTM().inverse(); + let pt = svg.createSVGPoint(); + for (let i = 0; i < points.length; i += 2) { + pt.x = points[i]; + pt.y = points[i + 1]; + pt = pt.matrixTransform(transformationMatrix); + output.push(pt.x, pt.y); + } + + return output; +} diff --git a/cvat-canvas/src/svg.patch.ts b/cvat-canvas/src/svg.patch.ts new file mode 100644 index 00000000..ae06566f --- /dev/null +++ b/cvat-canvas/src/svg.patch.ts @@ -0,0 +1,172 @@ +import * as SVG from 'svg.js'; + +/* eslint-disable */ + +import 'svg.draggable.js'; +import 'svg.resize.js'; +import 'svg.select.js'; +import 'svg.draw.js'; + +// Update constructor +const originalDraw = SVG.Element.prototype.draw; +SVG.Element.prototype.draw = function constructor(...args: any): any { + let handler = this.remember('_paintHandler'); + if (!handler) { + originalDraw.call(this, ...args); + handler = this.remember('_paintHandler'); + handler.set = new SVG.Set(); + } else { + originalDraw.call(this, ...args); + } + + return this; +}; +for (const key of Object.keys(originalDraw)) { + SVG.Element.prototype.draw[key] = originalDraw[key]; +} + +// Create undo for polygones and polylines +function undo(): void { + if (this.set.length()) { + this.set.members.splice(-1, 1)[0].remove(); + this.el.array().value.splice(-2, 1); + this.el.plot(this.el.array()); + this.el.fire('undopoint'); + } +} + +SVG.Element.prototype.draw.extend('polyline', Object.assign({}, + SVG.Element.prototype.draw.plugins.polyline, + { + undo: undo, + }, +)); + +SVG.Element.prototype.draw.extend('polygon', Object.assign({}, + SVG.Element.prototype.draw.plugins.polygon, + { + undo: undo, + }, +)); + + +// Create transform for rect, polyline and polygon +function transform(): void { + this.m = this.el.node.getScreenCTM().inverse(); + this.offset = { x: window.pageXOffset, y: window.pageYOffset }; +} + +SVG.Element.prototype.draw.extend('rect', Object.assign({}, + SVG.Element.prototype.draw.plugins.rect, + { + transform: transform, + }, +)); + +SVG.Element.prototype.draw.extend('polyline', Object.assign({}, + SVG.Element.prototype.draw.plugins.polyline, + { + transform: transform, + }, +)); + +SVG.Element.prototype.draw.extend('polygon', Object.assign({}, + SVG.Element.prototype.draw.plugins.polygon, + { + transform: transform, + }, +)); + +// Fix method drawCircles +function drawCircles(): void { + const array = this.el.array().valueOf(); + + this.set.each(function (): void { + this.remove(); + }); + + this.set.clear(); + + for (let i = 0; i < array.length - 1; ++i) { + [this.p.x] = array[i]; + [, this.p.y] = array[i]; + + const p = this.p.matrixTransform( + this.parent.node.getScreenCTM() + .inverse() + .multiply(this.el.node.getScreenCTM()), + ); + + this.set.add( + this.parent + .circle(5) + .stroke({ + width: 1, + }).fill('#ccc') + .center(p.x, p.y), + ); + } +} + +SVG.Element.prototype.draw.extend('line', Object.assign({}, + SVG.Element.prototype.draw.plugins.line, + { + drawCircles: drawCircles, + } +)); + +SVG.Element.prototype.draw.extend('polyline', Object.assign({}, + SVG.Element.prototype.draw.plugins.polyline, + { + drawCircles: drawCircles, + } +)); + +SVG.Element.prototype.draw.extend('polygon', Object.assign({}, + SVG.Element.prototype.draw.plugins.polygon, + { + drawCircles: drawCircles, + } +)); + +// Fix method drag +const originalDraggable = SVG.Element.prototype.draggable; +SVG.Element.prototype.draggable = function constructor(...args: any): any { + let handler = this.remember('_draggable'); + if (!handler) { + originalDraggable.call(this, ...args); + handler = this.remember('_draggable'); + handler.drag = function(e: any) { + this.m = this.el.node.getScreenCTM().inverse(); + handler.constructor.prototype.drag.call(this, e); + } + } else { + originalDraggable.call(this, ...args); + } + + return this; +}; +for (const key of Object.keys(originalDraggable)) { + SVG.Element.prototype.draggable[key] = originalDraggable[key]; +} + +// Fix method resize +const originalResize = SVG.Element.prototype.resize; +SVG.Element.prototype.resize = function constructor(...args: any): any { + let handler = this.remember('_resizeHandler'); + if (!handler) { + originalResize.call(this, ...args); + handler = this.remember('_resizeHandler'); + handler.update = function(e: any) { + this.m = this.el.node.getScreenCTM().inverse(); + handler.constructor.prototype.update.call(this, e); + } + } else { + originalResize.call(this, ...args); + } + + return this; +}; +for (const key of Object.keys(originalResize)) { + SVG.Element.prototype.resize[key] = originalResize[key]; +} diff --git a/cvat-core/src/annotations-objects.js b/cvat-core/src/annotations-objects.js index d81c505f..a9206a44 100644 --- a/cvat-core/src/annotations-objects.js +++ b/cvat-core/src/annotations-objects.js @@ -9,11 +9,15 @@ (() => { const ObjectState = require('./object-state'); - const { checkObjectType } = require('./common'); + const { + checkObjectType, + isEnum, + } = require('./common'); const { ObjectShape, ObjectType, AttributeType, + VisibleState, } = require('./enums'); const { @@ -169,6 +173,7 @@ this.frameMeta = injection.frameMeta; this.collectionZ = injection.collectionZ; + this.visibility = VisibleState.SHAPE; this.color = color; this.shapeType = null; @@ -272,6 +277,7 @@ label: this.label, group: this.group, color: this.color, + visibility: this.visibility, }; } @@ -373,6 +379,16 @@ copy.color = data.color; } + if (updated.visibility) { + if (!isEnum.call(VisibleState, data.visibility)) { + throw new ArgumentError( + `Got invalid visibility value: "${data.visibility}"`, + ); + } + + copy.visibility = data.visibility; + } + // Reset flags and commit all changes updated.reset(); for (const prop of Object.keys(copy)) { @@ -475,6 +491,7 @@ serverID: this.serverID, lock: this.lock, color: this.color, + visibility: this.visibility, }, ); @@ -643,6 +660,16 @@ copy.color = data.color; } + if (updated.visibility) { + if (!isEnum.call(VisibleState, data.visibility)) { + throw new ArgumentError( + `Got invalid visibility value: "${data.visibility}"`, + ); + } + + copy.visibility = data.visibility; + } + if (updated.keyframe) { // Just check here checkObjectType('keyframe', data.keyframe, 'boolean', null); diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index adc6fa8f..21c2299a 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -27,8 +27,8 @@ function build() { AttributeType, ObjectType, ObjectShape, + VisibleState, LogType, - EventType, } = require('./enums'); const { @@ -467,8 +467,8 @@ function build() { AttributeType, ObjectType, ObjectShape, + VisibleState, LogType, - EventType, }, /** * Namespace is used for access to exceptions diff --git a/cvat-core/src/enums.js b/cvat-core/src/enums.js index e8fbdeae..241cca71 100644 --- a/cvat-core/src/enums.js +++ b/cvat-core/src/enums.js @@ -5,7 +5,7 @@ (() => { /** - * Enum for type of server files + * Share files types * @enum {string} * @name ShareFileType * @memberof module:API.cvat.enums @@ -19,7 +19,7 @@ }); /** - * Enum for a status of a task + * Task statuses * @enum {string} * @name TaskStatus * @memberof module:API.cvat.enums @@ -35,7 +35,7 @@ }); /** - * Enum for a mode of a task + * Task modes * @enum {string} * @name TaskMode * @memberof module:API.cvat.enums @@ -49,7 +49,7 @@ }); /** - * Enum for type of server files + * Attribute types * @enum {string} * @name AttributeType * @memberof module:API.cvat.enums @@ -69,7 +69,7 @@ }); /** - * Enum for type of an object + * Object types * @enum {string} * @name ObjectType * @memberof module:API.cvat.enums @@ -85,7 +85,7 @@ }); /** - * Enum for type of server files + * Object shapes * @enum {string} * @name ObjectShape * @memberof module:API.cvat.enums @@ -103,7 +103,23 @@ }); /** - * Enum for type of server files + * Object visibility states + * @enum {string} + * @name ObjectShape + * @memberof module:API.cvat.enums + * @property {string} ALL 'all' + * @property {string} SHAPE 'shape' + * @property {string} NONE 'none' + * @readonly + */ + const VisibleState = Object.freeze({ + ALL: 'all', + SHAPE: 'shape', + NONE: 'none', + }); + + /** + * Event types * @enum {number} * @name LogType * @memberof module:API.cvat.enums @@ -166,18 +182,6 @@ rotateImage: 26, }; - /** - * Enum for type of server files - * @enum {number} - * @name EventType - * @memberof module:API.cvat.enums - * @property {number} frameDownloaded 0 - * @readonly - */ - const EventType = Object.freeze({ - frameDownloaded: 0, - }); - module.exports = { ShareFileType, TaskStatus, @@ -185,7 +189,7 @@ AttributeType, ObjectType, ObjectShape, + VisibleState, LogType, - EventType, }; })(); diff --git a/cvat-core/src/object-state.js b/cvat-core/src/object-state.js index f1eb44ae..fce64097 100644 --- a/cvat-core/src/object-state.js +++ b/cvat-core/src/object-state.js @@ -39,6 +39,7 @@ zOrder: null, lock: null, color: null, + visibility: null, clientID: serialized.clientID, serverID: serialized.serverID, @@ -64,6 +65,7 @@ this.zOrder = false; this.lock = false; this.color = false; + this.visibility = false; }, writable: false, }); @@ -149,6 +151,19 @@ data.color = color; }, }, + visibility: { + /** + * @name visibility + * @type {module:API.cvat.enums.VisibleState} + * @memberof module:API.cvat.classes.ObjectState + * @instance + */ + get: () => data.visibility, + set: (visibility) => { + data.updateFlags.visibility = true; + data.visibility = visibility; + }, + }, points: { /** * @name points @@ -285,6 +300,7 @@ this.occluded = serialized.occluded; this.color = serialized.color; this.lock = serialized.lock; + this.visibility = serialized.visibility; // It can be undefined in a constructor and it can be defined later if (typeof (serialized.points) !== 'undefined') {