Interpolation of polyshapes in CVAT (#1571)

* cvat-core interpolation

* Unlocked polyshapes

* Updated versions

* Second implementation

* Splitted line

* Inverse, set start point, fixed editing a bit, point menu refactoring

* Removed extra code, fixed bug: Cannot open shape context menu when point context menu is opened

* Do not close shape context menu on change shap[e

* Improved editing

* Updated mouseevents to edit/delete, added ability to redraw (shift + N)

* Ignore tags when redraw

* Ingoring tags on canvas, added method isAbleToChangeFrame, hide shapes during redraw, do not allow changing frame during redraw

* Canceled advanced editing for polygons

* A couple of fixes in interpolation, editing, reducing number of points

* Moved change orientation functionality

* Fixed interpolation algorithm

* Zero division issue

* Zero division issue

* Segment minimization

* Server implementation

* Keyframe set to False

* Simplified code, fixed server side code

* Updated changelog.md

* Added pdf describing interpolation

* Resolved some issues

* Updated UI version

* Updated canvas version

* Fixed host
main
Boris Sekachev 6 years ago committed by GitHub
parent 07a3c4a9a2
commit a300684876
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -15,11 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Built-in search for labels when create an object or change a label (<https://github.com/opencv/cvat/pull/1683>)
- Better validation of labels and attributes in raw viewer (<https://github.com/opencv/cvat/pull/1727>)
- ClamAV antivirus integration (<https://github.com/opencv/cvat/pull/1712>)
- Polygon and polylines interpolation (<https://github.com/opencv/cvat/pull/1571>)
- Ability to redraw shape from scratch (Shift + N) for an activated shape (<https://github.com/opencv/cvat/pull/1571>)
- Highlights for the first point of a polygon/polyline and direction (<https://github.com/opencv/cvat/pull/1571>)
- Ability to change orientation for poylgons/polylines in context menu (<https://github.com/opencv/cvat/pull/1571>)
- Ability to set the first point for polygons in points context menu (<https://github.com/opencv/cvat/pull/1571>)
### Changed
- Removed information about e-mail from the basic user information (<https://github.com/opencv/cvat/pull/1627>)
- Update https install manual. Makes it easier and more robust. Includes automatic renewing of lets encrypt certificates.
- Implemented import and export of annotations with relative image paths (<https://github.com/opencv/cvat/pull/1463>)
- Using only single click to start editing or remove a point (<https://github.com/opencv/cvat/pull/1571>)
### Deprecated
-

@ -117,6 +117,7 @@ Canvas itself handles:
mode(): Mode;
cancel(): void;
configure(configuration: Configuration): void;
isAbleToChangeFrame(): boolean;
}
```
@ -188,8 +189,7 @@ Standard JS events are used.
| | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS |
|--------------|------|-------|-------|------|-------|------|------|--------|-------------|-------------|
| html() | + | + | + | + | + | + | + | + | + | + |
| setup() | + | + | + | + | + | +/- | +/- | +/- | + | + |
| setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + |
| activate() | + | - | - | - | - | - | - | - | - | - |
| rotate() | + | + | + | + | + | + | + | + | + | + |
| focus() | + | + | + | + | + | + | + | + | + | + |
@ -208,3 +208,6 @@ Standard JS events are used.
| setZLayer() | + | + | + | + | + | + | + | + | + | + |
You can call setup() during editing, dragging, and resizing only to update objects, not to change a frame.
You can change frame during draw only when you do not redraw an existing object
Other methods do not change state and can be used everytime.

@ -1,6 +1,6 @@
{
"name": "cvat-canvas",
"version": "1.1.1",
"version": "1.2.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

@ -1,6 +1,6 @@
{
"name": "cvat-canvas",
"version": "1.1.1",
"version": "1.2.0",
"description": "Part of Computer Vision Annotation Tool which presents its canvas library",
"main": "src/canvas.ts",
"scripts": {

@ -151,6 +151,25 @@ polyline.cvat_canvas_shape_splitting {
cursor: move;
}
.cvat_canvas_first_poly_point {
fill: lightgray;
}
.cvat_canvas_poly_direction {
fill: lightgray;
stroke: black;
&:hover {
fill: black;
stroke: lightgray;
}
&:active {
fill: lightgray;
stroke: black;
}
}
#cvat_canvas_wrapper {
width: calc(100% - 10px);
height: calc(100% - 10px);

@ -58,6 +58,7 @@ interface Canvas {
mode(): Mode;
cancel(): void;
configure(configuration: Configuration): void;
isAbleToChangeFrame(): boolean;
}
class CanvasImpl implements Canvas {
@ -153,6 +154,10 @@ class CanvasImpl implements Canvas {
public configure(configuration: Configuration): void {
this.model.configure(configuration);
}
public isAbleToChangeFrame(): boolean {
return this.model.isAbleToChangeFrame();
}
}
export {

@ -66,6 +66,7 @@ export interface DrawData {
numberOfPoints?: number;
initialState?: any;
crosshair?: boolean;
redraw?: number;
}
export interface EditData {
@ -169,6 +170,7 @@ export interface CanvasModel {
dragCanvas(enable: boolean): void;
zoomCanvas(enable: boolean): void;
isAbleToChangeFrame(): boolean;
configure(configuration: Configuration): void;
cancel(): void;
}
@ -382,10 +384,17 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
}
if (this.data.mode !== Mode.IDLE && clientID !== null) {
// Exception or just return?
throw Error(`Canvas is busy. Action: ${this.data.mode}`);
}
if (typeof (clientID) === 'number') {
const [state] = this.data.objects
.filter((_state: any): boolean => _state.clientID === clientID);
if (!['rectangle', 'polygon', 'polyline', 'points', 'cuboid'].includes(state.shapeType)) {
return;
}
}
this.data.activeElement = {
clientID,
attributeID,
@ -465,10 +474,24 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
}
}
this.data.drawData = { ...drawData };
if (this.data.drawData.initialState) {
this.data.drawData.shapeType = this.data.drawData.initialState.shapeType;
if (typeof (drawData.redraw) === 'number') {
const clientID = drawData.redraw;
const [state] = this.data.objects
.filter((_state: any): boolean => _state.clientID === clientID);
if (state) {
this.data.drawData = { ...drawData };
this.data.drawData.shapeType = state.shapeType;
} else {
return;
}
} else {
this.data.drawData = { ...drawData };
if (this.data.drawData.initialState) {
this.data.drawData.shapeType = this.data.drawData.initialState.shapeType;
}
}
this.notify(UpdateReasons.DRAW);
}
@ -548,6 +571,13 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel {
this.notify(UpdateReasons.CONFIG_UPDATED);
}
public isAbleToChangeFrame(): boolean {
const isUnable = [Mode.DRAG, Mode.EDIT, Mode.RESIZE].includes(this.data.mode)
|| (this.data.mode === Mode.DRAW && typeof (this.data.drawData.redraw) === 'number');
return !isUnable;
}
public cancel(): void {
this.notify(UpdateReasons.CANCEL);
}

@ -21,8 +21,11 @@ import consts from './consts';
import {
translateToSVG,
translateFromSVG,
pointsToArray,
pointsToNumberArray,
parsePoints,
displayShapeSize,
scalarProduct,
vectorLength,
ShapeSizeElement,
DrawnState,
} from './shared';
@ -71,6 +74,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
private autoborderHandler: AutoborderHandler;
private activeElement: ActiveElement;
private configuration: Configuration;
private serviceFlags: {
drawHidden: Record<number, boolean>;
};
private set mode(value: Mode) {
this.controller.mode = value;
@ -80,8 +86,75 @@ export class CanvasViewImpl implements CanvasView, Listener {
return this.controller.mode;
}
private isServiceHidden(clientID: number): boolean {
return this.serviceFlags.drawHidden[clientID] || false;
}
private setupServiceHidden(clientID: number, value: boolean): void {
this.serviceFlags.drawHidden[clientID] = value;
const shape = this.svgShapes[clientID];
const text = this.svgTexts[clientID];
const state = this.drawnStates[clientID];
if (value) {
if (shape) {
(state.shapeType === 'points' ? shape.remember('_selectHandler').nested : shape)
.style('display', 'none');
}
if (text) {
text.addClass('cvat_canvas_hidden');
}
} else {
delete this.serviceFlags.drawHidden[clientID];
if (state) {
if (!state.outside && !state.hidden) {
if (shape) {
(state.shapeType === 'points' ? shape.remember('_selectHandler').nested : shape)
.style('display', '');
}
if (text) {
text.removeClass('cvat_canvas_hidden');
this.updateTextPosition(
text,
shape,
);
}
}
}
}
}
private onDrawDone(data: object | null, duration: number, continueDraw?: boolean): void {
const hiddenBecauseOfDraw = Object.keys(this.serviceFlags.drawHidden)
.map((_clientID): number => +_clientID);
if (hiddenBecauseOfDraw.length) {
for (const hidden of hiddenBecauseOfDraw) {
this.setupServiceHidden(hidden, false);
}
}
if (data) {
const { clientID, points } = data as any;
if (typeof (clientID) === 'number') {
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;
}
const { zLayer } = this.controller;
const event: CustomEvent = new CustomEvent('canvas.drawn', {
bubbles: false,
@ -323,6 +396,15 @@ export class CanvasViewImpl implements CanvasView, Listener {
);
}
for (const element of
window.document.getElementsByClassName('cvat_canvas_poly_direction')) {
const angle = (element as any).instance.data('angle');
(element as any).instance.style({
transform: `scale(${1 / this.geometry.scale}) rotate(${angle}deg)`,
});
}
for (const element of
window.document.getElementsByClassName('cvat_canvas_selected_point')) {
const previousWidth = element.getAttribute('stroke-width') as string;
@ -425,13 +507,88 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
}
private hideDirection(shape: SVG.Polygon | SVG.PolyLine): void {
/* eslint class-methods-use-this: 0 */
const handler = shape.remember('_selectHandler');
if (!handler || !handler.nested) return;
const nested = handler.nested as SVG.Parent;
if (nested.children().length) {
nested.children()[0].removeClass('cvat_canvas_first_poly_point');
}
const node = nested.node as SVG.LinkedHTMLElement;
const directions = node.getElementsByClassName('cvat_canvas_poly_direction');
for (const direction of directions) {
const { instance } = (direction as any);
instance.off('click');
instance.remove();
}
}
private showDirection(state: any, shape: SVG.Polygon | SVG.PolyLine): void {
const path = consts.ARROW_PATH;
const points = parsePoints(state.points);
const handler = shape.remember('_selectHandler');
if (!handler || !handler.nested) return;
const firstCircle = handler.nested.children()[0];
const secondCircle = handler.nested.children()[1];
firstCircle.addClass('cvat_canvas_first_poly_point');
const [cx, cy] = [
(secondCircle.cx() + firstCircle.cx()) / 2,
(secondCircle.cy() + firstCircle.cy()) / 2,
];
const [firstPoint, secondPoint] = points.slice(0, 2);
const xAxis = { i: 1, j: 0 };
const baseVector = { i: secondPoint.x - firstPoint.x, j: secondPoint.y - firstPoint.y };
const baseVectorLength = vectorLength(baseVector);
let cosinus = 0;
if (baseVectorLength !== 0) {
// two points have the same coordinates
cosinus = scalarProduct(xAxis, baseVector)
/ (vectorLength(xAxis) * baseVectorLength);
}
const angle = Math.acos(cosinus) * (Math.sign(baseVector.j) || 1) * 180 / Math.PI;
const pathElement = handler.nested.path(path).fill('white')
.stroke({
width: 1,
color: 'black',
}).addClass('cvat_canvas_poly_direction').style({
'transform-origin': `${cx}px ${cy}px`,
transform: `scale(${1 / this.geometry.scale}) rotate(${angle}deg)`,
}).move(cx, cy);
pathElement.on('click', (e: MouseEvent): void => {
if (e.button === 0) {
e.stopPropagation();
if (state.shapeType === 'polygon') {
const reversedPoints = [points[0], ...points.slice(1).reverse()];
this.onEditDone(state, pointsToNumberArray(reversedPoints));
} else {
const reversedPoints = points.reverse();
this.onEditDone(state, pointsToNumberArray(reversedPoints));
}
}
});
pathElement.data('angle', angle);
pathElement.dmove(-pathElement.width() / 2, -pathElement.height() / 2);
}
private selectize(value: boolean, shape: SVG.Element): void {
const self = this;
const { offset } = this.controller.geometry;
const translate = (points: number[]): number[] => points
.map((coord: number): number => coord - offset);
function dblClickHandler(e: MouseEvent): void {
function mousedownHandler(e: MouseEvent): void {
if (e.button !== 0) return;
e.preventDefault();
const pointID = Array.prototype.indexOf
.call(((e.target as HTMLElement).parentElement as HTMLElement).children, e.target);
@ -440,45 +597,52 @@ export class CanvasViewImpl implements CanvasView, Listener {
.filter((_state: any): boolean => (
_state.clientID === self.activeElement.clientID
));
if (state.shapeType === 'rectangle') {
e.preventDefault();
return;
if (['polygon', 'polyline', 'points'].includes(state.shapeType)) {
if (e.ctrlKey) {
const { points } = state;
self.onEditDone(
state,
points.slice(0, pointID * 2).concat(points.slice(pointID * 2 + 2)),
);
} else if (e.shiftKey) {
self.canvas.dispatchEvent(new CustomEvent('canvas.editstart', {
bubbles: false,
cancelable: true,
}));
self.mode = Mode.EDIT;
self.deactivate();
self.editHandler.edit({
enabled: true,
state,
pointID,
});
}
}
}
}
function dblClickHandler(e: MouseEvent): void {
e.preventDefault();
if (self.activeElement.clientID !== null) {
const [state] = self.controller.objects
.filter((_state: any): boolean => (
_state.clientID === self.activeElement.clientID
));
if (state.shapeType === 'cuboid') {
if (e.shiftKey) {
const points = translate(pointsToArray((e.target as any)
const points = translate(pointsToNumberArray((e.target as any)
.parentElement.parentElement.instance.attr('points')));
self.onEditDone(
state,
points,
);
e.preventDefault();
return;
}
}
if (e.ctrlKey) {
const { points } = state;
self.onEditDone(
state,
points.slice(0, pointID * 2).concat(points.slice(pointID * 2 + 2)),
);
} else if (e.shiftKey) {
self.canvas.dispatchEvent(new CustomEvent('canvas.editstart', {
bubbles: false,
cancelable: true,
}));
self.mode = Mode.EDIT;
self.deactivate();
self.editHandler.edit({
enabled: true,
state,
pointID,
});
}
}
e.preventDefault();
}
function contextMenuHandler(e: MouseEvent): void {
@ -524,6 +688,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
});
circle.on('dblclick', dblClickHandler);
circle.on('mousedown', mousedownHandler);
circle.on('contextmenu', contextMenuHandler);
circle.addClass('cvat_canvas_selected_point');
});
@ -534,6 +699,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
});
circle.off('dblclick', dblClickHandler);
circle.off('mousedown', mousedownHandler);
circle.off('contextmenu', contextMenuHandler);
circle.removeClass('cvat_canvas_selected_point');
});
@ -565,6 +731,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
};
this.configuration = model.configuration;
this.mode = Mode.IDLE;
this.serviceFlags = {
drawHidden: {},
};
// Create HTML elements
this.loadingAnimation = window.document
@ -864,6 +1033,9 @@ export class CanvasViewImpl implements CanvasView, Listener {
if (data.enabled && this.mode === Mode.IDLE) {
this.canvas.style.cursor = 'crosshair';
this.mode = Mode.DRAW;
if (typeof (data.redraw) === 'number') {
this.setupServiceHidden(data.redraw, true);
}
this.drawHandler.draw(data, this.geometry);
} else {
this.canvas.style.cursor = '';
@ -1045,7 +1217,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
const drawnState = this.drawnStates[clientID];
const shape = this.svgShapes[state.clientID];
const text = this.svgTexts[state.clientID];
const isInvisible = state.hidden || state.outside;
const isInvisible = state.hidden || state.outside
|| this.isServiceHidden(state.clientID);
if (drawnState.hidden !== state.hidden || drawnState.outside !== state.outside) {
if (isInvisible) {
@ -1147,59 +1320,57 @@ export class CanvasViewImpl implements CanvasView, Listener {
const { displayAllText } = this.configuration;
for (const state of states) {
if (state.objectType === 'tag') {
this.addTag(state);
const points: number[] = (state.points as number[]);
const translatedPoints: number[] = translate(points);
// TODO: Use enums after typification cvat-core
if (state.shapeType === 'rectangle') {
this.svgShapes[state.clientID] = this
.addRect(translatedPoints, state);
} else {
const points: number[] = (state.points as number[]);
const translatedPoints: number[] = translate(points);
const stringified = translatedPoints.reduce(
(acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc}${val} `;
}
// TODO: Use enums after typification cvat-core
if (state.shapeType === 'rectangle') {
return `${acc}${val},`;
}, '',
);
if (state.shapeType === 'polygon') {
this.svgShapes[state.clientID] = this
.addPolygon(stringified, state);
} else if (state.shapeType === 'polyline') {
this.svgShapes[state.clientID] = this
.addRect(translatedPoints, state);
.addPolyline(stringified, state);
} else if (state.shapeType === 'points') {
this.svgShapes[state.clientID] = this
.addPoints(stringified, state);
} else if (state.shapeType === 'cuboid') {
this.svgShapes[state.clientID] = this
.addCuboid(stringified, state);
} else {
const stringified = translatedPoints.reduce(
(acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc}${val} `;
}
return `${acc}${val},`;
}, '',
);
if (state.shapeType === 'polygon') {
this.svgShapes[state.clientID] = this
.addPolygon(stringified, state);
} else if (state.shapeType === 'polyline') {
this.svgShapes[state.clientID] = this
.addPolyline(stringified, state);
} else if (state.shapeType === 'points') {
this.svgShapes[state.clientID] = this
.addPoints(stringified, state);
} else if (state.shapeType === 'cuboid') {
this.svgShapes[state.clientID] = this
.addCuboid(stringified, state);
}
continue;
}
}
this.svgShapes[state.clientID].on('click.canvas', (): void => {
this.canvas.dispatchEvent(new CustomEvent('canvas.clicked', {
bubbles: false,
cancelable: true,
detail: {
state,
},
}));
});
this.svgShapes[state.clientID].on('click.canvas', (): void => {
this.canvas.dispatchEvent(new CustomEvent('canvas.clicked', {
bubbles: false,
cancelable: true,
detail: {
state,
},
}));
});
if (displayAllText) {
this.svgTexts[state.clientID] = this.addText(state);
this.updateTextPosition(
this.svgTexts[state.clientID],
this.svgShapes[state.clientID],
);
}
if (displayAllText) {
this.svgTexts[state.clientID] = this.addText(state);
this.updateTextPosition(
this.svgTexts[state.clientID],
this.svgShapes[state.clientID],
);
}
this.saveState(state);
@ -1327,16 +1498,6 @@ export class CanvasViewImpl implements CanvasView, Listener {
const shape = this.svgShapes[clientID];
let text = this.svgTexts[clientID];
if (!text) {
text = this.addText(state);
this.svgTexts[state.clientID] = text;
this.updateTextPosition(
text,
shape,
);
}
if (state.lock) {
return;
}
@ -1354,29 +1515,39 @@ export class CanvasViewImpl implements CanvasView, Listener {
(shape as any).attr('projections', true);
}
let text = this.svgTexts[clientID];
if (!text) {
text = this.addText(state);
this.svgTexts[state.clientID] = text;
}
const hideText = (): void => {
if (text) {
text.addClass('cvat_canvas_hidden');
}
};
const showText = (): void => {
if (text) {
text.removeClass('cvat_canvas_hidden');
this.updateTextPosition(text, shape);
}
};
if (!state.pinned) {
shape.addClass('cvat_canvas_shape_draggable');
(shape as any).draggable().on('dragstart', (): void => {
this.mode = Mode.DRAG;
if (text) {
text.addClass('cvat_canvas_hidden');
}
hideText();
}).on('dragend', (e: CustomEvent): void => {
if (text) {
text.removeClass('cvat_canvas_hidden');
this.updateTextPosition(
text,
shape,
);
}
showText();
this.mode = Mode.IDLE;
const p1 = e.detail.handler.startPoints.point;
const p2 = e.detail.p;
const delta = 1;
const { offset } = this.controller.geometry;
if (Math.sqrt(((p1.x - p2.x) ** 2) + ((p1.y - p2.y) ** 2)) >= delta) {
const points = pointsToArray(
const points = pointsToNumberArray(
shape.attr('points') || `${shape.attr('x')},${shape.attr('y')} `
+ `${shape.attr('x') + shape.attr('width')},`
+ `${shape.attr('y') + shape.attr('height')}`,
@ -1399,19 +1570,32 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.selectize(true, shape);
}
const showDirection = (): void => {
if (['polygon', 'polyline'].includes(state.shapeType)) {
this.showDirection(state, shape as SVG.Polygon | SVG.PolyLine);
}
};
const hideDirection = (): void => {
if (['polygon', 'polyline'].includes(state.shapeType)) {
this.hideDirection(shape as SVG.Polygon | SVG.PolyLine);
}
};
showDirection();
let shapeSizeElement: ShapeSizeElement | null = null;
let resized = false;
(shape as any).resize({
snapToGrid: 0.1,
}).on('resizestart', (): void => {
this.mode = Mode.RESIZE;
resized = false;
hideDirection();
hideText();
if (state.shapeType === 'rectangle') {
shapeSizeElement = displayShapeSize(this.adoptedContent, this.adoptedText);
}
resized = false;
if (text) {
text.addClass('cvat_canvas_hidden');
}
}).on('resizing', (): void => {
resized = true;
if (shapeSizeElement) {
@ -1422,20 +1606,15 @@ export class CanvasViewImpl implements CanvasView, Listener {
shapeSizeElement.rm();
}
if (text) {
text.removeClass('cvat_canvas_hidden');
this.updateTextPosition(
text,
shape,
);
}
showDirection();
showText();
this.mode = Mode.IDLE;
if (resized) {
const { offset } = this.controller.geometry;
const points = pointsToArray(
const points = pointsToNumberArray(
shape.attr('points') || `${shape.attr('x')},${shape.attr('y')} `
+ `${shape.attr('x') + shape.attr('width')},`
+ `${shape.attr('y') + shape.attr('height')}`,
@ -1453,6 +1632,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
}
});
this.updateTextPosition(text, shape);
this.canvas.dispatchEvent(new CustomEvent('canvas.activated', {
bubbles: false,
cancelable: true,
@ -1570,8 +1750,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
rect.addClass('cvat_canvas_shape_occluded');
}
if (state.hidden || state.outside) {
rect.style('display', 'none');
if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) {
rect.addClass('cvat_canvas_hidden');
}
return rect;
@ -1593,8 +1773,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
polygon.addClass('cvat_canvas_shape_occluded');
}
if (state.hidden || state.outside) {
polygon.style('display', 'none');
if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) {
polygon.addClass('cvat_canvas_hidden');
}
return polygon;
@ -1616,8 +1796,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
polyline.addClass('cvat_canvas_shape_occluded');
}
if (state.hidden || state.outside) {
polyline.style('display', 'none');
if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) {
polyline.addClass('cvat_canvas_hidden');
}
return polyline;
@ -1640,8 +1820,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
cube.addClass('cvat_canvas_shape_occluded');
}
if (state.hidden || state.outside) {
cube.style('display', 'none');
if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) {
cube.addClass('cvat_canvas_hidden');
}
return cube;
@ -1684,8 +1864,8 @@ export class CanvasViewImpl implements CanvasView, Listener {
const group = this.setupPoints(shape, state);
if (state.hidden || state.outside) {
group.style('display', 'none');
if (state.hidden || state.outside || this.isServiceHidden(state.clientID)) {
group.addClass('cvat_canvas_hidden');
}
shape.remove = (): SVG.PolyLine => {
@ -1696,9 +1876,4 @@ export class CanvasViewImpl implements CanvasView, Listener {
return shape;
}
/* eslint-disable-next-line */
private addTag(state: any): void {
console.log(state);
}
}

@ -14,6 +14,8 @@ const MIN_EDGE_LENGTH = 3;
const CUBOID_ACTIVE_EDGE_STROKE_WIDTH = 2.5;
const CUBOID_UNACTIVE_EDGE_STROKE_WIDTH = 1.75;
const UNDEFINED_ATTRIBUTE_VALUE = '__undefined__';
const ARROW_PATH = 'M13.162 6.284L.682.524a.483.483 0 0 0-.574.134.477.477 0 '
+ '0 0-.012.59L4.2 6.72.096 12.192a.479.479 0 0 0 .585.724l12.48-5.76a.48.48 0 0 0 0-.872z';
export default {
BASE_STROKE_WIDTH,
@ -28,4 +30,5 @@ export default {
CUBOID_ACTIVE_EDGE_STROKE_WIDTH,
CUBOID_UNACTIVE_EDGE_STROKE_WIDTH,
UNDEFINED_ATTRIBUTE_VALUE,
ARROW_PATH,
};

@ -11,8 +11,8 @@ import {
translateToSVG,
displayShapeSize,
ShapeSizeElement,
pointsToString,
pointsToArray,
stringifyPoints,
pointsToNumberArray,
BBox,
Box,
} from './shared';
@ -264,12 +264,13 @@ export class DrawHandlerImpl implements DrawHandler {
this.drawInstance.on('drawstop', (e: Event): void => {
const bbox = (e.target as SVGRectElement).getBBox();
const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(bbox);
const { shapeType } = this.drawData;
const { shapeType, redraw: clientID } = this.drawData;
this.release();
if (this.canceled) return;
if ((xbr - xtl) * (ybr - ytl) >= consts.AREA_THRESHOLD) {
this.onDrawDone({
clientID,
shapeType,
points: [xtl, ytl, xbr, ybr],
}, Date.now() - this.startTimestamp);
@ -298,12 +299,13 @@ export class DrawHandlerImpl implements DrawHandler {
if (numberOfPoints === 4) {
const bbox = (e.target as SVGPolylineElement).getBBox();
const [xtl, ytl, xbr, ybr] = this.getFinalRectCoordinates(bbox);
const { shapeType } = this.drawData;
const { shapeType, redraw: clientID } = this.drawData;
this.cancel();
if ((xbr - xtl) * (ybr - ytl) >= consts.AREA_THRESHOLD) {
this.onDrawDone({
shapeType,
clientID,
points: [xtl, ytl, xbr, ybr],
}, Date.now() - this.startTimestamp);
}
@ -356,6 +358,7 @@ export class DrawHandlerImpl implements DrawHandler {
if (lastDrawnPoint.x === null || lastDrawnPoint.y === null) {
this.drawInstance.draw('point', e);
} else {
this.drawInstance.draw('update', e);
const deltaTreshold = 15;
const delta = Math.sqrt(
((e.clientX - lastDrawnPoint.x) ** 2)
@ -379,8 +382,8 @@ export class DrawHandlerImpl implements DrawHandler {
});
this.drawInstance.on('drawdone', (e: CustomEvent): void => {
const targetPoints = pointsToArray((e.target as SVGElement).getAttribute('points'));
const { shapeType } = this.drawData;
const targetPoints = pointsToNumberArray((e.target as SVGElement).getAttribute('points'));
const { shapeType, redraw: clientID } = this.drawData;
const { points, box } = shapeType === 'cuboid' ? this.getFinalCuboidCoordinates(targetPoints)
: this.getFinalPolyshapeCoordinates(targetPoints);
this.release();
@ -390,6 +393,7 @@ export class DrawHandlerImpl implements DrawHandler {
&& ((box.xbr - box.xtl) * (box.ybr - box.ytl) >= consts.AREA_THRESHOLD)
&& points.length >= 3 * 2) {
this.onDrawDone({
clientID,
shapeType,
points,
}, Date.now() - this.startTimestamp);
@ -398,12 +402,14 @@ export class DrawHandlerImpl implements DrawHandler {
|| (box.ybr - box.ytl) >= consts.SIZE_THRESHOLD)
&& points.length >= 2 * 2) {
this.onDrawDone({
clientID,
shapeType,
points,
}, Date.now() - this.startTimestamp);
} else if (shapeType === 'points'
&& (e.target as any).getAttribute('points') !== '0,0') {
this.onDrawDone({
clientID,
shapeType,
points,
}, Date.now() - this.startTimestamp);
@ -411,6 +417,7 @@ export class DrawHandlerImpl implements DrawHandler {
} else if (shapeType === 'cuboid'
&& points.length === 4 * 2) {
this.onDrawDone({
clientID,
shapeType,
points: cuboidFrom4Points(points),
}, Date.now() - this.startTimestamp);
@ -673,7 +680,7 @@ export class DrawHandlerImpl implements DrawHandler {
} else {
const points = this.drawData.initialState.points
.map((coord: number): number => coord + offset);
const stringifiedPoints = pointsToString(points);
const stringifiedPoints = stringifyPoints(points);
if (this.drawData.shapeType === 'polygon') {
this.pastePolygon(stringifiedPoints);

@ -6,7 +6,7 @@ import * as SVG from 'svg.js';
import 'svg.select.js';
import consts from './consts';
import { translateFromSVG, pointsToArray } from './shared';
import { translateFromSVG, pointsToNumberArray } from './shared';
import { EditData, Geometry, Configuration } from './canvasModel';
import { AutoborderHandler } from './autoborderHandler';
@ -28,6 +28,38 @@ export class EditHandlerImpl implements EditHandler {
private clones: SVG.Polygon[];
private autobordersEnabled: boolean;
private setupTrailingPoint(circle: SVG.Circle): void {
const head = this.editedShape.attr('points').split(' ').slice(0, this.editData.pointID).join(' ');
circle.on('mouseenter', (): void => {
circle.attr({
'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale,
});
});
circle.on('mouseleave', (): void => {
circle.attr({
'stroke-width': consts.POINTS_STROKE_WIDTH / this.geometry.scale,
});
});
const minimumPoints = 2;
circle.on('mousedown', (e: MouseEvent): void => {
if (e.button !== 0) return;
const { offset } = this.geometry;
const stringifiedPoints = `${head} ${this.editLine.node.getAttribute('points').slice(0, -2)}`;
const points = pointsToNumberArray(stringifiedPoints).slice(0, -2)
.map((coord: number): number => coord - offset);
if (points.length >= minimumPoints * 2) {
const { state } = this.editData;
this.edit({
enabled: false,
});
this.onEditDone(state, points);
}
});
}
private startEdit(): void {
// get started coordinates
const [clientX, clientY] = translateFromSVG(
@ -72,6 +104,14 @@ export class EditHandlerImpl implements EditHandler {
});
this.editLine = (this.canvas as any).polyline();
if (this.editData.state.shapeType === 'polyline') {
(this.editLine as any).on('drawpoint', (e: CustomEvent): void => {
const circle = (e.target as any).instance.remember('_paintHandler').set.last();
if (circle) this.setupTrailingPoint(circle);
});
}
(this.editLine as any).addClass('cvat_canvas_shape_drawing').style({
'pointer-events': 'none',
'fill-opacity': 0,
@ -110,7 +150,7 @@ export class EditHandlerImpl implements EditHandler {
private selectPolygon(shape: SVG.Polygon): void {
const { offset } = this.geometry;
const points = pointsToArray(shape.attr('points'))
const points = pointsToNumberArray(shape.attr('points'))
.map((coord: number): number => coord - offset);
const { state } = this.editData;
@ -149,9 +189,8 @@ export class EditHandlerImpl implements EditHandler {
.concat(linePoints)
.concat(oldPoints.slice(stop + 1));
linePoints.reverse();
const secondPart = oldPoints.slice(start + 1, stop)
.concat(linePoints);
const secondPart = oldPoints.slice(start, stop)
.concat(linePoints.slice(1).reverse());
if (firstPart.length < 3 || secondPart.length < 3) {
this.cancel();
@ -198,7 +237,7 @@ export class EditHandlerImpl implements EditHandler {
points = oldPoints.concat(linePoints.slice(0, -1));
}
points = pointsToArray(points.join(' '))
points = pointsToNumberArray(points.join(' '))
.map((coord: number): number => coord - offset);
const { state } = this.editData;

@ -29,6 +29,12 @@ interface Point {
x: number;
y: number;
}
interface Vector2D {
i: number;
j: number;
}
export interface DrawnState {
clientID: number;
outside?: boolean;
@ -76,21 +82,6 @@ export function translateToSVG(svg: SVGSVGElement, points: number[]): number[] {
return output;
}
export function pointsToString(points: number[]): string {
return points.reduce((acc, val, idx): string => {
if (idx % 2) {
return `${acc},${val}`;
}
return `${acc} ${val}`.trim();
}, '');
}
export function pointsToArray(points: string): number[] {
return points.trim().split(/[,\s]+/g)
.map((coord: string): number => +coord);
}
export function displayShapeSize(
shapesContainer: SVG.Container,
textContainer: SVG.Container,
@ -120,25 +111,59 @@ export function displayShapeSize(
return shapeSize;
}
export function convertToArray(points: Point[]): number[][] {
const arr: number[][] = [];
points.forEach((point: Point): void => {
arr.push([point.x, point.y]);
});
return arr;
export function pointsToNumberArray(points: string | Point[]): number[] {
if (Array.isArray(points)) {
return points.reduce((acc: number[], point: Point): number[] => {
acc.push(point.x, point.y);
return acc;
}, []);
}
return points.trim().split(/[,\s]+/g)
.map((coord: string): number => +coord);
}
export function parsePoints(stringified: string): Point[] {
return stringified.trim().split(/\s/).map((point: string): Point => {
export function parsePoints(source: string | number[]): Point[] {
if (Array.isArray(source)) {
return source.reduce((acc: Point[], _: number, index: number): Point[] => {
if (index % 2) {
acc.push({
x: source[index - 1],
y: source[index],
});
}
return acc;
}, []);
}
return source.trim().split(/\s/).map((point: string): Point => {
const [x, y] = point.split(',').map((coord: string): number => +coord);
return { x, y };
});
}
export function stringifyPoints(points: Point[]): string {
export function stringifyPoints(points: (Point | number)[]): string {
if (typeof (points[0]) === 'number') {
return points.reduce((acc: string, val: number, idx: number): string => {
if (idx % 2) {
return `${acc},${val}`;
}
return `${acc} ${val}`.trim();
}, '');
}
return points.map((point: Point): string => `${point.x},${point.y}`).join(' ');
}
export function clamp(x: number, min: number, max: number): number {
return Math.min(Math.max(x, min), max);
}
export function scalarProduct(a: Vector2D, b: Vector2D): number {
return a.i * b.i + a.j * b.j;
}
export function vectorLength(vector: Vector2D): number {
return Math.sqrt((vector.i ** 2) + (vector.j ** 2));
}

@ -17,7 +17,7 @@ import {
Orientation,
Edge,
} from './cuboid';
import { parsePoints, stringifyPoints, clamp } from './shared';
import { parsePoints, clamp } from './shared';
// Update constructor
const originalDraw = SVG.Element.prototype.draw;
@ -174,7 +174,8 @@ SVG.Element.prototype.resize = function constructor(...args: any): any {
originalResize.call(this, ...args);
handler = this.remember('_resizeHandler');
handler.resize = function(e: any) {
if (e.detail.event.button === 0) {
const { event } = e.detail;
if (event.button === 0 && !event.shiftKey && !event.ctrlKey) {
return handler.constructor.prototype.resize.call(this, e);
}
}

Binary file not shown.

@ -1,6 +1,6 @@
{
"name": "cvat-core",
"version": "2.1.1",
"version": "3.0.0",
"description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "babel.config.js",
"scripts": {

@ -333,7 +333,9 @@
const { width, height } = this.frameMeta[frame];
fittedPoints = fitPoints(this.shapeType, data.points, width, height);
if ((!checkShapeArea(this.shapeType, fittedPoints)) || checkOutside(fittedPoints, width, height)) {
if ((!checkShapeArea(this.shapeType, fittedPoints))
|| checkOutside(fittedPoints, width, height)
) {
fittedPoints = [];
}
}
@ -1534,13 +1536,12 @@
}
interpolatePosition(leftPosition, rightPosition, offset) {
const positionOffset = leftPosition.points.map((point, index) => (
rightPosition.points[index] - point
))
));
return {
points: leftPosition.points.map((point ,index) => (
points: leftPosition.points.map((point, index) => (
point + positionOffset[index] * offset
)),
occluded: leftPosition.occluded,
@ -1556,385 +1557,274 @@
}
interpolatePosition(leftPosition, rightPosition, offset) {
function findBox(points) {
let xmin = Number.MAX_SAFE_INTEGER;
let ymin = Number.MAX_SAFE_INTEGER;
let xmax = Number.MIN_SAFE_INTEGER;
let ymax = Number.MIN_SAFE_INTEGER;
for (let i = 0; i < points.length; i += 2) {
if (points[i] < xmin) xmin = points[i];
if (points[i + 1] < ymin) ymin = points[i + 1];
if (points[i] > xmax) xmax = points[i];
if (points[i + 1] > ymax) ymax = points[i + 1];
}
if (offset === 0) {
return {
xmin,
ymin,
xmax,
ymax,
points: [...leftPosition.points],
occluded: leftPosition.occluded,
outside: leftPosition.outside,
zOrder: leftPosition.zOrder,
};
}
function normalize(points, box) {
const normalized = [];
const width = box.xmax - box.xmin;
const height = box.ymax - box.ymin;
function toArray(points) {
return points.reduce((acc, val) => {
acc.push(val.x, val.y);
return acc;
}, []);
}
for (let i = 0; i < points.length; i += 2) {
normalized.push(
(points[i] - box.xmin) / width,
(points[i + 1] - box.ymin) / height,
);
}
function toPoints(array) {
return array.reduce((acc, _, index) => {
if (index % 2) {
acc.push({
x: array[index - 1],
y: array[index],
});
}
return acc;
}, []);
}
return normalized;
function curveLength(points) {
return points.slice(1).reduce((acc, _, index) => {
const dx = points[index + 1].x - points[index].x;
const dy = points[index + 1].y - points[index].y;
return acc + Math.sqrt(dx ** 2 + dy ** 2);
}, 0);
}
function denormalize(points, box) {
const denormalized = [];
const width = box.xmax - box.xmin;
const height = box.ymax - box.ymin;
function curveToOffsetVec(points, length) {
const offsetVector = [0]; // with initial value
let accumulatedLength = 0;
for (let i = 0; i < points.length; i += 2) {
denormalized.push(
points[i] * width + box.xmin,
points[i + 1] * height + box.ymin,
);
}
points.slice(1).forEach((_, index) => {
const dx = points[index + 1].x - points[index].x;
const dy = points[index + 1].y - points[index].y;
accumulatedLength += Math.sqrt(dx ** 2 + dy ** 2);
offsetVector.push(accumulatedLength / length);
});
return denormalized;
return offsetVector;
}
function toPoints(array) {
const points = [];
for (let i = 0; i < array.length; i += 2) {
points.push({
x: array[i],
y: array[i + 1],
});
function findNearestPair(value, curve) {
let minimum = [0, Math.abs(value - curve[0])];
for (let i = 1; i < curve.length; i++) {
const distance = Math.abs(value - curve[i]);
if (distance < minimum[1]) {
minimum = [i, distance];
}
}
return points;
return minimum[0];
}
function toArray(points) {
const array = [];
for (const point of points) {
array.push(point.x, point.y);
function matchLeftRight(leftCurve, rightCurve) {
const matching = {};
for (let i = 0; i < leftCurve.length; i++) {
matching[i] = [findNearestPair(leftCurve[i], rightCurve)];
}
return array;
return matching;
}
function computeDistances(source, target) {
const distances = {};
for (let i = 0; i < source.length; i++) {
distances[i] = distances[i] || {};
for (let j = 0; j < target.length; j++) {
const dx = source[i].x - target[j].x;
const dy = source[i].y - target[j].y;
function matchRightLeft(leftCurve, rightCurve, leftRightMatching) {
const matchedRightPoints = Object.values(leftRightMatching);
const unmatchedRightPoints = rightCurve.map((_, index) => index)
.filter((index) => !matchedRightPoints.includes(index));
const updatedMatching = { ...leftRightMatching };
distances[i][j] = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
}
for (const rightPoint of unmatchedRightPoints) {
const leftPoint = findNearestPair(rightCurve[rightPoint], leftCurve);
updatedMatching[leftPoint].push(rightPoint);
}
return distances;
for (const key of Object.keys(updatedMatching)) {
const sortedRightIndexes = updatedMatching[key]
.sort((a, b) => a - b);
updatedMatching[key] = sortedRightIndexes;
}
return updatedMatching;
}
function truncateByThreshold(mapping, threshold) {
for (const key of Object.keys(mapping)) {
if (mapping[key].distance > threshold) {
delete mapping[key];
function reduceInterpolation(interpolatedPoints, matching, leftPoints, rightPoints) {
function averagePoint(points) {
let sumX = 0;
let sumY = 0;
for (const point of points) {
sumX += point.x;
sumY += point.y;
}
}
}
// https://en.wikipedia.org/wiki/Stable_marriage_problem
// TODO: One of important part of the algorithm is to correctly match
// "corner" points. Thus it is possible for each of such point calculate
// a descriptor (d) and use (x, y, d) to calculate the distance. One more
// idea is to be sure that order or matched points is preserved. For example,
// if p1 matches q1 and p2 matches q2 and between p1 and p2 we don't have any
// points thus we should not have points between q1 and q2 as well.
function stableMarriageProblem(men, women, distances) {
const menPreferences = {};
for (const man of men) {
menPreferences[man] = women.concat()
.sort((w1, w2) => distances[man][w1] - distances[man][w2]);
return {
x: sumX / points.length,
y: sumY / points.length,
};
}
// Start alghoritm with max N^2 complexity
const womenMaybe = {}; // id woman:id man,distance
const menBusy = {}; // id man:boolean
let prefIndex = 0;
// While there is at least one free man
while (Object.values(menBusy).length !== men.length) {
// Every man makes offer to the best woman
for (const man of men) {
// The man have already found a woman
if (menBusy[man]) {
continue;
}
function computeDistance(point1, point2) {
return Math.sqrt(
((point1.x - point2.x) ** 2) + ((point1.y - point2.y) ** 2),
);
}
const woman = menPreferences[man][prefIndex];
const distance = distances[man][woman];
function minimizeSegment(baseLength, N, startInterpolated, stopInterpolated) {
const threshold = baseLength / (2 * N);
const minimized = [interpolatedPoints[startInterpolated]];
let latestPushed = startInterpolated;
for (let i = startInterpolated + 1; i < stopInterpolated; i++) {
const distance = computeDistance(
interpolatedPoints[latestPushed], interpolatedPoints[i],
);
// A women chooses the best offer and says "maybe"
if (woman in womenMaybe && womenMaybe[woman].distance > distance) {
// A woman got better offer
const prevChoice = womenMaybe[woman].value;
delete womenMaybe[woman];
delete menBusy[prevChoice];
if (distance >= threshold) {
minimized.push(interpolatedPoints[i]);
latestPushed = i;
}
}
if (!(woman in womenMaybe)) {
womenMaybe[woman] = {
value: man,
distance,
};
minimized.push(interpolatedPoints[stopInterpolated]);
if (minimized.length === 2) {
const distance = computeDistance(
interpolatedPoints[startInterpolated],
interpolatedPoints[stopInterpolated],
);
menBusy[man] = true;
if (distance < threshold) {
return [averagePoint(minimized)];
}
}
prefIndex++;
return minimized;
}
const result = {};
for (const woman of Object.keys(womenMaybe)) {
result[womenMaybe[woman].value] = {
value: woman,
distance: womenMaybe[woman].distance,
};
const reduced = [];
const interpolatedIndexes = {};
let accumulated = 0;
for (let i = 0; i < leftPoints.length; i++) {
// eslint-disable-next-line
interpolatedIndexes[i] = matching[i].map(() => accumulated++);
}
return result;
}
function leftSegment(start, stop) {
const startInterpolated = interpolatedIndexes[start][0];
const stopInterpolated = interpolatedIndexes[stop][0];
function getMapping(source, target) {
function sumEdges(points) {
let result = 0;
for (let i = 1; i < points.length; i += 2) {
const distance = Math.sqrt(Math.pow(points[i].x - points[i - 1].x, 2)
+ Math.pow(points[i].y - points[i - 1].y, 2));
result += distance;
if (startInterpolated === stopInterpolated) {
reduced.push(interpolatedPoints[startInterpolated]);
return;
}
// Corner case when work with one point
// Mapping in this case can't be wrong
if (!result) {
return Number.MAX_SAFE_INTEGER;
}
const baseLength = curveLength(leftPoints.slice(start, stop + 1));
const N = stop - start + 1;
return result;
reduced.push(
...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated),
);
}
function computeDeviation(points, average) {
let result = 0;
for (let i = 1; i < points.length; i += 2) {
const distance = Math.sqrt(Math.pow(points[i].x - points[i - 1].x, 2)
+ Math.pow(points[i].y - points[i - 1].y, 2));
result += Math.pow(distance - average, 2);
}
return result;
}
function rightSegment(leftPoint) {
const start = matching[leftPoint][0];
const [stop] = matching[leftPoint].slice(-1);
const startInterpolated = interpolatedIndexes[leftPoint][0];
const [stopInterpolated] = interpolatedIndexes[leftPoint].slice(-1);
const baseLength = curveLength(rightPoints.slice(start, stop + 1));
const N = stop - start + 1;
const processedSource = [];
const processedTarget = [];
const distances = computeDistances(source, target);
const mapping = stableMarriageProblem(Array.from(source.keys()),
Array.from(target.keys()), distances);
const average = (sumEdges(target)
+ sumEdges(source)) / (target.length + source.length);
const meanSquareDeviation = Math.sqrt((computeDeviation(source, average)
+ computeDeviation(target, average)) / (source.length + target.length));
const threshold = average + 3 * meanSquareDeviation; // 3 sigma rule
truncateByThreshold(mapping, threshold);
for (const key of Object.keys(mapping)) {
mapping[key] = mapping[key].value;
reduced.push(
...minimizeSegment(baseLength, N, startInterpolated, stopInterpolated),
);
}
// const receivingOrder = Object.keys(mapping).map(x => +x).sort((a,b) => a - b);
const receivingOrder = this.appendMapping(mapping, source, target);
let previousOpened = null;
for (let i = 0; i < leftPoints.length; i++) {
if (matching[i].length === 1) {
// check if left segment is opened
if (previousOpened !== null) {
// check if we should continue the left segment
if (matching[i][0] === matching[previousOpened][0]) {
continue;
} else {
// left segment found
const start = previousOpened;
const stop = i - 1;
leftSegment(start, stop);
// start next left segment
previousOpened = i;
}
} else {
// start next left segment
previousOpened = i;
}
} else {
// check if left segment is opened
if (previousOpened !== null) {
// left segment found
const start = previousOpened;
const stop = i - 1;
leftSegment(start, stop);
previousOpened = null;
}
for (const pointIdx of receivingOrder) {
processedSource.push(source[pointIdx]);
processedTarget.push(target[mapping[pointIdx]]);
// right segment found
rightSegment(i);
}
}
return [processedSource, processedTarget];
}
// check if there is an opened segment
if (previousOpened !== null) {
leftSegment(previousOpened, leftPoints.length - 1);
}
if (offset === 0) {
return {
points: [...leftPosition.points],
occluded: leftPosition.occluded,
outside: leftPosition.outside,
zOrder: leftPosition.zOrder,
};
return reduced;
}
let leftBox = findBox(leftPosition.points);
let rightBox = findBox(rightPosition.points);
// Sometimes (if shape has one point or shape is line),
// We can get box with zero area
// Next computation will be with NaN in this case
// We have to prevent it
const delta = 1;
if (leftBox.xmax - leftBox.xmin < delta || rightBox.ymax - rightBox.ymin < delta) {
leftBox = {
xmin: 0,
xmax: 1024, // TODO: Get actual image size
ymin: 0,
ymax: 768,
};
rightBox = leftBox;
}
// the algorithm below is based on fact that both left and right
// polyshapes have the same start point and the same draw direction
const leftPoints = toPoints(leftPosition.points);
const rightPoints = toPoints(rightPosition.points);
const leftOffsetVec = curveToOffsetVec(leftPoints, curveLength(leftPoints));
const rightOffsetVec = curveToOffsetVec(rightPoints, curveLength(rightPoints));
const leftPoints = toPoints(normalize(leftPosition.points, leftBox));
const rightPoints = toPoints(normalize(rightPosition.points, rightBox));
const matching = matchLeftRight(leftOffsetVec, rightOffsetVec);
const completedMatching = matchRightLeft(
leftOffsetVec, rightOffsetVec, matching,
);
let newLeftPoints = [];
let newRightPoints = [];
if (leftPoints.length > rightPoints.length) {
const [
processedRight,
processedLeft,
] = getMapping.call(this, rightPoints, leftPoints);
newLeftPoints = processedLeft;
newRightPoints = processedRight;
} else {
const [
processedLeft,
processedRight,
] = getMapping.call(this, leftPoints, rightPoints);
newLeftPoints = processedLeft;
newRightPoints = processedRight;
}
const interpolatedPoints = Object.keys(completedMatching)
.map((leftPointIdx) => +leftPointIdx).sort((a, b) => a - b)
.reduce((acc, leftPointIdx) => {
const leftPoint = leftPoints[leftPointIdx];
for (const rightPointIdx of completedMatching[leftPointIdx]) {
const rightPoint = rightPoints[rightPointIdx];
acc.push({
x: leftPoint.x + (rightPoint.x - leftPoint.x) * offset,
y: leftPoint.y + (rightPoint.y - leftPoint.y) * offset,
});
}
const absoluteLeftPoints = denormalize(toArray(newLeftPoints), leftBox);
const absoluteRightPoints = denormalize(toArray(newRightPoints), rightBox);
return acc;
}, []);
const interpolation = [];
for (let i = 0; i < absoluteLeftPoints.length; i++) {
interpolation.push(absoluteLeftPoints[i] + (
absoluteRightPoints[i] - absoluteLeftPoints[i]) * offset);
}
const reducedPoints = reduceInterpolation(
interpolatedPoints,
completedMatching,
leftPoints,
rightPoints,
);
return {
points: interpolation,
points: toArray(reducedPoints),
occluded: leftPosition.occluded,
outside: leftPosition.outside,
zOrder: leftPosition.zOrder,
};
}
// mapping is predicted order of points sourse_idx:target_idx
// some points from source and target can absent in mapping
// source, target - arrays of points. Target array size >= sourse array size
appendMapping(mapping, source, target) {
const targetMatched = Object.values(mapping).map((x) => +x);
const sourceMatched = Object.keys(mapping).map((x) => +x);
const orderForReceive = [];
function findNeighbors(point) {
let prev = point;
let next = point;
if (!targetMatched.length) {
// Prevent infinity loop
throw new ScriptingError('Interpolation mapping is empty');
}
while (!targetMatched.includes(prev)) {
prev--;
if (prev < 0) {
prev = target.length - 1;
}
}
while (!targetMatched.includes(next)) {
next++;
if (next >= target.length) {
next = 0;
}
}
return [prev, next];
}
function computeOffset(point, prev, next) {
const pathPoints = [];
while (prev !== next) {
pathPoints.push(target[prev]);
prev++;
if (prev >= target.length) {
prev = 0;
}
}
pathPoints.push(target[next]);
let curveLength = 0;
let offset = 0;
let iCrossed = false;
for (let k = 1; k < pathPoints.length; k++) {
const p1 = pathPoints[k];
const p2 = pathPoints[k - 1];
const distance = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
if (!iCrossed) {
offset += distance;
}
curveLength += distance;
if (target[point] === pathPoints[k]) {
iCrossed = true;
}
}
if (!curveLength) {
return 0;
}
return offset / curveLength;
}
for (let i = 0; i < target.length; i++) {
const index = targetMatched.indexOf(i);
if (index === -1) {
// We have to find a neighbours which have been mapped
const [prev, next] = findNeighbors(i);
// Now compute edge offset
const offset = computeOffset(i, prev, next);
// Get point between two neighbors points
const prevPoint = target[prev];
const nextPoint = target[next];
const autoPoint = {
x: prevPoint.x + (nextPoint.x - prevPoint.x) * offset,
y: prevPoint.y + (nextPoint.y - prevPoint.y) * offset,
};
// Put it into matched
source.push(autoPoint);
mapping[source.length - 1] = i;
orderForReceive.push(source.length - 1);
} else {
orderForReceive.push(sourceMatched[index]);
}
}
return orderForReceive;
}
}
class PolygonTrack extends PolyTrack {
@ -1945,6 +1835,26 @@
checkNumberOfPoints(this.shapeType, shape.points);
}
}
interpolatePosition(leftPosition, rightPosition, offset) {
const copyLeft = {
...leftPosition,
points: [...leftPosition.points, leftPosition.points[0], leftPosition.points[1]],
};
const copyRight = {
...rightPosition,
points: [...rightPosition.points, rightPosition.points[0], rightPosition.points[1]],
};
const result = PolyTrack.prototype.interpolatePosition
.call(this, copyLeft, copyRight, offset);
return {
...result,
points: result.points.slice(0, -2),
};
}
}
class PolylineTrack extends PolyTrack {
@ -1965,6 +1875,27 @@
checkNumberOfPoints(this.shapeType, shape.points);
}
}
interpolatePosition(leftPosition, rightPosition, offset) {
// interpolate only when one point in both left and right positions
if (leftPosition.points.length === 2 && rightPosition.points.length === 2) {
return {
points: leftPosition.points.map(
(value, index) => value + (rightPosition.points[index] - value) * offset,
),
occluded: leftPosition.occluded,
outside: leftPosition.outside,
zOrder: leftPosition.zOrder,
};
}
return {
points: [...leftPosition.points],
occluded: leftPosition.occluded,
outside: leftPosition.outside,
zOrder: leftPosition.zOrder,
};
}
}
class CuboidTrack extends Track {
@ -1978,13 +1909,12 @@
}
interpolatePosition(leftPosition, rightPosition, offset) {
const positionOffset = leftPosition.points.map((point, index) => (
rightPosition.points[index] - point
))
));
return {
points: leftPosition.points.map((point ,index) => (
points: leftPosition.points.map((point, index) => (
point + positionOffset[index] * offset
)),
occluded: leftPosition.occluded,

@ -1,6 +1,6 @@
{
"name": "cvat-ui",
"version": "1.3.2",
"version": "1.4.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

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

@ -1487,3 +1487,51 @@ export function repeatDrawShapeAsync(): ThunkAction<Promise<void>, {}, {}, AnyAc
}
};
}
export function redrawShapeAsync(): ThunkAction<Promise<void>, {}, {}, AnyAction> {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const {
annotations: {
activatedStateID,
states,
},
canvas: {
instance: canvasInstance,
},
} = getStore().getState().annotation;
if (activatedStateID !== null) {
const [state] = states
.filter((_state: any): boolean => _state.clientID === activatedStateID);
if (state && state.objectType !== ObjectType.TAG) {
let activeControl = ActiveControl.CURSOR;
if (state.shapeType === ShapeType.RECTANGLE) {
activeControl = ActiveControl.DRAW_RECTANGLE;
} else if (state.shapeType === ShapeType.POINTS) {
activeControl = ActiveControl.DRAW_POINTS;
} else if (state.shapeType === ShapeType.POLYGON) {
activeControl = ActiveControl.DRAW_POLYGON;
} else if (state.shapeType === ShapeType.POLYLINE) {
activeControl = ActiveControl.DRAW_POLYLINE;
} else if (state.shapeType === ShapeType.CUBOID) {
activeControl = ActiveControl.DRAW_CUBOID;
}
dispatch({
type: AnnotationActionTypes.REPEAT_DRAW_SHAPE,
payload: {
activeControl,
},
});
canvasInstance.cancel();
canvasInstance.draw({
enabled: true,
redraw: activatedStateID,
shapeType: state.shapeType,
crosshair: state.shapeType === ShapeType.RECTANGLE,
});
}
}
};
}

@ -2,40 +2,131 @@
//
// SPDX-License-Identifier: MIT
import React from 'react';
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import Button from 'antd/lib/button';
import Tooltip from 'antd/lib/tooltip';
import { connect } from 'react-redux';
interface Props {
activatedStateID: number | null;
import { CombinedState, ContextMenuType } from 'reducers/interfaces';
import { updateAnnotationsAsync, updateCanvasContextMenu } from 'actions/annotation-actions';
interface StateToProps {
activatedState: any | null;
selectedPoint: number | null;
visible: boolean;
left: number;
top: number;
onPointDelete(): void;
left: number;
type: ContextMenuType;
}
export default function CanvasPointContextMenu(props: Props): JSX.Element | null {
function mapStateToProps(state: CombinedState): StateToProps {
const {
onPointDelete,
activatedStateID,
annotation: {
annotations: {
states,
activatedStateID,
},
canvas: {
contextMenu: {
visible,
top,
left,
type,
pointID: selectedPoint,
},
},
},
} = state;
return {
activatedState: activatedStateID === null
? null : states.filter((_state) => _state.clientID === activatedStateID)[0] || null,
selectedPoint,
visible,
left,
top,
type,
};
}
interface DispatchToProps {
onUpdateAnnotations(states: any[]): void;
onCloseContextMenu(): void;
}
function mapDispatchToProps(dispatch: any): DispatchToProps {
return {
onUpdateAnnotations(states: any[]): void {
dispatch(updateAnnotationsAsync(states));
},
onCloseContextMenu(): void {
dispatch(updateCanvasContextMenu(false, 0, 0));
},
};
}
type Props = StateToProps & DispatchToProps;
function CanvasPointContextMenu(props: Props): React.ReactPortal | null {
const {
onCloseContextMenu,
onUpdateAnnotations,
activatedState,
visible,
type,
top,
left,
} = props;
if (!visible || activatedStateID === null) {
return null;
const [contextMenuFor, setContextMenuFor] = useState(activatedState);
if (activatedState !== contextMenuFor) {
setContextMenuFor(activatedState);
if (visible && type === ContextMenuType.CANVAS_SHAPE_POINT) {
onCloseContextMenu();
}
}
return ReactDOM.createPortal(
<div className='cvat-canvas-point-context-menu' style={{ top, left }}>
<Tooltip title='Delete point [Ctrl + dblclick]'>
<Button type='link' icon='delete' onClick={onPointDelete}>
Delete point
</Button>
</Tooltip>
</div>,
window.document.body,
);
const onPointDelete = (): void => {
const { selectedPoint } = props;
if (contextMenuFor && selectedPoint !== null) {
contextMenuFor.points = contextMenuFor.points.slice(0, selectedPoint * 2)
.concat(contextMenuFor.points.slice(selectedPoint * 2 + 2));
onUpdateAnnotations([contextMenuFor]);
onCloseContextMenu();
}
};
const onSetStartPoint = (): void => {
const { selectedPoint } = props;
if (contextMenuFor && selectedPoint !== null && contextMenuFor.shapeType === 'polygon') {
contextMenuFor.points = contextMenuFor.points.slice(selectedPoint * 2)
.concat(contextMenuFor.points.slice(0, selectedPoint * 2));
onUpdateAnnotations([contextMenuFor]);
onCloseContextMenu();
}
};
return visible && contextMenuFor && type === ContextMenuType.CANVAS_SHAPE_POINT
? (ReactDOM.createPortal(
<div className='cvat-canvas-point-context-menu' style={{ top, left }}>
<Tooltip title='Delete point [Ctrl + dblclick]'>
<Button type='link' icon='delete' onClick={onPointDelete}>
Delete point
</Button>
</Tooltip>
{contextMenuFor && contextMenuFor.shapeType === 'polygon' && (
<Button type='link' icon='environment' onClick={onSetStartPoint}>
Set start point
</Button>
)}
</div>,
window.document.body,
)) : null;
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(CanvasPointContextMenu);

@ -58,8 +58,6 @@ interface Props {
contrastLevel: number;
saturationLevel: number;
resetZoom: boolean;
contextVisible: boolean;
contextType: ContextMenuType;
aamZoomMargin: number;
showObjectsTextAlways: boolean;
workspace: Workspace;
@ -382,10 +380,9 @@ export default class CanvasWrapperComponent extends React.PureComponent<Props> {
const {
activatedStateID,
onUpdateContextMenu,
contextType,
} = this.props;
if (contextType !== ContextMenuType.CANVAS_SHAPE_POINT) {
if (e.target && !(e.target as HTMLElement).classList.contains('svg_select_points')) {
onUpdateContextMenu(activatedStateID !== null, e.clientX, e.clientY,
ContextMenuType.CANVAS_SHAPE);
}

@ -37,13 +37,15 @@ interface Props {
repeatDrawShape(): void;
pasteShape(): void;
resetGroup(): void;
redrawShape(): void;
}
export default function ControlsSideBarComponent(props: Props): JSX.Element {
const {
canvasInstance,
activeControl,
normalizedKeyMap,
keyMap,
mergeObjects,
groupObjects,
splitTrack,
@ -51,8 +53,7 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
repeatDrawShape,
pasteShape,
resetGroup,
normalizedKeyMap,
keyMap,
redrawShape,
} = props;
const preventDefault = (event: KeyboardEvent | undefined): void => {
@ -89,7 +90,12 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
canvasInstance.cancel();
// repeateDrawShapes gets all the latest parameters
// and calls canvasInstance.draw() with them
repeatDrawShape();
if (event && event.shiftKey) {
redrawShape();
} else {
repeatDrawShape();
}
} else {
canvasInstance.draw({ enabled: false });
}

@ -51,10 +51,6 @@ function DrawShapePopoverComponent(props: Props): JSX.Element {
onChangeCuboidDrawingMethod,
} = props;
const trackDisabled = shapeType === ShapeType.POLYGON
|| shapeType === ShapeType.POLYLINE
|| (shapeType === ShapeType.POINTS && numberOfPoints !== 1);
return (
<div className='cvat-draw-shape-popover-content'>
<Row type='flex' justify='start'>
@ -198,7 +194,7 @@ function DrawShapePopoverComponent(props: Props): JSX.Element {
</Col>
<Col span={12}>
<Tooltip title={`Press ${repeatShapeShortcut} to draw again`}>
<Button onClick={onDrawTrack} disabled={trackDisabled}>
<Button onClick={onDrawTrack}>
Track
</Button>
</Tooltip>

@ -38,8 +38,8 @@ import { clamp } from 'utils/math';
function ItemMenu(
serverID: number | undefined,
locked: boolean,
objectType: ObjectType,
shapeType: ShapeType,
objectType: ObjectType,
copyShortcut: string,
pasteShortcut: string,
propagateShortcut: string,
@ -50,9 +50,9 @@ function ItemMenu(
remove: (() => void),
propagate: (() => void),
createURL: (() => void),
switchOrientation: (() => void),
toBackground: (() => void),
toForeground: (() => void),
switchCuboidOrientation: (() => void),
resetCuboidPerspective: (() => void),
): JSX.Element {
return (
@ -76,9 +76,9 @@ function ItemMenu(
</Button>
</Tooltip>
</Menu.Item>
{shapeType === ShapeType.CUBOID && (
{ [ShapeType.POLYGON, ShapeType.POLYLINE, ShapeType.CUBOID].includes(shapeType) && (
<Menu.Item>
<Button type='link' icon='retweet' onClick={switchCuboidOrientation}>
<Button type='link' icon='retweet' onClick={switchOrientation}>
Switch orientation
</Button>
</Menu.Item>
@ -143,8 +143,8 @@ interface ItemTopComponentProps {
serverID: number | undefined;
labelID: number;
labels: any[];
objectType: ObjectType;
shapeType: ShapeType;
objectType: ObjectType;
type: string;
locked: boolean;
copyShortcut: string;
@ -158,9 +158,9 @@ interface ItemTopComponentProps {
remove(): void;
propagate(): void;
createURL(): void;
switchOrientation(): void;
toBackground(): void;
toForeground(): void;
switchCuboidOrientation(): void;
resetCuboidPerspective(): void;
}
@ -170,8 +170,8 @@ function ItemTopComponent(props: ItemTopComponentProps): JSX.Element {
serverID,
labelID,
labels,
objectType,
shapeType,
objectType,
type,
locked,
copyShortcut,
@ -185,9 +185,9 @@ function ItemTopComponent(props: ItemTopComponentProps): JSX.Element {
remove,
propagate,
createURL,
switchOrientation,
toBackground,
toForeground,
switchCuboidOrientation,
resetCuboidPerspective,
} = props;
@ -228,8 +228,8 @@ function ItemTopComponent(props: ItemTopComponentProps): JSX.Element {
overlay={ItemMenu(
serverID,
locked,
objectType,
shapeType,
objectType,
copyShortcut,
pasteShortcut,
propagateShortcut,
@ -240,9 +240,9 @@ function ItemTopComponent(props: ItemTopComponentProps): JSX.Element {
remove,
propagate,
createURL,
switchOrientation,
toBackground,
toForeground,
switchCuboidOrientation,
resetCuboidPerspective,
)}
>
@ -749,6 +749,7 @@ interface Props {
copy(): void;
propagate(): void;
createURL(): void;
switchOrientation(): void;
toBackground(): void;
toForeground(): void;
remove(): void;
@ -768,7 +769,6 @@ interface Props {
changeAttribute(attrID: number, value: string): void;
changeColor(color: string): void;
collapse(): void;
switchCuboidOrientation(): void;
resetCuboidPerspective(): void;
}
@ -828,6 +828,7 @@ function ObjectItemComponent(props: Props): JSX.Element {
copy,
propagate,
createURL,
switchOrientation,
toBackground,
toForeground,
remove,
@ -847,7 +848,6 @@ function ObjectItemComponent(props: Props): JSX.Element {
changeAttribute,
changeColor,
collapse,
switchCuboidOrientation,
resetCuboidPerspective,
} = props;
@ -886,9 +886,9 @@ function ObjectItemComponent(props: Props): JSX.Element {
clientID={clientID}
labelID={labelID}
labels={labels}
shapeType={shapeType}
objectType={objectType}
type={type}
shapeType={shapeType}
locked={locked}
copyShortcut={normalizedKeyMap.COPY_SHAPE}
pasteShortcut={normalizedKeyMap.PASTE_SHAPE}
@ -901,9 +901,9 @@ function ObjectItemComponent(props: Props): JSX.Element {
remove={remove}
propagate={propagate}
createURL={createURL}
switchOrientation={switchOrientation}
toBackground={toBackground}
toForeground={toForeground}
switchCuboidOrientation={switchCuboidOrientation}
resetCuboidPerspective={resetCuboidPerspective}
/>
<ItemButtons

@ -11,7 +11,7 @@ import ControlsSideBarContainer from 'containers/annotation-page/standard-worksp
import ObjectSideBarContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-side-bar';
import PropagateConfirmContainer from 'containers/annotation-page/standard-workspace/propagate-confirm';
import CanvasContextMenuContainer from 'containers/annotation-page/standard-workspace/canvas-context-menu';
import CanvasPointContextMenuContainer from 'containers/annotation-page/standard-workspace/canvas-point-context-menu';
import CanvasPointContextMenuComponent from 'components/annotation-page/standard-workspace/canvas-point-context-menu';
export default function StandardWorkspaceComponent(): JSX.Element {
return (
@ -21,7 +21,7 @@ export default function StandardWorkspaceComponent(): JSX.Element {
<ObjectSideBarContainer />
<PropagateConfirmContainer />
<CanvasContextMenuContainer />
<CanvasPointContextMenuContainer />
<CanvasPointContextMenuComponent />
</Layout>
);
}

@ -231,12 +231,10 @@
}
.cvat-canvas-point-context-menu {
display: grid;
opacity: 0.6;
position: fixed;
width: 135px;
z-index: 10;
max-height: 50%;
overflow-y: auto;
background-color: #ffffff;
border-radius: 4px;

@ -53,8 +53,8 @@ interface CVATAppProps {
installedAutoAnnotation: boolean;
installedTFAnnotation: boolean;
installedTFSegmentation: boolean;
userAgreementsFetching: boolean,
userAgreementsInitialized: boolean,
userAgreementsFetching: boolean;
userAgreementsInitialized: boolean;
notifications: NotificationsState;
user: any;
}
@ -72,7 +72,7 @@ class CVATApplication extends React.PureComponent<CVATAppProps & RouteComponentP
});
core.logger.configure(() => window.document.hasFocus, userActivityCallback);
customWaViewHit(location.pathname, location.search, location.hash);
customWaViewHit(history.location.pathname, history.location.search, history.location.hash);
history.listen((location) => {
customWaViewHit(location.pathname, location.search, location.hash);
});

@ -1,193 +0,0 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { connect } from 'react-redux';
import { CombinedState, ContextMenuType } from 'reducers/interfaces';
import { updateAnnotationsAsync, updateCanvasContextMenu } from 'actions/annotation-actions';
import CanvasPointContextMenuComponent from 'components/annotation-page/standard-workspace/canvas-point-context-menu';
interface StateToProps {
activatedStateID: number | null;
activatedPointID: number | null;
states: any[];
visible: boolean;
top: number;
left: number;
type: ContextMenuType;
}
function mapStateToProps(state: CombinedState): StateToProps {
const {
annotation: {
annotations: {
states,
activatedStateID,
},
canvas: {
contextMenu: {
visible,
top,
left,
type,
pointID: activatedPointID,
},
},
},
} = state;
return {
activatedStateID,
activatedPointID,
states,
visible,
left,
top,
type,
};
}
interface DispatchToProps {
onUpdateAnnotations(states: any[]): void;
onCloseContextMenu(): void;
}
function mapDispatchToProps(dispatch: any): DispatchToProps {
return {
onUpdateAnnotations(states: any[]): void {
dispatch(updateAnnotationsAsync(states));
},
onCloseContextMenu(): void {
dispatch(updateCanvasContextMenu(false, 0, 0));
},
};
}
type Props = StateToProps & DispatchToProps;
interface State {
activatedStateID: number | null;
activatedPointID: number | null;
latestLeft: number;
latestTop: number;
left: number;
top: number;
}
class CanvasPointContextMenuContainer extends React.PureComponent<Props, State> {
public constructor(props: Props) {
super(props);
this.state = {
activatedStateID: null,
activatedPointID: null,
latestLeft: 0,
latestTop: 0,
left: 0,
top: 0,
};
}
static getDerivedStateFromProps(props: Props, state: State): State {
const newState: State = { ...state };
if (props.left !== state.latestLeft
|| props.top !== state.latestTop) {
newState.latestLeft = props.left;
newState.latestTop = props.top;
newState.top = props.top;
newState.left = props.left;
}
if (typeof state.activatedStateID !== typeof props.activatedStateID
|| state.activatedPointID !== props.activatedPointID) {
newState.activatedStateID = props.activatedStateID;
newState.activatedPointID = props.activatedPointID;
}
return newState;
}
public componentDidUpdate(): void {
const {
top,
left,
} = this.state;
const {
innerWidth,
innerHeight,
} = window;
const [element] = window.document.getElementsByClassName('cvat-canvas-point-context-menu');
if (element) {
const height = element.clientHeight;
const width = element.clientWidth;
if (top + height > innerHeight || left + width > innerWidth) {
this.setState({
top: top - Math.max(top + height - innerHeight, 0),
left: left - Math.max(left + width - innerWidth, 0),
});
}
}
}
private deletePoint(): void {
const {
states,
onUpdateAnnotations,
onCloseContextMenu,
} = this.props;
const {
activatedStateID,
activatedPointID,
} = this.state;
const [objectState] = states.filter((e) => (e.clientID === activatedStateID));
if (typeof activatedPointID === 'number') {
objectState.points = objectState.points.slice(0, activatedPointID * 2)
.concat(objectState.points.slice(activatedPointID * 2 + 2));
onUpdateAnnotations([objectState]);
onCloseContextMenu();
}
}
public render(): JSX.Element {
const {
visible,
activatedStateID,
type,
} = this.props;
const {
top,
left,
} = this.state;
return (
<>
{type === ContextMenuType.CANVAS_SHAPE_POINT && (
<CanvasPointContextMenuComponent
left={left}
top={top}
visible={visible}
activatedStateID={activatedStateID}
onPointDelete={() => this.deletePoint()}
/>
)}
</>
);
}
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(CanvasPointContextMenuContainer);

@ -83,8 +83,6 @@ interface StateToProps {
maxZLayer: number;
curZLayer: number;
automaticBordering: boolean;
contextVisible: boolean;
contextType: ContextMenuType;
switchableAutomaticBordering: boolean;
keyMap: Record<string, ExtendedKeyMapOptions>;
}
@ -124,10 +122,6 @@ function mapStateToProps(state: CombinedState): StateToProps {
annotation: {
canvas: {
activeControl,
contextMenu: {
visible: contextVisible,
type: contextType,
},
instance: canvasInstance,
},
drawing: {
@ -223,8 +217,6 @@ function mapStateToProps(state: CombinedState): StateToProps {
minZLayer,
maxZLayer,
automaticBordering,
contextVisible,
contextType,
workspace,
keyMap,
switchableAutomaticBordering: activeControl === ActiveControl.DRAW_POLYGON

@ -10,6 +10,7 @@ import {
mergeObjects,
groupObjects,
splitTrack,
redrawShapeAsync,
rotateCurrentFrame,
repeatDrawShapeAsync,
pasteShapeAsync,
@ -34,6 +35,7 @@ interface DispatchToProps {
resetGroup(): void;
repeatDrawShape(): void;
pasteShape(): void;
redrawShape(): void;
}
function mapStateToProps(state: CombinedState): StateToProps {
@ -87,6 +89,9 @@ function dispatchToProps(dispatch: any): DispatchToProps {
resetGroup(): void {
dispatch(resetAnnotationsGroup());
},
redrawShape(): void {
dispatch(redrawShapeAsync());
},
};
}

@ -7,8 +7,13 @@ import copy from 'copy-to-clipboard';
import { connect } from 'react-redux';
import { LogType } from 'cvat-logger';
import { Canvas, isAbleToChangeFrame } from 'cvat-canvas-wrapper';
import { ActiveControl, CombinedState, ColorBy } from 'reducers/interfaces';
import { Canvas } from 'cvat-canvas-wrapper';
import {
ActiveControl,
CombinedState,
ColorBy,
ShapeType,
} from 'reducers/interfaces';
import {
collapseObjectItems,
changeLabelColorAsync,
@ -235,6 +240,33 @@ class ObjectItemContainer extends React.PureComponent<Props> {
copy(url);
};
private switchOrientation = (): void => {
const { objectState, updateState } = this.props;
if (objectState.shapeType === ShapeType.CUBOID) {
this.switchCuboidOrientation();
return;
}
const reducedPoints = objectState.points.reduce(
(acc: number[][], _: number, index: number, array: number[]): number[][] => {
if (index % 2) {
acc.push([array[index - 1], array[index]]);
}
return acc;
}, [],
);
if (objectState.shapeType === ShapeType.POLYGON) {
objectState.points = reducedPoints.slice(0, 1)
.concat(reducedPoints.reverse().slice(0, -1)).flat();
updateState(objectState);
} else if (objectState.shapeType === ShapeType.POLYLINE) {
objectState.points = reducedPoints.reverse().flat();
updateState(objectState);
}
};
private toBackground = (): void => {
const {
objectState,
@ -394,7 +426,6 @@ class ObjectItemContainer extends React.PureComponent<Props> {
this.commit();
};
private switchCuboidOrientation = (): void => {
function cuboidOrientationIsLeft(points: number[]): boolean {
return points[12] > points[0];
@ -444,7 +475,7 @@ class ObjectItemContainer extends React.PureComponent<Props> {
private changeFrame(frame: number): void {
const { changeFrame, canvasInstance } = this.props;
if (isAbleToChangeFrame(canvasInstance)) {
if (canvasInstance.isAbleToChangeFrame()) {
changeFrame(frame);
}
}
@ -534,6 +565,7 @@ class ObjectItemContainer extends React.PureComponent<Props> {
copy={this.copy}
propagate={this.propagate}
createURL={this.createURL}
switchOrientation={this.switchOrientation}
toBackground={this.toBackground}
toForeground={this.toForeground}
setOccluded={this.setOccluded}
@ -552,7 +584,6 @@ class ObjectItemContainer extends React.PureComponent<Props> {
changeLabel={this.changeLabel}
changeAttribute={this.changeAttribute}
collapse={this.collapse}
switchCuboidOrientation={this.switchCuboidOrientation}
resetCuboidPerspective={() => this.resetCuboidPerspective()}
/>
);

@ -17,7 +17,7 @@ import {
changeGroupColorAsync,
changeLabelColorAsync,
} from 'actions/annotation-actions';
import { Canvas, isAbleToChangeFrame } from 'cvat-canvas-wrapper';
import { Canvas } from 'cvat-canvas-wrapper';
import {
CombinedState,
StatesOrdering,
@ -446,7 +446,7 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
if (state && state.objectType === ObjectType.TRACK) {
const frame = typeof (state.keyframes.next) === 'number'
? state.keyframes.next : null;
if (frame !== null && isAbleToChangeFrame(canvasInstance)) {
if (frame !== null && canvasInstance.isAbleToChangeFrame()) {
changeFrame(frame);
}
}
@ -457,7 +457,7 @@ class ObjectsListContainer extends React.PureComponent<Props, State> {
if (state && state.objectType === ObjectType.TRACK) {
const frame = typeof (state.keyframes.prev) === 'number'
? state.keyframes.prev : null;
if (frame !== null && isAbleToChangeFrame(canvasInstance)) {
if (frame !== null && canvasInstance.isAbleToChangeFrame()) {
changeFrame(frame);
}
}

@ -23,7 +23,7 @@ import {
changeWorkspace as changeWorkspaceAction,
activateObject,
} from 'actions/annotation-actions';
import { Canvas, isAbleToChangeFrame } from 'cvat-canvas-wrapper';
import { Canvas } from 'cvat-canvas-wrapper';
import AnnotationTopBarComponent from 'components/annotation-page/top-bar/top-bar';
import { CombinedState, FrameSpeed, Workspace } from 'reducers/interfaces';
@ -222,7 +222,7 @@ class AnnotationTopBarContainer extends React.PureComponent<Props> {
setTimeout(() => {
const { playing: stillPlaying } = this.props;
if (stillPlaying) {
if (isAbleToChangeFrame(canvasInstance)) {
if (canvasInstance.isAbleToChangeFrame()) {
onChangeFrame(
frameNumber + 1 + framesSkiped,
stillPlaying, framesSkiped + 1,
@ -252,7 +252,7 @@ class AnnotationTopBarContainer extends React.PureComponent<Props> {
canvasInstance,
} = this.props;
if (isAbleToChangeFrame(canvasInstance)) {
if (canvasInstance.isAbleToChangeFrame()) {
undo(jobInstance, frameNumber);
}
};
@ -265,7 +265,7 @@ class AnnotationTopBarContainer extends React.PureComponent<Props> {
canvasInstance,
} = this.props;
if (isAbleToChangeFrame(canvasInstance)) {
if (canvasInstance.isAbleToChangeFrame()) {
redo(jobInstance, frameNumber);
}
};
@ -446,7 +446,7 @@ class AnnotationTopBarContainer extends React.PureComponent<Props> {
private changeFrame(frame: number): void {
const { onChangeFrame, canvasInstance } = this.props;
if (isAbleToChangeFrame(canvasInstance)) {
if (canvasInstance.isAbleToChangeFrame()) {
onChangeFrame(frame);
}
}
@ -551,7 +551,7 @@ class AnnotationTopBarContainer extends React.PureComponent<Props> {
SEARCH_FORWARD: (event: KeyboardEvent | undefined) => {
preventDefault(event);
if (frameNumber + 1 <= stopFrame && canvasIsReady
&& isAbleToChangeFrame(canvasInstance)
&& canvasInstance.isAbleToChangeFrame()
) {
searchAnnotations(jobInstance, frameNumber + 1, stopFrame);
}
@ -559,7 +559,7 @@ class AnnotationTopBarContainer extends React.PureComponent<Props> {
SEARCH_BACKWARD: (event: KeyboardEvent | undefined) => {
preventDefault(event);
if (frameNumber - 1 >= startFrame && canvasIsReady
&& isAbleToChangeFrame(canvasInstance)
&& canvasInstance.isAbleToChangeFrame()
) {
searchAnnotations(jobInstance, frameNumber - 1, startFrame);
}

@ -10,16 +10,10 @@ import {
CuboidDrawingMethod,
} from 'cvat-canvas/src/typescript/canvas';
function isAbleToChangeFrame(canvas: Canvas): boolean {
return ![CanvasMode.DRAG, CanvasMode.EDIT, CanvasMode.RESIZE]
.includes(canvas.mode());
}
export {
Canvas,
CanvasMode,
CanvasVersion,
RectDrawingMethod,
CuboidDrawingMethod,
isAbleToChangeFrame,
};

@ -305,7 +305,6 @@ export enum StatesOrdering {
}
export enum ContextMenuType {
CANVAS = 'canvas',
CANVAS_SHAPE = 'canvas_shape',
CANVAS_SHAPE_POINT = 'canvas_shape_point',
}

@ -207,8 +207,8 @@ const defaultKeyMap = {
},
SWITCH_DRAW_MODE: {
name: 'Draw mode',
description: 'Repeat the latest procedure of drawing with the same parameters',
sequences: ['n'],
description: 'Repeat the latest procedure of drawing with the same parameters (shift to redraw an existing shape)',
sequences: ['shift+n', 'n'],
action: 'keydown',
},
SWITCH_MERGE_MODE: {

@ -442,34 +442,284 @@ class TrackManager(ObjectManager):
@staticmethod
def get_interpolated_shapes(track, start_frame, end_frame):
def interpolate(shape0, shape1):
def copy_shape(source, frame, points = None):
copied = deepcopy(source)
copied["keyframe"] = True
copied["frame"] = frame
if points:
copied["points"] = points
return copied
def simple_interpolation(shape0, shape1):
shapes = []
distance = shape1["frame"] - shape0["frame"]
diff = np.subtract(shape1["points"], shape0["points"])
for frame in range(shape0["frame"] + 1, shape1["frame"]):
offset = (frame - shape0["frame"]) / distance
points = None
if shape1["outside"]:
points = np.asarray(shape0["points"])
else:
points = shape0["points"] + diff * offset
shapes.append(copy_shape(shape0, frame, points.tolist()))
return shapes
def points_interpolation(shape0, shape1):
if len(shape0["points"]) == 2 and len(shape1["points"]) == 2:
return simple_interpolation(shape0, shape1)
else:
shapes = []
for frame in range(shape0["frame"] + 1, shape1["frame"]):
shapes.append(copy_shape(shape0, frame))
return shapes
def interpolate_position(left_position, right_position, offset):
def to_array(points):
return np.asarray(
list(map(lambda point: [point["x"], point["y"]], points))
).flatten()
def to_points(array):
return list(map(
lambda point: {"x": point[0], "y": point[1]}, np.asarray(array).reshape(-1, 2)
))
def curve_length(points):
length = 0
for i in range(1, len(points)):
dx = points[i]["x"] - points[i - 1]["x"]
dy = points[i]["y"] - points[i - 1]["y"]
length += np.sqrt(dx ** 2 + dy ** 2)
return length
def curve_to_offset_vec(points, length):
offset_vector = [0]
accumulated_length = 0
for i in range(1, len(points)):
dx = points[i]["x"] - points[i - 1]["x"]
dy = points[i]["y"] - points[i - 1]["y"]
accumulated_length += np.sqrt(dx ** 2 + dy ** 2)
offset_vector.append(accumulated_length / length)
return offset_vector
def find_nearest_pair(value, curve):
minimum = [0, abs(value - curve[0])]
for i in range(1, len(curve)):
distance = abs(value - curve[i])
if distance < minimum[1]:
minimum = [i, distance]
return minimum[0]
def match_left_right(left_curve, right_curve):
matching = {}
for i, left_curve_item in enumerate(left_curve):
matching[i] = [find_nearest_pair(left_curve_item, right_curve)]
return matching
def match_right_left(left_curve, right_curve, left_right_matching):
matched_right_points = left_right_matching.values()
unmatched_right_points = filter(lambda x: x not in matched_right_points, range(len(right_curve)))
updated_matching = deepcopy(left_right_matching)
for right_point in unmatched_right_points:
left_point = find_nearest_pair(right_curve[right_point], left_curve)
updated_matching[left_point].append(right_point)
for key, value in updated_matching.items():
updated_matching[key] = sorted(value)
return updated_matching
def reduce_interpolation(interpolated_points, matching, left_points, right_points):
def average_point(points):
sumX = 0
sumY = 0
for point in points:
sumX += point["x"]
sumY += point["y"]
return {
"x": sumX / len(points),
"y": sumY / len(points)
}
def compute_distance(point1, point2):
return np.sqrt(
((point1["x"] - point2["x"])) ** 2
+ ((point1["y"] - point2["y"]) ** 2)
)
def minimize_segment(base_length, N, start_interpolated, stop_interpolated):
threshold = base_length / (2 * N)
minimized = [interpolated_points[start_interpolated]]
latest_pushed = start_interpolated
for i in range(start_interpolated + 1, stop_interpolated):
distance = compute_distance(
interpolated_points[latest_pushed], interpolated_points[i]
)
if distance >= threshold:
minimized.append(interpolated_points[i])
latest_pushed = i
minimized.append(interpolated_points[stop_interpolated])
if len(minimized) == 2:
distance = compute_distance(
interpolated_points[start_interpolated],
interpolated_points[stop_interpolated]
)
if distance < threshold:
return [average_point(minimized)]
return minimized
reduced = []
interpolated_indexes = {}
accumulated = 0
for i in range(len(left_points)):
interpolated_indexes[i] = []
for _ in range(len(matching[i])):
interpolated_indexes[i].append(accumulated)
accumulated += 1
def left_segment(start, stop):
start_interpolated = interpolated_indexes[start][0]
stop_interpolated = interpolated_indexes[stop][0]
if start_interpolated == stop_interpolated:
reduced.append(interpolated_points[start_interpolated])
return
base_length = curve_length(left_points[start: stop + 1])
N = stop - start + 1
reduced.extend(
minimize_segment(base_length, N, start_interpolated, stop_interpolated)
)
def right_segment(left_point):
start = matching[left_point][0]
stop = matching[left_point][-1]
start_interpolated = interpolated_indexes[left_point][0]
stop_interpolated = interpolated_indexes[left_point][-1]
base_length = curve_length(right_points[start: stop + 1])
N = stop - start + 1
reduced.extend(
minimize_segment(base_length, N, start_interpolated, stop_interpolated)
)
previous_opened = None
for i in range(len(left_points)):
if len(matching[i]) == 1:
if previous_opened is not None:
if matching[i][0] == matching[previous_opened][0]:
continue
else:
start = previous_opened
stop = i - 1
left_segment(start, stop)
previous_opened = i
else:
previous_opened = i
else:
if previous_opened is not None:
start = previous_opened
stop = i - 1
left_segment(start, stop)
previous_opened = None
right_segment(i)
if previous_opened is not None:
left_segment(previous_opened, len(left_points) - 1)
return reduced
left_points = to_points(left_position["points"])
right_points = to_points(right_position["points"])
left_offset_vec = curve_to_offset_vec(left_points, curve_length(left_points))
right_offset_vec = curve_to_offset_vec(right_points, curve_length(right_points))
matching = match_left_right(left_offset_vec, right_offset_vec)
completed_matching = match_right_left(
left_offset_vec, right_offset_vec, matching
)
interpolated_points = []
for left_point_index, left_point in enumerate(left_points):
for right_point_index in completed_matching[left_point_index]:
right_point = right_points[right_point_index]
interpolated_points.append({
"x": left_point["x"] + (right_point["x"] - left_point["x"]) * offset,
"y": left_point["y"] + (right_point["y"] - left_point["y"]) * offset
})
reducedPoints = reduce_interpolation(
interpolated_points,
completed_matching,
left_points,
right_points
)
return to_array(reducedPoints).tolist()
def polyshape_interpolation(shape0, shape1):
shapes = []
is_same_type = shape0["type"] == shape1["type"]
is_polygon = shape0["type"] == ShapeType.POLYGON
is_polyline = shape0["type"] == ShapeType.POLYLINE
is_same_size = len(shape0["points"]) == len(shape1["points"])
if not is_same_type or is_polygon or is_polyline or not is_same_size:
shape0 = TrackManager.normalize_shape(shape0)
shape1 = TrackManager.normalize_shape(shape1)
if is_polygon:
shape0["points"].extend(shape0["points"][:2])
shape1["points"].extend(shape1["points"][:2])
distance = shape1["frame"] - shape0["frame"]
step = np.subtract(shape1["points"], shape0["points"]) / distance
for frame in range(shape0["frame"] + 1, shape1["frame"]):
off = frame - shape0["frame"]
offset = (frame - shape0["frame"]) / distance
points = None
if shape1["outside"]:
points = np.asarray(shape0["points"]).reshape(-1, 2)
else:
points = (shape0["points"] + step * off).reshape(-1, 2)
shape = deepcopy(shape0)
if len(points) == 1:
shape["points"] = points.flatten()
points = np.asarray(shape0["points"])
else:
broken_line = geometry.LineString(points).simplify(0.05, False)
shape["points"] = [x for p in broken_line.coords for x in p]
points = interpolate_position(shape0, shape1, offset)
shapes.append(copy_shape(shape0, frame, points))
if is_polygon:
shape0["points"] = shape0["points"][:-2]
shape1["points"] = shape1["points"][:-2]
for shape in shapes:
shape["points"] = shape["points"][:-2]
return shapes
def interpolate(shape0, shape1):
is_same_type = shape0["type"] == shape1["type"]
is_rectangle = shape0["type"] == ShapeType.RECTANGLE
is_cuboid = shape0["type"] == ShapeType.CUBOID
is_polygon = shape0["type"] == ShapeType.POLYGON
is_polyline = shape0["type"] == ShapeType.POLYLINE
is_points = shape0["type"] == ShapeType.POINTS
if not is_same_type:
raise NotImplementedError()
shapes = []
if is_rectangle or is_cuboid:
shapes = simple_interpolation(shape0, shape1)
elif is_points:
shapes = points_interpolation(shape0, shape1)
elif is_polygon or is_polyline:
shapes = polyshape_interpolation(shape0, shape1)
else:
raise NotImplementedError()
shape["keyframe"] = False
shape["frame"] = frame
shapes.append(shape)
return shapes
if track.get("interpolated_shapes"):

Loading…
Cancel
Save