diff --git a/RELEASE.txt b/RELEASE.txt index 7806a0b..ee643a1 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. 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 be43c93..96357de 100644 --- a/persim/images.py +++ b/persim/images.py @@ -17,6 +17,15 @@ __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 + return count + + @deprecated( reason="""Replaced with the class :class:`persim.PersistenceImager`.""", version="0.1.5", @@ -345,12 +354,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), + _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 self._create_mesh() @property @@ -403,19 +413,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), + _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 self._create_mesh() @property @@ -432,15 +438,12 @@ 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 = _pixel_count(self.birth_range, self.pixel_size) + self._width = count * self.pixel_size + self._resolution = (count, self.resolution[1]) self._create_mesh() @property @@ -457,15 +460,12 @@ 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 = _pixel_count(self.pers_range, 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 f3a095c..0e0a0af 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,66 @@ # ---------------------------------------- +@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.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): + 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) + + +@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)