From d75d3f022e4c2f0a3fe90e18ac8441f62ef0febb Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Sat, 6 Jun 2026 17:16:10 +0200 Subject: [PATCH 1/4] perf(sizeshape): vectorize get_zernike on foreground pixels (~8x) `get_zernike` delegated to `centrosome.zernike.zernike`, which scatters the per-pixel Zernike basis into a full `(H, W, K)` complex array (~560 MB at 1080^2, K~30) and scores via ~60 full-image `scipy.ndimage.sum` calls. Both costs scale with image area rather than object pixels, so most work lands on background. Replace it with a pure-numpy `_zernike_scores` helper that keeps the basis on the masked foreground vectors and segment-sums each moment by label with a single `numpy.bincount`. The Horner basis evaluation is copied verbatim from `centrosome.construct_zernike_polynomials` (same lookup-table coefficients, `r**2 > 1` cutoff and `z = y + i*x` convention), so results track the installed centrosome to floating-point round-off (bit-identical in practice). The helper lives in `cp_measure.utils` (alongside `masks_to_ijv`) and takes an optional pixel `weight` so the sibling `get_radial_zernikes` can reuse it for intensity-weighted moments in a follow-up PR. Measured: 8.6x at typical density (782->98 ms large tier), 3.7-44x depending on foreground fraction. No new deps. Also switches from `range(1, n+1)` to the actual unique labels (identical on contiguous masks, correct for non-contiguous). Adds golden + edge tests (empty, single-pixel r=0, non-contiguous labels, edge-touching, non-default zernike_numbers) asserting parity with centrosome. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cp_measure/core/measureobjectsizeshape.py | 22 ++-- src/cp_measure/utils.py | 71 ++++++++++++ test/test_zernike.py | 106 ++++++++++++++++++ 3 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 test/test_zernike.py diff --git a/src/cp_measure/core/measureobjectsizeshape.py b/src/cp_measure/core/measureobjectsizeshape.py index 0e32b17..b1db6cf 100644 --- a/src/cp_measure/core/measureobjectsizeshape.py +++ b/src/cp_measure/core/measureobjectsizeshape.py @@ -7,7 +7,7 @@ import numpy import scipy.ndimage import skimage.measure -from cp_measure.utils import masks_to_ijv +from cp_measure.utils import _zernike_scores, masks_to_ijv __doc__ = """\ MeasureObjectSizeShape @@ -1009,22 +1009,20 @@ def get_zernike( zernike_numbers: int = 9, ) -> dict[str, NDArray[numpy.floating]]: # - # Zernike features (2D only) + # Zernike features (2D only). `pixels` is unused: these are shape moments. # if masks.ndim == 3: return {} - unique_indices = numpy.unique(masks) - unique_indices = unique_indices[unique_indices > 0] - indices = list(range(1, len(unique_indices) + 1)) - labels = masks - zernike_numbers = centrosome.zernike.get_zernike_indexes(zernike_numbers + 1) + indices = numpy.unique(masks) + indices = indices[indices > 0] + zernike_indexes = centrosome.zernike.get_zernike_indexes(zernike_numbers + 1) - zf_l = centrosome.zernike.zernike(zernike_numbers, labels, indices) - results = {} - for (n, m), z in zip(zernike_numbers, zf_l.transpose()): # type: ignore[call-overload] - results[f"Zernike_{n}_{m}"] = z + real_sums, imag_sums, radii = _zernike_scores(masks, indices, zernike_indexes) + areas = numpy.pi * radii * radii + with numpy.errstate(divide="ignore", invalid="ignore"): + score = numpy.sqrt(real_sums**2 + imag_sums**2) / areas[:, numpy.newaxis] - return results + return {f"Zernike_{n}_{m}": score[:, i] for i, (n, m) in enumerate(zernike_indexes)} def get_feret( diff --git a/src/cp_measure/utils.py b/src/cp_measure/utils.py index 4d5d2d2..71297a7 100644 --- a/src/cp_measure/utils.py +++ b/src/cp_measure/utils.py @@ -2,7 +2,9 @@ Utilities reused in multiple measurements. """ +import centrosome.zernike import numpy +from numpy.typing import NDArray def _ensure_np_array(value): @@ -49,3 +51,72 @@ def labels_to_binmasks(masks: numpy.ndarray) -> numpy.ndarray: labels = numpy.unique(masks) labels = labels[labels > 0] return masks == labels.reshape((-1,) + (1,) * masks.ndim) + + +def _zernike_scores( + masks: NDArray[numpy.integer], + indices: NDArray[numpy.integer], + zernike_indexes: NDArray[numpy.integer], + weight: NDArray[numpy.floating] | None = None, +) -> tuple[NDArray[numpy.floating], NDArray[numpy.floating], NDArray[numpy.floating]]: + """Per-object real/imaginary Zernike moment sums, vectorised on foreground pixels. + + Mirrors ``centrosome.zernike.zernike`` but avoids its two area-scaling costs: it never + scatters the basis into a full ``(H, W, K)`` complex array, and it segment-sums each + moment by label with one ``numpy.bincount`` instead of a per-channel + ``scipy.ndimage.sum`` over the whole image. The Horner basis evaluation is copied + verbatim from ``centrosome.construct_zernike_polynomials`` (same lookup-table + coefficients, ``r**2 > 1`` cutoff and ``z = y + i*x`` convention) so the result matches + centrosome to floating-point round-off. + + Returns ``(real_sums, imag_sums, radii)`` with shapes ``(n, K)``, ``(n, K)`` and + ``(n,)`` ordered by ``indices``. ``weight`` (e.g. intensity) multiplies each pixel's + contribution; ``None`` gives the unweighted shape moments. Used by both ``get_zernike`` + (unweighted shape moments) and ``get_radial_zernikes`` (intensity-weighted moments). + """ + n = len(indices) + k = len(zernike_indexes) + centers, radii = centrosome.zernike.minimum_enclosing_circle(masks, indices) + radii = numpy.asarray(radii, dtype=float) + real_sums = numpy.zeros((n, k)) + imag_sums = numpy.zeros((n, k)) + if n == 0: + return real_sums, imag_sums, radii + + # Map each label to its row in [0, n); -1 for background / unselected labels. + rev = numpy.full(int(masks.max()) + 1, -1, dtype=int) + rev[indices] = numpy.arange(n) + mask = rev[masks] != -1 + rows, cols = numpy.nonzero(mask) + rev_idx = rev[masks[rows, cols]] + + # Coordinates relative to each object's enclosing circle (unit disk). Taken straight + # from the foreground pixel indices — no full (H, W) coordinate grid is materialised. + ym = (rows - centers[rev_idx, 0]) / radii[rev_idx] + xm = (cols - centers[rev_idx, 1]) / radii[rev_idx] + + lut = centrosome.zernike.construct_zernike_lookuptable(zernike_indexes) + r_square = xm * xm + ym * ym + z = ym + 1j * xm + w = None if weight is None else weight[mask].astype(float) + + z_pows: dict[int, NDArray] = {} + for idx in range(k): + zn, zm = zernike_indexes[idx] + s = numpy.zeros_like(xm) + for kk in range((zn - zm) // 2 + 1): # Horner scheme on r**2 + s *= r_square + s += lut[idx, kk] + s[r_square > 1] = 0 + if w is not None: + s *= w + if zm == 0: + zf = s.astype(complex) + else: + if zm not in z_pows: + z_pows[zm] = z if zm == 1 else z**zm + zf = s * z_pows[zm] + real_sums[:, idx] = numpy.bincount(rev_idx, weights=zf.real, minlength=n) + imag_sums[:, idx] = numpy.bincount(rev_idx, weights=zf.imag, minlength=n) + + return real_sums, imag_sums, radii diff --git a/test/test_zernike.py b/test/test_zernike.py new file mode 100644 index 0000000..9262ba6 --- /dev/null +++ b/test/test_zernike.py @@ -0,0 +1,106 @@ +"""Golden + edge tests for the vectorised numpy ``get_zernike``. + +The vectorised implementation avoids centrosome's full (H, W, K) scatter and the per-channel +``scipy.ndimage.sum``; it must match ``centrosome.zernike.zernike`` (called with the actual +label values) to floating-point round-off. Existing ``test_core_measurements`` already covers +shape / 3D-empty; here we lock the numerical result and the edge cases. +""" + +import centrosome.zernike +import numpy + +from cp_measure.core.measureobjectsizeshape import get_zernike + +ATOL = 1e-10 # >> the ~2e-16 round-off observed, << any real signal + + +def _reference(masks, zernike_numbers=9): + """Old centrosome path, called with the real label values as indices.""" + uniq = numpy.unique(masks) + uniq = uniq[uniq > 0] + zidx = centrosome.zernike.get_zernike_indexes(zernike_numbers + 1) + zf = centrosome.zernike.zernike(zidx, masks, uniq) + return {f"Zernike_{n}_{m}": zf[:, i] for i, (n, m) in enumerate(zidx)} + + +def _assert_matches(masks, zernike_numbers=9): + ref = _reference(masks, zernike_numbers) + got = get_zernike(masks, None, zernike_numbers) + assert list(got) == list(ref), "key set / order changed" + for k in ref: + assert got[k].shape == ref[k].shape, k + assert numpy.allclose(got[k], ref[k], atol=ATOL, rtol=1e-10, equal_nan=True), ( + f"{k}: max|diff|={numpy.nanmax(numpy.abs(got[k] - ref[k]))}" + ) + + +def _square_objects(size, n, gap_frac=0.75): + 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 test_zernike_matches_centrosome_single_object(): + masks = numpy.zeros((64, 64), numpy.int32) + masks[20:45, 18:50] = 1 + _assert_matches(masks) + + +def test_zernike_matches_centrosome_multi_object(): + _assert_matches(_square_objects(256, 4)) + + +def test_zernike_matches_centrosome_irregular(): + rng = numpy.random.default_rng(0) + masks = numpy.zeros((128, 128), numpy.int32) + for lab, (cy, cx) in enumerate(rng.integers(20, 108, size=(6, 2)), 1): + yy, xx = numpy.mgrid[0:128, 0:128] + masks[(yy - cy) ** 2 + (xx - cx) ** 2 < rng.integers(40, 120)] = lab + _assert_matches(masks) + + +def test_zernike_object_touching_edge(): + masks = numpy.zeros((64, 64), numpy.int32) + masks[0:20, 0:20] = 1 # clipped at the top-left corner + masks[40:64, 40:64] = 2 # clipped at the bottom-right corner + _assert_matches(masks) + + +def test_zernike_noncontiguous_labels(): + # labels {1, 3, 7}: the old range(1, n+1) assumption would be wrong; the new code + # uses the real label values and must match centrosome called the same way. + 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_matches(masks) + + +def test_zernike_single_pixel_object(): + # minimum_enclosing_circle radius -> 0, so both paths divide by zero identically. + masks = numpy.zeros((32, 32), numpy.int32) + masks[16, 16] = 1 + masks[5:15, 5:15] = 2 # a normal object alongside the degenerate one + _assert_matches(masks) + + +def test_zernike_non_default_degree(): + _assert_matches(_square_objects(128, 3), zernike_numbers=6) + + +def test_zernike_empty_mask(): + masks = numpy.zeros((40, 40), numpy.int32) + got = get_zernike(masks, None) + zidx = centrosome.zernike.get_zernike_indexes(9 + 1) + assert list(got) == [f"Zernike_{n}_{m}" for n, m in zidx] + assert all(v.shape == (0,) for v in got.values()) + + +def test_zernike_3d_returns_empty(): + assert get_zernike(numpy.zeros((4, 16, 16), numpy.int32), None) == {} From 844acdfc9b920abc6f6efb289e2bf64e7bb8683f Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 9 Jun 2026 21:20:46 +0200 Subject: [PATCH 2/4] refactor(sizeshape): make _zernike_scores a complete reusable primitive Reuse primitives.segment.label_to_idx_lut for the label->row map (correct sizing, find_objects-based) instead of a hand-rolled reverse map keyed on masks.max(); derive labels internally so get_zernike no longer needs its own unique() pass. Single foreground gather, skip the identically-zero imaginary segment-sum for m==0 moments, and precompute the azimuthal powers once. Return (real_sums, imag_sums, radii, counts): radii feeds get_zernike's pi*r**2 normalisation, counts the intensity-weighted radial Zernikes (PR #75), which reuse this via the restored `weight` arg. Add weighted + count golden tests vs centrosome so no path ships untested. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cp_measure/core/measureobjectsizeshape.py | 4 +- src/cp_measure/utils.py | 92 ++++++++++--------- test/test_zernike.py | 55 +++++++++++ 3 files changed, 103 insertions(+), 48 deletions(-) diff --git a/src/cp_measure/core/measureobjectsizeshape.py b/src/cp_measure/core/measureobjectsizeshape.py index b1db6cf..cf5261f 100644 --- a/src/cp_measure/core/measureobjectsizeshape.py +++ b/src/cp_measure/core/measureobjectsizeshape.py @@ -1013,11 +1013,9 @@ def get_zernike( # if masks.ndim == 3: return {} - indices = numpy.unique(masks) - indices = indices[indices > 0] zernike_indexes = centrosome.zernike.get_zernike_indexes(zernike_numbers + 1) - real_sums, imag_sums, radii = _zernike_scores(masks, indices, zernike_indexes) + real_sums, imag_sums, radii, _counts = _zernike_scores(masks, zernike_indexes) areas = numpy.pi * radii * radii with numpy.errstate(divide="ignore", invalid="ignore"): score = numpy.sqrt(real_sums**2 + imag_sums**2) / areas[:, numpy.newaxis] diff --git a/src/cp_measure/utils.py b/src/cp_measure/utils.py index 71297a7..888c9ed 100644 --- a/src/cp_measure/utils.py +++ b/src/cp_measure/utils.py @@ -6,6 +6,8 @@ import numpy from numpy.typing import NDArray +from cp_measure.primitives.segment import label_to_idx_lut + def _ensure_np_array(value): """Convert a result from scipy.ndimage to a numpy array @@ -55,68 +57,68 @@ def labels_to_binmasks(masks: numpy.ndarray) -> numpy.ndarray: def _zernike_scores( masks: NDArray[numpy.integer], - indices: NDArray[numpy.integer], zernike_indexes: NDArray[numpy.integer], weight: NDArray[numpy.floating] | None = None, -) -> tuple[NDArray[numpy.floating], NDArray[numpy.floating], NDArray[numpy.floating]]: +) -> tuple[ + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], + NDArray[numpy.floating], +]: """Per-object real/imaginary Zernike moment sums, vectorised on foreground pixels. - Mirrors ``centrosome.zernike.zernike`` but avoids its two area-scaling costs: it never - scatters the basis into a full ``(H, W, K)`` complex array, and it segment-sums each - moment by label with one ``numpy.bincount`` instead of a per-channel - ``scipy.ndimage.sum`` over the whole image. The Horner basis evaluation is copied - verbatim from ``centrosome.construct_zernike_polynomials`` (same lookup-table - coefficients, ``r**2 > 1`` cutoff and ``z = y + i*x`` convention) so the result matches - centrosome to floating-point round-off. - - Returns ``(real_sums, imag_sums, radii)`` with shapes ``(n, K)``, ``(n, K)`` and - ``(n,)`` ordered by ``indices``. ``weight`` (e.g. intensity) multiplies each pixel's - contribution; ``None`` gives the unweighted shape moments. Used by both ``get_zernike`` - (unweighted shape moments) and ``get_radial_zernikes`` (intensity-weighted moments). + Mirrors ``centrosome.zernike.zernike`` without its two area-scaling costs: the basis is + never scattered into a full ``(H, W, K)`` complex array, and each moment is segment-summed + by label with one ``numpy.bincount`` instead of a whole-image ``scipy.ndimage.sum``. The + Horner basis evaluation is copied from ``centrosome.construct_zernike_polynomials`` (same + lookup table, ``r**2 > 1`` cutoff, ``z = y + i*x`` convention), so results match centrosome + to round-off. + + ``weight`` (co-shaped with ``masks``, e.g. an intensity image) scales each pixel's + contribution; ``None`` gives the unweighted shape moments. Returns + ``(real_sums, imag_sums, radii, counts)`` of shapes ``(n, K)``, ``(n, K)``, ``(n,)`` and + ``(n,)``, ordered by ascending label, where ``radii`` is the enclosing-circle radius and + ``counts`` the object pixel count. ``get_zernike`` normalises by ``pi * radii**2``; the + intensity-weighted radial Zernikes normalise by ``counts``. """ - n = len(indices) + lut, n = label_to_idx_lut(masks) k = len(zernike_indexes) - centers, radii = centrosome.zernike.minimum_enclosing_circle(masks, indices) + labels = numpy.flatnonzero(lut >= 0) + centers, radii = centrosome.zernike.minimum_enclosing_circle(masks, labels) radii = numpy.asarray(radii, dtype=float) real_sums = numpy.zeros((n, k)) imag_sums = numpy.zeros((n, k)) if n == 0: - return real_sums, imag_sums, radii - - # Map each label to its row in [0, n); -1 for background / unselected labels. - rev = numpy.full(int(masks.max()) + 1, -1, dtype=int) - rev[indices] = numpy.arange(n) - mask = rev[masks] != -1 - rows, cols = numpy.nonzero(mask) - rev_idx = rev[masks[rows, cols]] - - # Coordinates relative to each object's enclosing circle (unit disk). Taken straight - # from the foreground pixel indices — no full (H, W) coordinate grid is materialised. - ym = (rows - centers[rev_idx, 0]) / radii[rev_idx] - xm = (cols - centers[rev_idx, 1]) / radii[rev_idx] - - lut = centrosome.zernike.construct_zernike_lookuptable(zernike_indexes) + return real_sums, imag_sums, radii, numpy.zeros(0) + + # Foreground pixels, their object row, and unit-disk coordinates relative to each + # object's enclosing circle — no full (H, W) coordinate grid is materialised. + seg_full = lut[masks] + keep = seg_full >= 0 + rows, cols = numpy.nonzero(keep) + seg = seg_full[keep] + counts = numpy.bincount(seg, minlength=n).astype(float) + ym = (rows - centers[seg, 0]) / radii[seg] + xm = (cols - centers[seg, 1]) / radii[seg] + + coeffs = centrosome.zernike.construct_zernike_lookuptable(zernike_indexes) r_square = xm * xm + ym * ym z = ym + 1j * xm - w = None if weight is None else weight[mask].astype(float) - - z_pows: dict[int, NDArray] = {} - for idx in range(k): - zn, zm = zernike_indexes[idx] + w = None if weight is None else weight[keep].astype(float) + z_pows = {m: z**m for m in numpy.unique(zernike_indexes[:, 1]) if m} + for idx, (zn, zm) in enumerate(zernike_indexes): s = numpy.zeros_like(xm) - for kk in range((zn - zm) // 2 + 1): # Horner scheme on r**2 + for c in coeffs[idx, : (zn - zm) // 2 + 1]: # Horner scheme on r**2 s *= r_square - s += lut[idx, kk] + s += c s[r_square > 1] = 0 if w is not None: s *= w - if zm == 0: - zf = s.astype(complex) + if zm == 0: # purely real moment; the imaginary segment-sum is identically zero + real_sums[:, idx] = numpy.bincount(seg, weights=s, minlength=n) else: - if zm not in z_pows: - z_pows[zm] = z if zm == 1 else z**zm zf = s * z_pows[zm] - real_sums[:, idx] = numpy.bincount(rev_idx, weights=zf.real, minlength=n) - imag_sums[:, idx] = numpy.bincount(rev_idx, weights=zf.imag, minlength=n) + real_sums[:, idx] = numpy.bincount(seg, weights=zf.real, minlength=n) + imag_sums[:, idx] = numpy.bincount(seg, weights=zf.imag, minlength=n) - return real_sums, imag_sums, radii + return real_sums, imag_sums, radii, counts diff --git a/test/test_zernike.py b/test/test_zernike.py index 9262ba6..c18ba3d 100644 --- a/test/test_zernike.py +++ b/test/test_zernike.py @@ -8,8 +8,10 @@ import centrosome.zernike import numpy +import scipy.ndimage from cp_measure.core.measureobjectsizeshape import get_zernike +from cp_measure.utils import _zernike_scores ATOL = 1e-10 # >> the ~2e-16 round-off observed, << any real signal @@ -104,3 +106,56 @@ def test_zernike_empty_mask(): def test_zernike_3d_returns_empty(): assert get_zernike(numpy.zeros((4, 16, 16), numpy.int32), None) == {} + + +def _weighted_reference(masks, pixels, zernike_numbers=9): + """Independent weighted real/imag/count via centrosome's own basis + scipy segment-sum. + + Mirrors centrosome.zernike()'s coordinate construction but feeds the intensity image as + ``weight`` to ``construct_zernike_polynomials`` — the path the radial Zernikes (PR #75) use. + """ + zidx = centrosome.zernike.get_zernike_indexes(zernike_numbers + 1) + labels = numpy.unique(masks) + labels = labels[labels > 0] + centers, radii = centrosome.zernike.minimum_enclosing_circle(masks, labels) + rev = numpy.full(int(masks.max()) + 1, -1, int) + rev[labels] = numpy.arange(len(labels)) + mask = rev[masks] != -1 + ny, nx = masks.shape + y, x = numpy.mgrid[0:ny, 0:nx].astype(float) + rev_ind = rev[masks[mask]] + xc = numpy.zeros((ny, nx)) + yc = numpy.zeros((ny, nx)) + yc[mask] = (y[mask] - centers[rev_ind, 0]) / radii[rev_ind] + xc[mask] = (x[mask] - centers[rev_ind, 1]) / radii[rev_ind] + zf = centrosome.zernike.construct_zernike_polynomials(xc, yc, zidx, mask, weight=pixels) + real = numpy.column_stack( + [scipy.ndimage.sum_labels(zf[:, :, i].real, masks, labels) for i in range(len(zidx))] + ) + imag = numpy.column_stack( + [scipy.ndimage.sum_labels(zf[:, :, i].imag, masks, labels) for i in range(len(zidx))] + ) + counts = numpy.asarray(scipy.ndimage.sum_labels(numpy.ones((ny, nx)), masks, labels), float) + return real, imag, counts + + +def test_zernike_scores_weighted_matches_centrosome(): + # The intensity-weighted path (weight != None) + per-object counts that PR #75 reuses. + rng = numpy.random.default_rng(1) + masks = _square_objects(160, 3) + pixels = rng.random(masks.shape) + zidx = centrosome.zernike.get_zernike_indexes(9 + 1) + real, imag, radii, counts = _zernike_scores(masks, zidx, weight=pixels) + ref_real, ref_imag, ref_counts = _weighted_reference(masks, pixels) + assert numpy.allclose(real, ref_real, atol=ATOL, rtol=1e-10) + assert numpy.allclose(imag, ref_imag, atol=ATOL, rtol=1e-10) + assert numpy.allclose(counts, ref_counts) + + +def test_zernike_scores_unit_weight_equals_unweighted(): + masks = _square_objects(128, 3) + zidx = centrosome.zernike.get_zernike_indexes(9 + 1) + r0, i0, _rad0, c0 = _zernike_scores(masks, zidx) + r1, i1, _rad1, c1 = _zernike_scores(masks, zidx, weight=numpy.ones(masks.shape)) + assert numpy.allclose(r0, r1) and numpy.allclose(i0, i1) + assert numpy.array_equal(c0, c1) From 2c1a1f1228a524d10e46f447ff740623e4dae771 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 10 Jun 2026 00:58:04 +0200 Subject: [PATCH 3/4] fix(zernike): suppress expected single-pixel divide warning in _zernike_scores Single-pixel objects have an enclosing-circle radius of 0, so the unit-disk coordinate division is 0/0 -> NaN (discarded later by the r**2 > 1 cutoff, matching centrosome). Wrap it in numpy.errstate so the expected RuntimeWarning isn't emitted from this shared helper (it would otherwise crash callers running under -W error::RuntimeWarning). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cp_measure/utils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cp_measure/utils.py b/src/cp_measure/utils.py index 888c9ed..ac07d2e 100644 --- a/src/cp_measure/utils.py +++ b/src/cp_measure/utils.py @@ -98,8 +98,12 @@ def _zernike_scores( rows, cols = numpy.nonzero(keep) seg = seg_full[keep] counts = numpy.bincount(seg, minlength=n).astype(float) - ym = (rows - centers[seg, 0]) / radii[seg] - xm = (cols - centers[seg, 1]) / radii[seg] + # Single-pixel objects have an enclosing-circle radius of 0; the resulting 0/0 yields NaN + # coordinates (which the r**2 > 1 cutoff later discards), matching centrosome — suppress the + # warning since it is expected, not a fault. + with numpy.errstate(invalid="ignore", divide="ignore"): + ym = (rows - centers[seg, 0]) / radii[seg] + xm = (cols - centers[seg, 1]) / radii[seg] coeffs = centrosome.zernike.construct_zernike_lookuptable(zernike_indexes) r_square = xm * xm + ym * ym From 35ca89244dead2cfee973b183b21ddcfaf3e3d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Al=C3=A1n=20F=2E=20Mu=C3=B1oz?= Date: Mon, 15 Jun 2026 17:53:36 -0400 Subject: [PATCH 4/4] style(test): Wrap long lines in `_weighted_reference` function --- test/test_zernike.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/test_zernike.py b/test/test_zernike.py index c18ba3d..4d3a228 100644 --- a/test/test_zernike.py +++ b/test/test_zernike.py @@ -128,14 +128,24 @@ def _weighted_reference(masks, pixels, zernike_numbers=9): yc = numpy.zeros((ny, nx)) yc[mask] = (y[mask] - centers[rev_ind, 0]) / radii[rev_ind] xc[mask] = (x[mask] - centers[rev_ind, 1]) / radii[rev_ind] - zf = centrosome.zernike.construct_zernike_polynomials(xc, yc, zidx, mask, weight=pixels) + zf = centrosome.zernike.construct_zernike_polynomials( + xc, yc, zidx, mask, weight=pixels + ) real = numpy.column_stack( - [scipy.ndimage.sum_labels(zf[:, :, i].real, masks, labels) for i in range(len(zidx))] + [ + scipy.ndimage.sum_labels(zf[:, :, i].real, masks, labels) + for i in range(len(zidx)) + ] ) imag = numpy.column_stack( - [scipy.ndimage.sum_labels(zf[:, :, i].imag, masks, labels) for i in range(len(zidx))] + [ + scipy.ndimage.sum_labels(zf[:, :, i].imag, masks, labels) + for i in range(len(zidx)) + ] + ) + counts = numpy.asarray( + scipy.ndimage.sum_labels(numpy.ones((ny, nx)), masks, labels), float ) - counts = numpy.asarray(scipy.ndimage.sum_labels(numpy.ones((ny, nx)), masks, labels), float) return real, imag, counts