diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 5f8e2070c37..2964917c3ab 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -599,6 +599,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": { @@ -2093,6 +2101,8 @@ "uploadOrDragAnImage": "Drag an image from the gallery or upload an image.", "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", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx index 4e1f5c1dc4a..3410f0cce70 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx @@ -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'; @@ -165,6 +166,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI + {type === 'inpaint_mask' && } {type === 'raster_layer' && } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx new file mode 100644 index 00000000000..05f4df1adc4 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx @@ -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 ( + + } + size="sm" + variant="ghost" + onClick={handleAdjustBbox} + /> + + ); +}); + +InpaintMaskAdjustBboxButton.displayName = 'InpaintMaskAdjustBboxButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx new file mode 100644 index 00000000000..887543c2dce --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -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 ( + + + + ); +}); + +InpaintMaskBboxAdjuster.displayName = 'InpaintMaskBboxAdjuster'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx index 8bbb49a9865..cb8cc4eeca6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx @@ -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'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx index 0d0289adf87..60aba9819e8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx @@ -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(() => { @@ -21,6 +23,8 @@ export const InpaintMaskMenuItems = memo(() => { + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx new file mode 100644 index 00000000000..2750e209c4a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx @@ -0,0 +1,110 @@ +import { MenuItem } 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 InpaintMaskMenuItemsAdjustBbox = 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 ( + }> + {t('controlLayers.adjustBboxToMasks')} + + ); +}); + +InpaintMaskMenuItemsAdjustBbox.displayName = 'InpaintMaskMenuItemsAdjustBbox'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx new file mode 100644 index 00000000000..933f26fa1d5 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx @@ -0,0 +1,36 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSelectionInverseBold } from 'react-icons/pi'; + +export const InpaintMaskMenuItemsInvert = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('inpaint_mask'); + const canvasSlice = useAppSelector(selectCanvasSlice); + + const handleInvertMask = useCallback(() => { + dispatch(inpaintMaskInverted({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + // Only show if there are objects to invert and we have a valid bounding box + const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === entityIdentifier.id); + const hasObjects = Boolean(entity?.objects && entity.objects.length > 0); + const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0; + + if (!hasObjects || !hasBbox) { + return null; + } + + return ( + }> + {t('controlLayers.invertMask')} + + ); +}); + +InpaintMaskMenuItemsInvert.displayName = 'InpaintMaskMenuItemsInvert'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 453d13b3c50..062428343b4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -9,9 +9,11 @@ import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/ import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton'; import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale'; import { CanvasToolbarUndoButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarUndoButton'; +import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey'; import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey'; import { useCanvasFilterHotkey } from 'features/controlLayers/hooks/useCanvasFilterHotkey'; +import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey'; import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey'; import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey'; @@ -28,6 +30,8 @@ export const CanvasToolbar = memo(() => { useCanvasTransformHotkey(); useCanvasFilterHotkey(); useCanvasToggleNonRasterLayersHotkey(); + useCanvasInvertMaskHotkey(); + useCanvasAdjustBboxHotkey(); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts new file mode 100644 index 00000000000..4878f7850c1 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -0,0 +1,167 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +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 { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { useCallback, useMemo } from 'react'; + +export const useCanvasAdjustBboxHotkey = () => { + useAssertSingleton('useCanvasAdjustBboxHotkey'); + const dispatch = useAppDispatch(); + const canvasSlice = useAppSelector(selectCanvasSlice); + const maskBlur = useAppSelector(selectMaskBlur); + const isBusy = useCanvasIsBusy(); + 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; + } + + // Use the current bbox as the reference container + const canvasWidth = bboxRect.width; + const canvasHeight = bboxRect.height; + + // Collect all mask objects and adjust their positions relative to the bbox + const allObjects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; + for (const mask of inpaintMasks) { + if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { + continue; + } + + // Adjust object positions relative to the bbox (not the entity position) + 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 + // We'll skip them for now as they're not commonly used in masks + 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; + + // Find the actual bounds of the rendered mask + 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 this pixel has any opacity, it's part of the mask + if (alpha > 0) { + maskMinX = Math.min(maskMinX, x); + maskMinY = Math.min(maskMinY, y); + maskMaxX = Math.max(maskMaxX, x); + maskMaxY = Math.max(maskMaxY, y); + } + } + } + + // If no mask pixels found, return null + if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) { + return null; + } + + // Clamp the mask bounds to the bbox boundaries + maskMinX = Math.max(0, maskMinX); + maskMinY = Math.max(0, maskMinY); + maskMaxX = Math.min(width - 1, maskMaxX); + maskMaxY = Math.min(height - 1, maskMaxY); + + // Convert back to world coordinates relative to the bbox + return { + x: bboxRect.x + maskMinX, + y: bboxRect.y + maskMinY, + width: maskMaxX - maskMinX + 1, + height: maskMaxY - maskMinY + 1, + }; + }, [inpaintMasks, bboxRect]); + + const handleAdjustBbox = useCallback(() => { + const maskBbox = calculateMaskBbox(); + 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, calculateMaskBbox, maskBlur]); + + const isAdjustBboxAllowed = useMemo(() => { + const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects && mask.objects.length > 0); + return hasValidMasks; + }, [inpaintMasks]); + + useRegisteredHotkeys({ + id: 'adjustBbox', + category: 'canvas', + callback: handleAdjustBbox, + options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true }, + dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox], + }); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts new file mode 100644 index 00000000000..01698062e29 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts @@ -0,0 +1,55 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { useCallback, useMemo } from 'react'; + +export const useCanvasInvertMaskHotkey = () => { + useAssertSingleton('useCanvasInvertMaskHotkey'); + const dispatch = useAppDispatch(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const canvasSlice = useAppSelector(selectCanvasSlice); + const isBusy = useCanvasIsBusy(); + + const handleInvertMask = useCallback(() => { + if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') { + return; + } + + // Check if the selected entity has objects and there's a valid bounding box + const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id); + const hasObjects = entity?.objects && entity.objects.length > 0; + const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0; + + if (!hasObjects || !hasBbox) { + return; + } + + dispatch( + inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as CanvasEntityIdentifier<'inpaint_mask'> }) + ); + }, [dispatch, selectedEntityIdentifier, canvasSlice]); + + const isInvertMaskAllowed = useMemo(() => { + if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') { + return false; + } + + const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id); + const hasObjects = entity?.objects && entity.objects.length > 0; + const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0; + + return hasObjects && hasBbox; + }, [selectedEntityIdentifier, canvasSlice]); + + useRegisteredHotkeys({ + id: 'invertMask', + category: 'canvas', + callback: handleInvertMask, + options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true }, + dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask], + }); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index e6af138b1d8..cc2e4dc5c2b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -15,10 +15,15 @@ import { selectRegionalGuidanceReferenceImage, } from 'features/controlLayers/store/selectors'; import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, CanvasEntityStateFromType, CanvasEntityType, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, CanvasInpaintMaskState, CanvasMetadata, + CanvasRectState, ControlLoRAConfig, EntityMovedByPayload, FillStyle, @@ -996,6 +1001,154 @@ export const canvasSlice = createSlice({ entity.denoiseLimit = undefined; } }, + inpaintMaskInverted: (state, action: PayloadAction>) => { + const { entityIdentifier } = action.payload; + const entity = selectEntity(state, entityIdentifier); + if (!entity || entity.type !== 'inpaint_mask') { + return; + } + + // If there are no objects to invert, do nothing + if (entity.objects.length === 0) { + return; + } + + // Get the current bbox dimensions for the mask + const bboxRect = state.bbox.rect; + + // Create a clip rect that constrains all objects to the bbox + const bboxClip = { + x: bboxRect.x - entity.position.x, + y: bboxRect.y - entity.position.y, + width: bboxRect.width, + height: bboxRect.height, + }; + + // Check if we already have a full rectangle covering the entire bbox + const hasFullRect = entity.objects.some((obj) => { + if (obj.type !== 'rect') { + return false; + } + const { x, y, width, height } = obj.rect; + return ( + Math.abs(x - bboxClip.x) < 1 && + Math.abs(y - bboxClip.y) < 1 && + Math.abs(width - bboxClip.width) < 1 && + Math.abs(height - bboxClip.height) < 1 + ); + }); + + // Convert existing brush lines to eraser lines to "punch holes" in the full rectangle + const invertedObjects: ( + | CanvasRectState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + )[] = []; + + // Only add the fill rectangle if we don't already have one + if (!hasFullRect) { + const fillRect: CanvasRectState = { + id: getPrefixedId('rect'), + type: 'rect', + rect: { + x: bboxRect.x - entity.position.x, + y: bboxRect.y - entity.position.y, + width: bboxRect.width, + height: bboxRect.height, + }, + color: { r: 255, g: 255, b: 255, a: 1 }, + }; + + invertedObjects.push(fillRect); + } + + for (const obj of entity.objects) { + if (obj.type === 'brush_line') { + // Convert brush line to eraser line, constrained to bbox + const eraserLine: CanvasEraserLineState = { + id: getPrefixedId('eraser_line'), + type: 'eraser_line', + strokeWidth: obj.strokeWidth, + points: obj.points, + clip: bboxClip, // Ensure the eraser line is clipped to the bbox + }; + invertedObjects.push(eraserLine); + } else if (obj.type === 'brush_line_with_pressure') { + // Convert brush line with pressure to eraser line with pressure, constrained to bbox + const eraserLine: CanvasEraserLineWithPressureState = { + id: getPrefixedId('eraser_line'), + type: 'eraser_line_with_pressure', + strokeWidth: obj.strokeWidth, + points: obj.points, + clip: bboxClip, // Ensure the eraser line is clipped to the bbox + }; + invertedObjects.push(eraserLine); + } else if (obj.type === 'rect') { + // Convert rectangle to eraser rectangle, constrained to bbox + const { x, y, width, height } = obj.rect; + + // Clamp the rectangle to the bbox boundaries + const clampedX = Math.max(x, bboxClip.x); + const clampedY = Math.max(y, bboxClip.y); + const clampedWidth = Math.min(width, bboxClip.x + bboxClip.width - clampedX); + const clampedHeight = Math.min(height, bboxClip.y + bboxClip.height - clampedY); + + // Only add the eraser rectangle if it has valid dimensions + if (clampedWidth > 0 && clampedHeight > 0) { + const points = [ + clampedX, + clampedY, + clampedX + clampedWidth, + clampedY, + clampedX + clampedWidth, + clampedY + clampedHeight, + clampedX, + clampedY + clampedHeight, + clampedX, + clampedY, // Close the rectangle + ]; + + const eraserLine: CanvasEraserLineState = { + id: getPrefixedId('eraser_line'), + type: 'eraser_line', + points, + strokeWidth: Math.max(clampedWidth, clampedHeight) / 2, // Use a stroke width that covers the rectangle + clip: bboxClip, // Ensure the eraser line is clipped to the bbox + }; + invertedObjects.push(eraserLine); + } + } else if (obj.type === 'eraser_line') { + // Convert eraser line to brush line, constrained to bbox + const brushLine: CanvasBrushLineState = { + id: getPrefixedId('brush_line'), + type: 'brush_line', + strokeWidth: obj.strokeWidth, + points: obj.points, + clip: bboxClip, // Ensure the brush line is clipped to the bbox + color: { r: 255, g: 255, b: 255, a: 1 }, + }; + invertedObjects.push(brushLine); + } else if (obj.type === 'eraser_line_with_pressure') { + // Convert eraser line with pressure to brush line with pressure, constrained to bbox + const brushLine: CanvasBrushLineWithPressureState = { + id: getPrefixedId('brush_line'), + type: 'brush_line_with_pressure', + strokeWidth: obj.strokeWidth, + points: obj.points, + clip: bboxClip, // Ensure the brush line is clipped to the bbox + color: { r: 255, g: 255, b: 255, a: 1 }, + }; + invertedObjects.push(brushLine); + } + // Note: Image objects are not handled in this simple approach + // They would need to be processed through the compositor + } + + // Replace the entity's objects with the inverted mask objects + entity.objects = invertedObjects; + }, //#region BBox bboxScaledWidthChanged: (state, action: PayloadAction) => { const gridSize = getGridSize(state.bbox.modelBase); @@ -1675,6 +1828,7 @@ export const { inpaintMaskDenoiseLimitAdded, inpaintMaskDenoiseLimitChanged, inpaintMaskDenoiseLimitDeleted, + inpaintMaskInverted, // inpaintMaskRecalled, } = canvasSlice.actions; diff --git a/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts b/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts new file mode 100644 index 00000000000..a58bb20ef6a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts @@ -0,0 +1,243 @@ +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasImageState, + CanvasRectState, + RgbaColor, +} from 'features/controlLayers/store/types'; + +/** + * Options for converting bitmap to mask objects + */ +export interface BitmapToMaskOptions { + /** + * The threshold for considering a pixel as masked (0-255) + * Pixels with alpha >= threshold are considered masked + */ + threshold?: number; + /** + * The color to use for brush lines + */ + brushColor?: RgbaColor; + /** + * The stroke width for brush lines + */ + strokeWidth?: number; + /** + * Whether to use pressure-sensitive lines + */ + usePressure?: boolean; + /** + * The pressure value to use for pressure-sensitive lines (0-1) + */ + pressure?: number; +} + +/** + * Default options for bitmap to mask conversion + */ +const DEFAULT_OPTIONS: Required = { + threshold: 128, + brushColor: { r: 255, g: 255, b: 255, a: 1 }, + strokeWidth: 50, + usePressure: false, + pressure: 1.0, +}; + +/** + * Converts a bitmap (ImageData) to mask objects (brush lines, eraser lines, rectangles) + * + * @param imageData - The bitmap data to convert + * @param options - Conversion options + * @returns Array of mask objects + */ +export function bitmapToMaskObjects( + imageData: ImageData, + options: BitmapToMaskOptions = {} +): ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState +)[] { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const { width, height, data } = imageData; + const objects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; + + // For now, we'll create a simple approach that creates rectangles for masked areas + // This can be enhanced later to create more sophisticated brush/eraser line patterns + + // Scan the image data to find masked areas + for (let y = 0; y < height; y += opts.strokeWidth) { + for (let x = 0; x < width; x += opts.strokeWidth) { + // Check if this pixel is masked + const pixelIndex = (y * width + x) * 4; + const alpha = data[pixelIndex + 3] ?? 0; + + if (alpha >= opts.threshold) { + // Create a rectangle for this masked area + const rect: CanvasRectState = { + id: getPrefixedId('rect'), + type: 'rect', + rect: { + x, + y, + width: Math.min(opts.strokeWidth, width - x), + height: Math.min(opts.strokeWidth, height - y), + }, + color: opts.brushColor, + }; + objects.push(rect); + } + } + } + + return objects; +} + +/** + * Inverts a bitmap by flipping the alpha channel + * + * @param imageData - The bitmap data to invert + * @returns New ImageData with inverted alpha channel + */ +export function invertBitmap(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const newImageData = new ImageData(width, height); + const newData = newImageData.data; + + for (let i = 0; i < data.length; i += 4) { + // Copy RGB values + newData[i] = data[i] ?? 0; // R + newData[i + 1] = data[i + 1] ?? 0; // G + newData[i + 2] = data[i + 2] ?? 0; // B + // Invert alpha + newData[i + 3] = 255 - (data[i + 3] ?? 0); // A + } + + return newImageData; +} + +/** + * Converts mask objects to a bitmap (ImageData) + * This is a simplified version that creates a basic bitmap representation + * + * @param objects - Array of mask objects + * @param width - Width of the output bitmap + * @param height - Height of the output bitmap + * @returns ImageData representing the mask + */ +export function maskObjectsToBitmap( + objects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[], + width: number, + height: number +): ImageData { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + // Clear canvas with transparent background + ctx.clearRect(0, 0, width, height); + + // Draw each object + for (const obj of objects) { + if (obj.type === 'rect') { + ctx.fillStyle = `rgba(${obj.color.r}, ${obj.color.g}, ${obj.color.b}, ${obj.color.a})`; + ctx.fillRect(obj.rect.x, obj.rect.y, obj.rect.width, obj.rect.height); + } else if (obj.type === 'brush_line' || obj.type === 'brush_line_with_pressure') { + ctx.strokeStyle = `rgba(${obj.color.r}, ${obj.color.g}, ${obj.color.b}, ${obj.color.a})`; + ctx.lineWidth = obj.strokeWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Draw the line + ctx.beginPath(); + for (let i = 0; i < obj.points.length; i += 2) { + const x = obj.points[i] ?? 0; + const y = obj.points[i + 1] ?? 0; + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } else if (obj.type === 'eraser_line' || obj.type === 'eraser_line_with_pressure') { + // Eraser lines use destination-out composite operation + ctx.globalCompositeOperation = 'destination-out'; + ctx.strokeStyle = 'rgba(0, 0, 0, 1)'; + ctx.lineWidth = obj.strokeWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Draw the line + ctx.beginPath(); + for (let i = 0; i < obj.points.length; i += 2) { + const x = obj.points[i] ?? 0; + const y = obj.points[i + 1] ?? 0; + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + + // Reset composite operation + ctx.globalCompositeOperation = 'source-over'; + } else if (obj.type === 'image') { + // For image objects, we need to load the image and draw it + // This is a simplified approach - in a real implementation, you'd want to handle image loading properly + const img = new Image(); + img.crossOrigin = 'anonymous'; + + // Create a temporary canvas to draw the image + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if (tempCtx) { + tempCanvas.width = obj.image.width; + tempCanvas.height = obj.image.height; + + // Draw the image to the temp canvas + if ('image_name' in obj.image) { + // This would need proper image loading from the server + // For now, we'll skip image objects in the mask conversion + // Image objects are not supported in this simple mask conversion + } else { + // Data URL image + img.src = obj.image.dataURL; + tempCtx.drawImage(img, 0, 0); + + // Draw the temp canvas to the main canvas + ctx.drawImage(tempCanvas, 0, 0); + } + } + } + } + + return ctx.getImageData(0, 0, width, height); +} diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts index aaf3c6a26fc..437b939402d 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -123,6 +123,8 @@ export const useHotkeyData = (): HotkeysData => { addHotkey('canvas', 'applySegmentAnything', ['enter']); addHotkey('canvas', 'cancelSegmentAnything', ['esc']); addHotkey('canvas', 'toggleNonRasterLayers', ['shift+h']); + addHotkey('canvas', 'invertMask', ['shift+v']); + addHotkey('canvas', 'adjustBbox', ['shift+b']); // Workflows addHotkey('workflows', 'addNode', ['shift+a', 'space']);