diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 63cd0e4d4a9..09568f0bf95 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -604,3 +604,36 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: img, cutoff=10, preserve_tone=True ) # single color 10 cutoff assert_image_equal(img, out) + + +def test_sepia_preserves_size_and_mode() -> None: + img = Image.new("RGB", (10, 10), (100, 150, 200)) + out = ImageOps.sepia(img) + + assert out.mode == "RGB" + assert out.size == img.size + + +def test_sobel_detects_edge() -> None: + img = Image.new("L", (5, 5)) + for x in range(3, 5): + img.putpixel((x, 2), 255) + + out = ImageOps.sobel(img) + assert max(out.getdata()) > 0 + + +def test_sobel_output_mode_and_size() -> None: + img = Image.new("RGB", (10, 10)) + out = ImageOps.sobel(img) + + assert out.mode == "L" + assert out.size == img.size + + +def test_neon_effect_mode_and_size() -> None: + img = Image.new("RGB", (20, 20)) + out = ImageOps.neon_effect(img) + + assert out.mode == "RGB" + assert out.size == img.size diff --git a/docs/reference/ImageOps.rst b/docs/reference/ImageOps.rst index 1ecff09f000..e9a7c02268b 100644 --- a/docs/reference/ImageOps.rst +++ b/docs/reference/ImageOps.rst @@ -26,6 +26,9 @@ only work on L and RGB images. .. autofunction:: posterize .. autofunction:: solarize .. autofunction:: exif_transpose +.. autofunction:: sepia +.. autofunction:: sobel +.. autofunction:: neon_effect .. _relative-resize: diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 42b10bd7bc8..d0ebf0e4340 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -24,7 +24,7 @@ from collections.abc import Sequence from typing import Literal, Protocol, cast, overload -from . import ExifTags, Image, ImagePalette +from . import ExifTags, Image, ImageFilter, ImagePalette # # helpers @@ -623,6 +623,100 @@ def grayscale(image: Image.Image) -> Image.Image: return image.convert("L") +def sepia(image: Image.Image) -> Image.Image: + """ + Apply a sepia tone effect to an image. + + :param image: The image to modify. + :return: An image. + + """ + if image.mode != "RGB": + image = image.convert("RGB") + + out = Image.new("RGB", image.size) + + for x in range(image.width): + for y in range(image.height): + value = image.getpixel((x, y)) + assert isinstance(value, tuple) + r, g, b = value + + tr = 0.393 * r + 0.769 * g + 0.189 * b + tg = 0.349 * r + 0.686 * g + 0.168 * b + tb = 0.272 * r + 0.534 * g + 0.131 * b + + out.putpixel((x, y), tuple(min(255, int(c)) for c in (tr, tg, tb))) + + return out + + +def sobel(image: Image.Image) -> Image.Image: + """ + Applies a Sobel edge-detection filter to the given image. + + This function computes the Sobel gradient magnitude using the + horizontal (Gx) and vertical (Gy) Sobel kernels. + + :param image: the image to apply the filter + :return: An image. + """ + if image.mode != "L": + image = image.convert("L") + + Kx = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] + Ky = [[1, 2, 1], [0, 0, 0], [-1, -2, -1]] + + out = Image.new("L", image.size) + + for y in range(1, image.height - 1): + for x in range(1, image.width - 1): + + gx = gy = 0.0 + + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + v = image.getpixel((x + dx, y + dy)) + assert isinstance(v, (int, float)) + + gx += v * Kx[dy + 1][dx + 1] + gy += v * Ky[dy + 1][dx + 1] + + # Approximate gradient magnitude and clamp to [0, 255] + mag = int(min(255, abs(gx) + abs(gy))) + out.putpixel((x, y), mag) + + return out + + +def neon_effect( + image: Image.Image, color: tuple[int, int, int] = (255, 0, 255), alpha: float = 0.2 +) -> Image.Image: + """ + Apply a neon glow effect to an image using edge detection, + blur-based glow generation, colorization, and alpha blending. + It calls all auxiliary functions required to generate + the final result. + + :param image: Image to create the effect + :param color: RGB color used for neon effect + :alpha: controls the intensity of the neon effect + :return: An image + """ + edges = sobel(image).filter(ImageFilter.GaussianBlur(2)) + + # Apply a glow-enhancing mask transformation + glow = edges.point(lambda value: 255 - ((255 - value) ** 2 // 255)) + + # Apply a color tint to the intensity mask + neon = Image.merge( + "RGB", + tuple(glow.point(lambda value: min(255, int(value * c / 255))) for c in color), + ) + + return Image.blend(image, neon, alpha) + + def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image.