From 7fc0f29536df804faaa7f1c8d7f1bd8a30081bee Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 03:34:05 +0530 Subject: [PATCH 01/16] helper functions for moire synthesis --- src/PIL/_moire.py | 249 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/PIL/_moire.py diff --git a/src/PIL/_moire.py b/src/PIL/_moire.py new file mode 100644 index 00000000000..2b4828922e6 --- /dev/null +++ b/src/PIL/_moire.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from . import Image, ImageFilter +import random +import math +import io + +def _lcd_resampling(img: Image.Image) -> Image.Image: + """ + Simulate an LCD display by mapping each pixel to a single RGB subpixel + in the repeating R-G-B stripe layout. + + :param img: + :return: An image. + """ + w, h = img.size + resampled_img = Image.new("RGB", (w, h)) + + for y in range(h): + num = 1 + for x in range(w): + r, g, b = img.getpixel((x, y)) + if num % 3 == 0: + resampled_img.putpixel((x, y), (0, 0, b)) + elif num % 3 == 1: + resampled_img.putpixel((x, y), (r, 0, 0)) + else: + resampled_img.putpixel((x, y), (0, g, 0)) + num += 1 + + return resampled_img + +def _projective_transformation(img: Image.Image) -> Image.Image: + """ + Apply a random projective transformation to simulate varying camera + position and orientation relative to the display. + + :param img: + :return: An image. + """ + w, h = img.size + theta = math.radians(random.uniform(-1,1)) + + #rotation + a = math.cos(theta) + b = -math.sin(theta) + d = math.sin(theta) + e = math.cos(theta) + + # Translation + c = random.uniform(-0.01 * w, 0.01 * w) + f = random.uniform(-0.01 * h, 0.01 * h) + + # Perspective distortion + g = random.uniform(-1e-5, 1e-5) + h_p = random.uniform(-1e-5, 1e-5) + + # H + coeffs = (a, b, c, + d, e, f, + g, h_p) + + return img.transform((w, h), Image.PERSPECTIVE, coeffs, resample=Image.BICUBIC) + +def _radial_distortion(img: Image.Image, k=-1e-7) -> Image.Image: + """ + Use radial distortion function to simulate lens distortion + + :param img: + :param k: + :return: An image + """ + w, h = img.size + radial_distort = Image.new("RGB", (w, h)) + + cx = w / 2 + cy = h / 2 + + for y in range(h): + for x in range(w): + r, g, b = img.getpixel((x, y)) + xc = x - cx + yc = y - cy + radius2 = xc**2 + yc**2 + + factor = 1 + k * radius2 + radial_x = int(xc * factor + cx) + radial_y = int(yc * factor + cy) + + # Boundary check + if 0 <= radial_x < w and 0 <= radial_y < h: + radial_distort.putpixel((radial_x, radial_y),(r, g, b)) + + return radial_distort + + +def _flat_top_kernel(size=5, sigma=1.0, n=2): + """ + Generate a flat-top Gaussian kernel. + + :param size: the size of the kernel to be produced + :param sigma: controls the broadness of the Gaussian kernel + :param n: controls the flatness of the kernel peak + :return: An Array + """ + kernel = [] + center = size // 2 + total = 0 + + for y in range(size): + row = [] + for x in range(size): + dx = x - center + dy = y - center + r2 = dx * dx + dy * dy + value = math.exp(-((r2 / (2 * sigma * sigma)) ** n)) + row.append(value) + total += value + kernel.append(row) + + for y in range(size): + for x in range(size): + kernel[y][x] /= total + + return kernel + +def _flat_top_filtering(img, size=5, sigma=1.0, n=2): + """ + Applying the flat top gaussian kernel on the image to simulate anti-aliasing fiter + + :param img: + :param size: + :param sigma: + :param n: + :return: An image + """ + kernel = _flat_top_kernel(size=size, sigma=sigma, n=n) + flat_kernel = [] + for row in kernel: + flat_kernel.extend(row) + + return img.filter(ImageFilter.Kernel((5,5), flat_kernel, scale=1)) + +def _bayer_resampling(img: Image.Image) -> Image.Image: + """ + Simulate a Bayer CFA (GRBG) where each pixel only captures one color channel + + :param img: + :return: An image + """ + w, h = img.size + resample = Image.new("RGB", (w, h)) + + for y in range(h): + for x in range(w): + r, g, b = img.getpixel((x, y)) + if y % 2 == 0: + if x % 2 == 0: + resample.putpixel((x, y), (0, g, 0)) + else: + resample.putpixel((x, y), (r, 0, 0)) + else: + if x % 2 == 0: + resample.putpixel((x, y), (0, 0, b)) + else: + resample.putpixel((x, y), (0, g, 0)) + + return resample + +def _add_noise(img: Image.Image) -> Image.Image: + """ + Add standard normal noise to the image to simulate sensor noise + + :param img: + :return: An image + """ + w, h = img.size + noisy = Image.new("RGB", (w, h)) + for y in range(h): + for x in range(w): + r, g, b = img.getpixel((x, y)) + nr = int(r + random.gauss(0, 1)) + ng = int(g + random.gauss(0, 1)) + nb = int(b + random.gauss(0, 1)) + noisy.putpixel((x, y), (nr, ng, nb)) + + return noisy + +def _clamp(v, lo, hi): + return lo if v < lo else (hi if v > hi else v) + +def _get_channel(img, x, y, ch, w, h): + x = _clamp(x, 0, w - 1) + y = _clamp(y, 0, h - 1) + return img.getpixel((x, y))[ch] + +def _demosaic_bilinear(img: Image.Image) -> Image.Image: + """ + Reconstruct the full RGB image from the Bayer CFA image using bilinear interpolation + of the other 2 remaining channels from nearby pixels at each pixel + + :param img: + :return: An image + """ + w, h = img.size + out = Image.new("RGB", (w, h)) + + for y in range(h): + for x in range(w): + pixel = img.getpixel((x, y)) + + if y % 2 == 0 and x % 2 == 0: + new_r = (_get_channel(img, x-1, y, 0, w, h) + _get_channel(img, x+1, y, 0, w, h)) >> 1 + new_g = pixel[1] + new_b = (_get_channel(img, x, y-1, 2, w, h) + _get_channel(img, x, y+1, 2, w, h)) >> 1 + + elif y % 2 == 0 and x % 2 == 1: + new_r = pixel[0] + new_g = (_get_channel(img, x-1, y, 1, w, h) + _get_channel(img, x+1, y, 1, w, h) + + _get_channel(img, x, y-1, 1, w, h) + _get_channel(img, x, y+1, 1, w, h)) >> 2 + new_b = (_get_channel(img, x-1, y-1, 2, w, h) + _get_channel(img, x+1, y-1, 2, w, h) + + _get_channel(img, x-1, y+1, 2, w, h) + _get_channel(img, x+1, y+1, 2, w, h)) >> 2 + + elif y % 2 == 1 and x % 2 == 0: + new_r = (_get_channel(img, x-1, y-1, 0, w, h) + _get_channel(img, x+1, y-1, 0, w, h) + + _get_channel(img, x-1, y+1, 0, w, h) + _get_channel(img, x+1, y+1, 0, w, h)) >> 2 + new_g = (_get_channel(img, x-1, y, 1, w, h) + _get_channel(img, x+1, y, 1, w, h) + + _get_channel(img, x, y-1, 1, w, h) + _get_channel(img, x, y+1, 1, w, h)) >> 2 + new_b = pixel[2] + + else: + new_r = (_get_channel(img, x, y-1, 0, w, h) + _get_channel(img, x, y+1, 0, w, h)) >> 1 + new_g = pixel[1] + new_b = (_get_channel(img, x-1, y, 2, w, h) + _get_channel(img, x+1, y, 2, w, h)) >> 1 + + out.putpixel((x, y), (_clamp(new_r, 0, 255), _clamp(new_g, 0, 255), _clamp(new_b, 0, 255))) + + return out + +def _denoise(img: Image.Image) -> Image.Image: + return img.filter(ImageFilter.GaussianBlur(radius=1)) + + +def _jpeg_compression(img: Image.Image) -> Image.Image: + buffer = io.BytesIO() + img.save(buffer, format='JPEG') + buffer.seek(0) + + return Image.open(buffer).convert('RGB') From 2ef4858542630cc92e0fd91d540ad73d24740db4 Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 03:34:27 +0530 Subject: [PATCH 02/16] test suite for moire synthesis --- Tests/test_moire_synthesis.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Tests/test_moire_synthesis.py diff --git a/Tests/test_moire_synthesis.py b/Tests/test_moire_synthesis.py new file mode 100644 index 00000000000..74853820b52 --- /dev/null +++ b/Tests/test_moire_synthesis.py @@ -0,0 +1,17 @@ +import pytest +from PIL import Image,ImageOps + +def test_moire_output_size(): + img = Image.new("RGB", (100, 100), (128, 128, 128)) + result = ImageOps.moire(img) + assert result.size == img.size + +def test_moire_output_mode(): + img = Image.new("RGB", (100, 100), (128, 128, 128)) + result = ImageOps.moire(img) + assert result.mode == "RGB" + +def test_moire_accepts_non_rgb(): + img = Image.new("L", (100, 100), 128) + result = ImageOps.moire(img) + assert result.mode == "RGB" From 2c57ab5dfc88a14488ae4da12fcb109885f3ccb2 Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 03:34:53 +0530 Subject: [PATCH 03/16] added the moire synthesis function --- src/PIL/ImageOps.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index f0ae142b9ba..3e7c5229b11 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -24,6 +24,18 @@ from collections.abc import Sequence from typing import Literal, Protocol, cast, overload +from ._moire import ( + _lcd_resampling, + _projective_transformation, + _radial_distortion, + _flat_top_filtering, + _bayer_resampling, + _add_noise, + _demosaic_bilinear, + _denoise, + _jpeg_compression, +) + from . import ExifTags, Image, ImagePalette # @@ -646,6 +658,26 @@ def mirror(image: Image.Image) -> Image.Image: """ return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) +def moire(image: Image.Image) -> Image.Image: + """ + This is the applications of the helper functions defines in _moire.py to generate a synthetic moire image. + :param image: + :return: An image. + """ + if len(image.getbands()) == 1: + image = image.convert("RGB") + + resampled_img = _lcd_resampling(image) + projective_transform = _projective_transformation(resampled_img) + distorted_img = _radial_distortion(projective_transform) + filtered_img = _flat_top_filtering(distorted_img) + Bayer = _bayer_resampling(filtered_img) + Bayer_noise = _add_noise(Bayer) + rgb_img = _demosaic_bilinear(Bayer_noise) + rgb_denoised = _denoise(rgb_img) + compressed_img = _jpeg_compression(rgb_denoised) + + return compressed_img def posterize(image: Image.Image, bits: int) -> Image.Image: """ From c52317340cf19b5a2710a78fc149ea16286d8a4e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 22:20:04 +0000 Subject: [PATCH 04/16] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_moire_synthesis.py | 8 +++- src/PIL/ImageOps.py | 15 +++--- src/PIL/_moire.py | 90 +++++++++++++++++++++++++---------- 3 files changed, 79 insertions(+), 34 deletions(-) diff --git a/Tests/test_moire_synthesis.py b/Tests/test_moire_synthesis.py index 74853820b52..fcbac7b6058 100644 --- a/Tests/test_moire_synthesis.py +++ b/Tests/test_moire_synthesis.py @@ -1,16 +1,20 @@ -import pytest -from PIL import Image,ImageOps +from __future__ import annotations + +from PIL import Image, ImageOps + def test_moire_output_size(): img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.size == img.size + def test_moire_output_mode(): img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.mode == "RGB" + def test_moire_accepts_non_rgb(): img = Image.new("L", (100, 100), 128) result = ImageOps.moire(img) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 3e7c5229b11..2feb81535c3 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -24,20 +24,19 @@ from collections.abc import Sequence from typing import Literal, Protocol, cast, overload +from . import ExifTags, Image, ImagePalette from ._moire import ( - _lcd_resampling, - _projective_transformation, - _radial_distortion, - _flat_top_filtering, - _bayer_resampling, _add_noise, + _bayer_resampling, _demosaic_bilinear, _denoise, + _flat_top_filtering, _jpeg_compression, + _lcd_resampling, + _projective_transformation, + _radial_distortion, ) -from . import ExifTags, Image, ImagePalette - # # helpers @@ -658,6 +657,7 @@ def mirror(image: Image.Image) -> Image.Image: """ return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + def moire(image: Image.Image) -> Image.Image: """ This is the applications of the helper functions defines in _moire.py to generate a synthetic moire image. @@ -679,6 +679,7 @@ def moire(image: Image.Image) -> Image.Image: return compressed_img + def posterize(image: Image.Image, bits: int) -> Image.Image: """ Reduce the number of bits for each color channel. diff --git a/src/PIL/_moire.py b/src/PIL/_moire.py index 2b4828922e6..d348371ef4f 100644 --- a/src/PIL/_moire.py +++ b/src/PIL/_moire.py @@ -1,9 +1,11 @@ from __future__ import annotations -from . import Image, ImageFilter -import random -import math import io +import math +import random + +from . import Image, ImageFilter + def _lcd_resampling(img: Image.Image) -> Image.Image: """ @@ -30,6 +32,7 @@ def _lcd_resampling(img: Image.Image) -> Image.Image: return resampled_img + def _projective_transformation(img: Image.Image) -> Image.Image: """ Apply a random projective transformation to simulate varying camera @@ -39,9 +42,9 @@ def _projective_transformation(img: Image.Image) -> Image.Image: :return: An image. """ w, h = img.size - theta = math.radians(random.uniform(-1,1)) + theta = math.radians(random.uniform(-1, 1)) - #rotation + # rotation a = math.cos(theta) b = -math.sin(theta) d = math.sin(theta) @@ -56,12 +59,11 @@ def _projective_transformation(img: Image.Image) -> Image.Image: h_p = random.uniform(-1e-5, 1e-5) # H - coeffs = (a, b, c, - d, e, f, - g, h_p) + coeffs = (a, b, c, d, e, f, g, h_p) return img.transform((w, h), Image.PERSPECTIVE, coeffs, resample=Image.BICUBIC) + def _radial_distortion(img: Image.Image, k=-1e-7) -> Image.Image: """ Use radial distortion function to simulate lens distortion @@ -89,7 +91,7 @@ def _radial_distortion(img: Image.Image, k=-1e-7) -> Image.Image: # Boundary check if 0 <= radial_x < w and 0 <= radial_y < h: - radial_distort.putpixel((radial_x, radial_y),(r, g, b)) + radial_distort.putpixel((radial_x, radial_y), (r, g, b)) return radial_distort @@ -124,6 +126,7 @@ def _flat_top_kernel(size=5, sigma=1.0, n=2): return kernel + def _flat_top_filtering(img, size=5, sigma=1.0, n=2): """ Applying the flat top gaussian kernel on the image to simulate anti-aliasing fiter @@ -139,7 +142,8 @@ def _flat_top_filtering(img, size=5, sigma=1.0, n=2): for row in kernel: flat_kernel.extend(row) - return img.filter(ImageFilter.Kernel((5,5), flat_kernel, scale=1)) + return img.filter(ImageFilter.Kernel((5, 5), flat_kernel, scale=1)) + def _bayer_resampling(img: Image.Image) -> Image.Image: """ @@ -167,6 +171,7 @@ def _bayer_resampling(img: Image.Image) -> Image.Image: return resample + def _add_noise(img: Image.Image) -> Image.Image: """ Add standard normal noise to the image to simulate sensor noise @@ -186,14 +191,17 @@ def _add_noise(img: Image.Image) -> Image.Image: return noisy + def _clamp(v, lo, hi): return lo if v < lo else (hi if v > hi else v) + def _get_channel(img, x, y, ch, w, h): x = _clamp(x, 0, w - 1) y = _clamp(y, 0, h - 1) return img.getpixel((x, y))[ch] + def _demosaic_bilinear(img: Image.Image) -> Image.Image: """ Reconstruct the full RGB image from the Bayer CFA image using bilinear interpolation @@ -210,40 +218,72 @@ def _demosaic_bilinear(img: Image.Image) -> Image.Image: pixel = img.getpixel((x, y)) if y % 2 == 0 and x % 2 == 0: - new_r = (_get_channel(img, x-1, y, 0, w, h) + _get_channel(img, x+1, y, 0, w, h)) >> 1 + new_r = ( + _get_channel(img, x - 1, y, 0, w, h) + + _get_channel(img, x + 1, y, 0, w, h) + ) >> 1 new_g = pixel[1] - new_b = (_get_channel(img, x, y-1, 2, w, h) + _get_channel(img, x, y+1, 2, w, h)) >> 1 + new_b = ( + _get_channel(img, x, y - 1, 2, w, h) + + _get_channel(img, x, y + 1, 2, w, h) + ) >> 1 elif y % 2 == 0 and x % 2 == 1: new_r = pixel[0] - new_g = (_get_channel(img, x-1, y, 1, w, h) + _get_channel(img, x+1, y, 1, w, h) + - _get_channel(img, x, y-1, 1, w, h) + _get_channel(img, x, y+1, 1, w, h)) >> 2 - new_b = (_get_channel(img, x-1, y-1, 2, w, h) + _get_channel(img, x+1, y-1, 2, w, h) + - _get_channel(img, x-1, y+1, 2, w, h) + _get_channel(img, x+1, y+1, 2, w, h)) >> 2 + new_g = ( + _get_channel(img, x - 1, y, 1, w, h) + + _get_channel(img, x + 1, y, 1, w, h) + + _get_channel(img, x, y - 1, 1, w, h) + + _get_channel(img, x, y + 1, 1, w, h) + ) >> 2 + new_b = ( + _get_channel(img, x - 1, y - 1, 2, w, h) + + _get_channel(img, x + 1, y - 1, 2, w, h) + + _get_channel(img, x - 1, y + 1, 2, w, h) + + _get_channel(img, x + 1, y + 1, 2, w, h) + ) >> 2 elif y % 2 == 1 and x % 2 == 0: - new_r = (_get_channel(img, x-1, y-1, 0, w, h) + _get_channel(img, x+1, y-1, 0, w, h) + - _get_channel(img, x-1, y+1, 0, w, h) + _get_channel(img, x+1, y+1, 0, w, h)) >> 2 - new_g = (_get_channel(img, x-1, y, 1, w, h) + _get_channel(img, x+1, y, 1, w, h) + - _get_channel(img, x, y-1, 1, w, h) + _get_channel(img, x, y+1, 1, w, h)) >> 2 + new_r = ( + _get_channel(img, x - 1, y - 1, 0, w, h) + + _get_channel(img, x + 1, y - 1, 0, w, h) + + _get_channel(img, x - 1, y + 1, 0, w, h) + + _get_channel(img, x + 1, y + 1, 0, w, h) + ) >> 2 + new_g = ( + _get_channel(img, x - 1, y, 1, w, h) + + _get_channel(img, x + 1, y, 1, w, h) + + _get_channel(img, x, y - 1, 1, w, h) + + _get_channel(img, x, y + 1, 1, w, h) + ) >> 2 new_b = pixel[2] else: - new_r = (_get_channel(img, x, y-1, 0, w, h) + _get_channel(img, x, y+1, 0, w, h)) >> 1 + new_r = ( + _get_channel(img, x, y - 1, 0, w, h) + + _get_channel(img, x, y + 1, 0, w, h) + ) >> 1 new_g = pixel[1] - new_b = (_get_channel(img, x-1, y, 2, w, h) + _get_channel(img, x+1, y, 2, w, h)) >> 1 + new_b = ( + _get_channel(img, x - 1, y, 2, w, h) + + _get_channel(img, x + 1, y, 2, w, h) + ) >> 1 - out.putpixel((x, y), (_clamp(new_r, 0, 255), _clamp(new_g, 0, 255), _clamp(new_b, 0, 255))) + out.putpixel( + (x, y), + (_clamp(new_r, 0, 255), _clamp(new_g, 0, 255), _clamp(new_b, 0, 255)), + ) return out + def _denoise(img: Image.Image) -> Image.Image: return img.filter(ImageFilter.GaussianBlur(radius=1)) def _jpeg_compression(img: Image.Image) -> Image.Image: buffer = io.BytesIO() - img.save(buffer, format='JPEG') + img.save(buffer, format="JPEG") buffer.seek(0) - return Image.open(buffer).convert('RGB') + return Image.open(buffer).convert("RGB") From 9bb24ad9aa1ad1b8b8951b38485f0b1fd5b55bd7 Mon Sep 17 00:00:00 2001 From: Apaar Raina <164377477+ApaarRaina@users.noreply.github.com> Date: Wed, 27 May 2026 13:24:58 +0530 Subject: [PATCH 05/16] Updated docstring of the moire function Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- 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 2feb81535c3..3e88672e792 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -660,7 +660,7 @@ def mirror(image: Image.Image) -> Image.Image: def moire(image: Image.Image) -> Image.Image: """ - This is the applications of the helper functions defines in _moire.py to generate a synthetic moire image. + Generates a synthetic Moire image :param image: :return: An image. """ From 0711b7f7e993d8c4ac9c3b8948dd095152df0696 Mon Sep 17 00:00:00 2001 From: Apaar Raina <164377477+ApaarRaina@users.noreply.github.com> Date: Wed, 27 May 2026 15:50:04 +0530 Subject: [PATCH 06/16] Add return type annotation to test_moire_synthesis Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_moire_synthesis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_moire_synthesis.py b/Tests/test_moire_synthesis.py index fcbac7b6058..7183e999483 100644 --- a/Tests/test_moire_synthesis.py +++ b/Tests/test_moire_synthesis.py @@ -15,7 +15,7 @@ def test_moire_output_mode(): assert result.mode == "RGB" -def test_moire_accepts_non_rgb(): +def test_moire_accepts_non_rgb() -> None: img = Image.new("L", (100, 100), 128) result = ImageOps.moire(img) assert result.mode == "RGB" From f621626f3cbdc2dc3f6bd8d19b2822037bb83fab Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 13:31:28 +0530 Subject: [PATCH 07/16] Add return type annotation to test functions --- Tests/test_moire_synthesis.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Tests/test_moire_synthesis.py b/Tests/test_moire_synthesis.py index 7183e999483..b385882ffe6 100644 --- a/Tests/test_moire_synthesis.py +++ b/Tests/test_moire_synthesis.py @@ -1,21 +1,17 @@ -from __future__ import annotations +import pytest +from PIL import Image,ImageOps -from PIL import Image, ImageOps - - -def test_moire_output_size(): +def test_moire_output_size()->None: img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.size == img.size - -def test_moire_output_mode(): +def test_moire_output_mode()->None: img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.mode == "RGB" - -def test_moire_accepts_non_rgb() -> None: +def test_moire_accepts_non_rgb()->None: img = Image.new("L", (100, 100), 128) result = ImageOps.moire(img) assert result.mode == "RGB" From dd26167d38501f40d95a65fb3884bd0926a0a584 Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 13:32:28 +0530 Subject: [PATCH 08/16] Updated the docstring for moire function --- src/PIL/ImageOps.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 3e88672e792..a3a9bed3af6 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -24,19 +24,20 @@ from collections.abc import Sequence from typing import Literal, Protocol, cast, overload -from . import ExifTags, Image, ImagePalette from ._moire import ( - _add_noise, + _lcd_resampling, + _projective_transformation, + _radial_distortion, + _flat_top_filtering, _bayer_resampling, + _add_noise, _demosaic_bilinear, _denoise, - _flat_top_filtering, _jpeg_compression, - _lcd_resampling, - _projective_transformation, - _radial_distortion, ) +from . import ExifTags, Image, ImagePalette + # # helpers @@ -657,10 +658,9 @@ def mirror(image: Image.Image) -> Image.Image: """ return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - def moire(image: Image.Image) -> Image.Image: """ - Generates a synthetic Moire image + Generate a synthetic Moire image. :param image: :return: An image. """ @@ -679,7 +679,6 @@ def moire(image: Image.Image) -> Image.Image: return compressed_img - def posterize(image: Image.Image, bits: int) -> Image.Image: """ Reduce the number of bits for each color channel. From 10cbfe085116c4b3bc4e41438b72d2ad24f3bbcb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 10:30:08 +0000 Subject: [PATCH 09/16] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_moire_synthesis.py | 14 +++++++++----- src/PIL/ImageOps.py | 15 ++++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Tests/test_moire_synthesis.py b/Tests/test_moire_synthesis.py index b385882ffe6..e57e20495b7 100644 --- a/Tests/test_moire_synthesis.py +++ b/Tests/test_moire_synthesis.py @@ -1,17 +1,21 @@ -import pytest -from PIL import Image,ImageOps +from __future__ import annotations -def test_moire_output_size()->None: +from PIL import Image, ImageOps + + +def test_moire_output_size() -> None: img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.size == img.size -def test_moire_output_mode()->None: + +def test_moire_output_mode() -> None: img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.mode == "RGB" -def test_moire_accepts_non_rgb()->None: + +def test_moire_accepts_non_rgb() -> None: img = Image.new("L", (100, 100), 128) result = ImageOps.moire(img) assert result.mode == "RGB" diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index a3a9bed3af6..93bb3bae5f7 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -24,20 +24,19 @@ from collections.abc import Sequence from typing import Literal, Protocol, cast, overload +from . import ExifTags, Image, ImagePalette from ._moire import ( - _lcd_resampling, - _projective_transformation, - _radial_distortion, - _flat_top_filtering, - _bayer_resampling, _add_noise, + _bayer_resampling, _demosaic_bilinear, _denoise, + _flat_top_filtering, _jpeg_compression, + _lcd_resampling, + _projective_transformation, + _radial_distortion, ) -from . import ExifTags, Image, ImagePalette - # # helpers @@ -658,6 +657,7 @@ def mirror(image: Image.Image) -> Image.Image: """ return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + def moire(image: Image.Image) -> Image.Image: """ Generate a synthetic Moire image. @@ -679,6 +679,7 @@ def moire(image: Image.Image) -> Image.Image: return compressed_img + def posterize(image: Image.Image, bits: int) -> Image.Image: """ Reduce the number of bits for each color channel. From f47c205761c10215ee0919e5fe3f75063410483e Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 16:12:26 +0530 Subject: [PATCH 10/16] added remaining annotations --- src/PIL/_moire.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PIL/_moire.py b/src/PIL/_moire.py index d348371ef4f..6462442eb78 100644 --- a/src/PIL/_moire.py +++ b/src/PIL/_moire.py @@ -96,7 +96,7 @@ def _radial_distortion(img: Image.Image, k=-1e-7) -> Image.Image: return radial_distort -def _flat_top_kernel(size=5, sigma=1.0, n=2): +def _flat_top_kernel(size=5, sigma=1.0, n=2) -> list[list[float]]: """ Generate a flat-top Gaussian kernel. @@ -127,7 +127,7 @@ def _flat_top_kernel(size=5, sigma=1.0, n=2): return kernel -def _flat_top_filtering(img, size=5, sigma=1.0, n=2): +def _flat_top_filtering(img, size=5, sigma=1.0, n=2) -> Image.Image: """ Applying the flat top gaussian kernel on the image to simulate anti-aliasing fiter @@ -192,11 +192,11 @@ def _add_noise(img: Image.Image) -> Image.Image: return noisy -def _clamp(v, lo, hi): +def _clamp(v, lo, hi) -> int: return lo if v < lo else (hi if v > hi else v) -def _get_channel(img, x, y, ch, w, h): +def _get_channel(img, x, y, ch, w, h) -> int: x = _clamp(x, 0, w - 1) y = _clamp(y, 0, h - 1) return img.getpixel((x, y))[ch] From 879564868834d98a427042939d5a126e68aee240 Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 16:14:26 +0530 Subject: [PATCH 11/16] updated formating --- Tests/test_moire_synthesis.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Tests/test_moire_synthesis.py b/Tests/test_moire_synthesis.py index e57e20495b7..34fc6b1d9c2 100644 --- a/Tests/test_moire_synthesis.py +++ b/Tests/test_moire_synthesis.py @@ -1,20 +1,16 @@ -from __future__ import annotations - -from PIL import Image, ImageOps - +import pytest +from PIL import Image,ImageOps def test_moire_output_size() -> None: img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.size == img.size - def test_moire_output_mode() -> None: img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.mode == "RGB" - def test_moire_accepts_non_rgb() -> None: img = Image.new("L", (100, 100), 128) result = ImageOps.moire(img) From 0fd21177244f5c830a44ee8f8ad9308099c75108 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 10:47:38 +0000 Subject: [PATCH 12/16] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_moire_synthesis.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Tests/test_moire_synthesis.py b/Tests/test_moire_synthesis.py index 34fc6b1d9c2..e57e20495b7 100644 --- a/Tests/test_moire_synthesis.py +++ b/Tests/test_moire_synthesis.py @@ -1,16 +1,20 @@ -import pytest -from PIL import Image,ImageOps +from __future__ import annotations + +from PIL import Image, ImageOps + def test_moire_output_size() -> None: img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.size == img.size + def test_moire_output_mode() -> None: img = Image.new("RGB", (100, 100), (128, 128, 128)) result = ImageOps.moire(img) assert result.mode == "RGB" + def test_moire_accepts_non_rgb() -> None: img = Image.new("L", (100, 100), 128) result = ImageOps.moire(img) From 95253e5339d8345508647b6dd9f59369dc50ddd3 Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 16:32:17 +0530 Subject: [PATCH 13/16] fixed mypy errors --- src/PIL/_moire.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/PIL/_moire.py b/src/PIL/_moire.py index 6462442eb78..e938a698d63 100644 --- a/src/PIL/_moire.py +++ b/src/PIL/_moire.py @@ -3,6 +3,7 @@ import io import math import random +from typing import cast from . import Image, ImageFilter @@ -21,7 +22,7 @@ def _lcd_resampling(img: Image.Image) -> Image.Image: for y in range(h): num = 1 for x in range(w): - r, g, b = img.getpixel((x, y)) + r, g, b = cast(tuple[int, int, int], img.getpixel((x, y))) if num % 3 == 0: resampled_img.putpixel((x, y), (0, 0, b)) elif num % 3 == 1: @@ -64,7 +65,7 @@ def _projective_transformation(img: Image.Image) -> Image.Image: return img.transform((w, h), Image.PERSPECTIVE, coeffs, resample=Image.BICUBIC) -def _radial_distortion(img: Image.Image, k=-1e-7) -> Image.Image: +def _radial_distortion(img: Image.Image, k: float=-1e-7) -> Image.Image: """ Use radial distortion function to simulate lens distortion @@ -80,7 +81,7 @@ def _radial_distortion(img: Image.Image, k=-1e-7) -> Image.Image: for y in range(h): for x in range(w): - r, g, b = img.getpixel((x, y)) + r, g, b = cast(tuple[int, int, int], img.getpixel((x, y))) xc = x - cx yc = y - cy radius2 = xc**2 + yc**2 @@ -96,7 +97,7 @@ def _radial_distortion(img: Image.Image, k=-1e-7) -> Image.Image: return radial_distort -def _flat_top_kernel(size=5, sigma=1.0, n=2) -> list[list[float]]: +def _flat_top_kernel(size: int=5, sigma: float=1.0, n: int=2) -> list[list[float]]: """ Generate a flat-top Gaussian kernel. @@ -127,7 +128,7 @@ def _flat_top_kernel(size=5, sigma=1.0, n=2) -> list[list[float]]: return kernel -def _flat_top_filtering(img, size=5, sigma=1.0, n=2) -> Image.Image: +def _flat_top_filtering(img: Image.Image, size: int=5, sigma: float=1.0, n: int=2) -> Image.Image: """ Applying the flat top gaussian kernel on the image to simulate anti-aliasing fiter @@ -157,7 +158,7 @@ def _bayer_resampling(img: Image.Image) -> Image.Image: for y in range(h): for x in range(w): - r, g, b = img.getpixel((x, y)) + r, g, b = cast(tuple[int, int, int], img.getpixel((x, y))) if y % 2 == 0: if x % 2 == 0: resample.putpixel((x, y), (0, g, 0)) @@ -183,7 +184,7 @@ def _add_noise(img: Image.Image) -> Image.Image: noisy = Image.new("RGB", (w, h)) for y in range(h): for x in range(w): - r, g, b = img.getpixel((x, y)) + r, g, b = cast(tuple[int, int, int], img.getpixel((x, y))) nr = int(r + random.gauss(0, 1)) ng = int(g + random.gauss(0, 1)) nb = int(b + random.gauss(0, 1)) @@ -192,14 +193,14 @@ def _add_noise(img: Image.Image) -> Image.Image: return noisy -def _clamp(v, lo, hi) -> int: +def _clamp(v: int, lo: int, hi: int) -> int: return lo if v < lo else (hi if v > hi else v) -def _get_channel(img, x, y, ch, w, h) -> int: +def _get_channel(img: Image.Image, x: int, y: int, ch: int, w: int, h: int) -> int: x = _clamp(x, 0, w - 1) y = _clamp(y, 0, h - 1) - return img.getpixel((x, y))[ch] + return cast(tuple[int, int, int], img.getpixel((x, y)))[ch] def _demosaic_bilinear(img: Image.Image) -> Image.Image: @@ -215,7 +216,7 @@ def _demosaic_bilinear(img: Image.Image) -> Image.Image: for y in range(h): for x in range(w): - pixel = img.getpixel((x, y)) + pixel = cast(tuple[int, int, int], img.getpixel((x, y))) if y % 2 == 0 and x % 2 == 0: new_r = ( From 51ce8e3eb2037e138a5b066fe1fa345de6c5ddce Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 11:04:19 +0000 Subject: [PATCH 14/16] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/PIL/_moire.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/PIL/_moire.py b/src/PIL/_moire.py index e938a698d63..af2e1866ca4 100644 --- a/src/PIL/_moire.py +++ b/src/PIL/_moire.py @@ -65,7 +65,7 @@ def _projective_transformation(img: Image.Image) -> Image.Image: return img.transform((w, h), Image.PERSPECTIVE, coeffs, resample=Image.BICUBIC) -def _radial_distortion(img: Image.Image, k: float=-1e-7) -> Image.Image: +def _radial_distortion(img: Image.Image, k: float = -1e-7) -> Image.Image: """ Use radial distortion function to simulate lens distortion @@ -97,7 +97,9 @@ def _radial_distortion(img: Image.Image, k: float=-1e-7) -> Image.Image: return radial_distort -def _flat_top_kernel(size: int=5, sigma: float=1.0, n: int=2) -> list[list[float]]: +def _flat_top_kernel( + size: int = 5, sigma: float = 1.0, n: int = 2 +) -> list[list[float]]: """ Generate a flat-top Gaussian kernel. @@ -128,7 +130,9 @@ def _flat_top_kernel(size: int=5, sigma: float=1.0, n: int=2) -> list[list[float return kernel -def _flat_top_filtering(img: Image.Image, size: int=5, sigma: float=1.0, n: int=2) -> Image.Image: +def _flat_top_filtering( + img: Image.Image, size: int = 5, sigma: float = 1.0, n: int = 2 +) -> Image.Image: """ Applying the flat top gaussian kernel on the image to simulate anti-aliasing fiter From 0443e94b6bf28a06614c62da5e4d6558c80aa3c9 Mon Sep 17 00:00:00 2001 From: Apaar Raina Date: Wed, 27 May 2026 16:50:30 +0530 Subject: [PATCH 15/16] fixed formating --- src/PIL/_moire.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/_moire.py b/src/PIL/_moire.py index af2e1866ca4..ba710e17e0b 100644 --- a/src/PIL/_moire.py +++ b/src/PIL/_moire.py @@ -62,7 +62,7 @@ def _projective_transformation(img: Image.Image) -> Image.Image: # H coeffs = (a, b, c, d, e, f, g, h_p) - return img.transform((w, h), Image.PERSPECTIVE, coeffs, resample=Image.BICUBIC) + return img.transform((w, h), Image.Transform.PERSPECTIVE, coeffs, resample=Image.Resampling.BICUBIC) def _radial_distortion(img: Image.Image, k: float = -1e-7) -> Image.Image: @@ -110,7 +110,7 @@ def _flat_top_kernel( """ kernel = [] center = size // 2 - total = 0 + total = 0.0 for y in range(size): row = [] From 8d85aab6b966769a6e7e6aa42b6e9b8234415f07 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 11:21:56 +0000 Subject: [PATCH 16/16] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/PIL/_moire.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PIL/_moire.py b/src/PIL/_moire.py index ba710e17e0b..97635c13366 100644 --- a/src/PIL/_moire.py +++ b/src/PIL/_moire.py @@ -62,7 +62,9 @@ def _projective_transformation(img: Image.Image) -> Image.Image: # H coeffs = (a, b, c, d, e, f, g, h_p) - return img.transform((w, h), Image.Transform.PERSPECTIVE, coeffs, resample=Image.Resampling.BICUBIC) + return img.transform( + (w, h), Image.Transform.PERSPECTIVE, coeffs, resample=Image.Resampling.BICUBIC + ) def _radial_distortion(img: Image.Image, k: float = -1e-7) -> Image.Image: