From aab732a5ba67657ac0f9e59041724de3ef3ed9b2 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Fri, 16 Oct 2026 00:54:28 -0700 Subject: [PATCH 1/3] Preserve persistence-image pixel counts and grid spacing --- RELEASE.txt | 3 ++ persim/images.py | 51 +++++++++++++++------------------ test/test_persistence_imager.py | 46 +++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 28 deletions(-) diff --git a/RELEASE.txt b/RELEASE.txt index 7806a0b9..36ce9402 100644 --- a/RELEASE.txt +++ b/RELEASE.txt @@ -1,3 +1,6 @@ +Unreleased + - Preserve integer persistence-image pixel counts and pad constructor ranges consistently with parameter updates. Reassigning an unchanged grid parameter keeps the grid unchanged. + 0.4.1 - Correct Lp norms of signed persistence landscapes, including zero crossings and fractional p. Compute segment integrals from endpoint magnitudes to avoid coordinate-intercept cancellation. diff --git a/persim/images.py b/persim/images.py index be43c932..181c07eb 100644 --- a/persim/images.py +++ b/persim/images.py @@ -345,12 +345,13 @@ def __init__( self._pixel_size = pixel_size self._birth_range = birth_range self._pers_range = pers_range - self._width = birth_range[1] - birth_range[0] - self._height = pers_range[1] - pers_range[0] + # Retain integer counts: dividing rounded widths can lose a pixel. self._resolution = ( - int(self._width / self._pixel_size), - int(self._height / self._pixel_size), + int(np.ceil((birth_range[1] - birth_range[0]) / pixel_size)), + int(np.ceil((pers_range[1] - pers_range[0]) / pixel_size)), ) + self._width = self._resolution[0] * pixel_size + self._height = self._resolution[1] * pixel_size self._create_mesh() @property @@ -403,19 +404,15 @@ def pixel_size(self): @pixel_size.setter def pixel_size(self, val): + if val == self._pixel_size: + return self._pixel_size = val - self._width = ( - int(np.ceil((self.birth_range[1] - self.birth_range[0]) / self.pixel_size)) - * self.pixel_size - ) - self._height = ( - int(np.ceil((self.pers_range[1] - self.pers_range[0]) / self.pixel_size)) - * self.pixel_size - ) self._resolution = ( - int(self.width / self.pixel_size), - int(self.height / self.pixel_size), + int(np.ceil((self.birth_range[1] - self.birth_range[0]) / self.pixel_size)), + int(np.ceil((self.pers_range[1] - self.pers_range[0]) / self.pixel_size)), ) + self._width = self._resolution[0] * self.pixel_size + self._height = self._resolution[1] * self.pixel_size self._create_mesh() @property @@ -432,15 +429,14 @@ def birth_range(self): @birth_range.setter def birth_range(self, val): + if np.array_equal(val, self._birth_range): + return self._birth_range = val - self._width = ( - int(np.ceil((self.birth_range[1] - self.birth_range[0]) / self.pixel_size)) - * self._pixel_size - ) - self._resolution = ( - int(self.width / self.pixel_size), - int(self.height / self.pixel_size), + count = int( + np.ceil((self.birth_range[1] - self.birth_range[0]) / self.pixel_size) ) + self._width = count * self.pixel_size + self._resolution = (count, self.resolution[1]) self._create_mesh() @property @@ -457,15 +453,14 @@ def pers_range(self): @pers_range.setter def pers_range(self, val): + if np.array_equal(val, self._pers_range): + return self._pers_range = val - self._height = ( - int(np.ceil((self.pers_range[1] - self.pers_range[0]) / self.pixel_size)) - * self._pixel_size - ) - self._resolution = ( - int(self.width / self.pixel_size), - int(self.height / self.pixel_size), + count = int( + np.ceil((self.pers_range[1] - self.pers_range[0]) / self.pixel_size) ) + self._height = count * self.pixel_size + self._resolution = (self.resolution[0], count) self._create_mesh() def __repr__(self): diff --git a/test/test_persistence_imager.py b/test/test_persistence_imager.py index f3a095c7..299ecc9b 100644 --- a/test/test_persistence_imager.py +++ b/test/test_persistence_imager.py @@ -1,4 +1,5 @@ import numpy as np +import pytest from persim import PersistenceImager, images_kernels, images_weights @@ -7,6 +8,51 @@ # ---------------------------------------- +@pytest.mark.parametrize( + "width,pixel_size,count", + [(0.3, 0.1, 3), (4.3, 0.1, 43), (0.95, 0.2, 5), (1.3, 0.3, 5)], +) +@pytest.mark.parametrize("operation", ["constructor", "pixel_size", "birth_range", "pers_range"]) +def test_pixel_grid_dimensions(width, pixel_size, count, operation): + if operation == "constructor": + imager = PersistenceImager( + birth_range=(0, width), pers_range=(0, width), pixel_size=pixel_size + ) + expected = (count, count) + elif operation == "pixel_size": + imager = PersistenceImager( + birth_range=(0, width), pers_range=(0, width), pixel_size=width + ) + imager.pixel_size = pixel_size + expected = (count, count) + else: + imager = PersistenceImager( + birth_range=(0, pixel_size), + pers_range=(0, pixel_size), + pixel_size=pixel_size, + ) + setattr(imager, operation, (0, width)) + expected = (count, 1) if operation == "birth_range" else (1, count) + + assert imager.resolution == expected + assert imager.transform(np.array([[0.1, 0.2]])).shape == expected + np.testing.assert_allclose(np.diff(imager._bpnts), pixel_size, rtol=1e-13) + np.testing.assert_allclose(np.diff(imager._ppnts), pixel_size, rtol=1e-13) + assert imager._bpnts[-1] == pytest.approx(imager.birth_range[1]) + assert imager._ppnts[-1] == pytest.approx(imager.pers_range[1]) + + +@pytest.mark.parametrize("attribute", ["pixel_size", "birth_range", "pers_range"]) +def test_unchanged_grid_parameter(attribute): + imager = PersistenceImager(birth_range=(0, 0.3), pers_range=(0, 0.3), pixel_size=0.1) + before = (imager.resolution, imager.birth_range, imager.pers_range) + birth_points, persistence_points = imager._bpnts.copy(), imager._ppnts.copy() + setattr(imager, attribute, getattr(imager, attribute)) + assert (imager.resolution, imager.birth_range, imager.pers_range) == before + np.testing.assert_array_equal(imager._bpnts, birth_points) + np.testing.assert_array_equal(imager._ppnts, persistence_points) + + def test_empty_diagram(): dgm = np.zeros((0, 2)) persimgr = PersistenceImager(pixel_size=0.1) From 87c248cd12a090d207ad3901b03c21e718e09dde Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Fri, 16 Oct 2026 01:02:05 -0700 Subject: [PATCH 2/3] Correct pixel-count roundoff against the covered width --- RELEASE.txt | 2 +- persim/images.py | 27 +++++++++++++++++---------- test/test_persistence_imager.py | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/RELEASE.txt b/RELEASE.txt index 36ce9402..ee643a13 100644 --- a/RELEASE.txt +++ b/RELEASE.txt @@ -1,5 +1,5 @@ Unreleased - - Preserve integer persistence-image pixel counts and pad constructor ranges consistently with parameter updates. Reassigning an unchanged grid parameter keeps the grid unchanged. + - Preserve integer persistence-image pixel counts and pad constructor ranges consistently with parameter updates. Correct division roundoff against the actual covered width. Reassigning an unchanged grid parameter keeps the grid unchanged. 0.4.1 - Correct Lp norms of signed persistence landscapes, including zero crossings and fractional p. Compute segment integrals from endpoint magnitudes to avoid coordinate-intercept cancellation. diff --git a/persim/images.py b/persim/images.py index 181c07eb..49be0c94 100644 --- a/persim/images.py +++ b/persim/images.py @@ -17,6 +17,17 @@ __all__ = ["PersImage", "PersistenceImager"] +def _pixel_count(bounds, pixel_size): + """Count covering pixels without quotient-rounding an exact multiple up.""" + span = bounds[1] - bounds[0] + count = int(np.ceil(span / pixel_size)) + if count > 0 and (count - 1) * pixel_size >= span: + return count - 1 + if count * pixel_size < span: + return count + 1 + return count + + @deprecated( reason="""Replaced with the class :class:`persim.PersistenceImager`.""", version="0.1.5", @@ -347,8 +358,8 @@ def __init__( self._pers_range = pers_range # Retain integer counts: dividing rounded widths can lose a pixel. self._resolution = ( - int(np.ceil((birth_range[1] - birth_range[0]) / pixel_size)), - int(np.ceil((pers_range[1] - pers_range[0]) / pixel_size)), + _pixel_count(birth_range, pixel_size), + _pixel_count(pers_range, pixel_size), ) self._width = self._resolution[0] * pixel_size self._height = self._resolution[1] * pixel_size @@ -408,8 +419,8 @@ def pixel_size(self, val): return self._pixel_size = val self._resolution = ( - int(np.ceil((self.birth_range[1] - self.birth_range[0]) / self.pixel_size)), - int(np.ceil((self.pers_range[1] - self.pers_range[0]) / self.pixel_size)), + _pixel_count(self.birth_range, self.pixel_size), + _pixel_count(self.pers_range, self.pixel_size), ) self._width = self._resolution[0] * self.pixel_size self._height = self._resolution[1] * self.pixel_size @@ -432,9 +443,7 @@ def birth_range(self, val): if np.array_equal(val, self._birth_range): return self._birth_range = val - count = int( - np.ceil((self.birth_range[1] - self.birth_range[0]) / self.pixel_size) - ) + count = _pixel_count(self.birth_range, self.pixel_size) self._width = count * self.pixel_size self._resolution = (count, self.resolution[1]) self._create_mesh() @@ -456,9 +465,7 @@ def pers_range(self, val): if np.array_equal(val, self._pers_range): return self._pers_range = val - count = int( - np.ceil((self.pers_range[1] - self.pers_range[0]) / self.pixel_size) - ) + count = _pixel_count(self.pers_range, self.pixel_size) self._height = count * self.pixel_size self._resolution = (self.resolution[0], count) self._create_mesh() diff --git a/test/test_persistence_imager.py b/test/test_persistence_imager.py index 299ecc9b..9ec8c675 100644 --- a/test/test_persistence_imager.py +++ b/test/test_persistence_imager.py @@ -53,6 +53,21 @@ def test_unchanged_grid_parameter(attribute): np.testing.assert_array_equal(imager._ppnts, persistence_points) +@pytest.mark.parametrize("pixel_size", [0.1, 0.2, 0.3, 0.05]) +@pytest.mark.parametrize("operation", ["constructor", "pixel_size"]) +def test_integer_pixel_multiple(pixel_size, operation): + width = 3 * pixel_size + imager = PersistenceImager( + birth_range=(0, width), + pers_range=(0, width), + pixel_size=pixel_size if operation == "constructor" else width, + ) + if operation == "pixel_size": + imager.pixel_size = pixel_size + assert imager.resolution == (3, 3) + np.testing.assert_allclose(np.diff(imager._bpnts), pixel_size, rtol=1e-13) + + def test_empty_diagram(): dgm = np.zeros((0, 2)) persimgr = PersistenceImager(pixel_size=0.1) From bd476de652bb5f6b59ffc5997ca7a04da6b50357 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Fri, 16 Oct 2026 01:11:20 -0700 Subject: [PATCH 3/3] Keep decimal grid widths at their rounded pixel count --- persim/images.py | 2 -- test/test_persistence_imager.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/persim/images.py b/persim/images.py index 49be0c94..96357de0 100644 --- a/persim/images.py +++ b/persim/images.py @@ -23,8 +23,6 @@ def _pixel_count(bounds, pixel_size): count = int(np.ceil(span / pixel_size)) if count > 0 and (count - 1) * pixel_size >= span: return count - 1 - if count * pixel_size < span: - return count + 1 return count diff --git a/test/test_persistence_imager.py b/test/test_persistence_imager.py index 9ec8c675..0e0a0afb 100644 --- a/test/test_persistence_imager.py +++ b/test/test_persistence_imager.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize( "width,pixel_size,count", - [(0.3, 0.1, 3), (4.3, 0.1, 43), (0.95, 0.2, 5), (1.3, 0.3, 5)], + [(0.3, 0.1, 3), (4.3, 0.1, 43), (0.95, 0.2, 5), (1.3, 0.3, 5), (0.9, 0.3, 3)], ) @pytest.mark.parametrize("operation", ["constructor", "pixel_size", "birth_range", "pers_range"]) def test_pixel_grid_dimensions(width, pixel_size, count, operation):