Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
81 changes: 81 additions & 0 deletions Tests/test_imageops.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,3 +604,84 @@ 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), 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)

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

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))
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
165 changes: 164 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,169 @@ 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]]
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):

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 _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.
:param mask: single-channel mask.
: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


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)
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),
),
)

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