Added outlines to interactor mask previews to make mask boundaries easier to distinguish (#10966)

### Motivation and context

Interactor preview masks are rendered in white by default, making them
difficult to distinguish on bright or grayscale images.

This change adds a color picker to the interactor menu and passes the
selected color through the canvas interaction payload. The selected
color is applied to temporary mask and polygon previews without
affecting the color of the final annotation.

Existing callers that do not provide a color continue to use white as a
fallback.

Closes #6111.

### How has this been tested?

The following checks were run:

- ESLint for the modified `cvat-ui` and `cvat-canvas` files.
- Full TypeScript type checking with `yarn type-check`.

Both checks passed.

### Checklist

- [x] I submit my changes into the `develop` branch
- [x] I have created a changelog fragment
- [ ] ~~I have updated the documentation accordingly~~
- [ ] I have added tests to cover my changes
- [x] I have linked related issues (see [GitHub docs](

https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue-using-a-keyword))

### License

- [x] I submit *my code changes* under the same [MIT License](
https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the
project.
  Feel free to contact the maintainers if that's a concern.
This commit is contained in:
Gleb Potapov
2026-08-03 12:42:28 +04:00
committed by GitHub
parent c5a5c67c4c
commit bf8843567c
5 changed files with 54 additions and 12 deletions
@@ -0,0 +1,4 @@
### Added
- Added outlines to interactor mask previews to make mask boundaries easier to distinguish.
(<https://github.com/cvat-ai/cvat/pull/10966>)
+4
View File
@@ -250,6 +250,10 @@ g.cvat_canvas_shape_occluded {
@extend .cvat_canvas_shape;
}
.cvat_canvas_interact_mask_outline {
pointer-events: none;
}
.cvat_interaction_delete_button {
opacity: 0.25;
@@ -138,6 +138,7 @@ export interface InteractionData {
shapes: {
shapeType: string;
points: ArrayLike<number>;
maskOutlines?: ArrayLike<number>[];
}[];
};
settings?: {
@@ -62,6 +62,7 @@ export class InteractionHandlerImpl implements InteractionHandler {
private allPrompts: SupportedShapes[];
private deletionButtons: Map<SupportedShapes, SVG.G>;
private intermediateShapes: (SVG.Image | SVG.Polygon)[];
private intermediateMaskOutlines: SVG.Polygon[];
private onInteraction: (interactionResult: InteractionResult[], finished?: boolean) => void;
private onMessage: (messages: CanvasHint[] | null, topic: string) => void;
private geometry: Geometry;
@@ -101,6 +102,7 @@ export class InteractionHandlerImpl implements InteractionHandler {
this.pointPrompts = [];
this.allPrompts = [];
this.intermediateShapes = [];
this.intermediateMaskOutlines = [];
this.deletionButtons = new Map();
this.effectiveStrokeWidth = consts.BASE_STROKE_WIDTH / this.geometry.scale;
this.effectivePointSize = (configuration.controlPointsSize ?? consts.BASE_POINT_SIZE) / this.geometry.scale;
@@ -150,7 +152,9 @@ export class InteractionHandlerImpl implements InteractionHandler {
}
shape.remove();
});
this.intermediateMaskOutlines.forEach((outline) => outline.remove());
this.intermediateShapes = [];
this.intermediateMaskOutlines = [];
}
private release(): void {
@@ -278,7 +282,9 @@ export class InteractionHandlerImpl implements InteractionHandler {
this.clearIntermediateShapes();
for (const shape of shapes) {
const { points, shapeType } = shape;
const {
points, shapeType, maskOutlines,
} = shape;
if (shapeType === 'polygon') {
const isInvalidShape = points.length < 3 * 2;
const polygon = this.container
@@ -309,6 +315,23 @@ export class InteractionHandlerImpl implements InteractionHandler {
this.container.node.prepend(image.node);
this.intermediateShapes.push(image);
let insertionPoint = image.node;
for (const outline of maskOutlines ?? []) {
if (outline.length >= 3 * 2) {
const outlinePoints = stringifyPoints(translateToCanvas(this.geometry.offset, outline));
const strokeWidth = consts.BASE_STROKE_WIDTH / this.geometry.scale;
const maskOutline = this.container
.polygon(outlinePoints)
.fill('none')
.stroke({ color: '#000000', width: strokeWidth })
.addClass('cvat_canvas_interact_mask_outline');
insertionPoint.after(maskOutline.node);
insertionPoint = maskOutline.node;
this.intermediateMaskOutlines.push(maskOutline);
}
}
imageDataToDataURL(
imageBitmap,
right - left + 1,
@@ -554,6 +577,10 @@ export class InteractionHandlerImpl implements InteractionHandler {
this.intermediateShapes.forEach((shape) => {
shape.fill({ opacity: this.effectiveShapeOpacity });
});
this.intermediateMaskOutlines.forEach((outline) => {
outline.stroke({ width: this.effectiveStrokeWidth });
});
}
public interact(interactData: InteractionData): void {
@@ -244,6 +244,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
latestResponse: {
rle: Int32Array;
points: [number, number][];
contours: [number, number][][];
approximatedPoints: [number, number][];
confidence: number;
}[];
@@ -544,18 +545,20 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
if (item.type !== ShapeType.MASK) continue;
const points = Int32Array.from(item.points);
const polygonPoints = this.receivePointsFromMask(points);
const contours = this.receiveContoursFromMask(points);
const polygonPoints = this.receivePointsFromMask(contours);
if (polygonPoints.length < 3) {
continue;
}
const approximated = this.approximateResponsePoints(polygonPoints!);
const approximated = this.approximateResponsePoints(polygonPoints);
const confidenceAttr = item.attributes.find((attr) => attr.spec_id === 0);
const confidence = confidenceAttr ? +confidenceAttr.value : 1;
showConfidenceControl = showConfidenceControl || !!confidenceAttr;
latestResponse.push({
rle: points,
points: polygonPoints,
contours,
approximatedPoints: approximated,
confidence,
});
@@ -783,9 +786,10 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
const shapesToBeDrawn = this.interaction.latestResponse
.filter(({ confidence }) => typeof confidence !== 'number' || confidence >= thresholdValue)
.filter(({ approximatedPoints }) => !convertMasksToPolygons || approximatedPoints.length >= 3)
.map(({ rle, approximatedPoints }) => ({
.map(({ rle, contours, approximatedPoints }) => ({
shapeType: convertMasksToPolygons ? ShapeType.POLYGON : ShapeType.MASK,
points: convertMasksToPolygons ? approximatedPoints.flat() : rle,
maskOutlines: contours.map((contour) => contour.flat()),
}));
canvasInstance.interact({
@@ -1142,7 +1146,15 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
}
}
private receivePointsFromMask(mask: Int32Array): [number, number][] {
private receivePointsFromMask(contours: [number, number][][]): [number, number][] {
if (contours.length) {
return contours[0].map<[number, number]>((val) => [val[0], val[1]]);
}
return [];
}
private receiveContoursFromMask(mask: Int32Array): [number, number][][] {
if (!openCVWrapper.isInitialized) {
throw new Error('OpenCV was not initialized');
}
@@ -1152,12 +1164,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
return [];
}
const polygons = openCVWrapper.getContoursFromStateSync({ points: mask, shapeType: ShapeType.MASK });
if (polygons.length) {
return polygons[0].map<[number, number]>((val) => [val[0], val[1]]);
}
return [];
return openCVWrapper.getContoursFromStateSync({ points: mask, shapeType: ShapeType.MASK });
}
private approximateResponsePoints(points: [number, number][]): [number, number][] {
@@ -1353,7 +1360,6 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
/>
<Text>Convert masks to polygons</Text>
</div>
{renderStartWithBox && (
<div>
<Switch