From 0c4c9b56e0c25bc9f9094a617906e604e576fa61 Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:54:12 +0800 Subject: [PATCH 1/5] Prevent negative ImageStat variance from floating-point rounding --- Tests/test_imagestat.py | 24 ++++++++++++++++++++++++ docs/releasenotes/13.0.0.rst | 8 ++++++++ src/PIL/ImageStat.py | 7 ++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagestat.py b/Tests/test_imagestat.py index 0baab7ce21e..155f44fe847 100644 --- a/Tests/test_imagestat.py +++ b/Tests/test_imagestat.py @@ -67,3 +67,27 @@ def test_zero_count() -> None: assert st.mean == [0] assert st.rms == [0] assert st.var == [0] + + +@pytest.mark.parametrize("mode", ("L", "RGB")) +@pytest.mark.parametrize("use_mask", (False, True)) +def test_variance_roundoff(mode: str, use_mask: bool) -> None: + im = Image.new( + mode, (919, 810 if use_mask else 405), 255 if mode == "L" else (255, 128, 0) + ) + mask = None + if use_mask: + im.paste(0, (0, 405, 919, 810)) + mask = Image.new("L", im.size) + mask.paste(255, (0, 0, 919, 405)) + + for st in (ImageStat.Stat(im, mask), ImageStat.Stat(im.histogram(mask))): + assert st.var == [0] * len(im.getbands()) + assert st.stddev == [0] * len(im.getbands()) + + +def test_nonzero_variance() -> None: + im = Image.frombytes("L", (2, 1), b"\x00\xff") + st = ImageStat.Stat(im) + assert st.var == [16256.25] + assert st.stddev == [127.5] diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 46dbed3b58b..27311e32f44 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -139,3 +139,11 @@ Fixed ImageChops.offset() for 16-bit images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:meth:`~PIL.ImageChops.offset` now works correctly for 16-bit-per-channel images. + +Fixed ImageStat variance rounding +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:attr:`~PIL.ImageStat.Stat.var` now returns zero when floating-point rounding +would otherwise produce a negative variance. This prevents +:py:attr:`~PIL.ImageStat.Stat.stddev` from raising a ``ValueError`` for some +constant-color images. diff --git a/src/PIL/ImageStat.py b/src/PIL/ImageStat.py index fde0256b2f8..4454ac07b73 100644 --- a/src/PIL/ImageStat.py +++ b/src/PIL/ImageStat.py @@ -151,9 +151,14 @@ def rms(self) -> list[float]: @cached_property def var(self) -> list[float]: """Variance for each band in the image.""" + # Roundoff can produce a negative variance for a constant image. return [ ( - (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i] + max( + 0.0, + (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) + / self.count[i], + ) if self.count[i] else 0 ) From a30031ac6e06ac77a2d5d5437d160a0de4e4d485 Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:16:11 +0800 Subject: [PATCH 2/5] Make ImageStat variance regression portable and fix release heading --- Tests/test_imagestat.py | 9 +++++++-- docs/releasenotes/13.0.0.rst | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Tests/test_imagestat.py b/Tests/test_imagestat.py index 155f44fe847..5f0d3a3acbb 100644 --- a/Tests/test_imagestat.py +++ b/Tests/test_imagestat.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math + import pytest from PIL import Image, ImageStat @@ -82,8 +84,11 @@ def test_variance_roundoff(mode: str, use_mask: bool) -> None: mask.paste(255, (0, 0, 919, 405)) for st in (ImageStat.Stat(im, mask), ImageStat.Stat(im.histogram(mask))): - assert st.var == [0] * len(im.getbands()) - assert st.stddev == [0] * len(im.getbands()) + # Different platforms can round toward either side of zero. Allow a + # few ULPs at the squared pixel scale, but reject any negative variance. + tolerance = 4 * math.ulp(255**2) + assert all(0 <= value <= tolerance for value in st.var) + assert all(0 <= value <= math.sqrt(tolerance) for value in st.stddev) def test_nonzero_variance() -> None: diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 27311e32f44..db7e1239627 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -141,7 +141,7 @@ Fixed ImageChops.offset() for 16-bit images :py:meth:`~PIL.ImageChops.offset` now works correctly for 16-bit-per-channel images. Fixed ImageStat variance rounding -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:attr:`~PIL.ImageStat.Stat.var` now returns zero when floating-point rounding would otherwise produce a negative variance. This prevents From 452d6688e88f9166397ac1f9a54bc63d6498a00e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 14 Sep 2026 13:58:59 +1000 Subject: [PATCH 3/5] Simplify tests --- Tests/test_imagestat.py | 29 +++-------------------------- docs/releasenotes/13.0.0.rst | 4 +--- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/Tests/test_imagestat.py b/Tests/test_imagestat.py index 5f0d3a3acbb..47e534696aa 100644 --- a/Tests/test_imagestat.py +++ b/Tests/test_imagestat.py @@ -1,7 +1,5 @@ from __future__ import annotations -import math - import pytest from PIL import Image, ImageStat @@ -71,28 +69,7 @@ def test_zero_count() -> None: assert st.var == [0] -@pytest.mark.parametrize("mode", ("L", "RGB")) -@pytest.mark.parametrize("use_mask", (False, True)) -def test_variance_roundoff(mode: str, use_mask: bool) -> None: - im = Image.new( - mode, (919, 810 if use_mask else 405), 255 if mode == "L" else (255, 128, 0) - ) - mask = None - if use_mask: - im.paste(0, (0, 405, 919, 810)) - mask = Image.new("L", im.size) - mask.paste(255, (0, 0, 919, 405)) - - for st in (ImageStat.Stat(im, mask), ImageStat.Stat(im.histogram(mask))): - # Different platforms can round toward either side of zero. Allow a - # few ULPs at the squared pixel scale, but reject any negative variance. - tolerance = 4 * math.ulp(255**2) - assert all(0 <= value <= tolerance for value in st.var) - assert all(0 <= value <= math.sqrt(tolerance) for value in st.stddev) - - -def test_nonzero_variance() -> None: - im = Image.frombytes("L", (2, 1), b"\x00\xff") +def test_variance_rounding() -> None: + im = Image.new("L", (919, 405), 255) st = ImageStat.Stat(im) - assert st.var == [16256.25] - assert st.stddev == [127.5] + assert st.var == [0.0] diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index db7e1239627..35579a4cc78 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -144,6 +144,4 @@ Fixed ImageStat variance rounding ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:attr:`~PIL.ImageStat.Stat.var` now returns zero when floating-point rounding -would otherwise produce a negative variance. This prevents -:py:attr:`~PIL.ImageStat.Stat.stddev` from raising a ``ValueError`` for some -constant-color images. +would otherwise produce a negative variance. From 06e7aac5f5d4042005e1c00d132bc405c2182208 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 14 Sep 2026 14:08:28 +1000 Subject: [PATCH 4/5] Use int in sum --- docs/releasenotes/13.0.0.rst | 4 ++-- src/PIL/ImageStat.py | 11 +++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 35579a4cc78..1f2ee494f01 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -143,5 +143,5 @@ Fixed ImageChops.offset() for 16-bit images Fixed ImageStat variance rounding ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -:py:attr:`~PIL.ImageStat.Stat.var` now returns zero when floating-point rounding -would otherwise produce a negative variance. +Fixed a floating-point rounding error that may have produced a negative variance in +:py:attr:`~PIL.ImageStat.Stat.var`. diff --git a/src/PIL/ImageStat.py b/src/PIL/ImageStat.py index 4454ac07b73..e1078f2004a 100644 --- a/src/PIL/ImageStat.py +++ b/src/PIL/ImageStat.py @@ -96,12 +96,12 @@ def count(self) -> list[int]: return [sum(self.h[i : i + 256]) for i in range(0, len(self.h), 256)] @cached_property - def sum(self) -> list[float]: + def sum(self) -> list[int]: """Sum of all pixels for each band in the image.""" v = [] for i in range(0, len(self.h), 256): - layer_sum = 0.0 + layer_sum = 0 for j in range(256): layer_sum += j * self.h[i + j] v.append(layer_sum) @@ -151,14 +151,9 @@ def rms(self) -> list[float]: @cached_property def var(self) -> list[float]: """Variance for each band in the image.""" - # Roundoff can produce a negative variance for a constant image. return [ ( - max( - 0.0, - (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) - / self.count[i], - ) + (self.sum2[i] - (self.sum[i] ** 2) / self.count[i]) / self.count[i] if self.count[i] else 0 ) From e5203abd793bb46037d4c94fcd0546cf7c961a23 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 14 Sep 2026 14:08:03 +1000 Subject: [PATCH 5/5] Return list of ints from sum2 --- src/PIL/ImageStat.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/ImageStat.py b/src/PIL/ImageStat.py index e1078f2004a..769c035b79b 100644 --- a/src/PIL/ImageStat.py +++ b/src/PIL/ImageStat.py @@ -108,14 +108,14 @@ def sum(self) -> list[int]: return v @cached_property - def sum2(self) -> list[float]: + def sum2(self) -> list[int]: """Squared sum of all pixels for each band in the image.""" v = [] for i in range(0, len(self.h), 256): - sum2 = 0.0 + sum2 = 0 for j in range(256): - sum2 += (j**2) * float(self.h[i + j]) + sum2 += (j**2) * self.h[i + j] v.append(sum2) return v