Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/cp_measure/core/measureobjectintensitydistribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -132,6 +133,17 @@ 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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out things won't be as simple. We should define what "legacy" means:

(3) Cellprofiler before the MAD fix was merged CellProfiler/CellProfiler@02a8b51
(2) scipy before the fix for #22 was merged scipy/scipy#25293

I think "legacy" should mean "before the MAD fix, but not necessarily before the scipy change". It's still unclear how the scipy change will affect the benchmark dataset, but I don't think it will do so significantly. We can add a flag to increase the tolerance for that case /at the test level/.

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).
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:
Expand All @@ -140,6 +152,50 @@ 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.
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
)
per_object = [
_radial_distribution_image(
numpy.pad(labels[sl] == label, 1).astype(int),
numpy.pad(pixels[sl], 1),
scaled,
bin_count,
maximum_radius,
)
for label, sl in present
]
return {
key: numpy.concatenate([obj[key] 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)
Expand Down
57 changes: 57 additions & 0 deletions test/test_radial_distribution.py
Original file line number Diff line number Diff line change
@@ -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():

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's assume that "legacy" has always covered this (because this is sort-of orthogonal to the legacy implementation since it is due to a dependency)

"""#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():

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a note to remove this once github.com/scipy/scipy/pull/25293 is merged.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And maybe the legacy flag for this measurement altogether (?) This one is a tricky one.

"""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():

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is unnecessary, we have already identified the cause of the divergence. Not worth having this test if it will be merged in the future anyways. I'm becoming more keen on raising the lower bound of scipy if it doesn't break other python versions or dependencies.

"""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
)
Loading