Skip to content

feat: Support snap to pixel for RectangleLabels #7983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jul 21, 2025
Merged
1 change: 1 addition & 0 deletions docs/source/includes/tags/choices.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@
| [perItem] | <code>boolean</code> | | Use this tag to select a choice for a specific item inside the object instead of the whole object |
| [value] | <code>string</code> | | Task data field containing a list of dynamically loaded choices (see example below) |
| [allowNested] | <code>boolean</code> | | Allow to use `children` field in dynamic choices to nest them. Submitted result will contain array of arrays, every item is a list of values from topmost parent choice down to selected one. |
| [layout] | <code>select</code> \| <code>inline</code> \| <code>vertical</code> | | Layout of the choices: `select` for dropdown/select box format, `inline` for horizontal single row display, `vertical` for vertically stacked display (default) |

1 change: 1 addition & 0 deletions docs/source/includes/tags/rectangle.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
| [canRotate] | <code>boolean</code> | <code>true</code> | Whether to show or hide rotation control. Note that the anchor point in the results is different than the anchor point used when rotating with the rotation tool. For more information, see [Rotation](/templates/image_bbox#Rotation). |
| [smart] | <code>boolean</code> | | Show smart tool for interactive pre-annotations |
| [smartOnly] | <code>boolean</code> | | Only show smart tool for interactive pre-annotations |
| [snap] | <code>pixel</code> \| <code>none</code> | <code>none</code> | Snap rectangle to image pixels |

1 change: 1 addition & 0 deletions docs/source/includes/tags/rectanglelabels.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
| [strokeColor] | <code>string</code> | | Stroke color in hexadecimal |
| [strokeWidth] | <code>number</code> | <code>1</code> | Width of stroke |
| [canRotate] | <code>boolean</code> | <code>true</code> | Show or hide rotation control. Note that the anchor point in the results is different than the anchor point used when rotating with the rotation tool. For more information, see [Rotation](/templates/image_bbox#Rotation). |
| [snap] | <code>pixel</code> \| <code>none</code> | <code>none</code> | Snap rectangle to image pixels |

### Result parameters

Expand Down
67 changes: 59 additions & 8 deletions web/libs/editor/src/regions/RectRegion.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,13 @@ const Model = types

self.height = self.parent.canvasToInternalY(canvasHeight);
}
self.setPositionInternal(self.x, self.y, self.width, self.height, self.rotation);
self.setPosition(
self.parent.internalToCanvasX(self.x),
self.parent.internalToCanvasY(self.y),
self.parent.internalToCanvasX(self.width),
self.parent.internalToCanvasY(self.height),
self.rotation,
);

const areaBBoxCoords = self?.bboxCoords;

Expand Down Expand Up @@ -336,13 +342,33 @@ const Model = types
* @param {number} rotation
*/
setPosition(x, y, width, height, rotation) {
self.setPositionInternal(
self.parent.canvasToInternalX(x),
self.parent.canvasToInternalY(y),
self.parent.canvasToInternalX(width),
self.parent.canvasToInternalY(height),
rotation,
);
const internalX = self.parent.canvasToInternalX(x);
const internalY = self.parent.canvasToInternalY(y);
const internalWidth = self.parent.canvasToInternalX(width);
const internalHeight = self.parent.canvasToInternalY(height);

// Apply snap to pixel if enabled
if (self.control?.snap === "pixel") {
// Snap top-left corner
const topLeftPoint = self.control.getSnappedPoint({
x: internalX,
y: internalY,
});

// Snap bottom-right corner
const bottomRightPoint = self.control.getSnappedPoint({
x: internalX + internalWidth,
y: internalY + internalHeight,
});

// Calculate snapped dimensions
const snappedWidth = bottomRightPoint.x - topLeftPoint.x;
const snappedHeight = bottomRightPoint.y - topLeftPoint.y;

self.setPositionInternal(topLeftPoint.x, topLeftPoint.y, snappedWidth, snappedHeight, rotation);
} else {
self.setPositionInternal(internalX, internalY, internalWidth, internalHeight, rotation);
}
},

setScale(x, y) {
Expand Down Expand Up @@ -478,6 +504,18 @@ const HtxRectangleView = ({ item, setShapeRef }) => {
t.setAttr("scaleX", 1);
t.setAttr("scaleY", 1);

if (self.control?.snap === "pixel") {
// If snap is enabled, we need to snap the coordinates to the pixel grid -
// Sync Konva shape attributes back to computed canvas coordinates to cause a re-render
// Canvas coordinates are updated in the setPosition method
t.position({
x: item.canvasX,
y: item.canvasY,
width: item.canvasWidth,
height: item.canvasHeight,
});
}

item.notifyDrawingFinished();
};

Expand All @@ -494,6 +532,19 @@ const HtxRectangleView = ({ item, setShapeRef }) => {

item.setPosition(t.getAttr("x"), t.getAttr("y"), t.getAttr("width"), t.getAttr("height"), t.getAttr("rotation"));
item.setScale(t.getAttr("scaleX"), t.getAttr("scaleY"));

if (item.control?.snap === "pixel") {
// If snap is enabled, we need to snap the coordinates to the pixel grid -
// Sync Konva shape attributes back to computed canvas coordinates to cause a re-render
// Canvas coordinates are updated in the setPosition method
t.position({
x: item.canvasX,
y: item.canvasY,
width: item.canvasWidth,
height: item.canvasHeight,
});
}

item.annotation.history.unfreeze(item.id);

item.notifyDrawingFinished();
Expand Down
1 change: 1 addition & 0 deletions web/libs/editor/src/tags/control/Choices.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import { Select, Tooltip } from "@humansignal/ui";
* @param {boolean} [perItem] - Use this tag to select a choice for a specific item inside the object instead of the whole object
* @param {string} [value] - Task data field containing a list of dynamically loaded choices (see example below)
* @param {boolean} [allowNested] - Allow to use `children` field in dynamic choices to nest them. Submitted result will contain array of arrays, every item is a list of values from topmost parent choice down to selected one.
* @param {select|inline|vertical} [layout] - Layout of the choices: `select` for dropdown/select box format, `inline` for horizontal single row display, `vertical` for vertically stacked display (default)
*/
const TagAttrs = types.model({
toname: types.maybeNull(types.string),
Expand Down
2 changes: 2 additions & 0 deletions web/libs/editor/src/tags/control/Rectangle.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { ToolManagerMixin } from "../../mixins/ToolManagerMixin";
* @param {boolean=} [canRotate=true] - Whether to show or hide rotation control. Note that the anchor point in the results is different than the anchor point used when rotating with the rotation tool. For more information, see [Rotation](/templates/image_bbox#Rotation).
* @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 rectangle to image pixels
*/
const TagAttrs = types.model({
toname: types.maybeNull(types.string),
Expand All @@ -43,6 +44,7 @@ const TagAttrs = types.model({
fillopacity: types.maybeNull(customTypes.range()),

canrotate: types.optional(types.boolean, true),
snap: types.optional(types.string, "none"),
});

const Model = types
Expand Down
1 change: 1 addition & 0 deletions web/libs/editor/src/tags/control/RectangleLabels.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import ControlBase from "./Base";
* @param {string} [strokeColor] - Stroke color in hexadecimal
* @param {number} [strokeWidth=1] - Width of stroke
* @param {boolean} [canRotate=true] - Show or hide rotation control. Note that the anchor point in the results is different than the anchor point used when rotating with the rotation tool. For more information, see [Rotation](/templates/image_bbox#Rotation).
* @param {pixel|none} [snap=none] - Snap rectangle to image pixels
*/

const Validation = types.model({
Expand Down
38 changes: 32 additions & 6 deletions web/libs/editor/src/tools/Rect.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,39 @@ const _BaseNPointTool = types
},
};
})
.actions((self) => ({
beforeCommitDrawing() {
const s = self.getActiveShape;
.actions((self) => {
const Super = {
commitDrawingRegion: self.commitDrawingRegion,
};

return s.width > self.MIN_SIZE.X && s.height * self.MIN_SIZE.Y;
},
}));
return {
beforeCommitDrawing() {
const s = self.getActiveShape;

return s.width > self.MIN_SIZE.X && s.height > self.MIN_SIZE.Y;
},

commitDrawingRegion() {
const { currentArea, control, obj } = self;

if (!currentArea) return;

// Apply snap to pixel if enabled before finalizing the region
if (control?.snap === "pixel") {
const canvasX = currentArea.parent.internalToCanvasX(currentArea.x);
const canvasY = currentArea.parent.internalToCanvasY(currentArea.y);
const canvasWidth = currentArea.parent.internalToCanvasX(currentArea.width);
const canvasHeight = currentArea.parent.internalToCanvasY(currentArea.height);

// Apply snap logic through setPosition which handles both corners
currentArea.setPosition(canvasX, canvasY, canvasWidth, canvasHeight, currentArea.rotation);
}

// Use the parent commitDrawingRegion to finalize the region
return Super.commitDrawingRegion();
},
};
});

const _Tool = types
.model("RectangleTool", {
Expand Down
Loading