Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
62ed526
Add sepia function logic and documentation
Dec 13, 2025
637bc99
Add sobel edge detector logic and documentation
Dec 13, 2025
0807c85
Add glow effect function implementation
Dec 13, 2025
e15b398
Add function to apply color for the glow effect
Dec 13, 2025
3494fcb
Add blend function that blend the layer with original image
Dec 13, 2025
c0511fa
Add function that apply neon glow effect
Dec 13, 2025
032183e
Import ImageFIlter
Dec 13, 2025
9bde470
Add tests for sepia, sobel and glow effect functions
Dec 13, 2025
fa37192
Import cast
Dec 13, 2025
8856b03
Add autofunction for sepia, sobel and neon_effect functions
Dec 13, 2025
2663478
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 13, 2025
52d7d5e
Merge branch 'main' into feat-neon-and-sepia-filters
radarhere Dec 20, 2025
168c0bf
Updated type hints
radarhere Dec 14, 2025
74266a5
Only convert mode when needed
radarhere Dec 14, 2025
8883018
Inlined _glow_mask
radarhere Dec 22, 2025
0e2b57a
Colorize image band by band
radarhere Dec 22, 2025
d241df1
Inlined _neon_colorize
radarhere Dec 22, 2025
20b3ccb
Replaced _neon_blend with Image.blend
radarhere Dec 22, 2025
2b946f6
Merge pull request #2 from radarhere/feat-neon-and-sepia-filters
matheusmpff Dec 23, 2025
9060a85
Test sepia with non-RGB image
radarhere Dec 27, 2025
73277d7
Corrected param documentation
radarhere Dec 28, 2025
c6572cf
Updated param description
radarhere Dec 29, 2025
a66d730
Swap loop order
radarhere Dec 31, 2025
dacf2f8
Merge branch 'main' into feat-neon-and-sepia-filters
radarhere Jan 2, 2026
3394dc8
Do not use deprecated getdata
radarhere Jan 2, 2026
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
34 changes: 34 additions & 0 deletions Tests/test_imageops.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,3 +604,37 @@ 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)


@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"
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.tobytes()) > 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
97 changes: 96 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,101 @@ 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 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 x in range(1, image.width - 1):
for y in range(1, image.height - 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(
Copy link
Member

Choose a reason for hiding this comment

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

Name this just neon? The other two could also be _effect but it doesn't add much.

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
: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