Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement convex_hull_image and convex_hull_object #828

Open
wants to merge 4 commits into
base: branch-25.04
Choose a base branch
from
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
9 changes: 2 additions & 7 deletions python/cucim/src/cucim/skimage/measure/_regionprops.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from scipy.ndimage import find_objects as cpu_find_objects

from cucim.skimage._vendored import pad
from cucim.skimage.morphology.convex_hull import convex_hull_image

from . import _moments
from ._regionprops_utils import euler_number, perimeter, perimeter_crofton
Expand Down Expand Up @@ -465,15 +466,9 @@ def area_convex(self):
@property
@_cached
def image_convex(self):
# TODO: grlee77: avoid host/device transfers
# from ..morphology.convex_hull import convex_hull_image
from skimage.morphology.convex_hull import convex_hull_image

# CuPy Backend: explicitly cast to uint8 to avoid the issue see in
# reported in https://github.com/cupy/cupy/issues/4354
return cp.asarray(convex_hull_image(cp.asnumpy(self.image))).astype(
cp.uint8
)
return convex_hull_image(self.image).view(dtype=cp.uint8)

@property
def coords_scaled(self):
Expand Down
6 changes: 6 additions & 0 deletions python/cucim/src/cucim/skimage/morphology/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
binary_erosion,
binary_opening,
)
from .convex_hull import (
convex_hull_image,
convex_hull_object,
)
from .footprints import (
ball,
cube,
Expand Down Expand Up @@ -38,6 +42,8 @@
from .misc import remove_small_holes, remove_small_objects

__all__ = [
"convex_hull_image",
"convex_hull_object",
"binary_erosion",
"binary_dilation",
"binary_opening",
Expand Down
315 changes: 315 additions & 0 deletions python/cucim/src/cucim/skimage/morphology/convex_hull.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
from itertools import product

import cupy as cp
import numpy as np

try:
from scipy.spatial import ConvexHull, QhullError

scipy_available = True
except ImportError:
scipy_available = False

try:
from skimage.util import unique_rows

unique_rows_available = True
except ImportError:
unique_rows_available = False


from cucim.skimage._shared.utils import warn
from cucim.skimage._vendored import ndimage as ndi
from cucim.skimage.measure._label import label
from cucim.skimage.measure._regionprops_gpu_utils import _unravel_loop_index

__all__ = [
"convex_hull_image",
"convex_hull_object",
]


@cp.memoize(for_each_device=True)
def _offsets_diamond(ndim):
offsets = np.zeros((2 * ndim, ndim))
for vertex, (axis, offset) in enumerate(product(range(ndim), (-0.5, 0.5))):
offsets[vertex, axis] = offset
return cp.asarray(offsets)
Comment on lines +33 to +37
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if np.zeros() is used here because calculating offsets in system memory and then converting them to GPU memory is more efficient than creating or manipulating a CuPy array.



@cp.memoize(for_each_device=True)
def get_coords_in_hull_kernel(coord_dtype, float_dtype, ndim):
"""Keep this kernel for n-dimensional support as the raw_moments kernels
currently only support 2D and 3D data.
"""
coord_dtype = cp.dtype(coord_dtype)
float_dtype = cp.dtype(float_dtype)
uint_t = (
"unsigned int" if coord_dtype.itemsize <= 4 else "unsigned long long"
)
float_t = "float" if float_dtype.itemsize <= 4 else "double"

source = """
if (image[i]) {
convex_image = true;
} else {"""
source += _unravel_loop_index("image", ndim, uint_t=uint_t)
source += """
bool in_hull = true;
int n_hull_equations = hull_equations.shape()[0];
for (int i_eq = 0; i_eq < n_hull_equations; i_eq++) {"""
source += f"""
{float_t} v = 0.0;"""
for d in range(ndim):
source += f"""
v += hull_equations[i_eq * {ndim + 1} + {d}] * in_coord[{d}];"""
source += f"""
v += hull_equations[i_eq * {ndim + 1} + {ndim}];"""
source += """
if (v > tol) {
in_hull = false;
break;
}
}
convex_image = in_hull;
}\n"""
inputs = (
f"raw bool image, raw {float_dtype.name} hull_equations, float64 tol"
)
outputs = "bool convex_image"
name = f"cucim_convex_hull_{ndim}d_{coord_dtype.char}_{float_dtype.char}"
return cp.ElementwiseKernel(inputs, outputs, source, name=name)


def convex_hull_image(
image,
offset_coordinates=True,
tolerance=1e-10,
include_borders=True,
*,
omit_empty_coords_check=False,
float64_computation=True,
cpu_fallback_threshold=None,
):
"""Compute the convex hull image of a binary image.

The convex hull is the set of pixels included in the smallest convex
polygon that surround all white pixels in the input image.

Parameters
----------
image : array
Binary input image. This array is cast to bool before processing.
offset_coordinates : bool, optional
If ``True``, a pixel at coordinate, e.g., (4, 7) will be represented
by coordinates (3.5, 7), (4.5, 7), (4, 6.5), and (4, 7.5). This adds
some "extent" to a pixel when computing the hull.
tolerance : float, optional
Tolerance when determining whether a point is inside the hull. Due
to numerical floating point errors, a tolerance of 0 can result in
some points erroneously being classified as being outside the hull.
include_borders: bool, optional
If ``False``, vertices/edges are excluded from the final hull mask.
Copy link
Contributor

Choose a reason for hiding this comment

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

Since include_borders=False case raises the NotImplementedError, it might be good to document this in the docstring.



Extra Parameters
----------------
omit_empty_coords_check : bool, optional
If ``True``, skip check that there are not any True values in `image`.
float64_computation : bool, optional
If False, allow use of 32-bit float during the postprocessing stage
that determines whether each pixel falls within the convex hull.
cpu_fallback_threshold : non-negative int or None
Number of pixels in an image before convex_hull_image will fallback
to pure CPU implementation.

Returns
-------
hull : (M, N) array of bool
Binary image with pixels in convex hull set to True.

Notes
-----
The parameters listed under "Extra Parameters" above are present only
in cuCIM and not in scikit-image.

References
----------
.. [1] https://blogs.mathworks.com/steve/2011/10/04/binary-image-convex-hull-algorithm-notes/

"""
if cpu_fallback_threshold is None:
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be worth adding validation that the threshold is non-negative when provided:

Suggested change
if cpu_fallback_threshold is None:
if cpu_fallback_threshold is not None and cpu_fallback_threshold < 0:
raise ValueError("cpu_fallback_threshold must be non-negative")
if cpu_fallback_threshold is None:

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if cpu_fallback_threshold is None:
if tolerance <= 0:
raise ValueError("tolerance must be positive")
if cpu_fallback_threshold is None:

# Fallback to scikit-image implementation of total number of pixels
# is less than this
cpu_fallback_threshold = 30000 if image.ndim == 2 else 13000

# singleton sizes can cause numeric problems in QHull, so squeeze out
# these dimensions first (and restore original shape at end)
# Note: This fix is not yet in upstream scikit-image as of v0.25.
original_shape = image.shape
image = cp.squeeze(image)
if image.ndim < 2:
return image

if image.size < cpu_fallback_threshold:
# Fallback to pure CPU implementation
from skimage import morphology as morphology_cpu

convex_image = cp.asarray(
morphology_cpu.convex_hull_image(
cp.asnumpy(image),
offset_coordinates=offset_coordinates,
tolerance=tolerance,
include_borders=include_borders,
)
)
return convex_image.reshape(original_shape)

if not scipy_available:
raise ImportError(
"This function requires SciPy, but it could not import: "
"scipy.spatial.ConvexHull, scipy.spatial.QhullError"
)
if not unique_rows_available:
raise ImportError(
"This function requires skimage.util.unique_rows, but it could "
"not be imported."
)

if not include_borders:
raise NotImplementedError(
"Only the `include_borders=True` case is implemented"
)

ndim = image.ndim
if not omit_empty_coords_check and cp.count_nonzero(image) == 0:
warn(
"Input image is entirely zero, no valid convex hull. "
"Returning empty image",
UserWarning,
)
return np.zeros(image.shape, dtype=bool)
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we want to return CuPy array here?

Suggested change
return np.zeros(image.shape, dtype=bool)
return cp.zeros(image.shape, dtype=bool)


if image.dtype != cp.dtype(bool):
if image.dtype == cp.uint8:
# bool are actually already stored as uint8 so can use a view to
# avoid a copy
image = image.view(bool)
else:
image = image.astype(bool)

# xor with eroded version to keep only edge pixels of the binary image
image_boundary = cp.bitwise_xor(image, ndi.binary_erosion(image, 3))

coords = cp.stack(cp.nonzero(image_boundary), axis=-1)

# Add a vertex for the middle of each pixel edge
if offset_coordinates:
offsets = _offsets_diamond(image.ndim)
coords = (coords[:, np.newaxis, :] + offsets).reshape(-1, ndim)

coords = cp.asnumpy(coords)

# repeated coordinates can *sometimes* cause problems in
# scipy.spatial.ConvexHull, so we remove them.
coords = unique_rows(coords)

# Find the convex hull
try:
hull = ConvexHull(coords)
except QhullError as err:
warn(
f"Failed to get convex hull image. "
f"Returning empty image, see error message below:\n"
f"{err}"
)
return cp.zeros(image.shape, dtype=bool)

coord_dtype = cp.min_scalar_type(max(image.shape))
if float64_computation:
float_dtype = cp.float64
else:
# float32 will be used if coord_dtype is <= 16-bit
# otherwise, use float64
Comment on lines +232 to +233
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment is slightly misleading as promote_types behavior depends on the specific dtype, not just its size. It would be better to explicitly document the exact promotion rules or use more explicit logic.

float_dtype = cp.promote_types(cp.float32, coord_dtype)

kernel = get_coords_in_hull_kernel(coord_dtype, float_dtype, ndim)

if not image.flags.c_contiguous:
image = cp.ascontiguousarray(image)
convex_image = cp.empty_like(image)
hull_equations = cp.asarray(hull.equations, dtype=float_dtype)
kernel(image, hull_equations, tolerance, convex_image)
return convex_image.reshape(original_shape)


def convex_hull_object(
image,
*,
connectivity=2,
float64_computation=False,
cpu_fallback_threshold=None,
):
r"""Compute the convex hull image of individual objects in a binary image.

The convex hull is the set of pixels included in the smallest convex
polygon that surround all white pixels in the input image.

Parameters
----------
image : (M, N) ndarray
Binary input image.
connectivity : {1, 2}, int, optional
Determines the neighbors of each pixel. Adjacent elements
within a squared distance of ``connectivity`` from pixel center
are considered neighbors.::

1-connectivity 2-connectivity
[ ] [ ] [ ] [ ]
| \ | /
[ ]--[x]--[ ] [ ]--[x]--[ ]
| / | \
[ ] [ ] [ ] [ ]

Extra Parameters
----------------
float64_computation : bool, optional
If False, allow use of 32-bit float during the postprocessing stage
cpu_fallback_threshold : non-negative int or None
Number of pixels in an image before convex_hull_image will fallback
to pure CPU implementation.

Returns
-------
hull : ndarray of bool
Binary image with pixels inside convex hull set to ``True``.

Notes
-----
This function uses ``skimage.morphology.label`` to define unique objects,
finds the convex hull of each using ``convex_hull_image``, and combines
these regions with logical OR. Be aware the convex hulls of unconnected
objects may overlap in the result. If this is suspected, consider using
convex_hull_image separately on each object or adjust ``connectivity``.

The parameters listed under "Extra Parameters" above are present only
in cuCIM and not in scikit-image.
"""
if connectivity not in tuple(range(1, image.ndim + 1)):
Copy link
Contributor

Choose a reason for hiding this comment

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

There's no validation that the input is 2D or 3D. While the error will be caught by the connectivity check, it might be clearer to add an explicit dimensionality check:

Suggested change
if connectivity not in tuple(range(1, image.ndim + 1)):
if image.ndim not in (2, 3):
raise ValueError("Input image must be 2D or 3D")
if connectivity not in tuple(range(1, image.ndim + 1)):

raise ValueError("`connectivity` must be between 1 and image.ndim.")

labeled_im = label(image, connectivity=connectivity, background=0)
convex_obj = cp.zeros(image.shape, dtype=bool)
Copy link
Contributor

Choose a reason for hiding this comment

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

this variable is always overwritten or not used so can be removed.

Suggested change
convex_obj = cp.zeros(image.shape, dtype=bool)

convex_img = cp.zeros(image.shape, dtype=bool)

max_label = int(labeled_im.max())
for i in range(1, max_label + 1):
convex_obj = convex_hull_image(
labeled_im == i,
omit_empty_coords_check=True,
float64_computation=float64_computation,
cpu_fallback_threshold=cpu_fallback_threshold,
)
convex_img = cp.logical_or(convex_img, convex_obj)

return convex_img
Loading
Loading