from typing import Union, List, Tuple, Optional
import numpy as np
from celldetective.log_manager import get_logger
logger = get_logger(__name__)
[docs]
def contour_of_instance_segmentation(
label: np.ndarray,
distance: Union[int, float, List, Tuple, str],
sdf: Optional[np.ndarray] = None,
voronoi_map: Optional[np.ndarray] = None,
) -> np.ndarray:
"""
Generate an instance mask containing the contour of the segmented objects.
This updated version uses a Signed Distance Field (SDF) and Voronoi tessellation approach
Generic enough to handle Inner contours, Outer contours, and arbitrary "stripes" (annuli).
Parameters
----------
label : ndarray
The instance segmentation labels.
distance : int, float, list, tuple, or str
The distance specification.
- Scalar > 0: Inner contour (Erosion) from boundary to 'distance' pixels inside. Range [0, distance].
- Scalar < 0: Outer contour (Dilation) from 'distance' pixels outside to boundary. Range [distance, 0].
- Tuple/List (a, b): Explicit range in SDF space.
- Positive values are inside the object.
- Negative values are outside methods.
- Example: (5, 10) -> Inner ring 5 to 10px deep.
- Example: (-10, -5) -> Outer ring 5 to 10px away.
- String "rad1-rad2": Interpretation for Batch Measurements (Outer Rings).
- Interpreted as an annular region OUTSIDE the object.
- "5-10" -> Range [-10, -5] in SDF space (5 to 10px away).
sdf : ndarray, optional
Pre-computed Signed Distance Field (dist_in - dist_out).
If provided, avoids recomputing EDT.
voronoi_map : ndarray, optional
Pre-computed Voronoi map of instance labels.
Required if sdf is provided and outer contours are needed.
Returns
-------
border_label : ndarray
An instance mask containing the contour of the segmented objects.
Outer contours preserve instance identity via Voronoi propagation.
"""
from scipy.ndimage import distance_transform_edt
# helper to parse string "rad1-rad2" or "-12-13" or "-5--2"
if isinstance(distance, str):
import re
try:
# Check for stringified tuple "(a, b)"
distance = distance.strip()
if distance.startswith("(") and distance.endswith(")"):
import ast
val_tuple = ast.literal_eval(distance)
if isinstance(val_tuple, (list, tuple)) and len(val_tuple) == 2:
min_r = val_tuple[0]
max_r = val_tuple[1]
else:
raise ValueError("Tuple string must have 2 elements")
else:
try:
val = float(distance)
# It's a scalar string like "5" or "-5"
if val >= 0:
min_r = 0
max_r = val
else:
min_r = val
max_r = 0
except ValueError:
# It's a range string like "5-10", "-12-13", or "-5--2"
# Use regex to parse range with potentially negative numbers
range_pattern = re.compile(r"^(-?\d+(?:\.\d+)?)-(-?\d+(?:\.\d+)?)$")
match = range_pattern.match(distance)
if match:
r1 = float(match.group(1))
r2 = float(match.group(2))
# Use the values directly as specified (min, max in SDF space)
min_r = min(r1, r2)
max_r = max(r1, r2)
else:
raise ValueError(f"Could not parse range string: {distance}")
except Exception:
logger.warning(
f"Could not parse contour string '{distance}'. returning empty."
)
return np.zeros_like(label)
elif isinstance(distance, (list, tuple)):
min_r = distance[0]
max_r = distance[1]
elif isinstance(distance, (int, float)):
if distance >= 0:
min_r = 0
max_r = distance
else:
min_r = distance
max_r = 0
else:
return np.zeros_like(label)
if sdf is None or voronoi_map is None:
# Compute SDF maps
# We need SDF = dist_in - dist_out
# inside > 0, outside < 0
# 1. Dist In (Inside object)
dist_in = distance_transform_edt(label > 0)
# 2. Dist Out (Outside object) + Voronoi
dist_out, indices = distance_transform_edt(label == 0, return_indices=True)
# Voronoi Map
voronoi_map = label[indices[0], indices[1]]
# Composite SDF
sdf = dist_in - dist_out
# Create Mask
mask = (sdf >= min_r) & (sdf <= max_r)
# Result
border_label = voronoi_map * mask
return border_label
[docs]
def create_patch_mask(
h: int,
w: int,
center: Optional[Tuple[int, int]] = None,
radius: Optional[Union[int, float, List[Union[int, float]]]] = None,
) -> np.ndarray:
"""
Create a circular patch mask of given dimensions.
Adapted from alkasm on https://stackoverflow.com/questions/44865023/how-can-i-create-a-circular-mask-for-a-numpy-array
Parameters
----------
h : int
Height of the mask. Prefer odd value.
w : int
Width of the mask. Prefer odd value.
center : tuple, optional
Coordinates of the center of the patch. If not provided, the middle of the image is used.
radius : int or float or list, optional
Radius of the circular patch. If not provided, the smallest distance between the center and image walls is used.
If a list is provided, it should contain two elements representing the inner and outer radii of a circular annular patch.
Returns
-------
numpy.ndarray
Boolean mask where True values represent pixels within the circular patch or annular patch, and False values represent pixels outside.
Notes
-----
The function creates a circular patch mask of the given dimensions by determining which pixels fall within the circular patch or annular patch.
The circular patch or annular patch is centered at the specified coordinates or at the middle of the image if coordinates are not provided.
The radius of the circular patch or annular patch is determined by the provided radius parameter or by the minimum distance between the center and image walls.
If an annular patch is desired, the radius parameter should be a list containing the inner and outer radii respectively.
Examples
--------
>>> mask = create_patch_mask(100, 100, center=(50, 50), radius=30)
>>> print(mask)
"""
if center is None: # use the middle of the image
center = (int(w / 2), int(h / 2))
if radius is None: # use the smallest distance between the center and image walls
radius = min(center[0], center[1], w - center[0], h - center[1])
Y, X = np.ogrid[:h, :w]
dist_from_center = np.sqrt((X - center[0]) ** 2 + (Y - center[1]) ** 2)
if isinstance(radius, int) or isinstance(radius, float):
mask = dist_from_center <= radius
elif isinstance(radius, list):
mask = (dist_from_center <= radius[1]) * (dist_from_center >= radius[0])
else:
print("Please provide a proper format for the radius")
return None
return mask