mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
compositing in frontend
This commit is contained in:
committed by
psychedelicious
parent
5e20c9a1ca
commit
23627cf18d
@@ -1218,12 +1218,16 @@ class ApplyMaskToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
title="Add Image Noise",
|
||||
tags=["image", "noise"],
|
||||
category="image",
|
||||
version="1.0.1",
|
||||
version="1.1.0",
|
||||
)
|
||||
class ImageNoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
"""Add noise to an image"""
|
||||
|
||||
image: ImageField = InputField(description="The image to add noise to")
|
||||
mask: Optional[ImageField] = InputField(
|
||||
default=None,
|
||||
description="Optional mask determining where to apply noise (black=noise, white=no noise)"
|
||||
)
|
||||
seed: int = InputField(
|
||||
default=0,
|
||||
ge=0,
|
||||
@@ -1267,12 +1271,27 @@ class ImageNoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
noise = Image.fromarray(noise.astype(numpy.uint8), mode="RGB").resize(
|
||||
(image.width, image.height), Image.Resampling.NEAREST
|
||||
)
|
||||
|
||||
# Create a noisy version of the input image
|
||||
noisy_image = Image.blend(image.convert("RGB"), noise, self.amount).convert("RGBA")
|
||||
|
||||
# Apply mask if provided
|
||||
if self.mask is not None:
|
||||
mask_image = context.images.get_pil(self.mask.image_name, mode="L")
|
||||
|
||||
if mask_image.size != image.size:
|
||||
mask_image = mask_image.resize(image.size, Image.Resampling.LANCZOS)
|
||||
|
||||
result_image = image.copy()
|
||||
mask_image = ImageOps.invert(mask_image)
|
||||
result_image.paste(noisy_image, (0, 0), mask=mask_image)
|
||||
else:
|
||||
result_image = noisy_image
|
||||
|
||||
# Paste back the alpha channel from the original image
|
||||
result_image.putalpha(alpha)
|
||||
|
||||
# Paste back the alpha channel
|
||||
noisy_image.putalpha(alpha)
|
||||
|
||||
image_dto = context.images.save(image=noisy_image)
|
||||
image_dto = context.images.save(image=result_image)
|
||||
|
||||
return ImageOutput.build(image_dto)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { withResult, withResultAsync } from 'common/util/result';
|
||||
import { CanvasCacheModule } from 'features/controlLayers/konva/CanvasCacheModule';
|
||||
import type { CanvasEntityAdapterInpaintMask } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask';
|
||||
import type { CanvasEntityAdapter, CanvasEntityAdapterFromType } from 'features/controlLayers/konva/CanvasEntity/types';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
@@ -426,6 +427,147 @@ export class CanvasCompositorModule extends CanvasModuleBase {
|
||||
return this.mergeByEntityIdentifiers(entityIdentifiers, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and uploads a grayscale representation of the noise masks or any other attribute.
|
||||
* This produces an image with a white background where the mask is represented by dark values
|
||||
* rather than transparency.
|
||||
*
|
||||
* @param adapters The adapters for the canvas entities to composite
|
||||
* @param rect The region to include in the rasterized image
|
||||
* @param attribute The attribute to use for grayscale values (defaults to 'noiseLevel')
|
||||
* @param uploadOptions Options for uploading the image
|
||||
* @param forceUpload If true, the image is always re-uploaded, returning a new image DTO
|
||||
* @returns A promise that resolves to the image DTO
|
||||
*/
|
||||
getGrayscaleMaskCompositeImageDTO = async (
|
||||
adapters: CanvasEntityAdapterInpaintMask[],
|
||||
rect: Rect,
|
||||
attribute: 'noiseLevel' = 'noiseLevel',
|
||||
uploadOptions: SetOptional<Omit<UploadImageArg, 'file'>, 'image_category'> = { is_intermediate: true },
|
||||
forceUpload?: boolean
|
||||
): Promise<ImageDTO> => {
|
||||
assert(rect.width > 0 && rect.height > 0, 'Unable to rasterize empty rect');
|
||||
|
||||
//const entityIdentifiers = adapters.map((adapter) => adapter.entityIdentifier);
|
||||
|
||||
// Use a unique hash that includes the attribute name for caching
|
||||
const hash = this.getCompositeHash(adapters, { rect, attribute, grayscale: true });
|
||||
const cachedImageName = forceUpload ? undefined : this.manager.cache.imageNameCache.get(hash);
|
||||
|
||||
let imageDTO: ImageDTO | null = null;
|
||||
|
||||
if (cachedImageName) {
|
||||
imageDTO = await getImageDTOSafe(cachedImageName);
|
||||
if (imageDTO) {
|
||||
this.log.debug({ rect, imageName: cachedImageName, imageDTO }, 'Using cached grayscale composite image');
|
||||
return imageDTO;
|
||||
}
|
||||
this.log.warn({ rect, imageName: cachedImageName }, 'Cached grayscale image name not found, recompositing');
|
||||
}
|
||||
|
||||
// Create a white background canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
assert(ctx !== null, 'Canvas 2D context is null');
|
||||
|
||||
// Fill with white first (creates white background)
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// Apply special compositing mode
|
||||
ctx.globalCompositeOperation = 'darken';
|
||||
|
||||
// Draw each adapter's content
|
||||
for (const adapter of adapters) {
|
||||
this.log.debug({ entityIdentifier: adapter.entityIdentifier }, 'Drawing entity to grayscale composite canvas');
|
||||
|
||||
// Get the canvas from the adapter
|
||||
const adapterCanvas = adapter.getCanvas(rect);
|
||||
|
||||
// Create a temporary canvas for grayscale conversion
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = adapterCanvas.width;
|
||||
tempCanvas.height = adapterCanvas.height;
|
||||
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
assert(tempCtx !== null, 'Temp canvas 2D context is null');
|
||||
|
||||
// Draw the original adapter canvas to the temp canvas
|
||||
tempCtx.drawImage(adapterCanvas, 0, 0);
|
||||
|
||||
// Get the image data for processing
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
// Convert alpha values to grayscale based on the specified attribute
|
||||
// Get the attribute value with proper type checking
|
||||
const attributeValue = typeof adapter.state[attribute] === 'number' ? (adapter.state[attribute] as number) : 1.0; // Default to full strength if attribute is not a number
|
||||
|
||||
// Process all pixels in the image data
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
// Make sure we're accessing valid array indices
|
||||
if (i + 3 < data.length) {
|
||||
const alpha = data[i + 3]! / 255;
|
||||
|
||||
// Calculate grayscale value: white (255) for no mask, darker for stronger mask
|
||||
// Scale according to the attribute value (higher attribute = darker pixels)
|
||||
const grayValue = Math.max(0, Math.min(255, 255 - Math.round(255 * alpha * attributeValue)));
|
||||
|
||||
data[i] = grayValue; // R
|
||||
data[i + 1] = grayValue; // G
|
||||
data[i + 2] = grayValue; // B
|
||||
data[i + 3] = 255; // A (fully opaque)
|
||||
}
|
||||
}
|
||||
|
||||
// Put the processed image data back to the temp canvas
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Draw the temp canvas to the main canvas
|
||||
ctx.drawImage(tempCanvas, 0, 0);
|
||||
}
|
||||
|
||||
// Convert to blob and upload
|
||||
this.$isProcessing.set(true);
|
||||
const blobResult = await withResultAsync(() => canvasToBlob(canvas));
|
||||
this.$isProcessing.set(false);
|
||||
|
||||
if (blobResult.isErr()) {
|
||||
this.log.error(
|
||||
{ error: serializeError(blobResult.error) },
|
||||
'Failed to convert grayscale composite canvas to blob'
|
||||
);
|
||||
throw blobResult.error;
|
||||
}
|
||||
|
||||
const blob = blobResult.value;
|
||||
|
||||
if (this.manager._isDebugging) {
|
||||
previewBlob(blob, 'Grayscale Composite');
|
||||
}
|
||||
|
||||
this.$isUploading.set(true);
|
||||
const uploadResult = await withResultAsync(() =>
|
||||
uploadImage({
|
||||
file: new File([blob], 'canvas-grayscale-composite.png', { type: 'image/png' }),
|
||||
image_category: 'general',
|
||||
...uploadOptions,
|
||||
})
|
||||
);
|
||||
this.$isUploading.set(false);
|
||||
|
||||
if (uploadResult.isErr()) {
|
||||
throw uploadResult.error;
|
||||
}
|
||||
|
||||
imageDTO = uploadResult.value;
|
||||
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
|
||||
return imageDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the transparency of the composite of the give adapters.
|
||||
* @param adapters The adapters to composite
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { CanvasEntityAdapterInpaintMask } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { getEmptyRect, getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
|
||||
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
|
||||
import type { Dimensions } from 'features/controlLayers/store/types';
|
||||
import type { Dimensions, Rect } from 'features/controlLayers/store/types';
|
||||
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
|
||||
import { isMainModelWithoutUnet } from 'features/nodes/util/graph/graphBuilderUtils';
|
||||
import type {
|
||||
@@ -51,20 +52,39 @@ export const addInpaint = async ({
|
||||
const canvasSettings = selectCanvasSettingsSlice(state);
|
||||
const canvas = selectCanvasSlice(state);
|
||||
|
||||
const { bbox } = canvas;
|
||||
// Make sure bbox.rect is defined, use an empty rect if it's not
|
||||
const rect: Rect = canvas.bbox?.rect ?? getEmptyRect();
|
||||
|
||||
const rasterAdapters = manager.compositor.getVisibleAdaptersOfType('raster_layer');
|
||||
const initialImage = await manager.compositor.getCompositeImageDTO(rasterAdapters, bbox.rect, {
|
||||
const initialImage = await manager.compositor.getCompositeImageDTO(rasterAdapters, rect, {
|
||||
is_intermediate: true,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const inpaintMaskAdapters = manager.compositor.getVisibleAdaptersOfType('inpaint_mask');
|
||||
const maskImage = await manager.compositor.getCompositeImageDTO(inpaintMaskAdapters, bbox.rect, {
|
||||
const maskImage = await manager.compositor.getCompositeImageDTO(inpaintMaskAdapters, rect, {
|
||||
is_intermediate: true,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
// Get inpaint mask adapters that have noise settings
|
||||
const noiseMaskAdapters = inpaintMaskAdapters.filter((adapter) => adapter.state.noiseLevel !== null);
|
||||
|
||||
// Create a composite noise mask if we have any adapters with noise settings
|
||||
let noiseMaskImage = null;
|
||||
if (noiseMaskAdapters.length > 0) {
|
||||
// Use the grayscale mask composite method with proper typing
|
||||
noiseMaskImage = await manager.compositor.getGrayscaleMaskCompositeImageDTO(
|
||||
noiseMaskAdapters as CanvasEntityAdapterInpaintMask[],
|
||||
rect,
|
||||
'noiseLevel',
|
||||
{
|
||||
is_intermediate: true,
|
||||
silent: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const needsScaleBeforeProcessing = !isEqual(scaledSize, originalSize);
|
||||
|
||||
if (needsScaleBeforeProcessing) {
|
||||
@@ -82,6 +102,32 @@ export const addInpaint = async ({
|
||||
image: { image_name: initialImage.image_name },
|
||||
...scaledSize,
|
||||
});
|
||||
|
||||
// If we have a noise mask, apply it to the input image before i2l conversion
|
||||
if (noiseMaskImage) {
|
||||
// Resize the noise mask to match the scaled size
|
||||
const resizeNoiseMaskToScaledSize = g.addNode({
|
||||
id: getPrefixedId('resize_noise_mask_to_scaled_size'),
|
||||
type: 'img_resize',
|
||||
image: { image_name: noiseMaskImage.image_name },
|
||||
...scaledSize,
|
||||
});
|
||||
|
||||
// Add noise to the scaled image using the mask
|
||||
const noiseNode = g.addNode({
|
||||
type: 'img_noise',
|
||||
id: getPrefixedId('add_inpaint_noise'),
|
||||
noise_type: 'gaussian',
|
||||
amount: 1.0, // the mask controls the actual intensity
|
||||
noise_color: true,
|
||||
seed: Math.floor(Math.random() * 2147483647), // should this seed match the denoise latents seed?
|
||||
});
|
||||
|
||||
g.addEdge(resizeImageToScaledSize, 'image', noiseNode, 'image');
|
||||
g.addEdge(resizeNoiseMaskToScaledSize, 'image', noiseNode, 'mask');
|
||||
g.addEdge(noiseNode, 'image', i2l, 'image');
|
||||
}
|
||||
|
||||
const alphaToMask = g.addNode({
|
||||
id: getPrefixedId('alpha_to_mask'),
|
||||
type: 'tomask',
|
||||
@@ -120,8 +166,6 @@ export const addInpaint = async ({
|
||||
// Resize initial image and mask to scaled size, feed into to gradient mask
|
||||
g.addEdge(alphaToMask, 'image', resizeMaskToScaledSize, 'image');
|
||||
g.addEdge(resizeImageToScaledSize, 'image', i2l, 'image');
|
||||
g.addEdge(i2l, 'latents', denoise, 'latents');
|
||||
g.addEdge(vaeSource, 'vae', i2l, 'vae');
|
||||
|
||||
g.addEdge(vaeSource, 'vae', createGradientMask, 'vae');
|
||||
if (!isMainModelWithoutUnet(modelLoader)) {
|
||||
@@ -169,6 +213,23 @@ export const addInpaint = async ({
|
||||
...(i2lNodeType === 'i2l' ? { fp32 } : {}),
|
||||
});
|
||||
|
||||
// If we have a noise mask, apply it to the input image before i2l conversion
|
||||
if (noiseMaskImage) {
|
||||
// Add noise to the scaled image using the mask
|
||||
const noiseNode = g.addNode({
|
||||
type: 'img_noise',
|
||||
id: getPrefixedId('add_inpaint_noise'),
|
||||
image: initialImage.image_name ? { image_name: initialImage.image_name } : undefined,
|
||||
noise_type: 'gaussian',
|
||||
amount: 1.0, // the mask controls the actual intensity
|
||||
noise_color: true,
|
||||
seed: Math.floor(Math.random() * 2147483647), // should this seed match the denoise latents seed?
|
||||
mask: { image_name: noiseMaskImage.image_name },
|
||||
});
|
||||
|
||||
g.addEdge(noiseNode, 'image', i2l, 'image');
|
||||
}
|
||||
|
||||
const alphaToMask = g.addNode({
|
||||
id: getPrefixedId('alpha_to_mask'),
|
||||
type: 'tomask',
|
||||
|
||||
356
invokeai/frontend/web/src/mask_noise_steps.md
Normal file
356
invokeai/frontend/web/src/mask_noise_steps.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# Adding Image Noise Slider to InpaintMask Layer
|
||||
|
||||
This document outlines the steps required to add an "Image Noise" slider feature to the InpaintMask layer type in the Invoke AI frontend.
|
||||
|
||||
## Overview
|
||||
|
||||
The feature allows users to add and adjust an image noise level to an InpaintMask layer, similar to how Regional Guidance layers have the ability to add prompts. The implementation includes:
|
||||
|
||||
1. Extending the InpaintMask data model
|
||||
2. Creating UI components for adding and adjusting noise
|
||||
3. Adding Redux actions to handle state changes
|
||||
4. Adding a delete button to remove the noise setting
|
||||
5. Implementing conditional rendering of the add button based on current state
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Extend the InpaintMask Data Model
|
||||
|
||||
Update the `CanvasInpaintMaskState` type in `types.ts` to include a `noiseLevel` property:
|
||||
|
||||
```typescript
|
||||
const zCanvasInpaintMaskState = zCanvasEntityBase.extend({
|
||||
type: z.literal('inpaint_mask'),
|
||||
position: zCoordinate,
|
||||
fill: zFill,
|
||||
opacity: zOpacity,
|
||||
objects: z.array(zCanvasObjectState),
|
||||
noiseLevel: z.number().gte(0).lte(1).nullable().default(null),
|
||||
});
|
||||
export type CanvasInpaintMaskState = z.infer<typeof zCanvasInpaintMaskState>;
|
||||
```
|
||||
|
||||
### 2. Update the Initial State Function
|
||||
|
||||
Modify the `getInpaintMaskState` function in `util.ts` to include the new `noiseLevel` property:
|
||||
|
||||
```typescript
|
||||
export const getInpaintMaskState = (
|
||||
id: string,
|
||||
overrides?: Partial<CanvasInpaintMaskState>
|
||||
): CanvasInpaintMaskState => {
|
||||
const entityState: CanvasInpaintMaskState = {
|
||||
id,
|
||||
name: null,
|
||||
type: 'inpaint_mask',
|
||||
isEnabled: true,
|
||||
isLocked: false,
|
||||
objects: [],
|
||||
opacity: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
fill: {
|
||||
style: 'diagonal',
|
||||
color: getInpaintMaskFillColor(),
|
||||
},
|
||||
noiseLevel: null,
|
||||
};
|
||||
merge(entityState, overrides);
|
||||
return entityState;
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Add Redux Actions
|
||||
|
||||
Add new actions to the `canvasSlice.ts` file to handle adding, changing, and deleting noise:
|
||||
|
||||
```typescript
|
||||
inpaintMaskNoiseAdded: (state, action: PayloadAction<EntityIdentifierPayload<void, 'inpaint_mask'>>) => {
|
||||
const { entityIdentifier } = action.payload;
|
||||
const entity = selectEntity(state, entityIdentifier);
|
||||
if (entity && entity.type === 'inpaint_mask') {
|
||||
entity.noiseLevel = 0.5; // Default noise level
|
||||
}
|
||||
},
|
||||
inpaintMaskNoiseChanged: (state, action: PayloadAction<EntityIdentifierPayload<{ noiseLevel: number }, 'inpaint_mask'>>) => {
|
||||
const { entityIdentifier, noiseLevel } = action.payload;
|
||||
const entity = selectEntity(state, entityIdentifier);
|
||||
if (entity && entity.type === 'inpaint_mask') {
|
||||
entity.noiseLevel = noiseLevel;
|
||||
}
|
||||
},
|
||||
inpaintMaskNoiseDeleted: (state, action: PayloadAction<EntityIdentifierPayload<void, 'inpaint_mask'>>) => {
|
||||
const { entityIdentifier } = action.payload;
|
||||
const entity = selectEntity(state, entityIdentifier);
|
||||
if (entity && entity.type === 'inpaint_mask') {
|
||||
entity.noiseLevel = null;
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
Make sure to export these actions from the canvasSlice:
|
||||
|
||||
```typescript
|
||||
export const {
|
||||
// ... other exports
|
||||
inpaintMaskNoiseAdded,
|
||||
inpaintMaskNoiseChanged,
|
||||
inpaintMaskNoiseDeleted,
|
||||
} = canvasSlice.actions;
|
||||
```
|
||||
|
||||
### 4. Create a Hook for Adding Noise
|
||||
|
||||
Add a new hook in `addLayerHooks.ts` to handle adding noise to an InpaintMask:
|
||||
|
||||
```typescript
|
||||
export const useAddInpaintMaskNoise = (entityIdentifier: CanvasEntityIdentifier<'inpaint_mask'>) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const func = useCallback(() => {
|
||||
dispatch(inpaintMaskNoiseAdded({ entityIdentifier }));
|
||||
}, [dispatch, entityIdentifier]);
|
||||
|
||||
return func;
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Create UI Components
|
||||
|
||||
#### 5.1. Create a Button Component for Adding Noise
|
||||
|
||||
Create a new file `InpaintMaskAddNoiseButton.tsx` for the button that adds noise:
|
||||
|
||||
```typescript
|
||||
import { Button, Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { useAddInpaintMaskNoise } from 'features/controlLayers/hooks/addLayerHooks';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
|
||||
export const InpaintMaskAddNoiseButton = () => {
|
||||
const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
|
||||
const { t } = useTranslation();
|
||||
const addInpaintMaskNoise = useAddInpaintMaskNoise(entityIdentifier);
|
||||
|
||||
return (
|
||||
<Flex w="full" p={2} justifyContent="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<PiPlusBold />}
|
||||
onClick={addInpaintMaskNoise}
|
||||
>
|
||||
{t('controlLayers.imageNoise')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### 5.2. Create a Delete Button Component
|
||||
|
||||
Create a new file `InpaintMaskDeleteNoiseButton.tsx` for the X button that deletes noise:
|
||||
|
||||
```typescript
|
||||
import type { IconButtonProps } from '@invoke-ai/ui-library';
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
|
||||
type Props = Omit<IconButtonProps, 'aria-label'> & {
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export const InpaintMaskDeleteNoiseButton = memo(({ onDelete, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={t('common.delete')}
|
||||
variant="link"
|
||||
aria-label={t('common.delete')}
|
||||
icon={<PiXBold />}
|
||||
onClick={onDelete}
|
||||
flexGrow={0}
|
||||
size="sm"
|
||||
p={0}
|
||||
colorScheme="error"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
InpaintMaskDeleteNoiseButton.displayName = 'InpaintMaskDeleteNoiseButton';
|
||||
```
|
||||
|
||||
#### 5.3. Create a Slider Component with Delete Button
|
||||
|
||||
Create a new file `InpaintMaskNoiseSlider.tsx` for adjusting the noise level and including the delete button:
|
||||
|
||||
```typescript
|
||||
import { Flex, Slider, SliderFilledTrack, SliderThumb, SliderTrack, Text } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { inpaintMaskNoiseChanged, inpaintMaskNoiseDeleted } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InpaintMaskDeleteNoiseButton } from './InpaintMaskDeleteNoiseButton';
|
||||
|
||||
export const InpaintMaskNoiseSlider = memo(() => {
|
||||
const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const selectNoiseLevel = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
selectCanvasSlice,
|
||||
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'InpaintMaskNoiseSlider').noiseLevel
|
||||
),
|
||||
[entityIdentifier]
|
||||
);
|
||||
const noiseLevel = useAppSelector(selectNoiseLevel);
|
||||
|
||||
const handleNoiseChange = useCallback(
|
||||
(value: number) => {
|
||||
dispatch(inpaintMaskNoiseChanged({ entityIdentifier, noiseLevel: value }));
|
||||
},
|
||||
[dispatch, entityIdentifier]
|
||||
);
|
||||
|
||||
const onDeleteNoise = useCallback(() => {
|
||||
dispatch(inpaintMaskNoiseDeleted({ entityIdentifier }));
|
||||
}, [dispatch, entityIdentifier]);
|
||||
|
||||
if (noiseLevel === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={1} w="full" px={2} pb={2}>
|
||||
<Flex justifyContent="space-between" w="full" alignItems="center">
|
||||
<Text fontSize="sm">{t('controlLayers.imageNoise')}</Text>
|
||||
<Flex alignItems="center" gap={1}>
|
||||
<Text fontSize="sm">{Math.round(noiseLevel * 100)}%</Text>
|
||||
<InpaintMaskDeleteNoiseButton onDelete={onDeleteNoise} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Slider
|
||||
aria-label={t('controlLayers.imageNoise')}
|
||||
value={noiseLevel}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={handleNoiseChange}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
InpaintMaskNoiseSlider.displayName = 'InpaintMaskNoiseSlider';
|
||||
```
|
||||
|
||||
### 6. Create a Settings Component for Conditional Rendering
|
||||
|
||||
Create a new file `InpaintMaskSettings.tsx` to handle conditional rendering of components based on state:
|
||||
|
||||
```typescript
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper';
|
||||
import { InpaintMaskAddNoiseButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskAddNoiseButton';
|
||||
import { InpaintMaskNoiseSlider } from 'features/controlLayers/components/InpaintMask/InpaintMaskNoiseSlider';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
const buildSelectFlags = (entityIdentifier: CanvasEntityIdentifier<'inpaint_mask'>) =>
|
||||
createMemoizedSelector(selectCanvasSlice, (canvas) => {
|
||||
const entity = selectEntityOrThrow(canvas, entityIdentifier, 'InpaintMaskSettings');
|
||||
return {
|
||||
hasNoiseLevel: entity.noiseLevel !== null,
|
||||
};
|
||||
});
|
||||
|
||||
export const InpaintMaskSettings = memo(() => {
|
||||
const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
|
||||
const selectFlags = useMemo(() => buildSelectFlags(entityIdentifier), [entityIdentifier]);
|
||||
const flags = useAppSelector(selectFlags);
|
||||
|
||||
return (
|
||||
<CanvasEntitySettingsWrapper>
|
||||
{!flags.hasNoiseLevel && <InpaintMaskAddNoiseButton />}
|
||||
{flags.hasNoiseLevel && <InpaintMaskNoiseSlider />}
|
||||
</CanvasEntitySettingsWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
InpaintMaskSettings.displayName = 'InpaintMaskSettings';
|
||||
```
|
||||
|
||||
### 7. Update the InpaintMask Component
|
||||
|
||||
Modify the `InpaintMask.tsx` file to use the new settings component:
|
||||
|
||||
```typescript
|
||||
import { Spacer } from '@invoke-ai/ui-library';
|
||||
import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer';
|
||||
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
|
||||
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
|
||||
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
|
||||
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
|
||||
import { InpaintMaskSettings } from 'features/controlLayers/components/InpaintMask/InpaintMaskSettings';
|
||||
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
|
||||
import { InpaintMaskAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
|
||||
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const InpaintMask = memo(({ id }: Props) => {
|
||||
const entityIdentifier = useMemo<CanvasEntityIdentifier<'inpaint_mask'>>(() => ({ id, type: 'inpaint_mask' }), [id]);
|
||||
|
||||
return (
|
||||
<EntityIdentifierContext.Provider value={entityIdentifier}>
|
||||
<InpaintMaskAdapterGate>
|
||||
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
|
||||
<CanvasEntityContainer>
|
||||
<CanvasEntityHeader>
|
||||
<CanvasEntityPreviewImage />
|
||||
<CanvasEntityEditableTitle />
|
||||
<Spacer />
|
||||
<CanvasEntityHeaderCommonActions />
|
||||
</CanvasEntityHeader>
|
||||
<InpaintMaskSettings />
|
||||
</CanvasEntityContainer>
|
||||
</CanvasEntityStateGate>
|
||||
</InpaintMaskAdapterGate>
|
||||
</EntityIdentifierContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
InpaintMask.displayName = 'InpaintMask';
|
||||
```
|
||||
|
||||
## Additional Considerations
|
||||
|
||||
1. Add translations for the "Image Noise" text in the translation files
|
||||
2. Implement the backend functionality to process the noise level during image generation
|
||||
3. Add tests for the new functionality
|
||||
4. Update documentation to reflect the new feature
|
||||
|
||||
## Conclusion
|
||||
|
||||
This implementation adds an "Image Noise" slider to the InpaintMask layer, following a similar pattern to how Regional Guidance layers handle prompts. The feature allows users to add, adjust, and delete noise levels for their inpainting masks, enhancing the functionality of the application. The implementation includes conditional rendering of UI components based on the current state, ensuring that the add button is only shown when no noise level is set, and the slider with delete button is shown when a noise level exists.
|
||||
@@ -10443,6 +10443,11 @@ export type components = {
|
||||
* @default null
|
||||
*/
|
||||
image?: components["schemas"]["ImageField"] | null;
|
||||
/**
|
||||
* @description Optional mask determining where to apply noise (black=noise, white=no noise)
|
||||
* @default null
|
||||
*/
|
||||
mask?: components["schemas"]["ImageField"] | null;
|
||||
/**
|
||||
* Seed
|
||||
* @description Seed for random number generation
|
||||
|
||||
Reference in New Issue
Block a user