From 28a94a90cbd200b9de54d2d379d3cb9a0ce99046 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Fri, 5 Jun 2026 03:59:18 +0200 Subject: [PATCH 1/3] fix(radial): per-object measurement so results are label-independent (Issue #22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_radial_distribution computed its geometry (distance_to_edge / propagate) over the whole label image, so an object's radial features could be perturbed by neighbouring labels (Issue #22). Measure each object on its own cropped + 1px-padded mask instead, reusing the exact same numpy/centrosome algorithm — so each object's result equals the baseline run on that object in isolation, by construction. - new default (legacy=False): per-object crop, field-independent. - legacy=True: the original whole-image behaviour, byte-for-byte. The original whole-image algorithm is preserved verbatim as the private _radial_distribution_image; the public function dispatches to it per isolated crop or once on the whole image. +tests: per-object independence (#22 property, bit-exact), legacy-leaks-where-new-doesn't, and odd-centre object new==legacy. Closes #22. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../measureobjectintensitydistribution.py | 52 +++++++++++++++++ test/test_radial_distribution.py | 57 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 test/test_radial_distribution.py diff --git a/src/cp_measure/core/measureobjectintensitydistribution.py b/src/cp_measure/core/measureobjectintensitydistribution.py index f142d2a..386c3e1 100644 --- a/src/cp_measure/core/measureobjectintensitydistribution.py +++ b/src/cp_measure/core/measureobjectintensitydistribution.py @@ -95,6 +95,7 @@ def get_radial_distribution( scaled: bool = True, bin_count: int = 4, maximum_radius: int = 100, + legacy: bool = False, ) -> dict[str, NDArray[numpy.floating]]: """ Radial features (2D only) @@ -132,6 +133,12 @@ def get_radial_distribution( creates the number of bins that you specify and creates equally spaced bin boundaries up to the maximum radius. Parts of the object that are beyond this radius will be counted in an overflow bin. The radius is measured in pixels. + + legacy : bool + When False (default) each object is measured on its own cropped mask, so its + result is independent of every other label in the field (the Issue #22 fix). + When True, reproduce the original whole-image behaviour, where an object's + radial geometry can be perturbed by neighbouring labels. """ if labels.ndim == 3: @@ -140,6 +147,51 @@ def get_radial_distribution( if labels.dtype == bool: labels = labels.astype(numpy.integer) + if legacy: + return _radial_distribution_image( + labels, pixels, scaled, bin_count, maximum_radius + ) + + # Issue #22 fix: measure each object on its own cropped + 1px-padded mask, so the + # imported geometry (distance_to_edge / propagate) and the binning see ONLY this + # object — its result equals the baseline run on that object in isolation. + unique_labels = numpy.unique(labels) + unique_labels = unique_labels[unique_labels > 0] + if len(unique_labels) == 0: # no objects -> empty arrays with the right keys + return _radial_distribution_image( + labels, pixels, scaled, bin_count, maximum_radius + ) + slices = scipy.ndimage.find_objects(labels) + per_object = [ + _radial_distribution_image( + numpy.pad(labels[sl] == label_value, 1).astype(int), + numpy.pad(pixels[sl], 1), + scaled, + bin_count, + maximum_radius, + ) + for label_value in unique_labels + for sl in (slices[label_value - 1],) + ] + return { + key: numpy.array([obj[key][0] for obj in per_object]) for key in per_object[0] + } + + +def _radial_distribution_image( + labels: NDArray[numpy.integer], + pixels: NDArray[numpy.floating], + scaled: bool = True, + bin_count: int = 4, + maximum_radius: int = 100, +) -> dict[str, NDArray[numpy.floating]]: + """Whole-image radial distribution over every label (the original algorithm). + + All objects are measured together, so an object's geometry can be influenced by + other labels in the field. :func:`get_radial_distribution` calls this once per + isolated object crop (the Issue #22 fix) or once on the whole image + (``legacy=True``). + """ unique_labels = numpy.unique(labels) unique_labels = unique_labels[unique_labels > 0] nobjects = len(unique_labels) diff --git a/test/test_radial_distribution.py b/test/test_radial_distribution.py new file mode 100644 index 0000000..ab2bc34 --- /dev/null +++ b/test/test_radial_distribution.py @@ -0,0 +1,57 @@ +"""Issue #22: get_radial_distribution must give per-object results independent of +other labels. The default (legacy=False) measures each object on its own crop; +legacy=True keeps the original whole-image behaviour.""" + +import numpy as np + +from cp_measure.core.measureobjectintensitydistribution import get_radial_distribution + + +def _three_touching(): + """Three abutting rectangles — a layout where the whole-image geometry leaks.""" + labels = np.zeros((48, 48), dtype=np.int32) + labels[5:40, 5:18] = 1 + labels[5:40, 18:31] = 2 + labels[5:40, 31:44] = 3 + pixels = np.random.default_rng(1).random((48, 48)) + return labels, pixels + + +def test_radial_per_object_independent_of_neighbours(): + """#22: an object's features are identical with or without other labels present. + + Both sides use the per-object default, so this holds bit-exactly even for + symmetric objects whose centre is a plateau (the crop is the same array).""" + labels, pixels = _three_touching() + new = get_radial_distribution(labels, pixels) + for lbl in (1, 2, 3): + alone = np.where(labels == lbl, 1, 0).astype(np.int32) + iso = get_radial_distribution(alone, pixels) + for key in new: + np.testing.assert_allclose( + new[key][lbl - 1], iso[key][0], equal_nan=True, err_msg=key + ) + + +def test_radial_legacy_leaks_where_new_does_not(): + """The whole-image (legacy) path is perturbed by neighbours on this layout while + the per-object default is not — they differ, proving #22 is real and fixed.""" + labels, pixels = _three_touching() + new = get_radial_distribution(labels, pixels) + legacy = get_radial_distribution(labels, pixels, legacy=True) + assert any(not np.allclose(new[k], legacy[k], equal_nan=True) for k in new) + + +def test_radial_unique_centre_object_new_equals_legacy(): + """For an unambiguous (odd-sized) centre, the per-object crop reproduces the + whole-image result exactly — confirming the crop introduces no difference of its + own; the only systematic divergence is the centre tie-break on symmetric objects.""" + labels = np.zeros((41, 41), dtype=np.int32) + labels[8:29, 8:29] = 1 # 21x21 odd square -> unique centre pixel + pixels = np.random.default_rng(2).random((41, 41)) + new = get_radial_distribution(labels, pixels) + legacy = get_radial_distribution(labels, pixels, legacy=True) + for k in new: + np.testing.assert_allclose( + new[k], legacy[k], rtol=1e-6, atol=1e-8, equal_nan=True, err_msg=k + ) From 54f348911204ea00d4c72f8373da8df76a733dcb Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Fri, 5 Jun 2026 04:13:42 +0200 Subject: [PATCH 2/3] refactor(radial): tidy per-object dispatcher after /simplify - derive present labels from find_objects (drops the redundant numpy.unique and the `for sl in (slices[...],)` comprehension trick) - assemble results with numpy.concatenate over the (1,) per-object arrays instead of numpy.array([obj[key][0] ...]) No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/measureobjectintensitydistribution.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/cp_measure/core/measureobjectintensitydistribution.py b/src/cp_measure/core/measureobjectintensitydistribution.py index 386c3e1..9920e3e 100644 --- a/src/cp_measure/core/measureobjectintensitydistribution.py +++ b/src/cp_measure/core/measureobjectintensitydistribution.py @@ -155,26 +155,25 @@ def get_radial_distribution( # Issue #22 fix: measure each object on its own cropped + 1px-padded mask, so the # imported geometry (distance_to_edge / propagate) and the binning see ONLY this # object — its result equals the baseline run on that object in isolation. - unique_labels = numpy.unique(labels) - unique_labels = unique_labels[unique_labels > 0] - if len(unique_labels) == 0: # no objects -> empty arrays with the right keys + slices = scipy.ndimage.find_objects(labels) + present = [(label, sl) for label, sl in enumerate(slices, 1) if sl is not None] + if not present: # no objects -> empty arrays with the right keys return _radial_distribution_image( labels, pixels, scaled, bin_count, maximum_radius ) - slices = scipy.ndimage.find_objects(labels) per_object = [ _radial_distribution_image( - numpy.pad(labels[sl] == label_value, 1).astype(int), + numpy.pad(labels[sl] == label, 1).astype(int), numpy.pad(pixels[sl], 1), scaled, bin_count, maximum_radius, ) - for label_value in unique_labels - for sl in (slices[label_value - 1],) + for label, sl in present ] return { - key: numpy.array([obj[key][0] for obj in per_object]) for key in per_object[0] + key: numpy.concatenate([obj[key] for obj in per_object]) + for key in per_object[0] } From 454a6cb876d3e31d06595b4c1c48417e475f2e30 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Fri, 5 Jun 2026 04:21:53 +0200 Subject: [PATCH 3/3] docs(radial): clarify legacy=True is the CellProfiler-faithful whole-image result Make the contract explicit: legacy=True reproduces CellProfiler / centrosome (the Issue #22 leak is rooted upstream in scipy), while the default deliberately diverges from CellProfiler to give per-object-independent values. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/measureobjectintensitydistribution.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cp_measure/core/measureobjectintensitydistribution.py b/src/cp_measure/core/measureobjectintensitydistribution.py index 9920e3e..3cb6134 100644 --- a/src/cp_measure/core/measureobjectintensitydistribution.py +++ b/src/cp_measure/core/measureobjectintensitydistribution.py @@ -137,8 +137,13 @@ def get_radial_distribution( legacy : bool When False (default) each object is measured on its own cropped mask, so its result is independent of every other label in the field (the Issue #22 fix). - When True, reproduce the original whole-image behaviour, where an object's - radial geometry can be perturbed by neighbouring labels. + This deliberately diverges from CellProfiler on multi-object images. + + When True, reproduce the whole-image result of CellProfiler / centrosome, + where an object's radial geometry can be perturbed by neighbouring labels + (Issue #22, rooted upstream in scipy). Use this for CellProfiler-faithful + values; otherwise the default gives the corrected, per-object-independent + measurement. """ if labels.ndim == 3: