Skip to content

fix: BROS-100: Flip rectangle regions without shift #7760

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 7 commits into from
Jun 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export default class TransformerComponent extends Component {
const selectedNodes = [];

selectedRegions.forEach((shape) => {
if (shape.height < 0) {
shape.flipRegion?.();
}

const shapeContainer = stage.findOne((node) => {
return node.hasName(shape.id) && node.parent;
});
Expand Down Expand Up @@ -194,7 +198,7 @@ export default class TransformerComponent extends Component {
// borderStroke={"red"}
boundBoxFunc={this.constrainSizes}
anchorSize={8}
flipEnabled={false}
flipEnabled={true}
zoomedIn={this.props.item.zoomScale > 1}
onDragStart={(e) => {
const {
Expand Down Expand Up @@ -242,7 +246,7 @@ export default class TransformerComponent extends Component {
// borderStroke={"red"}
boundBoxFunc={this.constrainSizes}
anchorSize={8}
flipEnabled={false}
flipEnabled={true}
zoomedIn={this.props.item.zoomScale > 1}
onDragStart={(e) => {
const {
Expand Down
32 changes: 32 additions & 0 deletions web/libs/editor/src/regions/RectRegion.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,38 @@ const Model = types

updateImageSize() {},

/**
* Konva.js allows region to be flipped, but it saves the origin, so the result is unusual.
* When you resize it along height, it just inverts the height, no other changes.
* When you resize it along width, it inverts the height and rotates the region by 180°.
* This method fixes the region to have positive height.
* Rotation is kept intact except for the two most common cases when it stays 0:
* - when the region is flipped horizontally with no rotation, we fix the rotation back to 0.
* - when the region is flipped vertically, rotation is still 0, we just flip the height.
*/
flipRegion() {
const height = -self.height;

// the most common case, when the region is flipped horizontally with no rotation,
// for this case we are fixing rotation back to 0, that's more intuitive for the user.
if (self.rotation === 180) {
self.height = height;
self.x -= self.width;
self.rotation = 0;
} else {
// we need to invert the height and swap top-left and bottom-left corners, but with respect to rotation.
// we'll use tranform from Konva.js to not fight aspect ratio and rotation.
// transform is calculated in canvas coords, so we need to convert coords back and forth.
const transform = self.shapeRef.getAbsoluteTransform();
// bottom-left corner; it's "above" the top-left corner because of inverted height
const { x, y } = transform.point({ x: 0, y: -self.parent.internalToCanvasY(height) });

self.height = height;
self.x = self.parent.canvasToInternalX(x);
self.y = self.parent.canvasToInternalY(y);
}
},

/**
* @example
* {
Expand Down
70 changes: 70 additions & 0 deletions web/libs/editor/tests/e2e/tests/image.transformer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,76 @@ Data(shapesTable.filter(({ shapeName }) => shapes[shapeName].hasMoveToolTransfor
},
);

// Currently flipping is handled correctly only for rectangles.
Data(shapesTable.filter(({ shapeName }) => shapeName === "Rectangle")).Scenario(
"Flip region during resizing",
async ({ I, LabelStudio, AtImageView, AtOutliner, AtPanels, current }) => {
const { shapeName } = current;
const Shape = shapes[shapeName];
const AtDetailsPanel = AtPanels.usePanel(AtPanels.PANEL.DETAILS);

I.amOnPage("/");
LabelStudio.init(getParamsWithShape(shapeName, Shape.params));
AtDetailsPanel.collapsePanel();
LabelStudio.waitForObjectsReady();
AtOutliner.seeRegions(0);
await AtImageView.lookForStage();
const canvasSize = await AtImageView.getCanvasSize();
const convertToImageSize = Helpers.getSizeConvertor(canvasSize.width, canvasSize.height);
let rectangleResult;

// Draw a region in bbox {x1:50,y1:50,x2:150,y2:150}
I.pressKey(Shape.hotKey);
drawShapeByBbox(Shape, 50, 50, 100, 100, AtImageView);
AtOutliner.seeRegions(1);
AtOutliner.dontSeeIncompleteRegion();

// Select the shape
AtImageView.clickAt(100, 100);
AtOutliner.seeSelectedRegion();

// Switch to move tool to force appearance of transformer
I.pressKey("v");
const isTransformerExist = await AtImageView.isTransformerExist();

assert.strictEqual(isTransformerExist, true);

// Flip the shape horizontally
// Move the left anchor to the right further than region width, effectively flipping it and reducing width to 50px
AtImageView.drawByDrag(50, 100, 150, 0);
// Check resulting sizes
rectangleResult = await LabelStudio.serialize();
const exceptedResult = Shape.byBBox(150, 50, 50, 100).result;

Asserts.deepEqualWithTolerance(rectangleResult[0].value, convertToImageSize(exceptedResult));

// new center of the region
const center = [150 + 25, 50 + 50];

// Rotate the shape by 45 degrees, rotation handle is 50px above the top anchor
// we move the rotation handle to the right to rotate the shape by 45 degrees
AtImageView.drawByDrag(center[0], 0, center[1], 0);

rectangleResult = await LabelStudio.serialize();
Asserts.deepEqualWithTolerance(rectangleResult[0].value.rotation, 45);

// Flip the shape and keep the width;
// we have to move the rotation handle to the left and down twice the width of the region,
// with respect to 45° rotation
const shift = (50 * 2) / Math.SQRT2;
AtImageView.drawByDrag(center[0] - 25 / Math.SQRT2, center[1] - 25 / Math.SQRT2, shift, shift);

const rotatedResult = {
...convertToImageSize(Shape.byBBox(center[0] + 25 / Math.SQRT2, center[1] + 125 / Math.SQRT2, 50, 100).result),
rotation: 180 + 45,
};

rectangleResult = await LabelStudio.serialize();
// flipping is not very precise, so we have to increase the tolerance
Asserts.deepEqualWithTolerance(rectangleResult[0].value, rotatedResult, 0);
},
);

Data(shapesTable.filter(({ shapeName }) => shapes[shapeName].hasMoveToolTransformer)).Scenario(
"Resizing a single region with zoom",
async ({ I, LabelStudio, AtImageView, AtOutliner, AtPanels, Regions, current }) => {
Expand Down
Loading