From 303cd4813a9e17c8d0950b2b62afc1c0841e5a3d Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Sat, 6 Jun 2026 20:18:48 +0200 Subject: [PATCH 1/3] perf(sizeshape): scatter-based spatial moments + inertia, replacing regionprops einsum (~1.6x) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_sizeshape`'s moment features came from `regionprops_table`'s `moments` / `moments_central` / `moments_normalized` / `moments_hu` columns plus the `inertia_tensor` / `inertia_tensor_eigvals`, all computed per-region with an `einsum` routine whose contraction path is re-derived for every object. On a 1080^2 / 142-object tile cProfile showed this moment machinery dominated the call (~120 ms of 352 ms, ~40 ms of it pure `einsum_path` overhead). Moments are plain reductions, so compute the whole set in one label-scatter pass (`primitives/_moments.py`): raw spatial moments in each object's local bbox frame, central moments by direct centred summation, then normalized + the 7 Hu invariants. The 2D inertia tensor and its eigenvalues are derived from the same central moments (`inertia_2d`), so for 2D regionprops now computes *no* moments at all — its per-region einsum is gone entirely. Raw spatial moments are bit-exact vs regionprops; the centroid-dependent matrices and inertia match to ~1e-13 relative (moments reach ~1e8 magnitude). Object order (ascending label) and the NaN convention for normalized moments (p+q<2) match regionprops. 2D only; the 3D path is unchanged. Measured: get_sizeshape 352 -> 220 ms (1.6x), large tile. No new deps. `spatial_moments_2d` / `inertia_2d` live in `primitives/` so the planned numba sizeshape lane can reuse them. Adds golden tests vs regionprops (raw bit-exact; central/normalized/Hu/inertia across multi / non-contiguous / edge-touching / single-pixel / empty + end-to-end get_sizeshape wiring). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cp_measure/core/measureobjectsizeshape.py | 136 +++++++------- src/cp_measure/primitives/_moments.py | 136 ++++++++++++++ test/test_sizeshape_moments.py | 174 ++++++++++++++++++ 3 files changed, 377 insertions(+), 69 deletions(-) create mode 100644 src/cp_measure/primitives/_moments.py create mode 100644 test/test_sizeshape_moments.py diff --git a/src/cp_measure/core/measureobjectsizeshape.py b/src/cp_measure/core/measureobjectsizeshape.py index 0e32b17..a042f6e 100644 --- a/src/cp_measure/core/measureobjectsizeshape.py +++ b/src/cp_measure/core/measureobjectsizeshape.py @@ -7,6 +7,7 @@ import numpy import scipy.ndimage import skimage.measure +from cp_measure.primitives._moments import inertia_2d, spatial_moments_2d from cp_measure.utils import masks_to_ijv __doc__ = """\ @@ -604,30 +605,22 @@ def get_sizeshape( if new_features: desired_properties += ["perimeter_crofton"] - if calculate_advanced: - if masks.ndim == 2: + # 2D advanced moments (spatial / central / normalized / Hu) and the inertia tensor are all + # derived from the `spatial_moments_2d` scatter below, so 2D requests nothing moment-related + # from regionprops — removing its per-region einsum. Only the 3D path still needs them. + if calculate_advanced and masks.ndim != 2: + desired_properties += ["solidity"] + + # These advanced props were not in CP4 for 3D images + if new_features: desired_properties += [ "inertia_tensor", "inertia_tensor_eigvals", "moments", - "moments_hu", "moments_central", "moments_normalized", ] - else: - desired_properties += ["solidity"] - - # These advanced props were not in CP4 for 3D images - if new_features: - desired_properties += [ - "inertia_tensor", - "inertia_tensor_eigvals", - "moments", - "moments_central", - "moments_normalized", - ] - labels = masks nobjects = (numpy.unique(masks) > 0).sum() results: dict[str, NDArray[numpy.floating]] = {} @@ -686,60 +679,65 @@ def get_sizeshape( } if calculate_advanced: + # Spatial / central / normalized / Hu moments via one scatter pass (drop-in for the + # regionprops einsum columns); the inertia tensor is derived from the same central + # moments, so regionprops computes no moments at all. + raw, central, normalized, hu = spatial_moments_2d(labels) + it_00, it_off, it_11, eig_0, eig_1 = inertia_2d(central) results |= { - F_SPATIAL_MOMENT_0_0: props["moments-0-0"], - F_SPATIAL_MOMENT_0_1: props["moments-0-1"], - F_SPATIAL_MOMENT_0_2: props["moments-0-2"], - F_SPATIAL_MOMENT_0_3: props["moments-0-3"], - F_SPATIAL_MOMENT_1_0: props["moments-1-0"], - F_SPATIAL_MOMENT_1_1: props["moments-1-1"], - F_SPATIAL_MOMENT_1_2: props["moments-1-2"], - F_SPATIAL_MOMENT_1_3: props["moments-1-3"], - F_SPATIAL_MOMENT_2_0: props["moments-2-0"], - F_SPATIAL_MOMENT_2_1: props["moments-2-1"], - F_SPATIAL_MOMENT_2_2: props["moments-2-2"], - F_SPATIAL_MOMENT_2_3: props["moments-2-3"], - F_CENTRAL_MOMENT_0_0: props["moments_central-0-0"], - F_CENTRAL_MOMENT_0_1: props["moments_central-0-1"], - F_CENTRAL_MOMENT_0_2: props["moments_central-0-2"], - F_CENTRAL_MOMENT_0_3: props["moments_central-0-3"], - F_CENTRAL_MOMENT_1_0: props["moments_central-1-0"], - F_CENTRAL_MOMENT_1_1: props["moments_central-1-1"], - F_CENTRAL_MOMENT_1_2: props["moments_central-1-2"], - F_CENTRAL_MOMENT_1_3: props["moments_central-1-3"], - F_CENTRAL_MOMENT_2_0: props["moments_central-2-0"], - F_CENTRAL_MOMENT_2_1: props["moments_central-2-1"], - F_CENTRAL_MOMENT_2_2: props["moments_central-2-2"], - F_CENTRAL_MOMENT_2_3: props["moments_central-2-3"], - F_NORMALIZED_MOMENT_0_0: props["moments_normalized-0-0"], - F_NORMALIZED_MOMENT_0_1: props["moments_normalized-0-1"], - F_NORMALIZED_MOMENT_0_2: props["moments_normalized-0-2"], - F_NORMALIZED_MOMENT_0_3: props["moments_normalized-0-3"], - F_NORMALIZED_MOMENT_1_0: props["moments_normalized-1-0"], - F_NORMALIZED_MOMENT_1_1: props["moments_normalized-1-1"], - F_NORMALIZED_MOMENT_1_2: props["moments_normalized-1-2"], - F_NORMALIZED_MOMENT_1_3: props["moments_normalized-1-3"], - F_NORMALIZED_MOMENT_2_0: props["moments_normalized-2-0"], - F_NORMALIZED_MOMENT_2_1: props["moments_normalized-2-1"], - F_NORMALIZED_MOMENT_2_2: props["moments_normalized-2-2"], - F_NORMALIZED_MOMENT_2_3: props["moments_normalized-2-3"], - F_NORMALIZED_MOMENT_3_0: props["moments_normalized-3-0"], - F_NORMALIZED_MOMENT_3_1: props["moments_normalized-3-1"], - F_NORMALIZED_MOMENT_3_2: props["moments_normalized-3-2"], - F_NORMALIZED_MOMENT_3_3: props["moments_normalized-3-3"], - F_HU_MOMENT_0: props["moments_hu-0"], - F_HU_MOMENT_1: props["moments_hu-1"], - F_HU_MOMENT_2: props["moments_hu-2"], - F_HU_MOMENT_3: props["moments_hu-3"], - F_HU_MOMENT_4: props["moments_hu-4"], - F_HU_MOMENT_5: props["moments_hu-5"], - F_HU_MOMENT_6: props["moments_hu-6"], - F_INERTIA_TENSOR_0_0: props["inertia_tensor-0-0"], - F_INERTIA_TENSOR_0_1: props["inertia_tensor-0-1"], - F_INERTIA_TENSOR_1_0: props["inertia_tensor-1-0"], - F_INERTIA_TENSOR_1_1: props["inertia_tensor-1-1"], - F_INERTIA_TENSOR_EIGENVALUES_0: props["inertia_tensor_eigvals-0"], - F_INERTIA_TENSOR_EIGENVALUES_1: props["inertia_tensor_eigvals-1"], + F_SPATIAL_MOMENT_0_0: raw[:, 0, 0], + F_SPATIAL_MOMENT_0_1: raw[:, 0, 1], + F_SPATIAL_MOMENT_0_2: raw[:, 0, 2], + F_SPATIAL_MOMENT_0_3: raw[:, 0, 3], + F_SPATIAL_MOMENT_1_0: raw[:, 1, 0], + F_SPATIAL_MOMENT_1_1: raw[:, 1, 1], + F_SPATIAL_MOMENT_1_2: raw[:, 1, 2], + F_SPATIAL_MOMENT_1_3: raw[:, 1, 3], + F_SPATIAL_MOMENT_2_0: raw[:, 2, 0], + F_SPATIAL_MOMENT_2_1: raw[:, 2, 1], + F_SPATIAL_MOMENT_2_2: raw[:, 2, 2], + F_SPATIAL_MOMENT_2_3: raw[:, 2, 3], + F_CENTRAL_MOMENT_0_0: central[:, 0, 0], + F_CENTRAL_MOMENT_0_1: central[:, 0, 1], + F_CENTRAL_MOMENT_0_2: central[:, 0, 2], + F_CENTRAL_MOMENT_0_3: central[:, 0, 3], + F_CENTRAL_MOMENT_1_0: central[:, 1, 0], + F_CENTRAL_MOMENT_1_1: central[:, 1, 1], + F_CENTRAL_MOMENT_1_2: central[:, 1, 2], + F_CENTRAL_MOMENT_1_3: central[:, 1, 3], + F_CENTRAL_MOMENT_2_0: central[:, 2, 0], + F_CENTRAL_MOMENT_2_1: central[:, 2, 1], + F_CENTRAL_MOMENT_2_2: central[:, 2, 2], + F_CENTRAL_MOMENT_2_3: central[:, 2, 3], + F_NORMALIZED_MOMENT_0_0: normalized[:, 0, 0], + F_NORMALIZED_MOMENT_0_1: normalized[:, 0, 1], + F_NORMALIZED_MOMENT_0_2: normalized[:, 0, 2], + F_NORMALIZED_MOMENT_0_3: normalized[:, 0, 3], + F_NORMALIZED_MOMENT_1_0: normalized[:, 1, 0], + F_NORMALIZED_MOMENT_1_1: normalized[:, 1, 1], + F_NORMALIZED_MOMENT_1_2: normalized[:, 1, 2], + F_NORMALIZED_MOMENT_1_3: normalized[:, 1, 3], + F_NORMALIZED_MOMENT_2_0: normalized[:, 2, 0], + F_NORMALIZED_MOMENT_2_1: normalized[:, 2, 1], + F_NORMALIZED_MOMENT_2_2: normalized[:, 2, 2], + F_NORMALIZED_MOMENT_2_3: normalized[:, 2, 3], + F_NORMALIZED_MOMENT_3_0: normalized[:, 3, 0], + F_NORMALIZED_MOMENT_3_1: normalized[:, 3, 1], + F_NORMALIZED_MOMENT_3_2: normalized[:, 3, 2], + F_NORMALIZED_MOMENT_3_3: normalized[:, 3, 3], + F_HU_MOMENT_0: hu[:, 0], + F_HU_MOMENT_1: hu[:, 1], + F_HU_MOMENT_2: hu[:, 2], + F_HU_MOMENT_3: hu[:, 3], + F_HU_MOMENT_4: hu[:, 4], + F_HU_MOMENT_5: hu[:, 5], + F_HU_MOMENT_6: hu[:, 6], + F_INERTIA_TENSOR_0_0: it_00, + F_INERTIA_TENSOR_0_1: it_off, + F_INERTIA_TENSOR_1_0: it_off, + F_INERTIA_TENSOR_1_1: it_11, + F_INERTIA_TENSOR_EIGENVALUES_0: eig_0, + F_INERTIA_TENSOR_EIGENVALUES_1: eig_1, } if new_features: diff --git a/src/cp_measure/primitives/_moments.py b/src/cp_measure/primitives/_moments.py new file mode 100644 index 0000000..1532b11 --- /dev/null +++ b/src/cp_measure/primitives/_moments.py @@ -0,0 +1,136 @@ +"""Per-object spatial-moment matrices via a single label-scatter pass. + +``skimage.measure.regionprops_table`` computes ``moments`` / ``moments_central`` / +``moments_normalized`` / ``moments_hu`` per region with an ``einsum``-based routine whose +contraction path is re-derived for every object — on a 1080² / 142-object tile this dominates +``get_sizeshape`` (~120 ms, ~40 ms of which is pure ``einsum_path`` overhead). The moments are +plain reductions, so the whole set is one scatter over the foreground pixels. + +The raw spatial moments are bit-exact vs regionprops; the centroid-dependent matrices +(``central`` / ``normalized`` / ``hu``) match to floating-point round-off (~1e-13 relative — the +moments reach ~1e8 magnitude). Objects are ordered by ascending label, exactly as +``regionprops_table``. +""" + +import numpy +from numpy.typing import NDArray + +# Moments up to order 3 (indices 0..3), matching skimage's order-3 regionprops matrices. +_ORDER = 4 + + +def _moment_matrix( + obj: NDArray[numpy.integer], + r: NDArray[numpy.floating], + c: NDArray[numpy.floating], + n: int, +) -> NDArray[numpy.floating]: + """Segment-sum ``r**p * c**q`` per object into an ``(n, 4, 4)`` moment matrix.""" + r_pow = [numpy.ones_like(r), r, r * r, r * r * r] + c_pow = [numpy.ones_like(c), c, c * c, c * c * c] + moments = numpy.zeros((n, _ORDER, _ORDER)) + for p in range(_ORDER): + for q in range(_ORDER): + moments[:, p, q] = numpy.bincount( + obj, weights=r_pow[p] * c_pow[q], minlength=n + ) + return moments + + +def _hu_from_normalized(nu: NDArray[numpy.floating]) -> NDArray[numpy.floating]: + """The 7 Hu invariants from normalized central moments (skimage convention).""" + n20, n02, n11 = nu[:, 2, 0], nu[:, 0, 2], nu[:, 1, 1] + n30, n03, n21, n12 = nu[:, 3, 0], nu[:, 0, 3], nu[:, 2, 1], nu[:, 1, 2] + a, b = n30 + n12, n21 + n03 # recurring (rotation-coupled) pairs + hu = numpy.zeros((nu.shape[0], 7)) + hu[:, 0] = n20 + n02 + hu[:, 1] = (n20 - n02) ** 2 + 4 * n11**2 + hu[:, 2] = (n30 - 3 * n12) ** 2 + (3 * n21 - n03) ** 2 + hu[:, 3] = a**2 + b**2 + hu[:, 4] = (n30 - 3 * n12) * a * (a**2 - 3 * b**2) + (3 * n21 - n03) * b * ( + 3 * a**2 - b**2 + ) + hu[:, 5] = (n20 - n02) * (a**2 - b**2) + 4 * n11 * a * b + hu[:, 6] = (3 * n21 - n03) * a * (a**2 - 3 * b**2) - (n30 - 3 * n12) * b * ( + 3 * a**2 - b**2 + ) + return hu + + +def spatial_moments_2d( + labels: NDArray[numpy.integer], +) -> tuple[ + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], +]: + """Per-object ``(raw, central, normalized, hu)`` spatial moments for a 2D label image. + + ``raw`` / ``central`` / ``normalized`` are ``(n, 4, 4)`` and ``hu`` is ``(n, 7)``, ordered by + ascending label. Drop-in for the matching ``regionprops_table`` columns + (``moments-p-q`` etc.); ``normalized`` is NaN where ``p + q < 2`` (skimage convention). + """ + unique_labels = numpy.unique(labels) + unique_labels = unique_labels[unique_labels > 0] + n = len(unique_labels) + if n == 0: + empty = numpy.zeros((0, _ORDER, _ORDER)) + return empty, empty, empty, numpy.zeros((0, 7)) + + rows, cols = numpy.nonzero(labels) + obj = numpy.searchsorted(unique_labels, labels[rows, cols]) + + # skimage takes moments in each object's local (bounding-box) frame, so reduce to the + # per-object minimum row/col. Init the accumulators above any pixel index for minimum.at. + sentinel = 1 << 31 + rmin = numpy.full(n, sentinel) + cmin = numpy.full(n, sentinel) + numpy.minimum.at(rmin, obj, rows) + numpy.minimum.at(cmin, obj, cols) + local_r = (rows - rmin[obj]).astype(float) + local_c = (cols - cmin[obj]).astype(float) + + raw = _moment_matrix(obj, local_r, local_c, n) + centre_r = raw[:, 1, 0] / raw[:, 0, 0] + centre_c = raw[:, 0, 1] / raw[:, 0, 0] + # Central moments by direct centred summation (binomial-from-raw loses ~1e-4 to cancellation). + central = _moment_matrix(obj, local_r - centre_r[obj], local_c - centre_c[obj], n) + + normalized = numpy.full((n, _ORDER, _ORDER), numpy.nan) + mu00 = central[:, 0, 0] + for p in range(_ORDER): + for q in range(_ORDER): + if p + q >= 2: + normalized[:, p, q] = central[:, p, q] / mu00 ** ((p + q) / 2 + 1) + + return raw, central, normalized, _hu_from_normalized(normalized) + + +def inertia_2d( + central: NDArray[numpy.floating], +) -> tuple[ + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], +]: + """2D inertia tensor and its eigenvalues from per-object central moments. + + Matches ``skimage.measure.regionprops`` ``inertia_tensor`` / ``inertia_tensor_eigvals`` to + floating-point round-off. The tensor is ``[[c, -b], [-b, a]]`` with ``a = mu20/mu00``, + ``b = mu11/mu00``, ``c = mu02/mu00`` (skimage's row/col convention); eigenvalues are returned + in descending order. Reuses the central moments from :func:`spatial_moments_2d`, so the + inertia features need no separate regionprops einsum. + + Returns ``(t00, t_offdiag, t11, eig_major, eig_minor)`` — ``t_offdiag`` is both + off-diagonal entries (the tensor is symmetric). + """ + mu00 = central[:, 0, 0] + a = central[:, 2, 0] / mu00 + b = central[:, 1, 1] / mu00 + c = central[:, 0, 2] / mu00 + half_trace = (a + c) / 2 + disc = numpy.sqrt(((c - a) / 2) ** 2 + b**2) + return c, -b, a, half_trace + disc, half_trace - disc diff --git a/test/test_sizeshape_moments.py b/test/test_sizeshape_moments.py new file mode 100644 index 0000000..451ca54 --- /dev/null +++ b/test/test_sizeshape_moments.py @@ -0,0 +1,174 @@ +"""Golden tests for the scatter-based spatial moments in ``get_sizeshape``. + +``spatial_moments_2d`` replaces regionprops' per-region einsum for the ``moments`` / +``moments_central`` / ``moments_normalized`` / ``moments_hu`` columns. It must match +``skimage.measure.regionprops_table`` to floating-point round-off (raw moments bit-exact; +centroid-dependent matrices ~1e-13 relative — moments reach ~1e8 magnitude). The whole +``get_sizeshape`` output is otherwise unchanged. +""" + +import numpy +import skimage.measure + +from cp_measure.core.measureobjectsizeshape import get_sizeshape +from cp_measure.primitives._moments import inertia_2d, spatial_moments_2d + +ATOL_REL = 1e-7 # relative tolerance; moments span many orders of magnitude + + +def _square_objects(size, n, gap_frac=0.7): + masks = numpy.zeros((size, size), numpy.int32) + step = size // n + obj = int(step * gap_frac) + lab = 0 + for a in range(n): + for b in range(n): + lab += 1 + masks[a * step : a * step + obj, b * step : b * step + obj] = lab + return masks + + +def _assert_moments_match(masks): + raw, central, normalized, hu = spatial_moments_2d(masks) + ref = skimage.measure.regionprops_table( + masks, + properties=["moments", "moments_central", "moments_normalized", "moments_hu"], + ) + n = raw.shape[0] + for p in range(4): + for q in range(4): + scale = max( + numpy.nanmax(numpy.abs(ref[f"moments-{p}-{q}"])) if n else 0.0, 1.0 + ) + numpy.testing.assert_allclose( + raw[:, p, q], + ref[f"moments-{p}-{q}"], + rtol=ATOL_REL, + atol=ATOL_REL * scale, + ) + cscale = max( + numpy.nanmax(numpy.abs(ref[f"moments_central-{p}-{q}"])) if n else 0.0, + 1.0, + ) + numpy.testing.assert_allclose( + central[:, p, q], + ref[f"moments_central-{p}-{q}"], + rtol=ATOL_REL, + atol=ATOL_REL * cscale, + ) + # normalized: NaN where p+q<2 in both + assert numpy.array_equal( + numpy.isnan(normalized[:, p, q]), + numpy.isnan(ref[f"moments_normalized-{p}-{q}"]), + ), f"NaN pattern mismatch normalized {p}{q}" + numpy.testing.assert_allclose( + normalized[:, p, q], + ref[f"moments_normalized-{p}-{q}"], + rtol=ATOL_REL, + atol=ATOL_REL, + equal_nan=True, + ) + for k in range(7): + kscale = max(numpy.nanmax(numpy.abs(ref[f"moments_hu-{k}"])) if n else 0.0, 1.0) + numpy.testing.assert_allclose( + hu[:, k], ref[f"moments_hu-{k}"], rtol=ATOL_REL, atol=ATOL_REL * kscale + ) + + # inertia tensor + eigenvalues derived from the same central moments + it_00, it_off, it_11, eig_0, eig_1 = inertia_2d(central) + iref = skimage.measure.regionprops_table( + masks, properties=["inertia_tensor", "inertia_tensor_eigvals"] + ) + for got, key in [ + (it_00, "inertia_tensor-0-0"), + (it_off, "inertia_tensor-0-1"), + (it_off, "inertia_tensor-1-0"), + (it_11, "inertia_tensor-1-1"), + (eig_0, "inertia_tensor_eigvals-0"), + (eig_1, "inertia_tensor_eigvals-1"), + ]: + scale = max(numpy.nanmax(numpy.abs(iref[key])) if n else 0.0, 1.0) + numpy.testing.assert_allclose( + got, iref[key], rtol=ATOL_REL, atol=ATOL_REL * scale + ) + + +def test_raw_moments_bit_exact(): + # raw spatial moments are integer-coordinate sums -> exactly equal to regionprops. + masks = _square_objects(256, 4) + raw, *_ = spatial_moments_2d(masks) + ref = skimage.measure.regionprops_table(masks, properties=["moments"]) + for p in range(4): + for q in range(4): + numpy.testing.assert_array_equal(raw[:, p, q], ref[f"moments-{p}-{q}"]) + + +def test_moments_match_single_object(): + masks = numpy.zeros((64, 64), numpy.int32) + masks[18:50, 20:45] = 1 + _assert_moments_match(masks) + + +def test_moments_match_multi_object(): + _assert_moments_match(_square_objects(256, 4)) + + +def test_moments_match_noncontiguous_labels(): + masks = numpy.zeros((96, 96), numpy.int32) + masks[10:30, 10:30] = 1 + masks[40:60, 40:60] = 3 + masks[70:90, 70:90] = 7 + _assert_moments_match(masks) + + +def test_moments_match_edge_touching(): + masks = numpy.zeros((64, 64), numpy.int32) + masks[0:20, 0:20] = 1 + masks[44:64, 44:64] = 2 + _assert_moments_match(masks) + + +def test_moments_single_pixel_object(): + # degenerate: central/normalized are 0/NaN; must match regionprops' handling. + masks = numpy.zeros((32, 32), numpy.int32) + masks[16, 16] = 1 + masks[5:15, 5:15] = 2 + _assert_moments_match(masks) + + +def test_spatial_moments_empty(): + raw, central, normalized, hu = spatial_moments_2d( + numpy.zeros((20, 20), numpy.int32) + ) + assert raw.shape == (0, 4, 4) and hu.shape == (0, 7) + + +def test_get_sizeshape_wires_moment_features(): + # get_sizeshape exposes spatial_moments_2d under the public F_* names; the helper-vs- + # regionprops accuracy is covered by the tests above, so this only checks the wiring. + masks = _square_objects(200, 3) + pixels = numpy.random.default_rng(0).random(masks.shape) + out = get_sizeshape(masks, pixels) + raw, central, normalized, hu = spatial_moments_2d(masks) + for p in range(3): # spatial/central exposed for p in {0,1,2}, q in {0,1,2,3} + for q in range(4): + numpy.testing.assert_array_equal( + out[f"SpatialMoment_{p}_{q}"], raw[:, p, q] + ) + numpy.testing.assert_array_equal( + out[f"CentralMoment_{p}_{q}"], central[:, p, q] + ) + for p in range(4): # normalized exposed for the full 4x4 + for q in range(4): + numpy.testing.assert_array_equal( + out[f"NormalizedMoment_{p}_{q}"], normalized[:, p, q] + ) + for k in range(7): + numpy.testing.assert_array_equal(out[f"HuMoment_{k}"], hu[:, k]) + it_00, it_off, it_11, eig_0, eig_1 = inertia_2d(central) + numpy.testing.assert_array_equal(out["InertiaTensor_0_0"], it_00) + numpy.testing.assert_array_equal(out["InertiaTensor_0_1"], it_off) + numpy.testing.assert_array_equal(out["InertiaTensor_1_0"], it_off) + numpy.testing.assert_array_equal(out["InertiaTensor_1_1"], it_11) + numpy.testing.assert_array_equal(out["InertiaTensorEigenvalues_0"], eig_0) + numpy.testing.assert_array_equal(out["InertiaTensorEigenvalues_1"], eig_1) From f7b32beaef8bdbbade4f8e18e8102097dc55e05b Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Sun, 7 Jun 2026 00:38:19 +0200 Subject: [PATCH 2/3] =?UTF-8?q?perf(sizeshape):=20option=20B=20=E2=80=94?= =?UTF-8?q?=20derive=20axis/ecc/orientation=20from=20scatter=20moments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit stripped the explicit moment columns from the 2D regionprops request but kept axis_major_length / axis_minor_length / eccentricity / orientation — and regionprops derives THOSE from moments_central, so it still ran the per-region moment einsum. The added scatter was therefore pure overhead and 2D get_sizeshape regressed (~198ms baseline -> ~216ms). Apply "option B" (already used by the numba sizeshape backend): derive all four from the scatter's central moments via a new `axes_eccentricity_orientation` helper (built on the existing `inertia_2d`), so the 2D regionprops call requests NOTHING moment-related and never runs the einsum. The scatter (raw/central/normalized/hu) is now computed once for 2D and reused by both the moment features and the axis/ecc/orientation features. 3D keeps axis lengths on regionprops (3D moments out of scope). large 1080^2/142obj: regionprops 208->139ms/call, get_sizeshape ~216 -> ~178ms (now below the 198ms pre-opt baseline). Bit-exact vs regionprops to 1e-9 incl. degenerate (single-pixel, line) — new `test_axes_match_*` golden tests added. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cp_measure/core/measureobjectsizeshape.py | 73 ++++++++++--------- src/cp_measure/primitives/_moments.py | 30 ++++++++ test/test_sizeshape_moments.py | 54 ++++++++++++++ 3 files changed, 123 insertions(+), 34 deletions(-) diff --git a/src/cp_measure/core/measureobjectsizeshape.py b/src/cp_measure/core/measureobjectsizeshape.py index a042f6e..d7dd118 100644 --- a/src/cp_measure/core/measureobjectsizeshape.py +++ b/src/cp_measure/core/measureobjectsizeshape.py @@ -7,7 +7,11 @@ import numpy import scipy.ndimage import skimage.measure -from cp_measure.primitives._moments import inertia_2d, spatial_moments_2d +from cp_measure.primitives._moments import ( + axes_eccentricity_orientation, + inertia_2d, + spatial_moments_2d, +) from cp_measure.utils import masks_to_ijv __doc__ = """\ @@ -586,40 +590,34 @@ def get_sizeshape( "centroid", "euler_number", "extent", - "axis_major_length", - "axis_minor_length", ] # Features not in CellProfiler 4 if new_features: desired_properties += ["area_filled"] - # 2d specific properties if masks.ndim == 2: - desired_properties += [ - "eccentricity", - "orientation", - "perimeter", - "solidity", - ] + # 2D requests NOTHING moment-related from regionprops: the spatial / central / normalized + # / Hu moments, the inertia tensor, AND the axis lengths / eccentricity / orientation are + # all derived from the `spatial_moments_2d` central moments below (option B), so + # regionprops never runs its per-region moment einsum. + desired_properties += ["perimeter", "solidity"] if new_features: desired_properties += ["perimeter_crofton"] - - # 2D advanced moments (spatial / central / normalized / Hu) and the inertia tensor are all - # derived from the `spatial_moments_2d` scatter below, so 2D requests nothing moment-related - # from regionprops — removing its per-region einsum. Only the 3D path still needs them. - if calculate_advanced and masks.ndim != 2: - desired_properties += ["solidity"] - - # These advanced props were not in CP4 for 3D images - if new_features: - desired_properties += [ - "inertia_tensor", - "inertia_tensor_eigvals", - "moments", - "moments_central", - "moments_normalized", - ] + else: + # 3D: axis lengths still come from regionprops (3D moments are out of scope here). + desired_properties += ["axis_major_length", "axis_minor_length"] + if calculate_advanced: + desired_properties += ["solidity"] + # These advanced props were not in CP4 for 3D images + if new_features: + desired_properties += [ + "inertia_tensor", + "inertia_tensor_eigvals", + "moments", + "moments_central", + "moments_normalized", + ] labels = masks nobjects = (numpy.unique(masks) > 0).sum() @@ -629,6 +627,15 @@ def get_sizeshape( labels, pixels, properties=desired_properties ) + # Option B: every moment-derived 2D feature (spatial / central / normalized / Hu moments, + # the inertia tensor, AND axis lengths / eccentricity / orientation) comes from this one + # scatter pass, so regionprops above ran no moment einsum. normalized / Hu are only emitted + # under calculate_advanced, but the scatter returns them in the same call. + raw, central, normalized, hu = spatial_moments_2d(labels) + axis_major, axis_minor, eccentricity, orientation = ( + axes_eccentricity_orientation(central) + ) + formfactor = 4.0 * numpy.pi * props["area"] / props["perimeter"] ** 2 denom = [max(x, 1) for x in 4.0 * numpy.pi * props["area"]] compactness = props["perimeter"] ** 2 / denom @@ -652,10 +659,10 @@ def get_sizeshape( F_CONVEX_AREA: props["area_convex"], F_EQUIVALENT_DIAMETER: props["equivalent_diameter_area"], F_PERIMETER: props["perimeter"], - F_MAJOR_AXIS_LENGTH: props["axis_major_length"], - F_MINOR_AXIS_LENGTH: props["axis_minor_length"], - F_ECCENTRICITY: props["eccentricity"], - F_ORIENTATION: props["orientation"] * (180 / numpy.pi), + F_MAJOR_AXIS_LENGTH: axis_major, + F_MINOR_AXIS_LENGTH: axis_minor, + F_ECCENTRICITY: eccentricity, + F_ORIENTATION: orientation * (180 / numpy.pi), F_CENTER_X: props["centroid-1"], F_CENTER_Y: props["centroid-0"], F_MIN_X: props["bbox-1"], @@ -679,10 +686,8 @@ def get_sizeshape( } if calculate_advanced: - # Spatial / central / normalized / Hu moments via one scatter pass (drop-in for the - # regionprops einsum columns); the inertia tensor is derived from the same central - # moments, so regionprops computes no moments at all. - raw, central, normalized, hu = spatial_moments_2d(labels) + # Spatial / central / normalized / Hu moments and the inertia tensor all come from the + # scatter `central` computed above — regionprops ran no moment einsum. it_00, it_off, it_11, eig_0, eig_1 = inertia_2d(central) results |= { F_SPATIAL_MOMENT_0_0: raw[:, 0, 0], diff --git a/src/cp_measure/primitives/_moments.py b/src/cp_measure/primitives/_moments.py index 1532b11..607041c 100644 --- a/src/cp_measure/primitives/_moments.py +++ b/src/cp_measure/primitives/_moments.py @@ -134,3 +134,33 @@ def inertia_2d( half_trace = (a + c) / 2 disc = numpy.sqrt(((c - a) / 2) ** 2 + b**2) return c, -b, a, half_trace + disc, half_trace - disc + + +def axes_eccentricity_orientation( + central: NDArray[numpy.floating], +) -> tuple[ + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], +]: + """``(axis_major, axis_minor, eccentricity, orientation)`` from per-object central moments. + + Matches ``skimage.measure.regionprops`` to floating-point round-off, including the symmetric + fallback (``it00 == it11`` -> ±pi/4). Derived from the same inertia tensor / eigenvalues as + :func:`inertia_2d`, so requesting these no longer forces regionprops' per-region moment einsum + (CellProfiler reports orientation in degrees; callers apply the ``180/pi`` conversion). + """ + it00, it_off, it11, eig_major, eig_minor = inertia_2d(central) + axis_major = 4 * numpy.sqrt(eig_major) + axis_minor = 4 * numpy.sqrt(eig_minor) + with numpy.errstate(invalid="ignore", divide="ignore"): + eccentricity = numpy.where( + eig_major == 0, 0.0, numpy.sqrt(1 - eig_minor / eig_major) + ) + orientation = numpy.where( + it00 - it11 == 0, + numpy.where(it_off < 0, numpy.pi / 4, -numpy.pi / 4), + 0.5 * numpy.arctan2(-2 * it_off, it11 - it00), + ) + return axis_major, axis_minor, eccentricity, orientation diff --git a/test/test_sizeshape_moments.py b/test/test_sizeshape_moments.py index 451ca54..b04d2b5 100644 --- a/test/test_sizeshape_moments.py +++ b/test/test_sizeshape_moments.py @@ -172,3 +172,57 @@ def test_get_sizeshape_wires_moment_features(): numpy.testing.assert_array_equal(out["InertiaTensor_1_1"], it_11) numpy.testing.assert_array_equal(out["InertiaTensorEigenvalues_0"], eig_0) numpy.testing.assert_array_equal(out["InertiaTensorEigenvalues_1"], eig_1) + + +def _assert_axes_match(masks): + # Option B: axis lengths / eccentricity / orientation are derived from the scatter central + # moments instead of regionprops, so get_sizeshape requests no moments at all. They must still + # match regionprops to round-off (orientation reported in degrees: radians * 180/pi). + out = get_sizeshape(masks, masks.astype(float)) + ref = skimage.measure.regionprops_table( + masks, + properties=[ + "axis_major_length", + "axis_minor_length", + "eccentricity", + "orientation", + ], + ) + numpy.testing.assert_allclose( + out["MajorAxisLength"], ref["axis_major_length"], rtol=1e-9, atol=1e-9 + ) + numpy.testing.assert_allclose( + out["MinorAxisLength"], ref["axis_minor_length"], rtol=1e-9, atol=1e-9 + ) + numpy.testing.assert_allclose( + out["Eccentricity"], ref["eccentricity"], rtol=1e-9, atol=1e-9 + ) + numpy.testing.assert_allclose( + out["Orientation"], ref["orientation"] * (180 / numpy.pi), rtol=1e-9, atol=1e-9 + ) + + +def test_axes_match_multi_object(): + _assert_axes_match(_square_objects(256, 4)) + + +def test_axes_match_single_object(): + masks = numpy.zeros((64, 64), numpy.int32) + masks[18:50, 20:45] = 1 # rectangle -> nonzero orientation, distinct axis lengths + _assert_axes_match(masks) + + +def test_axes_match_noncontiguous_labels(): + masks = numpy.zeros((96, 96), numpy.int32) + masks[10:30, 10:30] = 1 + masks[40:60, 40:55] = 3 + masks[70:90, 72:90] = 7 + _assert_axes_match(masks) + + +def test_axes_match_single_pixel_and_line(): + # degenerate objects: single pixel (axes 0) and a 1-wide line (minor axis 0). + masks = numpy.zeros((40, 40), numpy.int32) + masks[20, 20] = 1 + masks[5:15, 8] = 2 + _assert_axes_match(masks) From 6d961eb6b637ea3e512b469668dbf3dfbde6e657 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 10 Jun 2026 00:48:42 +0200 Subject: [PATCH 3/3] refactor(sizeshape): slim moment assembly, fix eigenvalue clip, share label LUT Review-driven cleanups to the scatter-based get_sizeshape, all verified bit-exact vs the PyPI 0.1.19 release (column order + values): - inertia_2d clips eigenvalues to >=0 like skimage, fixing NaN MinorAxisLength / eccentricity>1 on thin/oblique objects (~4% of thin lines triggered it). - spatial_moments_2d takes the per-object bbox origin from label_to_idx_lut's find_objects pass (new return_bbox=) instead of a 1<<31 sentinel + minimum.at; gains an `advanced` flag so the normalized/Hu moments are skipped when the caller won't emit them. - get_sizeshape computes inertia_2d once (shared by the axes derivation and the tensor features) and assembles the 53 advanced moment features via a new moment_feature_dict (grouped order matching 0.1.19); drops the redundant whole-image numpy.unique for the object count. - _moment_matrix powers driven by _ORDER; honest docstring on raw-moment divergence (round-off, grows with object size; not "bit-exact"). - Regression tests: pin the 0.1.19 key order; thin-object clip vs skimage. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cp_measure/core/measureobjectsizeshape.py | 79 +++-------- src/cp_measure/primitives/_moments.py | 123 +++++++++++++----- src/cp_measure/primitives/segment.py | 21 ++- test/test_sizeshape_moments.py | 55 ++++++++ 4 files changed, 180 insertions(+), 98 deletions(-) diff --git a/src/cp_measure/core/measureobjectsizeshape.py b/src/cp_measure/core/measureobjectsizeshape.py index d7dd118..8db9936 100644 --- a/src/cp_measure/core/measureobjectsizeshape.py +++ b/src/cp_measure/core/measureobjectsizeshape.py @@ -10,8 +10,10 @@ from cp_measure.primitives._moments import ( axes_eccentricity_orientation, inertia_2d, + moment_feature_dict, spatial_moments_2d, ) +from cp_measure.primitives.segment import label_to_idx_lut from cp_measure.utils import masks_to_ijv __doc__ = """\ @@ -620,7 +622,7 @@ def get_sizeshape( ] labels = masks - nobjects = (numpy.unique(masks) > 0).sum() + nobjects = label_to_idx_lut(masks)[1] results: dict[str, NDArray[numpy.floating]] = {} if labels.ndim == 2: props = skimage.measure.regionprops_table( @@ -629,11 +631,14 @@ def get_sizeshape( # Option B: every moment-derived 2D feature (spatial / central / normalized / Hu moments, # the inertia tensor, AND axis lengths / eccentricity / orientation) comes from this one - # scatter pass, so regionprops above ran no moment einsum. normalized / Hu are only emitted - # under calculate_advanced, but the scatter returns them in the same call. - raw, central, normalized, hu = spatial_moments_2d(labels) + # scatter pass, so regionprops above ran no moment einsum. The normalized / Hu moments are + # only needed under calculate_advanced, so the scatter skips them otherwise. + raw, central, normalized, hu = spatial_moments_2d( + labels, advanced=calculate_advanced + ) + inertia = inertia_2d(central) axis_major, axis_minor, eccentricity, orientation = ( - axes_eccentricity_orientation(central) + axes_eccentricity_orientation(inertia) ) formfactor = 4.0 * numpy.pi * props["area"] / props["perimeter"] ** 2 @@ -687,63 +692,13 @@ def get_sizeshape( if calculate_advanced: # Spatial / central / normalized / Hu moments and the inertia tensor all come from the - # scatter `central` computed above — regionprops ran no moment einsum. - it_00, it_off, it_11, eig_0, eig_1 = inertia_2d(central) - results |= { - F_SPATIAL_MOMENT_0_0: raw[:, 0, 0], - F_SPATIAL_MOMENT_0_1: raw[:, 0, 1], - F_SPATIAL_MOMENT_0_2: raw[:, 0, 2], - F_SPATIAL_MOMENT_0_3: raw[:, 0, 3], - F_SPATIAL_MOMENT_1_0: raw[:, 1, 0], - F_SPATIAL_MOMENT_1_1: raw[:, 1, 1], - F_SPATIAL_MOMENT_1_2: raw[:, 1, 2], - F_SPATIAL_MOMENT_1_3: raw[:, 1, 3], - F_SPATIAL_MOMENT_2_0: raw[:, 2, 0], - F_SPATIAL_MOMENT_2_1: raw[:, 2, 1], - F_SPATIAL_MOMENT_2_2: raw[:, 2, 2], - F_SPATIAL_MOMENT_2_3: raw[:, 2, 3], - F_CENTRAL_MOMENT_0_0: central[:, 0, 0], - F_CENTRAL_MOMENT_0_1: central[:, 0, 1], - F_CENTRAL_MOMENT_0_2: central[:, 0, 2], - F_CENTRAL_MOMENT_0_3: central[:, 0, 3], - F_CENTRAL_MOMENT_1_0: central[:, 1, 0], - F_CENTRAL_MOMENT_1_1: central[:, 1, 1], - F_CENTRAL_MOMENT_1_2: central[:, 1, 2], - F_CENTRAL_MOMENT_1_3: central[:, 1, 3], - F_CENTRAL_MOMENT_2_0: central[:, 2, 0], - F_CENTRAL_MOMENT_2_1: central[:, 2, 1], - F_CENTRAL_MOMENT_2_2: central[:, 2, 2], - F_CENTRAL_MOMENT_2_3: central[:, 2, 3], - F_NORMALIZED_MOMENT_0_0: normalized[:, 0, 0], - F_NORMALIZED_MOMENT_0_1: normalized[:, 0, 1], - F_NORMALIZED_MOMENT_0_2: normalized[:, 0, 2], - F_NORMALIZED_MOMENT_0_3: normalized[:, 0, 3], - F_NORMALIZED_MOMENT_1_0: normalized[:, 1, 0], - F_NORMALIZED_MOMENT_1_1: normalized[:, 1, 1], - F_NORMALIZED_MOMENT_1_2: normalized[:, 1, 2], - F_NORMALIZED_MOMENT_1_3: normalized[:, 1, 3], - F_NORMALIZED_MOMENT_2_0: normalized[:, 2, 0], - F_NORMALIZED_MOMENT_2_1: normalized[:, 2, 1], - F_NORMALIZED_MOMENT_2_2: normalized[:, 2, 2], - F_NORMALIZED_MOMENT_2_3: normalized[:, 2, 3], - F_NORMALIZED_MOMENT_3_0: normalized[:, 3, 0], - F_NORMALIZED_MOMENT_3_1: normalized[:, 3, 1], - F_NORMALIZED_MOMENT_3_2: normalized[:, 3, 2], - F_NORMALIZED_MOMENT_3_3: normalized[:, 3, 3], - F_HU_MOMENT_0: hu[:, 0], - F_HU_MOMENT_1: hu[:, 1], - F_HU_MOMENT_2: hu[:, 2], - F_HU_MOMENT_3: hu[:, 3], - F_HU_MOMENT_4: hu[:, 4], - F_HU_MOMENT_5: hu[:, 5], - F_HU_MOMENT_6: hu[:, 6], - F_INERTIA_TENSOR_0_0: it_00, - F_INERTIA_TENSOR_0_1: it_off, - F_INERTIA_TENSOR_1_0: it_off, - F_INERTIA_TENSOR_1_1: it_11, - F_INERTIA_TENSOR_EIGENVALUES_0: eig_0, - F_INERTIA_TENSOR_EIGENVALUES_1: eig_1, - } + # scatter `central` + the single `inertia_2d` computed above — regionprops ran no + # moment einsum. moment_feature_dict owns the 53 feature names / orders (shared with + # the numba backend); the 6-tuple passes both off-diagonals (equal, symmetric tensor). + it_00, it_off, it_11, eig_0, eig_1 = inertia + results |= moment_feature_dict( + raw, central, normalized, hu, (it_00, it_off, it_off, it_11, eig_0, eig_1) + ) if new_features: results |= { diff --git a/src/cp_measure/primitives/_moments.py b/src/cp_measure/primitives/_moments.py index 607041c..1d595dd 100644 --- a/src/cp_measure/primitives/_moments.py +++ b/src/cp_measure/primitives/_moments.py @@ -6,15 +6,18 @@ ``get_sizeshape`` (~120 ms, ~40 ms of which is pure ``einsum_path`` overhead). The moments are plain reductions, so the whole set is one scatter over the foreground pixels. -The raw spatial moments are bit-exact vs regionprops; the centroid-dependent matrices -(``central`` / ``normalized`` / ``hu``) match to floating-point round-off (~1e-13 relative — the -moments reach ~1e8 magnitude). Objects are ordered by ascending label, exactly as -``regionprops_table``. +The raw spatial moments match ``regionprops`` to floating-point round-off — NOT bit-exact: the +``bincount`` summation order differs from skimage's ``einsum``, so the divergence grows with object +size (worst seen ~3e-12 relative on a 1000² object). The centroid-dependent matrices +(``central`` / ``normalized`` / ``hu``) likewise match to round-off. Objects are ordered by +ascending label, exactly as ``regionprops_table``. """ import numpy from numpy.typing import NDArray +from cp_measure.primitives.segment import label_to_idx_lut + # Moments up to order 3 (indices 0..3), matching skimage's order-3 regionprops matrices. _ORDER = 4 @@ -26,8 +29,8 @@ def _moment_matrix( n: int, ) -> NDArray[numpy.floating]: """Segment-sum ``r**p * c**q`` per object into an ``(n, 4, 4)`` moment matrix.""" - r_pow = [numpy.ones_like(r), r, r * r, r * r * r] - c_pow = [numpy.ones_like(c), c, c * c, c * c * c] + r_pow = [r**k for k in range(_ORDER)] + c_pow = [c**k for k in range(_ORDER)] moments = numpy.zeros((n, _ORDER, _ORDER)) for p in range(_ORDER): for q in range(_ORDER): @@ -59,43 +62,45 @@ def _hu_from_normalized(nu: NDArray[numpy.floating]) -> NDArray[numpy.floating]: def spatial_moments_2d( labels: NDArray[numpy.integer], + *, + advanced: bool = True, ) -> tuple[ NDArray[numpy.floating], NDArray[numpy.floating], - NDArray[numpy.floating], - NDArray[numpy.floating], + NDArray[numpy.floating] | None, + NDArray[numpy.floating] | None, ]: """Per-object ``(raw, central, normalized, hu)`` spatial moments for a 2D label image. ``raw`` / ``central`` / ``normalized`` are ``(n, 4, 4)`` and ``hu`` is ``(n, 7)``, ordered by ascending label. Drop-in for the matching ``regionprops_table`` columns (``moments-p-q`` etc.); ``normalized`` is NaN where ``p + q < 2`` (skimage convention). + + ``advanced=False`` returns ``(raw, central, None, None)`` — the centroid-axes path only needs + ``central``, so the normalized / Hu moments are skipped when the caller won't emit them. """ - unique_labels = numpy.unique(labels) - unique_labels = unique_labels[unique_labels > 0] - n = len(unique_labels) + lut, n, origins = label_to_idx_lut(labels, return_bbox=True) if n == 0: empty = numpy.zeros((0, _ORDER, _ORDER)) - return empty, empty, empty, numpy.zeros((0, 7)) + if advanced: + return empty, empty, empty, numpy.zeros((0, 7)) + return empty, empty, None, None rows, cols = numpy.nonzero(labels) - obj = numpy.searchsorted(unique_labels, labels[rows, cols]) - - # skimage takes moments in each object's local (bounding-box) frame, so reduce to the - # per-object minimum row/col. Init the accumulators above any pixel index for minimum.at. - sentinel = 1 << 31 - rmin = numpy.full(n, sentinel) - cmin = numpy.full(n, sentinel) - numpy.minimum.at(rmin, obj, rows) - numpy.minimum.at(cmin, obj, cols) - local_r = (rows - rmin[obj]).astype(float) - local_c = (cols - cmin[obj]).astype(float) + obj = lut[labels[rows, cols]] + + # skimage takes moments in each object's local (bounding-box) frame; the per-object bbox + # origin comes straight from label_to_idx_lut's find_objects pass (no minimum.at). + local_r = (rows - origins[obj, 0]).astype(float) + local_c = (cols - origins[obj, 1]).astype(float) raw = _moment_matrix(obj, local_r, local_c, n) centre_r = raw[:, 1, 0] / raw[:, 0, 0] centre_c = raw[:, 0, 1] / raw[:, 0, 0] # Central moments by direct centred summation (binomial-from-raw loses ~1e-4 to cancellation). central = _moment_matrix(obj, local_r - centre_r[obj], local_c - centre_c[obj], n) + if not advanced: + return raw, central, None, None normalized = numpy.full((n, _ORDER, _ORDER), numpy.nan) mu00 = central[:, 0, 0] @@ -133,25 +138,35 @@ def inertia_2d( c = central[:, 0, 2] / mu00 half_trace = (a + c) / 2 disc = numpy.sqrt(((c - a) / 2) ** 2 + b**2) - return c, -b, a, half_trace + disc, half_trace - disc + # Clip eigenvalues to >= 0 (skimage does the same): tiny-negative float error on degenerate / + # thin objects would otherwise give NaN axis lengths and eccentricity > 1. + eig_major = numpy.clip(half_trace + disc, 0.0, None) + eig_minor = numpy.clip(half_trace - disc, 0.0, None) + return c, -b, a, eig_major, eig_minor def axes_eccentricity_orientation( - central: NDArray[numpy.floating], + inertia: tuple[ + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + ], ) -> tuple[ NDArray[numpy.floating], NDArray[numpy.floating], NDArray[numpy.floating], NDArray[numpy.floating], ]: - """``(axis_major, axis_minor, eccentricity, orientation)`` from per-object central moments. + """``(axis_major, axis_minor, eccentricity, orientation)`` from the per-object inertia tuple. - Matches ``skimage.measure.regionprops`` to floating-point round-off, including the symmetric - fallback (``it00 == it11`` -> ±pi/4). Derived from the same inertia tensor / eigenvalues as - :func:`inertia_2d`, so requesting these no longer forces regionprops' per-region moment einsum - (CellProfiler reports orientation in degrees; callers apply the ``180/pi`` conversion). + Takes the output of :func:`inertia_2d` (so the eigendecomposition is computed once and shared + with the inertia features) and matches ``skimage.measure.regionprops`` to floating-point + round-off, including the symmetric fallback (``it00 == it11`` -> ±pi/4). CellProfiler reports + orientation in degrees; callers apply the ``180/pi`` conversion. """ - it00, it_off, it11, eig_major, eig_minor = inertia_2d(central) + it00, it_off, it11, eig_major, eig_minor = inertia axis_major = 4 * numpy.sqrt(eig_major) axis_minor = 4 * numpy.sqrt(eig_minor) with numpy.errstate(invalid="ignore", divide="ignore"): @@ -164,3 +179,49 @@ def axes_eccentricity_orientation( 0.5 * numpy.arctan2(-2 * it_off, it11 - it00), ) return axis_major, axis_minor, eccentricity, orientation + + +def moment_feature_dict( + raw: NDArray[numpy.floating], + central: NDArray[numpy.floating], + normalized: NDArray[numpy.floating], + hu: NDArray[numpy.floating], + inertia: tuple[ + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + ], +) -> dict[str, NDArray[numpy.floating]]: + """Assemble the ``calculate_advanced`` moment + inertia features of 2D ``get_sizeshape``. + + Single source of truth for these 53 feature names and the ``(p, q)`` orders. Keys are emitted + in the *grouped* order of the CellProfiler / PyPI release (all Spatial, then Central, then + Normalized, then Hu, then the inertia tensor) so the output column order is unchanged. ``raw`` + and ``central`` are ``(n, 4, 4)`` with only ``p in {0, 1, 2}`` exposed; ``normalized`` is the + full ``(n, 4, 4)``; ``hu`` is ``(n, 7)``. ``inertia`` is + ``(it_0_0, it_0_1, it_1_0, it_1_1, eigenvalue_0, eigenvalue_1)`` (both off-diagonals passed + explicitly — they are equal for the symmetric tensor). + """ + it_0_0, it_0_1, it_1_0, it_1_1, eig_0, eig_1 = inertia + features: dict[str, NDArray[numpy.floating]] = {} + for p in range(3): # spatial / central expose p in {0,1,2}, q in {0,1,2,3} + for q in range(_ORDER): + features[f"SpatialMoment_{p}_{q}"] = raw[:, p, q] + for p in range(3): + for q in range(_ORDER): + features[f"CentralMoment_{p}_{q}"] = central[:, p, q] + for p in range(_ORDER): # normalized full 4x4 + for q in range(_ORDER): + features[f"NormalizedMoment_{p}_{q}"] = normalized[:, p, q] + for k in range(7): + features[f"HuMoment_{k}"] = hu[:, k] + features["InertiaTensor_0_0"] = it_0_0 + features["InertiaTensor_0_1"] = it_0_1 + features["InertiaTensor_1_0"] = it_1_0 + features["InertiaTensor_1_1"] = it_1_1 + features["InertiaTensorEigenvalues_0"] = eig_0 + features["InertiaTensorEigenvalues_1"] = eig_1 + return features diff --git a/src/cp_measure/primitives/segment.py b/src/cp_measure/primitives/segment.py index e5b5894..ab741e4 100644 --- a/src/cp_measure/primitives/segment.py +++ b/src/cp_measure/primitives/segment.py @@ -14,7 +14,9 @@ def label_to_idx_lut( masks: NDArray[numpy.integer], -) -> tuple[NDArray[numpy.int64], int]: + *, + return_bbox: bool = False, +): """Build a ``label -> 0..n-1`` lookup over the sorted positive labels. Returns ``(lut, n)`` where ``lut[label]`` is the segment index (and ``-1`` @@ -25,15 +27,24 @@ def label_to_idx_lut( Present labels are enumerated with ``scipy.ndimage.find_objects`` (one pass) rather than ``numpy.unique`` (a full-image sort): same ascending label set, identical LUT, ~3-5x faster on large/3D masks. + + With ``return_bbox=True`` also returns ``origins``, an ``(n, ndim)`` array of each + object's per-axis bounding-box minimum (the ``find_objects`` slice starts, already + computed here) — the local-frame origin the moment routines need, for free instead of + a separate ``numpy.minimum.at`` pass. """ if not numpy.issubdtype(masks.dtype, numpy.integer): masks = masks.astype(numpy.intp, copy=False) bboxes = scipy.ndimage.find_objects(masks) - labels = numpy.array( - [i + 1 for i, sl in enumerate(bboxes) if sl is not None], dtype=numpy.int64 - ) + present = [(i + 1, sl) for i, sl in enumerate(bboxes) if sl is not None] + labels = numpy.array([lab for lab, _ in present], dtype=numpy.int64) n = int(labels.size) max_label = int(labels[-1]) if n else 0 lut = numpy.full(max_label + 1, -1, dtype=numpy.int64) lut[labels] = numpy.arange(n, dtype=numpy.int64) - return lut, n + if not return_bbox: + return lut, n + origins = numpy.array( + [[s.start for s in sl] for _, sl in present], dtype=numpy.int64 + ).reshape(n, masks.ndim) + return lut, n, origins diff --git a/test/test_sizeshape_moments.py b/test/test_sizeshape_moments.py index b04d2b5..5898555 100644 --- a/test/test_sizeshape_moments.py +++ b/test/test_sizeshape_moments.py @@ -212,6 +212,61 @@ def test_axes_match_single_object(): _assert_axes_match(masks) +# Exact output column order of the PyPI 0.1.19 release (2D, new_features + calculate_advanced on). +# moment_feature_dict must keep this grouped order (all Spatial, then Central, then Normalized, +# then Hu, then the inertia tensor); a reorder is a silent schema break. +_RELEASE_KEY_ORDER = [ + "Area", "BoundingBoxArea", "ConvexArea", "EquivalentDiameter", "Perimeter", + "MajorAxisLength", "MinorAxisLength", "Eccentricity", "Orientation", "Center_X", + "Center_Y", "BoundingBoxMinimum_X", "BoundingBoxMaximum_X", "BoundingBoxMinimum_Y", + "BoundingBoxMaximum_Y", "FormFactor", "Extent", "Solidity", "Compactness", "EulerNumber", + "MaximumRadius", "MeanRadius", "MedianRadius", "FilledArea", + *[f"SpatialMoment_{p}_{q}" for p in range(3) for q in range(4)], + *[f"CentralMoment_{p}_{q}" for p in range(3) for q in range(4)], + *[f"NormalizedMoment_{p}_{q}" for p in range(4) for q in range(4)], + *[f"HuMoment_{k}" for k in range(7)], + "InertiaTensor_0_0", "InertiaTensor_0_1", "InertiaTensor_1_0", "InertiaTensor_1_1", + "InertiaTensorEigenvalues_0", "InertiaTensorEigenvalues_1", "PerimeterCrofton", +] + + +def test_sizeshape_key_order_matches_release(): + masks = numpy.zeros((40, 40), numpy.int32) + masks[5:25, 5:25] = 1 + assert list(get_sizeshape(masks, masks.astype(float))) == _RELEASE_KEY_ORDER + + +def test_axes_clip_thin_objects_match_skimage(): + # Thin / oblique objects have a (near-)singular inertia tensor; float error can drive the minor + # eigenvalue slightly negative. inertia_2d clips to 0 like skimage, so axis lengths and + # eccentricity match regionprops and are never NaN. Pre-clip, ~4% of these gave NaN axis_minor + # / eccentricity > 1 — this loop guards that regression. + rng = numpy.random.default_rng(1) + for _ in range(50): + masks = numpy.zeros((40, 40), numpy.int32) + r0, c0 = rng.integers(2, 18, 2) + length = int(rng.integers(6, 18)) + dr, dc = rng.integers(-2, 3, 2) + for t in range(length): + r, c = r0 + t * dr, c0 + t * dc + if 0 <= r < 40 and 0 <= c < 40: + masks[r, c] = 1 + if masks.max() == 0: + continue + out = get_sizeshape(masks, masks.astype(float)) + assert not numpy.isnan(out["MinorAxisLength"]).any() + assert not numpy.isnan(out["Eccentricity"]).any() + ref = skimage.measure.regionprops_table( + masks, properties=["axis_minor_length", "eccentricity"] + ) + numpy.testing.assert_allclose( + out["MinorAxisLength"], ref["axis_minor_length"], rtol=1e-7, atol=1e-9 + ) + numpy.testing.assert_allclose( + out["Eccentricity"], ref["eccentricity"], rtol=1e-7, atol=1e-9 + ) + + def test_axes_match_noncontiguous_labels(): masks = numpy.zeros((96, 96), numpy.int32) masks[10:30, 10:30] = 1