Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Tests/test_imageops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment on lines +609 to +610
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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))

Tests sepia() with a non-RGB image.

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
3 changes: 3 additions & 0 deletions docs/reference/ImageOps.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
96 changes: 95 additions & 1 deletion src/PIL/ImageOps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
:param image: the image to apply the filter
:param image: The image to be filtered

: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]]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to confirm - you're sure the Ky values are correct? Looking at https://en.wikipedia.org/wiki/Sobel_operator#Formulation, one might expect that Ky should actually be [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had used an inverted kernel for the Sobel operator in the y direction. The right one is the [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]. But I think in the implementation we use the absolute values of gy so I think the results will be the same. It should be great to change for the correct one


out = Image.new("L", image.size)

for y in range(1, image.height - 1):
for x in range(1, image.width - 1):
Comment on lines +672 to +673
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for y in range(1, image.height - 1):
for x in range(1, image.width - 1):
for x in range(1, image.width - 1):
for y in range(1, image.height - 1):

Nitpick: I think it would be nice if the order of the loops matched the order in sepia()


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
Copy link
Member

@radarhere radarhere Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
: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))

# 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.
Expand Down
Loading