Added paint brush tools (#4543)
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -1,116 +1,44 @@
|
|||||||
// Copyright (C) 2019-2022 Intel Corporation
|
// Copyright (C) 2019-2022 Intel Corporation
|
||||||
|
// Copyright (C) 2022 CVAT.ai Corporation
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
(() => {
|
interface ObjectStatistics {
|
||||||
/**
|
track: number;
|
||||||
* Class representing collection statistics
|
shape: number;
|
||||||
* @memberof module:API.cvat.classes
|
}
|
||||||
* @hideconstructor
|
|
||||||
*/
|
interface StatisticsBody {
|
||||||
class Statistics {
|
rectangle: ObjectStatistics;
|
||||||
constructor(label, total) {
|
polygon: ObjectStatistics;
|
||||||
Object.defineProperties(
|
polyline: ObjectStatistics;
|
||||||
this,
|
points: ObjectStatistics;
|
||||||
Object.freeze({
|
ellipse: ObjectStatistics;
|
||||||
/**
|
cuboid: ObjectStatistics;
|
||||||
* Statistics collected by labels, has the following structure:
|
skeleton: ObjectStatistics;
|
||||||
* @example
|
mask: {
|
||||||
* {
|
shape: number;
|
||||||
* label: {
|
};
|
||||||
* rectangle: {
|
tag: number;
|
||||||
* track: 10,
|
manually: number;
|
||||||
* shape: 11,
|
interpolated: number;
|
||||||
* },
|
total: number;
|
||||||
* polygon: {
|
}
|
||||||
* track: 13,
|
|
||||||
* shape: 14,
|
export default class Statistics {
|
||||||
* },
|
private labelData: Record<string, StatisticsBody>;
|
||||||
* polyline: {
|
private totalData: StatisticsBody;
|
||||||
* track: 16,
|
|
||||||
* shape: 17,
|
constructor(label: Statistics['labelData'], total: Statistics['totalData']) {
|
||||||
* },
|
this.labelData = label;
|
||||||
* points: {
|
this.totalData = total;
|
||||||
* 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)),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Statistics;
|
public get label(): Record<string, StatisticsBody> {
|
||||||
})();
|
return JSON.parse(JSON.stringify(this.labelData));
|
||||||
|
}
|
||||||
|
|
||||||
|
public get total(): StatisticsBody {
|
||||||
|
return JSON.parse(JSON.stringify(this.totalData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 |
|
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);
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||