From 76810168f8bd9baa953c5136f5f6f5b7c7c1a60e Mon Sep 17 00:00:00 2001
From: Hayato Ikoma <hayato.ikoma@10xgenomics.com>
Date: Fri, 20 Mar 2026 22:16:59 +0000
Subject: [PATCH 1/8] Fix integer overflow in 16-bit resampling

This commit fixes a bug in Resample.c where downsampling 16-bit
images (I;16) using filters with negative lobes (such as
Image.Resampling.LANCZOS) could result in byte corruption.

Because Lanczos weighting can create overshoots (ringing artifacts)
near sharp edges, the accumulated floating-point sum can sometimes
exceed the 16-bit maximum (65535) or fall below zero. Previously,
these out-of-bounds values were not correctly clamped before being
cast or packed into the 16-bit output buffer, leading to integer
overflow/underflow and corrupted pixels.

This update correctly clamps the accumulated float values to the
[0, 65535] range for I;16 images during resampling.
---
 Tests/test_image_resample.py | 34 ++++++++++++++++++++++++++++++++++
 src/libImaging/Resample.c    | 18 ++++++++++++++----
 2 files changed, 48 insertions(+), 4 deletions(-)

diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py
index 73b25ed51b2..0da8856b03e 100644
--- a/Tests/test_image_resample.py
+++ b/Tests/test_image_resample.py
@@ -627,3 +627,37 @@ def test_skip_vertical(self, flt: Image.Resampling) -> None:
                 0.4,
                 f">>> {size} {box} {flt}",
             )
+
+
+class TestCoreResample16bpc:
+    def test_resampling_clamp(self) -> None:
+        # Lanczos weighting during downsampling can push accumulated float sums
+        # above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
+        width, height = 100, 10
+        # I;16 image: left half = 0, right half = 65535
+        im_16 = Image.new("I;16", (width, height))
+        for y in range(height):
+            for x in range(width // 2, width):
+                im_16.putpixel((x, y), 65535)
+        # F image: same values as float reference
+        im_f = Image.new("F", (width, height))
+        for y in range(height):
+            for x in range(width // 2, width):
+                im_f.putpixel((x, y), 65535.0)
+
+        # 5x downsampling with Lanczos creates ~8.7% overshoot at the step edge
+        result_16 = im_16.resize((20, height), Image.Resampling.LANCZOS)
+        result_f = im_f.resize((20, height), Image.Resampling.LANCZOS)
+
+        px_16 = result_16.load()
+        px_f = result_f.load()
+        assert px_16 is not None
+        assert px_f is not None
+        for y in range(height):
+            for x in range(20):
+                v = px_f[x, y]
+                assert isinstance(v, float)
+                expected = max(0, min(65535, round(v)))
+                assert (
+                    px_16[x, y] == expected
+                ), f"Pixel ({x}, {y}): expected {expected}, got {px_16[x, y]}"
diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c
index cbd18d0c116..760e0ee6987 100644
--- a/src/libImaging/Resample.c
+++ b/src/libImaging/Resample.c
@@ -493,8 +493,13 @@ ImagingResampleHorizontal_16bpc(
                       k[x];
             }
             ss_int = ROUND_UP(ss);
-            imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256);
-            imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8);
+            if (ss_int < 0) {
+                ss_int = 0;
+            } else if (ss_int > 65535) {
+                ss_int = 65535;
+            }
+            imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF;
+            imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8;
         }
     }
     ImagingSectionLeave(&cookie);
@@ -532,8 +537,13 @@ ImagingResampleVertical_16bpc(
                       k[y];
             }
             ss_int = ROUND_UP(ss);
-            imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256);
-            imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8);
+            if (ss_int < 0) {
+                ss_int = 0;
+            } else if (ss_int > 65535) {
+                ss_int = 65535;
+            }
+            imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF;
+            imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8;
         }
     }
     ImagingSectionLeave(&cookie);

From d4bf19f15ce4952f2ef1ac4ed5ceea2ce4ef88b4 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 21 Mar 2026 10:28:32 +1100
Subject: [PATCH 2/8] Simplified code

---
 Tests/test_image_resample.py | 34 ++++++++++++++--------------------
 1 file changed, 14 insertions(+), 20 deletions(-)

diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py
index 0da8856b03e..385408e8731 100644
--- a/Tests/test_image_resample.py
+++ b/Tests/test_image_resample.py
@@ -633,31 +633,25 @@ class TestCoreResample16bpc:
     def test_resampling_clamp(self) -> None:
         # Lanczos weighting during downsampling can push accumulated float sums
         # above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
+        ims = {}
         width, height = 100, 10
-        # I;16 image: left half = 0, right half = 65535
-        im_16 = Image.new("I;16", (width, height))
-        for y in range(height):
-            for x in range(width // 2, width):
-                im_16.putpixel((x, y), 65535)
-        # F image: same values as float reference
-        im_f = Image.new("F", (width, height))
-        for y in range(height):
-            for x in range(width // 2, width):
-                im_f.putpixel((x, y), 65535.0)
+        for mode in ("I;16", "F"):
+            # Left half = 0, right half = 65535
+            im = Image.new(mode, (width, height))
+            for y in range(height):
+                for x in range(width // 2, width):
+                    im.putpixel((x, y), 65535)
 
-        # 5x downsampling with Lanczos creates ~8.7% overshoot at the step edge
-        result_16 = im_16.resize((20, height), Image.Resampling.LANCZOS)
-        result_f = im_f.resize((20, height), Image.Resampling.LANCZOS)
+            # 5x downsampling with Lanczos creates ~8.7% overshoot at the step edge
+            ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS)
 
-        px_16 = result_16.load()
-        px_f = result_f.load()
-        assert px_16 is not None
-        assert px_f is not None
         for y in range(height):
             for x in range(20):
-                v = px_f[x, y]
+                v = ims["F"].getpixel((x, y))
                 assert isinstance(v, float)
                 expected = max(0, min(65535, round(v)))
+
+                value = ims["I;16"].getpixel((x, y))
                 assert (
-                    px_16[x, y] == expected
-                ), f"Pixel ({x}, {y}): expected {expected}, got {px_16[x, y]}"
+                    value == expected
+                ), f"Pixel ({x}, {y}): expected {expected}, got {value}"

From 354daade73b84a7d50c2455ae07eeacd1e84fb5c Mon Sep 17 00:00:00 2001
From: Hayato Ikoma <hayato.ikoma@10xgenomics.com>
Date: Wed, 25 Mar 2026 18:37:44 +0000
Subject: [PATCH 3/8] add a unit test for underflow

---
 Tests/test_image_resample.py | 28 +++++++++++++++++++++++++++-
 1 file changed, 27 insertions(+), 1 deletion(-)

diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py
index 385408e8731..77185900fa2 100644
--- a/Tests/test_image_resample.py
+++ b/Tests/test_image_resample.py
@@ -630,7 +630,7 @@ def test_skip_vertical(self, flt: Image.Resampling) -> None:
 
 
 class TestCoreResample16bpc:
-    def test_resampling_clamp(self) -> None:
+    def test_resampling_clamp_overflow(self) -> None:
         # Lanczos weighting during downsampling can push accumulated float sums
         # above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
         ims = {}
@@ -655,3 +655,29 @@ def test_resampling_clamp(self) -> None:
                 assert (
                     value == expected
                 ), f"Pixel ({x}, {y}): expected {expected}, got {value}"
+
+    def test_resampling_clamp_underflow(self) -> None:
+        # Lanczos weighting during downsampling can push accumulated float sums
+        # below 0. These must be clamped to 0, not corrupted byte-by-byte.
+        ims = {}
+        width, height = 100, 10
+        for mode in ("I;16", "F"):
+            # Left half = 65535, right half = 0
+            im = Image.new(mode, (width, height))
+            for y in range(height):
+                for x in range(width // 2):
+                    im.putpixel((x, y), 65535)
+
+            # 5x downsampling with Lanczos creates ~8.7% undershoot at the step edge
+            ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS)
+
+        for y in range(height):
+            for x in range(20):
+                v = ims["F"].getpixel((x, y))
+                assert isinstance(v, float)
+                expected = max(0, min(65535, round(v)))
+
+                value = ims["I;16"].getpixel((x, y))
+                assert (
+                    value == expected
+                ), f"Pixel ({x}, {y}): expected {expected}, got {value}"

From 4c636df323ceb0b05052eb70c6301808f493085f Mon Sep 17 00:00:00 2001
From: Hayato Ikoma <hayato.ikoma@10xgenomics.com>
Date: Fri, 27 Mar 2026 21:58:12 +0000
Subject: [PATCH 4/8] Define CLIP16

---
 src/libImaging/ImagingUtils.h |  2 ++
 src/libImaging/Resample.c     | 14 ++------------
 2 files changed, 4 insertions(+), 12 deletions(-)

diff --git a/src/libImaging/ImagingUtils.h b/src/libImaging/ImagingUtils.h
index 714458ad02a..a362780d0ee 100644
--- a/src/libImaging/ImagingUtils.h
+++ b/src/libImaging/ImagingUtils.h
@@ -27,6 +27,8 @@
 
 #define CLIP8(v) ((v) <= 0 ? 0 : (v) < 256 ? (v) : 255)
 
+#define CLIP16(v) ((v) <= 0 ? 0 : (v) < 65536 ? (v) : 65535)
+
 /* This is to work around a bug in GCC prior 4.9 in 64 bit mode.
    GCC generates code with partial dependency which is 3 times slower.
    See: https://stackoverflow.com/a/26588074/253146 */
diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c
index 760e0ee6987..a3625701455 100644
--- a/src/libImaging/Resample.c
+++ b/src/libImaging/Resample.c
@@ -492,12 +492,7 @@ ImagingResampleHorizontal_16bpc(
                         << 8)) *
                       k[x];
             }
-            ss_int = ROUND_UP(ss);
-            if (ss_int < 0) {
-                ss_int = 0;
-            } else if (ss_int > 65535) {
-                ss_int = 65535;
-            }
+            ss_int = CLIP16(ROUND_UP(ss));
             imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF;
             imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8;
         }
@@ -536,12 +531,7 @@ ImagingResampleVertical_16bpc(
                        (imIn->image8[y + ymin][xx * 2 + (bigendian ? 0 : 1)] << 8)) *
                       k[y];
             }
-            ss_int = ROUND_UP(ss);
-            if (ss_int < 0) {
-                ss_int = 0;
-            } else if (ss_int > 65535) {
-                ss_int = 65535;
-            }
+            ss_int = CLIP16(ROUND_UP(ss));
             imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF;
             imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8;
         }

From a04819817f16e3cd7157727c3a703a72820635e0 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 28 Mar 2026 13:59:13 +1100
Subject: [PATCH 5/8] Parametrize tests

---
 Tests/test_image_resample.py | 46 +++++++++++-------------------------
 1 file changed, 14 insertions(+), 32 deletions(-)

diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py
index 77185900fa2..c2848ba3422 100644
--- a/Tests/test_image_resample.py
+++ b/Tests/test_image_resample.py
@@ -630,45 +630,27 @@ def test_skip_vertical(self, flt: Image.Resampling) -> None:
 
 
 class TestCoreResample16bpc:
-    def test_resampling_clamp_overflow(self) -> None:
-        # Lanczos weighting during downsampling can push accumulated float sums
-        # above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
-        ims = {}
-        width, height = 100, 10
-        for mode in ("I;16", "F"):
-            # Left half = 0, right half = 65535
-            im = Image.new(mode, (width, height))
-            for y in range(height):
-                for x in range(width // 2, width):
-                    im.putpixel((x, y), 65535)
-
-            # 5x downsampling with Lanczos creates ~8.7% overshoot at the step edge
-            ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS)
-
-        for y in range(height):
-            for x in range(20):
-                v = ims["F"].getpixel((x, y))
-                assert isinstance(v, float)
-                expected = max(0, min(65535, round(v)))
-
-                value = ims["I;16"].getpixel((x, y))
-                assert (
-                    value == expected
-                ), f"Pixel ({x}, {y}): expected {expected}, got {value}"
-
-    def test_resampling_clamp_underflow(self) -> None:
-        # Lanczos weighting during downsampling can push accumulated float sums
-        # below 0. These must be clamped to 0, not corrupted byte-by-byte.
+    # Lanczos weighting during downsampling can push accumulated float sums
+    @pytest.mark.parametrize(
+        "width_range",
+        (
+            # below 0. These must be clamped to 0, not corrupted byte-by-byte.
+            (0, 50),  # Left half = 65535, right half = 0
+            # above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
+            (50, 100),  # # Left half = 0, right half = 65535
+        ),
+    )
+    def test_resampling_clamp_overflow(self, width_range: tuple[int, int]) -> None:
         ims = {}
         width, height = 100, 10
         for mode in ("I;16", "F"):
-            # Left half = 65535, right half = 0
             im = Image.new(mode, (width, height))
             for y in range(height):
-                for x in range(width // 2):
+                for x in range(*width_range):
                     im.putpixel((x, y), 65535)
 
-            # 5x downsampling with Lanczos creates ~8.7% undershoot at the step edge
+            # 5x downsampling with Lanczos
+            # creates ~8.7% overshoot or undershoot at the step edge
             ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS)
 
         for y in range(height):

From 224cb6850c29850a9dd12993b9c576a39870ed57 Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 28 Mar 2026 14:01:50 +1100
Subject: [PATCH 6/8] Use paste

---
 Tests/test_image_resample.py | 12 +++++-------
 1 file changed, 5 insertions(+), 7 deletions(-)

diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py
index c2848ba3422..536ab0baf71 100644
--- a/Tests/test_image_resample.py
+++ b/Tests/test_image_resample.py
@@ -632,22 +632,20 @@ def test_skip_vertical(self, flt: Image.Resampling) -> None:
 class TestCoreResample16bpc:
     # Lanczos weighting during downsampling can push accumulated float sums
     @pytest.mark.parametrize(
-        "width_range",
+        "x",
         (
             # below 0. These must be clamped to 0, not corrupted byte-by-byte.
-            (0, 50),  # Left half = 65535, right half = 0
+            0,  # Left half = 65535, right half = 0
             # above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
-            (50, 100),  # # Left half = 0, right half = 65535
+            50,  # # Left half = 0, right half = 65535
         ),
     )
-    def test_resampling_clamp_overflow(self, width_range: tuple[int, int]) -> None:
+    def test_resampling_clamp_overflow(self, x: int) -> None:
         ims = {}
         width, height = 100, 10
         for mode in ("I;16", "F"):
             im = Image.new(mode, (width, height))
-            for y in range(height):
-                for x in range(*width_range):
-                    im.putpixel((x, y), 65535)
+            im.paste(65535, (x, 0, x + width // 2, height))
 
             # 5x downsampling with Lanczos
             # creates ~8.7% overshoot or undershoot at the step edge

From 968b2dd23ead9a8f22f2a604e7787540d3d134ad Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 28 Mar 2026 17:31:32 +1100
Subject: [PATCH 7/8] Renamed parameter

---
 Tests/test_image_resample.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py
index 536ab0baf71..f188be81ecb 100644
--- a/Tests/test_image_resample.py
+++ b/Tests/test_image_resample.py
@@ -632,7 +632,7 @@ def test_skip_vertical(self, flt: Image.Resampling) -> None:
 class TestCoreResample16bpc:
     # Lanczos weighting during downsampling can push accumulated float sums
     @pytest.mark.parametrize(
-        "x",
+        "offset",
         (
             # below 0. These must be clamped to 0, not corrupted byte-by-byte.
             0,  # Left half = 65535, right half = 0
@@ -640,12 +640,12 @@ class TestCoreResample16bpc:
             50,  # # Left half = 0, right half = 65535
         ),
     )
-    def test_resampling_clamp_overflow(self, x: int) -> None:
+    def test_resampling_clamp_overflow(self, offset: int) -> None:
         ims = {}
         width, height = 100, 10
         for mode in ("I;16", "F"):
             im = Image.new(mode, (width, height))
-            im.paste(65535, (x, 0, x + width // 2, height))
+            im.paste(65535, (offset, 0, offset + width // 2, height))
 
             # 5x downsampling with Lanczos
             # creates ~8.7% overshoot or undershoot at the step edge

From bb086c3e9ce222d018f60089cb63d047048f141a Mon Sep 17 00:00:00 2001
From: Andrew Murray <radarhere@users.noreply.github.com>
Date: Sat, 11 Apr 2026 17:30:42 +1000
Subject: [PATCH 8/8] CLIP16 is already defined in ImagingUtils.h

---
 src/libImaging/Convert.c | 2 --
 1 file changed, 2 deletions(-)

diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c
index 002497c3264..f156810ffa2 100644
--- a/src/libImaging/Convert.c
+++ b/src/libImaging/Convert.c
@@ -37,8 +37,6 @@
 #define MAX(a, b) (a) > (b) ? (a) : (b)
 #define MIN(a, b) (a) < (b) ? (a) : (b)
 
-#define CLIP16(v) ((v) <= 0 ? 0 : (v) >= 65535 ? 65535 : (v))
-
 /* ITU-R Recommendation 601-2 (assuming nonlinear RGB) */
 #define L(rgb) ((INT32)(rgb)[0] * 299 + (INT32)(rgb)[1] * 587 + (INT32)(rgb)[2] * 114)
 #define L24(rgb) ((rgb)[0] * 19595 + (rgb)[1] * 38470 + (rgb)[2] * 7471 + 0x8000)
