From 62ed526493fdba2eafb284fdf9959f3d11a51464 Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:49:05 -0300 Subject: [PATCH 01/19] Add sepia function logic and documentation --- src/PIL/ImageOps.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 42b10bd7bc8..0f598187215 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -623,6 +623,37 @@ 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") + + width, height = image.size + out = Image.new("RGB", (width, height)) + + for x in range(width): + for y in range(height): + r, g, b = cast(tuple[int, int, int], image.getpixel((x, y))) + + tr = int(0.393 * r + 0.769 * g + 0.189 * b) + tg = int(0.349 * r + 0.686 * g + 0.168 * b) + tb = int(0.272 * r + 0.534 * g + 0.131 * b) + + tr = min(255, int(tr)) + tg = min(255, int(tg)) + tb = min(255, int(tb)) + + out.putpixel((x, y), (tr, tg, tb)) + + return out + + def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. From 637bc995d00bd90cd24463097fcbc70556769150 Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:50:17 -0300 Subject: [PATCH 02/19] Add sobel edge detector logic and documentation --- src/PIL/ImageOps.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 0f598187215..13368fc7978 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -654,6 +654,50 @@ def sepia(image: Image.Image) -> Image.Image: 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. + """ + image = image.convert("L") + width, height = image.size + + Kx = [[-1, 0, 1], + [-2, 0, 2], + [-1, 0, 1]] + + Ky = [[1, 2, 1], + [0, 0, 0], + [-1, -2, -1]] + + out = Image.new("L", (width, height)) + + for y in range(1, height - 1): + for x in range(1, width - 1): + + gx = gy = 0 + + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + v = cast(int, image.getpixel((x + dx, y + dy))) + + kx = Kx[dy + 1][dx + 1] + ky = Ky[dy + 1][dx + 1] + + gx += v * kx + gy += v * ky + # Approximate gradient magnitude and clamp to [0, 255] + mag = int(min(255, abs(gx) + abs(gy))) + out.putpixel((x, y), int(mag)) + + return out + + def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. From 0807c8536495ed42e810645b858f7ff249147831 Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:51:23 -0300 Subject: [PATCH 03/19] Add glow effect function implementation --- src/PIL/ImageOps.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 13368fc7978..68709fef19f 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -698,6 +698,19 @@ def sobel(image: Image.Image) -> Image.Image: return out +def _glow_mask(edge_img: Image.Image) -> Image.Image: + """ + Apply a glow-enhancing mask transformation to an edge image. + + :param edge_img: A grayscale image containing edge intensities. + :return: An image. + """ + def screen_point(x): + return 255 - ((255 - x) * (255 - x) // 255) + + return edge_img.point(screen_point) + + def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. From e15b398f8f96aa8fcafb40edeca492bc642e03b8 Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:52:54 -0300 Subject: [PATCH 04/19] Add function to apply color for the glow effect --- src/PIL/ImageOps.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 68709fef19f..f0ff030f2e2 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -711,6 +711,36 @@ def screen_point(x): return edge_img.point(screen_point) +def _neon_colorize( + mask: Image.Image, + color: tuple[int, int, int] +) -> Image.Image: + """ + Apply a color tint to an intensity mask for neon/glow effects. + :param mask: single-channel mask. + :param color: color to be applied + :return: An image + """ + r, g, b = color + width, height = mask.size + out = Image.new("RGB", (width, height)) + + for y in range(height): + for x in range(width): + v = cast(int, mask.getpixel((x, y))) + + out.putpixel( + (x, y), + ( + min(255, v * r // 255), + min(255, v * g // 255), + min(255, v * b // 255), + ), + ) + + return out + + def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. From 3494fcb36cbeed1d4d0f5007898158ed222e0bf6 Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:54:26 -0300 Subject: [PATCH 05/19] Add blend function that blend the layer with original image --- src/PIL/ImageOps.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index f0ff030f2e2..7949fbdffca 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -741,6 +741,44 @@ def _neon_colorize( return out +def _neon_blend( + original: Image.Image, + neon: Image.Image, + alpha: float = 0.55 +) -> Image.Image: + """ + Blend the original image with its neon/glow layer + + :param original: Image to blend whith neon layer + :param neon: neon Layer + :param alpha: controls intensity of neon effect + :return: An image + """ + if alpha < 0: + alpha = 0 + if alpha > 1: + alpha = 1 + + width, height = original.size + out = Image.new("RGB", (width, height)) + + for y in range(height): + for x in range(width): + r1, g1, b1 = cast(tuple[int, int, int], original.getpixel((x, y))) + r2, g2, b2 = cast(tuple[int, int, int], neon.getpixel((x, y))) + + out.putpixel( + (x, y), + ( + int((1 - alpha) * r1 + alpha * r2), + int((1 - alpha) * g1 + alpha * g2), + int((1 - alpha) * b1 + alpha * b2), + ), + ) + + return out + + def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. From c0511fab177a2383166a5122719601e41c8a5b05 Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:55:41 -0300 Subject: [PATCH 06/19] Add function that apply neon glow effect --- src/PIL/ImageOps.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 7949fbdffca..ff3bc499126 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -779,6 +779,32 @@ def _neon_blend( 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) + edges = edges.filter(ImageFilter.GaussianBlur(2)) + + glow = _glow_mask(edges) + neon = _neon_colorize(glow, color) + + return _neon_blend(image, neon, alpha) + + def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. From 032183e7cfd038f570368ae2e69fd6be914d6bbb Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:56:32 -0300 Subject: [PATCH 07/19] Import ImageFIlter --- src/PIL/ImageOps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index ff3bc499126..0a1e3b5f3c7 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, ImagePalette, ImageFilter # # helpers From 9bde47015ca56c7a417e215736eb8b7ed95e42bc Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:58:40 -0300 Subject: [PATCH 08/19] Add tests for sepia, sobel and glow effect functions --- Tests/test_imageops.py | 80 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 63cd0e4d4a9..aad14a23fde 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -604,3 +604,83 @@ 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.size == img.size + assert out.mode == "RGB" + + +def test_sobel_detects_edge() -> None: + img = Image.new("L", (5, 5), 0) + 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), "black") + out = ImageOps.sobel(img) + + assert out.mode == "L" + assert out.size == img.size + + +def test_glow_mask_preserves_mode_and_size() -> None: + img = Image.new("L", (10, 10), 128) + out = ImageOps._glow_mask(img) + + assert out.mode == "L" + assert out.size == img.size + + +def test_glow_mask_increases_intensity() -> None: + img = Image.new("L", (1, 1), 128) + out = ImageOps._glow_mask(img) + + v = cast(int, out.getpixel((0, 0))) + assert v > 128 + + +def test_neon_colorize_output_mode() -> None: + mask = Image.new("L", (5, 5), 128) + out = ImageOps._neon_colorize(mask, (255, 0, 0)) + + assert out.mode == "RGB" + assert out.size == mask.size + + +def test_neon_colorize_red_channel_only() -> None: + mask = Image.new("L", (1, 1), 255) + out = ImageOps._neon_colorize(mask, (255, 0, 0)) + + assert out.getpixel((0, 0)) == (255, 0, 0) + + +def test_neon_blend_alpha_zero() -> None: + base = Image.new("RGB", (1, 1), (10, 20, 30)) + neon = Image.new("RGB", (1, 1), (200, 200, 200)) + + out = ImageOps._neon_blend(base, neon, alpha=0) + assert out.getpixel((0, 0)) == (10, 20, 30) + + +def test_neon_blend_alpha_one() -> None: + base = Image.new("RGB", (1, 1), (10, 20, 30)) + neon = Image.new("RGB", (1, 1), (200, 200, 200)) + + out = ImageOps._neon_blend(base, neon, alpha=1) + assert out.getpixel((0, 0)) == (200, 200, 200) + + +def test_neon_effect_mode_and_size() -> None: + img = Image.new("RGB", (20, 20), "black") + out = ImageOps.neon_effect(img) + + assert out.mode == "RGB" + assert out.size == img.size From fa37192acaf16ef1c774d91b7bbd4b3ef2160eb8 Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 05:59:40 -0300 Subject: [PATCH 09/19] Import cast --- Tests/test_imageops.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index aad14a23fde..b2f5e5ac502 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -3,6 +3,7 @@ import pytest from PIL import Image, ImageDraw, ImageOps, ImageStat, features +from typing import cast from .helper import ( assert_image_equal, From 8856b0343eddf91ac62f1e4c08b6853715cf8496 Mon Sep 17 00:00:00 2001 From: MATHEUS Date: Sat, 13 Dec 2025 06:01:30 -0300 Subject: [PATCH 10/19] Add autofunction for sepia, sobel and neon_effect functions --- docs/reference/ImageOps.rst | 3 +++ 1 file changed, 3 insertions(+) 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: From 266347892a332d57db72c23a52d34aa92dbb18bd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:50:56 +0000 Subject: [PATCH 11/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_imageops.py | 3 ++- src/PIL/ImageOps.py | 26 ++++++++------------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index b2f5e5ac502..04cdb8f3796 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -1,9 +1,10 @@ from __future__ import annotations +from typing import cast + import pytest from PIL import Image, ImageDraw, ImageOps, ImageStat, features -from typing import cast from .helper import ( assert_image_equal, diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 0a1e3b5f3c7..09d4c41b096 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, ImageFilter +from . import ExifTags, Image, ImageFilter, ImagePalette # # helpers @@ -667,13 +667,9 @@ def sobel(image: Image.Image) -> Image.Image: image = image.convert("L") width, height = image.size - Kx = [[-1, 0, 1], - [-2, 0, 2], - [-1, 0, 1]] + Kx = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] - Ky = [[1, 2, 1], - [0, 0, 0], - [-1, -2, -1]] + Ky = [[1, 2, 1], [0, 0, 0], [-1, -2, -1]] out = Image.new("L", (width, height)) @@ -690,7 +686,7 @@ def sobel(image: Image.Image) -> Image.Image: ky = Ky[dy + 1][dx + 1] gx += v * kx - gy += v * ky + gy += v * ky # Approximate gradient magnitude and clamp to [0, 255] mag = int(min(255, abs(gx) + abs(gy))) out.putpixel((x, y), int(mag)) @@ -705,16 +701,14 @@ def _glow_mask(edge_img: Image.Image) -> Image.Image: :param edge_img: A grayscale image containing edge intensities. :return: An image. """ + def screen_point(x): return 255 - ((255 - x) * (255 - x) // 255) return edge_img.point(screen_point) -def _neon_colorize( - mask: Image.Image, - color: tuple[int, int, int] -) -> Image.Image: +def _neon_colorize(mask: Image.Image, color: tuple[int, int, int]) -> Image.Image: """ Apply a color tint to an intensity mask for neon/glow effects. :param mask: single-channel mask. @@ -742,9 +736,7 @@ def _neon_colorize( def _neon_blend( - original: Image.Image, - neon: Image.Image, - alpha: float = 0.55 + original: Image.Image, neon: Image.Image, alpha: float = 0.55 ) -> Image.Image: """ Blend the original image with its neon/glow layer @@ -780,9 +772,7 @@ def _neon_blend( def neon_effect( - image: Image.Image, - color: tuple[int, int, int] = (255, 0, 255), - alpha: float = 0.2 + 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, From 168c0bf338fcc4064a408306c0a2e74d67797d52 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 14 Dec 2025 23:13:07 +1100 Subject: [PATCH 12/19] Updated type hints --- Tests/test_imageops.py | 11 +++--- src/PIL/ImageOps.py | 82 +++++++++++++++++++----------------------- 2 files changed, 41 insertions(+), 52 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 04cdb8f3796..f39a839624d 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import cast - import pytest from PIL import Image, ImageDraw, ImageOps, ImageStat, features @@ -612,8 +610,8 @@ def test_sepia_preserves_size_and_mode() -> None: img = Image.new("RGB", (10, 10), (100, 150, 200)) out = ImageOps.sepia(img) - assert out.size == img.size assert out.mode == "RGB" + assert out.size == img.size def test_sobel_detects_edge() -> None: @@ -645,8 +643,9 @@ def test_glow_mask_increases_intensity() -> None: img = Image.new("L", (1, 1), 128) out = ImageOps._glow_mask(img) - v = cast(int, out.getpixel((0, 0))) - assert v > 128 + value = out.getpixel((0, 0)) + assert isinstance(value, (int, float)) + assert value > 128 def test_neon_colorize_output_mode() -> None: @@ -681,7 +680,7 @@ def test_neon_blend_alpha_one() -> None: def test_neon_effect_mode_and_size() -> None: - img = Image.new("RGB", (20, 20), "black") + img = Image.new("RGB", (20, 20)) out = ImageOps.neon_effect(img) assert out.mode == "RGB" diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 09d4c41b096..743b365a3ae 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -634,22 +634,19 @@ def sepia(image: Image.Image) -> Image.Image: if image.mode != "RGB": image = image.convert("RGB") - width, height = image.size - out = Image.new("RGB", (width, height)) + out = Image.new("RGB", image.size) - for x in range(width): - for y in range(height): - r, g, b = cast(tuple[int, int, int], image.getpixel((x, y))) + 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 = int(0.393 * r + 0.769 * g + 0.189 * b) - tg = int(0.349 * r + 0.686 * g + 0.168 * b) - tb = int(0.272 * r + 0.534 * g + 0.131 * b) + 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 - tr = min(255, int(tr)) - tg = min(255, int(tg)) - tb = min(255, int(tb)) - - out.putpixel((x, y), (tr, tg, tb)) + out.putpixel((x, y), tuple(min(255, int(c)) for c in (tr, tg, tb))) return out @@ -665,31 +662,28 @@ def sobel(image: Image.Image) -> Image.Image: :return: An image. """ image = image.convert("L") - width, height = image.size Kx = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] Ky = [[1, 2, 1], [0, 0, 0], [-1, -2, -1]] - out = Image.new("L", (width, height)) + out = Image.new("L", image.size) - for y in range(1, height - 1): - for x in range(1, width - 1): + for y in range(1, image.height - 1): + for x in range(1, image.width - 1): - gx = gy = 0 + gx = gy = 0.0 for dy in (-1, 0, 1): for dx in (-1, 0, 1): - v = cast(int, image.getpixel((x + dx, y + dy))) - - kx = Kx[dy + 1][dx + 1] - ky = Ky[dy + 1][dx + 1] + v = image.getpixel((x + dx, y + dy)) + assert isinstance(v, (int, float)) - gx += v * kx - gy += v * ky + 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), int(mag)) + out.putpixel((x, y), mag) return out @@ -702,8 +696,8 @@ def _glow_mask(edge_img: Image.Image) -> Image.Image: :return: An image. """ - def screen_point(x): - return 255 - ((255 - x) * (255 - x) // 255) + def screen_point(value: int) -> int: + return 255 - ((255 - value) * (255 - value) // 255) return edge_img.point(screen_point) @@ -716,21 +710,14 @@ def _neon_colorize(mask: Image.Image, color: tuple[int, int, int]) -> Image.Imag :return: An image """ r, g, b = color - width, height = mask.size - out = Image.new("RGB", (width, height)) + out = Image.new("RGB", mask.size) - for y in range(height): - for x in range(width): - v = cast(int, mask.getpixel((x, y))) + for y in range(mask.height): + for x in range(mask.width): + v = mask.getpixel((x, y)) + assert isinstance(v, (int, float)) - out.putpixel( - (x, y), - ( - min(255, v * r // 255), - min(255, v * g // 255), - min(255, v * b // 255), - ), - ) + out.putpixel((x, y), tuple(min(255, int(v * c / 255)) for c in (r, g, b))) return out @@ -751,13 +738,16 @@ def _neon_blend( if alpha > 1: alpha = 1 - width, height = original.size - out = Image.new("RGB", (width, height)) + out = Image.new("RGB", original.size) - for y in range(height): - for x in range(width): - r1, g1, b1 = cast(tuple[int, int, int], original.getpixel((x, y))) - r2, g2, b2 = cast(tuple[int, int, int], neon.getpixel((x, y))) + for y in range(original.height): + for x in range(original.width): + value1 = original.getpixel((x, y)) + value2 = neon.getpixel((x, y)) + assert isinstance(value1, tuple) + assert isinstance(value2, tuple) + r1, g1, b1 = value1 + r2, g2, b2 = value2 out.putpixel( (x, y), From 74266a54fcea9944c82d1c7785361951ae13674e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 14 Dec 2025 23:15:13 +1100 Subject: [PATCH 13/19] Only convert mode when needed --- src/PIL/ImageOps.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 743b365a3ae..9f4b76e7531 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -658,13 +658,13 @@ def sobel(image: Image.Image) -> Image.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 + :param image: the image to apply the filter :return: An image. """ - image = image.convert("L") + 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) @@ -681,6 +681,7 @@ def sobel(image: Image.Image) -> Image.Image: 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) From 88830186b2add95afc26222c4704ae711b7114f9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 22 Dec 2025 11:47:38 +1100 Subject: [PATCH 14/19] Inlined _glow_mask --- Tests/test_imageops.py | 17 ----------------- src/PIL/ImageOps.py | 19 +++---------------- 2 files changed, 3 insertions(+), 33 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index f39a839624d..2f930a445e5 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -631,23 +631,6 @@ def test_sobel_output_mode_and_size() -> None: assert out.size == img.size -def test_glow_mask_preserves_mode_and_size() -> None: - img = Image.new("L", (10, 10), 128) - out = ImageOps._glow_mask(img) - - assert out.mode == "L" - assert out.size == img.size - - -def test_glow_mask_increases_intensity() -> None: - img = Image.new("L", (1, 1), 128) - out = ImageOps._glow_mask(img) - - value = out.getpixel((0, 0)) - assert isinstance(value, (int, float)) - assert value > 128 - - def test_neon_colorize_output_mode() -> None: mask = Image.new("L", (5, 5), 128) out = ImageOps._neon_colorize(mask, (255, 0, 0)) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 9f4b76e7531..e05c35c9f99 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -689,20 +689,6 @@ def sobel(image: Image.Image) -> Image.Image: return out -def _glow_mask(edge_img: Image.Image) -> Image.Image: - """ - Apply a glow-enhancing mask transformation to an edge image. - - :param edge_img: A grayscale image containing edge intensities. - :return: An image. - """ - - def screen_point(value: int) -> int: - return 255 - ((255 - value) * (255 - value) // 255) - - return edge_img.point(screen_point) - - def _neon_colorize(mask: Image.Image, color: tuple[int, int, int]) -> Image.Image: """ Apply a color tint to an intensity mask for neon/glow effects. @@ -780,9 +766,10 @@ def neon_effect( edges = sobel(image) edges = edges.filter(ImageFilter.GaussianBlur(2)) - glow = _glow_mask(edges) - neon = _neon_colorize(glow, color) + # Apply a glow-enhancing mask transformation + glow = edges.point(lambda value: 255 - ((255 - value) ** 2 // 255)) + neon = _neon_colorize(glow, color) return _neon_blend(image, neon, alpha) From 0e2b57ac0a4193e5018ba7ec3c889a60e64d9bbc Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 22 Dec 2025 12:01:19 +1100 Subject: [PATCH 15/19] Colorize image band by band --- src/PIL/ImageOps.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index e05c35c9f99..195e6ad8401 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -696,17 +696,9 @@ def _neon_colorize(mask: Image.Image, color: tuple[int, int, int]) -> Image.Imag :param color: color to be applied :return: An image """ - r, g, b = color - out = Image.new("RGB", mask.size) - - for y in range(mask.height): - for x in range(mask.width): - v = mask.getpixel((x, y)) - assert isinstance(v, (int, float)) - - out.putpixel((x, y), tuple(min(255, int(v * c / 255)) for c in (r, g, b))) - - return out + return Image.merge( + "RGB", tuple(mask.point(lambda v: min(255, int(v * c / 255))) for c in color) + ) def _neon_blend( From d241df1ba9448e582881d633cf784872b3b71d37 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 22 Dec 2025 12:12:38 +1100 Subject: [PATCH 16/19] Inlined _neon_colorize --- Tests/test_imageops.py | 19 ++----------------- src/PIL/ImageOps.py | 31 +++++++++---------------------- 2 files changed, 11 insertions(+), 39 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 2f930a445e5..670f3ffa0d1 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -615,7 +615,7 @@ def test_sepia_preserves_size_and_mode() -> None: def test_sobel_detects_edge() -> None: - img = Image.new("L", (5, 5), 0) + img = Image.new("L", (5, 5)) for x in range(3, 5): img.putpixel((x, 2), 255) @@ -624,28 +624,13 @@ def test_sobel_detects_edge() -> None: def test_sobel_output_mode_and_size() -> None: - img = Image.new("RGB", (10, 10), "black") + img = Image.new("RGB", (10, 10)) out = ImageOps.sobel(img) assert out.mode == "L" assert out.size == img.size -def test_neon_colorize_output_mode() -> None: - mask = Image.new("L", (5, 5), 128) - out = ImageOps._neon_colorize(mask, (255, 0, 0)) - - assert out.mode == "RGB" - assert out.size == mask.size - - -def test_neon_colorize_red_channel_only() -> None: - mask = Image.new("L", (1, 1), 255) - out = ImageOps._neon_colorize(mask, (255, 0, 0)) - - assert out.getpixel((0, 0)) == (255, 0, 0) - - def test_neon_blend_alpha_zero() -> None: base = Image.new("RGB", (1, 1), (10, 20, 30)) neon = Image.new("RGB", (1, 1), (200, 200, 200)) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 195e6ad8401..d80861604ce 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -689,18 +689,6 @@ def sobel(image: Image.Image) -> Image.Image: return out -def _neon_colorize(mask: Image.Image, color: tuple[int, int, int]) -> Image.Image: - """ - Apply a color tint to an intensity mask for neon/glow effects. - :param mask: single-channel mask. - :param color: color to be applied - :return: An image - """ - return Image.merge( - "RGB", tuple(mask.point(lambda v: min(255, int(v * c / 255))) for c in color) - ) - - def _neon_blend( original: Image.Image, neon: Image.Image, alpha: float = 0.55 ) -> Image.Image: @@ -725,15 +713,11 @@ def _neon_blend( value2 = neon.getpixel((x, y)) assert isinstance(value1, tuple) assert isinstance(value2, tuple) - r1, g1, b1 = value1 - r2, g2, b2 = value2 out.putpixel( (x, y), - ( - int((1 - alpha) * r1 + alpha * r2), - int((1 - alpha) * g1 + alpha * g2), - int((1 - alpha) * b1 + alpha * b2), + tuple( + int((1 - alpha) * value1[i] + alpha * value2[i]) for i in range(3) ), ) @@ -753,15 +737,18 @@ def neon_effect( :param color: RGB color used for neon effect :alpha: controls the intensity of the neon effect :return: An image - """ - edges = sobel(image) - edges = edges.filter(ImageFilter.GaussianBlur(2)) + edges = sobel(image).filter(ImageFilter.GaussianBlur(2)) # Apply a glow-enhancing mask transformation glow = edges.point(lambda value: 255 - ((255 - value) ** 2 // 255)) - neon = _neon_colorize(glow, color) + # 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 _neon_blend(image, neon, alpha) From 20b3ccb51cc77ccc1bf7191386ebe1c7e021d540 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 22 Dec 2025 12:15:17 +1100 Subject: [PATCH 17/19] Replaced _neon_blend with Image.blend --- Tests/test_imageops.py | 16 ---------------- src/PIL/ImageOps.py | 37 +------------------------------------ 2 files changed, 1 insertion(+), 52 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 670f3ffa0d1..09568f0bf95 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -631,22 +631,6 @@ def test_sobel_output_mode_and_size() -> None: assert out.size == img.size -def test_neon_blend_alpha_zero() -> None: - base = Image.new("RGB", (1, 1), (10, 20, 30)) - neon = Image.new("RGB", (1, 1), (200, 200, 200)) - - out = ImageOps._neon_blend(base, neon, alpha=0) - assert out.getpixel((0, 0)) == (10, 20, 30) - - -def test_neon_blend_alpha_one() -> None: - base = Image.new("RGB", (1, 1), (10, 20, 30)) - neon = Image.new("RGB", (1, 1), (200, 200, 200)) - - out = ImageOps._neon_blend(base, neon, alpha=1) - assert out.getpixel((0, 0)) == (200, 200, 200) - - def test_neon_effect_mode_and_size() -> None: img = Image.new("RGB", (20, 20)) out = ImageOps.neon_effect(img) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index d80861604ce..d0ebf0e4340 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -689,41 +689,6 @@ def sobel(image: Image.Image) -> Image.Image: return out -def _neon_blend( - original: Image.Image, neon: Image.Image, alpha: float = 0.55 -) -> Image.Image: - """ - Blend the original image with its neon/glow layer - - :param original: Image to blend whith neon layer - :param neon: neon Layer - :param alpha: controls intensity of neon effect - :return: An image - """ - if alpha < 0: - alpha = 0 - if alpha > 1: - alpha = 1 - - out = Image.new("RGB", original.size) - - for y in range(original.height): - for x in range(original.width): - value1 = original.getpixel((x, y)) - value2 = neon.getpixel((x, y)) - assert isinstance(value1, tuple) - assert isinstance(value2, tuple) - - out.putpixel( - (x, y), - tuple( - int((1 - alpha) * value1[i] + alpha * value2[i]) for i in range(3) - ), - ) - - return out - - def neon_effect( image: Image.Image, color: tuple[int, int, int] = (255, 0, 255), alpha: float = 0.2 ) -> Image.Image: @@ -749,7 +714,7 @@ def neon_effect( tuple(glow.point(lambda value: min(255, int(value * c / 255))) for c in color), ) - return _neon_blend(image, neon, alpha) + return Image.blend(image, neon, alpha) def invert(image: Image.Image) -> Image.Image: From 9060a85ccf0c18fd9237d7a82d9f7b7250930c4b Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 27 Dec 2025 18:59:51 +1100 Subject: [PATCH 18/19] Test sepia with non-RGB image --- Tests/test_imageops.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 09568f0bf95..791b2e1d374 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -606,8 +606,9 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: assert_image_equal(img, out) -def test_sepia_preserves_size_and_mode() -> None: - img = Image.new("RGB", (10, 10), (100, 150, 200)) +@pytest.mark.parametrize("mode", ("L", "RGB")) +def test_sepia_size_and_mode(mode: str) -> None: + img = Image.new(mode, (10, 10)) out = ImageOps.sepia(img) assert out.mode == "RGB" From 73277d76f17023075ce293c2dd0eea8b4802fd06 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:23:31 +1100 Subject: [PATCH 19/19] Corrected param documentation --- src/PIL/ImageOps.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index d0ebf0e4340..334dcac0842 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -700,7 +700,8 @@ def neon_effect( :param image: Image to create the effect :param color: RGB color used for neon effect - :alpha: controls the intensity of the neon effect + :param alpha: Controls the intensity of the neon effect. If alpha is 0.0, a copy of + the image is returned unaltered. :return: An image """ edges = sobel(image).filter(ImageFilter.GaussianBlur(2))