-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(ui): inpaint mask extraction to raster layer #8662
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
Closed
Closed
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
0c43d26
feat: extract masked raster content
DustyShoe 04327ab
docs: clarify mask extraction workflow
DustyShoe 35fb145
fix: guard mask extraction data access
DustyShoe c4bade1
fix: satisfy strict indexed access in mask extraction
DustyShoe c5b24da
fix: assert mask extraction arrays
DustyShoe 6c0fd3e
Fix mask extraction implementation
DustyShoe 557b57f
Fix alpha masking to remove fringe
DustyShoe 862e330
Clarify layer insertion comment for mask extraction
DustyShoe 831ef2d
Merge branch 'main' into feature-mask-extraction
DustyShoe 7596bca
Merge branch 'main' into feature-mask-extraction
DustyShoe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
160 changes: 160 additions & 0 deletions
160
...c/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import { MenuItem } from '@invoke-ai/ui-library'; | ||
| import { logger } from 'app/logging/logger'; | ||
| import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; | ||
| import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; | ||
| import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; | ||
| import { canvasToImageData, getPrefixedId } from 'features/controlLayers/konva/util'; | ||
| import type { CanvasImageState, Rect } from 'features/controlLayers/store/types'; | ||
| import { toast } from 'features/toast/toast'; | ||
| import { memo, useCallback } from 'react'; | ||
| import { PiSelectionBackgroundBold } from 'react-icons/pi'; | ||
| import { serializeError } from 'serialize-error'; | ||
|
|
||
| const log = logger('canvas'); | ||
|
|
||
| export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => { | ||
| const canvasManager = useCanvasManager(); | ||
| const entityIdentifier = useEntityIdentifierContext('inpaint_mask'); | ||
| const isBusy = useCanvasIsBusy(); | ||
|
|
||
| const onExtract = useCallback(() => { | ||
| // The active inpaint mask layer is required to build the mask used for extraction. | ||
| const maskAdapter = canvasManager.getAdapter(entityIdentifier); | ||
| if (!maskAdapter) { | ||
| log.error({ entityIdentifier }, 'Inpaint mask adapter not found when extracting masked area'); | ||
| toast({ status: 'error', title: 'Unable to extract masked area' }); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| // Get the canvas bounding box so the raster extraction respects the visible canvas bounds. | ||
| const bbox = canvasManager.stateApi.getBbox(); | ||
| const rect: Rect = { | ||
| x: Math.floor(bbox.rect.x), | ||
| y: Math.floor(bbox.rect.y), | ||
| width: Math.floor(bbox.rect.width), | ||
| height: Math.floor(bbox.rect.height), | ||
| }; | ||
|
|
||
| // Abort when the canvas is effectively empty—no pixels to extract. | ||
| if (rect.width <= 0 || rect.height <= 0) { | ||
| toast({ status: 'warning', title: 'Canvas is empty' }); | ||
| return; | ||
| } | ||
|
|
||
| // Gather the visible raster layer adapters so we can composite them into a single bitmap. | ||
| const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer'); | ||
|
|
||
| let compositeImageData: ImageData; | ||
| if (rasterAdapters.length === 0) { | ||
| // No visible raster layers—create a transparent buffer that matches the canvas bounds. | ||
| compositeImageData = new ImageData(rect.width, rect.height); | ||
| } else { | ||
| // Render the visible raster layers into an offscreen canvas restricted to the canvas bounds. | ||
| const compositeCanvas = canvasManager.compositor.getCompositeCanvas(rasterAdapters, rect); | ||
| compositeImageData = canvasToImageData(compositeCanvas); | ||
| } | ||
|
|
||
| // Render the inpaint mask layer into a canvas so we have the alpha data that defines the mask. | ||
| const maskCanvas = maskAdapter.getCanvas(rect); | ||
| const maskImageData = canvasToImageData(maskCanvas); | ||
|
|
||
| // Ensure both composite and mask image data exist and agree on dimensions. | ||
| if ( | ||
| !compositeImageData || | ||
| !maskImageData || | ||
| maskImageData.width !== compositeImageData.width || | ||
| maskImageData.height !== compositeImageData.height | ||
| ) { | ||
| log.error( | ||
| { | ||
| hasComposite: !!compositeImageData, | ||
| hasMask: !!maskImageData, | ||
| maskDimensions: maskImageData ? { width: maskImageData.width, height: maskImageData.height } : null, | ||
| compositeDimensions: compositeImageData | ||
| ? { width: compositeImageData.width, height: compositeImageData.height } | ||
| : null, | ||
| }, | ||
| 'Mask and composite dimensions did not match or image data missing when extracting masked area' | ||
| ); | ||
| toast({ status: 'error', title: 'Unable to extract masked area' }); | ||
| return; | ||
| } | ||
|
|
||
| // At this point both image buffers are guaranteed to be valid and dimensionally aligned. | ||
| const compositeArray = compositeImageData.data; | ||
| const maskArray = maskImageData.data; | ||
|
|
||
| // Prepare output pixel buffer. | ||
| const outputArray = new Uint8ClampedArray(compositeArray.length); | ||
|
|
||
| // Apply the mask alpha only to the alpha channel. | ||
| // Do NOT multiply RGB by maskAlpha to avoid dark fringe artifacts around mask edges. | ||
| for (let i = 0; i < compositeArray.length; i += 4) { | ||
| // Read original composite pixel, defaulting to 0 to satisfy strict indexed access rules. | ||
| const r = compositeArray[i] ?? 0; | ||
| const g = compositeArray[i + 1] ?? 0; | ||
| const b = compositeArray[i + 2] ?? 0; | ||
| const a = compositeArray[i + 3] ?? 0; | ||
|
|
||
| // Extract mask alpha (0..255 → 0..1). | ||
| const maskAlpha = (maskArray[i + 3] ?? 0) / 255; | ||
|
|
||
| // Preserve original RGB values. | ||
| outputArray[i] = r; | ||
| outputArray[i + 1] = g; | ||
| outputArray[i + 2] = b; | ||
|
|
||
| // Mask only the alpha channel to avoid halo artifacts. | ||
| outputArray[i + 3] = Math.round(a * maskAlpha); | ||
| } | ||
DustyShoe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Package the masked pixels into an ImageData and draw them to an offscreen canvas. | ||
| const outputImageData = new ImageData(outputArray, rect.width, rect.height); | ||
| const outputCanvas = document.createElement('canvas'); | ||
| outputCanvas.width = rect.width; | ||
| outputCanvas.height = rect.height; | ||
| const outputContext = outputCanvas.getContext('2d'); | ||
|
|
||
| if (!outputContext) { | ||
| throw new Error('Failed to create canvas context for masked extraction'); | ||
| } | ||
|
|
||
| outputContext.putImageData(outputImageData, 0, 0); | ||
|
|
||
| // Convert the offscreen canvas into an Invoke canvas image state for insertion into the layer stack. | ||
| const imageState: CanvasImageState = { | ||
| id: getPrefixedId('image'), | ||
| type: 'image', | ||
| image: { | ||
| dataURL: outputCanvas.toDataURL('image/png'), | ||
| width: rect.width, | ||
| height: rect.height, | ||
| }, | ||
| }; | ||
|
|
||
| // Insert the new raster layer at the top of the raster layer stack. | ||
| const addAfter = canvasManager.stateApi.getRasterLayersState().entities.at(-1)?.id; | ||
|
|
||
| canvasManager.stateApi.addRasterLayer({ | ||
| overrides: { | ||
| objects: [imageState], | ||
| position: { x: rect.x, y: rect.y }, | ||
| }, | ||
| isSelected: true, | ||
| addAfter, | ||
| }); | ||
| } catch (error) { | ||
| log.error({ error: serializeError(error as Error) }, 'Failed to extract masked area to raster layer'); | ||
| toast({ status: 'error', title: 'Unable to extract masked area' }); | ||
| } | ||
| }, [canvasManager, entityIdentifier]); | ||
|
|
||
| return ( | ||
| <MenuItem onClick={onExtract} icon={<PiSelectionBackgroundBold />} isDisabled={isBusy}> | ||
| Extract masked area to new layer | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should use translation and get the title from en.json Also, the current title looks a bit too long in the menu. Maybe something simpler like "Extract Region" or "Create Raster Layer". |
||
| </MenuItem> | ||
| ); | ||
| }); | ||
|
|
||
| InpaintMaskMenuItemsExtractMaskedArea.displayName = 'InpaintMaskMenuItemsExtractMaskedArea'; | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From a user standpoint, it's not clear that the operation is restricted to the bounding box or why that would be the case. If I have a mask that exists exclusively outside of the bbox, then this operation returns an empty raster layer without any warnings. Perhaps we can piggyback off of the Merge Visible feature to create a new raster layer of the entire canvas, and then cut that down to match the inpaint mask dimensions and alpha.