diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index edc0da3e917..47092925316 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 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 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 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 not all(value == 255 for value in out.get_flattened_data()) + class TestCoreResampleConsistency: def make_case( diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index b2bce2a84a4..a3d64053d75 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -108,6 +108,14 @@ 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. + Python 3.15 ^^^^^^^^^^^ diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index 9235571f554..e625291d8ed 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 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; 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; }