diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 2b1625d99e0..bba298ee228 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -95,6 +95,21 @@ def test_sanity() -> None: ImageOps.neon(im) +def test_sobel() -> None: + im = Image.new("L", (6, 4)) + im.paste(30, (3, 0, 6, 4)) + im.paste(200, (5, 0, 6, 4)) + + edges = ImageOps._sobel(im) + + assert edges.getpixel((0, 1)) == 0 + assert [edges.getpixel((x, 1)) for x in range(1, 5)] == [0, 120, 120, 255] + assert edges.getpixel((1, 0)) == 0 + + flipped = ImageOps._sobel(im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)) + assert flipped.getpixel((2, 1)) == 120 + + @pytest.mark.parametrize( "border, expected_box", ( diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index e2f4446bc89..74bce60cb84 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -23,7 +23,7 @@ import re from typing import Protocol, cast, overload -from . import ExifTags, Image, ImageFilter, ImagePalette +from . import ExifTags, Image, ImageChops, ImageFilter, ImagePalette TYPE_CHECKING = False if TYPE_CHECKING: @@ -723,28 +723,21 @@ def _sobel(image: Image.Image) -> Image.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]] + 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 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) + # Kernel clips to 0-255, so |g| is the sum of the positive and negative halves + gx, gx_neg, gy, gy_neg = ( + image.filter(ImageFilter.Kernel((3, 3), [s * v for v in k], 1)) + for k in (Kx, Ky) + for s in (1, -1) + ) + mag = ImageChops.add(ImageChops.add(gx, gx_neg), ImageChops.add(gy, gy_neg)) + # Edge pixels have no full neighbourhood and stay 0 + out = Image.new("L", image.size) + if image.width > 2 and image.height > 2: + out.paste(mag.crop((1, 1, image.width - 1, image.height - 1)), (1, 1)) return out