Skip to content
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

Feat: Noise and Blur filters for Canvas #7551

Merged
merged 10 commits into from
Jan 15, 2025
115 changes: 107 additions & 8 deletions invokeai/app/invocations/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.misc import SEED_MAX
from invokeai.backend.image_util.invisible_watermark import InvisibleWatermark
from invokeai.backend.image_util.safety_checker import SafetyChecker

Expand Down Expand Up @@ -161,12 +162,12 @@ class ImagePasteInvocation(BaseInvocation, WithMetadata, WithBoard):
crop: bool = InputField(default=False, description="Crop to base image dimensions")

def invoke(self, context: InvocationContext) -> ImageOutput:
base_image = context.images.get_pil(self.base_image.image_name)
image = context.images.get_pil(self.image.image_name)
base_image = context.images.get_pil(self.base_image.image_name, mode="RGBA")
image = context.images.get_pil(self.image.image_name, mode="RGBA")
mask = None
if self.mask is not None:
mask = context.images.get_pil(self.mask.image_name)
mask = ImageOps.invert(mask.convert("L"))
mask = context.images.get_pil(self.mask.image_name, mode="L")
mask = ImageOps.invert(mask)
# TODO: probably shouldn't invert mask here... should user be required to do it?

min_x = min(0, self.x)
Expand All @@ -176,7 +177,11 @@ def invoke(self, context: InvocationContext) -> ImageOutput:

new_image = Image.new(mode="RGBA", size=(max_x - min_x, max_y - min_y), color=(0, 0, 0, 0))
new_image.paste(base_image, (abs(min_x), abs(min_y)))
new_image.paste(image, (max(0, self.x), max(0, self.y)), mask=mask)

# Create a temporary image to paste the image with transparency
temp_image = Image.new("RGBA", new_image.size)
temp_image.paste(image, (max(0, self.x), max(0, self.y)), mask=mask)
new_image = Image.alpha_composite(new_image, temp_image)

if self.crop:
base_w, base_h = base_image.size
Expand Down Expand Up @@ -301,14 +306,44 @@ class ImageBlurInvocation(BaseInvocation, WithMetadata, WithBoard):
blur_type: Literal["gaussian", "box"] = InputField(default="gaussian", description="The type of blur")

def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)
image = context.images.get_pil(self.image.image_name, mode="RGBA")

# Split the image into RGBA channels
r, g, b, a = image.split()

# Premultiply RGB channels by alpha
premultiplied_image = ImageChops.multiply(image, a.convert("RGBA"))
premultiplied_image.putalpha(a)

# Apply the blur
blur = (
ImageFilter.GaussianBlur(self.radius) if self.blur_type == "gaussian" else ImageFilter.BoxBlur(self.radius)
)
blur_image = image.filter(blur)
blurred_image = premultiplied_image.filter(blur)

# Split the blurred image into RGBA channels
r, g, b, a_orig = blurred_image.split()

# Convert to float using NumPy. float 32/64 division are much faster than float 16
r = numpy.array(r, dtype=numpy.float32)
g = numpy.array(g, dtype=numpy.float32)
b = numpy.array(b, dtype=numpy.float32)
a = numpy.array(a_orig, dtype=numpy.float32) / 255.0 # Normalize alpha to [0, 1]

image_dto = context.images.save(image=blur_image)
# Unpremultiply RGB channels by alpha
r /= a + 1e-6 # Add a small epsilon to avoid division by zero
g /= a + 1e-6
b /= a + 1e-6

# Convert back to PIL images
r = Image.fromarray(numpy.uint8(numpy.clip(r, 0, 255)))
g = Image.fromarray(numpy.uint8(numpy.clip(g, 0, 255)))
b = Image.fromarray(numpy.uint8(numpy.clip(b, 0, 255)))

# Merge back into a single image
result_image = Image.merge("RGBA", (r, g, b, a_orig))

image_dto = context.images.save(image=result_image)

return ImageOutput.build(image_dto)

Expand Down Expand Up @@ -1055,3 +1090,67 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
image_dto = context.images.save(image=generated_image)

return ImageOutput.build(image_dto)


@invocation(
"img_noise",
title="Add Image Noise",
tags=["image", "noise"],
category="image",
version="1.0.1",
)
class ImageNoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Add noise to an image"""

image: ImageField = InputField(description="The image to add noise to")
seed: int = InputField(
default=0,
ge=0,
le=SEED_MAX,
description=FieldDescriptions.seed,
)
noise_type: Literal["gaussian", "salt_and_pepper"] = InputField(
default="gaussian",
description="The type of noise to add",
)
amount: float = InputField(default=0.1, ge=0, le=1, description="The amount of noise to add")
noise_color: bool = InputField(default=True, description="Whether to add colored noise")
size: int = InputField(default=1, ge=1, description="The size of the noise points")

def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name, mode="RGBA")

# Save out the alpha channel
alpha = image.getchannel("A")

# Set the seed for numpy random
rs = numpy.random.RandomState(numpy.random.MT19937(numpy.random.SeedSequence(self.seed)))

if self.noise_type == "gaussian":
if self.noise_color:
noise = rs.normal(0, 1, (image.height // self.size, image.width // self.size, 3)) * 255
else:
noise = rs.normal(0, 1, (image.height // self.size, image.width // self.size)) * 255
noise = numpy.stack([noise] * 3, axis=-1)
elif self.noise_type == "salt_and_pepper":
if self.noise_color:
noise = rs.choice(
[0, 255], (image.height // self.size, image.width // self.size, 3), p=[1 - self.amount, self.amount]
)
else:
noise = rs.choice(
[0, 255], (image.height // self.size, image.width // self.size), p=[1 - self.amount, self.amount]
)
noise = numpy.stack([noise] * 3, axis=-1)

noise = Image.fromarray(noise.astype(numpy.uint8), mode="RGB").resize(
(image.width, image.height), Image.Resampling.NEAREST
)
noisy_image = Image.blend(image.convert("RGB"), noise, self.amount).convert("RGBA")

# Paste back the alpha channel
noisy_image.putalpha(alpha)

image_dto = context.images.save(image=noisy_image)

return ImageOutput.build(image_dto)
18 changes: 18 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1932,6 +1932,24 @@
"description": "Generates an edge map from the selected layer using the PiDiNet edge detection model.",
"scribble": "Scribble",
"quantize_edges": "Quantize Edges"
},
"img_blur": {
"label": "Blur Image",
"description": "Blurs the selected layer.",
"blur_type": "Blur Type",
"blur_radius": "Radius",
"gaussian_type": "Gaussian",
"box_type": "Box"
},
"img_noise": {
"label": "Noise Image",
"description": "Adds noise to the selected layer.",
"noise_type": "Noise Type",
"noise_amount": "Amount",
"gaussian_type": "Gaussian",
"salt_and_pepper_type": "Salt and Pepper",
"noise_color": "Colored Noise",
"size": "Noise Size"
}
},
"transform": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import type { BlurFilterConfig, BlurTypes } from 'features/controlLayers/store/filters';
import { IMAGE_FILTERS, isBlurTypes } from 'features/controlLayers/store/filters';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import type { FilterComponentProps } from './types';

type Props = FilterComponentProps<BlurFilterConfig>;
const DEFAULTS = IMAGE_FILTERS.img_blur.buildDefaults();

export const FilterBlur = memo(({ onChange, config }: Props) => {
const { t } = useTranslation();
const handleBlurTypeChange = useCallback<ComboboxOnChange>(
(v) => {
if (!isBlurTypes(v?.value)) {
return;
}
onChange({ ...config, blur_type: v.value });
},
[config, onChange]
);

const handleRadiusChange = useCallback(
(v: number) => {
onChange({ ...config, radius: v });
},
[config, onChange]
);

const options: { label: string; value: BlurTypes }[] = useMemo(
() => [
{ label: t('controlLayers.filter.img_blur.gaussian_type'), value: 'gaussian' },
{ label: t('controlLayers.filter.img_blur.box_type'), value: 'box' },
],
[t]
);

const value = useMemo(() => options.filter((o) => o.value === config.blur_type)[0], [options, config.blur_type]);

return (
<>
<FormControl>
<FormLabel m={0}>{t('controlLayers.filter.img_blur.blur_type')}</FormLabel>
<Combobox value={value} options={options} onChange={handleBlurTypeChange} isSearchable={false} />
</FormControl>
<FormControl>
<FormLabel m={0}>{t('controlLayers.filter.img_blur.blur_radius')}</FormLabel>
<CompositeSlider
value={config.radius}
defaultValue={DEFAULTS.radius}
onChange={handleRadiusChange}
min={1}
max={64}
step={0.1}
marks
/>
<CompositeNumberInput
value={config.radius}
defaultValue={DEFAULTS.radius}
onChange={handleRadiusChange}
min={1}
max={4096}
step={0.1}
/>
</FormControl>
</>
);
});

FilterBlur.displayName = 'FilterBlur';
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, CompositeNumberInput, CompositeSlider, FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
import type { NoiseFilterConfig, NoiseTypes } from 'features/controlLayers/store/filters';
import { IMAGE_FILTERS, isNoiseTypes } from 'features/controlLayers/store/filters';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import type { FilterComponentProps } from './types';

type Props = FilterComponentProps<NoiseFilterConfig>;
const DEFAULTS = IMAGE_FILTERS.img_noise.buildDefaults();

export const FilterNoise = memo(({ onChange, config }: Props) => {
const { t } = useTranslation();
const handleNoiseTypeChange = useCallback<ComboboxOnChange>(
(v) => {
if (!isNoiseTypes(v?.value)) {
return;
}
onChange({ ...config, noise_type: v.value });
},
[config, onChange]
);

const handleAmountChange = useCallback(
(v: number) => {
onChange({ ...config, amount: v });
},
[config, onChange]
);

const handleColorChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange({ ...config, noise_color: e.target.checked });
},
[config, onChange]
);

const handleSizeChange = useCallback(
(v: number) => {
onChange({ ...config, size: v });
},
[config, onChange]
);

const options: { label: string; value: NoiseTypes }[] = useMemo(
() => [
{ label: t('controlLayers.filter.img_noise.gaussian_type'), value: 'gaussian' },
{ label: t('controlLayers.filter.img_noise.salt_and_pepper_type'), value: 'salt_and_pepper' },
],
[t]
);

const value = useMemo(() => options.filter((o) => o.value === config.noise_type)[0], [options, config.noise_type]);

return (
<>
<FormControl>
<FormLabel m={0}>{t('controlLayers.filter.img_noise.noise_type')}</FormLabel>
<Combobox value={value} options={options} onChange={handleNoiseTypeChange} isSearchable={false} />
</FormControl>
<FormControl>
<FormLabel m={0}>{t('controlLayers.filter.img_noise.noise_amount')}</FormLabel>
<CompositeSlider
value={config.amount}
defaultValue={DEFAULTS.amount}
onChange={handleAmountChange}
min={0}
max={1}
step={0.01}
marks
/>
<CompositeNumberInput
value={config.amount}
defaultValue={DEFAULTS.amount}
onChange={handleAmountChange}
min={0}
max={1}
step={0.01}
/>
</FormControl>
<FormControl>
<FormLabel m={0}>{t('controlLayers.filter.img_noise.size')}</FormLabel>
<CompositeSlider
value={config.size}
defaultValue={DEFAULTS.size}
onChange={handleSizeChange}
min={1}
max={16}
step={1}
marks
/>
<CompositeNumberInput
value={config.size}
defaultValue={DEFAULTS.size}
onChange={handleSizeChange}
min={1}
max={256}
step={1}
/>
</FormControl>
<FormControl w="max-content">
<FormLabel m={0}>{t('controlLayers.filter.img_noise.noise_color')}</FormLabel>
<Switch defaultChecked={DEFAULTS.noise_color} isChecked={config.noise_color} onChange={handleColorChange} />
</FormControl>
</>
);
});

FilterNoise.displayName = 'Filternoise';
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { FilterBlur } from 'features/controlLayers/components/Filters/FilterBlur';
import { FilterCannyEdgeDetection } from 'features/controlLayers/components/Filters/FilterCannyEdgeDetection';
import { FilterColorMap } from 'features/controlLayers/components/Filters/FilterColorMap';
import { FilterContentShuffle } from 'features/controlLayers/components/Filters/FilterContentShuffle';
Expand All @@ -8,6 +9,7 @@ import { FilterHEDEdgeDetection } from 'features/controlLayers/components/Filter
import { FilterLineartEdgeDetection } from 'features/controlLayers/components/Filters/FilterLineartEdgeDetection';
import { FilterMediaPipeFaceDetection } from 'features/controlLayers/components/Filters/FilterMediaPipeFaceDetection';
import { FilterMLSDDetection } from 'features/controlLayers/components/Filters/FilterMLSDDetection';
import { FilterNoise } from 'features/controlLayers/components/Filters/FilterNoise';
import { FilterPiDiNetEdgeDetection } from 'features/controlLayers/components/Filters/FilterPiDiNetEdgeDetection';
import { FilterSpandrel } from 'features/controlLayers/components/Filters/FilterSpandrel';
import type { FilterConfig } from 'features/controlLayers/store/filters';
Expand All @@ -19,6 +21,10 @@ type Props = { filterConfig: FilterConfig; onChange: (filterConfig: FilterConfig
export const FilterSettings = memo(({ filterConfig, onChange }: Props) => {
const { t } = useTranslation();

if (filterConfig.type === 'img_blur') {
return <FilterBlur config={filterConfig} onChange={onChange} />;
}

if (filterConfig.type === 'canny_edge_detection') {
return <FilterCannyEdgeDetection config={filterConfig} onChange={onChange} />;
}
Expand Down Expand Up @@ -59,6 +65,10 @@ export const FilterSettings = memo(({ filterConfig, onChange }: Props) => {
return <FilterPiDiNetEdgeDetection config={filterConfig} onChange={onChange} />;
}

if (filterConfig.type === 'img_noise') {
return <FilterNoise config={filterConfig} onChange={onChange} />;
}

if (filterConfig.type === 'spandrel_filter') {
return <FilterSpandrel config={filterConfig} onChange={onChange} />;
}
Expand Down
Loading
Loading