Skip to content

Commit 89a6c05

Browse files
authored
Add edit bounding box tool & friends (#4593)
1 parent 5b49f2c commit 89a6c05

12 files changed

+705
-4
lines changed

ui/eslint.config.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,7 @@ export default [
4545
'header/header': [
4646
'warn',
4747
'line',
48-
[
49-
' Copyright (C) 2025 Intel Corporation',
50-
' SPDX-License-Identifier: Apache-2.0',
51-
],
48+
[' Copyright (C) 2025 Intel Corporation', ' SPDX-License-Identifier: Apache-2.0'],
5249
],
5350
},
5451
},
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { Annotation as AnnotationInterface } from '../shapes/interfaces';
5+
import { ShapeFactory } from './shape-factory.component';
6+
7+
interface AnnotationProps {
8+
annotation: AnnotationInterface;
9+
maskId?: string;
10+
}
11+
12+
export const Annotation = ({ maskId, annotation }: AnnotationProps): JSX.Element => {
13+
const { id, color, shape } = annotation;
14+
15+
return (
16+
<>
17+
<g
18+
mask={maskId}
19+
id={`canvas-annotation-${id}`}
20+
strokeLinecap={'round'}
21+
{...(color !== undefined
22+
? {
23+
fill: color,
24+
stroke: color,
25+
strokeOpacity: 'var(--annotation-border-opacity)',
26+
}
27+
: {})}
28+
>
29+
<ShapeFactory shape={shape} styles={{}} ariaLabel={''} />
30+
</g>
31+
</>
32+
);
33+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { Shape } from '../shapes/interfaces';
5+
import { Polygon } from '../shapes/polygon.component';
6+
import { Rectangle } from '../shapes/rectangle.component';
7+
8+
interface ShapeFactoryProps {
9+
shape: Shape;
10+
styles: React.SVGProps<SVGPolygonElement & SVGRectElement>;
11+
ariaLabel: string;
12+
}
13+
export const ShapeFactory = ({ shape, styles, ariaLabel }: ShapeFactoryProps) => {
14+
if (shape.shapeType === 'rect') {
15+
return <Rectangle rect={shape} styles={styles} ariaLabel={ariaLabel} />;
16+
} else {
17+
return <Polygon polygon={shape} styles={styles} ariaLabel={ariaLabel} />;
18+
}
19+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { CSSProperties, PointerEvent, ReactNode, useState } from 'react';
5+
6+
import { isFunction } from 'lodash-es';
7+
8+
import { Point } from '../shapes/interfaces';
9+
import { isLeftButton } from './utils';
10+
11+
interface AnchorProps {
12+
children: ReactNode;
13+
x: number;
14+
y: number;
15+
size: number;
16+
zoom: number;
17+
label: string;
18+
fill?: string;
19+
cursor?: CSSProperties['cursor'];
20+
onStart?: () => void;
21+
onComplete: () => void;
22+
moveAnchorTo: (x: number, y: number) => void;
23+
}
24+
25+
export const Anchor = ({
26+
x,
27+
y,
28+
fill = 'white',
29+
size,
30+
zoom,
31+
label,
32+
cursor,
33+
children,
34+
onStart,
35+
moveAnchorTo,
36+
onComplete,
37+
}: AnchorProps): JSX.Element => {
38+
const [dragFrom, setDragFrom] = useState<Point | null>(null);
39+
40+
const onPointerDown = (event: PointerEvent) => {
41+
event.preventDefault();
42+
43+
if (event.pointerType === 'touch' || !isLeftButton(event)) {
44+
return;
45+
}
46+
47+
event.currentTarget.setPointerCapture(event.pointerId);
48+
49+
const mouse = { x: Math.round(event.clientX / zoom), y: Math.round(event.clientY / zoom) };
50+
51+
isFunction(onStart) && onStart();
52+
setDragFrom({ x: mouse.x - x, y: mouse.y - y });
53+
};
54+
55+
const onPointerMove = (event: PointerEvent) => {
56+
event.preventDefault();
57+
58+
if (dragFrom === null) {
59+
return;
60+
}
61+
62+
const mouse = { x: Math.round(event.clientX / zoom), y: Math.round(event.clientY / zoom) };
63+
64+
moveAnchorTo(mouse.x - dragFrom.x, mouse.y - dragFrom.y);
65+
};
66+
67+
const onPointerUp = (event: PointerEvent) => {
68+
if (event.pointerType === 'touch' || !isLeftButton(event)) {
69+
return;
70+
}
71+
72+
event.preventDefault();
73+
event.currentTarget.releasePointerCapture(event.pointerId);
74+
75+
setDragFrom(null);
76+
onComplete();
77+
};
78+
79+
// We render both a visual anchor and an invisible anchor that has a larger
80+
// clicking area than the visible one
81+
const interactiveAnchorProps = {
82+
style: { cursor },
83+
fill: dragFrom === null ? fill : 'var(--energy-blue)',
84+
'aria-label': label,
85+
onPointerUp,
86+
onPointerMove,
87+
onPointerDown,
88+
};
89+
90+
return (
91+
<g>
92+
{children}
93+
<rect
94+
x={x - size}
95+
y={y - size}
96+
cx={x}
97+
cy={y}
98+
width={size * 2}
99+
height={size * 2}
100+
fillOpacity={0}
101+
{...interactiveAnchorProps}
102+
/>
103+
</g>
104+
);
105+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useEffect, useState } from 'react';
5+
6+
import { Annotation, Point, RegionOfInterest } from '../shapes/interfaces';
7+
import { ResizeAnchor } from './resize-anchor.component';
8+
import { TranslateShape } from './translate-shape.component';
9+
import { getBoundingBoxInRoi, getBoundingBoxResizePoints, getClampedBoundingBox } from './utils';
10+
11+
import classes from './bounding-box-tool.module.scss';
12+
13+
interface EditBoundingBoxProps {
14+
annotation: Annotation & { shape: { shapeType: 'rect' } };
15+
roi: RegionOfInterest;
16+
image: ImageData;
17+
zoom: number;
18+
updateAnnotation: (annotation: Annotation) => void;
19+
}
20+
21+
const ANCHOR_SIZE = 8;
22+
23+
export const EditBoundingBox = ({
24+
annotation,
25+
roi,
26+
image,
27+
zoom,
28+
updateAnnotation,
29+
}: EditBoundingBoxProps): JSX.Element => {
30+
const [shape, setShape] = useState(annotation.shape);
31+
32+
useEffect(() => setShape(annotation.shape), [annotation.shape]);
33+
34+
const onComplete = () => {
35+
updateAnnotation({ ...annotation, shape });
36+
};
37+
38+
const translate = (point: Point) => {
39+
const newBoundingBox = getClampedBoundingBox(point, shape, roi);
40+
41+
setShape({ ...shape, ...newBoundingBox });
42+
};
43+
44+
const anchorPoints = getBoundingBoxResizePoints({
45+
gap: (2 * ANCHOR_SIZE) / zoom,
46+
boundingBox: shape,
47+
onResized: (boundingBox) => {
48+
setShape({ ...shape, ...getBoundingBoxInRoi(boundingBox, roi) });
49+
},
50+
});
51+
52+
return (
53+
<>
54+
<svg
55+
width={image.width}
56+
height={image.height}
57+
className={classes.disabledLayer}
58+
id={`translate-bounding-box-${annotation.id}`}
59+
>
60+
<TranslateShape
61+
zoom={zoom}
62+
annotation={{ ...annotation, shape }}
63+
translateShape={translate}
64+
onComplete={onComplete}
65+
/>
66+
</svg>
67+
68+
<svg
69+
width={image.width}
70+
height={image.height}
71+
className={classes.disabledLayer}
72+
aria-label={`Edit bounding box points ${annotation.id}`}
73+
id={`edit-bounding-box-points-${annotation.id}`}
74+
>
75+
<g style={{ pointerEvents: 'auto' }}>
76+
{anchorPoints.map((anchor) => {
77+
return <ResizeAnchor key={anchor.label} zoom={zoom} onComplete={onComplete} {...anchor} />;
78+
})}
79+
</g>
80+
</svg>
81+
</>
82+
);
83+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.disabledLayer {
2+
position: absolute;
3+
top: 0;
4+
left: 0;
5+
bottom: 0;
6+
right: 0;
7+
pointer-events: none;
8+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { CSSProperties, ReactNode } from 'react';
5+
6+
import { Anchor as InternalAnchor } from './anchor.component';
7+
8+
export const ANCHOR_SIZE = 8;
9+
10+
enum ResizeAnchorType {
11+
SQUARE,
12+
CIRCLE,
13+
CUSTOM,
14+
}
15+
16+
interface ResizeAnchorProps {
17+
zoom: number;
18+
x: number;
19+
y: number;
20+
moveAnchorTo: (x: number, y: number) => void;
21+
cursor?: CSSProperties['cursor'];
22+
label: string;
23+
onStart?: () => void;
24+
onComplete: () => void;
25+
type?: ResizeAnchorType;
26+
fill?: string;
27+
stroke?: string;
28+
strokeWidth?: number;
29+
Anchor?: ReactNode;
30+
}
31+
32+
interface DefaultCircleProps {
33+
zoom: number;
34+
x: number;
35+
y: number;
36+
fill?: string;
37+
stroke?: string;
38+
strokeWidth?: number;
39+
}
40+
41+
const DefaultCircle = ({ x, y, zoom, fill, stroke, strokeWidth = 1 }: DefaultCircleProps) => {
42+
return <circle cx={x} cy={y} r={ANCHOR_SIZE / zoom / 2} {...{ fill, stroke, strokeWidth: strokeWidth / zoom }} />;
43+
};
44+
45+
export const ResizeAnchor = ({
46+
x,
47+
y,
48+
zoom,
49+
onStart,
50+
onComplete,
51+
moveAnchorTo,
52+
label,
53+
fill = 'white',
54+
type = ResizeAnchorType.SQUARE,
55+
cursor = 'all-scroll',
56+
stroke = 'var(--energy-blue)',
57+
strokeWidth = 1,
58+
Anchor = <DefaultCircle x={x} y={y} zoom={zoom} fill={fill} stroke={stroke} strokeWidth={strokeWidth} />,
59+
}: ResizeAnchorProps): JSX.Element => {
60+
const size = ANCHOR_SIZE / zoom;
61+
62+
// We render both a visual anchor and an invisible anchor that has a larger
63+
// clicking area than the visible one
64+
const visualAnchorProps = {
65+
fill,
66+
stroke,
67+
strokeWidth: strokeWidth / zoom,
68+
};
69+
70+
return (
71+
<InternalAnchor
72+
size={size}
73+
label={label}
74+
x={x}
75+
y={y}
76+
zoom={zoom}
77+
fill={fill}
78+
cursor={cursor ? cursor : 'default'}
79+
onStart={onStart}
80+
onComplete={onComplete}
81+
moveAnchorTo={moveAnchorTo}
82+
>
83+
{type === ResizeAnchorType.SQUARE ? (
84+
<g fillOpacity={1.0} transform-origin={`${x}px ${y}px`}>
85+
<rect x={x - size / 2} y={y - size / 2} width={size} height={size} {...visualAnchorProps} />
86+
</g>
87+
) : (
88+
Anchor
89+
)}
90+
</InternalAnchor>
91+
);
92+
};

0 commit comments

Comments
 (0)