Skip to content
This repository has been archived by the owner on Apr 18, 2024. It is now read-only.

Commit

Permalink
feat: LSDV-5518: Add snap prop to snap polygon to image pixels (#1539)
Browse files Browse the repository at this point in the history
* feat: LSDV-5518: Add snap prop to snap polygon to image pixels

* add snap to pixel on keypointLabel

* points -> point

* points -> point

* rebase

* fix rerender when item.x or item.y changes

* snap to pixel when move polygon point

* Add auto-closing of polygon loop in snap to pixel mode when point matches with start

* Add transformer position update on snapping after transform and dragging interactions

* Fix dragging by TransformerBack in relative coordinates

* Fix and refactor snapping functionality and add feature flag

* Prevent duplication on inserting polygon points in snap-to-pixel mode

* Add missed FF

* Add spaces

Co-authored-by: hlomzik <[email protected]>

* Remove spaces

Co-authored-by: hlomzik <[email protected]>

* Make code more readable

* Fix polygon initialization

---------

Co-authored-by: hlomzik <[email protected]>
Co-authored-by: Gondragos <[email protected]>
Co-authored-by: Sergey <[email protected]>
  • Loading branch information
4 people authored Sep 21, 2023
1 parent c49bba6 commit 2d9a66e
Show file tree
Hide file tree
Showing 15 changed files with 213 additions and 37 deletions.
8 changes: 8 additions & 0 deletions src/components/ImageTransformer/ImageTransformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ export default class TransformerComponent extends Component {
dragBoundFunc={this.dragBoundFunc}
onDragEnd={() => {
this.unfreeze();
setTimeout(this.checkNode);
}}
onTransformEnd={() => {
setTimeout(this.checkNode);
}}
backSelector={this.props.draggableBackgroundSelector}
/>
Expand Down Expand Up @@ -249,6 +253,10 @@ export default class TransformerComponent extends Component {
dragBoundFunc={this.dragBoundFunc}
onDragEnd={() => {
this.unfreeze();
setTimeout(this.checkNode);
}}
onTransformEnd={() => {
setTimeout(this.checkNode);
}}
backSelector={this.props.draggableBackgroundSelector}
/>
Expand Down
8 changes: 8 additions & 0 deletions src/components/ImageView/Image.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ export const RELATIVE_STAGE_WIDTH = 100;
*/
export const RELATIVE_STAGE_HEIGHT = 100;

/**
* Mode of snapping to pixel
*/
export const SNAP_TO_PIXEL_MODE = {
EDGE: 'edge',
CENTER: 'center',
};

export const Image = observer(forwardRef(({
imageEntity,
imageTransform,
Expand Down
4 changes: 2 additions & 2 deletions src/components/ImageView/ImageView.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ const TransformerBack = observer(({ item }) => {
}}
onDragStart={e => {
dragStartPointRef.current = {
x: e.target.getAttr('x'),
y: e.target.getAttr('y'),
x: item.canvasToInternalX(e.target.getAttr('x')),
y: item.canvasToInternalY(e.target.getAttr('y')),
};
}}
dragBoundFunc={(pos) => {
Expand Down
24 changes: 18 additions & 6 deletions src/regions/KeyPointRegion.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,16 @@ const KeyPointRegionAbsoluteCoordsDEV3793 = types
},

setPosition(x, y) {
self.x = x;
self.y = y;
const point = self.control?.getSnappedPoint({
x: self.parent.canvasToInternalX(x),
y: self.parent.canvasToInternalY(y),
});

self.relativeX = (x / self.parent.stageWidth) * RELATIVE_STAGE_WIDTH;
self.relativeY = (y / self.parent.stageHeight) * RELATIVE_STAGE_HEIGHT;
self.x = point.x;
self.y = point.y;

self.relativeX = (point.x / self.parent.stageWidth) * RELATIVE_STAGE_WIDTH;
self.relativeY = (point.y / self.parent.stageHeight) * RELATIVE_STAGE_HEIGHT;
},

updateImageSize(wp, hp, sw, sh) {
Expand Down Expand Up @@ -117,8 +122,13 @@ const Model = types
}))
.actions(self => ({
setPosition(x, y) {
self.x = self.parent.canvasToInternalX(x);
self.y = self.parent.canvasToInternalY(y);
const point = self.control?.getSnappedPoint({
x: self.parent.canvasToInternalX(x),
y: self.parent.canvasToInternalY(y),
});

self.x = point.x;
self.y = point.y;
},

updateImageSize() {},
Expand Down Expand Up @@ -227,6 +237,8 @@ const HtxKeyPointView = ({ item, setShapeRef }) => {
const t = e.target;

item.setPosition(t.getAttr('x'), t.getAttr('y'));
t.setAttr('x', item.canvasX);
t.setAttr('y', item.canvasY);
item.annotation.history.unfreeze(item.id);
item.notifyDrawingFinished();
}}
Expand Down
27 changes: 23 additions & 4 deletions src/regions/PolygonPoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const PolygonPointAbsoluteCoordsDEV3793 = types.model()
self.relativeX = (self.x / self.stage.stageWidth) * RELATIVE_STAGE_WIDTH;
self.relativeY = (self.y / self.stage.stageHeight) * RELATIVE_STAGE_HEIGHT;
},
_movePoint(x, y) {
_setPos(x, y) {
self.initX = x;
self.initY = y;

Expand All @@ -47,6 +47,14 @@ const PolygonPointAbsoluteCoordsDEV3793 = types.model()
self.x = x;
self.y = y;
},
_movePoint(x, y) {
const point = self.parent.control?.getSnappedPoint({
x: self.stage.canvasToInternalX(x),
y: self.stage.canvasToInternalY(y),
});

self._setPos(point.x, point.y);
},
}));

const PolygonPointRelativeCoords = types
Expand Down Expand Up @@ -99,9 +107,17 @@ const PolygonPointRelativeCoords = types
self.y = self.y + dy;
},

_setPos(x, y) {
self.x = x;
self.y = y;
},
_movePoint(canvasX, canvasY) {
self.x = self.stage.canvasToInternalX(canvasX);
self.y = self.stage.canvasToInternalY(canvasY);
const point = self.parent.control?.getSnappedPoint({
x: self.stage.canvasToInternalX(canvasX),
y: self.stage.canvasToInternalY(canvasY),
});

self._setPos(point.x, point.y);
},

/**
Expand Down Expand Up @@ -216,14 +232,17 @@ const PolygonPointView = observer(({ item, name }) => {
onDragMove: e => {
if (item.getSkipInteractions()) return false;
if (e.target !== e.currentTarget) return;
let { x, y } = e.target.attrs;
const shape = e.target;
let { x, y } = shape.attrs;

if (x < 0) x = 0;
if (y < 0) y = 0;
if (x > item.stage.stageWidth) x = item.stage.stageWidth;
if (y > item.stage.stageHeight) y = item.stage.stageHeight;

item._movePoint(x, y);
shape.setAttr('x', item.canvasX);
shape.setAttr('y', item.canvasY);
},

onDragStart: () => {
Expand Down
72 changes: 58 additions & 14 deletions src/regions/PolygonRegion.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const PolygonRegionAbsoluteCoordsDEV3793 = types
const x = (sw * p.relativeX) / RELATIVE_STAGE_WIDTH;
const y = (sh * p.relativeY) / RELATIVE_STAGE_HEIGHT;

p._movePoint(x, y);
p._setPos(x, y);
});
}

Expand All @@ -44,7 +44,7 @@ const PolygonRegionAbsoluteCoordsDEV3793 = types
const y = (sh * p.y) / RELATIVE_STAGE_HEIGHT;

self.coordstype = 'px';
p._movePoint(x, y);
p._setPos(x, y);
});
}
},
Expand Down Expand Up @@ -158,6 +158,7 @@ const Model = types
removeHoverAnchor({ layer: e.currentTarget.getLayer() });

const { offsetX, offsetY } = e.evt;

const [cursorX, cursorY] = self.parent.fixZoomedCoords([offsetX, offsetY]);
const point = getAnchorPoint({ flattenedPoints, cursorX, cursorY });

Expand All @@ -176,7 +177,10 @@ const Model = types

addPoint(x, y) {
if (self.closed) return;
self._addPoint(x, y);

const point = self.control?.getSnappedPoint({ x, y });

self._addPoint(point.x, point.y);
},

setPoints(points) {
Expand All @@ -187,19 +191,42 @@ const Model = types
},

insertPoint(insertIdx, x, y) {
const pointCoords = self.control?.getSnappedPoint({
x: self.parent.canvasToInternalX(x),
y: self.parent.canvasToInternalY(y),
});
const isMatchWithPrevPoint = self.points[insertIdx - 1] && self.parent.isSamePixel(pointCoords, self.points[insertIdx - 1]);
const isMatchWithNextPoint = self.points[insertIdx] && self.parent.isSamePixel(pointCoords, self.points[insertIdx]);

if (isMatchWithPrevPoint || isMatchWithNextPoint) {
return;
}


const p = {
id: guidGenerator(),
x: isFF(FF_DEV_3793) ? self.parent.canvasToInternalX(x) : x,
y: isFF(FF_DEV_3793) ? self.parent.canvasToInternalY(y) : y,
x: pointCoords.x,
y: pointCoords.y,
size: self.pointSize,
style: self.pointStyle,
index: self.points.length,
};

self.points.splice(insertIdx, 0, p);

return self.points[insertIdx];
},

_addPoint(x, y) {
const firstPoint = self.points[0];

// This is mostly for "snap to pixel" mode,
// 'cause there is also an ability to close polygon by clicking on the first point precisely
if (self.parent.isSamePixel(firstPoint, { x, y })) {
self.closePoly();
return;
}

self.points.push({
id: guidGenerator(),
x,
Expand All @@ -211,6 +238,7 @@ const Model = types
},

closePoly() {
if (self.closed || self.points.length < 3) return;
self.closed = true;
},

Expand Down Expand Up @@ -395,15 +423,23 @@ const Poly = memo(observer(({ item, colors, dragProps, draggable }) => {

const d = [t.getAttr('x', 0), t.getAttr('y', 0)];
const scale = [t.getAttr('scaleX', 1), t.getAttr('scaleY', 1)];
const points = t.getAttr('points');

if (isFF(FF_DEV_3793)) {
item.setPoints(t.getAttr('points').map((p, idx) => idx % 2
? item.parent.canvasToInternalY(p * scale[1] + d[1])
: item.parent.canvasToInternalX(p * scale[0] + d[0]),
));
} else {
item.setPoints(t.getAttr('points').map((c, idx) => c * scale[idx % 2] + d[idx % 2]));
}
item.setPoints(
points.reduce((result, coord, idx) => {
const isXCoord = idx % 2 === 0;

if (isXCoord) {
const point = item.control?.getSnappedPoint({
x: item.parent.canvasToInternalX(coord * scale[0] + d[0]),
y: item.parent.canvasToInternalY(points[idx + 1] * scale[1] + d[1]),
});

result.push(point.x, point.y);
}
return result;
}, []),
);

t.setAttr('x', 0);
t.setAttr('y', 0);
Expand Down Expand Up @@ -528,7 +564,15 @@ const HtxPolygonView = ({ item, setShapeRef }) => {

item.annotation.setDragMode(false);

item.points.forEach(p => p.movePoint(t.getAttr('x'), t.getAttr('y')));
const point = item.control?.getSnappedPoint({
x: item.parent?.canvasToInternalX(t.getAttr('x')),
y: item.parent?.canvasToInternalY(t.getAttr('y')),
});

point.x = item.parent?.internalToCanvasX(point.x);
point.y = item.parent?.internalToCanvasY(point.y);

item.points.forEach(p => p.movePoint(point.x, point.y));
item.annotation.history.unfreeze(item.id);
}

Expand Down
16 changes: 14 additions & 2 deletions src/tags/control/Base.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { types } from 'mobx-state-tree';
import { FF_DEV_3391, isFF } from '../../utils/feature-flags';
import { FF_DEV_3391, FF_SNAP_TO_PIXEL, isFF } from '../../utils/feature-flags';
import { BaseTag } from '../TagBase';
import { SNAP_TO_PIXEL_MODE } from '../../components/ImageView/Image';

const ControlBase = types.model({
...(isFF(FF_DEV_3391)
Expand All @@ -13,7 +14,9 @@ const ControlBase = types.model({
smart: true,
smartonly: false,
isControlTag: true,
}).views(self => ({
}).volatile(() => ({
snapMode: SNAP_TO_PIXEL_MODE.EDGE,
})).views(self => ({
// historically two "types" were used and we should keep that backward compatibility:
// 1. name of control tag for describing labeled region;
// 2. label type to attach corresponding value to this region.
Expand Down Expand Up @@ -43,6 +46,15 @@ const ControlBase = types.model({
get result() {
return self.annotation.results.find(r => r.from_name === self);
},

getSnappedPoint(point) {
if (!isFF(FF_SNAP_TO_PIXEL)) return point;

if (self.snap === 'pixel') {
return self.toNameTag.snapPointToPixel(point, self.snapMode);
}
return point;
},
}));

export default types.compose(ControlBase, BaseTag);
5 changes: 5 additions & 0 deletions src/tags/control/KeyPoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { customTypes } from '../../core/CustomTypes';
import { AnnotationMixin } from '../../mixins/AnnotationMixin';
import SeparatedControlMixin from '../../mixins/SeparatedControlMixin';
import { ToolManagerMixin } from '../../mixins/ToolManagerMixin';
import { SNAP_TO_PIXEL_MODE } from '../../components/ImageView/Image';

/**
* The `KeyPoint` tag is used to add a key point to an image without selecting a label. This can be useful when you have only one label to assign to the key point.
Expand All @@ -28,13 +29,16 @@ import { ToolManagerMixin } from '../../mixins/ToolManagerMixin';
* @param {string=} [strokeColor=#8bad00] - Keypoint stroke color in hexadecimal
* @param {boolean} [smart] - Show smart tool for interactive pre-annotations
* @param {boolean} [smartOnly] - Only show smart tool for interactive pre-annotations
* @param {pixel|none} [snap=none] - Snap keypoint to image pixels
*/
const TagAttrs = types.model({
toname: types.maybeNull(types.string),

opacity: types.optional(customTypes.range(), '0.9'),
fillcolor: types.optional(customTypes.color, '#8bad00'),

snap: types.optional(types.string, 'none'),

strokecolor: types.optional(customTypes.color, '#8bad00'),
strokewidth: types.optional(types.string, '2'),
});
Expand All @@ -53,6 +57,7 @@ const Model = types
}))
.volatile(() => ({
toolNames: ['KeyPoint'],
snapMode: SNAP_TO_PIXEL_MODE.CENTER,
}));

const KeyPointModel = types.compose('KeyPointModel',
Expand Down
2 changes: 2 additions & 0 deletions src/tags/control/KeyPointLabels.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import ControlBase from './Base';
* @param {boolean} [showInline=true] - Show labels in the same visual line
* @param {float=} [opacity=0.9] - Opacity of the keypoint
* @param {number=} [strokeWidth=1] - Width of the stroke
* @param {pixel|none} [snap=none] - Snap keypoint to image pixels
*
*/

const Validation = types.model({
Expand Down
3 changes: 3 additions & 0 deletions src/tags/control/Polygon.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const hotkeys = Hotkey('Polygons');
* @param {rectangle|circle} [pointStyle=circle] - Style of points
* @param {boolean} [smart] - Show smart tool for interactive pre-annotations
* @param {boolean} [smartOnly] - Only show smart tool for interactive pre-annotations
* @param {pixel|none} [snap=none] - Snap polygon to image pixels
*/
const TagAttrs = types.model({
toname: types.maybeNull(types.string),
Expand All @@ -46,6 +47,8 @@ const TagAttrs = types.model({
strokewidth: types.optional(types.string, '2'),
strokecolor: types.optional(customTypes.color, '#f48a42'),

snap: types.optional(types.string, 'none'),

pointsize: types.optional(types.string, 'small'),
pointstyle: types.optional(types.string, 'circle'),
});
Expand Down
1 change: 1 addition & 0 deletions src/tags/control/PolygonLabels.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import ControlBase from './Base';
* @param {number} [strokeWidth=1] - Width of stroke
* @param {small|medium|large} [pointSize=medium] - Size of polygon handle points
* @param {rectangle|circle} [pointStyle=rectangle] - Style of points
* @param {pixel|none} [snap=none] - Snap polygon to image pixels
*/

const Validation = types.model({
Expand Down
Loading

0 comments on commit 2d9a66e

Please sign in to comment.