From ac04f5a24fc09ac7ef83e07b8474cd207790332f Mon Sep 17 00:00:00 2001 From: CenFangyu <164994318+Dmao233@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:23:05 +0000 Subject: [PATCH 1/4] Fix BOX downscale dropping boundary source pixels (#9939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BOX coefficient ranges used (int)(center ± support + 0.5) plus a discontinuous kernel, so a source pixel whose center landed on an output bin edge could get weight 0 in every output. Tile downscale bins as (left, right] so each source pixel maps to one destination. Co-authored-by: CenFangyu --- Tests/test_image_resample.py | 23 +++++++++++++++++++++++ docs/releasenotes/13.0.0.rst | 9 +++++++++ src/libImaging/Resample.c | 35 ++++++++++++++++++++++++++++++----- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index edc0da3e917..b195be793db 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -274,6 +274,29 @@ def test_box_filter_correct_range(self) -> None: ref = Image.new("RGB", (100, 100), "#1688ff") assert_image_equal(im, ref) + def test_box_downscale_includes_boundary_pixels(self) -> None: + # 13→6 places source x=6 on a bin edge; old range rounding dropped it. + # See #9939 (2755→688 dropped the central column for the same reason). + im = Image.new("L", (13, 1), 255) + im.putpixel((6, 0), 0) + out = im.resize((6, 1), Image.Resampling.BOX) + assert list(out.getdata()) == [255, 255, 170, 255, 255, 255] + + im = Image.new("L", (1, 13), 255) + im.putpixel((0, 6), 0) + out = im.resize((1, 6), Image.Resampling.BOX) + assert list(out.getdata()) == [255, 255, 170, 255, 255, 255] + + im = Image.new("L", (2755, 1), 255) + im.putpixel((1377, 0), 0) + out = im.resize((688, 1), Image.Resampling.BOX) + assert min(out.getdata()) < 255 + + im = Image.new("L", (1, 1837), 255) + im.putpixel((0, 918), 0) + out = im.resize((1, 306), Image.Resampling.BOX) + assert min(out.getdata()) < 255 + class TestCoreResampleConsistency: def make_case( diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index b2bce2a84a4..36b17f67368 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -108,6 +108,15 @@ Two new filters are available for :py:meth:`~PIL.Image.Image.resize` and Other changes ============= +BOX downscale includes boundary source pixels +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:data:`PIL.Image.Resampling.BOX` downscaling could skip a source pixel whose +center landed on an output bin edge, due to range rounding and the discontinuous +box kernel. Those pixels are now assigned to exactly one output pixel, matching +the documented "each source pixel contributes to one destination pixel" behavior. +:issue:`9939` + Python 3.15 ^^^^^^^^^^^ diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index 9235571f554..33fde082d14 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -249,6 +249,13 @@ precompute_coeffs( /* maximum number of coeffs */ ksize = (int)ceil(support) * 2 + 1; + if (filterp == &BOX && scale > 1.0) { + /* One extra source pixel per output when a bin edge hits a center. */ + int needed = (int)scale + 2; + if (needed > ksize) { + ksize = needed; + } + } // check for overflow if (outSize > INT_MAX / (ksize * (int)sizeof(double))) { @@ -272,24 +279,42 @@ precompute_coeffs( return 0; } + /* BOX maps each source pixel to exactly one output (docs: identical + weights). When downscaling, (int)(center ± support + 0.5) and the + discontinuous box kernel can both miss a pixel whose center lands + on a bin edge (issue #9939). Tile bins as (left, right] instead. */ + int box_downscale = (filterp == &BOX && scale > 1.0); + double inv_filterscale = 1.0 / filterscale; // invariant over the loop for (xx = 0; xx < outSize; xx++) { double center = in0 + (xx + 0.5) * scale; double ww = 0.0; - // Round the value - xmin = (int)(center - support + 0.5); + if (box_downscale) { + double left = in0 + (double)xx * scale; + double right = in0 + (double)(xx + 1) * scale; + xmin = (int)floor(left - 0.5) + 1; + xmax = (int)floor(right - 0.5) + 1; + } else { + // Round the value + xmin = (int)(center - support + 0.5); + // Round the value + xmax = (int)(center + support + 0.5); + } if (xmin < 0) { xmin = 0; } - // Round the value - xmax = (int)(center + support + 0.5); if (xmax > inSize) { xmax = inSize; } xmax -= xmin; k = &kk[xx * ksize]; for (x = 0; x < xmax; x++) { - double w = filterp->filter((x + xmin - center + 0.5) * inv_filterscale); + double w; + if (box_downscale) { + w = 1.0; + } else { + w = filterp->filter((x + xmin - center + 0.5) * inv_filterscale); + } k[x] = w; ww += w; } From 297bd35037ad2703487a25014b406b3186875504 Mon Sep 17 00:00:00 2001 From: CenFangyu <164994318+Dmao233@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:23:43 +0000 Subject: [PATCH 2/4] Use get_flattened_data in BOX boundary regression test Avoid the deprecated Image.getdata API in the #9939 test. Co-authored-by: CenFangyu --- Tests/test_image_resample.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index b195be793db..3c5943e9191 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -280,22 +280,22 @@ def test_box_downscale_includes_boundary_pixels(self) -> None: im = Image.new("L", (13, 1), 255) im.putpixel((6, 0), 0) out = im.resize((6, 1), Image.Resampling.BOX) - assert list(out.getdata()) == [255, 255, 170, 255, 255, 255] + assert out.get_flattened_data() == (255, 255, 170, 255, 255, 255) im = Image.new("L", (1, 13), 255) im.putpixel((0, 6), 0) out = im.resize((1, 6), Image.Resampling.BOX) - assert list(out.getdata()) == [255, 255, 170, 255, 255, 255] + assert out.get_flattened_data() == (255, 255, 170, 255, 255, 255) im = Image.new("L", (2755, 1), 255) im.putpixel((1377, 0), 0) out = im.resize((688, 1), Image.Resampling.BOX) - assert min(out.getdata()) < 255 + assert min(out.get_flattened_data()) < 255 im = Image.new("L", (1, 1837), 255) im.putpixel((0, 918), 0) out = im.resize((1, 306), Image.Resampling.BOX) - assert min(out.getdata()) < 255 + assert min(out.get_flattened_data()) < 255 class TestCoreResampleConsistency: From b43c6700f942a0b8467e2fca64937d50be226cd4 Mon Sep 17 00:00:00 2001 From: CenFangyu <164994318+Dmao233@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:18:22 +0000 Subject: [PATCH 3/4] Address review: drop issue link, fix lint asserts Co-authored-by: CenFangyu --- Tests/test_image_resample.py | 4 ++-- docs/releasenotes/13.0.0.rst | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 3c5943e9191..47092925316 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -290,12 +290,12 @@ def test_box_downscale_includes_boundary_pixels(self) -> None: im = Image.new("L", (2755, 1), 255) im.putpixel((1377, 0), 0) out = im.resize((688, 1), Image.Resampling.BOX) - assert min(out.get_flattened_data()) < 255 + assert not all(value == 255 for value in out.get_flattened_data()) im = Image.new("L", (1, 1837), 255) im.putpixel((0, 918), 0) out = im.resize((1, 306), Image.Resampling.BOX) - assert min(out.get_flattened_data()) < 255 + assert not all(value == 255 for value in out.get_flattened_data()) class TestCoreResampleConsistency: diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 36b17f67368..a3d64053d75 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -115,7 +115,6 @@ BOX downscale includes boundary source pixels center landed on an output bin edge, due to range rounding and the discontinuous box kernel. Those pixels are now assigned to exactly one output pixel, matching the documented "each source pixel contributes to one destination pixel" behavior. -:issue:`9939` Python 3.15 ^^^^^^^^^^^ From 097e00f7db0c6b5e3991721958d634d436cc30ce Mon Sep 17 00:00:00 2001 From: CenFangyu Date: Fri, 4 Sep 2026 07:10:08 +0000 Subject: [PATCH 4/4] Clarify BOX equal-weight comment in Resample.c Co-authored-by: CenFangyu --- src/libImaging/Resample.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index 33fde082d14..e625291d8ed 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -279,10 +279,10 @@ precompute_coeffs( return 0; } - /* BOX maps each source pixel to exactly one output (docs: identical - weights). When downscaling, (int)(center ± support + 0.5) and the - discontinuous box kernel can both miss a pixel whose center lands - on a bin edge (issue #9939). Tile bins as (left, right] instead. */ + /* BOX maps each source pixel to exactly one output with equal weights. + When downscaling, (int)(center ± support + 0.5) and the discontinuous + box kernel can both miss a pixel whose center lands on a bin edge + (issue #9939). Tile bins as (left, right] instead. */ int box_downscale = (filterp == &BOX && scale > 1.0); double inv_filterscale = 1.0 / filterscale; // invariant over the loop