"""
Extra Properties Module
=======================
This module defines custom measurement functions that extend `skimage.measure.regionprops`.
These functions are designed to be dynamically discovered and applied to single-cell regions during the feature extraction phase. They allow for complex, user-defined measurements that go beyond standard morphological or intensity features (e.g., area of dark regions, specific intensity percentiles).
Function Signature Specification
--------------------------------
To be valid, a function in this module must adhere to the following signature:
There are three valid signatures depending on whether the function needs the intensity image and, if so, which channels it should run on:
**Shape-only measurement** (regionmask only — called once per cell, no channel suffix):
.. code-block:: python
def my_shape_measurement(regionmask):
# No intensity image needed. Produces column named exactly 'my_shape_measurement'.
return scalar_value
**Run on all channels** (no ``target_channel`` — called once per channel):
.. code-block:: python
def my_custom_measurement(regionmask, intensity_image, **kwargs):
# intensity_image is a single-channel 2-D crop for each channel in turn
return scalar_value
**Run on one specific channel** (with ``target_channel`` default):
.. code-block:: python
def my_custom_measurement(regionmask, intensity_image, target_channel='my_channel', **kwargs):
# intensity_image is the crop for 'my_channel' only
return scalar_value
**How channel selection works:**
* ``target_channel`` is **never passed** as an argument at call time.
* The framework reads its **default value** via ``inspect.signature`` to identify which channel to use.
* The function is then called **once**, receiving only the crop for that channel.
* All other channel output slots are filled with ``NaN`` automatically.
* If the named channel is not present in the experiment, a warning is logged and all slots remain ``NaN``.
**Arguments:**
* **regionmask** (*ndarray*): A binary mask of the object (cell) within its bounding box.
* **intensity_image** (*ndarray*): The intensity image crop. **Note:** Unlike scikit-image's ``regionprops``, this image is *not* masked — the background is not zeroed — allowing threshold-based analysis within the bounding box.
* **target_channel** (*str, optional*): If present, its **default value** names the channel this function applies to. Do not pass it explicitly — it is read by the framework via ``inspect.signature``.
* **kwargs**: Additional keyword arguments may be passed by the system.
**Return Value:**
* Must return a **scalar** (float or int).
* Returning `NaN` is permitted and handled.
Code Examples
-------------
Here are some simple examples to illustrate the structure:
.. code-block:: python
def area(regionmask, intensity_image, **kwargs):
return np.sum(regionmask)
def mean_intensity(regionmask, intensity_image, **kwargs):
# We select only the pixels within the cell mask
masked_pixels = intensity_image[regionmask]
return np.mean(masked_pixels)
Naming and Indexing Rules
-------------------------
The name of the function determines the column name in the output measurement table.
1. **Channel Replacement**: If the function name contains the substring ``intensity``, it is automatically replaced by the actual channel name being measured.
* *Example:* ``intensity_mean`` -> ``green_channel_mean``
2. **Multi-channel Indexing**: Since these functions are often run on multiple channels, avoid using simple digits (0-9) in the function name if they could conflict with channel indexing.
* *Bad:* ``measure_ch1``
* *Good:* ``measure_channel_one``
Integration Details
-------------------
* **Automatic Discovery**: Any function defined in this module is automatically detected and listed in the GUI settings under "Extra features".
* **Execution**: These functions are called by `celldetective.measure.measure_features`.
Copyright © 2022 Laboratoire Adhesion et Inflammation
Authored by R. Torro, K. Dervanova, L. Limozin
"""
import warnings
import numpy as np
from scipy.ndimage import distance_transform_edt, center_of_mass, binary_erosion
from scipy.stats import skew, kurtosis
from celldetective.utils.masks import contour_of_instance_segmentation
from celldetective.utils.image_cleaning import interpolate_nan
import skimage.measure as skm
from celldetective.utils.mask_cleaning import fill_label_holes
from celldetective.segmentation import segment_frame_from_thresholds
from sklearn.metrics import r2_score
from typing import Tuple
# def area_detected_in_ricm(regionmask, intensity_image, target_channel='adhesion_channel'):
# instructions = {
# "thresholds": [
# 0.02,
# 1000
# ],
# "filters": [
# [
# "subtract",
# 1
# ],
# [
# "abs",
# 2
# ],
# [
# "gauss",
# 0.8
# ]
# ],
# #"marker_min_distance": 1,
# #"marker_footprint_size": 10,
# "feature_queries": [
# "eccentricity > 0.99 or area < 60"
# ],
# }
# lbl = segment_frame_from_thresholds(intensity_image, fill_holes=True, do_watershed=False, equalize_reference=None, edge_exclusion=False, **instructions)
# lbl[lbl>0] = 1 # instance to binary
# lbl[~regionmask] = 0 # make sure we don't measure stuff outside cell
# return np.sum(lbl)
[docs]
def fraction_of_area_detected_in_intensity(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Computes the fraction of the region area that is detected in the intensity image.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
Returns
-------
float
Fraction of the area detected in the intensity image.
"""
instructions = {
"thresholds": [0.02, 1000],
"filters": [["subtract", 1], ["abs", 2], ["gauss", 0.8]],
}
lbl = segment_frame_from_thresholds(
intensity_image,
do_watershed=False,
fill_holes=True,
equalize_reference=None,
edge_exclusion=False,
**instructions
)
lbl[lbl > 0] = 1 # instance to binary
lbl[~regionmask] = 0 # make sure we don't measure stuff outside cell
return float(np.sum(lbl)) / float(np.sum(regionmask))
[docs]
def area_detected_in_intensity(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Computes the detected area within the regionmask based on threshold-based segmentation.
The function applies a predefined filtering and thresholding pipeline to the intensity image (normalized adhesion channel)
to detect significant regions. The resulting segmented regions are restricted to the
`regionmask`, ensuring that only the relevant area is measured.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values define the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
target_channel : str, optional
Name of the intensity channel used for measurement. Defaults to `'adhesion_channel'`.
Returns
-------
detected_area : float
The total area (number of pixels) detected based on intensity-based segmentation.
Notes
-----
- The segmentation is performed using `segment_frame_from_thresholds()` with predefined parameters:
- Thresholding range: `[0.02, 1000]`
- Filters applied in sequence:
- `"subtract"` with value `1` (subtract 1 from intensity values)
- `"abs"` (take absolute value of intensities)
- `"gauss"` with sigma `0.8` (apply Gauss filter with sigma `0.8`)
- The segmentation includes hole filling.
- The detected regions are converted to a binary mask (`lbl > 0`).
- Any pixels outside the `regionmask` are excluded from the measurement.
"""
instructions = {
"thresholds": [0.02, 1000],
"filters": [["subtract", 1], ["abs", 2], ["gauss", 0.8]],
}
lbl = segment_frame_from_thresholds(
intensity_image,
do_watershed=False,
fill_holes=True,
equalize_reference=None,
edge_exclusion=False,
**instructions
)
lbl[lbl > 0] = 1 # instance to binary
lbl[~regionmask] = 0 # make sure we don't measure stuff outside cell
return float(np.sum(lbl))
[docs]
def area_dark_intensity(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
fill_holes: bool = True,
threshold: float = 0.95,
) -> float:
"""
Computes the absolute area within the regionmask where the intensity is below a given threshold.
This function identifies pixels in the region where the intensity is lower than `threshold`.
If `fill_holes` is `True`, small enclosed holes in the detected dark regions are filled before
computing the total area.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values define the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
target_channel : str, optional
Name of the intensity channel used for measurement. Defaults to `'adhesion_channel'`.
fill_holes : bool, optional
If `True`, fills enclosed holes in the detected dark intensity regions before computing
the area. Defaults to `True`.
threshold : float, optional
Intensity threshold below which a pixel is considered part of a dark region.
Defaults to `0.95`.
Returns
-------
dark_area : float
The absolute area (number of pixels) where intensity values are below `threshold`, within the regionmask.
Notes
-----
- The default threshold for defining "dark" intensity regions is `0.95`, but it can be adjusted.
- If `fill_holes` is `True`, the function applies hole-filling to the detected dark regions
using `skimage.measure.label` and `fill_label_holes()`.
- The ``target_channel`` default value (``'adhesion_channel'``) tells the framework which
channel to measure. The parameter is never passed at call time; instead, the framework
reads the default via ``inspect.signature``, calls the function once with that channel's
crop, and fills all other channel slots with ``NaN``.
"""
subregion = (
intensity_image < threshold
) * regionmask # under one, under 0.8, under 0.6, whatever value!
if fill_holes:
subregion = skm.label(subregion, connectivity=2, background=0)
subregion = fill_label_holes(subregion)
subregion[subregion > 0] = 1
return float(np.sum(subregion))
[docs]
def fraction_of_area_dark_intensity(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
fill_holes: bool = True,
threshold: float = 0.95,
) -> float:
"""
Computes the fraction of the region area where intensity is below a threshold.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
fill_holes : bool, optional
Whether to fill holes in the dark regions. Default is True.
threshold : float, optional
Intensity threshold. Default is 0.95.
Returns
-------
float
Fraction of the area with dark intensity.
"""
subregion = (
intensity_image < threshold
) * regionmask # under one, under 0.8, under 0.6, whatever value!
if fill_holes:
subregion = skm.label(subregion, connectivity=2, background=0)
subregion = fill_label_holes(subregion)
subregion[subregion > 0] = 1
return float(np.sum(subregion)) / float(np.sum(regionmask))
# STD
[docs]
def intensity_std(regionmask: np.ndarray, intensity_image: np.ndarray) -> float:
"""
Computes the standard deviation of intensity within the region.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
float
Standard deviation of intensity.
"""
return np.nanstd(intensity_image[regionmask])
[docs]
def intensity_nanmean(regionmask: np.ndarray, intensity_image: np.ndarray) -> float:
"""
Computes the mean intensity within the region, ignoring NaNs.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
float
Mean intensity, or NaN if the image is all zeros.
"""
if np.all(intensity_image == 0):
return np.nan
else:
return np.nanmean(intensity_image[regionmask])
[docs]
def intensity_center_of_mass_displacement(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> Tuple[float, float, float, float]:
"""
Computes the displacement between the geometric centroid and the
intensity-weighted center of mass of a region.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values indicate the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
Returns
-------
distance : float
Euclidean distance between the geometric centroid and the intensity-weighted center of mass.
direction_arctan : float
Angle (in degrees) of displacement from the geometric centroid to the intensity-weighted center of mass,
computed using `arctan2(delta_y, delta_x)`.
delta_x : float
Difference in x-coordinates (intensity-weighted centroid - geometric centroid).
delta_y : float
Difference in y-coordinates (intensity-weighted centroid - geometric centroid).
Notes
-----
- If the `intensity_image` contains NaN values, it is first processed using `interpolate_nan()`.
- Negative intensity values are set to zero to prevent misbehavior in center of mass calculation.
- If the intensity image is entirely zero, all outputs are `NaN`.
"""
if np.any(intensity_image != intensity_image):
intensity_image = interpolate_nan(intensity_image.copy())
if not np.all(intensity_image.flatten() == 0):
y, x = np.mgrid[: regionmask.shape[0], : regionmask.shape[1]]
xtemp = x.copy()
ytemp = y.copy()
intensity_image[intensity_image <= 0.0] = (
0.0 # important to clip as negative intensities misbehave with center of mass
)
intensity_weighted_center = center_of_mass(
intensity_image * regionmask, regionmask, 1
)
centroid_x = intensity_weighted_center[1]
centroid_y = intensity_weighted_center[0]
geometric_centroid_x = np.sum(xtemp * regionmask) / np.sum(regionmask)
geometric_centroid_y = np.sum(ytemp * regionmask) / np.sum(regionmask)
distance = np.sqrt(
(geometric_centroid_y - centroid_y) ** 2
+ (geometric_centroid_x - centroid_x) ** 2
)
delta_x = geometric_centroid_x - centroid_x
delta_y = geometric_centroid_y - centroid_y
direction_arctan = np.arctan2(delta_y, delta_x) * 180 / np.pi
return (
distance,
direction_arctan,
centroid_x - geometric_centroid_x,
centroid_y - geometric_centroid_y,
)
else:
return np.nan, np.nan, np.nan, np.nan
[docs]
def intensity_center_of_mass_displacement_edge(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> Tuple[float, float, float, float]:
"""
Computes displacement of center of mass relative to the edge of the region.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
tuple
(distance, direction_arctan, delta_x, delta_y).
Returns (NaN, NaN, NaN, NaN) if calculation fails.
"""
if np.any(intensity_image != intensity_image):
intensity_image = interpolate_nan(intensity_image.copy())
edge_mask = contour_of_instance_segmentation(regionmask, 3)
if not np.all(intensity_image.flatten() == 0) and np.sum(edge_mask) > 0:
y, x = np.mgrid[: edge_mask.shape[0], : edge_mask.shape[1]]
xtemp = x.copy()
ytemp = y.copy()
intensity_image[intensity_image <= 0.0] = (
0.0 # important to clip as negative intensities misbehave with center of mass
)
intensity_weighted_center = center_of_mass(
intensity_image * edge_mask, edge_mask, 1
)
centroid_x = intensity_weighted_center[1]
centroid_y = intensity_weighted_center[0]
# centroid_x = np.sum(xtemp * intensity_image) / np.sum(intensity_image)
geometric_centroid_x = np.sum(xtemp * regionmask) / np.sum(regionmask)
geometric_centroid_y = np.sum(ytemp * regionmask) / np.sum(regionmask)
distance = np.sqrt(
(geometric_centroid_y - centroid_y) ** 2
+ (geometric_centroid_x - centroid_x) ** 2
)
delta_x = geometric_centroid_x - centroid_x
delta_y = geometric_centroid_y - centroid_y
direction_arctan = np.arctan2(delta_y, delta_x) * 180 / np.pi
return (
distance,
direction_arctan,
centroid_x - geometric_centroid_x,
centroid_y - geometric_centroid_y,
)
else:
return np.nan, np.nan, np.nan, np.nan
[docs]
def intensity_radial_gradient(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> Tuple[float, float, float]:
"""
Determines whether the intensity follows a radial gradient from the center to the edge of the cell.
The function fits a linear model to the intensity values as a function of distance from the center
(computed via the Euclidean distance transform). The slope of the fitted line indicates whether
the intensity is higher at the center or at the edges.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values define the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
Returns
-------
slope : float
Slope of the fitted linear model.
- If `slope > 0`: Intensity increases towards the edge.
- If `slope < 0`: Intensity is higher at the center.
intercept : float
Intercept of the fitted linear model.
r2 : float
Coefficient of determination (R²), indicating how well the linear model fits the intensity profile.
Notes
-----
- If the `intensity_image` contains NaN values, they are interpolated using `interpolate_nan()`.
- The Euclidean distance transform (`distance_transform_edt`) is used to compute the distance
of each pixel from the edge.
- The x-values for the linear fit are reversed so that the origin is at the center.
- A warning suppression is applied to ignore messages about poorly conditioned polynomial fits.
"""
if np.any(intensity_image != intensity_image):
intensity_image = interpolate_nan(intensity_image.copy())
# try:
warnings.filterwarnings("ignore", message="Polyfit may be poorly conditioned")
# intensities
y = intensity_image[regionmask].flatten()
# distance to edge
x = distance_transform_edt(regionmask.copy())
x = x[regionmask].flatten()
x = max(x) - x # origin at center of cells
params = np.polyfit(x, y, 1)
line = np.poly1d(params)
# coef > 0 --> more signal at edge than center, coef < 0 --> more signal at center than edge
r2 = r2_score(y, line(x))
return line.coefficients[0], line.coefficients[1], r2
# Variations on intensity (for LAI)
[docs]
def area_dark_intensity_ninetyfive(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
fill_holes: bool = True,
) -> float:
"""
Computes the area of the region where intensity is below 0.95.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
fill_holes : bool, optional
Whether to fill holes in the dark regions. Default is True.
Returns
-------
float
Area with intensity below 0.95.
"""
subregion = (
intensity_image < 0.95
) * regionmask # under one, under 0.8, under 0.6, whatever value!
if fill_holes:
subregion = skm.label(subregion, connectivity=2, background=0)
subregion = fill_label_holes(subregion)
subregion[subregion > 0] = 1
return float(np.sum(subregion))
[docs]
def area_dark_intensity_ninety(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
fill_holes: bool = True,
) -> float:
"""
Computes the area of the region where intensity is below 0.90.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
fill_holes : bool, optional
Whether to fill holes in the dark regions. Default is True.
Returns
-------
float
Area with intensity below 0.90.
"""
subregion = (
intensity_image < 0.90
) * regionmask # under one, under 0.8, under 0.6, whatever value!
if fill_holes:
subregion = skm.label(subregion, connectivity=2, background=0)
subregion = fill_label_holes(subregion)
subregion[subregion > 0] = 1
return float(np.sum(subregion))
[docs]
def mean_dark_intensity_ninetyfive(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
fill_holes: bool = True,
) -> float:
"""
Calculate the mean intensity in a dark subregion below 95, handling NaN values.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values define the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
target_channel : str, optional
Name of the intensity channel used for measurement. Defaults to `'adhesion_channel'`.
fill_holes : bool, optional
If `True`, fills enclosed holes in the detected dark intensity regions before computing
the area. Defaults to `True`.
Returns
-------
float
Mean intensity value in the dark subregion.
"""
subregion = (intensity_image < 0.95) * regionmask
if fill_holes:
subregion = skm.label(subregion, connectivity=2, background=0)
subregion = fill_label_holes(subregion)
subregion[subregion > 0] = 1
masked_intensity = intensity_image[subregion == 1]
return float(np.nanmean(masked_intensity))
[docs]
def mean_dark_intensity_ninetyfive_fillhole_false(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Calculate the mean intensity in a dark subregion below 95, handling NaN values.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values define the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
target_channel : str, optional
Name of the intensity channel used for measurement. Defaults to `'adhesion_channel'`.
Returns
-------
float
Mean intensity value in the dark subregion.
"""
subregion = (
intensity_image < 0.95
) * regionmask # Select dark regions within the mask
masked_intensity = intensity_image[
subregion == 1
] # Extract pixel values from the selected region
return float(np.nanmean(masked_intensity)) # Compute mean, ignoring NaNs
[docs]
def mean_dark_intensity_ninety_fillhole_false(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Calculate the mean intensity in a dark subregion, handling NaN values.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values define the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
target_channel : str, optional
Name of the intensity channel used for measurement. Defaults to `'adhesion_channel'`.
Returns
-------
float
Mean intensity value in the dark subregion.
"""
subregion = (
intensity_image < 0.90
) * regionmask # Select dark regions within the mask
masked_intensity = intensity_image[
subregion == 1
] # Extract pixel values from the selected region
return float(np.nanmean(masked_intensity)) # Compute mean, ignoring NaNs
[docs]
def mean_dark_intensity_ninety(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
fill_holes: bool = True,
) -> float:
"""
Calculate the mean intensity in a dark subregion below 90, handling NaN values.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values define the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
target_channel : str, optional
Name of the intensity channel used for measurement. Defaults to `'adhesion_channel'`.
fill_holes : bool, optional
If `True`, fills enclosed holes in the detected dark intensity regions before computing
the area. Defaults to `True`.
Returns
-------
float
Mean intensity value in the dark subregion.
"""
subregion = (intensity_image < 0.90) * regionmask
if fill_holes:
subregion = skm.label(subregion, connectivity=2, background=0)
subregion = fill_label_holes(subregion)
subregion[subregion > 0] = 1
masked_intensity = intensity_image[subregion == 1]
return float(np.nanmean(masked_intensity))
[docs]
def mean_dark_intensity_eighty_five(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
fill_holes: bool = True,
) -> float:
"""
Calculate the mean intensity in a dark subregion below 85, handling NaN values.
Parameters
----------
regionmask : ndarray
A binary mask (2D array) where nonzero values define the region of interest.
intensity_image : ndarray
A 2D array of the same shape as `regionmask`, representing the intensity
values associated with the region.
target_channel : str, optional
Name of the intensity channel used for measurement. Defaults to `'adhesion_channel'`.
fill_holes : bool, optional
If `True`, fills enclosed holes in the detected dark intensity regions before computing
the area. Defaults to `True`.
Returns
-------
float
Mean intensity value in the dark subregion.
"""
subregion = (intensity_image < 0.85) * regionmask
if fill_holes:
subregion = skm.label(subregion, connectivity=2, background=0)
subregion = fill_label_holes(subregion)
subregion[subregion > 0] = 1
masked_intensity = intensity_image[subregion == 1]
return float(np.nanmean(masked_intensity))
[docs]
def mean_dark_intensity_eight_five_fillhole_false(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Computes the mean intensity in dark regions (< 0.85), ignoring NaNs and without hole filling.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
Returns
-------
float
Mean intensity in dark regions.
"""
subregion = (
intensity_image < 0.85
) * regionmask # Select dark regions within the mask
masked_intensity = intensity_image[
subregion == 1
] # Extract pixel values from the selected region
return float(np.nanmean(masked_intensity)) # Compute mean, ignoring NaNs
[docs]
def percentile_zero_one_dark_intensity_ninety(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Computes the 0.1th percentile of intensity in dark regions (< 0.95).
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
Returns
-------
float
0.1th percentile of intensity in dark regions.
"""
subregion = (intensity_image < 0.95) * regionmask
return float(np.nanpercentile(intensity_image[subregion], 0.1))
[docs]
def percentile_one_dark_intensity_ninety(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Computes the 1st percentile of intensity in dark regions (< 0.95).
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
Returns
-------
float
1st percentile of intensity in dark regions.
"""
subregion = (intensity_image < 0.95) * regionmask
return float(np.nanpercentile(intensity_image[subregion], 1))
[docs]
def percentile_five_dark_intensity_ninety(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Computes the 5th percentile of intensity in dark regions (< 0.95).
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
Returns
-------
float
5th percentile of intensity in dark regions.
"""
subregion = (intensity_image < 0.95) * regionmask
return float(np.nanpercentile(intensity_image[subregion], 5))
[docs]
def percentile_ten_dark_intensity_ninety(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Computes the 10th percentile of intensity in dark regions (< 0.95).
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
Returns
-------
float
10th percentile of intensity in dark regions.
"""
subregion = (intensity_image < 0.95) * regionmask
return float(np.nanpercentile(intensity_image[subregion], 10))
[docs]
def percentile_ninty_five_dark_intensity_ninety(
regionmask: np.ndarray,
intensity_image: np.ndarray,
target_channel: str = "adhesion_channel",
) -> float:
"""
Computes the 95th percentile of intensity in dark regions (< 0.95).
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
target_channel : str, optional
Target channel name. Default is 'adhesion_channel'.
Returns
-------
float
95th percentile of intensity in dark regions.
"""
subregion = (intensity_image < 0.95) * regionmask
return float(np.nanpercentile(intensity_image[subregion], 95))
[docs]
def intensity_percentile_ninety_nine(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> float:
"""
Computes the 99th percentile of intensity within the region.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
float
99th percentile of intensity.
"""
return np.nanpercentile(intensity_image[regionmask], 99)
[docs]
def intensity_percentile_ninety_five(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> float:
"""
Computes the 95th percentile of intensity within the region.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
float
95th percentile of intensity.
"""
return np.nanpercentile(intensity_image[regionmask], 95)
[docs]
def intensity_percentile_ninety(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> float:
"""
Computes the 90th percentile of intensity within the region.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
float
90th percentile of intensity.
"""
return np.nanpercentile(intensity_image[regionmask], 90)
[docs]
def intensity_percentile_seventy_five(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> float:
"""
Computes the 75th percentile of intensity within the region.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
float
75th percentile of intensity.
"""
return np.nanpercentile(intensity_image[regionmask], 75)
[docs]
def intensity_percentile_fifty(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> float:
"""
Computes the 50th percentile (median) of intensity within the region.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
float
50th percentile of intensity.
"""
return np.nanpercentile(intensity_image[regionmask], 50)
[docs]
def intensity_percentile_twenty_five(
regionmask: np.ndarray, intensity_image: np.ndarray
) -> float:
"""
Computes the 25th percentile of intensity within the region.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image.
Returns
-------
float
25th percentile of intensity.
"""
return np.nanpercentile(intensity_image[regionmask], 25)
# Shape descriptors
[docs]
def circularity(regionmask: np.ndarray) -> float:
"""
Computes the circularity of the cell mask.
Circularity is defined as 4π × area / perimeter², equal to 1.0 for a
perfect circle and approaching 0 for elongated or highly irregular shapes.
Valid for both fluorescence and label-free cells.
This function uses a **1-argument signature** (regionmask only), so the
framework calls it once per cell — not once per channel — and produces a
single scalar column named ``circularity`` with no channel suffix.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
Returns
-------
float
Circularity value in [0, 1]. Returns NaN if the perimeter is zero.
"""
area = float(np.sum(regionmask))
p = skm.perimeter(regionmask)
if p == 0:
return np.nan
return float((4.0 * np.pi * area) / (p ** 2))
[docs]
def aspect_ratio(regionmask: np.ndarray) -> float:
"""
Computes the aspect ratio of the cell mask (major axis / minor axis).
Values close to 1 indicate round cells; higher values indicate elongated
cells. Valid for both fluorescence and label-free cells.
This function uses a **1-argument signature** (regionmask only), so the
framework calls it once per cell — not once per channel — and produces a
single scalar column named ``aspect_ratio`` with no channel suffix.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
Returns
-------
float
Aspect ratio (≥ 1). Returns NaN if the minor axis has zero length.
"""
props = skm.regionprops(regionmask.astype(np.uint8))
if not props:
return np.nan
p = props[0]
if p.axis_minor_length == 0:
return np.nan
return float(p.axis_major_length / p.axis_minor_length)
# Per-channel intensity distribution moments
[docs]
def intensity_skewness(regionmask: np.ndarray, intensity_image: np.ndarray, **kwargs) -> float:
"""
Computes the skewness of the in-mask pixel intensity distribution.
Positive skewness indicates a long right tail (e.g., sparse bright puncta
over a dim background). Negative skewness indicates a long left tail.
Near-zero values are typical of uniformly distributed cytoplasmic staining.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image (single channel).
Returns
-------
float
Fisher skewness of in-mask pixels. Returns NaN if fewer than 3 pixels
are available.
"""
pixels = intensity_image[regionmask]
if len(pixels) < 3:
return np.nan
return float(skew(pixels))
[docs]
def intensity_kurtosis(regionmask: np.ndarray, intensity_image: np.ndarray, **kwargs) -> float:
"""
Computes the excess kurtosis of the in-mask pixel intensity distribution.
High positive kurtosis indicates a heavy-tailed or peaked distribution
(e.g., bright puncta concentrated in a small area). Negative kurtosis
indicates a flatter distribution than a Gaussian. Zero is the Gaussian
reference (Fisher definition).
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image (single channel).
Returns
-------
float
Excess kurtosis of in-mask pixels (Fisher definition, Gaussian = 0).
Returns NaN if fewer than 4 pixels are available.
"""
pixels = intensity_image[regionmask]
if len(pixels) < 4:
return np.nan
return float(kurtosis(pixels))
# Membrane-to-cytoplasm intensity ratio
[docs]
def intensity_membrane_cytoplasm_ratio(
regionmask: np.ndarray, intensity_image: np.ndarray, **kwargs
) -> float:
"""
Computes the ratio of mean membrane-zone intensity to mean cytoplasm intensity.
The membrane zone is approximated by eroding the cell mask by 3 pixels; the
resulting core is the cytoplasm region, and the ring between the full mask
and the eroded core is the membrane zone.
A ratio > 1 indicates membrane enrichment; a ratio < 1 indicates cytoplasmic
enrichment. Useful for assessing receptor polarisation, membrane trafficking,
or cortical actin accumulation.
Parameters
----------
regionmask : ndarray
Binary mask of the region of interest.
intensity_image : ndarray
Intensity image (single channel).
Returns
-------
float
Mean membrane intensity divided by mean cytoplasm intensity. Returns NaN
if either zone is empty or if the cytoplasm mean is zero.
"""
cytoplasm_mask = binary_erosion(regionmask, iterations=3)
membrane_mask = regionmask & ~cytoplasm_mask
if not np.any(membrane_mask) or not np.any(cytoplasm_mask):
return np.nan
mem_mean = np.mean(intensity_image[membrane_mask])
cyt_mean = np.mean(intensity_image[cytoplasm_mask])
if cyt_mean == 0:
return np.nan
return float(mem_mean / cyt_mean)