Skip to content

Commit cd47395

Browse files
Add AdjustBBox
1 parent f4a73ca commit cd47395

File tree

7 files changed

+382
-0
lines changed

7 files changed

+382
-0
lines changed

invokeai/frontend/web/public/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,6 +2074,7 @@
20742074
"uploadOrDragAnImage": "Drag an image from the gallery or <UploadButton>upload an image</UploadButton>.",
20752075
"imageNoise": "Image Noise",
20762076
"denoiseLimit": "Denoise Limit",
2077+
"adjustBboxToMasks": "Adjust Bbox to Masks",
20772078
"invertMask": "Invert Mask",
20782079
"warnings": {
20792080
"problemsFound": "Problems found",

invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScro
1010
import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
1111
import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton';
1212
import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
13+
import { InpaintMaskAdjustBboxButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton';
1314
import { RasterLayerExportPSDButton } from 'features/controlLayers/components/RasterLayer/RasterLayerExportPSDButton';
1415
import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover';
1516
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
@@ -165,6 +166,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI
165166

166167
<Spacer />
167168
</Flex>
169+
{type === 'inpaint_mask' && <InpaintMaskAdjustBboxButton />}
168170
<CanvasEntityMergeVisibleButton type={type} />
169171
<CanvasEntityTypeIsHiddenToggle type={type} />
170172
{type === 'raster_layer' && <RasterLayerExportPSDButton />}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
2+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3+
import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
4+
import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
5+
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
6+
import type { Rect } from 'features/controlLayers/store/types';
7+
import { memo, useCallback, useMemo } from 'react';
8+
import { useTranslation } from 'react-i18next';
9+
import { PiCropBold } from 'react-icons/pi';
10+
11+
export const InpaintMaskAdjustBboxButton = memo(() => {
12+
const { t } = useTranslation();
13+
const dispatch = useAppDispatch();
14+
const canvasSlice = useAppSelector(selectCanvasSlice);
15+
const maskBlur = useAppSelector(selectMaskBlur);
16+
const inpaintMasks = canvasSlice.inpaintMasks.entities;
17+
18+
// Calculate the bounding box that contains all inpaint masks
19+
const calculateMaskBbox = useCallback((): Rect | null => {
20+
if (inpaintMasks.length === 0) {
21+
return null;
22+
}
23+
let minX = Infinity;
24+
let minY = Infinity;
25+
let maxX = -Infinity;
26+
let maxY = -Infinity;
27+
for (const mask of inpaintMasks) {
28+
if (!mask.isEnabled || mask.objects.length === 0) {
29+
continue;
30+
}
31+
for (const obj of mask.objects) {
32+
let objMinX = 0;
33+
let objMinY = 0;
34+
let objMaxX = 0;
35+
let objMaxY = 0;
36+
if (obj.type === 'rect') {
37+
objMinX = mask.position.x + obj.rect.x;
38+
objMinY = mask.position.y + obj.rect.y;
39+
objMaxX = objMinX + obj.rect.width;
40+
objMaxY = objMinY + obj.rect.height;
41+
} else if (
42+
obj.type === 'brush_line' ||
43+
obj.type === 'brush_line_with_pressure' ||
44+
obj.type === 'eraser_line' ||
45+
obj.type === 'eraser_line_with_pressure'
46+
) {
47+
for (let i = 0; i < obj.points.length; i += 2) {
48+
const x = mask.position.x + (obj.points[i] ?? 0);
49+
const y = mask.position.y + (obj.points[i + 1] ?? 0);
50+
if (i === 0) {
51+
objMinX = objMaxX = x;
52+
objMinY = objMaxY = y;
53+
} else {
54+
objMinX = Math.min(objMinX, x);
55+
objMinY = Math.min(objMinY, y);
56+
objMaxX = Math.max(objMaxX, x);
57+
objMaxY = Math.max(objMaxY, y);
58+
}
59+
}
60+
const strokeRadius = (obj.strokeWidth ?? 50) / 2;
61+
objMinX -= strokeRadius;
62+
objMinY -= strokeRadius;
63+
objMaxX += strokeRadius;
64+
objMaxY += strokeRadius;
65+
} else if (obj.type === 'image') {
66+
objMinX = mask.position.x;
67+
objMinY = mask.position.y;
68+
objMaxX = objMinX + obj.image.width;
69+
objMaxY = objMinY + obj.image.height;
70+
}
71+
minX = Math.min(minX, objMinX);
72+
minY = Math.min(minY, objMinY);
73+
maxX = Math.max(maxX, objMaxX);
74+
maxY = Math.max(maxY, objMaxY);
75+
}
76+
}
77+
if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
78+
return null;
79+
}
80+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
81+
}, [inpaintMasks]);
82+
83+
const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]);
84+
const handleAdjustBbox = useCallback(() => {
85+
if (!maskBbox) {
86+
return;
87+
}
88+
const padding = maskBlur + 8;
89+
const adjustedBbox: Rect = {
90+
x: maskBbox.x - padding,
91+
y: maskBbox.y - padding,
92+
width: maskBbox.width + padding * 2,
93+
height: maskBbox.height + padding * 2,
94+
};
95+
dispatch(bboxChangedFromCanvas(adjustedBbox));
96+
}, [dispatch, maskBbox, maskBlur]);
97+
98+
const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0);
99+
if (!hasValidMasks) {
100+
return null;
101+
}
102+
103+
return (
104+
<Tooltip label={t('controlLayers.adjustBboxToMasks')}>
105+
<IconButton
106+
aria-label={t('controlLayers.adjustBboxToMasks')}
107+
icon={<PiCropBold />}
108+
size="sm"
109+
variant="ghost"
110+
onClick={handleAdjustBbox}
111+
/>
112+
</Tooltip>
113+
);
114+
});
115+
116+
InpaintMaskAdjustBboxButton.displayName = 'InpaintMaskAdjustBboxButton';
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Button, Flex, Icon, Text } from '@invoke-ai/ui-library';
2+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3+
import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
4+
import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
5+
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
6+
import type { Rect } from 'features/controlLayers/store/types';
7+
import { memo, useCallback, useMemo } from 'react';
8+
import { useTranslation } from 'react-i18next';
9+
import { PiCropBold } from 'react-icons/pi';
10+
11+
export const InpaintMaskBboxAdjuster = memo(() => {
12+
const { t } = useTranslation();
13+
const dispatch = useAppDispatch();
14+
const canvasSlice = useAppSelector(selectCanvasSlice);
15+
const maskBlur = useAppSelector(selectMaskBlur);
16+
17+
// Get all inpaint mask entities
18+
const inpaintMasks = canvasSlice.inpaintMasks.entities;
19+
20+
// Calculate the bounding box that contains all inpaint masks
21+
const calculateMaskBbox = useCallback((): Rect | null => {
22+
if (inpaintMasks.length === 0) {
23+
return null;
24+
}
25+
26+
let minX = Infinity;
27+
let minY = Infinity;
28+
let maxX = -Infinity;
29+
let maxY = -Infinity;
30+
31+
// Iterate through all inpaint masks to find the overall bounds
32+
for (const mask of inpaintMasks) {
33+
if (!mask.isEnabled || mask.objects.length === 0) {
34+
continue;
35+
}
36+
37+
// Calculate bounds for this mask's objects
38+
for (const obj of mask.objects) {
39+
let objMinX = 0;
40+
let objMinY = 0;
41+
let objMaxX = 0;
42+
let objMaxY = 0;
43+
44+
if (obj.type === 'rect') {
45+
objMinX = mask.position.x + obj.rect.x;
46+
objMinY = mask.position.y + obj.rect.y;
47+
objMaxX = objMinX + obj.rect.width;
48+
objMaxY = objMinY + obj.rect.height;
49+
} else if (
50+
obj.type === 'brush_line' ||
51+
obj.type === 'brush_line_with_pressure' ||
52+
obj.type === 'eraser_line' ||
53+
obj.type === 'eraser_line_with_pressure'
54+
) {
55+
// For lines, find the min/max points
56+
for (let i = 0; i < obj.points.length; i += 2) {
57+
const x = mask.position.x + (obj.points[i] ?? 0);
58+
const y = mask.position.y + (obj.points[i + 1] ?? 0);
59+
60+
if (i === 0) {
61+
objMinX = objMaxX = x;
62+
objMinY = objMaxY = y;
63+
} else {
64+
objMinX = Math.min(objMinX, x);
65+
objMinY = Math.min(objMinY, y);
66+
objMaxX = Math.max(objMaxX, x);
67+
objMaxY = Math.max(objMaxY, y);
68+
}
69+
}
70+
// Add stroke width to account for line thickness
71+
const strokeRadius = (obj.strokeWidth ?? 50) / 2;
72+
objMinX -= strokeRadius;
73+
objMinY -= strokeRadius;
74+
objMaxX += strokeRadius;
75+
objMaxY += strokeRadius;
76+
} else if (obj.type === 'image') {
77+
// Image objects are positioned at the entity's position
78+
objMinX = mask.position.x;
79+
objMinY = mask.position.y;
80+
objMaxX = objMinX + obj.image.width;
81+
objMaxY = objMinY + obj.image.height;
82+
}
83+
84+
// Update overall bounds
85+
minX = Math.min(minX, objMinX);
86+
minY = Math.min(minY, objMinY);
87+
maxX = Math.max(maxX, objMaxX);
88+
maxY = Math.max(maxY, objMaxY);
89+
}
90+
}
91+
92+
// If no valid bounds found, return null
93+
if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
94+
return null;
95+
}
96+
97+
return {
98+
x: minX,
99+
y: minY,
100+
width: maxX - minX,
101+
height: maxY - minY,
102+
};
103+
}, [inpaintMasks]);
104+
105+
const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]);
106+
107+
const handleAdjustBbox = useCallback(() => {
108+
if (!maskBbox) {
109+
return;
110+
}
111+
112+
// Add padding based on maskblur setting + 8px
113+
const padding = maskBlur + 8;
114+
const adjustedBbox: Rect = {
115+
x: maskBbox.x - padding,
116+
y: maskBbox.y - padding,
117+
width: maskBbox.width + padding * 2,
118+
height: maskBbox.height + padding * 2,
119+
};
120+
121+
dispatch(bboxChangedFromCanvas(adjustedBbox));
122+
}, [dispatch, maskBbox, maskBlur]);
123+
124+
// Only show if there are enabled inpaint masks with objects
125+
const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0);
126+
if (!hasValidMasks) {
127+
return null;
128+
}
129+
130+
return (
131+
<Flex w="full" ps={2} pe={2} pb={1}>
132+
<Button
133+
size="sm"
134+
variant="ghost"
135+
leftIcon={<Icon as={PiCropBold} boxSize={3} />}
136+
onClick={handleAdjustBbox}
137+
w="full"
138+
justifyContent="flex-start"
139+
h={6}
140+
fontSize="xs"
141+
>
142+
<Text fontSize="xs" fontWeight="medium">
143+
{t('controlLayers.adjustBboxToMasks')}
144+
</Text>
145+
</Button>
146+
</Flex>
147+
);
148+
});
149+
150+
InpaintMaskBboxAdjuster.displayName = 'InpaintMaskBboxAdjuster';

invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
33
import { useAppSelector } from 'app/store/storeHooks';
44
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
55
import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask';
6+
// import { InpaintMaskBboxAdjuster } from 'features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster';
67
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
78
import { getEntityIdentifier } from 'features/controlLayers/store/types';
89
import { memo } from 'react';

invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/component
88
import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave';
99
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
1010
import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers';
11+
import { InpaintMaskMenuItemsAdjustBbox } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox';
1112
import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu';
1213
import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu';
1314
import { InpaintMaskMenuItemsInvert } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert';
@@ -23,6 +24,7 @@ export const InpaintMaskMenuItems = memo(() => {
2324
</IconMenuItemGroup>
2425
<MenuDivider />
2526
<InpaintMaskMenuItemsInvert />
27+
<InpaintMaskMenuItemsAdjustBbox />
2628
<InpaintMaskMenuItemsAddModifiers />
2729
<MenuDivider />
2830
<CanvasEntityMenuItemsTransform />

0 commit comments

Comments
 (0)