Added paint brush tools (#4543)

main
Boris Sekachev 4 years ago committed by GitHub
parent 02eba10b45
commit f62aa276cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Objects sorting option in the sidebar, by z order. Additional visualization when the sorting is applied
(<https://github.com/opencv/cvat/pull/5145>)
- Added YOLOv5 serverless function NVIDIA GPU support (<https://github.com/opencv/cvat/pull/4960>)
- Mask tools are supported now (brush, eraser, polygon-plus, polygon-minus, returning masks
from online detectors & interactors) (<https://github.com/opencv/cvat/pull/4543>)
- Added Webhooks (<https://github.com/opencv/cvat/pull/4863>)
### Changed

@ -1,6 +1,6 @@
{
"name": "cvat-canvas",
"version": "2.15.4",
"version": "2.16.0",
"description": "Part of Computer Vision Annotation Tool which presents its canvas library",
"main": "src/canvas.ts",
"scripts": {
@ -17,6 +17,8 @@
],
"dependencies": {
"@types/polylabel": "^1.0.5",
"@types/fabric": "^4.5.7",
"fabric": "^5.2.1",
"polylabel": "^1.1.0",
"svg.draggable.js": "2.2.2",
"svg.draw.js": "^2.0.4",

@ -1,5 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corp
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -72,6 +72,10 @@ polyline.cvat_shape_drawing_opacity {
fill: darkmagenta;
}
image.cvat_canvas_shape_grouping {
visibility: hidden;
}
.cvat_canvas_shape_region_selection {
@extend .cvat_shape_action_dasharray;
@extend .cvat_shape_action_opacity;
@ -340,6 +344,11 @@ g.cvat_canvas_shape_occluded {
height: 100%;
}
.cvat_masks_canvas_wrapper {
z-index: 3;
display: none;
}
#cvat_canvas_attachment_board {
position: absolute;
z-index: 4;

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -8,6 +9,7 @@ import {
MergeData,
SplitData,
GroupData,
MasksEditData,
InteractionData as _InteractionData,
InteractionResult as _InteractionResult,
CanvasModel,
@ -38,6 +40,7 @@ interface Canvas {
interact(interactionData: InteractionData): void;
draw(drawData: DrawData): void;
edit(editData: MasksEditData): void;
group(groupData: GroupData): void;
split(splitData: SplitData): void;
merge(mergeData: MergeData): void;
@ -129,6 +132,10 @@ class CanvasImpl implements Canvas {
this.model.draw(drawData);
}
public edit(editData: MasksEditData): void {
this.model.edit(editData);
}
public split(splitData: SplitData): void {
this.model.split(splitData);
}

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -15,6 +16,7 @@ import {
Mode,
InteractionData,
Configuration,
MasksEditData,
} from './canvasModel';
export interface CanvasController {
@ -24,6 +26,7 @@ export interface CanvasController {
readonly focusData: FocusData;
readonly activeElement: ActiveElement;
readonly drawData: DrawData;
readonly editData: MasksEditData;
readonly interactionData: InteractionData;
readonly mergeData: MergeData;
readonly splitData: SplitData;
@ -35,6 +38,7 @@ export interface CanvasController {
zoom(x: number, y: number, direction: number): void;
draw(drawData: DrawData): void;
edit(editData: MasksEditData): void;
interact(interactionData: InteractionData): void;
merge(mergeData: MergeData): void;
split(splitData: SplitData): void;
@ -91,6 +95,10 @@ export class CanvasControllerImpl implements CanvasController {
this.model.draw(drawData);
}
public edit(editData: MasksEditData): void {
this.model.edit(editData);
}
public interact(interactionData: InteractionData): void {
this.model.interact(interactionData);
}
@ -143,6 +151,10 @@ export class CanvasControllerImpl implements CanvasController {
return this.model.drawData;
}
public get editData(): MasksEditData {
return this.model.editData;
}
public get interactionData(): InteractionData {
return this.model.interactionData;
}

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -52,6 +53,12 @@ export enum CuboidDrawingMethod {
CORNER_POINTS = 'By 4 points',
}
export enum ColorBy {
INSTANCE = 'Instance',
GROUP = 'Group',
LABEL = 'Label',
}
export interface Configuration {
smoothImage?: boolean;
autoborders?: boolean;
@ -65,15 +72,23 @@ export interface Configuration {
intelligentPolygonCrop?: boolean;
forceFrameUpdate?: boolean;
CSSImageFilter?: string;
colorBy?: string;
colorBy?: ColorBy;
selectedShapeOpacity?: number;
shapeOpacity?: number;
controlPointsSize?: number;
outlinedBorders?: string | false;
}
export interface BrushTool {
type: 'brush' | 'eraser' | 'polygon-plus' | 'polygon-minus';
color: string;
form: 'circle' | 'square';
size: number;
}
export interface DrawData {
enabled: boolean;
continue?: boolean;
shapeType?: string;
rectDrawingMethod?: RectDrawingMethod;
cuboidDrawingMethod?: CuboidDrawingMethod;
@ -81,7 +96,10 @@ export interface DrawData {
numberOfPoints?: number;
initialState?: any;
crosshair?: boolean;
brushTool?: BrushTool;
redraw?: number;
onDrawDone?: (data: object) => void;
onUpdateConfiguration?: (configuration: { brushTool?: Pick<BrushTool, 'size'> }) => void;
}
export interface InteractionData {
@ -107,12 +125,19 @@ export interface InteractionResult {
button: number;
}
export interface EditData {
export interface PolyEditData {
enabled: boolean;
state: any;
pointID: number;
}
export interface MasksEditData {
enabled: boolean;
state?: any;
brushTool?: BrushTool;
onUpdateConfiguration?: (configuration: { brushTool?: Pick<BrushTool, 'size'> }) => void;
}
export interface GroupData {
enabled: boolean;
}
@ -146,6 +171,7 @@ export enum UpdateReasons {
INTERACT = 'interact',
DRAW = 'draw',
EDIT = 'edit',
MERGE = 'merge',
SPLIT = 'split',
GROUP = 'group',
@ -186,6 +212,7 @@ export interface CanvasModel {
readonly focusData: FocusData;
readonly activeElement: ActiveElement;
readonly drawData: DrawData;
readonly editData: MasksEditData;
readonly interactionData: InteractionData;
readonly mergeData: MergeData;
readonly splitData: SplitData;
@ -208,6 +235,7 @@ export interface CanvasModel {
grid(stepX: number, stepY: number): void;
draw(drawData: DrawData): void;
edit(editData: MasksEditData): void;
group(groupData: GroupData): void;
split(splitData: SplitData): void;
merge(mergeData: MergeData): void;
@ -226,6 +254,50 @@ export interface CanvasModel {
destroy(): void;
}
const defaultData = {
drawData: {
enabled: false,
},
editData: {
enabled: false,
},
interactionData: {
enabled: false,
},
mergeData: {
enabled: false,
},
groupData: {
enabled: false,
},
splitData: {
enabled: false,
},
};
function hasShapeIsBeingDrawn(): boolean {
const [element] = window.document.getElementsByClassName('cvat_canvas_shape_drawing');
if (element) {
return !!(element as any).instance.remember('_paintHandler');
}
return false;
}
function disableInternalSVGDrawing(data: DrawData | MasksEditData, currentData: DrawData | MasksEditData): boolean {
// P.S. spaghetti code, but probably significant refactoring needed to find a better solution
// when it is a mask drawing/editing using polygon fill
// a user needs to close drawing/editing twice
// first close stops internal drawing/editing with svg.js
// the second one stops drawing/editing mask itself
return !data.enabled && currentData.enabled &&
(('shapeType' in currentData && currentData.shapeType === 'mask') ||
('state' in currentData && currentData.state.shapeType === 'mask')) &&
currentData.brushTool?.type?.startsWith('polygon-') &&
hasShapeIsBeingDrawn();
}
export class CanvasModelImpl extends MasterImpl implements CanvasModel {
private data: {
activeElement: ActiveElement;
@ -247,6 +319,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
top: number;
zLayer: number | null;
drawData: DrawData;
editData: MasksEditData;
interactionData: InteractionData;
mergeData: MergeData;
groupData: GroupData;
@ -278,7 +351,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
intelligentPolygonCrop: false,
forceFrameUpdate: false,
CSSImageFilter: '',
colorBy: 'Label',
colorBy: ColorBy.LABEL,
selectedShapeOpacity: 0.5,
shapeOpacity: 0.2,
outlinedBorders: false,
@ -311,25 +384,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
scale: 1,
top: 0,
zLayer: null,
drawData: {
enabled: false,
initialState: null,
},
interactionData: {
enabled: false,
},
mergeData: {
enabled: false,
},
groupData: {
enabled: false,
},
splitData: {
enabled: false,
},
selected: null,
mode: Mode.IDLE,
exception: null,
...defaultData,
};
}
@ -561,9 +619,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
throw new Error('Skeleton template must be specified when drawing a skeleton');
}
if (this.data.drawData.enabled) {
throw new Error('Drawing has been already started');
} else if (!drawData.shapeType && !drawData.initialState) {
if (!drawData.shapeType && !drawData.initialState) {
throw new Error('A shape type is not specified');
} else if (typeof drawData.numberOfPoints !== 'undefined') {
if (drawData.shapeType === 'polygon' && drawData.numberOfPoints < 3) {
@ -585,6 +641,11 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
return;
}
} else {
if (disableInternalSVGDrawing(drawData, this.data.drawData)) {
this.notify(UpdateReasons.DRAW);
return;
}
this.data.drawData = { ...drawData };
if (this.data.drawData.initialState) {
this.data.drawData.shapeType = this.data.drawData.initialState.shapeType;
@ -604,6 +665,33 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.notify(UpdateReasons.DRAW);
}
public edit(editData: MasksEditData): void {
if (![Mode.IDLE, Mode.EDIT].includes(this.data.mode)) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
if (editData.enabled && !editData.state) {
throw Error('State must be specified when call edit() editing process');
}
if (this.data.editData.enabled && editData.enabled &&
editData.state.clientID !== this.data.editData.state.clientID
) {
throw Error('State cannot be updated during editing, need to finish current editing first');
}
if (editData.enabled) {
this.data.editData = { ...editData };
} else if (disableInternalSVGDrawing(editData, this.data.editData)) {
this.notify(UpdateReasons.EDIT);
return;
} else {
this.data.editData = { enabled: false };
}
this.notify(UpdateReasons.EDIT);
}
public interact(interactionData: InteractionData): void {
if (![Mode.IDLE, Mode.INTERACT].includes(this.data.mode)) {
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
@ -735,7 +823,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
if (['string', 'boolean'].includes(typeof configuration.outlinedBorders)) {
this.data.configuration.outlinedBorders = configuration.outlinedBorders;
}
if (['Instance', 'Group', 'Label'].includes(configuration.colorBy)) {
if (Object.values(ColorBy).includes(configuration.colorBy)) {
this.data.configuration.colorBy = configuration.colorBy;
}
@ -754,6 +842,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
}
public cancel(): void {
this.data = {
...this.data,
...defaultData,
};
this.notify(UpdateReasons.CANCEL);
}
@ -837,6 +929,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
return { ...this.data.drawData };
}
public get editData(): MasksEditData {
return { ...this.data.editData };
}
public get interactionData(): InteractionData {
return { ...this.data.interactionData };
}

@ -1,9 +1,10 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corp
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import polylabel from 'polylabel';
import { fabric } from 'fabric';
import * as SVG from 'svg.js';
import 'svg.draggable.js';
@ -13,6 +14,7 @@ import 'svg.select.js';
import { CanvasController } from './canvasController';
import { Listener, Master } from './master';
import { DrawHandler, DrawHandlerImpl } from './drawHandler';
import { MasksHandler, MasksHandlerImpl } from './masksHandler';
import { EditHandler, EditHandlerImpl } from './editHandler';
import { MergeHandler, MergeHandlerImpl } from './mergeHandler';
import { SplitHandler, SplitHandlerImpl } from './splitHandler';
@ -38,6 +40,8 @@ import {
readPointsFromShape,
setupSkeletonEdges,
makeSVGFromTemplate,
imageDataToDataURL,
expandChannels,
} from './shared';
import {
CanvasModel,
@ -54,6 +58,7 @@ import {
Configuration,
InteractionResult,
InteractionData,
ColorBy,
} from './canvasModel';
export interface CanvasView {
@ -65,6 +70,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
private text: SVGSVGElement;
private adoptedText: SVG.Container;
private background: HTMLCanvasElement;
private masksContent: HTMLCanvasElement;
private bitmap: HTMLCanvasElement;
private grid: SVGSVGElement;
private content: SVGSVGElement;
@ -82,6 +88,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
private drawnIssueRegions: Record<number, SVG.Shape>;
private geometry: Geometry;
private drawHandler: DrawHandler;
private masksHandler: MasksHandler;
private editHandler: EditHandler;
private mergeHandler: MergeHandler;
private splitHandler: SplitHandler;
@ -95,6 +102,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
private snapToAngleResize: number;
private innerObjectsFlags: {
drawHidden: Record<number, boolean>;
editHidden: Record<number, boolean>;
};
private set mode(value: Mode) {
@ -180,7 +188,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
return this.innerObjectsFlags.drawHidden[clientID] || false;
}
private setupInnerFlags(clientID: number, path: 'drawHidden', value: boolean): void {
private setupInnerFlags(clientID: number, path: 'drawHidden' | 'editHidden', value: boolean): void {
this.innerObjectsFlags[path][clientID] = value;
const shape = this.svgShapes[clientID];
const text = this.svgTexts[clientID];
@ -253,7 +261,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
}
private onDrawDone(data: any | null, duration: number, continueDraw?: boolean): void {
private onDrawDone(data: any | null, duration: number, continueDraw?: boolean, prevDrawData?: DrawData): void {
const hiddenBecauseOfDraw = Object.keys(this.innerObjectsFlags.drawHidden)
.map((_clientID): number => +_clientID);
if (hiddenBecauseOfDraw.length) {
@ -266,16 +274,16 @@ export class CanvasViewImpl implements CanvasView, Listener {
const { clientID, elements } = data as any;
const points = data.points || elements.map((el: any) => el.points).flat();
if (typeof clientID === 'number') {
const [state] = this.controller.objects
.filter((_state: any): boolean => _state.clientID === clientID);
this.onEditDone(state, points);
const event: CustomEvent = new CustomEvent('canvas.canceled', {
bubbles: false,
cancelable: true,
});
this.canvas.dispatchEvent(event);
const [state] = this.controller.objects.filter((_state: any): boolean => _state.clientID === clientID);
this.onEditDone(state, points);
return;
}
@ -296,23 +304,50 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.canvas.dispatchEvent(event);
} else if (!continueDraw) {
const event: CustomEvent = new CustomEvent('canvas.canceled', {
this.canvas.dispatchEvent(new CustomEvent('canvas.canceled', {
bubbles: false,
cancelable: true,
});
this.canvas.dispatchEvent(event);
}));
}
if (!continueDraw) {
this.mode = Mode.IDLE;
this.controller.draw({
enabled: false,
});
if (continueDraw) {
this.canvas.dispatchEvent(
new CustomEvent('canvas.drawstart', {
bubbles: false,
cancelable: true,
detail: {
drawData: prevDrawData,
},
}),
);
} else {
// when draw stops from inside canvas (for example if use predefined number of points)
this.controller.draw({ enabled: false });
}
}
private onEditDone(state: any, points: number[], rotation?: number): void {
private onEditStart = (state?: any): void => {
this.canvas.style.cursor = 'crosshair';
this.deactivate();
this.canvas.dispatchEvent(
new CustomEvent('canvas.editstart', {
bubbles: false,
cancelable: true,
detail: {
state,
},
}),
);
if (state && state.shapeType === 'mask') {
this.setupInnerFlags(state.clientID, 'editHidden', true);
}
this.mode = Mode.EDIT;
};
private onEditDone = (state: any, points: number[], rotation?: number): void => {
this.canvas.style.cursor = '';
if (state && points) {
const event: CustomEvent = new CustomEvent('canvas.edited', {
bubbles: false,
@ -334,8 +369,11 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.canvas.dispatchEvent(event);
}
this.mode = Mode.IDLE;
for (const clientID of Object.keys(this.innerObjectsFlags.editHidden)) {
this.setupInnerFlags(+clientID, 'editHidden', false);
}
this.mode = Mode.IDLE;
};
private onMergeDone(objects: any[] | null, duration?: number): void {
if (objects) {
@ -358,10 +396,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.canvas.dispatchEvent(event);
}
this.controller.merge({
enabled: false,
});
this.controller.merge({ enabled: false });
this.mode = Mode.IDLE;
}
@ -386,10 +421,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.canvas.dispatchEvent(event);
}
this.controller.split({
enabled: false,
});
this.controller.split({ enabled: false });
this.mode = Mode.IDLE;
}
@ -413,10 +445,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.canvas.dispatchEvent(event);
}
this.controller.group({
enabled: false,
});
this.controller.group({ enabled: false });
this.mode = Mode.IDLE;
}
@ -459,7 +488,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
});
this.canvas.dispatchEvent(event);
e.preventDefault();
}
}
@ -523,6 +551,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
// Transform handlers
this.drawHandler.transform(this.geometry);
this.masksHandler.transform(this.geometry);
this.editHandler.transform(this.geometry);
this.zoomHandler.transform(this.geometry);
this.autoborderHandler.transform(this.geometry);
@ -533,7 +562,11 @@ export class CanvasViewImpl implements CanvasView, Listener {
private transformCanvas(): void {
// Transform canvas
for (const obj of [
this.background, this.grid, this.content, this.bitmap, this.attachmentBoard,
this.background,
this.grid,
this.content,
this.bitmap,
this.attachmentBoard,
]) {
obj.style.transform = `scale(${this.geometry.scale}) rotate(${this.geometry.angle}deg)`;
}
@ -545,6 +578,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
for (const element of [
...window.document.getElementsByClassName('svg_select_points'),
...window.document.getElementsByClassName('svg_select_points_rot'),
...window.document.getElementsByClassName('svg_select_boundingRect'),
]) {
element.setAttribute('stroke-width', `${consts.POINTS_STROKE_WIDTH / this.geometry.scale}`);
element.setAttribute('r', `${this.configuration.controlPointsSize / this.geometry.scale}`);
@ -606,6 +640,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
// Transform handlers
this.drawHandler.transform(this.geometry);
this.masksHandler.transform(this.geometry);
this.editHandler.transform(this.geometry);
this.zoomHandler.transform(this.geometry);
this.autoborderHandler.transform(this.geometry);
@ -614,7 +649,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
private resizeCanvas(): void {
for (const obj of [this.background, this.grid, this.bitmap]) {
for (const obj of [this.background, this.masksContent, this.grid, this.bitmap]) {
obj.style.width = `${this.geometry.image.width}px`;
obj.style.height = `${this.geometry.image.height}px`;
}
@ -685,6 +720,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
private setupObjects(states: any[]): void {
const created = [];
const updated = [];
for (const state of states) {
if (!(state.clientID in this.drawnStates)) {
created.push(state);
@ -831,15 +867,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
const { points } = state;
this.onEditDone(state, points.slice(0, pointID * 2).concat(points.slice(pointID * 2 + 2)));
} else if (e.shiftKey) {
this.canvas.dispatchEvent(
new CustomEvent('canvas.editstart', {
bubbles: false,
cancelable: true,
}),
);
this.mode = Mode.EDIT;
this.deactivate();
this.onEditStart(state);
this.editHandler.edit({
enabled: true,
state,
@ -901,6 +929,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
deepSelect: true,
pointSize: (2 * this.configuration.controlPointsSize) / this.geometry.scale,
rotationPoint: shape.type === 'rect' || shape.type === 'ellipse',
pointsExclude: shape.type === 'image' ? ['lt', 'rt', 'rb', 'lb', 't', 'r', 'b', 'l'] : [],
pointType(cx: number, cy: number): SVG.Circle {
const circle: SVG.Circle = this.nested
.circle(this.options.pointSize)
@ -974,6 +1003,19 @@ export class CanvasViewImpl implements CanvasView, Listener {
title.textContent = 'Hold Shift to snap angle';
rotationPoint.appendChild(title);
}
if (value && shape.type === 'image') {
const [boundingRect] = window.document.getElementsByClassName('svg_select_boundingRect');
if (boundingRect) {
(boundingRect as SVGRectElement).style.opacity = '1';
boundingRect.setAttribute('fill', 'none');
boundingRect.setAttribute('stroke', shape.attr('stroke'));
boundingRect.setAttribute('stroke-width', `${consts.BASE_STROKE_WIDTH / this.geometry.scale}px`);
if (shape.hasClass('cvat_canvas_shape_occluded')) {
boundingRect.setAttribute('stroke-dasharray', '5');
}
}
}
}
private onShiftKeyDown = (e: KeyboardEvent): void => {
@ -981,7 +1023,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.snapToAngleResize = consts.SNAP_TO_ANGLE_RESIZE_SHIFT;
if (this.activeElement) {
const shape = this.svgShapes[this.activeElement.clientID];
if (shape && shape.hasClass('cvat_canvas_shape_activated')) {
if (shape && shape?.remember('_selectHandler')?.options?.rotationPoint) {
if (this.drawnStates[this.activeElement.clientID]?.shapeType === 'skeleton') {
const wrappingRect = (shape as any).children().find((child: SVG.Element) => child.type === 'rect');
if (wrappingRect) {
@ -1000,7 +1042,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.snapToAngleResize = consts.SNAP_TO_ANGLE_RESIZE_DEFAULT;
if (this.activeElement) {
const shape = this.svgShapes[this.activeElement.clientID];
if (shape && shape.hasClass('cvat_canvas_shape_activated')) {
if (shape && shape?.remember('_selectHandler')?.options?.rotationPoint) {
if (this.drawnStates[this.activeElement.clientID]?.shapeType === 'skeleton') {
const wrappingRect = (shape as any).children().find((child: SVG.Element) => child.type === 'rect');
if (wrappingRect) {
@ -1036,6 +1078,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.snapToAngleResize = consts.SNAP_TO_ANGLE_RESIZE_DEFAULT;
this.innerObjectsFlags = {
drawHidden: {},
editHidden: {},
};
// Create HTML elements
@ -1043,6 +1086,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.text = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.adoptedText = SVG.adopt((this.text as any) as HTMLElement) as SVG.Container;
this.background = window.document.createElement('canvas');
this.masksContent = window.document.createElement('canvas');
this.bitmap = window.document.createElement('canvas');
// window.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
@ -1110,6 +1154,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
// Setup content
this.text.setAttribute('id', 'cvat_canvas_text_content');
this.background.setAttribute('id', 'cvat_canvas_background');
this.masksContent.setAttribute('id', 'cvat_canvas_masks_content');
this.content.setAttribute('id', 'cvat_canvas_content');
this.bitmap.setAttribute('id', 'cvat_canvas_bitmap');
this.bitmap.style.display = 'none';
@ -1131,6 +1176,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.canvas.appendChild(this.loadingAnimation);
this.canvas.appendChild(this.text);
this.canvas.appendChild(this.background);
this.canvas.appendChild(this.masksContent);
this.canvas.appendChild(this.bitmap);
this.canvas.appendChild(this.grid);
this.canvas.appendChild(this.content);
@ -1146,7 +1192,15 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.geometry,
this.configuration,
);
this.editHandler = new EditHandlerImpl(this.onEditDone.bind(this), this.adoptedContent, this.autoborderHandler);
this.masksHandler = new MasksHandlerImpl(
this.onDrawDone.bind(this),
this.controller.draw.bind(this.controller),
this.onEditStart,
this.onEditDone,
this.drawHandler,
this.masksContent,
);
this.editHandler = new EditHandlerImpl(this.onEditDone, this.adoptedContent, this.autoborderHandler);
this.mergeHandler = new MergeHandlerImpl(
this.onMergeDone.bind(this),
this.onFindObject.bind(this),
@ -1177,12 +1231,12 @@ export class CanvasViewImpl implements CanvasView, Listener {
);
// Setup event handlers
this.content.addEventListener('dblclick', (e: MouseEvent): void => {
this.canvas.addEventListener('dblclick', (e: MouseEvent): void => {
this.controller.fit();
e.preventDefault();
});
this.content.addEventListener('mousedown', (event): void => {
this.canvas.addEventListener('mousedown', (event): void => {
if ([0, 1].includes(event.button)) {
if (
[Mode.IDLE, Mode.DRAG_CANVAS, Mode.MERGE, Mode.SPLIT]
@ -1197,7 +1251,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
window.document.addEventListener('keydown', this.onShiftKeyDown);
window.document.addEventListener('keyup', this.onShiftKeyUp);
this.content.addEventListener('wheel', (event): void => {
this.canvas.addEventListener('wheel', (event): void => {
if (event.ctrlKey) return;
const { offset } = this.controller.geometry;
const point = translateToSVG(this.content, [event.clientX, event.clientY]);
@ -1211,7 +1265,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
event.preventDefault();
});
this.content.addEventListener('mousemove', (e): void => {
this.canvas.addEventListener('mousemove', (e): void => {
this.controller.drag(e.clientX, e.clientY);
if (this.mode !== Mode.IDLE) return;
@ -1244,32 +1298,41 @@ export class CanvasViewImpl implements CanvasView, Listener {
const { configuration } = model;
const updateShapeViews = (states: DrawnState[], parentState?: DrawnState): void => {
for (const state of states) {
const { fill, stroke, 'fill-opacity': fillOpacity } = this.getShapeColorization(state, { configuration, parentState });
const shapeView = window.document.getElementById(`cvat_canvas_shape_${state.clientID}`);
for (const drawnState of states) {
const {
fill, stroke, 'fill-opacity': fillOpacity,
} = this.getShapeColorization(drawnState, { parentState });
const shapeView = window.document.getElementById(`cvat_canvas_shape_${drawnState.clientID}`);
const [objectState] = this.controller.objects
.filter((_state: any) => _state.clientID === drawnState.clientID);
if (shapeView) {
const handler = (shapeView as any).instance.remember('_selectHandler');
if (handler && handler.nested) {
handler.nested.fill({ color: fill });
}
if (drawnState.shapeType === 'mask') {
// if there are masks, we need to redraw them
this.deleteObjects([drawnState]);
this.addObjects([objectState]);
continue;
}
(shapeView as any).instance
.fill({ color: fill, opacity: fillOpacity })
.stroke({ color: stroke });
}
if (state.elements) {
updateShapeViews(state.elements, state);
if (drawnState.elements) {
updateShapeViews(drawnState.elements, drawnState);
}
}
};
if (configuration.shapeOpacity !== this.configuration.shapeOpacity ||
const withUpdatingShapeViews = configuration.shapeOpacity !== this.configuration.shapeOpacity ||
configuration.selectedShapeOpacity !== this.configuration.selectedShapeOpacity ||
configuration.outlinedBorders !== this.configuration.outlinedBorders ||
configuration.colorBy !== this.configuration.colorBy) {
updateShapeViews(Object.values(this.drawnStates));
}
configuration.colorBy !== this.configuration.colorBy;
if (configuration.displayAllText && !this.configuration.displayAllText) {
for (const i in this.drawnStates) {
@ -1298,6 +1361,10 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
this.configuration = configuration;
if (withUpdatingShapeViews) {
updateShapeViews(Object.values(this.drawnStates));
}
if (recreateText) {
const states = this.controller.objects;
for (const key of Object.keys(this.drawnStates)) {
@ -1327,6 +1394,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.activate(activeElement);
this.editHandler.configurate(this.configuration);
this.drawHandler.configurate(this.configuration);
this.masksHandler.configurate(this.configuration);
this.autoborderHandler.configurate(this.configuration);
this.interactionHandler.configurate(this.configuration);
this.transformCanvas();
@ -1495,19 +1563,46 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
} else if (reason === UpdateReasons.DRAW) {
const data: DrawData = this.controller.drawData;
if (data.enabled && this.mode === Mode.IDLE) {
if (data.enabled && [Mode.IDLE, Mode.DRAW].includes(this.mode)) {
if (data.shapeType !== 'mask') {
this.drawHandler.draw(data, this.geometry);
} else {
this.masksHandler.draw(data);
}
if (this.mode === Mode.IDLE) {
this.canvas.style.cursor = 'crosshair';
this.mode = Mode.DRAW;
this.canvas.dispatchEvent(
new CustomEvent('canvas.drawstart', {
bubbles: false,
cancelable: true,
detail: {
drawData: data,
},
}),
);
if (typeof data.redraw === 'number') {
this.setupInnerFlags(data.redraw, 'drawHidden', true);
}
this.drawHandler.draw(data, this.geometry);
} else {
}
} else if (this.mode !== Mode.IDLE) {
this.canvas.style.cursor = '';
if (this.mode !== Mode.IDLE) {
this.mode = Mode.IDLE;
if (this.masksHandler.enabled) {
this.masksHandler.draw(data);
} else {
this.drawHandler.draw(data, this.geometry);
}
}
} else if (reason === UpdateReasons.EDIT) {
const data = this.controller.editData;
if (data.enabled && data.state.shapeType === 'mask') {
this.masksHandler.edit(data);
} else if (this.masksHandler.enabled) {
this.masksHandler.edit(data);
}
} else if (reason === UpdateReasons.INTERACT) {
const data: InteractionData = this.controller.interactionData;
if (data.enabled && (this.mode === Mode.IDLE || data.intermediateShape)) {
@ -1561,7 +1656,11 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
} else if (reason === UpdateReasons.CANCEL) {
if (this.mode === Mode.DRAW) {
if (this.masksHandler.enabled) {
this.masksHandler.cancel();
} else {
this.drawHandler.cancel();
}
} else if (this.mode === Mode.INTERACT) {
this.interactionHandler.cancel();
} else if (this.mode === Mode.MERGE) {
@ -1573,7 +1672,11 @@ export class CanvasViewImpl implements CanvasView, Listener {
} else if (this.mode === Mode.SELECT_REGION) {
this.regionSelector.cancel();
} else if (this.mode === Mode.EDIT) {
if (this.masksHandler.enabled) {
this.masksHandler.cancel();
} else {
this.editHandler.cancel();
}
} else if (this.mode === Mode.DRAG_CANVAS) {
this.canvas.dispatchEvent(
new CustomEvent('canvas.dragstop', {
@ -1684,6 +1787,22 @@ export class CanvasViewImpl implements CanvasView, Listener {
ctx.fill();
}
if (state.shapeType === 'mask') {
const { points } = state;
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(255, 255, 255, points, 4);
imageDataToDataURL(imageBitmap, right - left + 1, bottom - top + 1,
(dataURL: string) => new Promise((resolve) => {
const img = document.createElement('img');
img.addEventListener('load', () => {
ctx.drawImage(img, left, top);
URL.revokeObjectURL(dataURL);
resolve();
});
img.src = dataURL;
}));
}
if (state.shapeType === 'cuboid') {
for (let i = 0; i < 5; i++) {
const points = [
@ -1737,20 +1856,19 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
private getShapeColorization(state: any, opts: {
configuration?: Configuration,
parentState?: any,
} = {}): { fill: string; stroke: string, 'fill-opacity': number } {
const { shapeType } = state;
const parentShapeType = opts.parentState?.shapeType;
const configuration = opts.configuration || this.configuration;
const { configuration } = this;
const { colorBy, shapeOpacity, outlinedBorders } = configuration;
let shapeColor = '';
if (colorBy === 'Instance') {
if (colorBy === ColorBy.INSTANCE) {
shapeColor = state.color;
} else if (colorBy === 'Group') {
} else if (colorBy === ColorBy.GROUP) {
shapeColor = state.group.color;
} else if (colorBy === 'Label') {
} else if (colorBy === ColorBy.LABEL) {
shapeColor = state.label.color;
}
const outlinedColor = parentShapeType === 'skeleton' ? 'black' : outlinedBorders || shapeColor;
@ -1820,6 +1938,13 @@ export class CanvasViewImpl implements CanvasView, Listener {
state.points.length !== drawnState.points.length ||
state.points.some((p: number, id: number): boolean => p !== drawnState.points[id])
) {
if (state.shapeType === 'mask') {
// if masks points were updated, draw from scratch
this.deleteObjects([this.drawnStates[+clientID]]);
this.addObjects([state]);
continue;
}
const translatedPoints: number[] = this.translateToCanvas(state.points);
if (state.shapeType === 'rectangle') {
@ -1884,19 +2009,23 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
}
if (drawnState.label.id !== state.label.id || drawnState.color !== state.color) {
if (
drawnState.label.id !== state.label.id ||
drawnState.group.id !== state.group.id ||
drawnState.group.color !== state.group.color ||
drawnState.color !== state.color
) {
// update shape color if necessary
if (shape) {
shape.attr({
...this.getShapeColorization(state),
});
if (state.shapeType === 'mask') {
// if masks points were updated, draw from scratch
this.deleteObjects([this.drawnStates[+clientID]]);
this.addObjects([state]);
continue;
} else {
shape.attr({ ...this.getShapeColorization(state) });
}
}
if (
drawnState.group.id !== state.group.id || drawnState.group.color !== state.group.color
) {
shape.attr({ ...this.getShapeColorization(state) });
}
this.drawnStates[state.clientID] = this.saveState(state);
@ -1931,13 +2060,16 @@ export class CanvasViewImpl implements CanvasView, Listener {
const { displayAllText } = this.configuration;
for (const state of states) {
const points: number[] = state.points as number[];
const translatedPoints: number[] = this.translateToCanvas(points);
// TODO: Use enums after typification cvat-core
if (state.shapeType === 'rectangle') {
this.svgShapes[state.clientID] = this.addRect(translatedPoints, state);
if (state.shapeType === 'mask') {
this.svgShapes[state.clientID] = this.addMask(points, state);
} else if (state.shapeType === 'skeleton') {
this.svgShapes[state.clientID] = this.addSkeleton(state);
} else {
const translatedPoints: number[] = this.translateToCanvas(points);
if (state.shapeType === 'rectangle') {
this.svgShapes[state.clientID] = this.addRect(translatedPoints, state);
} else {
const stringified = this.stringifyToCanvas(translatedPoints);
@ -1955,6 +2087,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
continue;
}
}
}
this.svgShapes[state.clientID].on('click.canvas', (): void => {
this.canvas.dispatchEvent(
@ -2027,8 +2160,19 @@ export class CanvasViewImpl implements CanvasView, Listener {
const drawnState = this.drawnStates[clientID];
const shape = this.svgShapes[clientID];
if (drawnState.shapeType === 'points') {
this.svgShapes[clientID]
.remember('_selectHandler').nested
.removeClass('cvat_canvas_shape_activated');
} else {
shape.removeClass('cvat_canvas_shape_activated');
}
shape.removeClass('cvat_canvas_shape_draggable');
if (drawnState.shapeType === 'mask') {
shape.attr('opacity', `${this.configuration.shapeOpacity}`);
} else {
shape.attr('fill-opacity', `${this.configuration.shapeOpacity}`);
}
if (!drawnState.pinned) {
(shape as any).off('dragstart');
@ -2044,6 +2188,10 @@ export class CanvasViewImpl implements CanvasView, Listener {
(shape as any).attr('projections', false);
}
if (drawnState.shapeType === 'mask') {
(shape as any).off('mousedown');
}
(shape as any).off('resizestart');
(shape as any).off('resizing');
(shape as any).off('resizedone');
@ -2109,7 +2257,20 @@ export class CanvasViewImpl implements CanvasView, Listener {
return;
}
if (state.shapeType === 'points') {
this.svgShapes[clientID]
.remember('_selectHandler').nested
.addClass('cvat_canvas_shape_activated');
} else {
shape.addClass('cvat_canvas_shape_activated');
}
if (state.shapeType === 'mask') {
shape.attr('opacity', `${this.configuration.selectedShapeOpacity}`);
} else {
shape.attr('fill-opacity', `${this.configuration.selectedShapeOpacity}`);
}
if (state.shapeType === 'points') {
this.content.append(this.svgShapes[clientID].remember('_selectHandler').nested.node);
} else {
@ -2137,7 +2298,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
if (!state.pinned) {
shape.addClass('cvat_canvas_shape_draggable');
(shape as any)
.draggable()
.draggable({
...(state.shapeType === 'mask' ? { snapToGrid: 1 } : {}),
})
.on('dragstart', (): void => {
this.mode = Mode.DRAG;
hideText();
@ -2153,10 +2316,29 @@ export class CanvasViewImpl implements CanvasView, Listener {
showText();
const p1 = e.detail.handler.startPoints.point;
const p2 = e.detail.p;
const delta = 1;
const dx2 = (p1.x - p2.x) ** 2;
const dy2 = (p1.y - p2.y) ** 2;
if (Math.sqrt(dx2 + dy2) >= delta) {
if (Math.sqrt(dx2 + dy2) > 0) {
if (shape.type === 'image') {
const { points } = state;
const x = Math.trunc(shape.x()) - this.geometry.offset;
const y = Math.trunc(shape.y()) - this.geometry.offset;
points.splice(-4);
points.push(x, y, x + shape.width() - 1, y + shape.height() - 1);
this.drawnStates[state.clientID].points = points;
this.onEditDone(state, points);
this.canvas.dispatchEvent(
new CustomEvent('canvas.dragshape', {
bubbles: false,
cancelable: true,
detail: {
id: state.clientID,
},
}),
);
return;
}
// these points does not take into account possible transformations, applied on the element
// so, if any (like rotation) we need to map them to canvas coordinate space
let points = readPointsFromShape(shape);
@ -2213,6 +2395,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.mode = Mode.IDLE;
};
if (state.shapeType !== 'mask') {
(shape as any)
.resize({
snapToGrid: 0.1,
@ -2273,6 +2456,14 @@ export class CanvasViewImpl implements CanvasView, Listener {
);
}
});
} else {
(shape as any).on('dblclick', (e: MouseEvent) => {
if (e.shiftKey) {
this.controller.edit({ enabled: true, state });
e.stopPropagation();
}
});
}
this.canvas.dispatchEvent(
new CustomEvent('canvas.activated', {
@ -2605,6 +2796,41 @@ export class CanvasViewImpl implements CanvasView, Listener {
return cube;
}
private addMask(points: number[], state: any): SVG.Image {
const colorization = this.getShapeColorization(state);
const color = fabric.Color.fromHex(colorization.fill).getSource();
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(color[0], color[1], color[2], points, 4);
const image = this.adoptedContent.image().attr({
clientID: state.clientID,
'color-rendering': 'optimizeQuality',
id: `cvat_canvas_shape_${state.clientID}`,
'shape-rendering': 'geometricprecision',
'data-z-order': state.zOrder,
opacity: colorization['fill-opacity'],
stroke: colorization.stroke,
}).addClass('cvat_canvas_shape');
image.move(this.geometry.offset + left, this.geometry.offset + top);
imageDataToDataURL(
imageBitmap,
right - left + 1,
bottom - top + 1,
(dataURL: string) => new Promise((resolve, reject) => {
image.loaded(() => {
resolve();
});
image.error(() => {
reject();
});
image.load(dataURL);
}),
);
return image;
}
private addSkeleton(state: any): any {
const skeleton = (this.adoptedContent as any)
.group()
@ -2689,7 +2915,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
};
const mousemove = (e: MouseEvent) => {
const mousemove = (e: MouseEvent): void => {
if (this.mode === Mode.IDLE) {
// stop propagation to canvas where it calls another canvas.moved
// and does not allow to activate an element

@ -77,7 +77,12 @@ function checkConstraint(shapeType: string, points: number[], box: Box | null =
export class DrawHandlerImpl implements DrawHandler {
// callback is used to notify about creating new shape
private onDrawDone: (data: object | null, duration?: number, continueDraw?: boolean) => void;
private onDrawDoneDefault: (
data: object | null,
duration?: number,
continueDraw?: boolean,
prevDrawData?: DrawData,
) => void;
private startTimestamp: number;
private canvas: SVG.Container;
private text: SVG.Container;
@ -344,6 +349,15 @@ export class DrawHandlerImpl implements DrawHandler {
this.crosshair.hide();
}
private onDrawDone(...args: any[]): void {
if (this.drawData.onDrawDone) {
this.drawData.onDrawDone.call(this, ...args);
return;
}
this.onDrawDoneDefault.call(this, ...args);
}
private release(): void {
if (!this.initialized) {
// prevents recursive calls
@ -838,10 +852,6 @@ export class DrawHandlerImpl implements DrawHandler {
this.getFinalCuboidCoordinates(targetPoints) :
this.getFinalPolyshapeCoordinates(targetPoints, true);
if (!e.detail.originalEvent.ctrlKey) {
this.release();
}
if (checkConstraint(shapeType, points, box)) {
this.onDrawDone(
{
@ -855,8 +865,13 @@ export class DrawHandlerImpl implements DrawHandler {
},
Date.now() - this.startTimestamp,
e.detail.originalEvent.ctrlKey,
this.drawData,
);
}
if (!e.detail.originalEvent.ctrlKey) {
this.release();
}
});
}
@ -893,10 +908,6 @@ export class DrawHandlerImpl implements DrawHandler {
this.drawInstance.on('done', (e: CustomEvent): void => {
const points = readPointsFromShape((e.target as any as { instance: SVG.Rect }).instance);
const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(points, !this.drawData.initialState.rotation);
if (!e.detail.originalEvent.ctrlKey) {
this.release();
}
if (checkConstraint('rectangle', [xtl, ytl, xbr, ybr])) {
this.onDrawDone(
{
@ -911,8 +922,13 @@ export class DrawHandlerImpl implements DrawHandler {
},
Date.now() - this.startTimestamp,
e.detail.originalEvent.ctrlKey,
this.drawData,
);
}
if (!e.detail.originalEvent.ctrlKey) {
this.release();
}
});
}
@ -932,11 +948,6 @@ export class DrawHandlerImpl implements DrawHandler {
const points = this.getFinalEllipseCoordinates(
readPointsFromShape((e.target as any as { instance: SVG.Ellipse }).instance), false,
);
if (!e.detail.originalEvent.ctrlKey) {
this.release();
}
if (checkConstraint('ellipse', points)) {
this.onDrawDone(
{
@ -951,8 +962,13 @@ export class DrawHandlerImpl implements DrawHandler {
},
Date.now() - this.startTimestamp,
e.detail.originalEvent.ctrlKey,
this.drawData,
);
}
if (!e.detail.originalEvent.ctrlKey) {
this.release();
}
});
}
@ -1044,15 +1060,16 @@ export class DrawHandlerImpl implements DrawHandler {
rotation: this.drawData.initialState.rotation,
};
if (!e.detail.originalEvent.ctrlKey) {
this.release();
}
this.onDrawDone(
result,
Date.now() - this.startTimestamp,
e.detail.originalEvent.ctrlKey,
this.drawData,
);
if (!e.detail.originalEvent.ctrlKey) {
this.release();
}
});
this.canvas.on('mousemove.draw', (): void => {
@ -1213,7 +1230,7 @@ export class DrawHandlerImpl implements DrawHandler {
}
public constructor(
onDrawDone: (data: object | null, duration?: number, continueDraw?: boolean) => void,
onDrawDone: (data: object | null, duration?: number, continueDraw?: boolean, prevDrawData?: DrawData) => void,
canvas: SVG.Container,
text: SVG.Container,
autoborderHandler: AutoborderHandler,
@ -1226,7 +1243,7 @@ export class DrawHandlerImpl implements DrawHandler {
this.outlinedBorders = configuration.outlinedBorders || 'black';
this.autobordersEnabled = false;
this.startTimestamp = Date.now();
this.onDrawDone = onDrawDone;
this.onDrawDoneDefault = onDrawDone;
this.canvas = canvas;
this.text = text;
this.initialized = false;

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -7,11 +8,11 @@ import 'svg.select.js';
import consts from './consts';
import { translateFromSVG, pointsToNumberArray } from './shared';
import { EditData, Geometry, Configuration } from './canvasModel';
import { PolyEditData, Geometry, Configuration } from './canvasModel';
import { AutoborderHandler } from './autoborderHandler';
export interface EditHandler {
edit(editData: EditData): void;
edit(editData: PolyEditData): void;
transform(geometry: Geometry): void;
configurate(configuration: Configuration): void;
cancel(): void;
@ -22,7 +23,7 @@ export class EditHandlerImpl implements EditHandler {
private autoborderHandler: AutoborderHandler;
private geometry: Geometry | null;
private canvas: SVG.Container;
private editData: EditData | null;
private editData: PolyEditData | null;
private editedShape: SVG.Shape | null;
private editLine: SVG.PolyLine | null;
private clones: SVG.Polygon[];

@ -1,11 +1,11 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import * as SVG from 'svg.js';
import { GroupData } from './canvasModel';
import { translateToSVG } from './shared';
import { expandChannels, imageDataToDataURL, translateToSVG } from './shared';
export interface GroupHandler {
group(groupData: GroupData): void;
@ -31,6 +31,7 @@ export class GroupHandlerImpl implements GroupHandler {
private initialized: boolean;
private statesToBeGroupped: any[];
private highlightedShapes: Record<number, SVG.Shape>;
private groupingCopies: Record<number, SVG.Image>;
private getSelectionBox(
event: MouseEvent,
@ -106,11 +107,7 @@ export class GroupHandlerImpl implements GroupHandler {
(state: any): boolean => state.clientID === clientID,
)[0];
if (objectState) {
this.statesToBeGroupped.push(objectState);
this.highlightedShapes[clientID] = shape;
(shape as any).addClass('cvat_canvas_shape_grouping');
}
this.appendToSelection(objectState);
}
}
}
@ -164,6 +161,7 @@ export class GroupHandlerImpl implements GroupHandler {
this.canvas = canvas;
this.statesToBeGroupped = [];
this.highlightedShapes = {};
this.groupingCopies = {};
this.selectionRect = null;
this.initialized = false;
this.startSelectionPoint = {
@ -185,23 +183,67 @@ export class GroupHandlerImpl implements GroupHandler {
}
}
private appendToSelection(objectState: any): void {
const { clientID } = objectState;
const shape = this.canvas.select(`#cvat_canvas_shape_${clientID}`).first();
if (shape) {
if (objectState.shapeType === 'mask') {
const { points } = objectState;
const colorRGB = [139, 0, 139];
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(colorRGB[0], colorRGB[1], colorRGB[2], points, 4);
const bbox = shape.bbox();
const image = this.canvas.image().attr({
'color-rendering': 'optimizeQuality',
'shape-rendering': 'geometricprecision',
'data-z-order': Number.MAX_SAFE_INTEGER,
'grouping-copy-for': clientID,
}).move(bbox.x, bbox.y);
this.groupingCopies[clientID] = image;
imageDataToDataURL(
imageBitmap,
right - left + 1,
bottom - top + 1,
(dataURL: string) => new Promise((resolve, reject) => {
image.loaded(() => {
resolve();
});
image.error(() => {
reject();
});
image.load(dataURL);
}),
);
}
this.statesToBeGroupped.push(objectState);
this.highlightedShapes[clientID] = shape;
shape.addClass('cvat_canvas_shape_grouping');
}
}
public select(objectState: any): void {
const stateIndexes = this.statesToBeGroupped.map((state): number => state.clientID);
const includes = stateIndexes.indexOf(objectState.clientID);
const { clientID } = objectState;
const includes = stateIndexes.indexOf(clientID);
if (includes !== -1) {
const shape = this.highlightedShapes[objectState.clientID];
const shape = this.highlightedShapes[clientID];
this.statesToBeGroupped.splice(includes, 1);
if (shape) {
delete this.highlightedShapes[objectState.clientID];
if (this.groupingCopies[clientID]) {
// remove clones for masks
this.groupingCopies[clientID].remove();
delete this.groupingCopies[clientID];
}
delete this.highlightedShapes[clientID];
shape.removeClass('cvat_canvas_shape_grouping');
}
} else {
const shape = this.canvas.select(`#cvat_canvas_shape_${objectState.clientID}`).first();
if (shape) {
this.statesToBeGroupped.push(objectState);
this.highlightedShapes[objectState.clientID] = shape;
shape.addClass('cvat_canvas_shape_grouping');
}
this.appendToSelection(objectState);
}
}
@ -210,8 +252,14 @@ export class GroupHandlerImpl implements GroupHandler {
const shape = this.highlightedShapes[state.clientID];
shape.removeClass('cvat_canvas_shape_grouping');
}
for (const shape of Object.values(this.groupingCopies)) {
shape.remove();
}
this.statesToBeGroupped = [];
this.highlightedShapes = {};
this.groupingCopies = {};
if (this.selectionRect) {
this.selectionRect.remove();
this.selectionRect = null;

@ -6,7 +6,7 @@ import * as SVG from 'svg.js';
import consts from './consts';
import Crosshair from './crosshair';
import {
translateToSVG, PropType, stringifyPoints, translateToCanvas,
translateToSVG, PropType, stringifyPoints, translateToCanvas, expandChannels, imageDataToDataURL,
} from './shared';
import {
@ -304,6 +304,33 @@ export class InteractionHandlerImpl implements InteractionHandler {
.fill({ opacity: this.selectedShapeOpacity, color: 'white' })
.addClass('cvat_canvas_interact_intermediate_shape');
this.selectize(true, this.drawnIntermediateShape, erroredShape);
} else if (shapeType === 'mask') {
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(255, 255, 255, points, 4);
const image = this.canvas.image().attr({
'color-rendering': 'optimizeQuality',
'shape-rendering': 'geometricprecision',
'pointer-events': 'none',
opacity: 0.5,
}).addClass('cvat_canvas_interact_intermediate_shape');
image.move(this.geometry.offset, this.geometry.offset);
this.drawnIntermediateShape = image;
imageDataToDataURL(
imageBitmap,
right - left + 1,
bottom - top + 1,
(dataURL: string) => new Promise((resolve, reject) => {
image.loaded(() => {
resolve();
});
image.error(() => {
reject();
});
image.load(dataURL);
}),
);
} else {
throw new Error(
`Shape type "${shapeType}" was not implemented at interactionHandler::updateIntermediateShape`,

@ -0,0 +1,655 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { fabric } from 'fabric';
import {
DrawData, MasksEditData, Geometry, Configuration, BrushTool, ColorBy,
} from './canvasModel';
import consts from './consts';
import { DrawHandler } from './drawHandler';
import {
PropType, computeWrappingBox, alphaChannelOnly, expandChannels, imageDataToDataURL,
} from './shared';
interface WrappingBBox {
left: number;
top: number;
right: number;
bottom: number;
}
export interface MasksHandler {
draw(drawData: DrawData): void;
edit(state: MasksEditData): void;
configurate(configuration: Configuration): void;
transform(geometry: Geometry): void;
cancel(): void;
enabled: boolean;
}
export class MasksHandlerImpl implements MasksHandler {
private onDrawDone: (
data: object | null,
duration?: number,
continueDraw?: boolean,
prevDrawData?: DrawData,
) => void;
private onDrawRepeat: (data: DrawData) => void;
private onEditStart: (state: any) => void;
private onEditDone: (state: any, points: number[]) => void;
private vectorDrawHandler: DrawHandler;
private redraw: number | null;
private isDrawing: boolean;
private isEditing: boolean;
private isInsertion: boolean;
private isMouseDown: boolean;
private isBrushSizeChanging: boolean;
private resizeBrushToolLatestX: number;
private brushMarker: fabric.Rect | fabric.Circle | null;
private drawablePolygon: null | fabric.Polygon;
private isPolygonDrawing: boolean;
private drawnObjects: (fabric.Polygon | fabric.Circle | fabric.Rect | fabric.Line | fabric.Image)[];
private tool: DrawData['brushTool'] | null;
private drawData: DrawData | null;
private canvas: fabric.Canvas;
private editData: MasksEditData | null;
private colorBy: ColorBy;
private latestMousePos: { x: number; y: number; };
private startTimestamp: number;
private geometry: Geometry;
private drawingOpacity: number;
private keepDrawnPolygon(): void {
const canvasWrapper = this.canvas.getElement().parentElement;
canvasWrapper.style.pointerEvents = '';
canvasWrapper.style.zIndex = '';
this.isPolygonDrawing = false;
this.vectorDrawHandler.draw({ enabled: false }, this.geometry);
}
private removeBrushMarker(): void {
if (this.brushMarker) {
this.canvas.remove(this.brushMarker);
this.brushMarker = null;
this.canvas.renderAll();
}
}
private setupBrushMarker(): void {
if (['brush', 'eraser'].includes(this.tool.type)) {
const common = {
evented: false,
selectable: false,
opacity: 0.75,
left: this.latestMousePos.x - this.tool.size / 2,
top: this.latestMousePos.y - this.tool.size / 2,
stroke: 'white',
strokeWidth: 1,
};
this.brushMarker = this.tool.form === 'circle' ? new fabric.Circle({
...common,
radius: this.tool.size / 2,
}) : new fabric.Rect({
...common,
width: this.tool.size,
height: this.tool.size,
});
this.canvas.defaultCursor = 'none';
this.canvas.add(this.brushMarker);
} else {
this.canvas.defaultCursor = 'inherit';
}
}
private releaseCanvasWrapperCSS(): void {
const canvasWrapper = this.canvas.getElement().parentElement;
canvasWrapper.style.pointerEvents = '';
canvasWrapper.style.zIndex = '';
canvasWrapper.style.display = '';
}
private releasePaste(): void {
this.releaseCanvasWrapperCSS();
this.canvas.clear();
this.canvas.renderAll();
this.isInsertion = false;
this.drawnObjects = [];
this.onDrawDone(null);
}
private releaseDraw(): void {
this.removeBrushMarker();
this.releaseCanvasWrapperCSS();
if (this.isPolygonDrawing) {
this.isPolygonDrawing = false;
this.vectorDrawHandler.cancel();
}
this.canvas.clear();
this.canvas.renderAll();
this.isDrawing = false;
this.isInsertion = false;
this.redraw = null;
this.drawnObjects = [];
this.onDrawDone(null);
}
private releaseEdit(): void {
this.removeBrushMarker();
this.releaseCanvasWrapperCSS();
if (this.isPolygonDrawing) {
this.isPolygonDrawing = false;
this.vectorDrawHandler.cancel();
}
this.canvas.clear();
this.canvas.renderAll();
this.isEditing = false;
this.drawnObjects = [];
this.onEditDone(null, null);
}
private getStateColor(state: any): string {
if (this.colorBy === ColorBy.INSTANCE) {
return state.color;
}
if (this.colorBy === ColorBy.LABEL) {
return state.label.color;
}
return state.group.color;
}
private getDrawnObjectsWrappingBox(): WrappingBBox {
type BoundingRect = ReturnType<PropType<fabric.Polygon, 'getBoundingRect'>>;
type TwoCornerBox = Pick<BoundingRect, 'top' | 'left'> & { right: number; bottom: number };
const { width, height } = this.geometry.image;
const wrappingBbox = this.drawnObjects
.map((obj) => {
if (obj instanceof fabric.Polygon) {
const bbox = computeWrappingBox(obj.points
.reduce(((acc, val) => {
acc.push(val.x, val.y);
return acc;
}), []));
return {
left: bbox.xtl,
top: bbox.ytl,
width: bbox.width,
height: bbox.height,
};
}
if (obj instanceof fabric.Image) {
return {
left: obj.left,
top: obj.top,
width: obj.width,
height: obj.height,
};
}
return obj.getBoundingRect();
})
.reduce((acc: TwoCornerBox, rect: BoundingRect) => {
acc.top = Math.floor(Math.max(0, Math.min(rect.top, acc.top)));
acc.left = Math.floor(Math.max(0, Math.min(rect.left, acc.left)));
acc.bottom = Math.floor(Math.min(height, Math.max(rect.top + rect.height, acc.bottom)));
acc.right = Math.floor(Math.min(width, Math.max(rect.left + rect.width, acc.right)));
return acc;
}, {
left: Number.MAX_SAFE_INTEGER,
top: Number.MAX_SAFE_INTEGER,
right: Number.MIN_SAFE_INTEGER,
bottom: Number.MIN_SAFE_INTEGER,
});
return wrappingBbox;
}
private imageDataFromCanvas(wrappingBBox: WrappingBBox): Uint8ClampedArray {
const imageData = this.canvas.toCanvasElement()
.getContext('2d').getImageData(
wrappingBBox.left, wrappingBBox.top,
wrappingBBox.right - wrappingBBox.left + 1, wrappingBBox.bottom - wrappingBBox.top + 1,
).data;
return imageData;
}
private updateBrushTools(brushTool?: BrushTool, opts: Partial<BrushTool> = {}): void {
if (this.isPolygonDrawing) {
// tool was switched from polygon to brush for example
this.keepDrawnPolygon();
}
this.removeBrushMarker();
if (brushTool) {
if (brushTool.color && this.tool?.color !== brushTool.color) {
const color = fabric.Color.fromHex(brushTool.color);
for (const object of this.drawnObjects) {
if (object instanceof fabric.Line) {
const alpha = +object.stroke.split(',')[3].slice(0, -1);
color.setAlpha(alpha);
object.set({ stroke: color.toRgba() });
} else if (!(object instanceof fabric.Image)) {
const alpha = +(object.fill as string).split(',')[3].slice(0, -1);
color.setAlpha(alpha);
object.set({ fill: color.toRgba() });
}
}
this.canvas.renderAll();
}
this.tool = { ...brushTool, ...opts };
if (this.isDrawing || this.isEditing) {
this.setupBrushMarker();
}
}
if (this.tool?.type?.startsWith('polygon-')) {
this.isPolygonDrawing = true;
this.vectorDrawHandler.draw({
enabled: true,
shapeType: 'polygon',
onDrawDone: (data: { points: number[] } | null) => {
if (!data) return;
const points = data.points.reduce((acc: fabric.Point[], _: number, idx: number) => {
if (idx % 2) {
acc.push(new fabric.Point(data.points[idx - 1], data.points[idx]));
}
return acc;
}, []);
const color = fabric.Color.fromHex(this.tool.color);
color.setAlpha(this.tool.type === 'polygon-minus' ? 1 : this.drawingOpacity);
const polygon = new fabric.Polygon(points, {
fill: color.toRgba(),
selectable: false,
objectCaching: false,
absolutePositioned: true,
globalCompositeOperation: this.tool.type === 'polygon-minus' ? 'destination-out' : 'xor',
});
this.canvas.add(polygon);
this.drawnObjects.push(polygon);
this.canvas.renderAll();
},
}, this.geometry);
const canvasWrapper = this.canvas.getElement().parentElement as HTMLDivElement;
canvasWrapper.style.pointerEvents = 'none';
canvasWrapper.style.zIndex = '0';
}
}
public constructor(
onDrawDone: (
data: object | null,
duration?: number,
continueDraw?: boolean,
prevDrawData?: DrawData,
) => void,
onDrawRepeat: (data: DrawData) => void,
onEditStart: (state: any) => void,
onEditDone: (state: any, points: number[]) => void,
vectorDrawHandler: DrawHandler,
canvas: HTMLCanvasElement,
) {
this.redraw = null;
this.isDrawing = false;
this.isEditing = false;
this.isMouseDown = false;
this.isBrushSizeChanging = false;
this.isPolygonDrawing = false;
this.drawData = null;
this.editData = null;
this.drawnObjects = [];
this.drawingOpacity = 0.5;
this.brushMarker = null;
this.colorBy = ColorBy.LABEL;
this.onDrawDone = onDrawDone;
this.onDrawRepeat = onDrawRepeat;
this.onEditDone = onEditDone;
this.onEditStart = onEditStart;
this.vectorDrawHandler = vectorDrawHandler;
this.canvas = new fabric.Canvas(canvas, {
containerClass: 'cvat_masks_canvas_wrapper',
fireRightClick: true,
selection: false,
defaultCursor: 'inherit',
});
this.canvas.imageSmoothingEnabled = false;
this.canvas.getElement().parentElement.addEventListener('contextmenu', (e: MouseEvent) => e.preventDefault());
this.latestMousePos = { x: -1, y: -1 };
window.document.addEventListener('mouseup', () => {
this.isMouseDown = false;
this.isBrushSizeChanging = false;
});
this.canvas.on('mouse:down', (options: fabric.IEvent<MouseEvent>) => {
const { isDrawing, isEditing, isInsertion } = this;
this.isMouseDown = (isDrawing || isEditing) && options.e.button === 0 && !options.e.altKey;
this.isBrushSizeChanging = (isDrawing || isEditing) && options.e.button === 2 && options.e.altKey;
if (isInsertion) {
const continueInserting = options.e.ctrlKey;
const wrappingBbox = this.getDrawnObjectsWrappingBox();
const imageData = this.imageDataFromCanvas(wrappingBbox);
const alpha = alphaChannelOnly(imageData);
alpha.push(wrappingBbox.left, wrappingBbox.top, wrappingBbox.right, wrappingBbox.bottom);
this.onDrawDone({
shapeType: this.drawData.shapeType,
points: alpha,
}, Date.now() - this.startTimestamp, continueInserting, this.drawData);
if (!continueInserting) {
this.releasePaste();
}
}
});
this.canvas.on('mouse:move', (e: fabric.IEvent<MouseEvent>) => {
const { image: { width: imageWidth, height: imageHeight } } = this.geometry;
const { angle } = this.geometry;
let [x, y] = [e.pointer.x, e.pointer.y];
if (angle === 180) {
[x, y] = [imageWidth - x, imageHeight - y];
} else if (angle === 270) {
[x, y] = [imageWidth - (y / imageHeight) * imageWidth, (x / imageWidth) * imageHeight];
} else if (angle === 90) {
[x, y] = [(y / imageHeight) * imageWidth, imageHeight - (x / imageWidth) * imageHeight];
}
const position = { x, y };
const {
tool, isMouseDown, isInsertion, isBrushSizeChanging,
} = this;
if (isInsertion) {
const [object] = this.drawnObjects;
if (object && object instanceof fabric.Image) {
object.left = position.x - object.width / 2;
object.top = position.y - object.height / 2;
this.canvas.renderAll();
}
}
if (isBrushSizeChanging && ['brush', 'eraser'].includes(tool?.type)) {
const xDiff = e.pointer.x - this.resizeBrushToolLatestX;
let onUpdateConfiguration = null;
if (this.isDrawing) {
onUpdateConfiguration = this.drawData.onUpdateConfiguration;
} else if (this.isEditing) {
onUpdateConfiguration = this.editData.onUpdateConfiguration;
}
if (onUpdateConfiguration) {
onUpdateConfiguration({
brushTool: {
size: Math.trunc(Math.max(1, this.tool.size + xDiff)),
},
});
}
this.resizeBrushToolLatestX = e.pointer.x;
e.e.stopPropagation();
return;
}
if (this.brushMarker) {
this.brushMarker.left = position.x - tool.size / 2;
this.brushMarker.top = position.y - tool.size / 2;
this.canvas.bringToFront(this.brushMarker);
this.canvas.renderAll();
}
if (isMouseDown && !isBrushSizeChanging && ['brush', 'eraser'].includes(tool?.type)) {
const color = fabric.Color.fromHex(tool.color);
color.setAlpha(tool.type === 'eraser' ? 1 : 0.5);
const commonProperties = {
selectable: false,
evented: false,
globalCompositeOperation: tool.type === 'eraser' ? 'destination-out' : 'xor',
};
const shapeProperties = {
...commonProperties,
fill: color.toRgba(),
left: position.x - tool.size / 2,
top: position.y - tool.size / 2,
};
let shape: fabric.Circle | fabric.Rect | null = null;
if (tool.form === 'circle') {
shape = new fabric.Circle({
...shapeProperties,
radius: tool.size / 2,
});
} else if (tool.form === 'square') {
shape = new fabric.Rect({
...shapeProperties,
width: tool.size,
height: tool.size,
});
}
this.canvas.add(shape);
if (tool.type === 'brush') {
this.drawnObjects.push(shape);
}
// add line to smooth the mask
if (this.latestMousePos.x !== -1 && this.latestMousePos.y !== -1) {
const dx = position.x - this.latestMousePos.x;
const dy = position.y - this.latestMousePos.y;
if (Math.sqrt(dx ** 2 + dy ** 2) > tool.size / 2) {
const line = new fabric.Line([
this.latestMousePos.x - tool.size / 2,
this.latestMousePos.y - tool.size / 2,
position.x - tool.size / 2,
position.y - tool.size / 2,
], {
...commonProperties,
stroke: color.toRgba(),
strokeWidth: tool.size,
strokeLineCap: tool.form === 'circle' ? 'round' : 'square',
});
this.canvas.add(line);
if (tool.type === 'brush') {
this.drawnObjects.push(line);
}
}
}
this.canvas.renderAll();
} else if (tool?.type.startsWith('polygon-') && this.drawablePolygon) {
// update the polygon position
const points = this.drawablePolygon.get('points');
if (points.length) {
points[points.length - 1].setX(e.e.offsetX);
points[points.length - 1].setY(e.e.offsetY);
}
this.canvas.renderAll();
}
this.latestMousePos.x = position.x;
this.latestMousePos.y = position.y;
this.resizeBrushToolLatestX = position.x;
});
}
public configurate(configuration: Configuration): void {
this.colorBy = configuration.colorBy;
}
public transform(geometry: Geometry): void {
this.geometry = geometry;
const {
scale, angle, image: { width, height }, top, left,
} = geometry;
const topCanvas = this.canvas.getElement().parentElement as HTMLDivElement;
if (this.canvas.width !== width || this.canvas.height !== height) {
this.canvas.setHeight(height);
this.canvas.setWidth(width);
this.canvas.setDimensions({ width, height });
}
topCanvas.style.top = `${top}px`;
topCanvas.style.left = `${left}px`;
topCanvas.style.transform = `scale(${scale}) rotate(${angle}deg)`;
if (this.drawablePolygon) {
this.drawablePolygon.set('strokeWidth', consts.BASE_STROKE_WIDTH / scale);
this.canvas.renderAll();
}
}
public draw(drawData: DrawData): void {
if (drawData.enabled && drawData.shapeType === 'mask') {
if (!this.isInsertion && drawData.initialState?.shapeType === 'mask') {
// initialize inserting pipeline if not started
const { points } = drawData.initialState;
const color = fabric.Color.fromHex(this.getStateColor(drawData.initialState)).getSource();
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(color[0], color[1], color[2], points, 4);
imageDataToDataURL(imageBitmap, right - left + 1, bottom - top + 1,
(dataURL: string) => new Promise((resolve) => {
fabric.Image.fromURL(dataURL, (image: fabric.Image) => {
try {
image.selectable = false;
image.evented = false;
image.globalCompositeOperation = 'xor';
image.opacity = 0.5;
this.canvas.add(image);
this.drawnObjects.push(image);
this.canvas.renderAll();
} finally {
resolve();
}
}, { left, top });
}));
this.isInsertion = true;
} else if (!this.isDrawing) {
// initialize drawing pipeline if not started
this.isDrawing = true;
this.redraw = drawData.redraw || null;
}
this.canvas.getElement().parentElement.style.display = 'block';
this.startTimestamp = Date.now();
}
this.updateBrushTools(drawData.brushTool);
if (!drawData.enabled && this.isDrawing) {
try {
if (this.drawnObjects.length) {
const wrappingBbox = this.getDrawnObjectsWrappingBox();
const imageData = this.imageDataFromCanvas(wrappingBbox);
const alpha = alphaChannelOnly(imageData);
alpha.push(wrappingBbox.left, wrappingBbox.top, wrappingBbox.right, wrappingBbox.bottom);
this.onDrawDone({
shapeType: this.drawData.shapeType,
points: alpha,
...(Number.isInteger(this.redraw) ? { clientID: this.redraw } : {}),
}, Date.now() - this.startTimestamp, drawData.continue, this.drawData);
}
} finally {
this.releaseDraw();
}
if (drawData.continue) {
const newDrawData = {
...this.drawData,
brushTool: { ...this.tool },
...drawData,
enabled: true,
shapeType: 'mask',
};
this.onDrawRepeat(newDrawData);
return;
}
}
this.drawData = drawData;
}
public edit(editData: MasksEditData): void {
if (editData.enabled && editData.state.shapeType === 'mask') {
if (!this.isEditing) {
// start editing pipeline if not started yet
this.canvas.getElement().parentElement.style.display = 'block';
const { points } = editData.state;
const color = fabric.Color.fromHex(this.getStateColor(editData.state)).getSource();
const [left, top, right, bottom] = points.slice(-4);
const imageBitmap = expandChannels(color[0], color[1], color[2], points, 4);
imageDataToDataURL(imageBitmap, right - left + 1, bottom - top + 1,
(dataURL: string) => new Promise((resolve) => {
fabric.Image.fromURL(dataURL, (image: fabric.Image) => {
try {
image.selectable = false;
image.evented = false;
image.globalCompositeOperation = 'xor';
image.opacity = 0.5;
this.canvas.add(image);
this.drawnObjects.push(image);
this.canvas.renderAll();
} finally {
resolve();
}
}, { left, top });
}));
this.isEditing = true;
this.startTimestamp = Date.now();
this.onEditStart(editData.state);
}
}
this.updateBrushTools(
editData.brushTool,
editData.state ? { color: this.getStateColor(editData.state) } : {},
);
if (!editData.enabled && this.isEditing) {
try {
if (this.drawnObjects.length) {
const wrappingBbox = this.getDrawnObjectsWrappingBox();
const imageData = this.imageDataFromCanvas(wrappingBbox);
const alpha = alphaChannelOnly(imageData);
alpha.push(wrappingBbox.left, wrappingBbox.top, wrappingBbox.right, wrappingBbox.bottom);
this.onEditDone(this.editData.state, alpha);
}
} finally {
this.releaseEdit();
}
}
this.editData = editData;
}
get enabled(): boolean {
return this.isDrawing || this.isEditing || this.isInsertion;
}
public cancel(): void {
if (this.isDrawing || this.isInsertion) {
this.releaseDraw();
}
if (this.isEditing) {
this.releaseEdit();
}
}
}

@ -103,6 +103,10 @@ export class MergeHandlerImpl implements MergeHandler {
}
public select(objectState: any): void {
if (objectState.shapeType === 'mask') {
// masks can not be merged
return;
}
const stateIndexes = this.statesToBeMerged.map((state): number => state.clientID);
const stateFrames = this.statesToBeMerged.map((state): number => state.frame);
const includes = stateIndexes.indexOf(objectState.clientID);

@ -361,4 +361,46 @@ export function setupSkeletonEdges(skeleton: SVG.G, referenceSVG: SVG.G): void {
}
}
export function imageDataToDataURL(
imageBitmap: Uint8ClampedArray,
width: number,
height: number,
handleResult: (dataURL: string) => Promise<void>,
): void {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').putImageData(
new ImageData(imageBitmap, width, height), 0, 0,
);
canvas.toBlob((blob) => {
const dataURL = URL.createObjectURL(blob);
handleResult(dataURL).finally(() => {
URL.revokeObjectURL(dataURL);
});
}, 'image/png');
}
export function alphaChannelOnly(imageData: Uint8ClampedArray): number[] {
const alpha = new Array(imageData.length / 4);
for (let i = 3; i < imageData.length; i += 4) {
alpha[Math.floor(i / 4)] = imageData[i] > 0 ? 1 : 0;
}
return alpha;
}
export function expandChannels(r: number, g: number, b: number, alpha: number[], endOffset = 0): Uint8ClampedArray {
const imageBitmap = new Uint8ClampedArray((alpha.length - endOffset) * 4);
for (let i = 0; i < alpha.length - endOffset; i++) {
const val = alpha[i] ? 1 : 0;
imageBitmap[i * 4] = r;
imageBitmap[i * 4 + 1] = g;
imageBitmap[i * 4 + 2] = b;
imageBitmap[i * 4 + 3] = val * 255;
}
return imageBitmap;
}
export type PropType<T, Prop extends keyof T> = T[Prop];

@ -3,7 +3,7 @@
"baseUrl": ".",
"emitDeclarationOnly": true,
"module": "es6",
"target": "es2016",
"target": "es2019",
"noImplicitAny": true,
"preserveConstEnums": true,
"declaration": true,

@ -1,6 +1,6 @@
{
"name": "cvat-core",
"version": "7.0.3",
"version": "7.1.0",
"description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "src/api.ts",
"scripts": {

@ -1,5 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corp
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -13,10 +13,12 @@
} = require('./annotations-objects');
const AnnotationsFilter = require('./annotations-filter').default;
const { checkObjectType } = require('./common');
const Statistics = require('./statistics');
const Statistics = require('./statistics').default;
const { Label } = require('./labels');
const { ArgumentError, ScriptingError } = require('./exceptions');
const ObjectState = require('./object-state').default;
const { mask2Rle, truncateMask } = require('./object-utils');
const config = require('./config').default;
const {
HistoryActions, ShapeType, ObjectType, colors, Source,
@ -55,6 +57,8 @@
history: this.history,
nextClientID: () => ++this.count,
groupColors: {},
getMasksOnFrame: (frame: number) => this.shapes[frame]
.filter((object) => object.objectShape === ObjectType.MASK),
};
}
@ -93,9 +97,8 @@
// In this case a corresponded message will be sent to the console
if (trackModel) {
this.tracks.push(trackModel);
this.objects[clientID] = trackModel;
result.tracks.push(trackModel);
this.objects[clientID] = trackModel;
}
}
@ -106,15 +109,15 @@
const data = {
tracks: this.tracks.filter((track) => !track.removed).map((track) => track.toJSON()),
shapes: Object.values(this.shapes)
.reduce((accumulator, value) => {
accumulator.push(...value);
.reduce((accumulator, frameShapes) => {
accumulator.push(...frameShapes);
return accumulator;
}, [])
.filter((shape) => !shape.removed)
.map((shape) => shape.toJSON()),
tags: Object.values(this.tags)
.reduce((accumulator, value) => {
accumulator.push(...value);
.reduce((accumulator, frameTags) => {
accumulator.push(...frameTags);
return accumulator;
}, [])
.filter((tag) => !tag.removed)
@ -356,6 +359,12 @@
'The object is not in collection yet. Call ObjectState.put([state]) before you can merge it',
);
}
if (state.shapeType === ShapeType.MASK) {
throw new ArgumentError(
'Merging for masks is not supported',
);
}
return object;
});
@ -601,6 +610,7 @@
[val]: { shape: 0, track: 0 },
}), {})),
mask: { shape: 0 },
tag: 0,
manually: 0,
interpolated: 0,
@ -787,7 +797,14 @@
group: 0,
label_id: state.label.id,
occluded: state.occluded || false,
points: [...state.points],
points: state.shapeType === 'mask' ? (() => {
const { width, height } = this.frameMeta[state.frame];
const points = truncateMask(state.points, 0, width, height);
const [left, top, right, bottom] = points.splice(-4);
const rlePoints = mask2Rle(points);
rlePoints.push(left, top, right, bottom);
return rlePoints;
})() : state.points,
rotation: state.rotation || 0,
type: state.shapeType,
z_order: state.zOrder,
@ -865,6 +882,11 @@
// eslint-disable-next-line no-unsanitized/method
const imported = this.import(constructed);
const importedArray = imported.tags.concat(imported.tracks).concat(imported.shapes);
for (const object of importedArray) {
if (object.shapeType === ShapeType.MASK && config.removeUnderlyingMaskPixels) {
object.removeUnderlyingPixels(object.frame);
}
}
if (objectStates.length) {
this.history.do(

@ -3,7 +3,7 @@
// SPDX-License-Identifier: MIT
import jsonLogic from 'json-logic-js';
import { AttributeType, ObjectType } from './enums';
import { AttributeType, ObjectType, ShapeType } from './enums';
function adjustName(name): string {
return name.replace(/\./g, '\u2219');
@ -17,13 +17,18 @@ export default class AnnotationsFilter {
return acc;
}, {});
let [width, height] = [null, null];
if (state.objectType !== ObjectType.TAG) {
if (state.shapeType === ShapeType.MASK) {
const [xtl, ytl, xbr, ybr] = state.points.slice(-4);
[width, height] = [xbr - xtl + 1, ybr - ytl + 1];
} else {
let xtl = Number.MAX_SAFE_INTEGER;
let xbr = Number.MIN_SAFE_INTEGER;
let ytl = Number.MAX_SAFE_INTEGER;
let ybr = Number.MIN_SAFE_INTEGER;
let [width, height] = [null, null];
if (state.objectType !== ObjectType.TAG) {
const points = state.points || state.elements.reduce((acc, val) => {
acc.push(val.points);
return acc;
@ -41,6 +46,7 @@ export default class AnnotationsFilter {
});
[width, height] = [xbr - xtl, ybr - ytl];
}
}
const attributes = {};
Object.keys(state.attributes).reduce((acc, key) => {

File diff suppressed because it is too large Load Diff

@ -62,7 +62,7 @@ async function getAnnotationsFromServer(session) {
}
}
export async function closeSession(session) {
export async function clearCache(session) {
const sessionType = session instanceof Task ? 'task' : 'job';
const cache = getCache(sessionType);
@ -284,29 +284,57 @@ export function importDataset(
useDefaultSettings: boolean,
sourceStorage: Storage,
file: File | string,
updateStatusCallback = () => {},
) {
options: {
convMaskToPoly?: boolean,
updateStatusCallback?: (s: string, n: number) => void,
} = {},
): Promise<void> {
const updateStatusCallback = options.updateStatusCallback || (() => {});
const convMaskToPoly = 'convMaskToPoly' in options ? options.convMaskToPoly : true;
const adjustedOptions = {
updateStatusCallback,
convMaskToPoly,
};
if (!(instance instanceof Project || instance instanceof Task || instance instanceof Job)) {
throw new ArgumentError('Instance should be a Project || Task || Job instance');
throw new ArgumentError('Instance must be a Project || Task || Job instance');
}
if (!(typeof updateStatusCallback === 'function')) {
throw new ArgumentError('Callback should be a function');
throw new ArgumentError('Callback must be a function');
}
if (!(typeof convMaskToPoly === 'boolean')) {
throw new ArgumentError('Option "convMaskToPoly" must be a boolean');
}
if (typeof file === 'string' && !file.toLowerCase().endsWith('.zip')) {
throw new ArgumentError('File should be file instance with ZIP extension');
throw new ArgumentError('File must be file instance with ZIP extension');
}
if (file instanceof File && !(['application/zip', 'application/x-zip-compressed'].includes(file.type))) {
throw new ArgumentError('File should be file instance with ZIP extension');
throw new ArgumentError('File must be file instance with ZIP extension');
}
if (instance instanceof Project) {
return serverProxy.projects
.importDataset(instance.id, format, useDefaultSettings, sourceStorage, file, updateStatusCallback);
.importDataset(
instance.id,
format,
useDefaultSettings,
sourceStorage,
file,
adjustedOptions,
);
}
const instanceType = instance instanceof Task ? 'task' : 'job';
return serverProxy.annotations
.uploadAnnotations(instanceType, instance.id, format, useDefaultSettings, sourceStorage, file);
.uploadAnnotations(
instanceType,
instance.id,
format,
useDefaultSettings,
sourceStorage,
file,
adjustedOptions,
);
}
export function getHistory(session) {

@ -8,7 +8,7 @@ const config = require('./config').default;
(() => {
const PluginRegistry = require('./plugins').default;
const serverProxy = require('./server-proxy').default;
const lambdaManager = require('./lambda-manager');
const lambdaManager = require('./lambda-manager').default;
const {
isBoolean,
isInteger,

@ -13,14 +13,14 @@ function build() {
const loggerStorage = require('./logger-storage').default;
const { Log } = require('./log');
const ObjectState = require('./object-state').default;
const Statistics = require('./statistics');
const Statistics = require('./statistics').default;
const Comment = require('./comment').default;
const Issue = require('./issue').default;
const { Job, Task } = require('./session');
const Project = require('./project').default;
const implementProject = require('./project-implementation').default;
const { Attribute, Label } = require('./labels');
const MLModel = require('./ml-model');
const MLModel = require('./ml-model').default;
const { FrameData } = require('./frames');
const CloudStorage = require('./cloud-storage').default;
const Organization = require('./organization').default;
@ -703,6 +703,9 @@ function build() {
* @memberof module:API.cvat.config
* @property {number} uploadChunkSize max size of one data request in mb
* @memberof module:API.cvat.config
* @property {number} removeUnderlyingMaskPixels defines if after adding/changing
* a mask it should remove overlapped pixels from other objects
* @memberof module:API.cvat.config
*/
get backendAPI() {
return config.backendAPI;
@ -728,6 +731,12 @@ function build() {
set uploadChunkSize(value) {
config.uploadChunkSize = value;
},
get removeUnderlyingMaskPixels(): boolean {
return config.removeUnderlyingMaskPixels;
},
set removeUnderlyingMaskPixels(value: boolean) {
config.removeUnderlyingMaskPixels = value;
},
},
/**
* Namespace contains some library information e.g. api version

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -8,6 +9,7 @@ const config = {
organizationID: null,
origin: '',
uploadChunkSize: 100,
removeUnderlyingMaskPixels: false,
};
export default config;

@ -172,6 +172,7 @@ export enum ShapeType {
ELLIPSE = 'ellipse',
CUBOID = 'cuboid',
SKELETON = 'skeleton',
MASK = 'mask',
}
/**

@ -1,10 +1,10 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import Platform from 'platform';
import ErrorStackParser from 'error-stack-parser';
// import config from './config';
/**
* Base exception class

@ -1,4 +1,5 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -740,19 +741,19 @@
return frameDataCache[jobID].frameBuffer.require(frame, jobID, isPlaying, step);
}
async function getDeletedFrames(sessionType, id) {
if (sessionType === 'job') {
async function getDeletedFrames(instanceType, id) {
if (instanceType === 'job') {
const { meta } = frameDataCache[id];
return meta.deleted_frames;
}
if (sessionType === 'task') {
if (instanceType === 'task') {
const meta = await serverProxy.frames.getMeta('job', id);
meta.deleted_frames = Object.fromEntries(meta.deleted_frames.map((_frame) => [_frame, true]));
return meta;
}
throw Exception('getDeletedFrames is not implemented for tasks');
throw new Exception(`getDeletedFrames is not implemented for ${instanceType}`);
}
function deleteFrame(jobID, frame) {

@ -139,7 +139,7 @@ export class Attribute {
}
}
type LabelType = 'rectangle' | 'polygon' | 'polyline' | 'points' | 'ellipse' | 'cuboid' | 'skeleton' | 'any';
type LabelType = 'rectangle' | 'polygon' | 'polyline' | 'points' | 'ellipse' | 'cuboid' | 'skeleton' | 'mask' | 'tag' | 'any';
export interface RawLabel {
id?: number;
name: string;

@ -1,19 +1,23 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
const serverProxy = require('./server-proxy').default;
const { ArgumentError } = require('./exceptions');
const MLModel = require('./ml-model');
const { RQStatus } = require('./enums');
import serverProxy from './server-proxy';
import { ArgumentError } from './exceptions';
import MLModel from './ml-model';
import { RQStatus } from './enums';
class LambdaManager {
private listening: any;
private cachedList: any;
constructor() {
this.listening = {};
this.cachedList = null;
}
async list() {
async list(): Promise<MLModel[]> {
if (Array.isArray(this.cachedList)) {
return [...this.cachedList];
}
@ -34,7 +38,7 @@ class LambdaManager {
return models;
}
async run(taskID, model, args) {
async run(taskID: number, model: MLModel, args: any) {
if (!Number.isInteger(taskID) || taskID < 0) {
throw new ArgumentError(`Argument taskID must be a positive integer. Got "${taskID}"`);
}
@ -78,7 +82,7 @@ class LambdaManager {
return result.filter((request) => ['queued', 'started'].includes(request.status));
}
async cancel(requestID) {
async cancel(requestID): Promise<void> {
if (typeof requestID !== 'string') {
throw new ArgumentError(`Request id argument is required to be a string. But got ${requestID}`);
}
@ -90,8 +94,8 @@ class LambdaManager {
await serverProxy.lambda.cancel(requestID);
}
async listen(requestID, onUpdate) {
const timeoutCallback = async () => {
async listen(requestID, onUpdate): Promise<void> {
const timeoutCallback = async (): Promise<void> => {
try {
this.listening[requestID].timeout = null;
const response = await serverProxy.lambda.status(requestID);
@ -124,4 +128,4 @@ class LambdaManager {
}
}
module.exports = new LambdaManager();
export default new LambdaManager();

@ -1,134 +1,111 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
/**
* Class representing a serverless function
* @memberof module:API.cvat.classes
*/
class MLModel {
constructor(data) {
this._id = data.id;
this._name = data.name;
this._labels = data.labels;
this._attributes = data.attributes || [];
this._framework = data.framework;
this._description = data.description;
this._type = data.type;
this._tip = {
message: data.help_message,
gif: data.animated_gif,
};
this._params = {
import { ModelType } from './enums';
interface ModelAttribute {
name: string;
values: string[];
input_type: 'select' | 'number' | 'checkbox' | 'radio' | 'text';
}
interface ModelParams {
canvas: {
minPosVertices: data.min_pos_points,
minNegVertices: data.min_neg_points,
startWithBox: data.startswith_box,
},
minPosVertices?: number;
minNegVertices?: number;
startWithBox?: boolean;
onChangeToolsBlockerState?: (event: string) => void;
};
}
interface ModelTip {
message: string;
gif: string;
}
interface SerializedModel {
id: string;
name: string;
labels: string[];
version: number;
attributes: Record<string, ModelAttribute>;
framework: string;
description: string;
type: ModelType;
help_message?: string;
animated_gif?: string;
min_pos_points?: number;
min_neg_points?: number;
startswith_box?: boolean;
}
export default class MLModel {
private serialized: SerializedModel;
private changeToolsBlockerStateCallback?: (event: string) => void;
constructor(serialized: SerializedModel) {
this.serialized = { ...serialized };
}
/**
* @type {string}
* @readonly
*/
get id() {
return this._id;
public get id(): string {
return this.serialized.id;
}
/**
* @type {string}
* @readonly
*/
get name() {
return this._name;
public get name(): string {
return this.serialized.name;
}
/**
* @description labels supported by the model
* @type {string[]}
* @readonly
*/
get labels() {
if (Array.isArray(this._labels)) {
return [...this._labels];
public get labels(): string[] {
return Array.isArray(this.serialized.labels) ? [...this.serialized.labels] : [];
}
return [];
public get version(): number {
return this.serialized.version;
}
/**
* @typedef ModelAttribute
* @property {string} name
* @property {string[]} values
* @property {'select'|'number'|'checkbox'|'radio'|'text'} input_type
*/
/**
* @type {Object<string, ModelAttribute>}
* @readonly
*/
get attributes() {
return { ...this._attributes };
public get attributes(): Record<string, ModelAttribute> {
return this.serialized.attributes || {};
}
/**
* @type {string}
* @readonly
*/
get framework() {
return this._framework;
public get framework(): string {
return this.serialized.framework;
}
/**
* @type {string}
* @readonly
*/
get description() {
return this._description;
public get description(): string {
return this.serialized.description;
}
/**
* @type {module:API.cvat.enums.ModelType}
* @readonly
*/
get type() {
return this._type;
public get type(): ModelType {
return this.serialized.type;
}
/**
* @type {object}
* @readonly
*/
get params() {
return {
canvas: { ...this._params.canvas },
public get params(): ModelParams {
const result: ModelParams = {
canvas: {
minPosVertices: this.serialized.min_pos_points,
minNegVertices: this.serialized.min_neg_points,
startWithBox: this.serialized.startswith_box,
},
};
if (this.changeToolsBlockerStateCallback) {
result.canvas.onChangeToolsBlockerState = this.changeToolsBlockerStateCallback;
}
/**
* @type {MlModelTip}
* @property {string} message A short message for a user about the model
* @property {string} gif A gif URL to be shown to a user as an example
* @readonly
*/
get tip() {
return { ...this._tip };
return result;
}
/**
* @typedef onRequestStatusChange
* @param {string} event
* @global
*/
/**
* @param {onRequestStatusChange} onRequestStatusChange
* @instance
* @description Used to set a callback when the tool is blocked in UI
* @returns {void}
*/
set onChangeToolsBlockerState(onChangeToolsBlockerState) {
this._params.canvas.onChangeToolsBlockerState = onChangeToolsBlockerState;
public get tip(): ModelTip {
return {
message: this.serialized.help_message,
gif: this.serialized.animated_gif,
};
}
}
module.exports = MLModel;
// Used to set a callback when the tool is blocked in UI
public set onChangeToolsBlockerState(onChangeToolsBlockerState: (event: string) => void) {
this.changeToolsBlockerStateCallback = onChangeToolsBlockerState;
}
}

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -26,7 +27,7 @@ interface UpdateFlags {
reset: () => void;
}
interface SerializedData {
export interface SerializedData {
objectType: ObjectType;
label: Label;
frame: number;
@ -333,7 +334,7 @@ export default class ObjectState {
}
if (Array.isArray(data.points)) {
return [...data.points];
return data.points;
}
return [];
@ -365,7 +366,7 @@ export default class ObjectState {
data.updateFlags.points = true;
}
data.points = [...points];
data.points = points.slice();
},
},
rotation: {

@ -0,0 +1,279 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { DataError, ArgumentError } from './exceptions';
import { Attribute } from './labels';
import { ShapeType, AttributeType } from './enums';
export function checkNumberOfPoints(shapeType: ShapeType, points: number[]): void {
if (shapeType === ShapeType.RECTANGLE) {
if (points.length / 2 !== 2) {
throw new DataError(`Rectangle must have 2 points, but got ${points.length / 2}`);
}
} else if (shapeType === ShapeType.POLYGON) {
if (points.length / 2 < 3) {
throw new DataError(`Polygon must have at least 3 points, but got ${points.length / 2}`);
}
} else if (shapeType === ShapeType.POLYLINE) {
if (points.length / 2 < 2) {
throw new DataError(`Polyline must have at least 2 points, but got ${points.length / 2}`);
}
} else if (shapeType === ShapeType.POINTS) {
if (points.length / 2 < 1) {
throw new DataError(`Points must have at least 1 points, but got ${points.length / 2}`);
}
} else if (shapeType === ShapeType.CUBOID) {
if (points.length / 2 !== 8) {
throw new DataError(`Cuboid must have 8 points, but got ${points.length / 2}`);
}
} else if (shapeType === ShapeType.ELLIPSE) {
if (points.length / 2 !== 2) {
throw new DataError(`Ellipse must have 1 point, rx and ry but got ${points.toString()}`);
}
} else if (shapeType === ShapeType.MASK) {
const [left, top, right, bottom] = points.slice(-4);
const [width, height] = [right - left, bottom - top];
if (width < 0 || !Number.isInteger(width) || height < 0 || !Number.isInteger(height)) {
throw new DataError(`Mask width, height must be positive integers, but got ${width}x${height}`);
}
if (points.length !== width * height + 4) {
throw new DataError(`Points array must have length ${width}x${height} + 4, got ${points.length}`);
}
} else {
throw new ArgumentError(`Unknown value of shapeType has been received ${shapeType}`);
}
}
export function attrsAsAnObject(attributes: Attribute[]): Record<number, Attribute> {
return attributes.reduce((accumulator, value) => {
accumulator[value.id] = value;
return accumulator;
}, {});
}
export function findAngleDiff(rightAngle: number, leftAngle: number): number {
let angleDiff = rightAngle - leftAngle;
angleDiff = ((angleDiff + 180) % 360) - 180;
if (Math.abs(angleDiff) >= 180) {
// if the main arc is bigger than 180, go another arc
// to find it, just substract absolute value from 360 and inverse sign
angleDiff = 360 - Math.abs(angleDiff) * Math.sign(angleDiff) * -1;
}
return angleDiff;
}
export function checkShapeArea(shapeType: ShapeType, points: number[]): boolean {
const MIN_SHAPE_LENGTH = 3;
const MIN_SHAPE_AREA = 9;
const MIN_MASK_SHAPE_AREA = 1;
if (shapeType === ShapeType.POINTS) {
return true;
}
if (shapeType === ShapeType.MASK) {
const [left, top, right, bottom] = points.slice(-4);
const area = (right - left + 1) * (bottom - top + 1);
return area >= MIN_MASK_SHAPE_AREA;
}
if (shapeType === ShapeType.ELLIPSE) {
const [cx, cy, rightX, topY] = points;
const [rx, ry] = [rightX - cx, cy - topY];
return rx * ry * Math.PI > MIN_SHAPE_AREA;
}
let xmin = Number.MAX_SAFE_INTEGER;
let xmax = Number.MIN_SAFE_INTEGER;
let ymin = Number.MAX_SAFE_INTEGER;
let ymax = Number.MIN_SAFE_INTEGER;
for (let i = 0; i < points.length - 1; i += 2) {
xmin = Math.min(xmin, points[i]);
xmax = Math.max(xmax, points[i]);
ymin = Math.min(ymin, points[i + 1]);
ymax = Math.max(ymax, points[i + 1]);
}
if (shapeType === ShapeType.POLYLINE) {
const length = Math.max(xmax - xmin, ymax - ymin);
return length >= MIN_SHAPE_LENGTH;
}
const area = (xmax - xmin) * (ymax - ymin);
return area >= MIN_SHAPE_AREA;
}
export function rotatePoint(x: number, y: number, angle: number, cx = 0, cy = 0): number[] {
const sin = Math.sin((angle * Math.PI) / 180);
const cos = Math.cos((angle * Math.PI) / 180);
const rotX = (x - cx) * cos - (y - cy) * sin + cx;
const rotY = (y - cy) * cos + (x - cx) * sin + cy;
return [rotX, rotY];
}
export function computeWrappingBox(points: number[], margin = 0): {
xtl: number;
ytl: number;
xbr: number;
ybr: number;
x: number;
y: number;
width: number;
height: number;
} {
let xtl = Number.MAX_SAFE_INTEGER;
let ytl = Number.MAX_SAFE_INTEGER;
let xbr = Number.MIN_SAFE_INTEGER;
let ybr = Number.MIN_SAFE_INTEGER;
for (let i = 0; i < points.length; i += 2) {
const [x, y] = [points[i], points[i + 1]];
xtl = Math.min(xtl, x);
ytl = Math.min(ytl, y);
xbr = Math.max(xbr, x);
ybr = Math.max(ybr, y);
}
const box = {
xtl: xtl - margin,
ytl: ytl - margin,
xbr: xbr + margin,
ybr: ybr + margin,
};
return {
...box,
x: box.xtl,
y: box.ytl,
width: box.xbr - box.xtl,
height: box.ybr - box.ytl,
};
}
export function validateAttributeValue(value: string, attr: Attribute): boolean {
const { values } = attr;
const type = attr.inputType;
if (typeof value !== 'string') {
throw new ArgumentError(`Attribute value is expected to be string, but got ${typeof value}`);
}
if (type === AttributeType.NUMBER) {
return +value >= +values[0] && +value <= +values[1];
}
if (type === AttributeType.CHECKBOX) {
return ['true', 'false'].includes(value.toLowerCase());
}
if (type === AttributeType.TEXT) {
return true;
}
return values.includes(value);
}
export function truncateMask(points: number[], _: number, width: number, height: number): number[] {
const [currentLeft, currentTop, currentRight, currentBottom] = points.slice(-4);
const [currentWidth, currentHeight] = [currentRight - currentLeft + 1, currentBottom - currentTop + 1];
let left = width;
let right = 0;
let top = height;
let bottom = 0;
let atLeastOnePixel = false;
const truncatedPoints = [];
for (let y = 0; y < currentHeight; y++) {
const absY = y + currentTop;
for (let x = 0; x < currentWidth; x++) {
const absX = x + currentLeft;
const offset = y * currentWidth + x;
if (absX >= width || absY >= height || absX < 0 || absY < 0) {
points[offset] = 0;
}
if (points[offset]) {
atLeastOnePixel = true;
left = Math.min(left, absX);
top = Math.min(top, absY);
right = Math.max(right, absX);
bottom = Math.max(bottom, absY);
}
}
}
if (!atLeastOnePixel) {
// if mask is empty, set its size as 0
left = 0;
top = 0;
}
// TODO: check corner case when right = left = 0
const [newWidth, newHeight] = [right - left + 1, bottom - top + 1];
for (let y = 0; y < newHeight; y++) {
for (let x = 0; x < newWidth; x++) {
const leftDiff = left - currentLeft;
const topDiff = top - currentTop;
const offset = (y + topDiff) * currentWidth + (x + leftDiff);
truncatedPoints.push(points[offset]);
}
}
truncatedPoints.push(left, top, right, bottom);
if (!checkShapeArea(ShapeType.MASK, truncatedPoints)) {
return [];
}
return truncatedPoints;
}
export function mask2Rle(mask: number[]): number[] {
return mask.reduce((acc, val, idx, arr) => {
if (idx > 0) {
if (arr[idx - 1] === val) {
acc[acc.length - 1] += 1;
} else {
acc.push(1);
}
return acc;
}
if (val > 0) {
// 0, 0, 0, 1 => [3, 1]
// 1, 1, 0, 0 => [0, 2, 2]
acc.push(0, 1);
} else {
acc.push(1);
}
return acc;
}, []);
}
export function rle2Mask(rle: number[], width: number, height: number): number[] {
const decoded = Array(width * height).fill(0);
const { length } = rle;
let decodedIdx = 0;
let value = 0;
let i = 0;
while (i < length) {
let count = rle[i];
while (count > 0) {
decoded[decodedIdx] = value;
decodedIdx++;
count--;
}
i++;
value = Math.abs(value - 1);
}
return decoded;
}

@ -85,9 +85,12 @@ export default function implementProject(projectClass) {
useDefaultSettings: boolean,
sourceStorage: Storage,
file: File | string,
updateStatusCallback,
options?: {
convMaskToPoly?: boolean,
updateStatusCallback?: (s: string, n: number) => void,
},
) {
return importDataset(this, format, useDefaultSettings, sourceStorage, file, updateStatusCallback);
return importDataset(this, format, useDefaultSettings, sourceStorage, file, options);
};
projectClass.prototype.backup.implementation = async function (

@ -409,7 +409,10 @@ Object.defineProperties(
useDefaultSettings: boolean,
sourceStorage: Storage,
file: File | string,
updateStatusCallback = null,
options?: {
convMaskToPoly?: boolean,
updateStatusCallback?: (s: string, n: number) => void,
},
) {
const result = await PluginRegistry.apiWrapper.call(
this,
@ -418,7 +421,7 @@ Object.defineProperties(
useDefaultSettings,
sourceStorage,
file,
updateStatusCallback,
options,
);
return result;
},

@ -684,14 +684,18 @@ class ServerProxy {
useDefaultLocation: boolean,
sourceStorage: Storage,
file: File | string,
onUpdate,
) {
options: {
convMaskToPoly: boolean,
updateStatusCallback: (s: string, n: number) => void,
},
): Promise<void> {
const { backendAPI, origin } = config;
const params: Params = {
const params: Params & { conv_mask_to_poly: boolean } = {
...enableOrganization(),
...configureStorage(sourceStorage, useDefaultLocation),
format,
filename: typeof file === 'string' ? file : file.name,
conv_mask_to_poly: options.convMaskToPoly,
};
const url = `${backendAPI}/projects/${id}/dataset`;
@ -705,8 +709,8 @@ class ServerProxy {
proxy: config.proxy,
});
if (response.status === 202) {
if (onUpdate && response.data.message) {
onUpdate(response.data.message, response.data.progress || 0);
if (response.data.message) {
options.updateStatusCallback(response.data.message, response.data.progress || 0);
}
setTimeout(requestStatus, 3000);
} else if (response.status === 201) {
@ -740,7 +744,7 @@ class ServerProxy {
totalSentSize: 0,
totalSize: (file as File).size,
onUpdate: (percentage) => {
onUpdate('The dataset is being uploaded to the server', percentage);
options.updateStatusCallback('The dataset is being uploaded to the server', percentage);
},
};
@ -1449,13 +1453,15 @@ class ServerProxy {
useDefaultLocation: boolean,
sourceStorage: Storage,
file: File | string,
) {
options: { convMaskToPoly: boolean },
): Promise<void> {
const { backendAPI, origin } = config;
const params: Params = {
const params: Params & { conv_mask_to_poly: boolean } = {
...enableOrganization(),
...configureStorage(sourceStorage, useDefaultLocation),
format,
filename: typeof file === 'string' ? file : file.name,
conv_mask_to_poly: options.convMaskToPoly,
};
const url = `${backendAPI}/${session}s/${id}/annotations`;

@ -34,7 +34,13 @@ function buildDuplicatedAPI(prototype) {
Object.defineProperties(prototype, {
annotations: Object.freeze({
value: {
async upload(format: string, useDefaultLocation: boolean, sourceStorage: Storage, file: File | string) {
async upload(
format: string,
useDefaultLocation: boolean,
sourceStorage: Storage,
file: File | string,
options?: { convMaskToPoly?: boolean },
) {
const result = await PluginRegistry.apiWrapper.call(
this,
prototype.annotations.upload,
@ -42,6 +48,7 @@ function buildDuplicatedAPI(prototype) {
useDefaultLocation,
sourceStorage,
file,
options,
);
return result;
},
@ -1939,36 +1946,24 @@ export class Task extends Session {
}
}
const {
getAnnotations,
putAnnotations,
saveAnnotations,
hasUnsavedChanges,
searchAnnotations,
searchEmptyFrame,
mergeAnnotations,
splitAnnotations,
groupAnnotations,
clearAnnotations,
selectObject,
annotationsStatistics,
importCollection,
exportCollection,
importDataset,
exportDataset,
undoActions,
redoActions,
freezeHistory,
clearActions,
getActions,
closeSession,
getHistory,
} = require('./annotations');
buildDuplicatedAPI(Job.prototype);
buildDuplicatedAPI(Task.prototype);
Job.prototype.save.implementation = async function () {
(async () => {
const annotations = await import('./annotations');
const {
getAnnotations, putAnnotations, saveAnnotations,
hasUnsavedChanges, searchAnnotations, searchEmptyFrame,
mergeAnnotations, splitAnnotations, groupAnnotations,
clearAnnotations, selectObject, annotationsStatistics,
importCollection, exportCollection, importDataset,
exportDataset, undoActions, redoActions,
freezeHistory, clearActions, getActions,
clearCache, getHistory,
} = annotations;
Job.prototype.save.implementation = async function () {
if (this.id) {
const jobData = this._updateTrigger.getUpdated(this);
if (jobData.assignee) {
@ -1981,14 +1976,14 @@ Job.prototype.save.implementation = async function () {
}
throw new ArgumentError('Could not save job without id');
};
};
Job.prototype.issues.implementation = async function () {
Job.prototype.issues.implementation = async function () {
const result = await serverProxy.issues.get(this.id);
return result.map((issue) => new Issue(issue));
};
};
Job.prototype.openIssue.implementation = async function (issue, message) {
Job.prototype.openIssue.implementation = async function (issue, message) {
checkObjectType('issue', issue, null, Issue);
checkObjectType('message', message, 'string');
const result = await serverProxy.issues.create({
@ -1996,9 +1991,9 @@ Job.prototype.openIssue.implementation = async function (issue, message) {
message,
});
return new Issue(result);
};
};
Job.prototype.frames.get.implementation = async function (frame, isPlaying, step) {
Job.prototype.frames.get.implementation = async function (frame, isPlaying, step) {
if (!Number.isInteger(frame) || frame < 0) {
throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`);
}
@ -2020,10 +2015,10 @@ Job.prototype.frames.get.implementation = async function (frame, isPlaying, step
this.dimension,
);
return frameData;
};
};
// must be called with task/job context
async function deleteFrameWrapper(jobID, frame) {
// must be called with task/job context
async function deleteFrameWrapper(jobID, frame) {
const history = getHistory(this);
const redo = async () => {
deleteFrame(jobID, frame);
@ -2033,9 +2028,9 @@ async function deleteFrameWrapper(jobID, frame) {
history.do(HistoryActions.REMOVED_FRAME, async () => {
restoreFrame(jobID, frame);
}, redo, [], frame);
}
}
async function restoreFrameWrapper(jobID, frame) {
async function restoreFrameWrapper(jobID, frame) {
const history = getHistory(this);
const redo = async () => {
restoreFrame(jobID, frame);
@ -2045,9 +2040,9 @@ async function restoreFrameWrapper(jobID, frame) {
history.do(HistoryActions.RESTORED_FRAME, async () => {
deleteFrame(jobID, frame);
}, redo, [], frame);
}
}
Job.prototype.frames.delete.implementation = async function (frame) {
Job.prototype.frames.delete.implementation = async function (frame) {
if (!Number.isInteger(frame)) {
throw new Error(`Frame must be an integer. Got: "${frame}"`);
}
@ -2057,9 +2052,9 @@ Job.prototype.frames.delete.implementation = async function (frame) {
}
await deleteFrameWrapper.call(this, this.id, frame);
};
};
Job.prototype.frames.restore.implementation = async function (frame) {
Job.prototype.frames.restore.implementation = async function (frame) {
if (!Number.isInteger(frame)) {
throw new Error(`Frame must be an integer. Got: "${frame}"`);
}
@ -2069,33 +2064,33 @@ Job.prototype.frames.restore.implementation = async function (frame) {
}
await restoreFrameWrapper.call(this, this.id, frame);
};
};
Job.prototype.frames.save.implementation = async function () {
Job.prototype.frames.save.implementation = async function () {
const result = await patchMeta(this.id);
return result;
};
};
Job.prototype.frames.ranges.implementation = async function () {
Job.prototype.frames.ranges.implementation = async function () {
const rangesData = await getRanges(this.id);
return rangesData;
};
};
Job.prototype.frames.preview.implementation = async function () {
Job.prototype.frames.preview.implementation = async function () {
if (this.id === null || this.taskId === null) {
return '';
}
const frameData = await getPreview(this.taskId, this.id);
return frameData;
};
};
Job.prototype.frames.contextImage.implementation = async function (frameId) {
Job.prototype.frames.contextImage.implementation = async function (frameId) {
const result = await getContextImage(this.id, frameId);
return result;
};
};
Job.prototype.frames.search.implementation = async function (filters, frameFrom, frameTo) {
Job.prototype.frames.search.implementation = async function (filters, frameFrom, frameTo) {
if (typeof filters !== 'object') {
throw new ArgumentError('Filters should be an object');
}
@ -2115,10 +2110,10 @@ Job.prototype.frames.search.implementation = async function (filters, frameFrom,
return findNotDeletedFrame(this.id, frameFrom, frameTo, filters.offset || 1);
}
return null;
};
};
// TODO: Check filter for annotations
Job.prototype.annotations.get.implementation = async function (frame, allTracks, filters) {
// TODO: Check filter for annotations
Job.prototype.annotations.get.implementation = async function (frame, allTracks, filters) {
if (!Array.isArray(filters)) {
throw new ArgumentError('Filters must be an array');
}
@ -2138,9 +2133,9 @@ Job.prototype.annotations.get.implementation = async function (frame, allTracks,
}
return annotationsData;
};
};
Job.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) {
Job.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) {
if (!Array.isArray(filters)) {
throw new ArgumentError('Filters must be an array');
}
@ -2159,9 +2154,9 @@ Job.prototype.annotations.search.implementation = function (filters, frameFrom,
const result = searchAnnotations(this, filters, frameFrom, frameTo);
return result;
};
};
Job.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) {
Job.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) {
if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) {
throw new ArgumentError('The start and end frames both must be an integer');
}
@ -2176,117 +2171,118 @@ Job.prototype.annotations.searchEmpty.implementation = function (frameFrom, fram
const result = searchEmptyFrame(this, frameFrom, frameTo);
return result;
};
};
Job.prototype.annotations.save.implementation = async function (onUpdate) {
Job.prototype.annotations.save.implementation = async function (onUpdate) {
const result = await saveAnnotations(this, onUpdate);
return result;
};
};
Job.prototype.annotations.merge.implementation = async function (objectStates) {
Job.prototype.annotations.merge.implementation = async function (objectStates) {
const result = await mergeAnnotations(this, objectStates);
return result;
};
};
Job.prototype.annotations.split.implementation = async function (objectState, frame) {
Job.prototype.annotations.split.implementation = async function (objectState, frame) {
const result = await splitAnnotations(this, objectState, frame);
return result;
};
};
Job.prototype.annotations.group.implementation = async function (objectStates, reset) {
Job.prototype.annotations.group.implementation = async function (objectStates, reset) {
const result = await groupAnnotations(this, objectStates, reset);
return result;
};
};
Job.prototype.annotations.hasUnsavedChanges.implementation = function () {
Job.prototype.annotations.hasUnsavedChanges.implementation = function () {
const result = hasUnsavedChanges(this);
return result;
};
};
Job.prototype.annotations.clear.implementation = async function (
Job.prototype.annotations.clear.implementation = async function (
reload, startframe, endframe, delTrackKeyframesOnly,
) {
) {
const result = await clearAnnotations(this, reload, startframe, endframe, delTrackKeyframesOnly);
return result;
};
};
Job.prototype.annotations.select.implementation = function (frame, x, y) {
Job.prototype.annotations.select.implementation = function (frame, x, y) {
const result = selectObject(this, frame, x, y);
return result;
};
};
Job.prototype.annotations.statistics.implementation = function () {
Job.prototype.annotations.statistics.implementation = function () {
const result = annotationsStatistics(this);
return result;
};
};
Job.prototype.annotations.put.implementation = function (objectStates) {
Job.prototype.annotations.put.implementation = function (objectStates) {
const result = putAnnotations(this, objectStates);
return result;
};
};
Job.prototype.annotations.upload.implementation = async function (
Job.prototype.annotations.upload.implementation = async function (
format: string,
useDefaultLocation: boolean,
sourceStorage: Storage,
file: File | string,
) {
const result = await importDataset(this, format, useDefaultLocation, sourceStorage, file);
options?: { convMaskToPoly?: boolean },
) {
const result = await importDataset(this, format, useDefaultLocation, sourceStorage, file, options);
return result;
};
};
Job.prototype.annotations.import.implementation = function (data) {
Job.prototype.annotations.import.implementation = function (data) {
const result = importCollection(this, data);
return result;
};
};
Job.prototype.annotations.export.implementation = function () {
Job.prototype.annotations.export.implementation = function () {
const result = exportCollection(this);
return result;
};
};
Job.prototype.annotations.exportDataset.implementation = async function (
Job.prototype.annotations.exportDataset.implementation = async function (
format: string,
saveImages: boolean,
useDefaultSettings: boolean,
targetStorage: Storage,
customName?: string,
) {
) {
const result = await exportDataset(this, format, saveImages, useDefaultSettings, targetStorage, customName);
return result;
};
};
Job.prototype.actions.undo.implementation = async function (count) {
Job.prototype.actions.undo.implementation = async function (count) {
const result = await undoActions(this, count);
return result;
};
};
Job.prototype.actions.redo.implementation = async function (count) {
Job.prototype.actions.redo.implementation = async function (count) {
const result = await redoActions(this, count);
return result;
};
};
Job.prototype.actions.freeze.implementation = function (frozen) {
Job.prototype.actions.freeze.implementation = function (frozen) {
const result = freezeHistory(this, frozen);
return result;
};
};
Job.prototype.actions.clear.implementation = function () {
Job.prototype.actions.clear.implementation = function () {
const result = clearActions(this);
return result;
};
};
Job.prototype.actions.get.implementation = function () {
Job.prototype.actions.get.implementation = function () {
const result = getActions(this);
return result;
};
};
Job.prototype.logger.log.implementation = async function (logType, payload, wait) {
Job.prototype.logger.log.implementation = async function (logType, payload, wait) {
const result = await loggerStorage.log(logType, { ...payload, task_id: this.taskId, job_id: this.id }, wait);
return result;
};
};
Job.prototype.predictor.status.implementation = async function () {
Job.prototype.predictor.status.implementation = async function () {
if (!Number.isInteger(this.projectId)) {
throw new DataError('The job must belong to a project to use the feature');
}
@ -2300,9 +2296,9 @@ Job.prototype.predictor.status.implementation = async function () {
mediaAmount: result.media_amount,
annotationAmount: result.annotation_amount,
};
};
};
Job.prototype.predictor.predict.implementation = async function (frame) {
Job.prototype.predictor.predict.implementation = async function (frame) {
if (!Number.isInteger(frame) || frame < 0) {
throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`);
}
@ -2317,25 +2313,25 @@ Job.prototype.predictor.predict.implementation = async function (frame) {
const result = await serverProxy.predictor.predict(this.taskId, frame);
return result;
};
};
Job.prototype.close.implementation = function closeTask() {
Job.prototype.close.implementation = function closeTask() {
clearFrames(this.id);
closeSession(this);
clearCache(this);
return this;
};
};
Task.prototype.close.implementation = function closeTask() {
Task.prototype.close.implementation = function closeTask() {
for (const job of this.jobs) {
clearFrames(job.id);
closeSession(job);
clearCache(job);
}
closeSession(this);
clearCache(this);
return this;
};
};
Task.prototype.save.implementation = async function (onUpdate) {
Task.prototype.save.implementation = async function (onUpdate) {
// TODO: Add ability to change an owner and an assignee
if (typeof this.id !== 'undefined') {
// If the task has been already created, we update it
@ -2417,29 +2413,29 @@ Task.prototype.save.implementation = async function (onUpdate) {
const task = await serverProxy.tasks.create(taskSpec, taskDataSpec, onUpdate);
return new Task(task);
};
};
Task.prototype.delete.implementation = async function () {
Task.prototype.delete.implementation = async function () {
const result = await serverProxy.tasks.delete(this.id);
return result;
};
};
Task.prototype.backup.implementation = async function (
Task.prototype.backup.implementation = async function (
targetStorage: Storage,
useDefaultSettings: boolean,
fileName?: string,
) {
) {
const result = await serverProxy.tasks.backup(this.id, targetStorage, useDefaultSettings, fileName);
return result;
};
};
Task.restore.implementation = async function (storage: Storage, file: File | string) {
Task.restore.implementation = async function (storage: Storage, file: File | string) {
// eslint-disable-next-line no-unsanitized/method
const result = await serverProxy.tasks.restore(storage, file);
return result;
};
};
Task.prototype.frames.get.implementation = async function (frame, isPlaying, step) {
Task.prototype.frames.get.implementation = async function (frame, isPlaying, step) {
if (!Number.isInteger(frame) || frame < 0) {
throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`);
}
@ -2462,9 +2458,9 @@ Task.prototype.frames.get.implementation = async function (frame, isPlaying, ste
step,
);
return result;
};
};
Task.prototype.frames.ranges.implementation = async function () {
Task.prototype.frames.ranges.implementation = async function () {
const rangesData = {
decoded: [],
buffered: [],
@ -2475,18 +2471,18 @@ Task.prototype.frames.ranges.implementation = async function () {
rangesData.buffered.push(buffered);
}
return rangesData;
};
};
Task.prototype.frames.preview.implementation = async function () {
Task.prototype.frames.preview.implementation = async function () {
if (this.id === null) {
return '';
}
const frameData = await getPreview(this.id);
return frameData;
};
};
Task.prototype.frames.delete.implementation = async function (frame) {
Task.prototype.frames.delete.implementation = async function (frame) {
if (!Number.isInteger(frame)) {
throw new Error(`Frame must be an integer. Got: "${frame}"`);
}
@ -2499,9 +2495,9 @@ Task.prototype.frames.delete.implementation = async function (frame) {
if (job) {
await deleteFrameWrapper.call(this, job.id, frame);
}
};
};
Task.prototype.frames.restore.implementation = async function (frame) {
Task.prototype.frames.restore.implementation = async function (frame) {
if (!Number.isInteger(frame)) {
throw new Error(`Frame must be an integer. Got: "${frame}"`);
}
@ -2514,13 +2510,13 @@ Task.prototype.frames.restore.implementation = async function (frame) {
if (job) {
await restoreFrameWrapper.call(this, job.id, frame);
}
};
};
Task.prototype.frames.save.implementation = async function () {
Task.prototype.frames.save.implementation = async function () {
return Promise.all(this.jobs.map((job) => patchMeta(job.id)));
};
};
Task.prototype.frames.search.implementation = async function (filters, frameFrom, frameTo) {
Task.prototype.frames.search.implementation = async function (filters, frameFrom, frameTo) {
if (typeof filters !== 'object') {
throw new ArgumentError('Filters should be an object');
}
@ -2554,10 +2550,10 @@ Task.prototype.frames.search.implementation = async function (filters, frameFrom
}
return null;
};
};
// TODO: Check filter for annotations
Task.prototype.annotations.get.implementation = async function (frame, allTracks, filters) {
// TODO: Check filter for annotations
Task.prototype.annotations.get.implementation = async function (frame, allTracks, filters) {
if (!Array.isArray(filters) || filters.some((filter) => typeof filter !== 'string')) {
throw new ArgumentError('The filters argument must be an array of strings');
}
@ -2577,9 +2573,9 @@ Task.prototype.annotations.get.implementation = async function (frame, allTracks
}
return result;
};
};
Task.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) {
Task.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) {
if (!Array.isArray(filters) || filters.some((filter) => typeof filter !== 'string')) {
throw new ArgumentError('The filters argument must be an array of strings');
}
@ -2598,9 +2594,9 @@ Task.prototype.annotations.search.implementation = function (filters, frameFrom,
const result = searchAnnotations(this, filters, frameFrom, frameTo);
return result;
};
};
Task.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) {
Task.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) {
if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) {
throw new ArgumentError('The start and end frames both must be an integer');
}
@ -2615,115 +2611,116 @@ Task.prototype.annotations.searchEmpty.implementation = function (frameFrom, fra
const result = searchEmptyFrame(this, frameFrom, frameTo);
return result;
};
};
Task.prototype.annotations.save.implementation = async function (onUpdate) {
Task.prototype.annotations.save.implementation = async function (onUpdate) {
const result = await saveAnnotations(this, onUpdate);
return result;
};
};
Task.prototype.annotations.merge.implementation = async function (objectStates) {
Task.prototype.annotations.merge.implementation = async function (objectStates) {
const result = await mergeAnnotations(this, objectStates);
return result;
};
};
Task.prototype.annotations.split.implementation = async function (objectState, frame) {
Task.prototype.annotations.split.implementation = async function (objectState, frame) {
const result = await splitAnnotations(this, objectState, frame);
return result;
};
};
Task.prototype.annotations.group.implementation = async function (objectStates, reset) {
Task.prototype.annotations.group.implementation = async function (objectStates, reset) {
const result = await groupAnnotations(this, objectStates, reset);
return result;
};
};
Task.prototype.annotations.hasUnsavedChanges.implementation = function () {
Task.prototype.annotations.hasUnsavedChanges.implementation = function () {
const result = hasUnsavedChanges(this);
return result;
};
};
Task.prototype.annotations.clear.implementation = async function (reload) {
Task.prototype.annotations.clear.implementation = async function (reload) {
const result = await clearAnnotations(this, reload);
return result;
};
};
Task.prototype.annotations.select.implementation = function (frame, x, y) {
Task.prototype.annotations.select.implementation = function (frame, x, y) {
const result = selectObject(this, frame, x, y);
return result;
};
};
Task.prototype.annotations.statistics.implementation = function () {
Task.prototype.annotations.statistics.implementation = function () {
const result = annotationsStatistics(this);
return result;
};
};
Task.prototype.annotations.put.implementation = function (objectStates) {
Task.prototype.annotations.put.implementation = function (objectStates) {
const result = putAnnotations(this, objectStates);
return result;
};
};
Task.prototype.annotations.upload.implementation = async function (
Task.prototype.annotations.upload.implementation = async function (
format: string,
useDefaultLocation: boolean,
sourceStorage: Storage,
file: File | string,
) {
const result = await importDataset(this, format, useDefaultLocation, sourceStorage, file);
options?: { convMaskToPoly?: boolean },
) {
const result = await importDataset(this, format, useDefaultLocation, sourceStorage, file, options);
return result;
};
};
Task.prototype.annotations.import.implementation = function (data) {
Task.prototype.annotations.import.implementation = function (data) {
const result = importCollection(this, data);
return result;
};
};
Task.prototype.annotations.export.implementation = function () {
Task.prototype.annotations.export.implementation = function () {
const result = exportCollection(this);
return result;
};
};
Task.prototype.annotations.exportDataset.implementation = async function (
Task.prototype.annotations.exportDataset.implementation = async function (
format: string,
saveImages: boolean,
useDefaultSettings: boolean,
targetStorage: Storage,
customName?: string,
) {
) {
const result = await exportDataset(this, format, saveImages, useDefaultSettings, targetStorage, customName);
return result;
};
};
Task.prototype.actions.undo.implementation = function (count) {
Task.prototype.actions.undo.implementation = function (count) {
const result = undoActions(this, count);
return result;
};
};
Task.prototype.actions.redo.implementation = function (count) {
Task.prototype.actions.redo.implementation = function (count) {
const result = redoActions(this, count);
return result;
};
};
Task.prototype.actions.freeze.implementation = function (frozen) {
Task.prototype.actions.freeze.implementation = function (frozen) {
const result = freezeHistory(this, frozen);
return result;
};
};
Task.prototype.actions.clear.implementation = function () {
Task.prototype.actions.clear.implementation = function () {
const result = clearActions(this);
return result;
};
};
Task.prototype.actions.get.implementation = function () {
Task.prototype.actions.get.implementation = function () {
const result = getActions(this);
return result;
};
};
Task.prototype.logger.log.implementation = async function (logType, payload, wait) {
Task.prototype.logger.log.implementation = async function (logType, payload, wait) {
const result = await loggerStorage.log(logType, { ...payload, task_id: this.id }, wait);
return result;
};
};
Task.prototype.predictor.status.implementation = async function () {
Task.prototype.predictor.status.implementation = async function () {
if (!Number.isInteger(this.projectId)) {
throw new DataError('The task must belong to a project to use the feature');
}
@ -2737,9 +2734,9 @@ Task.prototype.predictor.status.implementation = async function () {
mediaAmount: result.media_amount,
annotationAmount: result.annotation_amount,
};
};
};
Task.prototype.predictor.predict.implementation = async function (frame) {
Task.prototype.predictor.predict.implementation = async function (frame) {
if (!Number.isInteger(frame) || frame < 0) {
throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`);
}
@ -2754,4 +2751,6 @@ Task.prototype.predictor.predict.implementation = async function (frame) {
const result = await serverProxy.predictor.predict(this.id, frame);
return result;
};
};
})();

@ -1,116 +1,44 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
(() => {
/**
* Class representing collection statistics
* @memberof module:API.cvat.classes
* @hideconstructor
*/
class Statistics {
constructor(label, total) {
Object.defineProperties(
this,
Object.freeze({
/**
* Statistics collected by labels, has the following structure:
* @example
* {
* label: {
* rectangle: {
* track: 10,
* shape: 11,
* },
* polygon: {
* track: 13,
* shape: 14,
* },
* polyline: {
* track: 16,
* shape: 17,
* },
* points: {
* track: 19,
* shape: 20,
* },
* ellipse: {
* track: 13,
* shape: 15,
* },
* cuboid: {
* track: 21,
* shape: 22,
* },
* skeleton: {
* track: 21,
* shape: 22,
* },
* tag: 66,
* manually: 207,
* interpolated: 500,
* total: 630,
* }
* }
* @name label
* @type {Object}
* @memberof module:API.cvat.classes.Statistics
* @readonly
* @instance
*/
label: {
get: () => JSON.parse(JSON.stringify(label)),
},
/**
* Total objects statistics (within all the labels), has the following structure:
* @example
* {
* rectangle: {
* tracks: 10,
* shapes: 11,
* },
* polygon: {
* tracks: 13,
* shapes: 14,
* },
* polyline: {
* tracks: 16,
* shapes: 17,
* },
* point: {
* tracks: 19,
* shapes: 20,
* },
* ellipse: {
* tracks: 13,
* shapes: 15,
* },
* cuboid: {
* tracks: 21,
* shapes: 22,
* },
* skeleton: {
* tracks: 21,
* shapes: 22,
* },
* tag: 66,
* manually: 186,
* interpolated: 500,
* total: 608,
* }
* @name total
* @type {Object}
* @memberof module:API.cvat.classes.Statistics
* @readonly
* @instance
*/
total: {
get: () => JSON.parse(JSON.stringify(total)),
},
}),
);
interface ObjectStatistics {
track: number;
shape: number;
}
interface StatisticsBody {
rectangle: ObjectStatistics;
polygon: ObjectStatistics;
polyline: ObjectStatistics;
points: ObjectStatistics;
ellipse: ObjectStatistics;
cuboid: ObjectStatistics;
skeleton: ObjectStatistics;
mask: {
shape: number;
};
tag: number;
manually: number;
interpolated: number;
total: number;
}
export default class Statistics {
private labelData: Record<string, StatisticsBody>;
private totalData: StatisticsBody;
constructor(label: Statistics['labelData'], total: Statistics['totalData']) {
this.labelData = label;
this.totalData = total;
}
public get label(): Record<string, StatisticsBody> {
return JSON.parse(JSON.stringify(this.labelData));
}
module.exports = Statistics;
})();
public get total(): StatisticsBody {
return JSON.parse(JSON.stringify(this.totalData));
}
}

@ -1,6 +1,6 @@
{
"name": "cvat-ui",
"version": "1.42.5",
"version": "1.43.0",
"description": "CVAT single-page application",
"main": "src/index.tsx",
"scripts": {

@ -208,6 +208,7 @@ export enum AnnotationActionTypes {
RESTORE_FRAME = 'RESTORE_FRAME',
RESTORE_FRAME_SUCCESS = 'RESTORE_FRAME_SUCCESS',
RESTORE_FRAME_FAILED = 'RESTORE_FRAME_FAILED',
UPDATE_BRUSH_TOOLS_CONFIG = 'UPDATE_BRUSH_TOOLS_CONFIG',
}
export function saveLogsAsync(): ThunkAction {
@ -319,6 +320,15 @@ export function updateCanvasContextMenu(
};
}
export function updateCanvasBrushTools(config: {
visible?: boolean, left?: number, top?: number
}): AnyAction {
return {
type: AnnotationActionTypes.UPDATE_BRUSH_TOOLS_CONFIG,
payload: config,
};
}
export function removeAnnotationsAsync(
startFrame: number, endFrame: number, delTrackKeyframesOnly: boolean,
): ThunkAction {
@ -1212,8 +1222,9 @@ export function updateAnnotationsAsync(statesToUpdate: any[]): ThunkAction {
const promises = statesToUpdate.map((objectState: any): Promise<any> => objectState.save());
const states = await Promise.all(promises);
const withSkeletonElements = states.some((state: any) => state.parentID !== null);
if (withSkeletonElements) {
const needToUpdateAll = states
.some((state: any) => [ShapeType.MASK, ShapeType.SKELETON].includes(state.shapeType));
if (needToUpdateAll) {
dispatch(fetchAnnotationsAsync());
return;
}
@ -1458,6 +1469,7 @@ const ShapeTypeToControl: Record<ShapeType, ActiveControl> = {
[ShapeType.CUBOID]: ActiveControl.DRAW_CUBOID,
[ShapeType.ELLIPSE]: ActiveControl.DRAW_ELLIPSE,
[ShapeType.SKELETON]: ActiveControl.DRAW_SKELETON,
[ShapeType.MASK]: ActiveControl.DRAW_MASK,
};
export function pasteShapeAsync(): ThunkAction {

@ -70,6 +70,7 @@ export const importDatasetAsync = (
useDefaultSettings: boolean,
sourceStorage: Storage,
file: File | string,
convMaskToPoly: boolean,
): ThunkAction => (
async (dispatch, getState) => {
const resource = instance instanceof core.classes.Project ? 'dataset' : 'annotation';
@ -83,18 +84,20 @@ export const importDatasetAsync = (
}
dispatch(importActions.importDataset(instance, format));
await instance.annotations
.importDataset(format, useDefaultSettings, sourceStorage, file,
(message: string, progress: number) => (
.importDataset(format, useDefaultSettings, sourceStorage, file, {
convMaskToPoly,
updateStatusCallback: (message: string, progress: number) => (
dispatch(importActions.importDatasetUpdateStatus(
instance, Math.floor(progress * 100), message,
))
));
),
});
} else if (instance instanceof core.classes.Task) {
if (state.import.tasks.dataset.current?.[instance.id]) {
throw Error('Only one importing of annotation/dataset allowed at the same time');
}
dispatch(importActions.importDataset(instance, format));
await instance.annotations.upload(format, useDefaultSettings, sourceStorage, file);
await instance.annotations.upload(format, useDefaultSettings, sourceStorage, file, { convMaskToPoly });
} else { // job
if (state.import.tasks.dataset.current?.[instance.taskId]) {
throw Error('Annotations is being uploaded for the task');
@ -107,7 +110,7 @@ export const importDatasetAsync = (
dispatch(importActions.importDataset(instance, format));
const frame = state.annotation.player.frame.number;
await instance.annotations.upload(format, useDefaultSettings, sourceStorage, file);
await instance.annotations.upload(format, useDefaultSettings, sourceStorage, file, { convMaskToPoly });
await instance.logger.log(LogType.uploadAnnotations, {
...(await jobInfoGenerator(instance)),

@ -0,0 +1,3 @@
<svg width="40px" height="40px" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.248 4.15283L12.6799 12.9155C12.0119 13.0658 11.3672 13.333 10.85 13.8502L10.8324 13.8663L10.8149 13.8854C10.0755 14.7293 9.82742 15.8244 9.42596 16.9781C9.05378 18.0478 8.55224 19.1753 7.68984 20.2526H2.25V21.7528H8.83553V21.6986C12.8813 21.5609 14.9145 20.4462 16.1565 19.1494C17.0676 18.2357 17.3401 16.9703 17.1088 15.7856L22.4813 5.60033L21.1539 4.90002L18.0626 10.7633L16.0803 9.63955L18.5783 4.84728L17.248 4.15283ZM15.3859 10.9713L17.3623 12.0906L16.3104 14.0861C16.2475 14.0125 16.2231 13.9197 16.1536 13.8502C15.648 13.3445 15.0192 13.0793 14.3677 12.9243L15.3859 10.9713ZM13.5018 14.2472C14.0762 14.2472 14.6504 14.4684 15.0929 14.9109C15.9779 15.7959 15.9779 17.2081 15.0929 18.0931L15.087 18.0989L15.0812 18.1048C14.1191 19.112 12.7313 19.9498 9.54023 20.1618C10.1124 19.2227 10.557 18.2913 10.8427 17.4704C11.2594 16.2728 11.5636 15.3363 11.9327 14.9021C12.3727 14.4717 12.9353 14.2472 13.5018 14.2472Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -0,0 +1,3 @@
<svg width="24px" height="25px" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.2109 5.46094L8.25 18.4219L2.78906 12.9609L1.71094 14.0391L7.71094 20.0391L8.25 20.5547L8.78906 20.0391L22.2891 6.53906L21.2109 5.46094Z" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 275 B

@ -0,0 +1,3 @@
<svg width="40px" height="40px" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.1797 3.82031C13.5762 3.82031 12.9492 4.02539 12.4687 4.45312V4.47656L12.4453 4.5L3.67969 13.1953C2.77148 14.1035 2.77734 15.583 3.63281 16.5469L3.65625 16.5703H3.67969L8.17968 21.0703C8.63379 21.5244 9.24023 21.7441 9.84375 21.75H21V20.25H12.375L20.25 12.375C21.1992 11.4258 21.2285 9.9082 20.3203 9L15.8203 4.5C15.3662 4.0459 14.7832 3.82031 14.1797 3.82031ZM14.1562 5.34375C14.3965 5.34375 14.6191 5.4082 14.7656 5.55469L19.2656 10.0547C19.5557 10.3447 19.5967 10.9189 19.1953 11.3203L15.4219 15.0938L9.67968 9.35156L13.4766 5.57812L13.5 5.55469C13.6904 5.39648 13.9277 5.34375 14.1562 5.34375ZM8.60156 10.4297L14.3437 16.1719L10.5234 19.9922C10.5146 19.998 10.5088 20.0098 10.5 20.0156C10.1133 20.3379 9.51855 20.2998 9.23437 20.0156L4.75781 15.5625C4.74609 15.5479 4.74609 15.5303 4.73437 15.5156C4.42676 15.1289 4.45312 14.5312 4.73437 14.25L8.60156 10.4297Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1000 B

@ -1 +1,3 @@
<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg"><path d="M36.918 19.593a1.05 1.05 0 0 0-.231-.346l-3.633-3.633a1.064 1.064 0 1 0-1.505 1.505l1.817 1.816H21.062l.001-12.301L22.96 8.53a1.062 1.062 0 0 0 1.505 0 1.064 1.064 0 0 0 0-1.505L20.752 3.31a1.063 1.063 0 0 0-1.16-.23c-.13.054-.246.13-.343.228l-.002.001-3.636 3.634a1.066 1.066 0 0 0 .753 1.818c.272 0 .545-.105.753-.312l1.818-1.817-.001 12.302H6.635l1.816-1.816a1.065 1.065 0 0 0-1.506-1.505l-3.633 3.634a1.059 1.059 0 0 0 0 1.505l3.713 3.713a1.059 1.059 0 0 0 1.506 0 1.065 1.065 0 0 0 0-1.505l-1.898-1.897h12.3v12.3l-1.816-1.815a1.066 1.066 0 0 0-1.506 1.506l3.636 3.633a1.059 1.059 0 0 0 1.504 0l3.714-3.714a1.064 1.064 0 1 0-1.506-1.505l-1.897 1.898V21.064h12.304l-1.896 1.897a1.065 1.065 0 0 0 1.505 1.505l3.713-3.713c.002-.003.003-.007.007-.01a1.064 1.064 0 0 0 .223-1.15z" fill="#000" fill-rule="evenodd"/></svg>
<svg width="40px" height="40px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 1.93945L8.46973 5.46973L9.53027 6.53027L11.25 4.81055V9H12.75V4.81055L14.4697 6.53027L15.5303 5.46973L12 1.93945ZM5.46973 8.46973L1.93945 12L5.46973 15.5303L6.53027 14.4697L4.81055 12.75H9.75V11.25H4.81055L6.53027 9.53027L5.46973 8.46973ZM18.5303 8.46973L17.4697 9.53027L19.1895 11.25H14.25V12.75H19.1895L17.4697 14.4697L18.5303 15.5303L22.0605 12L18.5303 8.46973ZM11.25 14.25V19.1895L9.53027 17.4697L8.46973 18.5303L12 22.0605L15.5303 18.5303L14.4697 17.4697L12.75 19.1895V14.25H11.25Z" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 891 B

After

Width:  |  Height:  |  Size: 626 B

@ -4,7 +4,7 @@
The file has been modified
-->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="40" height="40">
<g style="transform: scale(0.078)">
<g style="transform: scale(0.078)" stroke-width="0.5">
<path d="M148.6458282,81.0641403C191.8570709-0.3458452,307.612915-4.617764,356.5062561,73.3931732c37.8880615,60.4514771,13.7960815,135.4847717-41.8233948,167.7876129l-36.121521-62.5643005c22.1270447-12.8510284,31.7114563-42.7013397,16.6385498-66.750618c-19.4511414-31.034935-65.5021057-29.3354645-82.692749,3.0517044c-12.7206879,23.9658356-2.6391449,51.5502472,18.3088379,63.7294922l-36.1482544,62.6105804C142.0118256,210.643219,116.6704254,141.3057709,148.6458282,81.0641403z M167.9667206,374.4708557c-0.0435791,24.2778625-18.934967,46.8978271-46.092804,47.9000549c-36.6418304,1.3522339-61.0877724-37.6520386-43.8971252-70.0392151c13.2918015-25.0418091,43.8297424-31.7192383,65.9928284-19.1222839l36.2165222-62.7288513c-55.7241974-31.7991638-132.6246796-15.0146027-166.0706635,47.9976501c-43.2111893,81.4099731,18.2372913,179.4530945,110.3418884,176.0540161c68.1375427-2.5146179,115.5750122-59.1652527,115.8612366-120.0613708H167.9667206z M451.714386,270.7571411l-36.1215515,62.5642395c22.2027588,12.816864,31.8418274,42.7249451,16.744751,66.8127441c-19.4511414,31.0349426-65.5021057,29.3354797-82.692688-3.0516968c-12.742218-24.0063782-2.6048279-51.643219,18.4154358-63.7908325l-36.1482544-62.6105652c-52.7280579,30.5827942-78.1254272,99.9726562-46.128479,160.2548218c43.2111816,81.4099731,158.9670105,85.6818848,207.8603821,7.6710205C531.5561523,378.1168213,507.4096069,303.0259705,451.714386,270.7571411z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1,3 @@
<svg width="24px" height="25px" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.25 4.5V12H3.75V13.5H11.25V21H12.75V13.5H20.25V12H12.75V4.5H11.25Z" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 205 B

@ -0,0 +1,11 @@
<svg width="24px" height="25px" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_402_12038)">
<path d="M18 1C14.6916 1 12 3.6916 12 7C12 10.3084 14.6916 13 18 13C21.3084 13 24 10.3084 24 7C24 3.6916 21.3084 1 18 1ZM20.55 7.45C17.5517 7.45 18.7266 7.45 15.45 7.45C15.2013 7.45 15 7.2487 15 7C15 6.7513 15.2013 6.55 15.45 6.55C18.7266 6.55 17.5517 6.55 20.55 6.55C20.7987 6.55 21 6.7513 21 7C21 7.2487 20.7987 7.45 20.55 7.45Z" fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.37 4.74853L9.83243 3.63143C9.7201 3.54995 9.5858 3.50425 9.44709 3.50028C9.29488 3.49591 9.1455 3.54202 9.02223 3.63143L0.284204 9.98015C0.166471 10.0657 0.0788289 10.1864 0.033816 10.3248C-0.0111969 10.4632 -0.0112737 10.6124 0.0335966 10.7508L3.37054 21.023C3.41554 21.1617 3.50333 21.2826 3.62129 21.3683C3.73926 21.454 3.88134 21.5001 4.02715 21.5H14.8275C14.9733 21.5001 15.1154 21.454 15.2334 21.3683C15.3513 21.2826 15.4391 21.1617 15.4841 21.023L17.7668 13.9962C17.2897 13.9806 16.8247 13.9172 16.3764 13.8107L14.3263 20.1203H4.52836L1.50041 10.8011L9.42733 5.04255L11.0434 6.21653C11.0999 5.70882 11.2107 5.21751 11.37 4.74853Z" fill="black" />
</g>
<defs>
<clipPath id="clip0_402_12038">
<rect width="24" height="24" fill="white" transform="translate(0 0.75)" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -0,0 +1,11 @@
<svg width="24px" height="25px" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_402_12031)">
<path d="M18 1C14.6916 1 12 3.6916 12 7C12 10.3084 14.6916 13 18 13C21.3084 13 24 10.3084 24 7C24 3.6916 21.3084 1 18 1ZM20.55 7.45H18.45V9.55C18.45 9.7987 18.2487 10 18 10C17.7513 10 17.55 9.7987 17.55 9.55V7.45H15.45C15.2013 7.45 15 7.2487 15 7C15 6.7513 15.2013 6.55 15.45 6.55H17.55V4.45C17.55 4.2013 17.7513 4 18 4C18.2487 4 18.45 4.2013 18.45 4.45V6.55H20.55C20.7987 6.55 21 6.7513 21 7C21 7.2487 20.7987 7.45 20.55 7.45Z" fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.37 4.74853L9.83243 3.63143C9.7201 3.54995 9.5858 3.50425 9.44709 3.50028C9.29488 3.49591 9.1455 3.54202 9.02223 3.63143L0.284204 9.98015C0.166471 10.0657 0.0788289 10.1864 0.033816 10.3248C-0.0111969 10.4632 -0.0112737 10.6124 0.0335966 10.7508L3.37054 21.023C3.41554 21.1617 3.50333 21.2826 3.62129 21.3683C3.73926 21.454 3.88134 21.5001 4.02715 21.5H14.8275C14.9733 21.5001 15.1154 21.454 15.2334 21.3683C15.3513 21.2826 15.4391 21.1617 15.4841 21.023L17.7668 13.9962C17.2897 13.9806 16.8247 13.9172 16.3764 13.8107L14.3263 20.1203H4.52836L1.50041 10.8011L9.42733 5.04255L11.0434 6.21653C11.0999 5.70882 11.2107 5.21751 11.37 4.74853Z" fill="black" />
</g>
<defs>
<clipPath id="clip0_402_12031">
<rect width="24" height="24" fill="white" transform="translate(0 0.75)" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,57 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@import '../../../base.scss';
.cvat-brush-tools-toolbox {
position: absolute;
margin: $grid-unit-size;
padding: 0 $grid-unit-size;
border-radius: 4px;
background: $background-color-2;
display: flex;
align-items: center;
z-index: 100;
box-shadow: $box-shadow-base;
> hr {
width: 1px;
height: $grid-unit-size * 4;
background: $border-color-1;
}
> * {
margin-right: $grid-unit-size;
}
> button {
font-size: 20px;
> span.anticon {
font-size: inherit;
}
}
.cvat-brush-tools-brush,
.cvat-brush-tools-eraser {
svg {
width: 24px;
height: 25px;
}
}
.cvat-brush-tools-draggable-area {
display: flex;
font-size: 20px;
svg {
width: 24px;
height: 25px;
}
}
.cvat-brush-tools-active-tool {
background: $header-color;
}
}

@ -0,0 +1,275 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import './brush-toolbox-styles.scss';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { useDispatch, useSelector } from 'react-redux';
import Button from 'antd/lib/button';
import Icon, { VerticalAlignBottomOutlined } from '@ant-design/icons';
import InputNumber from 'antd/lib/input-number';
import Select from 'antd/lib/select';
import { getCore } from 'cvat-core-wrapper';
import { Canvas, CanvasMode } from 'cvat-canvas-wrapper';
import {
BrushIcon, EraserIcon, PolygonMinusIcon, PolygonPlusIcon,
PlusIcon, CheckIcon, MoveIcon,
} from 'icons';
import CVATTooltip from 'components/common/cvat-tooltip';
import { CombinedState, ObjectType, ShapeType } from 'reducers';
import LabelSelector from 'components/label-selector/label-selector';
import { rememberObject, updateCanvasBrushTools } from 'actions/annotation-actions';
import useDraggable from './draggable-hoc';
const DraggableArea = (
<div className='cvat-brush-tools-draggable-area'>
<Icon component={MoveIcon} />
</div>
);
const MIN_BRUSH_SIZE = 1;
function BrushTools(): React.ReactPortal | null {
const dispatch = useDispatch();
const defaultLabelID = useSelector((state: CombinedState) => state.annotation.drawing.activeLabelID);
const config = useSelector((state: CombinedState) => state.annotation.canvas.brushTools);
const canvasInstance = useSelector((state: CombinedState) => state.annotation.canvas.instance);
const labels = useSelector((state: CombinedState) => state.annotation.job.labels);
const { visible } = config;
const [editableState, setEditableState] = useState<any | null>(null);
const [currentTool, setCurrentTool] = useState<'brush' | 'eraser' | 'polygon-plus' | 'polygon-minus'>('brush');
const [brushForm, setBrushForm] = useState<'circle' | 'square'>('circle');
const [[top, left], setTopLeft] = useState([0, 0]);
const [brushSize, setBrushSize] = useState(10);
const [removeUnderlyingPixels, setRemoveUnderlyingPixels] = useState(false);
const dragBar = useDraggable(
(): number[] => {
const [element] = window.document.getElementsByClassName('cvat-brush-tools-toolbox');
if (element) {
const { offsetTop, offsetLeft } = element as HTMLDivElement;
return [offsetTop, offsetLeft];
}
return [0, 0];
},
(newTop, newLeft) => setTopLeft([newTop, newLeft]),
DraggableArea,
);
useEffect(() => {
const label = labels.find((_label: any) => _label.id === defaultLabelID);
getCore().config.removeUnderlyingMaskPixels = removeUnderlyingPixels;
if (visible && label && canvasInstance instanceof Canvas) {
const onUpdateConfiguration = ({ brushTool }: any): void => {
if (brushTool?.size) {
setBrushSize(Math.max(MIN_BRUSH_SIZE, brushTool.size));
}
};
if (canvasInstance.mode() === CanvasMode.DRAW) {
canvasInstance.draw({
enabled: true,
shapeType: ShapeType.MASK,
crosshair: false,
brushTool: {
type: currentTool,
size: brushSize,
form: brushForm,
color: label.color,
},
onUpdateConfiguration,
});
} else if (canvasInstance.mode() === CanvasMode.EDIT && editableState) {
canvasInstance.edit({
enabled: true,
state: editableState,
brushTool: {
type: currentTool,
size: brushSize,
form: brushForm,
color: label.color,
},
onUpdateConfiguration,
});
}
}
}, [currentTool, brushSize, brushForm, visible, defaultLabelID, editableState]);
useEffect(() => {
const canvasContainer = window.document.getElementsByClassName('cvat-canvas-container')[0];
if (canvasContainer) {
const { offsetTop, offsetLeft } = canvasContainer.parentElement as HTMLElement;
setTopLeft([offsetTop, offsetLeft]);
}
}, []);
useEffect(() => {
const hideToolset = (): void => {
if (visible) {
dispatch(updateCanvasBrushTools({ visible: false }));
}
};
const showToolset = (e: Event): void => {
const evt = e as CustomEvent;
if (evt.detail?.state?.shapeType === ShapeType.MASK ||
(evt.detail?.drawData?.shapeType === ShapeType.MASK && !evt.detail?.drawData?.initialState)) {
dispatch(updateCanvasBrushTools({ visible: true }));
}
};
const updateEditableState = (e: Event): void => {
const evt = e as CustomEvent;
if (evt.type === 'canvas.editstart' && evt.detail.state) {
setEditableState(evt.detail.state);
} else if (editableState) {
setEditableState(null);
}
};
if (canvasInstance instanceof Canvas) {
canvasInstance.html().addEventListener('canvas.drawn', hideToolset);
canvasInstance.html().addEventListener('canvas.canceled', hideToolset);
canvasInstance.html().addEventListener('canvas.canceled', updateEditableState);
canvasInstance.html().addEventListener('canvas.drawstart', showToolset);
canvasInstance.html().addEventListener('canvas.editstart', showToolset);
canvasInstance.html().addEventListener('canvas.editstart', updateEditableState);
canvasInstance.html().addEventListener('canvas.editdone', updateEditableState);
}
return () => {
if (canvasInstance instanceof Canvas) {
canvasInstance.html().removeEventListener('canvas.drawn', hideToolset);
canvasInstance.html().removeEventListener('canvas.canceled', hideToolset);
canvasInstance.html().removeEventListener('canvas.canceled', updateEditableState);
canvasInstance.html().removeEventListener('canvas.drawstart', showToolset);
canvasInstance.html().removeEventListener('canvas.editstart', showToolset);
canvasInstance.html().removeEventListener('canvas.editstart', updateEditableState);
canvasInstance.html().removeEventListener('canvas.editdone', updateEditableState);
}
};
}, [visible, editableState]);
if (!labels.length) {
return null;
}
return ReactDOM.createPortal((
<div className='cvat-brush-tools-toolbox' style={{ top, left, display: visible ? '' : 'none' }}>
<Button
type='text'
className='cvat-brush-tools-finish'
icon={<Icon component={CheckIcon} />}
onClick={() => {
if (canvasInstance instanceof Canvas) {
if (editableState) {
canvasInstance.edit({ enabled: false });
} else {
canvasInstance.draw({ enabled: false });
}
}
}}
/>
{!editableState && (
<Button
type='text'
disabled={!!editableState}
className='cvat-brush-tools-continue'
icon={<Icon component={PlusIcon} />}
onClick={() => {
if (canvasInstance instanceof Canvas) {
canvasInstance.draw({ enabled: false, continue: true });
dispatch(
rememberObject({
activeObjectType: ObjectType.SHAPE,
activeShapeType: ShapeType.MASK,
activeLabelID: defaultLabelID,
}),
);
}
}}
/>
)}
<hr />
<Button
type='text'
className={['cvat-brush-tools-brush', ...(currentTool === 'brush' ? ['cvat-brush-tools-active-tool'] : [])].join(' ')}
icon={<Icon component={BrushIcon} />}
onClick={() => setCurrentTool('brush')}
/>
<Button
type='text'
className={['cvat-brush-tools-eraser', ...(currentTool === 'eraser' ? ['cvat-brush-tools-active-tool'] : [])].join(' ')}
icon={<Icon component={EraserIcon} />}
onClick={() => setCurrentTool('eraser')}
/>
<Button
type='text'
className={['cvat-brush-tools-polygon-plus', ...(currentTool === 'polygon-plus' ? ['cvat-brush-tools-active-tool'] : [])].join(' ')}
icon={<Icon component={PolygonPlusIcon} />}
onClick={() => setCurrentTool('polygon-plus')}
/>
<Button
type='text'
className={['cvat-brush-tools-polygon-minus', ...(currentTool === 'polygon-minus' ? ['cvat-brush-tools-active-tool'] : [])].join(' ')}
icon={<Icon component={PolygonMinusIcon} />}
onClick={() => setCurrentTool('polygon-minus')}
/>
{ ['brush', 'eraser'].includes(currentTool) ? (
<CVATTooltip title='Brush size [Hold Alt + Right Mouse Click + Drag Left/Right]'>
<InputNumber
className='cvat-brush-tools-brush-size'
value={brushSize}
min={MIN_BRUSH_SIZE}
formatter={(val: number | undefined) => {
if (val) return `${val}px`;
return '';
}}
parser={(val: string | undefined): number => {
if (val) return +val.replace('px', '');
return 0;
}}
onChange={(value: number) => {
if (Number.isInteger(value) && value >= MIN_BRUSH_SIZE) {
setBrushSize(value);
}
}}
/>
</CVATTooltip>
) : null}
{ ['brush', 'eraser'].includes(currentTool) ? (
<Select value={brushForm} onChange={(value: 'circle' | 'square') => setBrushForm(value)}>
<Select.Option value='circle'>Circle</Select.Option>
<Select.Option value='square'>Square</Select.Option>
</Select>
) : null}
<Button
type='text'
className={['cvat-brush-tools-underlying-pixels', ...(removeUnderlyingPixels ? ['cvat-brush-tools-active-tool'] : [])].join(' ')}
icon={<VerticalAlignBottomOutlined />}
onClick={() => setRemoveUnderlyingPixels(!removeUnderlyingPixels)}
/>
{ !editableState && (
<LabelSelector
labels={labels}
value={defaultLabelID}
onChange={({ id: labelID }: { id: number }) => {
if (Number.isInteger(labelID)) {
dispatch(
rememberObject({ activeLabelID: labelID }),
);
}
}}
/>
)}
{ dragBar }
</div>
), window.document.body);
}
export default React.memo(BrushTools);

@ -11,7 +11,7 @@ import { MenuInfo } from 'rc-menu/lib/interface';
import ObjectItemElementComponent from 'components/annotation-page/standard-workspace/objects-side-bar/object-item-element';
import ObjectItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-item';
import { Workspace } from 'reducers';
import { ShapeType, Workspace } from 'reducers';
import { rotatePoint } from 'utils/math';
import consts from 'consts';
@ -24,9 +24,9 @@ interface Props {
visible: boolean;
left: number;
top: number;
latestComments: string[];
onStartIssue(position: number[]): void;
openIssue(position: number[], message: string): void;
latestComments: string[];
}
interface ReviewContextMenuProps {
@ -109,7 +109,7 @@ export default function CanvasContextMenu(props: Props): JSX.Element | null {
const state = objectStates.find((_state: any): boolean => _state.clientID === contextMenuClientID);
if (state) {
let { points } = state;
if (['ellipse', 'rectangle'].includes(state.shapeType)) {
if ([ShapeType.ELLIPSE, ShapeType.RECTANGLE].includes(state.shapeType)) {
const [cx, cy] = state.shapeType === 'ellipse' ? state.points : [
(state.points[0] + state.points[2]) / 2,
(state.points[1] + state.points[3]) / 2,
@ -128,6 +128,14 @@ export default function CanvasContextMenu(props: Props): JSX.Element | null {
[points[2], points[3]],
[points[0], points[3]],
].map(([x, y]: number[]) => rotatePoint(x, y, state.rotation, cx, cy)).flat();
} else if (state.shapeType === ShapeType.MASK) {
points = state.points.slice(-4);
points = [
points[0], points[1],
points[2], points[1],
points[2], points[3],
points[0], points[3],
];
}
if (param.key === ReviewContextMenuKeys.OPEN_ISSUE) {

@ -1,4 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -20,6 +21,7 @@ import consts from 'consts';
import CVATTooltip from 'components/common/cvat-tooltip';
import FrameTags from 'components/annotation-page/tag-annotation-workspace/frame-tags';
import ImageSetupsContent from './image-setups-content';
import BrushTools from './brush-tools';
import ContextImage from '../standard-workspace/context-image/context-image';
const cvat = getCore();
@ -243,10 +245,6 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
if (prevProps.activatedStateID !== null && prevProps.activatedStateID !== activatedStateID) {
canvasInstance.activate(null);
const el = window.document.getElementById(`cvat_canvas_shape_${prevProps.activatedStateID}`);
if (el) {
(el as any).instance.fill({ opacity });
}
}
if (gridSize !== prevProps.gridSize) {
@ -342,7 +340,6 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.html().removeEventListener('mousedown', this.onCanvasMouseDown);
canvasInstance.html().removeEventListener('click', this.onCanvasClicked);
canvasInstance.html().removeEventListener('contextmenu', this.onCanvasContextMenu);
canvasInstance.html().removeEventListener('canvas.editstart', this.onCanvasEditStart);
canvasInstance.html().removeEventListener('canvas.edited', this.onCanvasEditDone);
canvasInstance.html().removeEventListener('canvas.dragstart', this.onCanvasDragStart);
@ -367,7 +364,6 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.html().removeEventListener('canvas.regionselected', this.onCanvasPositionSelected);
canvasInstance.html().removeEventListener('canvas.splitted', this.onCanvasTrackSplitted);
canvasInstance.html().removeEventListener('canvas.contextmenu', this.onCanvasPointContextMenu);
canvasInstance.html().removeEventListener('canvas.error', this.onCanvasErrorOccurrence);
window.removeEventListener('resize', this.fitCanvas);
@ -480,22 +476,12 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
};
private onCanvasClicked = (): void => {
const { onUpdateContextMenu } = this.props;
const { canvasInstance } = this.props as { canvasInstance: Canvas };
onUpdateContextMenu(false, 0, 0, ContextMenuType.CANVAS_SHAPE);
if (!canvasInstance.html().contains(document.activeElement) && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
private onCanvasContextMenu = (e: MouseEvent): void => {
const { activatedStateID, onUpdateContextMenu } = this.props;
if (e.target && !(e.target as HTMLElement).classList.contains('svg_select_points')) {
onUpdateContextMenu(activatedStateID !== null, e.clientX, e.clientY, ContextMenuType.CANVAS_SHAPE);
}
};
private onCanvasShapeDragged = (e: any): void => {
const { jobInstance } = this.props;
const { id } = e.detail;
@ -653,7 +639,6 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
const {
activatedStateID,
activatedAttributeID,
selectedOpacity,
aamZoomMargin,
workspace,
annotations,
@ -672,10 +657,6 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
if (activatedState && activatedState.objectType !== ObjectType.TAG) {
canvasInstance.activate(activatedStateID, activatedAttributeID);
}
const el = window.document.getElementById(`cvat_canvas_shape_${activatedStateID}`);
if (el) {
((el as any) as SVGElement).setAttribute('fill-opacity', `${selectedOpacity}`);
}
}
}
@ -746,7 +727,6 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.html().addEventListener('mousedown', this.onCanvasMouseDown);
canvasInstance.html().addEventListener('click', this.onCanvasClicked);
canvasInstance.html().addEventListener('contextmenu', this.onCanvasContextMenu);
canvasInstance.html().addEventListener('canvas.editstart', this.onCanvasEditStart);
canvasInstance.html().addEventListener('canvas.edited', this.onCanvasEditDone);
canvasInstance.html().addEventListener('canvas.dragstart', this.onCanvasDragStart);
@ -771,7 +751,6 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
canvasInstance.html().addEventListener('canvas.regionselected', this.onCanvasPositionSelected);
canvasInstance.html().addEventListener('canvas.splitted', this.onCanvasTrackSplitted);
canvasInstance.html().addEventListener('canvas.contextmenu', this.onCanvasPointContextMenu);
canvasInstance.html().addEventListener('canvas.error', this.onCanvasErrorOccurrence);
}
@ -826,6 +805,7 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
/>
<ContextImage />
<BrushTools />
<Dropdown trigger={['click']} placement='topCenter' overlay={<ImageSetupsContent />}>
<UpOutlined className='cvat-canvas-image-setups-trigger' />

@ -0,0 +1,58 @@
// Copyright (C) 2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React, { useEffect, useRef } from 'react';
export default function useDraggable(
getPosition: () => number[],
onDrag: (diffX: number, diffY: number) => void,
component: JSX.Element,
): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return () => {};
const click = [0, 0];
const position = getPosition();
const mouseMoveListener = (event: MouseEvent): void => {
const dy = event.clientY - click[0];
const dx = event.clientX - click[1];
onDrag(position[0] + dy, position[1] + dx);
event.stopPropagation();
event.preventDefault();
};
const mouseDownListener = (event: MouseEvent): void => {
const [initialTop, initialLeft] = getPosition();
position[0] = initialTop;
position[1] = initialLeft;
click[0] = event.clientY;
click[1] = event.clientX;
window.addEventListener('mousemove', mouseMoveListener);
event.stopPropagation();
event.preventDefault();
};
const mouseUpListener = (): void => {
window.removeEventListener('mousemove', mouseMoveListener);
};
window.document.addEventListener('mouseup', mouseUpListener);
ref.current.addEventListener('mousedown', mouseDownListener);
return () => {
window.document.removeEventListener('mouseup', mouseUpListener);
if (ref.current) {
ref.current.removeEventListener('mousedown', mouseDownListener);
}
};
}, [ref.current]);
return (
<div ref={ref}>
{component}
</div>
);
}

@ -1,4 +1,5 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -8,6 +9,7 @@ import { SmallDashOutlined } from '@ant-design/icons';
import Popover from 'antd/lib/popover';
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { ConnectedComponent } from 'react-redux';
const extraControlsContentClassName = 'cvat-extra-controls-control-content';
@ -48,7 +50,7 @@ export function ExtraControlsControl(): JSX.Element {
}
export default function ControlVisibilityObserver<P = {}>(
ControlComponent: React.FunctionComponent<P>,
ControlComponent: React.FunctionComponent<P> | ConnectedComponent<any, any>,
): React.FunctionComponent<P> {
let visibilityHeightThreshold = 0; // minimum value of height when element can be pushed to main panel

@ -1,4 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -9,8 +10,8 @@ import {
ActiveControl, ObjectType, Rotation, ShapeType,
} from 'reducers';
import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react';
import { Canvas } from 'cvat-canvas-wrapper';
import { Label } from 'components/labels-editor/common';
import { Canvas, CanvasMode } from 'cvat-canvas-wrapper';
import { LabelOptColor } from 'components/labels-editor/common';
import ControlVisibilityObserver, { ExtraControlsControl } from './control-visibility-observer';
import RotateControl, { Props as RotateControlProps } from './rotate-control';
@ -26,6 +27,7 @@ import DrawPolylineControl, { Props as DrawPolylineControlProps } from './draw-p
import DrawPointsControl, { Props as DrawPointsControlProps } from './draw-points-control';
import DrawEllipseControl, { Props as DrawEllipseControlProps } from './draw-ellipse-control';
import DrawCuboidControl, { Props as DrawCuboidControlProps } from './draw-cuboid-control';
import DrawMaskControl, { Props as DrawMaskControlProps } from './draw-mask-control';
import DrawSkeletonControl, { Props as DrawSkeletonControlProps } from './draw-skeleton-control';
import SetupTagControl, { Props as SetupTagControlProps } from './setup-tag-control';
import MergeControl, { Props as MergeControlProps } from './merge-control';
@ -65,6 +67,7 @@ const ObservedDrawPolylineControl = ControlVisibilityObserver<DrawPolylineContro
const ObservedDrawPointsControl = ControlVisibilityObserver<DrawPointsControlProps>(DrawPointsControl);
const ObservedDrawEllipseControl = ControlVisibilityObserver<DrawEllipseControlProps>(DrawEllipseControl);
const ObservedDrawCuboidControl = ControlVisibilityObserver<DrawCuboidControlProps>(DrawCuboidControl);
const ObservedDrawMaskControl = ControlVisibilityObserver<DrawMaskControlProps>(DrawMaskControl);
const ObservedDrawSkeletonControl = ControlVisibilityObserver<DrawSkeletonControlProps>(DrawSkeletonControl);
const ObservedSetupTagControl = ControlVisibilityObserver<SetupTagControlProps>(SetupTagControl);
const ObservedMergeControl = ControlVisibilityObserver<MergeControlProps>(MergeControl);
@ -97,15 +100,17 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
let pointsControlVisible = withUnspecifiedType;
let ellipseControlVisible = withUnspecifiedType;
let cuboidControlVisible = withUnspecifiedType;
let maskControlVisible = withUnspecifiedType;
let tagControlVisible = withUnspecifiedType;
const skeletonControlVisible = labels.some((label: Label) => label.type === 'skeleton');
labels.forEach((label: Label) => {
const skeletonControlVisible = labels.some((label: LabelOptColor) => label.type === 'skeleton');
labels.forEach((label: LabelOptColor) => {
rectangleControlVisible = rectangleControlVisible || label.type === ShapeType.RECTANGLE;
polygonControlVisible = polygonControlVisible || label.type === ShapeType.POLYGON;
polylineControlVisible = polylineControlVisible || label.type === ShapeType.POLYLINE;
pointsControlVisible = pointsControlVisible || label.type === ShapeType.POINTS;
ellipseControlVisible = ellipseControlVisible || label.type === ShapeType.ELLIPSE;
cuboidControlVisible = cuboidControlVisible || label.type === ShapeType.CUBOID;
maskControlVisible = maskControlVisible || label.type === ShapeType.MASK;
tagControlVisible = tagControlVisible || label.type === ObjectType.TAG;
});
@ -156,11 +161,20 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
ActiveControl.DRAW_CUBOID,
ActiveControl.DRAW_ELLIPSE,
ActiveControl.DRAW_SKELETON,
ActiveControl.DRAW_MASK,
ActiveControl.AI_TOOLS,
ActiveControl.OPENCV_TOOLS,
].includes(activeControl);
const editing = canvasInstance.mode() === CanvasMode.EDIT;
if (!drawing) {
if (editing) {
// users probably will press N as they are used to do when they want to finish editing
// in this case, if a mask is being edited we probably want to finish editing first
canvasInstance.edit({ enabled: false });
return;
}
canvasInstance.cancel();
// repeateDrawShapes gets all the latest parameters
// and calls canvasInstance.draw() with them
@ -306,6 +320,15 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
/>
)
}
{
maskControlVisible && (
<ObservedDrawMaskControl
canvasInstance={canvasInstance}
isDrawing={activeControl === ActiveControl.DRAW_MASK}
disabled={controlsDisabled}
/>
)
}
{
skeletonControlVisible && (
<ObservedDrawSkeletonControl

@ -0,0 +1,53 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import Popover from 'antd/lib/popover';
import Icon from '@ant-design/icons';
import { Canvas } from 'cvat-canvas-wrapper';
import { BrushIcon } from 'icons';
import DrawShapePopoverContainer from 'containers/annotation-page/standard-workspace/controls-side-bar/draw-mask-popover';
import withVisibilityHandling from './handle-popover-visibility';
export interface Props {
canvasInstance: Canvas;
isDrawing: boolean;
disabled?: boolean;
}
const CustomPopover = withVisibilityHandling(Popover, 'draw-mask');
function DrawPointsControl(props: Props): JSX.Element {
const { canvasInstance, isDrawing, disabled } = props;
const dynamicPopoverProps = isDrawing ? {
overlayStyle: {
display: 'none',
},
} : {};
const dynamicIconProps = isDrawing ? {
className: 'cvat-draw-mask-control cvat-active-canvas-control',
onClick: (): void => {
canvasInstance.draw({ enabled: false });
},
} : {
className: 'cvat-draw-mask-control',
};
return disabled ? (
<Icon className='cvat-draw-mask-control cvat-disabled-canvas-control' component={BrushIcon} />
) : (
<CustomPopover
{...dynamicPopoverProps}
overlayClassName='cvat-draw-shape-popover'
placement='right'
content={<DrawShapePopoverContainer />}
>
<Icon {...dynamicIconProps} component={BrushIcon} />
</CustomPopover>
);
}
export default React.memo(DrawPointsControl);

@ -0,0 +1,61 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button';
import Text from 'antd/lib/typography/Text';
import LabelSelector from 'components/label-selector/label-selector';
import CVATTooltip from 'components/common/cvat-tooltip';
interface Props {
labels: any[];
selectedLabelID: number;
repeatShapeShortcut: string;
onChangeLabel(value: string): void;
onDraw(labelID: number): void;
}
function DrawMaskPopover(props: Props): JSX.Element {
const {
labels, selectedLabelID, repeatShapeShortcut, onChangeLabel, onDraw,
} = props;
return (
<div className='cvat-draw-shape-popover-content'>
<Row justify='start'>
<Col>
<Text className='cvat-text-color' strong>
Draw new mask
</Text>
</Col>
</Row>
<Row justify='start'>
<Col>
<Text className='cvat-text-color'>Label</Text>
</Col>
</Row>
<Row justify='center'>
<Col span={24}>
<LabelSelector
style={{ width: '100%' }}
labels={labels}
value={selectedLabelID}
onChange={onChangeLabel}
/>
</Col>
</Row>
<Row justify='space-around'>
<Col span={24}>
<CVATTooltip title={`Press ${repeatShapeShortcut} to draw a mask again`}>
<Button onClick={() => onDraw(selectedLabelID)}>Draw a mask</Button>
</CVATTooltip>
</Col>
</Row>
</div>
);
}
export default React.memo(DrawMaskPopover);

@ -21,11 +21,12 @@ import { Row, Col } from 'antd/lib/grid';
import notification from 'antd/lib/notification';
import message from 'antd/lib/message';
import Dropdown from 'antd/lib/dropdown';
import Switch from 'antd/lib/switch';
import lodash from 'lodash';
import { AIToolsIcon } from 'icons';
import { Canvas, convertShapesForInteractor } from 'cvat-canvas-wrapper';
import { getCore } from 'cvat-core-wrapper';
import { getCore, Attribute, Label } from 'cvat-core-wrapper';
import openCVWrapper from 'utils/opencv-wrapper/opencv-wrapper';
import {
CombinedState, ActiveControl, Model, ObjectType, ShapeType, ToolsBlockerState, ModelAttribute,
@ -40,7 +41,6 @@ import {
import DetectorRunner, { DetectorRequestBody } from 'components/model-runner-modal/detector-runner';
import LabelSelector from 'components/label-selector/label-selector';
import CVATTooltip from 'components/common/cvat-tooltip';
import { Attribute, Label } from 'components/labels-editor/common';
import ApproximationAccuracy, {
thresholdFromAccuracy,
@ -75,6 +75,7 @@ interface DispatchToProps {
switchNavigationBlocked(navigationBlocked: boolean): void;
}
const MIN_SUPPORTED_INTERACTOR_VERSION = 2;
const core = getCore();
const CustomPopover = withVisibilityHandling(Popover, 'tools-control');
@ -139,6 +140,7 @@ interface State {
activeInteractor: Model | null;
activeLabelID: number;
activeTracker: Model | null;
convertMasksToPolygons: boolean;
trackedShapes: TrackedShape[];
fetching: boolean;
pointsRecieved: boolean;
@ -203,8 +205,11 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
private interaction: {
id: string | null;
isAborted: boolean;
latestResponse: number[][];
latestResult: number[][];
latestResponse: {
mask: number[][],
points: number[][],
};
lastestApproximatedPoints: number[][];
latestRequest: null | {
interactor: Model;
data: {
@ -219,6 +224,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
public constructor(props: Props) {
super(props);
this.state = {
convertMasksToPolygons: false,
activeInteractor: props.interactors.length ? props.interactors[0] : null,
activeTracker: props.trackers.length ? props.trackers[0] : null,
activeLabelID: props.labels.length ? props.labels[0].id : null,
@ -233,8 +239,11 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
this.interaction = {
id: null,
isAborted: false,
latestResponse: [],
latestResult: [],
latestResponse: {
mask: [],
points: [],
},
lastestApproximatedPoints: [],
latestRequest: null,
hideMessage: null,
};
@ -277,8 +286,8 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
this.interaction = {
id: null,
isAborted: false,
latestResponse: [],
latestResult: [],
latestResponse: { mask: [], points: [] },
lastestApproximatedPoints: [],
latestRequest: null,
hideMessage: null,
};
@ -291,14 +300,15 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
}
if (prevState.approxPolyAccuracy !== approxPolyAccuracy) {
if (isActivated && mode === 'interaction' && this.interaction.latestResponse.length) {
this.approximateResponsePoints(this.interaction.latestResponse).then((points: number[][]) => {
this.interaction.latestResult = points;
if (isActivated && mode === 'interaction' && this.interaction.latestResponse.points.length) {
this.approximateResponsePoints(this.interaction.latestResponse.points)
.then((points: number[][]) => {
this.interaction.lastestApproximatedPoints = points;
canvasInstance.interact({
enabled: true,
intermediateShape: {
shapeType: ShapeType.POLYGON,
points: this.interaction.latestResult.flat(),
points: this.interaction.lastestApproximatedPoints.flat(),
},
onChangeToolsBlockerState: this.onChangeToolsBlockerState,
});
@ -337,7 +347,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
private runInteractionRequest = async (interactionId: string): Promise<void> => {
const { jobInstance, canvasInstance } = this.props;
const { activeInteractor, fetching } = this.state;
const { activeInteractor, fetching, convertMasksToPolygons } = this.state;
const { id, latestRequest } = this.interaction;
if (id !== interactionId || !latestRequest || fetching) {
@ -360,18 +370,22 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
// run server request
this.setState({ fetching: true });
const response = await core.lambda.call(jobInstance.taskId, interactor, data);
// approximation with cv.approxPolyDP
const approximated = await this.approximateResponsePoints(response);
const approximated = await this.approximateResponsePoints(response.points);
if (this.interaction.id !== interactionId || this.interaction.isAborted) {
// new interaction session or the session is aborted
return;
}
this.interaction.latestResponse = response;
this.interaction.latestResult = approximated;
this.interaction.latestResponse = {
mask: response.mask,
points: response.points,
};
this.interaction.lastestApproximatedPoints = approximated;
this.setState({ pointsRecieved: !!response.length });
this.setState({ pointsRecieved: !!response.points.length });
} finally {
if (this.interaction.id === interactionId && this.interaction.hideMessage) {
this.interaction.hideMessage();
@ -381,12 +395,17 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
this.setState({ fetching: false });
}
if (this.interaction.latestResult.length) {
if (this.interaction.lastestApproximatedPoints.length) {
const height = this.interaction.latestResponse.mask.length;
const width = this.interaction.latestResponse.mask[0].length;
const maskPoints = this.interaction.latestResponse.mask.flat();
maskPoints.push(0, 0, width - 1, height - 1);
canvasInstance.interact({
enabled: true,
intermediateShape: {
shapeType: ShapeType.POLYGON,
points: this.interaction.latestResult.flat(),
shapeType: convertMasksToPolygons ? ShapeType.POLYGON : ShapeType.MASK,
points: convertMasksToPolygons ? this.interaction.lastestApproximatedPoints.flat() :
maskPoints,
},
onChangeToolsBlockerState: this.onChangeToolsBlockerState,
});
@ -420,8 +439,8 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
// prevent future requests if possible
this.interaction.isAborted = true;
this.interaction.latestRequest = null;
if (this.interaction.latestResult.length) {
this.constructFromPoints(this.interaction.latestResult);
if (this.interaction.lastestApproximatedPoints.length) {
this.constructFromPoints(this.interaction.lastestApproximatedPoints);
}
} else if (shapesUpdated) {
const interactor = activeInteractor as Model;
@ -507,8 +526,17 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
private setActiveInteractor = (value: string): void => {
const { interactors } = this.props;
const [interactor] = interactors.filter((_interactor: Model) => _interactor.id === value);
if (interactor.version < MIN_SUPPORTED_INTERACTOR_VERSION) {
notification.warning({
message: 'Interactor API is outdated',
description: 'Probably, you should consider updating the serverless function',
});
}
this.setState({
activeInteractor: interactors.filter((interactor: Model) => interactor.id === value)[0],
activeInteractor: interactor,
});
};
@ -756,7 +784,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
});
// eslint-disable-next-line no-await-in-loop
const response = await core.lambda.call(jobInstance.taskId, tracker, {
frame: frame ,
frame,
shapes: trackableObjects.shapes,
states: trackableObjects.states,
});
@ -797,10 +825,12 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
}
private constructFromPoints(points: number[][]): void {
const { convertMasksToPolygons } = this.state;
const {
frame, labels, curZOrder, jobInstance, activeLabelID, createAnnotations,
} = this.props;
if (convertMasksToPolygons) {
const object = new core.classes.ObjectState({
frame,
objectType: ObjectType.SHAPE,
@ -812,6 +842,23 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
});
createAnnotations(jobInstance, frame, [object]);
} else {
const height = this.interaction.latestResponse.mask.length;
const width = this.interaction.latestResponse.mask[0].length;
const maskPoints = this.interaction.latestResponse.mask.flat();
maskPoints.push(0, 0, width - 1, height - 1);
const object = new core.classes.ObjectState({
frame,
objectType: ObjectType.SHAPE,
label: labels.length ? labels.filter((label: any) => label.id === activeLabelID)[0] : null,
shapeType: ShapeType.MASK,
points: maskPoints,
occluded: false,
zOrder: curZOrder,
});
createAnnotations(jobInstance, frame, [object]);
}
}
private async approximateResponsePoints(points: number[][]): Promise<number[][]> {
@ -833,6 +880,21 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
return points;
}
private renderMasksConvertingBlock(): JSX.Element {
const { convertMasksToPolygons } = this.state;
return (
<Row className='cvat-interactors-setups-container'>
<Switch
checked={convertMasksToPolygons}
onChange={(checked: boolean) => {
this.setState({ convertMasksToPolygons: checked });
}}
/>
<Text>Convert masks to polygons</Text>
</Row>
);
}
private renderLabelBlock(): JSX.Element {
const { labels } = this.props;
const { activeLabelID } = this.state;
@ -932,7 +994,9 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
private renderInteractorBlock(): JSX.Element {
const { interactors, canvasInstance, onInteractionStart } = this.props;
const { activeInteractor, activeLabelID, fetching } = this.state;
const {
activeInteractor, activeLabelID, fetching,
} = this.state;
if (!interactors.length) {
return (
@ -995,7 +1059,9 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
type='primary'
loading={fetching}
className='cvat-tools-interact-button'
disabled={!activeInteractor || fetching}
disabled={!activeInteractor ||
fetching ||
activeInteractor.version < MIN_SUPPORTED_INTERACTOR_VERSION}
onClick={() => {
this.setState({ mode: 'interaction' });
@ -1074,7 +1140,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
case 'number':
return dbAttribute.values.includes(value) || inputType === 'text';
case 'text':
return ['select', 'radio'].includes(dbAttribute.input_type) && dbAttribute.values.includes(value);
return ['select', 'radio'].includes(dbAttribute.inputType) && dbAttribute.values.includes(value);
case 'select':
return (inputType === 'radio' && dbAttribute.values.includes(value)) || inputType === 'text';
case 'radio':
@ -1105,10 +1171,8 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
if (!jobLabel || !modelLabel) return null;
return new core.classes.ObjectState({
shapeType: data.type,
const objectData = {
label: jobLabel,
points: data.points,
objectType: ObjectType.SHAPE,
frame,
occluded: false,
@ -1118,7 +1182,8 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
const [modelAttr] = Object.entries(body.mapping[modelLabel].attributes)
.find((value: string[]) => value[1] === attr.name) || [];
const areCompatible = checkAttributesCompatibility(
model.attributes[modelLabel].find((mAttr) => mAttr.name === modelAttr),
model.attributes[modelLabel]
.find((mAttr) => mAttr.name === modelAttr),
jobLabel.attributes.find((jobAttr: Attribute) => (
jobAttr.name === attr.name
)),
@ -1132,6 +1197,28 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
return acc;
}, {} as Record<number, string>),
zOrder: curZOrder,
};
if (data.type === 'mask' && data.points && body.convMaskToPoly) {
return new core.classes.ObjectState({
...objectData,
shapeType: 'polygon',
points: data.points,
});
}
if (data.type === 'mask') {
return new core.classes.ObjectState({
...objectData,
shapeType: data.type,
points: data.mask,
});
}
return new core.classes.ObjectState({
...objectData,
shapeType: data.type,
points: data.points,
});
},
).filter((state: any) => state);
@ -1164,6 +1251,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
</Row>
<Tabs type='card' tabBarGutter={8}>
<Tabs.TabPane key='interactors' tab='Interactors'>
{this.renderMasksConvertingBlock()}
{this.renderLabelBlock()}
{this.renderInteractorBlock()}
</Tabs.TabPane>
@ -1184,7 +1272,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
interactors, detectors, trackers, isActivated, canvasInstance, labels, frameIsDeleted,
} = this.props;
const {
fetching, approxPolyAccuracy, pointsRecieved, mode, portals,
fetching, approxPolyAccuracy, pointsRecieved, mode, portals, convertMasksToPolygons,
} = this.state;
if (![...interactors, ...detectors, ...trackers].length) return null;
@ -1209,7 +1297,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
};
const showAnyContent = labels.length && !frameIsDeleted;
const showInteractionContent = isActivated && mode === 'interaction' && pointsRecieved;
const showInteractionContent = isActivated && mode === 'interaction' && pointsRecieved && convertMasksToPolygons;
const showDetectionContent = fetching && mode === 'detection';
const interactionContent: JSX.Element | null = showInteractionContent ? (

@ -1,4 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -43,6 +44,7 @@ interface Props {
toBackground(): void;
toForeground(): void;
resetCuboidPerspective(): void;
edit(): void;
}
function ItemTopComponent(props: Props): JSX.Element {
@ -75,6 +77,7 @@ function ItemTopComponent(props: Props): JSX.Element {
toBackground,
toForeground,
resetCuboidPerspective,
edit,
jobInstance,
} = props;
@ -150,6 +153,7 @@ function ItemTopComponent(props: Props): JSX.Element {
toForeground,
resetCuboidPerspective,
changeColorPickerVisible,
edit,
})}
>
<MoreOutlined />

@ -1,4 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -6,7 +7,7 @@ import React from 'react';
import Menu from 'antd/lib/menu';
import Button from 'antd/lib/button';
import Icon, {
LinkOutlined, CopyOutlined, BlockOutlined, RetweetOutlined, DeleteOutlined,
LinkOutlined, CopyOutlined, BlockOutlined, RetweetOutlined, DeleteOutlined, EditOutlined,
} from '@ant-design/icons';
import {
@ -44,6 +45,7 @@ interface Props {
toForeground(): void;
resetCuboidPerspective(): void;
changeColorPickerVisible(visible: boolean): void;
edit(): void;
jobInstance: any;
}
@ -77,6 +79,20 @@ function MakeCopyItem(props: ItemProps): JSX.Element {
);
}
function EditMaskItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { edit } = toolProps;
return (
<Menu.Item {...rest}>
<CVATTooltip title='Shift + Double click'>
<Button type='link' icon={<EditOutlined />} onClick={edit}>
Edit
</Button>
</CVATTooltip>
</Menu.Item>
);
}
function PropagateItem(props: ItemProps): JSX.Element {
const { toolProps, ...rest } = props;
const { propagateShortcut, propagate } = toolProps;
@ -209,6 +225,7 @@ export default function ItemMenu(props: Props): JSX.Element {
TO_FOREGROUND = 'to_foreground',
SWITCH_COLOR = 'switch_color',
REMOVE_ITEM = 'remove_item',
EDIT_MASK = 'edit_mask',
}
const is2D = jobInstance.dimension === DimensionType.DIM_2D;
@ -219,6 +236,7 @@ export default function ItemMenu(props: Props): JSX.Element {
{!readonly && objectType !== ObjectType.TAG && (
<MakeCopyItem key={MenuKeys.COPY} toolProps={props} />
)}
{!readonly && <EditMaskItem key={MenuKeys.EDIT_MASK} toolProps={props} />}
{!readonly && <PropagateItem key={MenuKeys.PROPAGATE} toolProps={props} />}
{is2D && !readonly && [ShapeType.POLYGON, ShapeType.POLYLINE, ShapeType.CUBOID].includes(shapeType) && (
<SwitchOrientationItem key={MenuKeys.SWITCH_ORIENTATION} toolProps={props} />

@ -1,4 +1,5 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -40,6 +41,7 @@ interface Props {
changeLabel(label: any): void;
changeColor(color: string): void;
resetCuboidPerspective(): void;
edit(): void;
}
function ObjectItemComponent(props: Props): JSX.Element {
@ -69,6 +71,7 @@ function ObjectItemComponent(props: Props): JSX.Element {
changeLabel,
changeColor,
resetCuboidPerspective,
edit,
jobInstance,
} = props;
@ -124,6 +127,7 @@ function ObjectItemComponent(props: Props): JSX.Element {
toBackground={toBackground}
toForeground={toForeground}
resetCuboidPerspective={resetCuboidPerspective}
edit={edit}
/>
<ObjectButtonsContainer readonly={readonly} clientID={clientID} />
{!!attributes.length && (

@ -115,6 +115,7 @@
.cvat-draw-polyline-control,
.cvat-draw-points-control,
.cvat-draw-ellipse-control,
.cvat-draw-mask-control,
.cvat-draw-cuboid-control,
.cvat-draw-skeleton-control,
.cvat-setup-tag-control,
@ -191,7 +192,7 @@
.cvat-tools-track-button,
.cvat-tools-interact-button {
width: 100%;
margin-top: 10px;
margin-top: $grid-unit-size;
}
.cvat-interactors-tips-icon-container {
@ -199,6 +200,15 @@
font-size: 20px;
}
.cvat-interactors-setups-container {
margin-top: $grid-unit-size;
margin-bottom: $grid-unit-size;
> button {
margin-right: $grid-unit-size;
}
}
.cvat-interactor-tip-container {
background: $background-color-2;
padding: $grid-unit-size;

@ -112,6 +112,7 @@ function FiltersModalComponent(): JSX.Element {
{ value: 'cuboid', title: 'Cuboid' },
{ value: 'ellipse', title: 'Ellipse' },
{ value: 'skeleton', title: 'Skeleton' },
{ value: 'mask', title: 'Mask' },
],
},
},

@ -5,7 +5,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { Row, Col } from 'antd/lib/grid';
import { ArrowRightOutlined, QuestionCircleOutlined, RightOutlined } from '@ant-design/icons';
import { QuestionCircleOutlined } from '@ant-design/icons';
import Table from 'antd/lib/table';
import Modal from 'antd/lib/modal';
import Spin from 'antd/lib/spin';
@ -114,6 +114,7 @@ function StatisticsModalComponent(props: StateToProps & DispatchToProps): JSX.El
ellipse: `${data.label[key].ellipse.shape} / ${data.label[key].ellipse.track}`,
cuboid: `${data.label[key].cuboid.shape} / ${data.label[key].cuboid.track}`,
skeleton: `${data.label[key].skeleton.shape} / ${data.label[key].skeleton.track}`,
mask: `${data.label[key].mask.shape}`,
tag: data.label[key].tag,
manually: data.label[key].manually,
interpolated: data.label[key].interpolated,
@ -130,6 +131,7 @@ function StatisticsModalComponent(props: StateToProps & DispatchToProps): JSX.El
ellipse: `${data.total.ellipse.shape} / ${data.total.ellipse.track}`,
cuboid: `${data.total.cuboid.shape} / ${data.total.cuboid.track}`,
skeleton: `${data.total.skeleton.shape} / ${data.total.skeleton.track}`,
mask: `${data.total.mask.shape}`,
tag: data.total.tag,
manually: data.total.manually,
interpolated: data.total.interpolated,
@ -137,7 +139,7 @@ function StatisticsModalComponent(props: StateToProps & DispatchToProps): JSX.El
});
const makeShapesTracksTitle = (title: string): JSX.Element => (
<CVATTooltip title={is2D ? 'Shapes / Tracks' : 'Shapes'}>
<CVATTooltip title={is2D && !(title.toLowerCase() === 'mask') ? 'Shapes / Tracks' : 'Shapes'}>
<Text strong style={{ marginRight: 5 }}>
{title}
</Text>
@ -210,6 +212,12 @@ function StatisticsModalComponent(props: StateToProps & DispatchToProps): JSX.El
key: 'skeleton',
width: 100,
},
{
title: makeShapesTracksTitle('Mask'),
dataIndex: 'mask',
key: 'mask',
width: 100,
},
{
title: <Text strong> Tag </Text>,
dataIndex: 'tag',

@ -49,6 +49,7 @@ const initialValues: FormValues = {
interface UploadParams {
resource: 'annotation' | 'dataset';
convMaskToPoly: boolean;
useDefaultSettings: boolean;
sourceStorage: Storage;
selectedFormat: string | null;
@ -75,6 +76,7 @@ function ImportDatasetModal(props: StateToProps): JSX.Element {
const [helpMessage, setHelpMessage] = useState('');
const [selectedSourceStorageLocation, setSelectedSourceStorageLocation] = useState(StorageLocation.LOCAL);
const [uploadParams, setUploadParams] = useState<UploadParams>({
convMaskToPoly: true,
useDefaultSettings: true,
} as UploadParams);
const [resource, setResource] = useState('');
@ -242,7 +244,8 @@ function ImportDatasetModal(props: StateToProps): JSX.Element {
instance, uploadParams.selectedFormat as string,
uploadParams.useDefaultSettings, uploadParams.sourceStorage,
uploadParams.file || uploadParams.fileName as string,
));
uploadParams.convMaskToPoly,
) as any);
const resToPrint = uploadParams.resource.charAt(0).toUpperCase() + uploadParams.resource.slice(1);
Notification.info({
message: `${resToPrint} import started`,
@ -314,11 +317,15 @@ function ImportDatasetModal(props: StateToProps): JSX.Element {
onCancel={closeModal}
onOk={() => form.submit()}
className='cvat-modal-import-dataset'
destroyOnClose
>
<Form
name={`Import ${resource}`}
form={form}
initialValues={initialValues}
initialValues={{
...initialValues,
convMaskToPoly: uploadParams.convMaskToPoly,
}}
onFinish={handleImport}
layout='vertical'
>
@ -371,7 +378,27 @@ function ImportDatasetModal(props: StateToProps): JSX.Element {
)}
</Select>
</Form.Item>
<Space>
<Space className='cvat-modal-import-switch-conv-mask-to-poly-container'>
<Form.Item
name='convMaskToPoly'
valuePropName='checked'
className='cvat-modal-import-switch-conv-mask-to-poly'
>
<Switch
onChange={(value: boolean) => {
setUploadParams({
...uploadParams,
convMaskToPoly: value,
} as UploadParams);
}}
/>
</Form.Item>
<Text strong>Convert masks to polygons</Text>
<CVATTooltip title='The option is relevant for formats that work with masks only'>
<QuestionCircleOutlined />
</CVATTooltip>
</Space>
<Space className='cvat-modal-import-switch-use-default-storage-container'>
<Form.Item
name='useDefaultSettings'
valuePropName='checked'

@ -34,3 +34,8 @@
width: 100%;
}
}
.cvat-modal-import-switch-use-default-storage-container,
.cvat-modal-import-switch-conv-mask-to-poly-container {
width: 100%;
}

@ -7,11 +7,11 @@ import React, { useState } from 'react';
import { Row, Col } from 'antd/lib/grid';
import { DeleteOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import Select, { BaseOptionType } from 'antd/lib/select';
import Checkbox, { CheckboxChangeEvent } from 'antd/lib/checkbox';
import Tag from 'antd/lib/tag';
import Text from 'antd/lib/typography/Text';
import InputNumber from 'antd/lib/input-number';
import Button from 'antd/lib/button';
import Switch from 'antd/lib/switch';
import notification from 'antd/lib/notification';
import { Model, ModelAttribute, StringObject } from 'reducers';
@ -40,6 +40,7 @@ type MappedLabelsList = Record<string, MappedLabel>;
export interface DetectorRequestBody {
mapping: MappedLabelsList;
cleanup: boolean;
convMaskToPoly: boolean;
}
interface Match {
@ -57,6 +58,7 @@ function DetectorRunner(props: Props): JSX.Element {
const [threshold, setThreshold] = useState<number>(0.5);
const [distance, setDistance] = useState<number>(50);
const [cleanup, setCleanup] = useState<boolean>(false);
const [convertMasksToPolygons, setConvertMasksToPolygons] = useState<boolean>(false);
const [match, setMatch] = useState<Match>({ model: null, task: null });
const [attrMatches, setAttrMatch] = useState<Record<string, Match>>({});
@ -352,14 +354,24 @@ function DetectorRunner(props: Props): JSX.Element {
</Row>
</>
) : null}
{isDetector && (
<div className='detector-runner-convert-masks-to-polygons-wrapper'>
<Switch
checked={convertMasksToPolygons}
onChange={(checked: boolean) => {
setConvertMasksToPolygons(checked);
}}
/>
<Text>Convert masks to polygons</Text>
</div>
)}
{isDetector && withCleanup ? (
<div>
<Checkbox
<div className='detector-runner-clean-previous-annotations-wrapper'>
<Switch
checked={cleanup}
onChange={(e: CheckboxChangeEvent): void => setCleanup(e.target.checked)}
>
Clean old annotations
</Checkbox>
onChange={(checked: boolean): void => setCleanup(checked)}
/>
<Text>Clean previous annotations</Text>
</div>
) : null}
{isReId ? (
@ -414,6 +426,7 @@ function DetectorRunner(props: Props): JSX.Element {
const detectorRequestBody: DetectorRequestBody = {
mapping,
cleanup,
convMaskToPoly: convertMasksToPolygons,
};
runInference(

@ -15,3 +15,10 @@
.cvat-run-model-label-attribute-block {
padding-left: $grid-unit-size * 4;
}
.detector-runner-clean-previous-annotations-wrapper,
.detector-runner-convert-masks-to-polygons-wrapper {
span {
margin-left: $grid-unit-size;
}
}

@ -1,4 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -6,12 +7,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { CombinedState, ContextMenuType, Workspace } from 'reducers';
import {
CombinedState, ContextMenuType, ShapeType, Workspace,
} from 'reducers';
import CanvasContextMenuComponent from 'components/annotation-page/canvas/canvas-context-menu';
import { updateCanvasContextMenu } from 'actions/annotation-actions';
import { reviewActions, finishIssueAsync } from 'actions/review-actions';
import { ThunkDispatch } from 'utils/redux';
import { Canvas } from 'cvat-canvas-wrapper';
import { ObjectState } from 'cvat-core-wrapper';
interface OwnProps {
@ -21,6 +25,7 @@ interface OwnProps {
interface StateToProps {
contextMenuParentID: number | null;
contextMenuClientID: number | null;
canvasInstance: Canvas | null;
objectStates: any[];
visible: boolean;
top: number;
@ -29,9 +34,14 @@ interface StateToProps {
collapsed: boolean | undefined;
workspace: Workspace;
latestComments: string[];
activatedStateID: number | null;
}
interface DispatchToProps {
onUpdateContextMenu(
visible: boolean, left: number, top: number,
pointID: number | null, type?: ContextMenuType,
): void;
onStartIssue(position: number[]): void;
openIssue(position: number[], message: string): void;
}
@ -39,8 +49,9 @@ interface DispatchToProps {
function mapStateToProps(state: CombinedState): StateToProps {
const {
annotation: {
annotations: { collapsed, states: objectStates },
annotations: { collapsed, states: objectStates, activatedStateID },
canvas: {
instance,
contextMenu: {
visible, top, left, type, clientID, parentID,
},
@ -63,7 +74,9 @@ function mapStateToProps(state: CombinedState): StateToProps {
contextMenuClientID: clientID,
contextMenuParentID: parentID,
collapsed: clientID !== null ? collapsed[clientID] : undefined,
activatedStateID,
objectStates,
canvasInstance: instance instanceof Canvas ? instance : null,
visible:
clientID !== null &&
visible &&
@ -79,6 +92,12 @@ function mapStateToProps(state: CombinedState): StateToProps {
function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps {
return {
onUpdateContextMenu(
visible: boolean, left: number, top: number,
pointID: number | null, type?: ContextMenuType,
): void {
dispatch(updateCanvasContextMenu(visible, left, top, pointID, type));
},
onStartIssue(position: number[]): void {
dispatch(reviewActions.startIssue(position));
dispatch(updateCanvasContextMenu(false, 0, 0));
@ -144,8 +163,15 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
}
public componentDidMount(): void {
const { canvasInstance } = this.props;
this.updatePositionIfOutOfScreen();
window.addEventListener('mousemove', this.moveContextMenu);
if (canvasInstance) {
canvasInstance.html().addEventListener('canvas.clicked', this.onClickCanvas);
canvasInstance.html().addEventListener('contextmenu', this.onOpenCanvasContextMenu);
canvasInstance.html().addEventListener('canvas.contextmenu', this.onCanvasPointContextMenu);
}
}
public componentDidUpdate(prevProps: Props): void {
@ -180,9 +206,46 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
}
public componentWillUnmount(): void {
const { canvasInstance } = this.props;
window.removeEventListener('mousemove', this.moveContextMenu);
if (canvasInstance) {
canvasInstance.html().removeEventListener('canvas.clicked', this.onClickCanvas);
canvasInstance.html().removeEventListener('contextmenu', this.onOpenCanvasContextMenu);
canvasInstance.html().removeEventListener('canvas.contextmenu', this.onCanvasPointContextMenu);
}
}
private onClickCanvas = (): void => {
const { visible, onUpdateContextMenu } = this.props;
if (visible) {
onUpdateContextMenu(false, 0, 0, null, ContextMenuType.CANVAS_SHAPE);
}
};
private onOpenCanvasContextMenu = (e: MouseEvent): void => {
const { activatedStateID, onUpdateContextMenu } = this.props;
if (e.target && !(e.target as HTMLElement).classList.contains('svg_select_points')) {
onUpdateContextMenu(
activatedStateID !== null, e.clientX, e.clientY, null, ContextMenuType.CANVAS_SHAPE,
);
}
};
private onCanvasPointContextMenu = (e: any): void => {
const { objectStates, activatedStateID, onUpdateContextMenu } = this.props;
const [state] = objectStates.filter((el: any) => el.clientID === activatedStateID);
if (![ShapeType.CUBOID, ShapeType.RECTANGLE, ShapeType.MASK].includes(state.shapeType)) {
onUpdateContextMenu(
activatedStateID !== null,
e.detail.mouseEvent.clientX,
e.detail.mouseEvent.clientY,
e.detail.pointID,
ContextMenuType.CANVAS_SHAPE_POINT,
);
}
};
private moveContextMenu = (e: MouseEvent): void => {
if (this.dragging) {
this.setState((state) => {
@ -219,7 +282,7 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
}
}
public render(): JSX.Element {
public render(): JSX.Element | null {
const { left, top } = this.state;
const {
visible,
@ -235,8 +298,7 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
} = this.props;
return (
<>
{type === ContextMenuType.CANVAS_SHAPE && (
type === ContextMenuType.CANVAS_SHAPE ? (
<CanvasContextMenuComponent
contextMenuClientID={contextMenuClientID}
contextMenuParentID={contextMenuParentID}
@ -250,8 +312,7 @@ class CanvasContextMenuContainer extends React.PureComponent<Props, State> {
onStartIssue={onStartIssue}
openIssue={openIssue}
/>
)}
</>
) : null
);
}
}

@ -232,6 +232,7 @@ function mapStateToProps(state: CombinedState): StateToProps {
switchableAutomaticBordering:
activeControl === ActiveControl.DRAW_POLYGON ||
activeControl === ActiveControl.DRAW_POLYLINE ||
activeControl === ActiveControl.DRAW_MASK ||
activeControl === ActiveControl.EDIT,
};
}

@ -0,0 +1,108 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { connect } from 'react-redux';
import DrawMaskPopoverComponent from 'components/annotation-page/standard-workspace/controls-side-bar/draw-mask-popover';
import { rememberObject } from 'actions/annotation-actions';
import { CombinedState, ShapeType, ObjectType } from 'reducers';
import { Canvas } from 'cvat-canvas-wrapper';
interface DispatchToProps {
onDrawStart(
shapeType: ShapeType,
labelID: number,
objectType: ObjectType,
): void;
}
interface StateToProps {
normalizedKeyMap: Record<string, string>;
canvasInstance: Canvas;
labels: any[];
}
function mapDispatchToProps(dispatch: any): DispatchToProps {
return {
onDrawStart(
shapeType: ShapeType,
labelID: number,
objectType: ObjectType,
): void {
dispatch(
rememberObject({
activeObjectType: objectType,
activeShapeType: shapeType,
activeLabelID: labelID,
}),
);
},
};
}
function mapStateToProps(state: CombinedState): StateToProps {
const {
annotation: {
canvas: { instance: canvasInstance },
job: { labels },
},
shortcuts: { normalizedKeyMap },
} = state;
return {
canvasInstance: canvasInstance as Canvas,
normalizedKeyMap,
labels,
};
}
type Props = StateToProps & DispatchToProps;
interface State {
selectedLabelID: number;
}
class DrawShapePopoverContainer extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
const defaultLabelID = props.labels.length ? props.labels[0].id : null;
this.state = { selectedLabelID: defaultLabelID };
}
private onDraw = (): void => {
const { canvasInstance, onDrawStart } = this.props;
const { selectedLabelID } = this.state;
canvasInstance.cancel();
canvasInstance.draw({
enabled: true,
shapeType: ShapeType.MASK,
crosshair: false,
});
onDrawStart(ShapeType.MASK, selectedLabelID, ObjectType.SHAPE);
};
private onChangeLabel = (value: any): void => {
this.setState({ selectedLabelID: value.id });
};
public render(): JSX.Element {
const { selectedLabelID } = this.state;
const { normalizedKeyMap, labels } = this.props;
return (
<DrawMaskPopoverComponent
labels={labels}
selectedLabelID={selectedLabelID}
repeatShapeShortcut={normalizedKeyMap.SWITCH_DRAW_MODE}
onChangeLabel={this.onChangeLabel}
onDraw={this.onDraw}
/>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(DrawShapePopoverContainer);

@ -1,4 +1,5 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -23,7 +24,7 @@ import ObjectStateItemComponent from 'components/annotation-page/standard-worksp
import { getColor } from 'components/annotation-page/standard-workspace/objects-side-bar/shared';
import { shift } from 'utils/math';
import { Label } from 'cvat-core-wrapper';
import { Canvas } from 'cvat-canvas-wrapper';
import { Canvas, CanvasMode } from 'cvat-canvas-wrapper';
import { Canvas3d } from 'cvat-canvas3d-wrapper';
interface OwnProps {
@ -142,6 +143,18 @@ class ObjectItemContainer extends React.PureComponent<Props> {
}
};
private edit = (): void => {
const { objectState, readonly, canvasInstance } = this.props;
if (!readonly && canvasInstance instanceof Canvas) {
if (canvasInstance.mode() !== CanvasMode.IDLE) {
canvasInstance.cancel();
}
canvasInstance.edit({ enabled: true, state: objectState });
}
}
private remove = (): void => {
const {
objectState, readonly, removeObject,
@ -342,6 +355,7 @@ class ObjectItemContainer extends React.PureComponent<Props> {
toForeground={this.toForeground}
changeColor={this.changeColor}
changeLabel={this.changeLabel}
edit={this.edit}
resetCuboidPerspective={() => this.resetCuboidPerspective()}
/>
);

@ -53,7 +53,13 @@ import SVGCVATAzureProvider from './assets/vscode-icons_file-type-azure.svg';
import SVGCVATS3Provider from './assets/S3.svg';
import SVGCVATGoogleCloudProvider from './assets/google-cloud.svg';
import SVGRestoreIcon from './assets/restore-icon.svg';
import SVGBrushIcon from './assets/brush-icon.svg';
import SVGEraserIcon from './assets/eraser-icon.svg';
import SVGPolygonPlusIcon from './assets/polygon-plus.svg';
import SVGPolygonMinusIcon from './assets/polygon-minus.svg';
import SVGMultiPlusIcon from './assets/multi-plus-icon.svg';
import SVGPlusIcon from './assets/plus-icon.svg';
import SVGCheckIcon from './assets/check-icon.svg';
export const CVATLogo = React.memo((): JSX.Element => <SVGCVATLogo />);
export const CursorIcon = React.memo((): JSX.Element => <SVGCursorIcon />);
@ -103,4 +109,10 @@ export const AzureProvider = React.memo((): JSX.Element => <SVGCVATAzureProvider
export const S3Provider = React.memo((): JSX.Element => <SVGCVATS3Provider />);
export const GoogleCloudProvider = React.memo((): JSX.Element => <SVGCVATGoogleCloudProvider />);
export const RestoreIcon = React.memo((): JSX.Element => <SVGRestoreIcon />);
export const BrushIcon = React.memo((): JSX.Element => <SVGBrushIcon />);
export const EraserIcon = React.memo((): JSX.Element => <SVGEraserIcon />);
export const PolygonPlusIcon = React.memo((): JSX.Element => <SVGPolygonPlusIcon />);
export const PolygonMinusIcon = React.memo((): JSX.Element => <SVGPolygonMinusIcon />);
export const MutliPlusIcon = React.memo((): JSX.Element => <SVGMultiPlusIcon />);
export const PlusIcon = React.memo((): JSX.Element => <SVGPlusIcon />);
export const CheckIcon = React.memo((): JSX.Element => <SVGCheckIcon />);

@ -42,6 +42,11 @@ const defaultState: AnnotationState = {
clientID: null,
parentID: null,
},
brushTools: {
visible: false,
top: 0,
left: 0,
},
instance: null,
ready: false,
activeControl: ActiveControl.CURSOR,
@ -484,22 +489,21 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
const { payload } = action;
let { activeControl } = state.canvas;
if (payload.activeShapeType === ShapeType.RECTANGLE) {
activeControl = ActiveControl.DRAW_RECTANGLE;
} else if (payload.activeShapeType === ShapeType.POLYGON) {
activeControl = ActiveControl.DRAW_POLYGON;
} else if (payload.activeShapeType === ShapeType.POLYLINE) {
activeControl = ActiveControl.DRAW_POLYLINE;
} else if (payload.activeShapeType === ShapeType.POINTS) {
activeControl = ActiveControl.DRAW_POINTS;
} else if (payload.activeShapeType === ShapeType.ELLIPSE) {
activeControl = ActiveControl.DRAW_ELLIPSE;
} else if (payload.activeShapeType === ShapeType.CUBOID) {
activeControl = ActiveControl.DRAW_CUBOID;
} else if (payload.activeShapeType === ShapeType.SKELETON) {
activeControl = ActiveControl.DRAW_SKELETON;
} else if (payload.activeObjectType === ObjectType.TAG) {
if ('activeObjectType' in payload && payload.activeObjectType === ObjectType.TAG) {
activeControl = ActiveControl.CURSOR;
} else if ('activeShapeType' in payload) {
const controlMapping = {
[ShapeType.RECTANGLE]: ActiveControl.DRAW_RECTANGLE,
[ShapeType.POLYGON]: ActiveControl.DRAW_POLYGON,
[ShapeType.POLYLINE]: ActiveControl.DRAW_POLYLINE,
[ShapeType.POINTS]: ActiveControl.DRAW_POINTS,
[ShapeType.ELLIPSE]: ActiveControl.DRAW_ELLIPSE,
[ShapeType.CUBOID]: ActiveControl.DRAW_CUBOID,
[ShapeType.SKELETON]: ActiveControl.DRAW_SKELETON,
[ShapeType.MASK]: ActiveControl.DRAW_MASK,
};
activeControl = controlMapping[payload.activeShapeType as ShapeType];
}
return {
@ -793,6 +797,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
canvas: {
...state.canvas,
activeControl,
contextMenu: {
...defaultState.canvas.contextMenu,
},
},
annotations: {
...state.annotations,
@ -991,6 +998,18 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
case AnnotationActionTypes.UPDATE_BRUSH_TOOLS_CONFIG: {
return {
...state,
canvas: {
...state.canvas,
brushTools: {
...state.canvas.brushTools,
...action.payload,
},
},
};
}
case AnnotationActionTypes.REDO_ACTION_SUCCESS:
case AnnotationActionTypes.UNDO_ACTION_SUCCESS: {
const { activatedStateID } = state.annotations;

@ -317,6 +317,7 @@ export interface Model {
id: string;
name: string;
labels: string[];
version: number;
attributes: Record<string, ModelAttribute[]>;
framework: string;
description: string;
@ -558,6 +559,7 @@ export enum ActiveControl {
DRAW_POLYLINE = 'draw_polyline',
DRAW_POINTS = 'draw_points',
DRAW_ELLIPSE = 'draw_ellipse',
DRAW_MASK = 'draw_mask',
DRAW_CUBOID = 'draw_cuboid',
DRAW_SKELETON = 'draw_skeleton',
MERGE = 'merge',
@ -577,6 +579,7 @@ export enum ShapeType {
POINTS = 'points',
ELLIPSE = 'ellipse',
CUBOID = 'cuboid',
MASK = 'mask',
SKELETON = 'skeleton',
}
@ -633,6 +636,11 @@ export interface AnnotationState {
parentID: number | null;
clientID: number | null;
};
brushTools: {
visible: boolean;
top: number;
left: number;
};
instance: Canvas | Canvas3d | null;
ready: boolean;
activeControl: ActiveControl;

@ -8,10 +8,10 @@ import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { AuthActionTypes } from 'actions/auth-actions';
import { SettingsActionTypes } from 'actions/settings-actions';
import { AnnotationActionTypes } from 'actions/annotation-actions';
import {
SettingsState, GridColor, FrameSpeed, ColorBy, DimensionType,
} from '.';
} from 'reducers';
import { ObjectState, ShapeType } from 'cvat-core-wrapper';
const defaultState: SettingsState = {
shapes: {
@ -383,6 +383,27 @@ export default (state = defaultState, action: AnyAction): SettingsState => {
},
};
}
case AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_SUCCESS:
case AnnotationActionTypes.CREATE_ANNOTATIONS_SUCCESS:
case AnnotationActionTypes.CHANGE_FRAME_SUCCESS: {
const { states } = action.payload;
if (states.some((_state: ObjectState): boolean => _state.shapeType === ShapeType.MASK)) {
const MIN_OPACITY = 30;
const { shapes: { opacity } } = state;
if (opacity < MIN_OPACITY) {
return {
...state,
shapes: {
...state.shapes,
opacity: MIN_OPACITY,
selectedOpacity: MIN_OPACITY * 2,
},
};
}
}
return state;
}
case BoundariesActionTypes.RESET_AFTER_ERROR:
case AnnotationActionTypes.GET_JOB_SUCCESS: {
const { job } = action.payload;

@ -4,9 +4,11 @@
#
# SPDX-License-Identifier: MIT
from functools import reduce
import os.path as osp
import re
import sys
import numpy as np
from collections import namedtuple
from pathlib import Path
from types import SimpleNamespace
@ -26,7 +28,7 @@ from cvat.apps.engine.models import Image as Img
from cvat.apps.engine.models import Label, LabelType, Project, ShapeType, Task
from .annotation import AnnotationIR, AnnotationManager, TrackManager
from .formats.transformations import EllipsesToMasks
from .formats.transformations import EllipsesToMasks, CVATRleToCOCORle
CVAT_INTERNAL_ATTRIBUTES = {'occluded', 'outside', 'keyframe', 'track_id', 'rotation'}
@ -1568,6 +1570,15 @@ def convert_cvat_anno_to_dm(cvat_frame_anno, label_attrs, map_label, format_name
"group": anno_group,
"attributes": anno_attr,
}), cvat_frame_anno.height, cvat_frame_anno.width)
elif shape_obj.type == ShapeType.MASK:
anno = CVATRleToCOCORle.convert_mask(SimpleNamespace(**{
"points": shape_obj.points,
"label": anno_label,
"z_order": shape_obj.z_order,
"rotation": shape_obj.rotation,
"group": anno_group,
"attributes": anno_attr,
}), cvat_frame_anno.height, cvat_frame_anno.width)
elif shape_obj.type == ShapeType.POLYLINE:
anno = dm.PolyLine(anno_points,
label=anno_label, attributes=anno_attr, group=anno_group,
@ -1671,7 +1682,8 @@ def import_dm_annotations(dm_dataset: dm.Dataset, instance_data: Union[ProjectDa
dm.AnnotationType.polyline: ShapeType.POLYLINE,
dm.AnnotationType.points: ShapeType.POINTS,
dm.AnnotationType.cuboid_3d: ShapeType.CUBOID,
dm.AnnotationType.skeleton: ShapeType.SKELETON
dm.AnnotationType.skeleton: ShapeType.SKELETON,
dm.AnnotationType.mask: ShapeType.MASK
}
label_cat = dm_dataset.categories()[dm.AnnotationType.label]
@ -1715,6 +1727,23 @@ def import_dm_annotations(dm_dataset: dm.Dataset, instance_data: Union[ProjectDa
points = []
if ann.type == dm.AnnotationType.cuboid_3d:
points = [*ann.position, *ann.rotation, *ann.scale, 0, 0, 0, 0, 0, 0, 0]
elif ann.type == dm.AnnotationType.mask:
istrue = np.argwhere(ann.image == 1).transpose()
top = int(istrue[0].min())
left = int(istrue[1].min())
bottom = int(istrue[0].max())
right = int(istrue[1].max())
points = ann.image[top:bottom + 1, left:right + 1]
def reduce_fn(acc, v):
if v == acc['val']:
acc['res'][-1] += 1
else:
acc['val'] = v
acc['res'].append(1)
return acc
points = reduce(reduce_fn, points.reshape(np.prod(points.shape)), { 'res': [0], 'val': False })['res']
points.extend([int(left), int(top), int(right), int(bottom)])
elif ann.type != dm.AnnotationType.skeleton:
points = ann.points

@ -11,7 +11,7 @@ from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor,
import_dm_annotations)
from cvat.apps.dataset_manager.util import make_zip_archive
from .transformations import RotatedBoxesToPolygons
from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons
from .registry import dm_env, exporter, importer
from .utils import make_colormap
@ -33,12 +33,12 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='CamVid', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
dataset = Dataset.import_from(tmp_dir, 'camvid', env=dm_env)
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)
import_dm_annotations(dataset, instance_data)

@ -13,7 +13,7 @@ from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor,
import_dm_annotations)
from cvat.apps.dataset_manager.util import make_zip_archive
from .transformations import RotatedBoxesToPolygons
from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons
from .registry import dm_env, exporter, importer
from .utils import make_colormap
@ -34,7 +34,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='Cityscapes', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
@ -45,7 +45,7 @@ def _import(src_file, instance_data, load_data_callback=None):
write_label_map(labelmap_file, colormap)
dataset = Dataset.import_from(tmp_dir, 'cityscapes', env=dm_env)
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)
import_dm_annotations(dataset, instance_data)

@ -25,7 +25,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='COCO', ext='JSON, ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
if zipfile.is_zipfile(src_file):
with TemporaryDirectory() as tmp_dir:
zipfile.ZipFile(src_file).extractall(tmp_dir)
@ -49,7 +49,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='COCO Keypoints', ext='JSON, ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
def remove_extra_annotations(dataset):
for item in dataset:
annotations = [ann for ann in item.annotations

@ -611,6 +611,11 @@ def create_xml_dumper(file_object):
self.xmlgen.startElement("points", points)
self._level += 1
def open_mask(self, points):
self._indent()
self.xmlgen.startElement("mask", points)
self._level += 1
def open_cuboid(self, cuboid):
self._indent()
self.xmlgen.startElement("cuboid", cuboid)
@ -657,6 +662,11 @@ def create_xml_dumper(file_object):
self._indent()
self.xmlgen.endElement("points")
def close_mask(self):
self._level -= 1
self._indent()
self.xmlgen.endElement("mask")
def close_cuboid(self):
self._level -= 1
self._indent()
@ -775,7 +785,14 @@ def dump_as_cvat_annotation(dumper, annotations):
("points", ''),
("rotation", "{:.2f}".format(shape.rotation))
]))
elif shape.type == "mask":
dump_data.update(OrderedDict([
("rle", f"{list(int (v) for v in shape.points[:-4])}"[1:-1]),
("left", f"{int(shape.points[-4])}"),
("top", f"{int(shape.points[-3])}"),
("width", f"{int(shape.points[-2] - shape.points[-4])}"),
("height", f"{int(shape.points[-1] - shape.points[-3])}"),
]))
else:
dump_data.update(OrderedDict([
("points", ';'.join((
@ -800,6 +817,8 @@ def dump_as_cvat_annotation(dumper, annotations):
dumper.open_polyline(dump_data)
elif shape.type == "points":
dumper.open_points(dump_data)
elif shape.type == "mask":
dumper.open_mask(dump_data)
elif shape.type == "cuboid":
dumper.open_cuboid(dump_data)
elif shape.type == "skeleton":
@ -826,6 +845,8 @@ def dump_as_cvat_annotation(dumper, annotations):
dumper.close_points()
elif shape.type == "cuboid":
dumper.close_cuboid()
elif shape.type == "mask":
dumper.close_mask()
elif shape.type == "skeleton":
dumper.close_skeleton()
else:
@ -907,6 +928,14 @@ def dump_as_cvat_interpolation(dumper, annotations):
dump_data.update(OrderedDict([
("rotation", "{:.2f}".format(shape.rotation))
]))
elif shape.type == "mask":
dump_data.update(OrderedDict([
("rle", f"{list(int (v) for v in shape.points[:-4])}"[1:-1]),
("left", f"{int(shape.points[-4])}"),
("top", f"{int(shape.points[-3])}"),
("width", f"{int(shape.points[-2] - shape.points[-4])}"),
("height", f"{int(shape.points[-1] - shape.points[-3])}"),
]))
elif shape.type == "cuboid":
dump_data.update(OrderedDict([
("xtl1", "{:.2f}".format(shape.points[0])),
@ -944,6 +973,8 @@ def dump_as_cvat_interpolation(dumper, annotations):
dumper.open_polyline(dump_data)
elif shape.type == "points":
dumper.open_points(dump_data)
elif shape.type == 'mask':
dumper.open_mask(dump_data)
elif shape.type == "cuboid":
dumper.open_cuboid(dump_data)
elif shape.type == 'skeleton':
@ -967,6 +998,8 @@ def dump_as_cvat_interpolation(dumper, annotations):
dumper.close_polyline()
elif shape.type == "points":
dumper.close_points()
elif shape.type == 'mask':
dumper.close_mask()
elif shape.type == "cuboid":
dumper.close_cuboid()
elif shape.type == "skeleton":
@ -1063,7 +1096,7 @@ def dump_as_cvat_interpolation(dumper, annotations):
dumper.close_root()
def load_anno(file_object, annotations):
supported_shapes = ('box', 'ellipse', 'polygon', 'polyline', 'points', 'cuboid', 'skeleton')
supported_shapes = ('box', 'ellipse', 'polygon', 'polyline', 'points', 'cuboid', 'skeleton', 'mask')
context = ElementTree.iterparse(file_object, events=("start", "end"))
context = iter(context)
next(context)
@ -1211,6 +1244,12 @@ def load_anno(file_object, annotations):
shape['points'].append(el.attrib['cy'])
shape['points'].append("{:.2f}".format(float(el.attrib['cx']) + float(el.attrib['rx'])))
shape['points'].append("{:.2f}".format(float(el.attrib['cy']) - float(el.attrib['ry'])))
elif el.tag == 'mask':
shape['points'] = el.attrib['rle'].split(',')
shape['points'].append(el.attrib['left'])
shape['points'].append(el.attrib['top'])
shape['points'].append("{}".format(int(el.attrib['left']) + int(el.attrib['width'])))
shape['points'].append("{}".format(int(el.attrib['top']) + int(el.attrib['height'])))
elif el.tag == 'cuboid':
shape['points'].append(el.attrib['xtl1'])
shape['points'].append(el.attrib['ytl1'])
@ -1248,6 +1287,22 @@ def load_anno(file_object, annotations):
if track_element:
track.elements.append(track_element)
track_element = None
else:
if track.shapes[0].type == 'mask':
# convert mask tracks to shapes
# because mask track are not supported
annotations.add_shape(annotations.LabeledShape(**{
'attributes': track.shapes[0].attributes,
'points': track.shapes[0].points,
'type': track.shapes[0].type,
'occluded': track.shapes[0].occluded,
'frame': track.shapes[0].frame,
'source': track.shapes[0].source,
'rotation': track.shapes[0].rotation,
'z_order': track.shapes[0].z_order,
'group': track.shapes[0].group,
'label': track.label,
}))
else:
annotations.add_track(track)
track = None
@ -1334,7 +1389,7 @@ def _export_images(dst_file, instance_data, save_images=False):
anno_callback=dump_as_cvat_annotation, save_images=save_images)
@importer(name='CVAT', ext='XML, ZIP', version='1.1')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
is_zip = zipfile.is_zipfile(src_file)
src_file.seek(0)
if is_zip:

@ -36,7 +36,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(tmp_dir, dst_file)
@importer(name="Datumaro", ext="ZIP", version="1.0")
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
@ -60,7 +60,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(tmp_dir, dst_file)
@importer(name="Datumaro 3D", ext="ZIP", version="1.0", dimension=DimensionType.DIM_3D)
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)

@ -15,7 +15,7 @@ from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor,
import_dm_annotations)
from cvat.apps.dataset_manager.util import make_zip_archive
from .transformations import RotatedBoxesToPolygons
from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons
from .registry import dm_env, exporter, importer
@ -87,7 +87,7 @@ def _export_recognition(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='ICDAR Recognition', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
zipfile.ZipFile(src_file).extractall(tmp_dir)
dataset = Dataset.import_from(tmp_dir, 'icdar_word_recognition', env=dm_env)
@ -106,7 +106,7 @@ def _export_localization(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='ICDAR Localization', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
zipfile.ZipFile(src_file).extractall(tmp_dir)
@ -130,12 +130,12 @@ def _export_segmentation(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='ICDAR Segmentation', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
zipfile.ZipFile(src_file).extractall(tmp_dir)
dataset = Dataset.import_from(tmp_dir, 'icdar_text_segmentation', env=dm_env)
dataset.transform(AddLabelToAnns, label='icdar')
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)
import_dm_annotations(dataset, instance_data)

@ -29,7 +29,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='ImageNet', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
zipfile.ZipFile(src_file).extractall(tmp_dir)
if glob(osp.join(tmp_dir, '*.txt')):

@ -13,7 +13,7 @@ from pyunpack import Archive
from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, import_dm_annotations)
from cvat.apps.dataset_manager.util import make_zip_archive
from .transformations import RotatedBoxesToPolygons
from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons
from .registry import dm_env, exporter, importer
from .utils import make_colormap
@ -35,7 +35,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(tmp_dir, dst_file)
@importer(name='KITTI', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
@ -49,7 +49,7 @@ def _import(src_file, instance_data, load_data_callback=None):
if 'background' not in [label['name'] for _, label in labels_meta]:
dataset.filter('/item/annotation[label != "background"]',
filter_annotations=True)
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)

@ -9,6 +9,7 @@ from pyunpack import Archive
from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor,
import_dm_annotations)
from cvat.apps.dataset_manager.formats.transformations import MaskToPolygonTransformation
from cvat.apps.dataset_manager.util import make_zip_archive
from .registry import dm_env, exporter, importer
@ -24,12 +25,12 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='LabelMe', ext='ZIP', version='3.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
dataset = Dataset.import_from(tmp_dir, 'label_me', env=dm_env)
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)
import_dm_annotations(dataset, instance_data)

@ -14,7 +14,7 @@ from .registry import dm_env, exporter, importer
@importer(name='LFW', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)

@ -71,7 +71,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='Market-1501', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
zipfile.ZipFile(src_file).extractall(tmp_dir)

@ -11,7 +11,7 @@ from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor,
import_dm_annotations)
from cvat.apps.dataset_manager.util import make_zip_archive
from .transformations import RotatedBoxesToPolygons
from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons
from .registry import dm_env, exporter, importer
from .utils import make_colormap
@ -30,12 +30,12 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='Segmentation mask', ext='ZIP', version='1.1')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
dataset = Dataset.import_from(tmp_dir, 'voc', env=dm_env)
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)
import_dm_annotations(dataset, instance_data)

@ -99,7 +99,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='MOT', ext='ZIP', version='1.1')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)

@ -14,7 +14,7 @@ from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor,
find_dataset_root, match_dm_item)
from cvat.apps.dataset_manager.util import make_zip_archive
from .transformations import RotatedBoxesToPolygons
from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons
from .registry import dm_env, exporter, importer
@ -109,12 +109,12 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='MOTS PNG', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
dataset = Dataset.import_from(tmp_dir, 'mots', env=dm_env)
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)

@ -15,7 +15,7 @@ from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor,
find_dataset_root, import_dm_annotations, match_dm_item)
from cvat.apps.dataset_manager.util import make_zip_archive
from .transformations import RotatedBoxesToPolygons
from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons
from .registry import dm_env, exporter, importer
@ -51,7 +51,7 @@ def _export(dst_file, task_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='Open Images V6', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
@ -79,7 +79,7 @@ def _import(src_file, instance_data, load_data_callback=None):
dataset = Dataset.import_from(tmp_dir, 'open_images',
image_meta=image_meta, env=dm_env)
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)
import_dm_annotations(dataset, instance_data)

@ -13,6 +13,7 @@ from datumaro.components.dataset import Dataset
from pyunpack import Archive
from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, import_dm_annotations)
from cvat.apps.dataset_manager.formats.transformations import MaskToPolygonTransformation
from cvat.apps.dataset_manager.util import make_zip_archive
from .registry import dm_env, exporter, importer
@ -29,7 +30,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='PASCAL VOC', ext='ZIP', version='1.1')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)
@ -56,7 +57,7 @@ def _import(src_file, instance_data, load_data_callback=None):
shutil.move(f, anno_dir)
dataset = Dataset.import_from(tmp_dir, 'voc', env=dm_env)
dataset.transform('masks_to_polygons')
dataset = MaskToPolygonTransformation.convert_dataset(dataset, **kwargs)
if load_data_callback is not None:
load_data_callback(dataset, instance_data)
import_dm_annotations(dataset, instance_data)

@ -28,7 +28,7 @@ def _export_images(dst_file, task_data, save_images=False):
@importer(name='Sly Point Cloud Format', ext='ZIP', version='1.0', dimension=DimensionType.DIM_3D)
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
if zipfile.is_zipfile(src_file):

@ -32,7 +32,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='TFRecord', ext='ZIP', version='1.0', enabled=tf_available)
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)

@ -36,6 +36,28 @@ class RotatedBoxesToPolygons(ItemTransform):
return item.wrap(annotations=annotations)
class CVATRleToCOCORle(ItemTransform):
@staticmethod
def convert_mask(shape, img_h, img_w):
rle = shape.points[:-4]
left, top, right = list(math.trunc(v) for v in shape.points[-4:-1])
mat = np.zeros((img_h, img_w), dtype=np.uint8)
width = right - left + 1
value = 0
offset = 0
for rleCount in rle:
rleCount = math.trunc(rleCount)
while rleCount > 0:
x, y = offset % width, offset // width
mat[y + top][x + left] = value
rleCount -= 1
offset += 1
value = abs(value - 1)
rle = mask_utils.encode(np.asfortranarray(mat))
return dm.RleMask(rle=rle, label=shape.label, z_order=shape.z_order,
attributes=shape.attributes, group=shape.group)
class EllipsesToMasks:
@staticmethod
def convert_ellipse(ellipse, img_h, img_w):
@ -50,3 +72,19 @@ class EllipsesToMasks:
rle = mask_utils.encode(np.asfortranarray(mat))
return dm.RleMask(rle=rle, label=ellipse.label, z_order=ellipse.z_order,
attributes=ellipse.attributes, group=ellipse.group)
class MaskToPolygonTransformation:
"""
Manages common logic for mask to polygons conversion in dataset import.
This usecase is supposed for backward compatibility for the transition period.
"""
@classmethod
def declare_arg_names(cls):
return ['conv_mask_to_poly']
@classmethod
def convert_dataset(cls, dataset, **kwargs):
if kwargs.get('conv_mask_to_poly', True):
dataset.transform('masks_to_polygons')
return dataset

@ -29,7 +29,7 @@ def _export_images(dst_file, task_data, save_images=False):
@importer(name='Kitti Raw Format', ext='ZIP', version='1.0', dimension=DimensionType.DIM_3D)
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
if zipfile.is_zipfile(src_file):
zipfile.ZipFile(src_file).extractall(tmp_dir)

@ -25,7 +25,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='VGGFace2', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
zipfile.ZipFile(src_file).extractall(tmp_dir)

@ -24,7 +24,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='WiderFace', ext='ZIP', version='1.0')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
zipfile.ZipFile(src_file).extractall(tmp_dir)

@ -28,7 +28,7 @@ def _export(dst_file, instance_data, save_images=False):
make_zip_archive(temp_dir, dst_file)
@importer(name='YOLO', ext='ZIP', version='1.1')
def _import(src_file, instance_data, load_data_callback=None):
def _import(src_file, instance_data, load_data_callback=None, **kwargs):
with TemporaryDirectory() as tmp_dir:
Archive(src_file.name).extractall(tmp_dir)

@ -132,7 +132,7 @@ class ProjectAnnotationAndData:
def load_dataset_data(self, *args, **kwargs):
load_dataset_data(self, *args, **kwargs)
def import_dataset(self, dataset_file, importer):
def import_dataset(self, dataset_file, importer, **options):
project_data = ProjectData(
annotation_irs=self.annotation_irs,
db_project=self.db_project,
@ -141,7 +141,7 @@ class ProjectAnnotationAndData:
)
project_data.soft_attribute_import = True
importer(dataset_file, project_data, self.load_dataset_data)
importer(dataset_file, project_data, self.load_dataset_data, **options)
self.create({tid: ir.serialize() for tid, ir in self.annotation_irs.items() if tid in project_data.new_tasks})
@ -150,7 +150,7 @@ class ProjectAnnotationAndData:
raise NotImplementedError()
@transaction.atomic
def import_dataset_as_project(project_id, dataset_file, format_name):
def import_dataset_as_project(project_id, dataset_file, format_name, conv_mask_to_poly):
rq_job = rq.get_current_job()
rq_job.meta['status'] = 'Dataset import has been started...'
rq_job.meta['progress'] = 0.
@ -161,4 +161,4 @@ def import_dataset_as_project(project_id, dataset_file, format_name):
importer = make_importer(format_name)
with open(dataset_file, 'rb') as f:
project.import_dataset(f, importer)
project.import_dataset(f, importer, conv_mask_to_poly=conv_mask_to_poly)

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save