Repository: rapidsai/cucim
Pull request #847: Region Properties Performance Overhaul - Part 5: Perimeter and Euler Characteristic
Author: login: grlee77
Labels: name: improvement
description: Improves an existing functionality
name: non-breaking
description: Introduces a non-breaking change
name: performance
description: Performance improvement
Created: 2025-03-04T00:58:31Z | Merged: 2025-04-04T03:40:00Z

Please review #843 first as that explains the general approach in more detail.

## Overview

This MR implements the final few region properties supported by `regionprops_table`
- perimeter
- perimeter_crofton
- euler_number

The batch implementation here is not applicable to tightly packed regions (i.e. when regions share a border with other regions). The default `robust_perimeter=True` will maintain accuracy by computing the values for individual regions in isolation at cost of performance. 

If accurate perimeter values are not required, but only relative perimeter size, then "num_perimeter_pixels" as implemented in #843 just counts the number of pixels/voxels on the object perimeter is much faster to compute. That number of pixels measurement does not suffer from this region adjacency issue and can always safely be applied even to tightly packed regions.

## Benchmarks

### Performance vs. Image Size (with # regions fixed)

The following show performance for a small fixed number of label regions at different spatial scale in both 2D and 3D

In 2D, there are 16 labeled regions for shapes ranging from (64, 64) up to (8192, 8192)
![regionprops_perimeter_vs_size](https://github.com/user-attachments/assets/7936d73a-c539-4e3e-9567-ba256a6ac6f2)


In 3D, there are 8 labeled regions for shapes ranging from (32, 32) up to (512, 512, 512)
![regionprops_3d_perimeter_vs_size](https://github.com/user-attachments/assets/bc6df451-3461-4e5e-9234-c0fa36a6f1d1)



### Performance vs. Region Size (with image shape fixed)

Here a single large 2D image (7680, 4320) is used, but with varying numbers of labeled regions within it. The total % of foreground vs. background voxels remains similar (i.e. regions are larger when there are fewer of them). The number of regions range from 4 up through 16,384.
![regionprops_perimeter_vs_object_size](https://github.com/user-attachments/assets/1ddc3b8c-d205-4fcc-bbd0-e01e0ff29fa1)



Here a single large 3D image (384, 384, 384) is used, but with varying numbers of labeled regions within it. The number of regions range from 8 up through 1,728.
![regionprops_3d_perimeter_vs_object_size](https://github.com/user-attachments/assets/e7041697-ed51-4c53-9051-4735569f9cc3)


### Benchmark conclusions

**Note:** We can see that performance improves as a function of image size (top 2 figures) and remains good across many labeled region sizes (lower 2 figures). 

**CAVEAT:**: The benchmarks shown above are for the case with `robust_perimeter=False` set. In this case the perimeter will not be accurate when labeled regions are immediately adjacent to each other (share a border with spacing of <= 1 background voxel). When `robust_perimeter=True`, performance will highly depend on how many labeled regions are within 1 pixel proximity of another label. Any such detected labels have to be processed in isolation to give accurate results and this will slow down the algorithm as we have to launch multiple CUDA kernels per each such labeled region.

If accurate perimeter values are not required, but only relative perimeter size, then "num_perimeter_pixels" which just counts the number of pixels/voxels on the object perimeter will be much faster to compute and does not suffer from this region adjacency issue.


Diff:
diff --git a/python/cucim/src/cucim/skimage/measure/_regionprops_gpu.py b/python/cucim/src/cucim/skimage/measure/_regionprops_gpu.py
index 7e02dd33c..39b0857aa 100644
--- a/python/cucim/src/cucim/skimage/measure/_regionprops_gpu.py
+++ b/python/cucim/src/cucim/skimage/measure/_regionprops_gpu.py
@@ -34,6 +34,12 @@
     regionprops_intensity_min_max,
     regionprops_intensity_std,
 )
+from ._regionprops_gpu_misc_kernels import (
+    misc_deps,
+    regionprops_euler,
+    regionprops_perimeter,
+    regionprops_perimeter_crofton,
+)
 from ._regionprops_gpu_moments_kernels import (
     moment_deps,
     regionprops_centroid,
@@ -47,7 +53,7 @@
     regionprops_moments_normalized,
     required_order,
 )
-from ._regionprops_gpu_utils import _get_min_integer_dtype
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
 
 __all__ = [
     "equivalent_diameter_area",
@@ -60,6 +66,7 @@
     "regionprops_centroid_weighted",
     "regionprops_coords",
     "regionprops_dict",
+    "regionprops_euler",
     "regionprops_extent",
     "regionprops_feret_diameter_max",
     "regionprops_image",
@@ -73,6 +80,8 @@
     "regionprops_moments_hu",
     "regionprops_moments_normalized",
     "regionprops_num_pixels",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
     # extra functions for cuCIM not currently in scikit-image
     "equivalent_spherical_perimeter",  # as in ITK
     "regionprops_num_boundary_pixels",
@@ -137,6 +146,7 @@
 property_deps.update(basic_deps)
 property_deps.update(convex_deps)
 property_deps.update(intensity_deps)
+property_deps.update(misc_deps)
 property_deps.update(moment_deps)
 
 
@@ -210,6 +220,7 @@ def regionprops_dict(
     moment_order=None,
     max_label=None,
     pixels_per_thread=16,
+    robust_perimeter=True,
 ):
     """Compute image properties and return them as a pandas-compatible table.
 
@@ -249,6 +260,15 @@ def regionprops_dict(
         from each GPU thread. The number of adjacent pixels processed
         corresponds to `pixels_per_thread` and can be used as a performance
         tuning parameter.
+    robust_perimeter : bool, optional
+        Batch computation of perimeter and euler characteristics can give
+        incorrect results for perimeter pixels that are not more than 1 pixel
+        spacing from another label. If True, a check for this condition is
+        performed and any labels close to another label have their perimeter
+        recomputed in isolation. Doing this check results in performance
+        overhead so can optionally be disabled. This parameter effects the
+        following regionprops: {"perimeter", "perimeter_crofton",
+        "euler_number"}.
     """
     supported_properties = CURRENT_PROPS_GPU | GLOBAL_PROPS
     properties = set(properties)
@@ -559,6 +579,61 @@ def regionprops_dict(
                     props_dict=out,
                 )
 
+    compute_perimeter = "perimeter" in required_props
+    compute_perimeter_crofton = "perimeter_crofton" in required_props
+    compute_euler = "euler_number" in required_props
+
+    if compute_euler or compute_perimeter or compute_perimeter_crofton:
+        # precompute list of labels with <2 pixels space between them
+        if label_image.dtype == cp.uint8:
+            labels_mask = label_image.view("bool")
+        else:
+            labels_mask = label_image > 0
+        if robust_perimeter:
+            # avoid repeatedly computing "labels_close" for
+            # perimeter, perimeter_crofton and euler_number regionprops
+            labels_close = _find_close_labels(
+                label_image, binary_image=labels_mask, max_label=max_label
+            )
+            if labels_close.size > 0:
+                print(
+                    f"Found {labels_close.size} regions with <=1 background "
+                    "pixel spacing from another region. Using slower robust "
+                    "perimeter/euler measurements for these regions."
+                )
+        else:
+            labels_close = None
+
+        if compute_perimeter:
+            regionprops_perimeter(
+                label_image,
+                neighborhood=4,
+                max_label=max_label,
+                robust=robust_perimeter,
+                labels_close=labels_close,
+                props_dict=out,
+            )
+        if compute_perimeter_crofton:
+            regionprops_perimeter_crofton(
+                label_image,
+                directions=4,
+                max_label=max_label,
+                robust=robust_perimeter,
+                omit_image_edges=False,
+                labels_close=labels_close,
+                props_dict=out,
+            )
+
+        if compute_euler:
+            regionprops_euler(
+                label_image,
+                connectivity=None,
+                max_label=max_label,
+                robust=robust_perimeter,
+                labels_close=labels_close,
+                props_dict=out,
+            )
+
     compute_images = "image" in required_props
     compute_intensity_images = "image_intensity" in required_props
     compute_convex = "image_convex" in required_props
diff --git a/python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py b/python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
new file mode 100644
index 000000000..5959ce906
--- /dev/null
+++ b/python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
@@ -0,0 +1,681 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
+            np.pi / (2 * sq2),
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            0,
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            np.pi / (4 * sq2),
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            0,
+            0,
+        ]
+    coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    return filter_weights, coefs
+
+
+def regionprops_perimeter_crofton(
+    labels,
+    directions=4,
+    *,
+    max_label=None,
+    robust=True,
+    omit_image_edges=False,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total Crofton perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter_crofton" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    directions : 2 or 4, optional
+        Number of directions used to approximate the Crofton perimeter. By
+        default, 4 is used: it should be more accurate than 2.
+        Computation time is the same in both cases.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    omit_image_edges : bool, optional
+        This can be set to avoid an additional padding step that includes the
+        edges of objects that correspond to the image edge as part of the
+        perimeter. We cannot accurately estimate the perimeter of objects
+        falling partly outside of `image`, so it seems acceptable to just set
+        this to True. The default remains False for consistency with upstream
+        scikit-image.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter_crofton" within this
+        dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    This measure is based on Crofton formula [1], which is a measure from
+    integral geometry. It is defined for general curve length evaluation via
+    a double integral along all directions. In a discrete
+    space, 2 or 4 directions give a quite good approximation, 4 being more
+    accurate than 2 for more complex shapes.
+
+    Similar to :func:`~.measure.perimeter`, this function returns an
+    approximation of the perimeter in continuous space.
+
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] https://en.wikipedia.org/wiki/Crofton_formula
+    .. [2] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010.
+           Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    ndim = labels.ndim
+    if ndim not in [2, 3]:
+        raise ValueError("labels must be 2D or 3D")
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+
+    footprint = 3  # scalar 3 -> (3, ) * ndim array of ones
+
+    if not omit_image_edges:
+        # Dilate labels by 1 pixel so we can sum with values in image_filtered
+        # to give unique histogram bins for each labeled regions (As long as no
+        # labeled regions are within < 2 pixels from another labeled region)
+        labels_pad = cp.pad(labels, pad_width=1, mode="constant")
+        labels_dilated = ndi.grey_dilation(labels_pad, 3, mode="constant")
+        binary_image = pad(binary_image, pad_width=1, mode="constant")
+        # need dilated mask for later use for indexing into
+        # `image_filtered_labeled` for bincount
+        binary_image_mask = ndi.binary_dilation(binary_image, footprint)
+        binary_image_mask = cp.logical_xor(
+            binary_image_mask, ndi.binary_erosion(binary_image, footprint)
+        )
+    else:
+        labels_dilated = ndi.grey_dilation(labels, footprint, mode="constant")
+        binary_image_mask = binary_image
+
+    # determine convolution weights and LUT for weighted sum
+    filter_weights, coefs = _get_perimeter_crofton_weights_and_coefs(
+        directions, cp.float32
+    )
+
+    image_filtered = ndi.convolve(
+        binary_image.view(cp.uint8),
+        filter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=image_filtered,
+        coefs=coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = labels_dilated.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+            ld = labels[
+                max(sl[0].start, 0) : min(sl[0].stop, shape[0]),
+                max(sl[1].start, 0) : min(sl[1].stop, shape[1]),
+            ]
+            p = regionprops_perimeter_crofton(
+                ld == lab,
+                max_label=1,
+                directions=directions,
+                omit_image_edges=False,
+                robust=False,
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter_crofton"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_euler_weights_and_coefs(ndim, connectivity, coefs_dtype=cp.float32):
+    from cucim.skimage.measure._regionprops_utils import (
+        EULER_COEFS2D_4,
+        EULER_COEFS2D_8,
+        EULER_COEFS3D_26,
+    )
+
+    if ndim not in [2, 3]:
+        raise ValueError("only 2D and 3D images are supported")
+
+    if ndim == 2:
+        filter_weights = cp.array([[0, 0, 0], [0, 1, 4], [0, 2, 8]])
+        if connectivity == 1:
+            coefs = EULER_COEFS2D_4
+        else:
+            coefs = EULER_COEFS2D_8
+        coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    else:  # 3D images
+        if connectivity == 2:
+            raise NotImplementedError(
+                "For 3D images, Euler number is implemented "
+                "for connectivities 1 and 3 only"
+            )
+
+        filter_weights = cp.array(
+            [
+                [[0, 0, 0], [0, 0, 0], [0, 0, 0]],
+                [[0, 0, 0], [0, 1, 4], [0, 2, 8]],
+                [[0, 0, 0], [0, 16, 64], [0, 32, 128]],
+            ]
+        )
+        if connectivity == 1:
+            coefs = EULER_COEFS3D_26[::-1]
+        else:
+            coefs = EULER_COEFS3D_26
+        coefs = cp.asarray(0.125 * coefs, dtype=coefs_dtype)
+
+    return filter_weights, coefs
+
+
+def regionprops_euler(
+    labels,
+    connectivity=None,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate the Euler characteristic in binary image.
+
+    For 2D objects, the Euler number is the number of objects minus the number
+    of holes. For 3D objects, the Euler number is obtained as the number of
+    objects plus the number of holes, minus the number of tunnels, or loops.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "euler_number" to `props_dict`
+
+    Parameters
+    ----------
+    labels: (M, N[, P]) cupy.ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    connectivity : int, optional
+        Maximum number of orthogonal hops to consider a pixel/voxel
+        as a neighbor.
+        Accepted values are ranging from  1 to input.ndim. If ``None``, a full
+        connectivity of ``input.ndim`` is used.
+        4 or 8 neighborhoods are defined for 2D images (connectivity 1 and 2,
+        respectively).
+        6 or 26 neighborhoods are defined for 3D images, (connectivity 1 and 3,
+        respectively). Connectivity 2 is not defined.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "euler_number" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    euler_number : cp.ndarray of int
+        Euler characteristic of the set of all objects in the image.
+
+    Notes
+    -----
+    The Euler characteristic is an integer number that describes the
+    topology of the set of all objects in the input image. If object is
+    4-connected, then background is 8-connected, and conversely.
+
+    The computation of the Euler characteristic is based on an integral
+    geometry formula in discretized space. In practice, a neighborhood
+    configuration is constructed, and a LUT is applied for each
+    configuration. The coefficients used are the ones of Ohser et al.
+
+    It can be useful to compute the Euler characteristic for several
+    connectivities. A large relative difference between results
+    for different connectivities suggests that the image resolution
+    (with respect to the size of objects and holes) is too low.
+
+    References
+    ----------
+    .. [1] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010. Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    .. [2] Ohser J., Nagel W., Schladitz K. (2002) The Euler Number of
+           Discretized Sets - On the Choice of Adjacency in Homogeneous
+           Lattices. In: Mecke K., Stoyan D. (eds) Morphology of Condensed
+           Matter. Lecture Notes in Physics, vol 600. Springer, Berlin,
+           Heidelberg.
+    """
+
+    if max_label is None:
+        max_label = int(labels.max())
+
+    # check connectivity
+    if connectivity is None:
+        connectivity = labels.ndim
+
+    binary_image = labels > 0
+
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+
+    filter_weights, coefs = _get_euler_weights_and_coefs(
+        labels.ndim, connectivity, cp.float32
+    )
+    binary_image = pad(binary_image, pad_width=1, mode="constant")
+    image_filtered = ndi.convolve(
+        binary_image.view(cp.uint8),
+        filter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    if labels.dtype != min_integer_type:
+        labels = labels.astype(min_integer_type)
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_pad = pad(labels, pad_width=1, mode="constant")
+    labels_dilated = ndi.grey_dilation(labels_pad, 3, mode="constant")
+
+    # sum the coefficients for each label to compute the Euler number
+    euler_number = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=image_filtered,
+        coefs=coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    euler_number = euler_number.astype(cp.int64, copy=False)
+
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = labels_dilated.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            # + 2 is because labels_pad is padded, but labels was not
+            ld = labels_pad[
+                max(sl[0].start, 0) : min(sl[0].stop + 2, shape[0]),
+                max(sl[1].start, 0) : min(sl[1].stop + 2, shape[1]),
+            ]
+            euler_num = regionprops_euler(
+                ld == lab, connectivity=connectivity, max_label=1, robust=False
+            )
+            euler_number[lab - 1] = euler_num[0]
+    if props_dict is not None:
+        props_dict["euler_number"] = euler_number
+    return euler_number
diff --git a/python/cucim/src/cucim/skimage/measure/_regionprops_gpu_utils.py b/python/cucim/src/cucim/skimage/measure/_regionprops_gpu_utils.py
index 3e331efcb..fad8b2d94 100644
--- a/python/cucim/src/cucim/skimage/measure/_regionprops_gpu_utils.py
+++ b/python/cucim/src/cucim/skimage/measure/_regionprops_gpu_utils.py
@@ -3,6 +3,9 @@
 import cupy as cp
 from packaging.version import parse
 
+from cucim.skimage._vendored import ndimage as ndi
+from cucim.skimage.util import map_array
+
 CUPY_GTE_13_3_0 = parse(cp.__version__) >= parse("13.3.0")
 
 # Need some default includes so uint32_t, uint64_t, etc. are defined
@@ -96,3 +99,25 @@ def _unravel_loop_index(
     code += """
         in_coord[0] = temp_idx;"""
     return code
+
+
+def _reverse_label_values(label_image, max_label):
+    """reverses the value of all labels (keeping background value=0 the same)"""
+    dtype = label_image.dtype
+    labs = cp.asarray(tuple(range(max_label + 1)), dtype=dtype)
+    rev_labs = cp.asarray((0,) + tuple(range(max_label, 0, -1)), dtype=dtype)
+    return map_array(label_image, labs, rev_labs)
+
+
+def _find_close_labels(labels, binary_image, max_label):
+    # check possibly too-close regions for which we may need to
+    # manually recompute the regions perimeter in isolation
+    labels_dilated2 = ndi.grey_dilation(labels, 5, mode="constant")
+    labels2 = labels_dilated2 * binary_image
+    rev_labels = _reverse_label_values(labels, max_label=max_label)
+    rev_labels = ndi.grey_dilation(rev_labels, 5, mode="constant")
+    rev_labels = rev_labels * binary_image
+    labels3 = _reverse_label_values(rev_labels, max_label=max_label)
+    diffs = cp.logical_or(labels != labels2, labels != labels3)
+    labels_close = cp.asnumpy(cp.unique(labels[diffs]))
+    return labels_close
diff --git a/python/cucim/src/cucim/skimage/measure/tests/test_regionprops_gpu_kernels.py b/python/cucim/src/cucim/skimage/measure/tests/test_regionprops_gpu_kernels.py
index fdfa46800..e0df49bca 100644
--- a/python/cucim/src/cucim/skimage/measure/tests/test_regionprops_gpu_kernels.py
+++ b/python/cucim/src/cucim/skimage/measure/tests/test_regionprops_gpu_kernels.py
@@ -17,6 +17,7 @@
 from cucim.skimage._vendored import ndimage as ndi
 from cucim.skimage.measure._regionprops import PROPS
 from cucim.skimage.measure._regionprops_gpu import (
+    _find_close_labels,
     equivalent_diameter_area,
     ndim_2_only,
     need_intensity_image,
@@ -29,6 +30,7 @@
     regionprops_centroid_weighted,
     regionprops_coords,
     regionprops_dict,
+    regionprops_euler,
     regionprops_extent,
     regionprops_feret_diameter_max,
     regionprops_image,
@@ -42,12 +44,15 @@
     regionprops_moments_hu,
     regionprops_moments_normalized,
     regionprops_num_pixels,
+    regionprops_perimeter,
+    regionprops_perimeter_crofton,
 )
 from cucim.skimage.measure._regionprops_gpu_basic_kernels import basic_deps
 from cucim.skimage.measure._regionprops_gpu_convex import convex_deps
 from cucim.skimage.measure._regionprops_gpu_intensity_kernels import (
     intensity_deps,
 )
+from cucim.skimage.measure._regionprops_gpu_misc_kernels import misc_deps
 from cucim.skimage.measure._regionprops_gpu_moments_kernels import (
     moment_deps,
 )
@@ -1009,6 +1014,126 @@ def test_centroid_weighted(
                 allclose(centroids[:, c, d], expected[prop + f"-{d}-{c}"])
 
 
+@pytest.mark.parametrize("shape", [(256, 512), (4096, 1024)])
+@pytest.mark.parametrize("volume_fraction", [0.1, 0.25, 0.5])
+@pytest.mark.parametrize("blob_size_fraction", [0.025, 0.05, 0.1])
+@pytest.mark.parametrize("robust", [False, True])
+def test_perimeter(shape, robust, volume_fraction, blob_size_fraction):
+    labels = get_labels_nd(
+        shape,
+        blob_size_fraction=blob_size_fraction,
+        volume_fraction=volume_fraction,
+    )
+
+    max_label = int(cp.max(labels))
+    if not robust:
+        # remove any regions that are to close for non-robust algorithm
+        labels_close = _find_close_labels(labels, labels > 0, max_label)
+        for value in labels_close:
+            labels[labels == value] = 0
+
+        # relabel to ensure sequential
+        labels = measure.label(labels > 0)
+        max_label = int(cp.max(labels))
+
+    max_label = int(cp.max(labels))
+    values = regionprops_perimeter(labels, max_label=max_label, robust=robust)
+    expected = measure_cpu.regionprops_table(
+        cp.asnumpy(labels), properties=["perimeter"]
+    )
+    rtol = 1e-5
+    atol = 1e-5
+    allclose = functools.partial(assert_allclose, rtol=rtol, atol=atol)
+    allclose(values, expected["perimeter"])
+
+
+@pytest.mark.parametrize("shape", [(256, 512), (4096, 1024)])
+@pytest.mark.parametrize("volume_fraction", [0.1, 0.25, 0.5])
+@pytest.mark.parametrize("blob_size_fraction", [0.025, 0.05, 0.1])
+@pytest.mark.parametrize("robust", [False, True])
+def test_perimeter_crofton(shape, robust, volume_fraction, blob_size_fraction):
+    labels = get_labels_nd(
+        shape,
+        blob_size_fraction=blob_size_fraction,
+        volume_fraction=volume_fraction,
+    )
+
+    max_label = int(cp.max(labels))
+    if not robust:
+        # remove any regions that are to close for non-robust algorithm
+        labels_close = _find_close_labels(labels, labels > 0, max_label)
+        for value in labels_close:
+            labels[labels == value] = 0
+
+        # relabel to ensure sequential
+        labels = measure.label(labels > 0)
+        max_label = int(cp.max(labels))
+
+    values = regionprops_perimeter_crofton(
+        labels, max_label=max_label, robust=robust
+    )
+    expected = measure_cpu.regionprops_table(
+        cp.asnumpy(labels), properties=["perimeter_crofton"]
+    )
+    rtol = 1e-5
+    atol = 1e-5
+    allclose = functools.partial(assert_allclose, rtol=rtol, atol=atol)
+    allclose(values, expected["perimeter_crofton"])
+
+
+@pytest.mark.parametrize(
+    "shape, connectivity",
+    [
+        ((256, 512), None),
+        ((256, 512), 1),
+        ((256, 512), 2),
+        ((96, 64, 48), None),
+        ((96, 64, 48), 1),
+        ((96, 64, 48), 3),
+    ],
+)
+@pytest.mark.parametrize("volume_fraction", [0.25])
+@pytest.mark.parametrize("blob_size_fraction", [0.04])
+@pytest.mark.parametrize("insert_holes", [False, True])
+@pytest.mark.parametrize("robust", [False, True])
+def test_euler(
+    shape,
+    connectivity,
+    robust,
+    volume_fraction,
+    blob_size_fraction,
+    insert_holes,
+):
+    labels = get_labels_nd(
+        shape,
+        blob_size_fraction=blob_size_fraction,
+        volume_fraction=volume_fraction,
+        insert_holes=insert_holes,
+    )
+
+    max_label = int(cp.max(labels))
+    if not robust:
+        # remove any regions that are to close for non-robust algorithm
+        labels_close = _find_close_labels(labels, labels > 0, max_label)
+        for value in labels_close:
+            labels[labels == value] = 0
+
+        # relabel to ensure sequential
+        labels = measure.label(labels > 0)
+        max_label = int(cp.max(labels))
+
+    values = regionprops_euler(
+        labels, connectivity, max_label=max_label, robust=robust
+    )
+    if connectivity is None or connectivity == labels.ndim:
+        # regionprops_table has hard-coded connectivity, can't use it to verify
+        # other values
+        expected = measure_cpu.regionprops_table(
+            cp.asnumpy(labels), properties=["euler_number"]
+        )
+        assert_array_equal(values, expected["euler_number"])
+
+
 @pytest.mark.parametrize("ndim", [2, 3])
 @pytest.mark.parametrize("num_channels", [1, 3])
 @pytest.mark.parametrize(
@@ -1159,6 +1284,7 @@ def test_feret_diameter_max(ndim, spacing, blob_kwargs):
         list(basic_deps.keys())
         + list(convex_deps.keys())
         + list(intensity_deps.keys())
+        + list(misc_deps.keys())
         + list(moment_deps.keys())
     ),
 )


Reviews:
user: login: gigony
body: Thanks @grlee77 for this update!
It looks good to me. Left minor comments.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T04:42:23Z
user: login: grlee77
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T00:57:00Z
user: login: grlee77
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T00:58:07Z
user: login: grlee77
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T01:00:53Z
user: login: grlee77
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T01:01:09Z
user: login: grlee77
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T01:08:31Z
user: login: grlee77
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T01:09:26Z
user: login: grlee77
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T01:09:53Z
user: login: grlee77
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T01:10:39Z

Review comments:
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: gigony
body: We may want to remove this or move these lines after line 224 (after `labels_dilated` is defined).
created_at: 2025-04-02T04:14:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
+            np.pi / (2 * sq2),
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            0,
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            np.pi / (4 * sq2),
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            0,
+            0,
+        ]
+    coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    return filter_weights, coefs
+
+
+def regionprops_perimeter_crofton(
+    labels,
+    directions=4,
+    *,
+    max_label=None,
+    robust=True,
+    omit_image_edges=False,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total Crofton perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter_crofton" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    directions : 2 or 4, optional
+        Number of directions used to approximate the Crofton perimeter. By
+        default, 4 is used: it should be more accurate than 2.
+        Computation time is the same in both cases.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    omit_image_edges : bool, optional
+        This can be set to avoid an additional padding step that includes the
+        edges of objects that correspond to the image edge as part of the
+        perimeter. We cannot accurately estimate the perimeter of objects
+        falling partly outside of `image`, so it seems acceptable to just set
+        this to True. The default remains False for consistency with upstream
+        scikit-image.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter_crofton" within this
+        dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    This measure is based on Crofton formula [1], which is a measure from
+    integral geometry. It is defined for general curve length evaluation via
+    a double integral along all directions. In a discrete
+    space, 2 or 4 directions give a quite good approximation, 4 being more
+    accurate than 2 for more complex shapes.
+
+    Similar to :func:`~.measure.perimeter`, this function returns an
+    approximation of the perimeter in continuous space.
+
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    See Also
+    --------
+    perimeter
+
+    References
+    ----------
+    .. [1] https://en.wikipedia.org/wiki/Crofton_formula
+    .. [2] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010.
+           Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    ndim = labels.ndim
+    if ndim not in [2, 3]:
+        raise ValueError("labels must be 2D or 3D")
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+
+    footprint = 3  # scalar 3 -> (3, ) * ndim array of ones
+
+    if not omit_image_edges:
+        # Dilate labels by 1 pixel so we can sum with values in image_filtered
+        # to give unique histogram bins for each labeled regions (As long as no
+        # labeled regions are within < 2 pixels from another labeled region)
+        labels_pad = cp.pad(labels, pad_width=1, mode="constant")
+        labels_dilated = ndi.grey_dilation(labels_pad, 3, mode="constant")
+        binary_image = pad(binary_image, pad_width=1, mode="constant")
+        # need dilated mask for later use for indexing into
+        # `image_filtered_labeled` for bincount
+        binary_image_mask = ndi.binary_dilation(binary_image, footprint)
+        binary_image_mask = cp.logical_xor(
+            binary_image_mask, ndi.binary_erosion(binary_image, footprint)
+        )
+    else:
+        labels_dilated = ndi.grey_dilation(labels, footprint, mode="constant")
+        binary_image_mask = binary_image
+
+    # determine convolution weights and LUT for weighted sum
+    filter_weights, coefs = _get_perimeter_crofton_weights_and_coefs(
+        directions, cp.float32
+    )
+
+    image_filtered = ndi.convolve(
+        binary_image.view(cp.uint8),
+        filter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=image_filtered,
+        coefs=coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = labels_dilated.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+            ld = labels[
+                max(sl[0].start, 0) : min(sl[0].stop, shape[0]),
+                max(sl[1].start, 0) : min(sl[1].stop, shape[1]),
+            ]
+            p = regionprops_perimeter_crofton(
+                ld == lab,
+                max_label=1,
+                directions=directions,
+                omit_image_edges=False,
+                robust=False,
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter_crofton"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_euler_weights_and_coefs(ndim, connectivity, coefs_dtype=cp.float32):
+    from cucim.skimage.measure._regionprops_utils import (
+        EULER_COEFS2D_4,
+        EULER_COEFS2D_8,
+        EULER_COEFS3D_26,
+    )
+
+    if ndim not in [2, 3]:
+        raise ValueError("only 2D and 3D images are supported")
+
+    if ndim == 2:
+        filter_weights = cp.array([[0, 0, 0], [0, 1, 4], [0, 2, 8]])
+        if connectivity == 1:
+            coefs = EULER_COEFS2D_4
+        else:
+            coefs = EULER_COEFS2D_8
+        coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    else:  # 3D images
+        if connectivity == 2:
+            raise NotImplementedError(
+                "For 3D images, Euler number is implemented "
+                "for connectivities 1 and 3 only"
+            )
+
+        filter_weights = cp.array(
+            [
+                [[0, 0, 0], [0, 0, 0], [0, 0, 0]],
+                [[0, 0, 0], [0, 1, 4], [0, 2, 8]],
+                [[0, 0, 0], [0, 16, 64], [0, 32, 128]],
+            ]
+        )
+        if connectivity == 1:
+            coefs = EULER_COEFS3D_26[::-1]
+        else:
+            coefs = EULER_COEFS3D_26
+        coefs = cp.asarray(0.125 * coefs, dtype=coefs_dtype)
+
+    return filter_weights, coefs
+
+
+def regionprops_euler(
+    labels,
+    connectivity=None,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate the Euler characteristic in binary image.
+
+    For 2D objects, the Euler number is the number of objects minus the number
+    of holes. For 3D objects, the Euler number is obtained as the number of
+    objects plus the number of holes, minus the number of tunnels, or loops.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "euler_number" to `props_dict`
+
+    Parameters
+    ----------
+    labels: (M, N[, P]) cupy.ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    connectivity : int, optional
+        Maximum number of orthogonal hops to consider a pixel/voxel
+        as a neighbor.
+        Accepted values are ranging from  1 to input.ndim. If ``None``, a full
+        connectivity of ``input.ndim`` is used.
+        4 or 8 neighborhoods are defined for 2D images (connectivity 1 and 2,
+        respectively).
+        6 or 26 neighborhoods are defined for 3D images, (connectivity 1 and 3,
+        respectively). Connectivity 2 is not defined.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "euler_number" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    euler_number : cp.ndarray of int
+        Euler characteristic of the set of all objects in the image.
+
+    Notes
+    -----
+    The Euler characteristic is an integer number that describes the
+    topology of the set of all objects in the input image. If object is
+    4-connected, then background is 8-connected, and conversely.
+
+    The computation of the Euler characteristic is based on an integral
+    geometry formula in discretized space. In practice, a neighborhood
+    configuration is constructed, and a LUT is applied for each
+    configuration. The coefficients used are the ones of Ohser et al.
+
+    It can be useful to compute the Euler characteristic for several
+    connectivities. A large relative difference between results
+    for different connectivities suggests that the image resolution
+    (with respect to the size of objects and holes) is too low.
+
+    References
+    ----------
+    .. [1] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010. Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    .. [2] Ohser J., Nagel W., Schladitz K. (2002) The Euler Number of
+           Discretized Sets - On the Choice of Adjacency in Homogeneous
+           Lattices. In: Mecke K., Stoyan D. (eds) Morphology of Condensed
+           Matter. Lecture Notes in Physics, vol 600. Springer, Berlin,
+           Heidelberg.
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: gigony
body: It looks like the examples section incorrectly shows `perimeter` function calls instead of `euler_number`.
created_at: 2025-04-02T04:19:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
+            np.pi / (2 * sq2),
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            0,
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            np.pi / (4 * sq2),
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            0,
+            0,
+        ]
+    coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    return filter_weights, coefs
+
+
+def regionprops_perimeter_crofton(
+    labels,
+    directions=4,
+    *,
+    max_label=None,
+    robust=True,
+    omit_image_edges=False,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total Crofton perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter_crofton" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    directions : 2 or 4, optional
+        Number of directions used to approximate the Crofton perimeter. By
+        default, 4 is used: it should be more accurate than 2.
+        Computation time is the same in both cases.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    omit_image_edges : bool, optional
+        This can be set to avoid an additional padding step that includes the
+        edges of objects that correspond to the image edge as part of the
+        perimeter. We cannot accurately estimate the perimeter of objects
+        falling partly outside of `image`, so it seems acceptable to just set
+        this to True. The default remains False for consistency with upstream
+        scikit-image.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter_crofton" within this
+        dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    This measure is based on Crofton formula [1], which is a measure from
+    integral geometry. It is defined for general curve length evaluation via
+    a double integral along all directions. In a discrete
+    space, 2 or 4 directions give a quite good approximation, 4 being more
+    accurate than 2 for more complex shapes.
+
+    Similar to :func:`~.measure.perimeter`, this function returns an
+    approximation of the perimeter in continuous space.
+
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    See Also
+    --------
+    perimeter
+
+    References
+    ----------
+    .. [1] https://en.wikipedia.org/wiki/Crofton_formula
+    .. [2] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010.
+           Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    ndim = labels.ndim
+    if ndim not in [2, 3]:
+        raise ValueError("labels must be 2D or 3D")
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+
+    footprint = 3  # scalar 3 -> (3, ) * ndim array of ones
+
+    if not omit_image_edges:
+        # Dilate labels by 1 pixel so we can sum with values in image_filtered
+        # to give unique histogram bins for each labeled regions (As long as no
+        # labeled regions are within < 2 pixels from another labeled region)
+        labels_pad = cp.pad(labels, pad_width=1, mode="constant")
+        labels_dilated = ndi.grey_dilation(labels_pad, 3, mode="constant")
+        binary_image = pad(binary_image, pad_width=1, mode="constant")
+        # need dilated mask for later use for indexing into
+        # `image_filtered_labeled` for bincount
+        binary_image_mask = ndi.binary_dilation(binary_image, footprint)
+        binary_image_mask = cp.logical_xor(
+            binary_image_mask, ndi.binary_erosion(binary_image, footprint)
+        )
+    else:
+        labels_dilated = ndi.grey_dilation(labels, footprint, mode="constant")
+        binary_image_mask = binary_image
+
+    # determine convolution weights and LUT for weighted sum
+    filter_weights, coefs = _get_perimeter_crofton_weights_and_coefs(
+        directions, cp.float32
+    )
+
+    image_filtered = ndi.convolve(
+        binary_image.view(cp.uint8),
+        filter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=image_filtered,
+        coefs=coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = labels_dilated.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+            ld = labels[
+                max(sl[0].start, 0) : min(sl[0].stop, shape[0]),
+                max(sl[1].start, 0) : min(sl[1].stop, shape[1]),
+            ]
+            p = regionprops_perimeter_crofton(
+                ld == lab,
+                max_label=1,
+                directions=directions,
+                omit_image_edges=False,
+                robust=False,
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter_crofton"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_euler_weights_and_coefs(ndim, connectivity, coefs_dtype=cp.float32):
+    from cucim.skimage.measure._regionprops_utils import (
+        EULER_COEFS2D_4,
+        EULER_COEFS2D_8,
+        EULER_COEFS3D_26,
+    )
+
+    if ndim not in [2, 3]:
+        raise ValueError("only 2D and 3D images are supported")
+
+    if ndim == 2:
+        filter_weights = cp.array([[0, 0, 0], [0, 1, 4], [0, 2, 8]])
+        if connectivity == 1:
+            coefs = EULER_COEFS2D_4
+        else:
+            coefs = EULER_COEFS2D_8
+        coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    else:  # 3D images
+        if connectivity == 2:
+            raise NotImplementedError(
+                "For 3D images, Euler number is implemented "
+                "for connectivities 1 and 3 only"
+            )
+
+        filter_weights = cp.array(
+            [
+                [[0, 0, 0], [0, 0, 0], [0, 0, 0]],
+                [[0, 0, 0], [0, 1, 4], [0, 2, 8]],
+                [[0, 0, 0], [0, 16, 64], [0, 32, 128]],
+            ]
+        )
+        if connectivity == 1:
+            coefs = EULER_COEFS3D_26[::-1]
+        else:
+            coefs = EULER_COEFS3D_26
+        coefs = cp.asarray(0.125 * coefs, dtype=coefs_dtype)
+
+    return filter_weights, coefs
+
+
+def regionprops_euler(
+    labels,
+    connectivity=None,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate the Euler characteristic in binary image.
+
+    For 2D objects, the Euler number is the number of objects minus the number
+    of holes. For 3D objects, the Euler number is obtained as the number of
+    objects plus the number of holes, minus the number of tunnels, or loops.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "euler_number" to `props_dict`
+
+    Parameters
+    ----------
+    labels: (M, N[, P]) cupy.ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    connectivity : int, optional
+        Maximum number of orthogonal hops to consider a pixel/voxel
+        as a neighbor.
+        Accepted values are ranging from  1 to input.ndim. If ``None``, a full
+        connectivity of ``input.ndim`` is used.
+        4 or 8 neighborhoods are defined for 2D images (connectivity 1 and 2,
+        respectively).
+        6 or 26 neighborhoods are defined for 3D images, (connectivity 1 and 3,
+        respectively). Connectivity 2 is not defined.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "euler_number" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    euler_number : cp.ndarray of int
+        Euler characteristic of the set of all objects in the image.
+
+    Notes
+    -----
+    The Euler characteristic is an integer number that describes the
+    topology of the set of all objects in the input image. If object is
+    4-connected, then background is 8-connected, and conversely.
+
+    The computation of the Euler characteristic is based on an integral
+    geometry formula in discretized space. In practice, a neighborhood
+    configuration is constructed, and a LUT is applied for each
+    configuration. The coefficients used are the ones of Ohser et al.
+
+    It can be useful to compute the Euler characteristic for several
+    connectivities. A large relative difference between results
+    for different connectivities suggests that the image resolution
+    (with respect to the size of objects and holes) is too low.
+
+    References
+    ----------
+    .. [1] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010. Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    .. [2] Ohser J., Nagel W., Schladitz K. (2002) The Euler Number of
+           Discretized Sets - On the Choice of Adjacency in Homogeneous
+           Lattices. In: Mecke K., Stoyan D. (eds) Morphology of Condensed
+           Matter. Lecture Notes in Physics, vol 600. Springer, Berlin,
+           Heidelberg.
+    --------
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: gigony
body: ```suggestion

    See Also
    --------
```
created_at: 2025-04-02T04:28:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: gigony
body: Could not specifying the cache size here lead to memory issues with many different parameter combinations?
created_at: 2025-04-02T04:31:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: gigony
body: I’m wondering if hardcoded floating-point values (e.g., np.pi / 4 * (1 + 1 / sq2)), which may introduce minor numerical errors, could be precomputed as high-precision constants instead.
created_at: 2025-04-02T04:34:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: grlee77
body: In this case the only items are the floating point data type and the GPU device ID so it should be only a handful of combinations. If you memoize over the exact image shape or something then that could be more problematic.
created_at: 2025-04-04T00:57:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: grlee77
body: thanks it should be removed here. labels_dilated is now created with this dtype below
```suggestion
```
created_at: 2025-04-04T00:58:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: grlee77
body: We could, but I don't think it will make any practical difference. Python's double precision float should be accurate enough. There will be much larger error from the discrete pixel size than numerical precision loss in these values.
created_at: 2025-04-04T01:00:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
+            np.pi / (2 * sq2),
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            0,
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            np.pi / (4 * sq2),
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            0,
+            0,
+        ]
+    coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    return filter_weights, coefs
+
+
+def regionprops_perimeter_crofton(
+    labels,
+    directions=4,
+    *,
+    max_label=None,
+    robust=True,
+    omit_image_edges=False,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total Crofton perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter_crofton" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    directions : 2 or 4, optional
+        Number of directions used to approximate the Crofton perimeter. By
+        default, 4 is used: it should be more accurate than 2.
+        Computation time is the same in both cases.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    omit_image_edges : bool, optional
+        This can be set to avoid an additional padding step that includes the
+        edges of objects that correspond to the image edge as part of the
+        perimeter. We cannot accurately estimate the perimeter of objects
+        falling partly outside of `image`, so it seems acceptable to just set
+        this to True. The default remains False for consistency with upstream
+        scikit-image.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter_crofton" within this
+        dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    This measure is based on Crofton formula [1], which is a measure from
+    integral geometry. It is defined for general curve length evaluation via
+    a double integral along all directions. In a discrete
+    space, 2 or 4 directions give a quite good approximation, 4 being more
+    accurate than 2 for more complex shapes.
+
+    Similar to :func:`~.measure.perimeter`, this function returns an
+    approximation of the perimeter in continuous space.
+
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    See Also
+    --------
+    perimeter
+
+    References
+    ----------
+    .. [1] https://en.wikipedia.org/wiki/Crofton_formula
+    .. [2] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010.
+           Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    ndim = labels.ndim
+    if ndim not in [2, 3]:
+        raise ValueError("labels must be 2D or 3D")
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+
+    footprint = 3  # scalar 3 -> (3, ) * ndim array of ones
+
+    if not omit_image_edges:
+        # Dilate labels by 1 pixel so we can sum with values in image_filtered
+        # to give unique histogram bins for each labeled regions (As long as no
+        # labeled regions are within < 2 pixels from another labeled region)
+        labels_pad = cp.pad(labels, pad_width=1, mode="constant")
+        labels_dilated = ndi.grey_dilation(labels_pad, 3, mode="constant")
+        binary_image = pad(binary_image, pad_width=1, mode="constant")
+        # need dilated mask for later use for indexing into
+        # `image_filtered_labeled` for bincount
+        binary_image_mask = ndi.binary_dilation(binary_image, footprint)
+        binary_image_mask = cp.logical_xor(
+            binary_image_mask, ndi.binary_erosion(binary_image, footprint)
+        )
+    else:
+        labels_dilated = ndi.grey_dilation(labels, footprint, mode="constant")
+        binary_image_mask = binary_image
+
+    # determine convolution weights and LUT for weighted sum
+    filter_weights, coefs = _get_perimeter_crofton_weights_and_coefs(
+        directions, cp.float32
+    )
+
+    image_filtered = ndi.convolve(
+        binary_image.view(cp.uint8),
+        filter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=image_filtered,
+        coefs=coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = labels_dilated.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+            ld = labels[
+                max(sl[0].start, 0) : min(sl[0].stop, shape[0]),
+                max(sl[1].start, 0) : min(sl[1].stop, shape[1]),
+            ]
+            p = regionprops_perimeter_crofton(
+                ld == lab,
+                max_label=1,
+                directions=directions,
+                omit_image_edges=False,
+                robust=False,
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter_crofton"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_euler_weights_and_coefs(ndim, connectivity, coefs_dtype=cp.float32):
+    from cucim.skimage.measure._regionprops_utils import (
+        EULER_COEFS2D_4,
+        EULER_COEFS2D_8,
+        EULER_COEFS3D_26,
+    )
+
+    if ndim not in [2, 3]:
+        raise ValueError("only 2D and 3D images are supported")
+
+    if ndim == 2:
+        filter_weights = cp.array([[0, 0, 0], [0, 1, 4], [0, 2, 8]])
+        if connectivity == 1:
+            coefs = EULER_COEFS2D_4
+        else:
+            coefs = EULER_COEFS2D_8
+        coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    else:  # 3D images
+        if connectivity == 2:
+            raise NotImplementedError(
+                "For 3D images, Euler number is implemented "
+                "for connectivities 1 and 3 only"
+            )
+
+        filter_weights = cp.array(
+            [
+                [[0, 0, 0], [0, 0, 0], [0, 0, 0]],
+                [[0, 0, 0], [0, 1, 4], [0, 2, 8]],
+                [[0, 0, 0], [0, 16, 64], [0, 32, 128]],
+            ]
+        )
+        if connectivity == 1:
+            coefs = EULER_COEFS3D_26[::-1]
+        else:
+            coefs = EULER_COEFS3D_26
+        coefs = cp.asarray(0.125 * coefs, dtype=coefs_dtype)
+
+    return filter_weights, coefs
+
+
+def regionprops_euler(
+    labels,
+    connectivity=None,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate the Euler characteristic in binary image.
+
+    For 2D objects, the Euler number is the number of objects minus the number
+    of holes. For 3D objects, the Euler number is obtained as the number of
+    objects plus the number of holes, minus the number of tunnels, or loops.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "euler_number" to `props_dict`
+
+    Parameters
+    ----------
+    labels: (M, N[, P]) cupy.ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    connectivity : int, optional
+        Maximum number of orthogonal hops to consider a pixel/voxel
+        as a neighbor.
+        Accepted values are ranging from  1 to input.ndim. If ``None``, a full
+        connectivity of ``input.ndim`` is used.
+        4 or 8 neighborhoods are defined for 2D images (connectivity 1 and 2,
+        respectively).
+        6 or 26 neighborhoods are defined for 3D images, (connectivity 1 and 3,
+        respectively). Connectivity 2 is not defined.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "euler_number" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    euler_number : cp.ndarray of int
+        Euler characteristic of the set of all objects in the image.
+
+    Notes
+    -----
+    The Euler characteristic is an integer number that describes the
+    topology of the set of all objects in the input image. If object is
+    4-connected, then background is 8-connected, and conversely.
+
+    The computation of the Euler characteristic is based on an integral
+    geometry formula in discretized space. In practice, a neighborhood
+    configuration is constructed, and a LUT is applied for each
+    configuration. The coefficients used are the ones of Ohser et al.
+
+    It can be useful to compute the Euler characteristic for several
+    connectivities. A large relative difference between results
+    for different connectivities suggests that the image resolution
+    (with respect to the size of objects and holes) is too low.
+
+    References
+    ----------
+    .. [1] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010. Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    .. [2] Ohser J., Nagel W., Schladitz K. (2002) The Euler Number of
+           Discretized Sets - On the Choice of Adjacency in Homogeneous
+           Lattices. In: Mecke K., Stoyan D. (eds) Morphology of Condensed
+           Matter. Lecture Notes in Physics, vol 600. Springer, Berlin,
+           Heidelberg.
+    --------
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: grlee77
body: thanks!
created_at: 2025-04-04T01:01:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
+            np.pi / (2 * sq2),
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            0,
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            np.pi / (4 * sq2),
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            0,
+            0,
+        ]
+    coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    return filter_weights, coefs
+
+
+def regionprops_perimeter_crofton(
+    labels,
+    directions=4,
+    *,
+    max_label=None,
+    robust=True,
+    omit_image_edges=False,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total Crofton perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter_crofton" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    directions : 2 or 4, optional
+        Number of directions used to approximate the Crofton perimeter. By
+        default, 4 is used: it should be more accurate than 2.
+        Computation time is the same in both cases.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    omit_image_edges : bool, optional
+        This can be set to avoid an additional padding step that includes the
+        edges of objects that correspond to the image edge as part of the
+        perimeter. We cannot accurately estimate the perimeter of objects
+        falling partly outside of `image`, so it seems acceptable to just set
+        this to True. The default remains False for consistency with upstream
+        scikit-image.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter_crofton" within this
+        dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    This measure is based on Crofton formula [1], which is a measure from
+    integral geometry. It is defined for general curve length evaluation via
+    a double integral along all directions. In a discrete
+    space, 2 or 4 directions give a quite good approximation, 4 being more
+    accurate than 2 for more complex shapes.
+
+    Similar to :func:`~.measure.perimeter`, this function returns an
+    approximation of the perimeter in continuous space.
+
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    See Also
+    --------
+    perimeter
+
+    References
+    ----------
+    .. [1] https://en.wikipedia.org/wiki/Crofton_formula
+    .. [2] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010.
+           Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    ndim = labels.ndim
+    if ndim not in [2, 3]:
+        raise ValueError("labels must be 2D or 3D")
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+
+    footprint = 3  # scalar 3 -> (3, ) * ndim array of ones
+
+    if not omit_image_edges:
+        # Dilate labels by 1 pixel so we can sum with values in image_filtered
+        # to give unique histogram bins for each labeled regions (As long as no
+        # labeled regions are within < 2 pixels from another labeled region)
+        labels_pad = cp.pad(labels, pad_width=1, mode="constant")
+        labels_dilated = ndi.grey_dilation(labels_pad, 3, mode="constant")
+        binary_image = pad(binary_image, pad_width=1, mode="constant")
+        # need dilated mask for later use for indexing into
+        # `image_filtered_labeled` for bincount
+        binary_image_mask = ndi.binary_dilation(binary_image, footprint)
+        binary_image_mask = cp.logical_xor(
+            binary_image_mask, ndi.binary_erosion(binary_image, footprint)
+        )
+    else:
+        labels_dilated = ndi.grey_dilation(labels, footprint, mode="constant")
+        binary_image_mask = binary_image
+
+    # determine convolution weights and LUT for weighted sum
+    filter_weights, coefs = _get_perimeter_crofton_weights_and_coefs(
+        directions, cp.float32
+    )
+
+    image_filtered = ndi.convolve(
+        binary_image.view(cp.uint8),
+        filter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=image_filtered,
+        coefs=coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = labels_dilated.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+            ld = labels[
+                max(sl[0].start, 0) : min(sl[0].stop, shape[0]),
+                max(sl[1].start, 0) : min(sl[1].stop, shape[1]),
+            ]
+            p = regionprops_perimeter_crofton(
+                ld == lab,
+                max_label=1,
+                directions=directions,
+                omit_image_edges=False,
+                robust=False,
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter_crofton"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_euler_weights_and_coefs(ndim, connectivity, coefs_dtype=cp.float32):
+    from cucim.skimage.measure._regionprops_utils import (
+        EULER_COEFS2D_4,
+        EULER_COEFS2D_8,
+        EULER_COEFS3D_26,
+    )
+
+    if ndim not in [2, 3]:
+        raise ValueError("only 2D and 3D images are supported")
+
+    if ndim == 2:
+        filter_weights = cp.array([[0, 0, 0], [0, 1, 4], [0, 2, 8]])
+        if connectivity == 1:
+            coefs = EULER_COEFS2D_4
+        else:
+            coefs = EULER_COEFS2D_8
+        coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    else:  # 3D images
+        if connectivity == 2:
+            raise NotImplementedError(
+                "For 3D images, Euler number is implemented "
+                "for connectivities 1 and 3 only"
+            )
+
+        filter_weights = cp.array(
+            [
+                [[0, 0, 0], [0, 0, 0], [0, 0, 0]],
+                [[0, 0, 0], [0, 1, 4], [0, 2, 8]],
+                [[0, 0, 0], [0, 16, 64], [0, 32, 128]],
+            ]
+        )
+        if connectivity == 1:
+            coefs = EULER_COEFS3D_26[::-1]
+        else:
+            coefs = EULER_COEFS3D_26
+        coefs = cp.asarray(0.125 * coefs, dtype=coefs_dtype)
+
+    return filter_weights, coefs
+
+
+def regionprops_euler(
+    labels,
+    connectivity=None,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate the Euler characteristic in binary image.
+
+    For 2D objects, the Euler number is the number of objects minus the number
+    of holes. For 3D objects, the Euler number is obtained as the number of
+    objects plus the number of holes, minus the number of tunnels, or loops.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "euler_number" to `props_dict`
+
+    Parameters
+    ----------
+    labels: (M, N[, P]) cupy.ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    connectivity : int, optional
+        Maximum number of orthogonal hops to consider a pixel/voxel
+        as a neighbor.
+        Accepted values are ranging from  1 to input.ndim. If ``None``, a full
+        connectivity of ``input.ndim`` is used.
+        4 or 8 neighborhoods are defined for 2D images (connectivity 1 and 2,
+        respectively).
+        6 or 26 neighborhoods are defined for 3D images, (connectivity 1 and 3,
+        respectively). Connectivity 2 is not defined.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "euler_number" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    euler_number : cp.ndarray of int
+        Euler characteristic of the set of all objects in the image.
+
+    Notes
+    -----
+    The Euler characteristic is an integer number that describes the
+    topology of the set of all objects in the input image. If object is
+    4-connected, then background is 8-connected, and conversely.
+
+    The computation of the Euler characteristic is based on an integral
+    geometry formula in discretized space. In practice, a neighborhood
+    configuration is constructed, and a LUT is applied for each
+    configuration. The coefficients used are the ones of Ohser et al.
+
+    It can be useful to compute the Euler characteristic for several
+    connectivities. A large relative difference between results
+    for different connectivities suggests that the image resolution
+    (with respect to the size of objects and holes) is too low.
+
+    References
+    ----------
+    .. [1] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010. Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    .. [2] Ohser J., Nagel W., Schladitz K. (2002) The Euler Number of
+           Discretized Sets - On the Choice of Adjacency in Homogeneous
+           Lattices. In: Mecke K., Stoyan D. (eds) Morphology of Condensed
+           Matter. Lecture Notes in Physics, vol 600. Springer, Berlin,
+           Heidelberg.
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: grlee77
body: ```suggestion
```
created_at: 2025-04-04T01:08:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
+            np.pi / (2 * sq2),
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            0,
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            np.pi / (4 * sq2),
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            0,
+            0,
+        ]
+    coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    return filter_weights, coefs
+
+
+def regionprops_perimeter_crofton(
+    labels,
+    directions=4,
+    *,
+    max_label=None,
+    robust=True,
+    omit_image_edges=False,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total Crofton perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter_crofton" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    directions : 2 or 4, optional
+        Number of directions used to approximate the Crofton perimeter. By
+        default, 4 is used: it should be more accurate than 2.
+        Computation time is the same in both cases.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    omit_image_edges : bool, optional
+        This can be set to avoid an additional padding step that includes the
+        edges of objects that correspond to the image edge as part of the
+        perimeter. We cannot accurately estimate the perimeter of objects
+        falling partly outside of `image`, so it seems acceptable to just set
+        this to True. The default remains False for consistency with upstream
+        scikit-image.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter_crofton" within this
+        dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    This measure is based on Crofton formula [1], which is a measure from
+    integral geometry. It is defined for general curve length evaluation via
+    a double integral along all directions. In a discrete
+    space, 2 or 4 directions give a quite good approximation, 4 being more
+    accurate than 2 for more complex shapes.
+
+    Similar to :func:`~.measure.perimeter`, this function returns an
+    approximation of the perimeter in continuous space.
+
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    See Also
+    --------
+    perimeter
+
+    References
+    ----------
+    .. [1] https://en.wikipedia.org/wiki/Crofton_formula
+    .. [2] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010.
+           Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    ndim = labels.ndim
+    if ndim not in [2, 3]:
+        raise ValueError("labels must be 2D or 3D")
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+
+    footprint = 3  # scalar 3 -> (3, ) * ndim array of ones
+
+    if not omit_image_edges:
+        # Dilate labels by 1 pixel so we can sum with values in image_filtered
+        # to give unique histogram bins for each labeled regions (As long as no
+        # labeled regions are within < 2 pixels from another labeled region)
+        labels_pad = cp.pad(labels, pad_width=1, mode="constant")
+        labels_dilated = ndi.grey_dilation(labels_pad, 3, mode="constant")
+        binary_image = pad(binary_image, pad_width=1, mode="constant")
+        # need dilated mask for later use for indexing into
+        # `image_filtered_labeled` for bincount
+        binary_image_mask = ndi.binary_dilation(binary_image, footprint)
+        binary_image_mask = cp.logical_xor(
+            binary_image_mask, ndi.binary_erosion(binary_image, footprint)
+        )
+    else:
+        labels_dilated = ndi.grey_dilation(labels, footprint, mode="constant")
+        binary_image_mask = binary_image
+
+    # determine convolution weights and LUT for weighted sum
+    filter_weights, coefs = _get_perimeter_crofton_weights_and_coefs(
+        directions, cp.float32
+    )
+
+    image_filtered = ndi.convolve(
+        binary_image.view(cp.uint8),
+        filter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=image_filtered,
+        coefs=coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = labels_dilated.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+            ld = labels[
+                max(sl[0].start, 0) : min(sl[0].stop, shape[0]),
+                max(sl[1].start, 0) : min(sl[1].stop, shape[1]),
+            ]
+            p = regionprops_perimeter_crofton(
+                ld == lab,
+                max_label=1,
+                directions=directions,
+                omit_image_edges=False,
+                robust=False,
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter_crofton"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_euler_weights_and_coefs(ndim, connectivity, coefs_dtype=cp.float32):
+    from cucim.skimage.measure._regionprops_utils import (
+        EULER_COEFS2D_4,
+        EULER_COEFS2D_8,
+        EULER_COEFS3D_26,
+    )
+
+    if ndim not in [2, 3]:
+        raise ValueError("only 2D and 3D images are supported")
+
+    if ndim == 2:
+        filter_weights = cp.array([[0, 0, 0], [0, 1, 4], [0, 2, 8]])
+        if connectivity == 1:
+            coefs = EULER_COEFS2D_4
+        else:
+            coefs = EULER_COEFS2D_8
+        coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    else:  # 3D images
+        if connectivity == 2:
+            raise NotImplementedError(
+                "For 3D images, Euler number is implemented "
+                "for connectivities 1 and 3 only"
+            )
+
+        filter_weights = cp.array(
+            [
+                [[0, 0, 0], [0, 0, 0], [0, 0, 0]],
+                [[0, 0, 0], [0, 1, 4], [0, 2, 8]],
+                [[0, 0, 0], [0, 16, 64], [0, 32, 128]],
+            ]
+        )
+        if connectivity == 1:
+            coefs = EULER_COEFS3D_26[::-1]
+        else:
+            coefs = EULER_COEFS3D_26
+        coefs = cp.asarray(0.125 * coefs, dtype=coefs_dtype)
+
+    return filter_weights, coefs
+
+
+def regionprops_euler(
+    labels,
+    connectivity=None,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate the Euler characteristic in binary image.
+
+    For 2D objects, the Euler number is the number of objects minus the number
+    of holes. For 3D objects, the Euler number is obtained as the number of
+    objects plus the number of holes, minus the number of tunnels, or loops.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "euler_number" to `props_dict`
+
+    Parameters
+    ----------
+    labels: (M, N[, P]) cupy.ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    connectivity : int, optional
+        Maximum number of orthogonal hops to consider a pixel/voxel
+        as a neighbor.
+        Accepted values are ranging from  1 to input.ndim. If ``None``, a full
+        connectivity of ``input.ndim`` is used.
+        4 or 8 neighborhoods are defined for 2D images (connectivity 1 and 2,
+        respectively).
+        6 or 26 neighborhoods are defined for 3D images, (connectivity 1 and 3,
+        respectively). Connectivity 2 is not defined.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "euler_number" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    euler_number : cp.ndarray of int
+        Euler characteristic of the set of all objects in the image.
+
+    Notes
+    -----
+    The Euler characteristic is an integer number that describes the
+    topology of the set of all objects in the input image. If object is
+    4-connected, then background is 8-connected, and conversely.
+
+    The computation of the Euler characteristic is based on an integral
+    geometry formula in discretized space. In practice, a neighborhood
+    configuration is constructed, and a LUT is applied for each
+    configuration. The coefficients used are the ones of Ohser et al.
+
+    It can be useful to compute the Euler characteristic for several
+    connectivities. A large relative difference between results
+    for different connectivities suggests that the image resolution
+    (with respect to the size of objects and holes) is too low.
+
+    References
+    ----------
+    .. [1] S. Rivollier. Analyse d’image geometrique et morphometrique par
+           diagrammes de forme et voisinages adaptatifs generaux. PhD thesis,
+           2010. Ecole Nationale Superieure des Mines de Saint-Etienne.
+           https://tel.archives-ouvertes.fr/tel-00560838
+    .. [2] Ohser J., Nagel W., Schladitz K. (2002) The Euler Number of
+           Discretized Sets - On the Choice of Adjacency in Homogeneous
+           Lattices. In: Mecke K., Stoyan D. (eds) Morphology of Condensed
+           Matter. Lecture Notes in Physics, vol 600. Springer, Berlin,
+           Heidelberg.
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: grlee77
body: I should just remove these "see also" and examples as we are not exposing these versions directly as public API. It is just for internal use within `regionprops_table`
created_at: 2025-04-04T01:09:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
+    """
+    if max_label is None:
+        max_label = int(labels.max())
+
+    binary_image = labels > 0
+    if robust and labels_close is None:
+        labels_close = _find_close_labels(labels, binary_image, max_label)
+    if neighborhood == 4:
+        footprint = cp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=cp.uint8)
+    else:
+        footprint = 3
+
+    eroded_image = ndi.binary_erosion(binary_image, footprint, border_value=0)
+    border_image = binary_image.view(cp.uint8) - eroded_image
+
+    perimeter_weights, perimeter_coefs = _get_perimeter_weights_and_coefs(
+        cp.float32
+    )
+
+    perimeter_image = ndi.convolve(
+        border_image,
+        perimeter_weights,
+        mode="constant",
+        cval=0,
+        output=cp.uint8,
+    )
+
+    min_integer_type = _get_min_integer_dtype(max_label, signed=False)
+    # if labels.dtype != min_integer_type:
+    #     labels = labels_dilated.astype(min_integer_type)
+
+    # dilate labels by 1 pixel so we can sum with values in XF to give
+    # unique histogram bins for each labeled regions (as long as no labeled
+    # regions are within < 2 pixels from another labeled region)
+    labels_dilated = ndi.grey_dilation(
+        labels, 3, mode="constant", output=min_integer_type
+    )
+
+    if robust and labels_close.size > 0:
+        if props_dict is not None and "slice" in props_dict:
+            slices = props_dict["slice"]
+        else:
+            _, slices = regionprops_bbox_coords(labels, return_slices=True)
+
+    # sum the coefficients for each label to compute the perimeter
+    perimeters = _weighted_sum_of_filtered_image(
+        label_image=labels_dilated,
+        max_label=max_label,
+        image_filtered=perimeter_image,
+        coefs=perimeter_coefs,
+        pixels_per_thread=pixels_per_thread,
+    )
+    if robust:
+        # recompute perimeter in isolation for each region that may be too
+        # close to another one
+        shape = binary_image.shape
+        for lab in labels_close:
+            sl = slices[lab - 1]
+
+            # keep boundary of 1 so object is not at 'edge' of cropped
+            # region (unless it is at a true image edge)
+            ld = labels[
+                max(sl[0].start - 1, 0) : min(sl[0].stop + 1, shape[0]),
+                max(sl[1].start - 1, 0) : min(sl[1].stop + 1, shape[1]),
+            ]
+
+            # print(f"{lab=}, {sl=}")
+            # import matplotlib.pyplot as plt
+            # plt.figure(); plt.imshow(ld.get()); plt.show()
+
+            p = regionprops_perimeter(
+                ld == lab, max_label=1, neighborhood=neighborhood, robust=False
+            )
+            perimeters[lab - 1] = p[0]
+    if props_dict is not None:
+        props_dict["perimeter"] = perimeters
+    return perimeters
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_crofton_weights_and_coefs(
+    directions, coefs_dtype=cp.float32
+):
+    # determine convolution weights
+    filter_weights = cp.array(
+        [[0, 0, 0], [0, 1, 4], [0, 2, 8]], dtype=cp.float32
+    )
+
+    if directions == 2:
+        coefs = [
+            0,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            np.pi / 2,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            np.pi / 2,
+            np.pi,
+            0,
+            0,
+            0,
+        ]
+    else:
+        sq2 = math.sqrt(2)
+        coefs = [
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            np.pi / (4 * sq2),
+            np.pi / (2 * sq2),
+            0,
+            np.pi / 4 * (1 + 1 / sq2),
+            0,
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            np.pi / (4 * sq2),
+            np.pi / (4 * sq2),
+            np.pi / 4,
+            np.pi / 2,
+            0,
+            0,
+            0,
+            0,
+            0,
+        ]
+    coefs = cp.asarray(coefs, dtype=coefs_dtype)
+    return filter_weights, coefs
+
+
+def regionprops_perimeter_crofton(
+    labels,
+    directions=4,
+    *,
+    max_label=None,
+    robust=True,
+    omit_image_edges=False,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total Crofton perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter_crofton" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Input image. If image is not binary, all values greater than zero
+        are considered as the object.
+    directions : 2 or 4, optional
+        Number of directions used to approximate the Crofton perimeter. By
+        default, 4 is used: it should be more accurate than 2.
+        Computation time is the same in both cases.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    omit_image_edges : bool, optional
+        This can be set to avoid an additional padding step that includes the
+        edges of objects that correspond to the image edge as part of the
+        perimeter. We cannot accurately estimate the perimeter of objects
+        falling partly outside of `image`, so it seems acceptable to just set
+        this to True. The default remains False for consistency with upstream
+        scikit-image.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter_crofton" within this
+        dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    This measure is based on Crofton formula [1], which is a measure from
+    integral geometry. It is defined for general curve length evaluation via
+    a double integral along all directions. In a discrete
+    space, 2 or 4 directions give a quite good approximation, 4 being more
+    accurate than 2 for more complex shapes.
+
+    Similar to :func:`~.measure.perimeter`, this function returns an
+    approximation of the perimeter in continuous space.
+
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    See Also
+    --------
+    perimeter
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: grlee77
body: ```suggestion
```
created_at: 2025-04-04T01:09:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,721 @@
+import math
+
+import cupy as cp
+import numpy as np
+
+from cucim.skimage._vendored import ndimage as ndi, pad
+
+from ._regionprops_gpu_basic_kernels import regionprops_bbox_coords
+from ._regionprops_gpu_intensity_kernels import (
+    _get_intensity_img_kernel_dtypes,
+    get_intensity_measure_kernel,
+)
+from ._regionprops_gpu_utils import _find_close_labels, _get_min_integer_dtype
+
+__all__ = [
+    "regionprops_euler",
+    "regionprops_perimeter",
+    "regionprops_perimeter_crofton",
+]
+
+misc_deps = dict()
+misc_deps["perimeter"] = ["slice"]
+misc_deps["perimeter_crofton"] = ["slice"]
+misc_deps["euler_number"] = ["slice"]
+
+
+def _weighted_sum_of_filtered_image(
+    label_image, max_label, image_filtered, coefs, pixels_per_thread=16
+):
+    """Compute weighted sums of pixels for each label.
+
+    1. Apply the coefs LUT to the filtered image to get a coefficient image
+    2. Sum the values in the coefficient image for each labeled region
+
+    This function is used during computation of the Euler characteristic and
+    perimeter properties.
+
+    Parameters
+    ----------
+    label_image : cupy.ndarray
+        Label image.
+    max_label : int
+        Maximum label value.
+    image_filtered : (M, N) ndarray
+        Filtered image (must have integer values that can be used to index into
+        the coefs LUT)
+    coefs : cupy.ndarray
+        Coefficients look-up table (LUT).
+    pixels_per_thread : int, optional
+        Number of pixels per thread.
+
+    Returns
+    -------
+    output : cupy.ndarray
+        Weighted sum of pixels for each label.
+    """
+    coefs_image = coefs[image_filtered]
+
+    # generate kernel for per-label weighted sum
+    coefs_sum_kernel = get_intensity_measure_kernel(
+        coefs_image.dtype,
+        num_channels=1,
+        compute_num_pixels=False,
+        compute_sum=True,
+        compute_sum_sq=False,
+        compute_min=False,
+        compute_max=False,
+        pixels_per_thread=pixels_per_thread,
+    )
+
+    # prepare output array
+    sum_dtype, _, _, _ = _get_intensity_img_kernel_dtypes(coefs_image.dtype)
+    output = cp.zeros((max_label,), dtype=sum_dtype)
+
+    coefs_sum_kernel(
+        label_image,
+        label_image.size,
+        coefs_image,
+        output,
+        size=math.ceil(label_image.size / pixels_per_thread),
+    )
+    return output
+
+
+@cp.memoize(for_each_device=True)
+def _get_perimeter_weights_and_coefs(coefs_dtype=cp.float32):
+    # convolution weights
+    weights = cp.array(
+        [[10, 2, 10], [2, 1, 2], [10, 2, 10]],
+    )
+
+    # LUT for weighted sum
+    coefs = np.zeros(50, dtype=coefs_dtype)
+    coefs[[5, 7, 15, 17, 25, 27]] = 1
+    coefs[[21, 33]] = math.sqrt(2)
+    coefs[[13, 23]] = (1 + math.sqrt(2)) / 2
+    coefs = cp.asarray(coefs)
+    return weights, coefs
+
+
+def regionprops_perimeter(
+    labels,
+    neighborhood=4,
+    *,
+    max_label=None,
+    robust=True,
+    labels_close=None,
+    props_dict=None,
+    pixels_per_thread=10,
+):
+    """Calculate total perimeter of all objects in binary image.
+
+    when `robust` is ``True``, reuses "slice" from `props_dict`
+
+    writes "perimeter" to `props_dict`
+
+    Parameters
+    ----------
+    labels : (M, N) ndarray
+        Binary input image.
+    neighborhood : 4 or 8, optional
+        Neighborhood connectivity for border pixel determination. It is used to
+        compute the contour. A higher neighborhood widens the border on which
+        the perimeter is computed.
+
+    Extra Parameters
+    ----------------
+    max_label : int or None, optional
+        The maximum label in labels can be provided to avoid recomputing it if
+        it was already known.
+    robust : bool, optional
+        If True, extra computation will be done to detect if any labeled
+        regions are <=2 pixel spacing from another. Any regions that meet that
+        criteria will have their perimeter recomputed in isolation to avoid
+        possible error that would otherwise occur in this case. Turning this
+        on will make the run time substantially longer, so it should only be
+        used when labeled regions may have a non-negligible portion of their
+        boundary within a <2 pixel gap from another label.
+    labels_close : numpy.ndarray or sequence of int
+        List of labeled regions that are less than 2 pixel gap from another
+        label. Used when robust=True. If not provided and robust=True, it
+        will be computed internally.
+    props_dict : dict or None, optional
+        Dictionary of pre-computed properties (e.g. "slice"). The output of this
+        function will be stored under key "perimeter" within this dictionary.
+    pixels_per_thread : int, optional
+        Number of pixels processed per thread on the GPU during the final
+        weighted summation.
+
+    Returns
+    -------
+    perimeter : float
+        Total perimeter of all objects in binary image.
+
+    Notes
+    -----
+    The `perimeter` method does not consider the boundary along the image edge
+    as image as part of the perimeter, while the `perimeter_crofton` method
+    does. In any case, an object touching the image edge likely extends outside
+    of the field of view, so an accurate perimeter cannot be measured for such
+    objects.
+
+    If the labeled regions have holes, the hole edges will be included in this
+    measurement. If this is not desired, use regionprops_label_filled to fill
+    the holes and then pass the filled labels image to this function.
+
+    References
+    ----------
+    .. [1] K. Benkrid, D. Crookes. Design and FPGA Implementation of
+           a Perimeter Estimator. The Queen's University of Belfast.
+           http://www.cs.qub.ac.uk/~d.crookes/webpubs/papers/perimeter.doc
+
+    See Also
+    --------
+    perimeter_crofton
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from skimage import data
+    >>> from cucim.skimage import util
+    >>> from cucim.skimage.measure import label
+    >>> # coins image (binary)
+    >>> img_coins = cp.array(data.coins() > 110)
+    >>> # total perimeter of all objects in the image
+    >>> perimeter(img_coins, neighborhood=4)  # doctest: +ELLIPSIS
+    array(7796.86799644)
+    >>> perimeter(img_coins, neighborhood=8)  # doctest: +ELLIPSIS
+    array(8806.26807333)
path: python/cucim/src/cucim/skimage/measure/_regionprops_gpu_misc_kernels.py
user: login: grlee77
body: ```suggestion
```
created_at: 2025-04-04T01:10:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: copy-pr-bot[bot]
created_at: 2025-04-03T19:18:28Z
body: Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message [here](https://docs.gha-runners.nvidia.com/cpr/auto-sync).

author_association: NONE
user: login: grlee77
created_at: 2025-04-04T03:39:55Z
body: /merge
author_association: CONTRIBUTOR