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/7] 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/7] 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/7] 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/7] 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 From b7947f7f70dda48b25ccd97834549a577f5b2201 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 12 Sep 2026 16:39:21 +1000 Subject: [PATCH 5/7] Simplify code --- src/libImaging/Resample.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index e625291d8ed..44eefef547d 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -249,7 +249,13 @@ precompute_coeffs( /* maximum number of coeffs */ ksize = (int)ceil(support) * 2 + 1; - if (filterp == &BOX && scale > 1.0) { + + /* 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; + if (box_downscale) { /* One extra source pixel per output when a bin edge hits a center. */ int needed = (int)scale + 2; if (needed > ksize) { @@ -279,12 +285,6 @@ precompute_coeffs( return 0; } - /* 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 for (xx = 0; xx < outSize; xx++) { double center = in0 + (xx + 0.5) * scale; From f1670bf323ca687a96453524bfde06a49563d15a Mon Sep 17 00:00:00 2001 From: CenFangyu <164994318+Dmao233@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:20:22 +0800 Subject: [PATCH 6/7] Test BOX downscale does not count a centre pixel twice --- Tests/test_image_resample.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 81e23fbb644..84497577166 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -296,6 +296,18 @@ def test_box_downscale_includes_boundary_pixels(self) -> None: out = im.resize((1, 306), Image.Resampling.BOX) assert not all(value == 255 for value in out.get_flattened_data()) + def test_box_downscale_counts_each_pixel_once(self) -> None: + # 15→14 used to count the central source pixel in two outputs. + im = Image.new("L", (15, 1)) + im.putpixel((7, 0), 255) + out = im.resize((14, 1), Image.Resampling.BOX) + assert [value for value in out.get_flattened_data() if value] == [128] + + im = Image.new("L", (1, 15)) + im.putpixel((0, 7), 255) + out = im.resize((1, 14), Image.Resampling.BOX) + assert [value for value in out.get_flattened_data() if value] == [128] + class TestCoreResampleConsistency: def make_case( From 7720f83e1344e88ab95e948c0750aeb26cadea50 Mon Sep 17 00:00:00 2001 From: CenFangyu <164994318+Dmao233@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:20:56 +0800 Subject: [PATCH 7/7] docs: match BOX filter quote and soften Resample.c comment wording Per review feedback on #9949. --- docs/releasenotes/13.1.0.rst | 9 +++++---- src/libImaging/Resample.c | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 3d46a321e55..fc3c30d7b00 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -52,10 +52,11 @@ 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. +: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 +behavior that "Each pixel of the source image contributes to one pixel of the +destination image with identical weights" Blurring I;16 images ^^^^^^^^^^^^^^^^^^^^ diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index a15a6606840..78a1b25b131 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -250,10 +250,10 @@ precompute_coeffs( /* maximum number of coeffs */ ksize = (int)ceil(support) * 2 + 1; - /* 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. */ + /* For BOX, each source pixel contributes to one output pixel 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; if (box_downscale) { /* One extra source pixel per output when a bin edge hits a center. */