Skip to content

feat(ui): Inpaint Mask Tools #8165

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

Draft
wants to merge 27 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a309d04
Add invert mask functionality to inpainting control layer
cursoragent Jul 1, 2025
220d77e
Refactor mask inversion logic with improved object type conversion
cursoragent Jul 1, 2025
425119d
Fix Invert Mask option.
hipsterusername Jul 1, 2025
f4a73ca
remove erroneous console warning
hipsterusername Jul 1, 2025
cd47395
Add AdjustBBox
hipsterusername Jul 1, 2025
9155a53
Add Hotkeys
hipsterusername Jul 1, 2025
f9a49e2
Fix bugs. Extract into transform utilities
hipsterusername Jul 1, 2025
2952988
lints
hipsterusername Jul 1, 2025
312e21a
Revert "lints"
hipsterusername Jul 1, 2025
d4bd43b
Revert "Fix bugs. Extract into transform utilities"
hipsterusername Jul 1, 2025
42161a5
Actual fixes.
hipsterusername Jul 1, 2025
3915d7f
lints
hipsterusername Jul 1, 2025
f2e8f84
Add invert mask functionality to inpainting control layer
cursoragent Jul 1, 2025
4af7aee
Refactor mask inversion logic with improved object type conversion
cursoragent Jul 1, 2025
dd0b398
Fix Invert Mask option.
hipsterusername Jul 1, 2025
0e65983
remove erroneous console warning
hipsterusername Jul 1, 2025
898de90
Add AdjustBBox
hipsterusername Jul 1, 2025
5958d08
Add Hotkeys
hipsterusername Jul 1, 2025
10ec64e
Fix bugs. Extract into transform utilities
hipsterusername Jul 1, 2025
6b0a022
lints
hipsterusername Jul 1, 2025
f713435
Revert "lints"
hipsterusername Jul 1, 2025
3619d37
Revert "Fix bugs. Extract into transform utilities"
hipsterusername Jul 1, 2025
953b9c7
Actual fixes.
hipsterusername Jul 1, 2025
46b91a0
lints
hipsterusername Jul 1, 2025
c72e1fd
Merge branch 'Inpaint-mask-tools' of https://github.com/invoke-ai/Inv…
hipsterusername Jul 1, 2025
dbad295
fix tsc issues.
hipsterusername Jul 2, 2025
42c15ac
Prettier
hipsterusername Jul 2, 2025
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
10 changes: 10 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,14 @@
"toggleNonRasterLayers": {
"title": "Toggle Non-Raster Layers",
"desc": "Show or hide all non-raster layer categories (Control Layers, Inpaint Masks, Regional Guidance)."
},
"invertMask": {
"title": "Invert Mask",
"desc": "Invert the selected inpaint mask. Only works when an inpaint mask is selected and has objects."
},
"adjustBbox": {
"title": "Adjust Bbox to Masks",
"desc": "Adjust the bounding box to fit all visible inpaint masks with padding."
}
},
"workflows": {
Expand Down Expand Up @@ -2074,6 +2082,8 @@
"uploadOrDragAnImage": "Drag an image from the gallery or <UploadButton>upload an image</UploadButton>.",
"imageNoise": "Image Noise",
"denoiseLimit": "Denoise Limit",
"adjustBboxToMasks": "Adjust Bbox to Masks",
"invertMask": "Invert Mask",
"warnings": {
"problemsFound": "Problems found",
"unsupportedModel": "layer not supported for selected base model",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScro
import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton';
import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
import { InpaintMaskAdjustBboxButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton';
import { RasterLayerExportPSDButton } from 'features/controlLayers/components/RasterLayer/RasterLayerExportPSDButton';
import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover';
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
Expand Down Expand Up @@ -165,6 +166,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI

<Spacer />
</Flex>
{type === 'inpaint_mask' && <InpaintMaskAdjustBboxButton />}
<CanvasEntityMergeVisibleButton type={type} />
<CanvasEntityTypeIsHiddenToggle type={type} />
{type === 'raster_layer' && <RasterLayerExportPSDButton />}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { Rect } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCropBold } from 'react-icons/pi';

export const InpaintMaskAdjustBboxButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const canvasSlice = useAppSelector(selectCanvasSlice);
const maskBlur = useAppSelector(selectMaskBlur);
const inpaintMasks = canvasSlice.inpaintMasks.entities;

// Calculate the bounding box that contains all inpaint masks
const calculateMaskBbox = useCallback((): Rect | null => {
if (inpaintMasks.length === 0) {
return null;
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const mask of inpaintMasks) {
if (!mask.isEnabled || mask.objects.length === 0) {
continue;
}
for (const obj of mask.objects) {
let objMinX = 0;
let objMinY = 0;
let objMaxX = 0;
let objMaxY = 0;
if (obj.type === 'rect') {
objMinX = mask.position.x + obj.rect.x;
objMinY = mask.position.y + obj.rect.y;
objMaxX = objMinX + obj.rect.width;
objMaxY = objMinY + obj.rect.height;
} else if (
obj.type === 'brush_line' ||
obj.type === 'brush_line_with_pressure' ||
obj.type === 'eraser_line' ||
obj.type === 'eraser_line_with_pressure'
) {
for (let i = 0; i < obj.points.length; i += 2) {
const x = mask.position.x + (obj.points[i] ?? 0);
const y = mask.position.y + (obj.points[i + 1] ?? 0);
if (i === 0) {
objMinX = objMaxX = x;
objMinY = objMaxY = y;
} else {
objMinX = Math.min(objMinX, x);
objMinY = Math.min(objMinY, y);
objMaxX = Math.max(objMaxX, x);
objMaxY = Math.max(objMaxY, y);
}
}
const strokeRadius = (obj.strokeWidth ?? 50) / 2;
objMinX -= strokeRadius;
objMinY -= strokeRadius;
objMaxX += strokeRadius;
objMaxY += strokeRadius;
} else if (obj.type === 'image') {
objMinX = mask.position.x;
objMinY = mask.position.y;
objMaxX = objMinX + obj.image.width;
objMaxY = objMinY + obj.image.height;
}
minX = Math.min(minX, objMinX);
minY = Math.min(minY, objMinY);
maxX = Math.max(maxX, objMaxX);
maxY = Math.max(maxY, objMaxY);
}
}
if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
return null;
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}, [inpaintMasks]);

const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]);
const handleAdjustBbox = useCallback(() => {
if (!maskBbox) {
return;
}
const padding = maskBlur + 8;
const adjustedBbox: Rect = {
x: maskBbox.x - padding,
y: maskBbox.y - padding,
width: maskBbox.width + padding * 2,
height: maskBbox.height + padding * 2,
};
dispatch(bboxChangedFromCanvas(adjustedBbox));
}, [dispatch, maskBbox, maskBlur]);

const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0);
if (!hasValidMasks) {
return null;
}

return (
<Tooltip label={t('controlLayers.adjustBboxToMasks')}>
<IconButton
aria-label={t('controlLayers.adjustBboxToMasks')}
icon={<PiCropBold />}
size="sm"
variant="ghost"
onClick={handleAdjustBbox}
/>
</Tooltip>
);
});

InpaintMaskAdjustBboxButton.displayName = 'InpaintMaskAdjustBboxButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Button, Flex, Icon, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type {
CanvasBrushLineState,
CanvasBrushLineWithPressureState,
CanvasEraserLineState,
CanvasEraserLineWithPressureState,
CanvasImageState,
CanvasRectState,
Rect,
} from 'features/controlLayers/store/types';
import { maskObjectsToBitmap } from 'features/controlLayers/util/bitmapToMaskObjects';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCropBold } from 'react-icons/pi';

export const InpaintMaskBboxAdjuster = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const canvasSlice = useAppSelector(selectCanvasSlice);
const maskBlur = useAppSelector(selectMaskBlur);

const inpaintMasks = canvasSlice.inpaintMasks.entities;
const bboxRect = canvasSlice.bbox.rect;

// Calculate the bounding box that contains all inpaint masks
const calculateMaskBbox = useCallback((): Rect | null => {
if (inpaintMasks.length === 0) {
return null;
}

const canvasWidth = bboxRect.width;
const canvasHeight = bboxRect.height;

const allObjects: (
| CanvasBrushLineState
| CanvasBrushLineWithPressureState
| CanvasEraserLineState
| CanvasEraserLineWithPressureState
| CanvasRectState
| CanvasImageState
)[] = [];
for (const mask of inpaintMasks) {
if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) {
continue;
}

for (const obj of mask.objects) {
if (obj.type === 'rect') {
const adjustedObj = {
...obj,
rect: {
...obj.rect,
x: obj.rect.x + mask.position.x - bboxRect.x,
y: obj.rect.y + mask.position.y - bboxRect.y,
},
};
allObjects.push(adjustedObj);
} else if (
obj.type === 'brush_line' ||
obj.type === 'brush_line_with_pressure' ||
obj.type === 'eraser_line' ||
obj.type === 'eraser_line_with_pressure'
) {
const adjustedPoints: number[] = [];
for (let i = 0; i < obj.points.length; i += 2) {
adjustedPoints.push((obj.points[i] ?? 0) + mask.position.x - bboxRect.x);
adjustedPoints.push((obj.points[i + 1] ?? 0) + mask.position.y - bboxRect.y);
}
const adjustedObj = {
...obj,
points: adjustedPoints,
};
allObjects.push(adjustedObj);
} else if (obj.type === 'image') {
// For image objects, we need to handle them differently since they don't have rect or points
continue;
}
}
}

if (allObjects.length === 0) {
return null;
}

// Render the consolidated mask to a bitmap
const bitmap = maskObjectsToBitmap(allObjects, canvasWidth, canvasHeight);
const { width, height, data } = bitmap;

let maskMinX = width;
let maskMinY = height;
let maskMaxX = 0;
let maskMaxY = 0;

for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4;
const alpha = data[pixelIndex + 3] ?? 0;

if (alpha > 0) {
maskMinX = Math.min(maskMinX, x);
maskMinY = Math.min(maskMinY, y);
maskMaxX = Math.max(maskMaxX, x);
maskMaxY = Math.max(maskMaxY, y);
}
}
}

if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) {
return null;
}

maskMinX = Math.max(0, maskMinX);
maskMinY = Math.max(0, maskMinY);
maskMaxX = Math.min(width - 1, maskMaxX);
maskMaxY = Math.min(height - 1, maskMaxY);

return {
x: bboxRect.x + maskMinX,
y: bboxRect.y + maskMinY,
width: maskMaxX - maskMinX + 1,
height: maskMaxY - maskMinY + 1,
};
}, [inpaintMasks, bboxRect]);

const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]);

const handleAdjustBbox = useCallback(() => {
if (!maskBbox) {
return;
}

// Add padding based on maskblur setting + 8px
const padding = maskBlur + 8;
const adjustedBbox: Rect = {
x: maskBbox.x - padding,
y: maskBbox.y - padding,
width: maskBbox.width + padding * 2,
height: maskBbox.height + padding * 2,
};

dispatch(bboxChangedFromCanvas(adjustedBbox));
}, [dispatch, maskBbox, maskBlur]);

const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0);
if (!hasValidMasks) {
return null;
}

return (
<Flex w="full" ps={2} pe={2} pb={1} gap={2}>
<Button
size="sm"
variant="ghost"
leftIcon={<Icon as={PiCropBold} boxSize={3} />}
onClick={handleAdjustBbox}
flex={1}
justifyContent="flex-start"
h={6}
fontSize="xs"
>
<Text fontSize="xs" fontWeight="medium">
{t('controlLayers.adjustBboxToMasks')}
</Text>
</Button>
</Flex>
);
});

InpaintMaskBboxAdjuster.displayName = 'InpaintMaskBboxAdjuster';
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask';
// import { InpaintMaskBboxAdjuster } from 'features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/component
import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers';
import { InpaintMaskMenuItemsAdjustBbox } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox';
import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu';
import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu';
import { InpaintMaskMenuItemsInvert } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert';
import { memo } from 'react';

export const InpaintMaskMenuItems = memo(() => {
Expand All @@ -21,6 +23,8 @@ export const InpaintMaskMenuItems = memo(() => {
<CanvasEntityMenuItemsDelete asIcon />
</IconMenuItemGroup>
<MenuDivider />
<InpaintMaskMenuItemsInvert />
<InpaintMaskMenuItemsAdjustBbox />
<InpaintMaskMenuItemsAddModifiers />
<MenuDivider />
<CanvasEntityMenuItemsTransform />
Expand Down
Loading