Repository: rapidsai/cucim (language: python)

Issue:
Forward-merge branch-24.04 into branch-24.06
Forward-merge triggered by push to branch-24.04 that creates a PR to keep branch-24.06 up-to-date. If this PR is unable to be immediately merged due to conflicts, it will remain open for the team to manually merge. See [forward-merger docs](https://docs.rapids.ai/maintainers/forward-merger/) for more info.
investigate switching KDTree usage from SciPy to CuPy
CuPy's main branch now has a KDTree implementation (`cupyx.scipy.spatial.KDTree`). It is not released for CuPy 13.x, but we should investigate whether it provides benefit over falling back to the host side implementation from SciPy.

Functions currently replying on SciPy for use of KDTree are:
- `cucim.skimage.feature.corner_peaks`
- `cucim.skimage.feature.peak_local_max`  (via private utility function `cucim.skimage._shared.ensure_spacing`)

Other functions that use it in scikit-image, but for which CuPy has an alternative CUDA kernel is the `_prune_blobs` helper function used by
- `cucim.skimage.feature.blob_dog`
- `cucim.skimage.feature.blob_doh`
- `cucim.skimage.feature.blob_log`

we can check if that kernel or the CuPy KDTree implementation is preferable.

If CuPy KDTree performance is good we can add implementation for the following scikit-image functions that primarily rely on it
- `skimage.metrics.hausdorff_distance`
- `skimage.metrics.hausdorff_pair`
- `skimage.transform.hough_circle_peaks`

Implement upstream changes from scikit-image 0.23 (part 1 of 2)
This MR updates cuCIM for consistency with version scikit-image `0.23.2rc1`. There were a relatively large number of changes in the `morphology` module, so I am going to submit those in a subsequent MR. I will also bump the scikit-image version pinning as part of that second MR.

The commits in this MR can be reviewed individually. Much of the new code is private functions in `cucim.skimage._shared` that are copied from upstream and don't need to be reviewed in detail.

The highlights are:

- `cucim.skimage.measure.regionprops` (and `regionprops_table`) support one new region property: `intensity_std` 
- `cucim.skimage.segmentation.expand_labels` now supports a `spacing` keyword argument to take a pixel's physical dimensions into account.
- additional modules use `__init__.pyi` instead of just `__init__.py`
- various fixes to documentation strings (consistent shape notation, etc.)
- adopted the private `_shared/compat.py` for future NumPy 2.0/SciPy compatibility
- adopted private test utility `assert_stacklevel`
- updated the private utility functions for handling deprecations to match those upstream
  - update existing decorators to the new ones
  - new rename of output->out for `cucim.skimage.filters.gaussian`
 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index c86856daa..17a953717 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -28,7 +28,7 @@ concurrency:
 jobs:
   cpp-build:
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/conda-cpp-build.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/conda-cpp-build.yaml@branch-24.06
     with:
       build_type: ${{ inputs.build_type || 'branch' }}
       branch: ${{ inputs.branch }}
@@ -38,7 +38,7 @@ jobs:
     if: github.ref_type == 'branch'
     needs: [python-build]
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@branch-24.06
     with:
       arch: "amd64"
       branch: ${{ inputs.branch }}
@@ -51,7 +51,7 @@ jobs:
   python-build:
     needs: [cpp-build]
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/conda-python-build.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/conda-python-build.yaml@branch-24.06
     with:
       build_type: ${{ inputs.build_type || 'branch' }}
       branch: ${{ inputs.branch }}
@@ -60,7 +60,7 @@ jobs:
   upload-conda:
     needs: [cpp-build, python-build]
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/conda-upload-packages.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/conda-upload-packages.yaml@branch-24.06
     with:
       build_type: ${{ inputs.build_type || 'branch' }}
       branch: ${{ inputs.branch }}
@@ -68,7 +68,7 @@ jobs:
       sha: ${{ inputs.sha }}
   wheel-build:
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/wheels-build.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/wheels-build.yaml@branch-24.06
     with:
       build_type: ${{ inputs.build_type || 'branch' }}
       branch: ${{ inputs.branch }}
@@ -78,7 +78,7 @@ jobs:
   wheel-publish:
     needs: wheel-build
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/wheels-publish.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/wheels-publish.yaml@branch-24.06
     with:
       build_type: ${{ inputs.build_type || 'branch' }}
       branch: ${{ inputs.branch }}
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index f580782b8..7b686cd7f 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -20,32 +20,32 @@ jobs:
       - wheel-build
       - wheel-tests
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/pr-builder.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/pr-builder.yaml@branch-24.06
   checks:
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/checks.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/checks.yaml@branch-24.06
   conda-cpp-build:
     needs: checks
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/conda-cpp-build.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/conda-cpp-build.yaml@branch-24.06
     with:
       build_type: pull-request
   conda-python-build:
     needs: conda-cpp-build
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/conda-python-build.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/conda-python-build.yaml@branch-24.06
     with:
       build_type: pull-request
   conda-python-tests:
     needs: conda-python-build
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/conda-python-tests.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/conda-python-tests.yaml@branch-24.06
     with:
       build_type: pull-request
   docs-build:
     needs: conda-python-build
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@branch-24.06
     with:
       build_type: pull-request
       node_type: "gpu-v100-latest-1"
@@ -55,14 +55,14 @@ jobs:
   wheel-build:
     needs: checks
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/wheels-build.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/wheels-build.yaml@branch-24.06
     with:
       build_type: pull-request
       script: ci/build_wheel.sh
   wheel-tests:
     needs: wheel-build
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@branch-24.06
     with:
       build_type: pull-request
       script: ci/test_wheel.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de31f9ddf..7bc436634 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,44 @@
+# cucim 24.06.00 (5 Jun 2024)
+
+## 🚨 Breaking Changes
+
+- The `output` argument of `cucim.skimage.filters.gaussian` has been renamed to `out`. The old name is deprecated and will be removed in release 25.02 (#727)
+- Renamed `get_xyz_coords` function is now removed (use `skimage.color.xyz_tristimulus_values` instead) (#724)
+- Removed deprecated `return_error` kwarg from `phase_cross_correlation` (the error is now always returned) (#724)
+- Removed deprecated `random_state` kwarg from `medial_axis` (it was renamed to `rng` previously) (#724)
+
+## 🐛 Bug Fixes
+
+- Use SciPy&#39;s KDTree instead of deprecated cKDTree ([#733](https://github.com/rapidsai/cucim/pull/733)) [@grlee77](https://github.com/grlee77)
+- Binary and grayscale morphology functions have bug fixes in the case of even-sized/non-symmetric footprints (for details see upstream MR: https://github.com/scikit-image/scikit-image/pull/6695) (#728)
+
+## 🚀 New Features
+
+- `cucim.skimage.measure.regionprops` (and `regionprops_table`) support one new region property: `intensity_std` (#727)
+- `cucim.skimage.segmentation.expand_labels` now supports a `spacing` keyword argument to take a pixel's physical dimensions into account (#727)
+- binary morphology functions have a new `mode` argument that controls how values outside the image boundaries are interpreted (#728)
+- grayscale morphology functions have new `mode` and `cval` arguments that control how boundaries are extended (#728)
+
+## 🛠️ Improvements
+
+- Enable FutureWarnings/DeprecationWarnings as errors ([#734](https://github.com/rapidsai/cucim/pull/734)) [@mroeschke](https://github.com/mroeschke)
+- Migrate to `{{ stdlib(&quot;c&quot;) }}` ([#731](https://github.com/rapidsai/cucim/pull/731)) [@hcho3](https://github.com/hcho3)
+- Implement upstream changes from scikit-image 0.23 (part 2 of 2: morphology) ([#728](https://github.com/rapidsai/cucim/pull/728)) [@grlee77](https://github.com/grlee77)
+- Implement upstream changes from scikit-image 0.23 (part 1 of 2) ([#727](https://github.com/rapidsai/cucim/pull/727)) [@grlee77](https://github.com/grlee77)
+- Update the test criteria for test_read_random_region_cpu_memleak ([#726](https://github.com/rapidsai/cucim/pull/726)) [@gigony](https://github.com/gigony)
+- Remove code needed to support Python &lt; 3.9 and apply ruff&#39;s pyupgrade rules ([#725](https://github.com/rapidsai/cucim/pull/725)) [@grlee77](https://github.com/grlee77)
+- removal of deprecated functions/kwargs scheduled for release 24.06 ([#724](https://github.com/rapidsai/cucim/pull/724)) [@grlee77](https://github.com/grlee77)
+- Enable all tests for `arm` jobs ([#717](https://github.com/rapidsai/cucim/pull/717)) [@galipremsagar](https://github.com/galipremsagar)
+- prevent path conflict ([#713](https://github.com/rapidsai/cucim/pull/713)) [@AyodeAwe](https://github.com/AyodeAwe)
+- Updated cuCIM APIs for consistency with scikit-image 0.23.2 (#727 and #728)
+- Additional modules use `__init__.pyi` instead of just `__init__.py` (#727)
+- Some grayscale tests now compare directly to `skimage` CPU outputs instead fetching previously saved values (#728)
+- Refactored some test cases to better use `pytest.mark.parametrize` (#728)
+- Bumped version pinning for scikit-image to allow 0.23.x to be installed (#728)
+
+## 📖 Documentation
+- Various fixes to documentation strings (consistent shape notation, etc.) (#727)
+
 # cuCIM 24.04.00 (10 Apr 2024)
 
 ## 🐛 Bug Fixes
diff --git a/VERSION b/VERSION
index 4a2fe8aa5..0bff6981a 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-24.04.00
+24.06.00
diff --git a/benchmarks/skimage/cucim_morphology_bench.py b/benchmarks/skimage/cucim_morphology_bench.py
index aeab0e142..68c5aeefc 100644
--- a/benchmarks/skimage/cucim_morphology_bench.py
+++ b/benchmarks/skimage/cucim_morphology_bench.py
@@ -151,7 +151,7 @@ def main(args):
         # _skeletonize.py
         (
             "medial_axis",
-            dict(random_state=123),
+            dict(rng=123),
             dict(return_distance=[False, True]),
             False,
             False,
diff --git a/ci/build_cpp.sh b/ci/build_cpp.sh
index 0ecf3d5bb..28d8644e0 100755
--- a/ci/build_cpp.sh
+++ b/ci/build_cpp.sh
@@ -17,6 +17,8 @@ version=$(rapids-generate-version)
 
 rapids-logger "Begin cpp build"
 
+conda config --set path_conflict prevent
+
 RAPIDS_PACKAGE_VERSION=${version} rapids-conda-retry mambabuild conda/recipes/libcucim
 
 rapids-upload-conda-to-s3 cpp
diff --git a/ci/build_python.sh b/ci/build_python.sh
index 35d3ed08c..684ce04b7 100755
--- a/ci/build_python.sh
+++ b/ci/build_python.sh
@@ -25,6 +25,7 @@ echo "${version}" > VERSION
 sed -i "/^__git_commit__/ s/= .*/= \"${commit}\"/g" "${package_src_dir}/_version.py"
 
 rapids-logger "Begin py build"
+conda config --set path_conflict prevent
 
 CPP_CHANNEL=$(rapids-download-conda-from-s3 cpp)
 
diff --git a/conda/environments/all_cuda-118_arch-x86_64.yaml b/conda/environments/all_cuda-118_arch-x86_64.yaml
index 2b6ff2f01..5a8e835a2 100644
--- a/conda/environments/all_cuda-118_arch-x86_64.yaml
+++ b/conda/environments/all_cuda-118_arch-x86_64.yaml
@@ -43,8 +43,8 @@ dependencies:
 - python>=3.8,<3.12
 - pywavelets>=1.0
 - recommonmark
-- scikit-image>=0.19.0,<0.23.0a0
-- scipy
+- scikit-image>=0.19.0,<0.24.0a0
+- scipy>=1.6.0
 - sphinx<6
 - sysroot_linux-64==2.17
 - tifffile>=2022.7.28
diff --git a/conda/environments/all_cuda-122_arch-x86_64.yaml b/conda/environments/all_cuda-122_arch-x86_64.yaml
index 0607c83db..0aa83182a 100644
--- a/conda/environments/all_cuda-122_arch-x86_64.yaml
+++ b/conda/environments/all_cuda-122_arch-x86_64.yaml
@@ -42,8 +42,8 @@ dependencies:
 - python>=3.8,<3.12
 - pywavelets>=1.0
 - recommonmark
-- scikit-image>=0.19.0,<0.23.0a0
-- scipy
+- scikit-image>=0.19.0,<0.24.0a0
+- scipy>=1.6.0
 - sphinx<6
 - sysroot_linux-64==2.17
 - tifffile>=2022.7.28
diff --git a/conda/recipes/cucim/conda_build_config.yaml b/conda/recipes/cucim/conda_build_config.yaml
index fb04ce5c8..8a1b64ec6 100644
--- a/conda/recipes/cucim/conda_build_config.yaml
+++ b/conda/recipes/cucim/conda_build_config.yaml
@@ -10,5 +10,8 @@ cuda_compiler:
 cuda11_compiler:
   - nvcc
 
-sysroot_version:
+c_stdlib:
+  - sysroot
+
+c_stdlib_version:
   - "2.17"
diff --git a/conda/recipes/cucim/meta.yaml b/conda/recipes/cucim/meta.yaml
index 61d85d080..dbacd8015 100644
--- a/conda/recipes/cucim/meta.yaml
+++ b/conda/recipes/cucim/meta.yaml
@@ -54,7 +54,7 @@ requirements:
     - cmake >=3.23.1,!=3.25.0
     - make
     - ninja
-    - sysroot_{{ target_platform }} {{ sysroot_version }}
+    - {{ stdlib("c") }}
   host:
     - click
     - cuda-version ={{ cuda_version }}
@@ -66,7 +66,7 @@ requirements:
     - numpy 1.23
     - python
     - scikit-image >=0.19.0,<0.23.0a0
-    - scipy
+    - scipy >=1.6
   run:
     - {{ pin_compatible('cuda-version', max_pin='x', min_pin='x') }}
     {% if cuda_major != "11" %}
@@ -79,7 +79,7 @@ requirements:
     - libcucim ={{ version }}
     - python
     - scikit-image >=0.19.0,<0.23.0a0
-    - scipy
+    - scipy >=1.6
   run_constrained:
     - openslide-python >=1.3.0
 
diff --git a/conda/recipes/libcucim/conda_build_config.yaml b/conda/recipes/libcucim/conda_build_config.yaml
index 6e58b05e2..d8a71327a 100644
--- a/conda/recipes/libcucim/conda_build_config.yaml
+++ b/conda/recipes/libcucim/conda_build_config.yaml
@@ -10,7 +10,10 @@ cuda_compiler:
 cuda11_compiler:
   - nvcc
 
-sysroot_version:
+c_stdlib:
+  - sysroot
+
+c_stdlib_version:
   - "2.17"
 
 # The CTK libraries below are missing from the conda-forge::cudatoolkit package
diff --git a/conda/recipes/libcucim/meta.yaml b/conda/recipes/libcucim/meta.yaml
index e2a9b2c78..5d24c368f 100644
--- a/conda/recipes/libcucim/meta.yaml
+++ b/conda/recipes/libcucim/meta.yaml
@@ -59,7 +59,7 @@ requirements:
     - cmake >=3.23.1,!=3.25.0
     - make
     - ninja
-    - sysroot_{{ target_platform }} {{ sysroot_version }}
+    - {{ stdlib("c") }}
     - yasm # [x86_64]
   host:
     - cuda-version ={{ cuda_version }}
diff --git a/cucim.code-workspace b/cucim.code-workspace
index a1da8399c..099b210fe 100644
--- a/cucim.code-workspace
+++ b/cucim.code-workspace
@@ -33,7 +33,7 @@
 					"CUCIM_TESTDATA_FOLDER": "${workspaceDirectory}/test_data",
 					// Add cuslide plugin's library path to LD_LIBRARY_PATH
 					"LD_LIBRARY_PATH": "${workspaceDirectory}/build-debug/lib:${workspaceDirectory}/cpp/plugins/cucim.kit.cuslide/build-debug/lib:${workspaceDirectory}/temp/cuda/lib64:${os_env:LD_LIBRARY_PATH}",
-					"CUCIM_TEST_PLUGIN_PATH": "cucim.kit.cuslide@24.04.00.so"
+					"CUCIM_TEST_PLUGIN_PATH": "cucim.kit.cuslide@24.06.00.so"
 				},
 				"cwd": "${workspaceDirectory}",
 				"catch2": {
@@ -226,7 +226,7 @@
 					},
 					{
 						"name": "CUCIM_TEST_PLUGIN_PATH",
-						"value": "cucim.kit.cuslide@24.04.00.so"
+						"value": "cucim.kit.cuslide@24.06.00.so"
 					}
 				],
 				"console": "externalTerminal",
@@ -254,7 +254,7 @@
 					},
 					{
 						"name": "CUCIM_TEST_PLUGIN_PATH",
-						"value": "cucim.kit.cuslide@24.04.00.so"
+						"value": "cucim.kit.cuslide@24.06.00.so"
 					}
 				],
 				"console": "externalTerminal",
@@ -286,7 +286,7 @@
 					},
 					{
 						"name": "CUCIM_TEST_PLUGIN_PATH",
-						"value": "cucim.kit.cuslide@24.04.00.so"
+						"value": "cucim.kit.cuslide@24.06.00.so"
 					}
 				],
 				"console": "externalTerminal",
diff --git a/dependencies.yaml b/dependencies.yaml
index 703cb70a3..d69b3cd75 100644
--- a/dependencies.yaml
+++ b/dependencies.yaml
@@ -247,8 +247,8 @@ dependencies:
           - click
           - lazy_loader>=0.1
           - numpy>=1.23.4,<2.0a0
-          - scikit-image>=0.19.0,<0.23.0a0
-          - scipy
+          - scikit-image>=0.19.0,<0.24.0a0
+          - scipy>=1.6.0
       - output_types: conda
         packages:
           - cupy>=12.0.0
diff --git a/python/cucim/pyproject.toml b/python/cucim/pyproject.toml
index 433afc0a5..b63ef7cdc 100644
--- a/python/cucim/pyproject.toml
+++ b/python/cucim/pyproject.toml
@@ -28,8 +28,8 @@ dependencies = [
     "cupy-cuda11x>=12.0.0",
     "lazy_loader>=0.1",
     "numpy>=1.23.4,<2.0a0",
-    "scikit-image>=0.19.0,<0.23.0a0",
-    "scipy",
+    "scikit-image>=0.19.0,<0.24.0a0",
+    "scipy>=1.6.0",
 ] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`.
 classifiers = [
     "Development Status :: 4 - Beta",
@@ -129,10 +129,16 @@ testpaths = [
     "src",
     "tests",
 ]
+filterwarnings = [
+    "error::FutureWarning",
+    "error::DeprecationWarning",
+    # https://github.com/cupy/cupy/blob/main/cupy/testing/_helper.py#L56
+    "ignore:pkg_resources is deprecated as an API:DeprecationWarning",
+]
 
 [tool.ruff]
 # see: https://docs.astral.sh/ruff/rules/
-select = ["E", "F", "W", "I"]
+select = ["E", "F", "W", "I", "UP"]
 fixable = ["ALL"]
 exclude = [
     # TODO: Remove this in a follow-up where we fix __all__.
diff --git a/python/cucim/src/cucim/_misc.py b/python/cucim/src/cucim/_misc.py
index fae77a7cc..bf0d8ffd0 100644
--- a/python/cucim/src/cucim/_misc.py
+++ b/python/cucim/src/cucim/_misc.py
@@ -1,14 +1,7 @@
 """Misc utility functions that are not from SciPy, NumPy or scikit-image.
 
 """
-import math
-
-import numpy
-
-if hasattr(math, "prod"):
-    prod = math.prod  # available in Python 3.8+ only
-else:
-    prod = numpy.prod
+import numpy as np
 
 
 def ndim(a):
@@ -46,4 +39,4 @@ def ndim(a):
     try:
         return a.ndim
     except AttributeError:
-        return numpy.asarray(a).ndim
+        return np.asarray(a).ndim
diff --git a/python/cucim/src/cucim/clara/converter/tiff.py b/python/cucim/src/cucim/clara/converter/tiff.py
index 801818305..d9019fb19 100644
--- a/python/cucim/src/cucim/clara/converter/tiff.py
+++ b/python/cucim/src/cucim/clara/converter/tiff.py
@@ -73,11 +73,12 @@ def svs2tif(
     logger.info("      num_workers: %d", num_workers)
     logger.info("      compression: %s", compression)
     logger.info("  output filename: %s", output_filename)
-
+    compressionargs = None
     if compression is not None:
         # handles only jpeg or None (no compression)
         if compression.lower() == "jpeg":
-            compression = ("jpeg", 95)
+            compression = "jpeg"
+            compressionargs = {"level": 95}
         else:
             raise ValueError(
                 f"Unsupported compression: {compression}."
@@ -98,7 +99,7 @@ def svs2tif(
         width, height = slide_dimensions
         img_w, img_h = width, height
         for level in range(tiles.level_count):
-            memmap_filename = Path(output_folder, "level{}.mmap".format(level))
+            memmap_filename = Path(output_folder, f"level{level}.mmap")
             memmap_shape = (img_h, img_w, 3)
             np_memmap_arr = np.memmap(
                 memmap_filename, dtype=np.uint8, mode="w+", shape=memmap_shape
@@ -198,9 +199,10 @@ def svs2tif(
                         resolution=(
                             x_resolution // 2**level,
                             y_resolution // 2**level,
-                            resolution_unit,
                         ),
+                        resolutionunit=resolution_unit,
                         compression=compression,  # requires imagecodecs
+                        compressionargs=compressionargs,
                         subfiletype=subfiletype,
                     )
                 logger.info("Done.")
diff --git a/python/cucim/src/cucim/core/operations/color/jitter.py b/python/cucim/src/cucim/core/operations/color/jitter.py
index 4d3f47b5b..67b70dfc2 100755
--- a/python/cucim/src/cucim/core/operations/color/jitter.py
+++ b/python/cucim/src/cucim/core/operations/color/jitter.py
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 import numbers
-from typing import Any, List, Optional, Tuple
+from typing import Any, Optional
 
 import cupy
 import numpy as np
@@ -29,25 +29,19 @@ def _check_input(
     if isinstance(value, numbers.Number):
         if value < 0:
             raise ValueError(
-                "If {} is a single number, \
-                             it must be non negative.".format(
-                    name
-                )
+                f"If {name} is a single number, \
+                             it must be non negative."
             )
         value = [center - float(value), center + float(value)]
         if clip_first_on_zero:
             value[0] = max(value[0], 0.0)
     elif isinstance(value, (tuple, list)) and len(value) == 2:
         if not bound[0] <= value[0] <= value[1] <= bound[1]:
-            raise ValueError(
-                "{} values should be between {}".format(name, bound)
-            )
+            raise ValueError(f"{name} values should be between {bound}")
     else:
         raise TypeError(
-            "{} should be a single number or a \
-                        list/tuple with length 2.".format(
-                name
-            )
+            f"{name} should be a single number or a \
+                        list/tuple with length 2."
         )
     # if value is 0 or (1., 1.) for brightness/contrast/saturation
     # or (0., 0.) for hue, do nothing
@@ -57,11 +51,11 @@ def _check_input(
 
 
 def _get_params(
-    brightness: Optional[List[float]],
-    contrast: Optional[List[float]],
-    saturation: Optional[List[float]],
-    hue: Optional[List[float]],
-) -> Tuple[
+    brightness: Optional[list[float]],
+    contrast: Optional[list[float]],
+    saturation: Optional[list[float]],
+    hue: Optional[list[float]],
+) -> tuple[
     np.ndarray,
     Optional[float],
     Optional[float],
@@ -184,7 +178,7 @@ def _adjust_saturation(input_arr, saturation):
 # hue jitter
 def _adjust_hue(input_arr, hue):
     if not (-0.5 <= hue <= 0.5):
-        raise ValueError("hue factor({}) is not in [-0.5, 0.5].".format(hue))
+        raise ValueError(f"hue factor({hue}) is not in [-0.5, 0.5].")
 
     if len(input_arr.shape) == 4:
         N, C, H, W = input_arr.shape
diff --git a/python/cucim/src/cucim/core/operations/intensity/normalize.py b/python/cucim/src/cucim/core/operations/intensity/normalize.py
index 6e283a776..466607fda 100644
--- a/python/cucim/src/cucim/core/operations/intensity/normalize.py
+++ b/python/cucim/src/cucim/core/operations/intensity/normalize.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import math
 from typing import Any
 
 import cupy
@@ -108,7 +109,7 @@ def normalize_data(
     value_range = max_value - min_value
     norm_factor = norm_constant / value_range
 
-    total_size = int(np.prod(img.shape))
+    total_size = int(math.prod(img.shape))
     blockx = 128
     gridx = int((total_size - 1) / blockx + 1)
 
diff --git a/python/cucim/src/cucim/core/operations/intensity/scaling.py b/python/cucim/src/cucim/core/operations/intensity/scaling.py
index c875cdad2..8b5b5ec63 100755
--- a/python/cucim/src/cucim/core/operations/intensity/scaling.py
+++ b/python/cucim/src/cucim/core/operations/intensity/scaling.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import math
 from typing import Any
 
 import cupy
@@ -100,7 +101,7 @@ def scale_intensity_range(
         b_min = float("-inf")
 
     sh = img.shape
-    total_size = np.prod(sh)
+    total_size = math.prod(sh)
     blockx = 128
     gridx = int((total_size - 1) / blockx + 1)
 
diff --git a/python/cucim/src/cucim/core/operations/intensity/zoom.py b/python/cucim/src/cucim/core/operations/intensity/zoom.py
index d01c866f1..3312a0638 100644
--- a/python/cucim/src/cucim/core/operations/intensity/zoom.py
+++ b/python/cucim/src/cucim/core/operations/intensity/zoom.py
@@ -13,7 +13,8 @@
 # limitations under the License.
 
 import math
-from typing import Any, Sequence, Union
+from collections.abc import Sequence
+from typing import Any, Union
 
 import cupy
 import numpy as np
diff --git a/python/cucim/src/cucim/core/operations/morphology/_distance_transform.py b/python/cucim/src/cucim/core/operations/morphology/_distance_transform.py
index 1ff30d952..27a3b2659 100644
--- a/python/cucim/src/cucim/core/operations/morphology/_distance_transform.py
+++ b/python/cucim/src/cucim/core/operations/morphology/_distance_transform.py
@@ -157,7 +157,7 @@ def distance_transform_edt(
         if len(unique_sampling) == 1:
             # In the isotropic case, can use the kernels without sample scaling
             # and just adjust the final distance accordingly.
-            scalar_sampling = float(unique_sampling)
+            scalar_sampling = float(unique_sampling.item())
             sampling = None
 
     if image.ndim == 3:
diff --git a/python/cucim/src/cucim/core/operations/morphology/_pba_2d.py b/python/cucim/src/cucim/core/operations/morphology/_pba_2d.py
index 4d1f9965d..b206c3015 100644
--- a/python/cucim/src/cucim/core/operations/morphology/_pba_2d.py
+++ b/python/cucim/src/cucim/core/operations/morphology/_pba_2d.py
@@ -1,6 +1,4 @@
-import functools
 import math
-import numbers
 import os
 
 import cupy
@@ -8,34 +6,6 @@
 from cucim.skimage._vendored import pad
 from cucim.skimage._vendored._ndimage_util import _get_inttype
 
-try:
-    # math.lcm was introduced in Python 3.9
-    from math import lcm
-except ImportError:
-    """Fallback implementation of least common multiple (lcm)
-
-    TODO: remove once minimum Python requirement is >= 3.9
-    """
-
-    def _lcm(a, b):
-        return abs(b * (a // math.gcd(a, b)))
-
-    @functools.lru_cache()
-    def lcm(*args):
-        nargs = len(args)
-        if not all(isinstance(a, numbers.Integral) for a in args):
-            raise TypeError("all arguments must be integers")
-        if nargs == 0:
-            return 1
-        res = int(args[0])
-        if nargs == 1:
-            return abs(res)
-        for i in range(1, nargs):
-            x = int(args[i])
-            res = _lcm(res, x)
-        return res
-
-
 pba2d_defines_template = """
 
 // MARKER is used to mark blank pixels in the texture.
@@ -76,7 +46,7 @@ def get_pba2d_src(block_size_2d=64, marker=-32768, pixel_int2_t="short2"):
         make_pixel_func=make_pixel_func,
     )
     kernel_directory = os.path.join(os.path.dirname(__file__), "cuda")
-    with open(os.path.join(kernel_directory, "pba_kernels_2d.h"), "rt") as f:
+    with open(os.path.join(kernel_directory, "pba_kernels_2d.h")) as f:
         pba2d_kernels = "\n".join(f.readlines())
 
     pba2d_code += pba2d_kernels
@@ -312,7 +282,7 @@ def _pba_2d(
         m1, m2, m3 = block_params
         if math.log2(m2) % 1 > 1e-5:
             raise ValueError("m2 must be a power of 2")
-        multiple = lcm(block_size, m1, m2, m3)
+        multiple = math.lcm(block_size, m1, m2, m3)
         padded_size = math.ceil(max(arr.shape) / multiple) * multiple
 
     if m1 > padded_size // block_size:
diff --git a/python/cucim/src/cucim/core/operations/morphology/_pba_3d.py b/python/cucim/src/cucim/core/operations/morphology/_pba_3d.py
index 142bd4a71..377a1f899 100644
--- a/python/cucim/src/cucim/core/operations/morphology/_pba_3d.py
+++ b/python/cucim/src/cucim/core/operations/morphology/_pba_3d.py
@@ -14,7 +14,6 @@
     _generate_indices_ops,
     _generate_shape,
     _get_block_size,
-    lcm,
 )
 
 pba3d_defines_template = """
@@ -85,7 +84,7 @@ def get_pba3d_src(
     else:
         pba3d_code += pba3d_defines_encode_32bit
     kernel_directory = os.path.join(os.path.dirname(__file__), "cuda")
-    with open(os.path.join(kernel_directory, "pba_kernels_3d.h"), "rt") as f:
+    with open(os.path.join(kernel_directory, "pba_kernels_3d.h")) as f:
         pba3d_kernels = "\n".join(f.readlines())
     pba3d_code += pba3d_kernels
     return pba3d_code
@@ -188,7 +187,7 @@ def decode3d(encoded, size_max=1024):
 def _determine_padding(shape, block_size, m1, m2, m3, blockx, blocky):
     # TODO: can possibly revise to consider only particular factors for LCM on
     #       a given axis
-    LCM = lcm(block_size, m1, m2, m3, blockx, blocky)
+    LCM = math.lcm(block_size, m1, m2, m3, blockx, blocky)
     orig_sz, orig_sy, orig_sx = shape
     round_up = False
     if orig_sx % LCM != 0:
diff --git a/python/cucim/src/cucim/skimage/_shared/compat.py b/python/cucim/src/cucim/skimage/_shared/compat.py
new file mode 100644
index 000000000..eb17c6202
--- /dev/null
+++ b/python/cucim/src/cucim/skimage/_shared/compat.py
@@ -0,0 +1,30 @@
+"""Compatibility helpers for dependencies."""
+
+import numpy as np
+from packaging.version import parse
+
+__all__ = [
+    "NP_COPY_IF_NEEDED",
+    "SCIPY_CG_TOL_PARAM_NAME",
+]
+
+
+NUMPY_LT_2_0_0 = parse(np.__version__) < parse("2.0.0.dev0")
+
+# With NumPy 2.0.0, `copy=False` now raises a ValueError if the copy cannot be
+# made. The previous behavior to only copy if needed is provided with
+# `copy=None`. During the transition period, use this symbol instead.
+# Remove once NumPy 2.0.0 is the minimal required version.
+# https://numpy.org/devdocs/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors  # noqa: E501
+# https://github.com/numpy/numpy/pull/25168
+NP_COPY_IF_NEEDED = False if NUMPY_LT_2_0_0 else None
+
+
+# check CuPy instead of SciPy
+# as of CuPy 13.0, tol is still being used instead of rtol as in latest SciPy
+# CUPY_LT_14 = parse(cp.__version__) < parse("14.0")
+
+# Starting in SciPy v1.12, 'scipy.sparse.linalg.cg' keyword argument `tol` is
+# deprecated in favor of `rtol`.
+# As of CuPy 13.0, it is still always using 'tol''
+SCIPY_CG_TOL_PARAM_NAME = "tol"  # if CUPY_LT_14 else "rtol"
diff --git a/python/cucim/src/cucim/skimage/_shared/coord.py b/python/cucim/src/cucim/skimage/_shared/coord.py
index f95ddd2aa..5ee981bd8 100644
--- a/python/cucim/src/cucim/skimage/_shared/coord.py
+++ b/python/cucim/src/cucim/skimage/_shared/coord.py
@@ -1,9 +1,9 @@
 import cupy as cp
 import numpy as np
-from scipy.spatial import cKDTree, distance
+from scipy.spatial import KDTree, distance
 
 
-# TODO: avoid host/device transfers (currently needed for cKDTree)
+# TODO: avoid host/device transfers (currently needed for KDTree)
 def _ensure_spacing(coord, spacing, p_norm, max_out):
     """Returns a subset of coord where a minimum spacing is guaranteed.
 
@@ -30,7 +30,7 @@ def _ensure_spacing(coord, spacing, p_norm, max_out):
     """
 
     # Use KDtree to find the peaks that are too close to each other
-    tree = cKDTree(coord)
+    tree = KDTree(coord)
 
     indices = tree.query_ball_point(coord, r=spacing, p=p_norm)
     rejected_peaks_indices = set()
@@ -106,6 +106,8 @@ def ensure_spacing(
     if len(coords):
         coords = cp.atleast_2d(coords)
         coords = cp.asnumpy(coords)
+        if not np.isscalar(spacing):
+            spacing = cp.asnumpy(spacing)
         if min_split_size is None:
             batch_list = [coords]
         else:
diff --git a/python/cucim/src/cucim/skimage/_shared/filters.py b/python/cucim/src/cucim/skimage/_shared/filters.py
index fe4c000ec..3eeb72774 100644
--- a/python/cucim/src/cucim/skimage/_shared/filters.py
+++ b/python/cucim/src/cucim/skimage/_shared/filters.py
@@ -10,34 +10,40 @@
 
 import cucim.skimage._vendored.ndimage as ndi
 
-from .._shared.utils import _supported_float_type, convert_to_float
+from .._shared.utils import (
+    DEPRECATED,
+    _supported_float_type,
+    convert_to_float,
+    deprecate_parameter,
+)
 
 
+@deprecate_parameter(
+    "output", new_name="out", start_version="24.06", stop_version="25.02"
+)
 def gaussian(
     image,
     sigma=1,
-    output=None,
+    output=DEPRECATED,
     mode="nearest",
     cval=0,
     preserve_range=False,
     truncate=4.0,
     *,
     channel_axis=None,
+    out=None,
 ):
     """Multi-dimensional Gaussian filter.
 
     Parameters
     ----------
-    image : array-like
+    image : ndarray
         Input image (grayscale or color) to filter.
     sigma : scalar or sequence of scalars, optional
         Standard deviation for Gaussian kernel. The standard
         deviations of the Gaussian filter are given for each axis as a
         sequence, or as a single number, in which case it is equal for
         all axes.
-    output : array, optional
-        The ``output`` parameter passes an array in which to store the
-        filter output.
     mode : {'reflect', 'constant', 'nearest', 'mirror', 'wrap'}, optional
         The ``mode`` parameter determines how the array borders are
         handled, where ``cval`` is the value when mode is equal to
@@ -57,11 +63,14 @@ def gaussian(
         Otherwise, this parameter indicates which axis of the array corresponds
         to channels.
 
-        .. warning::
-            In versions prior to 24.02, the last axis of an `image` with
-            dimensions (M, N, 3) was interpreted as a color channel if
-            `channel_axis` was not set. Starting with release 24.02,
-            ``channel_axis=None`` will be used as the new default value.
+        .. versionadded:: 24.02
+          `channel_axis` was added in 24.02`
+    out : ndarray, optional
+        If given, the filtered image will be stored in this array. It must have
+        a floating point data type.
+
+        .. versionadded:: 24.06
+            `out` was added in 24.06`
 
     Returns
     -------
@@ -74,8 +83,8 @@ def gaussian(
 
     Integer arrays are converted to float.
 
-    The ``output`` should be floating point data type since gaussian converts
-    to float provided ``image``. If ``output`` is not provided, another array
+    ``out`` should be of floating point data type since `gaussian` converts the
+    input `image` to float. If `out` is not provided, another array
     will be allocated and returned as the result.
 
     The multi-dimensional filter is implemented as a sequence of
@@ -89,29 +98,30 @@ def gaussian(
     --------
 
     >>> import cupy as cp
+    >>> from cucim import skimage as ski
     >>> a = cp.zeros((3, 3))
     >>> a[1, 1] = 1
     >>> a
     array([[0., 0., 0.],
            [0., 1., 0.],
            [0., 0., 0.]])
-    >>> gaussian(a, sigma=0.4)  # mild smoothing
+    >>> ski.filters.gaussian(a, sigma=0.4)  # mild smoothing
     array([[0.00163116, 0.03712502, 0.00163116],
            [0.03712502, 0.84496158, 0.03712502],
            [0.00163116, 0.03712502, 0.00163116]])
-    >>> gaussian(a, sigma=1)  # more smoothing
+    >>> ski.filters.gaussian(a, sigma=1)  # more smoothing
     array([[0.05855018, 0.09653293, 0.05855018],
            [0.09653293, 0.15915589, 0.09653293],
            [0.05855018, 0.09653293, 0.05855018]])
     >>> # Several modes are possible for handling boundaries
-    >>> gaussian(a, sigma=1, mode='reflect')
+    >>> ski.filters.gaussian(a, sigma=1, mode='reflect')
     array([[0.08767308, 0.12075024, 0.08767308],
            [0.12075024, 0.16630671, 0.12075024],
            [0.08767308, 0.12075024, 0.08767308]])
     >>> # For RGB images, each is filtered separately
     >>> from skimage.data import astronaut
     >>> image = cp.array(astronaut())
-    >>> filtered_img = gaussian(image, sigma=1, channel_axis=-1)
+    >>> filtered_img = ski.filters.gaussian(image, sigma=1, channel_axis=-1)
     """
 
     # CuPy Backend: refactor to avoid overhead of cp.any(cp.asarray(sigma))
@@ -132,11 +142,23 @@ def gaussian(
     image = convert_to_float(image, preserve_range)
     float_dtype = _supported_float_type(image.dtype)
     image = image.astype(float_dtype, copy=False)
-    if output is None:
-        output = cp.empty_like(image)
-    elif not cp.issubdtype(output.dtype, cp.floating):
-        raise ValueError("Provided output data type is not float")
+    if out is None:
+        out = cp.empty_like(image)
+    elif not cp.issubdtype(out.dtype, cp.floating):
+        raise ValueError(
+            f"dtype of `out` must be floating point; got {out.dtype!r}."
+        )
+    # ndi.gaussian_filter cannot do in-place filtering, must make a copy
+    out_copied = False
+    if out is image:
+        out_img = image.copy()
+        out_copied = True
+    else:
+        out_img = out
     ndi.gaussian_filter(
-        image, sigma, output=output, mode=mode, cval=cval, truncate=truncate
+        image, sigma, output=out_img, mode=mode, cval=cval, truncate=truncate
     )
-    return output
+    if out_img is not None and out_copied:
+        image[...] = out_img
+
+    return out
diff --git a/python/cucim/src/cucim/skimage/_shared/utils.py b/python/cucim/src/cucim/skimage/_shared/utils.py
index 83ab5d204..d5da200b6 100644
--- a/python/cucim/src/cucim/skimage/_shared/utils.py
+++ b/python/cucim/src/cucim/skimage/_shared/utils.py
@@ -1,6 +1,7 @@
 import functools
 import inspect
 import numbers
+import sys
 import warnings
 from collections.abc import Iterable
 
@@ -10,34 +11,72 @@
 from ._warnings import all_warnings, warn  # noqa
 
 __all__ = [
-    "deprecate_func",
-    "get_bound_method_class",
     "all_warnings",
-    "safe_as_int",
-    "check_shape_equality",
+    "channel_as_last_axis",
     "check_nD",
-    "warn",
-    "reshape_nd",
+    "check_shape_equality",
+    "deprecate_func",
+    "deprecate_parameter",
+    "DEPRECATED",
+    "get_bound_method_class",
     "identity",
+    "reshape_nd",
+    "safe_as_int",
     "slice_at_axis",
+    "warn",
 ]
 
 
-def _get_stack_rank(func):
-    """Return function rank in the call stack."""
-    if _is_wrapped(func):
-        return 1 + _get_stack_rank(func.__wrapped__)
-    else:
-        return 0
+def _count_wrappers(func):
+    """Count the number of wrappers around `func`."""
+    unwrapped = func
+    count = 0
+    while hasattr(unwrapped, "__wrapped__"):
+        unwrapped = unwrapped.__wrapped__
+        count += 1
+    return count
+
 
+def _warning_stacklevel(func):
+    """Find stacklevel for a warning raised from a wrapper around `func`.
 
-def _is_wrapped(func):
-    return "__wrapped__" in dir(func)
+    Try to determine the number of
+
+    Parameters
+    ----------
+    func : Callable
+
+
+    Returns
+    -------
+    stacklevel : int
+        The stacklevel. Minimum of 2.
+    """
+    # Count number of wrappers around `func`
+    wrapped_count = _count_wrappers(func)
+
+    # Count number of total wrappers around global version of `func`
+    module = sys.modules.get(func.__module__)
+    try:
+        for name in func.__qualname__.split("."):
+            global_func = getattr(module, name)
+    except AttributeError as e:
+        raise RuntimeError(
+            f"Could not access `{func.__qualname__}` in {module!r}, "
+            f" may be a closure. Set stacklevel manually. ",
+        ) from e
+    else:
+        global_wrapped_count = _count_wrappers(global_func)
+
+    stacklevel = global_wrapped_count - wrapped_count + 1
+    return max(stacklevel, 2)
 
 
 def _get_stack_length(func):
     """Return function call stack length."""
-    return _get_stack_rank(func.__globals__.get(func.__name__, func))
+    _func = func.__globals__.get(func.__name__, func)
+    length = _count_wrappers(_func)
+    return length
 
 
 class _DecoratorBaseClass:
@@ -88,7 +127,8 @@ def __call__(self, func):
         arg_idx = list(parameters.keys()).index(self.arg_name)
         old_value = parameters[self.arg_name].default
 
-        stack_rank = _get_stack_rank(func)
+        stack_rank = _count_wrappers(func)
+
         if self.warning_msg is None:
             self.warning_msg = (
                 f"The new recommended value for {self.arg_name} is "
@@ -112,49 +152,192 @@ def fixed_func(*args, **kwargs):
         return fixed_func
 
 
-class remove_arg(_DecoratorBaseClass):
-    """Decorator to remove an argument from function's signature.
+class PatchClassRepr(type):
+    """Control class representations in rendered signatures."""
+
+    def __repr__(cls):
+        return f"<{cls.__name__}>"
+
+
+class DEPRECATED(metaclass=PatchClassRepr):
+    """Signal value to help with deprecating parameters that use None.
+
+    This is a proxy object, used to signal that a parameter has not been set.
+    This is useful if ``None`` is already used for a different purpose or just
+    to highlight a deprecated parameter in the signature.
+    """
+
+
+class deprecate_parameter:
+    """Deprecate a parameter of a function.
 
     Parameters
     ----------
-    arg_name: str
-        The name of the argument to be removed.
-    changed_version : str
+    deprecated_name : str
+        The name of the deprecated parameter.
+    start_version : str
+        The package version in which the warning was introduced.
+    stop_version : str
         The package version in which the warning will be replaced by
-        an error.
-    help_msg: str
-        Optional message appended to the generic warning message.
+        an error / the deprecation is completed.
+    template : str, optional
+        If given, this message template is used instead of the default one.
+    new_name : str, optional
+        If given, the default message will recommend the new parameter name and
+        an error will be raised if the user uses both old and new names for the
+        same parameter.
+    modify_docstring : bool, optional
+        If the wrapped function has a docstring, add the deprecated parameters
+        to the "Other Parameters" section.
+    stacklevel : int, optional
+        This decorator attempts to detect the appropriate stacklevel for the
+        deprecation warning automatically. If this fails, e.g., due to
+        decorating a closure, you can set the stacklevel manually. The
+        outermost decorator should have stacklevel 2, the next inner one
+        stacklevel 3, etc.
+
+    Notes
+    -----
+    Assign `DEPRECATED` as the new default value for the deprecated parameter.
+    This marks the status of the parameter also in the signature and rendered
+    HTML docs.
+
+    This decorator can be stacked to deprecate more than one parameter.
+
+    Examples
+    --------
+    >>> from skimage._shared.utils import deprecate_parameter, DEPRECATED
+    >>> @deprecate_parameter(
+    ...     "b", new_name="c", start_version="0.1", stop_version="0.3"
+    ... )
+    ... def foo(a, b=DEPRECATED, *, c=None):
+    ...     return a, c
 
+    Calling ``foo(1, b=2)``  will warn with::
+
+        FutureWarning: Parameter `b` is deprecated since version 0.1 and will
+        be removed in 0.3 (or later). To avoid this warning, please use the
+        parameter `c` instead. For more details, see the documentation of
+        `foo`.
     """
 
-    def __init__(self, arg_name, *, changed_version, help_msg=None):
-        self.arg_name = arg_name
-        self.help_msg = help_msg
-        self.changed_version = changed_version
+    DEPRECATED = DEPRECATED  # Make signal value accessible for convenience
+
+    remove_parameter_template = (
+        "Parameter `{deprecated_name}` is deprecated since version "
+        "{deprecated_version} and will be removed in {changed_version} (or "
+        "later). To avoid this warning, please do not use the parameter "
+        "`{deprecated_name}`. For more details, see the documentation of "
+        "`{func_name}`."
+    )
+
+    replace_parameter_template = (
+        "Parameter `{deprecated_name}` is deprecated since version "
+        "{deprecated_version} and will be removed in {changed_version} (or "
+        "later). To avoid this warning, please use the parameter `{new_name}` "
+        "instead. For more details, see the documentation of `{func_name}`."
+    )
+
+    def __init__(
+        self,
+        deprecated_name,
+        *,
+        start_version,
+        stop_version,
+        template=None,
+        new_name=None,
+        modify_docstring=True,
+        stacklevel=None,
+    ):
+        self.deprecated_name = deprecated_name
+        self.new_name = new_name
+        self.template = template
+        self.start_version = start_version
+        self.stop_version = stop_version
+        self.modify_docstring = modify_docstring
+        self.stacklevel = stacklevel
 
     def __call__(self, func):
         parameters = inspect.signature(func).parameters
-        arg_idx = list(parameters.keys()).index(self.arg_name)
-        warning_msg = (
-            f"{self.arg_name} argument is deprecated in upstream scikit-image "
-            f"and will be removed in cuCIM {self.changed_version}. To avoid "
-            f"this warning, please do not use the {self.arg_name} argument. "
-            f"Please see {func.__name__} documentation for more details."
-        )
+        deprecated_idx = list(parameters.keys()).index(self.deprecated_name)
+        if self.new_name:
+            new_idx = list(parameters.keys()).index(self.new_name)
+        else:
+            new_idx = False
 
-        if self.help_msg is not None:
-            warning_msg += f" {self.help_msg}"
+        if parameters[self.deprecated_name].default is not DEPRECATED:
+            raise RuntimeError(
+                f"Expected `{self.deprecated_name}` to have the value "
+                f"{DEPRECATED!r} to indicate its status in the rendered "
+                "signature."
+            )
 
-        stack_rank = _get_stack_rank(func)
+        if self.template is not None:
+            template = self.template
+        elif self.new_name is not None:
+            template = self.replace_parameter_template
+        else:
+            template = self.remove_parameter_template
+        warning_message = template.format(
+            deprecated_name=self.deprecated_name,
+            deprecated_version=self.start_version,
+            changed_version=self.stop_version,
+            func_name=func.__qualname__,
+            new_name=self.new_name,
+        )
 
         @functools.wraps(func)
         def fixed_func(*args, **kwargs):
-            stacklevel = 1 + self.get_stack_length(func) - stack_rank
-            if len(args) > arg_idx or self.arg_name in kwargs.keys():
-                # warn that arg_name is deprecated
-                warnings.warn(warning_msg, FutureWarning, stacklevel=stacklevel)
+            deprecated_value = DEPRECATED
+            new_value = DEPRECATED
+
+            # Extract value of deprecated parameter
+            if len(args) > deprecated_idx:
+                deprecated_value = args[deprecated_idx]
+                args = (
+                    args[:deprecated_idx]
+                    + (DEPRECATED,)
+                    + args[deprecated_idx + 1 :]
+                )
+            if self.deprecated_name in kwargs.keys():
+                deprecated_value = kwargs[self.deprecated_name]
+                kwargs[self.deprecated_name] = DEPRECATED
+            # Extract value of new parameter (if present)
+            if new_idx is not False and len(args) > new_idx:
+                new_value = args[new_idx]
+            if self.new_name and self.new_name in kwargs.keys():
+                new_value = kwargs[self.new_name]
+
+            if deprecated_value is not DEPRECATED:
+                stacklevel = (
+                    self.stacklevel
+                    if self.stacklevel is not None
+                    else _warning_stacklevel(func)
+                )
+                warnings.warn(
+                    warning_message,
+                    category=FutureWarning,
+                    stacklevel=stacklevel,
+                )
+
+                if new_value is not DEPRECATED:
+                    raise ValueError(
+                        f"Both deprecated parameter `{self.deprecated_name}` "
+                        f"and new parameter `{self.new_name}` are used. Use "
+                        f"only the latter to avoid conflicting values."
+                    )
+                elif self.new_name is not None:
+                    # Assign old value to new one
+                    kwargs[self.new_name] = deprecated_value
+
             return func(*args, **kwargs)
 
+        if self.modify_docstring and func.__doc__ is not None:
+            newdoc = _docstring_add_deprecated(
+                func, {self.deprecated_name: self.new_name}, self.start_version
+            )
+            fixed_func.__doc__ = newdoc
+
         return fixed_func
 
 
@@ -167,7 +350,7 @@ def _docstring_add_deprecated(func, kwarg_mapping, deprecated_version):
         The function whose docstring we wish to update.
     kwarg_mapping : dict
         A dict containing {old_arg: new_arg} key/value pairs as used by
-        `deprecate_kwarg`.
+        `deprecate_parameter`.
     deprecated_version : str
         A major.minor version string specifying when old_arg was
         deprecated.
@@ -188,11 +371,13 @@ def _docstring_add_deprecated(func, kwarg_mapping, deprecated_version):
 
     Doc = FunctionDoc(func)
     for old_arg, new_arg in kwarg_mapping.items():
-        desc = [
-            f"Deprecated in favor of `{new_arg}`.",
-            "",
-            f".. deprecated:: {deprecated_version}",
-        ]
+        desc = []
+        if new_arg is None:
+            desc.append(f"`{old_arg}` is deprecated.")
+        else:
+            desc.append(f"Deprecated in favor of `{new_arg}`.")
+
+        desc += ["", f".. deprecated:: {deprecated_version}"]
         Doc["Other Parameters"].append(
             Parameter(name=old_arg, type="DEPRECATED", desc=desc)
         )
@@ -224,82 +409,6 @@ def _docstring_add_deprecated(func, kwarg_mapping, deprecated_version):
     return final_docstring
 
 
-class deprecate_kwarg(_DecoratorBaseClass):
-    """Decorator ensuring backward compatibility when argument names are
-    modified in a function definition.
-
-    Parameters
-    ----------
-    kwarg_mapping: dict
-        Mapping between the function's old argument names and the new
-        ones.
-    deprecated_version : str
-        The package version in which the argument was first deprecated.
-    warning_msg: str
-        Optional warning message. If None, a generic warning message
-        is used.
-    removed_version : str
-        The package version in which the deprecated argument will be
-        removed.
-
-    """
-
-    def __init__(
-        self,
-        kwarg_mapping,
-        deprecated_version,
-        warning_msg=None,
-        removed_version=None,
-    ):
-        self.kwarg_mapping = kwarg_mapping
-        if warning_msg is None:
-            self.warning_msg = (
-                "`{old_arg}` is a deprecated argument name "
-                "for `{func_name}`. "
-            )
-            if removed_version is not None:
-                self.warning_msg += (
-                    f"It will be removed in cuCIM "
-                    f"version {removed_version}."
-                )
-            self.warning_msg += "Please use `{new_arg}` instead."
-        else:
-            self.warning_msg = warning_msg
-
-        self.deprecated_version = deprecated_version
-
-    def __call__(self, func):
-        stack_rank = _get_stack_rank(func)
-
-        @functools.wraps(func)
-        def fixed_func(*args, **kwargs):
-            stacklevel = 1 + self.get_stack_length(func) - stack_rank
-            for old_arg, new_arg in self.kwarg_mapping.items():
-                if old_arg in kwargs:
-                    #  warn that the function interface has changed:
-                    warnings.warn(
-                        self.warning_msg.format(
-                            old_arg=old_arg,
-                            func_name=func.__name__,
-                            new_arg=new_arg,
-                        ),
-                        FutureWarning,
-                        stacklevel=stacklevel,
-                    )
-                    # Substitute new_arg to old_arg
-                    kwargs[new_arg] = kwargs.pop(old_arg)
-
-            # Call the function with the fixed arguments
-            return func(*args, **kwargs)
-
-        if func.__doc__ is not None:
-            newdoc = _docstring_add_deprecated(
-                func, self.kwarg_mapping, self.deprecated_version
-            )
-            fixed_func.__doc__ = newdoc
-        return fixed_func
-
-
 class channel_as_last_axis:
     """Decorator for automatically making channels axis last for all arrays.
 
@@ -427,7 +536,7 @@ def __call__(self, func):
         if self.hint:
             message += f" {self.hint.rstrip('.')}."
 
-        stack_rank = _get_stack_rank(func)
+        stack_rank = _count_wrappers(func)
 
         @functools.wraps(func)
         def wrapped(*args, **kwargs):
diff --git a/python/cucim/src/cucim/skimage/_vendored/_internal.py b/python/cucim/src/cucim/skimage/_vendored/_internal.py
index f87e07744..09c26a241 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_internal.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_internal.py
@@ -1,7 +1,3 @@
-import math
-from functools import reduce
-from operator import mul
-
 import cupy
 
 # TODO: when minimum numpy dependency is 1.25 use:
@@ -72,11 +68,3 @@ def _normalize_axis_indices(axes, ndim):  # NOQA
             res.append(axis)
 
         return tuple(sorted(res))
-
-
-if hasattr(math, "prod"):
-    prod = math.prod
-else:
-
-    def prod(iterable, *, start=1):
-        return reduce(mul, iterable, start)
diff --git a/python/cucim/src/cucim/skimage/_vendored/_ndimage_filters.py b/python/cucim/src/cucim/skimage/_vendored/_ndimage_filters.py
index c9d4880d9..b7e2ab4a3 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_ndimage_filters.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_ndimage_filters.py
@@ -1,4 +1,5 @@
 """A vendored subset of cupyx.scipy.ndimage._filters"""
+import math
 import warnings
 
 import cupy
@@ -1181,7 +1182,7 @@ def _get_min_or_max_kernel(
     ctype = "X" if has_weights else "double"
     value = "{value}"
     if not has_weights:
-        value = "cast<double>({})".format(value)
+        value = f"cast<double>({value})"
 
     # Having a non-flat structure biases the values
     if has_structure:
@@ -1386,7 +1387,7 @@ def _rank_filter(
     has_weights = True
     if sizes is not None:
         has_weights = False
-        filter_size = internal.prod(sizes)
+        filter_size = math.prod(sizes)
         if filter_size == 0:
             return cupy.zeros_like(input)
         footprint_shape = tuple(sizes)
@@ -1497,45 +1498,39 @@ def _get_rank_kernel(
         else:
             comp_op = ">"
         array_size = s_rank + 2
-        found_post = """
-            if (iv > {rank} + 1) {{{{
+        found_post = f"""
+            if (iv > {s_rank} + 1) {{{{
                 int target_iv = 0;
                 X target_val = values[0];
-                for (int jv = 1; jv <= {rank} + 1; jv++) {{{{
+                for (int jv = 1; jv <= {s_rank} + 1; jv++) {{{{
                     if (target_val {comp_op} values[jv]) {{{{
                         target_val = values[jv];
                         target_iv = jv;
                     }}}}
                 }}}}
-                if (target_iv <= {rank}) {{{{
-                    values[target_iv] = values[{rank} + 1];
+                if (target_iv <= {s_rank}) {{{{
+                    values[target_iv] = values[{s_rank} + 1];
                 }}}}
-                iv = {rank} + 1;
-            }}}}""".format(
-            rank=s_rank, comp_op=comp_op
-        )
-        post = """
+                iv = {s_rank} + 1;
+            }}}}"""
+        post = f"""
             X target_val = values[0];
-            for (int jv = 1; jv <= {rank}; jv++) {{
+            for (int jv = 1; jv <= {s_rank}; jv++) {{
                 if (target_val {comp_op} values[jv]) {{
                     target_val = values[jv];
                 }}
             }}
-            y=cast<Y>(target_val);""".format(
-            rank=s_rank, comp_op=comp_op
-        )
+            y=cast<Y>(target_val);"""
         sorter = ""
     else:
         array_size = filter_size
         found_post = ""
-        post = "sort(values,{});\ny=cast<Y>(values[{}]);".format(
-            filter_size, rank
-        )
+        post = f"sort(values,{filter_size});\ny=cast<Y>(values[{rank}]);"
         sorter = __SHELL_SORT.format(gap=_get_shell_gap(filter_size))
 
     return _filters_core._generate_nd_kernel(
-        "rank_{}_{}".format(filter_size, rank),
-        "int iv = 0;\nX values[{}];".format(array_size),
+        f"rank_{filter_size}_{rank}",
+        f"int iv = 0;\nX values[{array_size}];",
         "values[iv++] = {value};" + found_post,
         post,
         mode,
diff --git a/python/cucim/src/cucim/skimage/_vendored/_ndimage_filters_core.py b/python/cucim/src/cucim/skimage/_vendored/_ndimage_filters_core.py
index 13cccd6b7..788d34497 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_ndimage_filters_core.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_ndimage_filters_core.py
@@ -75,9 +75,7 @@ def _check_nd_args(
             )
         weight_dims = [x for x in weights.shape if x != 0]
         if len(weight_dims) != input.ndim:
-            raise RuntimeError(
-                "{} array has incorrect shape".format(wghts_name)
-            )
+            raise RuntimeError(f"{wghts_name} array has incorrect shape")
     elif sizes is None:
         raise ValueError("must specify either weights array or sizes")
     else:
@@ -258,7 +256,7 @@ def _generate_nd_kernel(
     sizes = [size.format(j=j) for j in range(ndim)]
     inds = _util._generate_indices_ops(ndim, int_type, offsets)
     # CArray: remove expr entirely
-    expr = " + ".join(["ix_{}".format(j) for j in range(ndim)])
+    expr = " + ".join([f"ix_{j}" for j in range(ndim)])
 
     ws_init = ws_pre = ws_post = ""
     if has_weights or has_structure:
@@ -275,32 +273,26 @@ def _generate_nd_kernel(
     for j in range(ndim):
         if w_shape[j] == 1:
             # CArray: string becomes 'inds[{j}] = ind_{j};', remove (int_)type
-            loops.append(
-                "{{ {type} ix_{j} = ind_{j} * xstride_{j};".format(
-                    j=j, type=int_type
-                )
-            )
+            loops.append(f"{{ {int_type} ix_{j} = ind_{j} * xstride_{j};")
         else:
             boundary = _util._generate_boundary_condition_ops(
-                mode, "ix_{}".format(j), "xsize_{}".format(j), int_type
+                mode, f"ix_{j}", f"xsize_{j}", int_type
             )
             # CArray: last line of string becomes inds[{j}] = ix_{j};
             loops.append(
-                """
-    for (int iw_{j} = 0; iw_{j} < {wsize}; iw_{j}++)
+                f"""
+    for (int iw_{j} = 0; iw_{j} < {w_shape[j]}; iw_{j}++)
     {{
-        {type} ix_{j} = ind_{j} + iw_{j};
+        {int_type} ix_{j} = ind_{j} + iw_{j};
         {boundary}
         ix_{j} *= xstride_{j};
-        """.format(
-                    j=j, wsize=w_shape[j], boundary=boundary, type=int_type
-                )
+        """
             )
 
     # CArray: string becomes 'x[inds]', no format call needed
-    value = "(*(X*)&data[{expr}])".format(expr=expr)
+    value = f"(*(X*)&data[{expr}])"
     if mode == "constant":
-        cond = " || ".join(["(ix_{} < 0)".format(j) for j in range(ndim)])
+        cond = " || ".join([f"(ix_{j} < 0)" for j in range(ndim)])
 
     if cval is numpy.nan:
         cval = "CUDART_NAN"
@@ -313,9 +305,7 @@ def _generate_nd_kernel(
         found = found.format(cond=cond, value=value)
     else:
         if mode == "constant":
-            value = "(({cond}) ? cast<{ctype}>({cval}) : {value})".format(
-                cond=cond, ctype=ctype, cval=cval, value=value
-            )
+            value = f"(({cond}) ? cast<{ctype}>({cval}) : {value})"
         found = found.format(value=value)
 
     # CArray: replace comment and next line in string with
@@ -351,7 +341,7 @@ def _generate_nd_kernel(
 
     mode_str = mode.replace("-", "_")  # avoid potential hyphen in kernel name
     name = "cupyx_scipy_ndimage_{}_{}d_{}_w{}".format(
-        name, ndim, mode_str, "_".join(["{}".format(x) for x in w_shape])
+        name, ndim, mode_str, "_".join([f"{x}" for x in w_shape])
     )
     if all_weights_nonzero:
         name += "_all_nonzero"
diff --git a/python/cucim/src/cucim/skimage/_vendored/_ndimage_interpolation.py b/python/cucim/src/cucim/skimage/_vendored/_ndimage_interpolation.py
index d77052918..ce68ab895 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_ndimage_interpolation.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_ndimage_interpolation.py
@@ -13,7 +13,7 @@
     _ndimage_util as _util,
     pad,
 )
-from cucim.skimage._vendored._internal import _normalize_axis_index, prod
+from cucim.skimage._vendored._internal import _normalize_axis_index
 
 
 def _check_parameter(func_name, order, mode):
@@ -38,7 +38,7 @@ def _check_parameter(func_name, order, mode):
         "opencv",
         "_opencv_edge",
     ):
-        raise ValueError("boundary mode ({}) is not supported".format(mode))
+        raise ValueError(f"boundary mode ({mode}) is not supported")
 
 
 def _get_spline_output(input, output):
@@ -326,7 +326,7 @@ def map_coordinates(
         input = input.astype(cupy.float32)
     coordinates = _check_coordinates(coordinates, order)
     filtered, nprepad = _filter_input(input, prefilter, mode, cval, order)
-    large_int = max(prod(input.shape), coordinates.shape[0]) > 1 << 31
+    large_int = max(math.prod(input.shape), coordinates.shape[0]) > 1 << 31
     kern = _interp_kernels._get_map_kernel(
         input.ndim,
         large_int,
@@ -485,7 +485,7 @@ def affine_transform(
 
     integer_output = output.dtype.kind in "iu"
     _util._check_cval(mode, cval, integer_output)
-    large_int = max(prod(input.shape), prod(output_shape)) > 1 << 31
+    large_int = max(math.prod(input.shape), math.prod(output_shape)) > 1 << 31
     if matrix.ndim == 1:
         offset = cupy.asarray(offset, dtype=cupy.float64)
         offset = -offset / matrix
@@ -708,7 +708,7 @@ def shift(
         filtered, nprepad = _filter_input(input, prefilter, mode, cval, order)
         integer_output = output.dtype.kind in "iu"
         _util._check_cval(mode, cval, integer_output)
-        large_int = prod(input.shape) > 1 << 31
+        large_int = math.prod(input.shape) > 1 << 31
         kern = _interp_kernels._get_shift_kernel(
             input.ndim,
             large_int,
@@ -845,7 +845,9 @@ def zoom(
         filtered, nprepad = _filter_input(input, prefilter, mode, cval, order)
         integer_output = output.dtype.kind in "iu"
         _util._check_cval(mode, cval, integer_output)
-        large_int = max(prod(input.shape), prod(output_shape)) > 1 << 31
+        large_int = (
+            max(math.prod(input.shape), math.prod(output_shape)) > 1 << 31
+        )
         kern = _interp_kernels._get_zoom_kernel(
             input.ndim,
             large_int,
diff --git a/python/cucim/src/cucim/skimage/_vendored/_ndimage_morphology.py b/python/cucim/src/cucim/skimage/_vendored/_ndimage_morphology.py
index c39f9c605..9a41f213e 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_ndimage_morphology.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_ndimage_morphology.py
@@ -1,3 +1,4 @@
+import math
 import operator
 import warnings
 
@@ -6,7 +7,6 @@
 from cupy import _core
 
 from cucim.skimage._vendored import (
-    _internal as internal,
     _ndimage_filters as _filters,
     _ndimage_filters_core as _filters_core,
     _ndimage_util as _util,
@@ -33,53 +33,43 @@ def _get_binary_erosion_kernel(
         false_val = 0
 
     if masked:
-        pre = """
+        pre = f"""
             bool mv = (bool)mask[i];
             bool _in = (bool)x[i];
             if (!mv) {{
                 y = cast<Y>(_in);
                 return;
-            }} else if ({center_is_true} && _in == {false_val}) {{
+            }} else if ({int(center_is_true)} && _in == {false_val}) {{
                 y = cast<Y>(_in);
                 return;
-            }}""".format(
-            center_is_true=int(center_is_true), false_val=false_val
-        )
+            }}"""
     else:
-        pre = """
+        pre = f"""
             bool _in = (bool)x[i];
-            if ({center_is_true} && _in == {false_val}) {{
+            if ({int(center_is_true)} && _in == {false_val}) {{
                 y = cast<Y>(_in);
                 return;
-            }}""".format(
-            center_is_true=int(center_is_true), false_val=false_val
-        )
+            }}"""
     pre = (
         pre
-        + """
-            y = cast<Y>({true_val});""".format(
-            true_val=true_val
-        )
+        + f"""
+            y = cast<Y>({true_val});"""
     )
 
     # {{{{ required because format is called again within _generate_nd_kernel
-    found = """
+    found = f"""
         if ({{cond}}) {{{{
-            if (!{border_value}) {{{{
-                y = cast<Y>({false_val});
+            if (!{int(border_value)}) {{{{
+                y = cast<Y>({int(false_val)});
                 return;
             }}}}
         }}}} else {{{{
-            bool nn = {{value}} ? {true_val} : {false_val};
+            bool nn = {{value}} ? {int(true_val)} : {int(false_val)};
             if (!nn) {{{{
-                y = cast<Y>({false_val});
+                y = cast<Y>({int(false_val)});
                 return;
             }}}}
-        }}}}""".format(
-        true_val=int(true_val),
-        false_val=int(false_val),
-        border_value=int(border_value),
-    )
+        }}}}"""
 
     name = "binary_erosion"
     if false_val:
@@ -253,7 +243,7 @@ def _binary_erosion(
     offsets = _filters_core._origins_to_offsets(origin, structure_shape)
     if not default_structure:
         if isinstance(structure, tuple):
-            nnz = internal.prod(structure_shape)
+            nnz = math.prod(structure_shape)
             all_weights_nonzero = True
             center_is_true = True
         else:
diff --git a/python/cucim/src/cucim/skimage/_vendored/_ndimage_spline_prefilter_core.py b/python/cucim/src/cucim/skimage/_vendored/_ndimage_spline_prefilter_core.py
index 5e9a7dd24..7f89133d2 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_ndimage_spline_prefilter_core.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_ndimage_spline_prefilter_core.py
@@ -85,7 +85,7 @@ def _causal_init_code(mode):
         c[0] *= z / (1 - z_n * z_n);
         c[0] += c0;"""
     else:
-        raise ValueError("invalid mode: {}".format(mode))
+        raise ValueError(f"invalid mode: {mode}")
     return code
 
 
@@ -114,7 +114,7 @@ def _anticausal_init_code(mode):
         code += """
         c[(n - 1) * element_stride] *= z / (z - 1);"""
     else:
-        raise ValueError("invalid mode: {}".format(mode))
+        raise ValueError(f"invalid mode: {mode}")
     return code
 
 
diff --git a/python/cucim/src/cucim/skimage/_vendored/_ndimage_util.py b/python/cucim/src/cucim/skimage/_vendored/_ndimage_util.py
index 08b5a68a0..95c893a5b 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_ndimage_util.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_ndimage_util.py
@@ -69,7 +69,7 @@ def _fix_sequence_arg(arg, ndim, name, conv=lambda x: x):
         return [conv(arg)] * ndim
     lst = [conv(x) for x in arg]
     if len(lst) != ndim:
-        msg = "{} must have length equal to input rank".format(name)
+        msg = f"{name} must have length equal to input rank"
         raise RuntimeError(msg)
     return lst
 
diff --git a/python/cucim/src/cucim/skimage/_vendored/_pearsonr.py b/python/cucim/src/cucim/skimage/_vendored/_pearsonr.py
index d3385da94..f0a969a44 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_pearsonr.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_pearsonr.py
@@ -253,14 +253,14 @@ def pearsonr(x, y, *, disable_checks=False):
     # As explained in the docstring, the p-value can be computed as
     #     p = 2*dist.cdf(-abs(r))
     # where dist is the beta distribution on [-1, 1] with shape parameters
-    # a = b = n/2 - 1.  `special.btdtr` is the CDF for the beta distribution
+    # a = b = n/2 - 1.  `special.betainc` is the CDF for the beta distribution
     # on [0, 1].  To use it, we make the transformation  x = (r + 1)/2; the
     # shape parameters do not change.  Then -abs(r) used in `cdf(-abs(r))`
     # becomes x = (-abs(r) + 1)/2 = 0.5*(1 - abs(r)).  (r is cast to float64
-    # to avoid a TypeError raised by btdtr when r is higher precision.)
+    # to avoid a TypeError raised by betainc when r is higher precision.)
     ab = n / 2 - 1
-    # scalar valued, so use special.btdtr from SciPy, not CuPy
-    prob = 2 * special.btdtr(ab, ab, 0.5 * (1.0 - abs(r)))
+    # scalar valued, so use special.betainc from SciPy, not CuPy
+    prob = 2 * special.betainc(ab, ab, 0.5 * (1.0 - abs(r)))
     if disable_checks:
         # warn only based on output values to avoid overhead of host/device
         # synchronization needed for the disabled checks above.
diff --git a/python/cucim/src/cucim/skimage/_vendored/_signaltools_core.py b/python/cucim/src/cucim/skimage/_vendored/_signaltools_core.py
index 69b9e8ac4..8d8ce6f5d 100644
--- a/python/cucim/src/cucim/skimage/_vendored/_signaltools_core.py
+++ b/python/cucim/src/cucim/skimage/_vendored/_signaltools_core.py
@@ -132,8 +132,7 @@ def _init_freq_conv_axes(in1, in2, mode, axes, sorted_axes=False):
     for ax, (dim1, dim2) in enumerate(zip(s1, s2)):
         if ax not in axes and dim1 != dim2 and dim1 != 1 and dim2 != 1:
             raise ValueError(
-                "incompatible shapes for in1 and in2:"
-                " {} and {}".format(s1, s2)
+                "incompatible shapes for in1 and in2:" f" {s1} and {s2}"
             )
 
     # Check that input sizes are compatible with 'valid' mode.
diff --git a/python/cucim/src/cucim/skimage/_vendored/pad.py b/python/cucim/src/cucim/skimage/_vendored/pad.py
index 1f6c81217..249a2c1ed 100644
--- a/python/cucim/src/cucim/skimage/_vendored/pad.py
+++ b/python/cucim/src/cucim/skimage/_vendored/pad.py
@@ -663,7 +663,7 @@ def pad(array, pad_width, mode="constant", **kwargs):
     try:
         unsupported_kwargs = set(kwargs) - set(allowed_kwargs[mode])
     except KeyError:
-        raise ValueError("mode '{}' is not supported".format(mode))
+        raise ValueError(f"mode '{mode}' is not supported")
     if unsupported_kwargs:
         raise ValueError(
             "unsupported keyword arguments for mode '{}': {}".format(
@@ -750,8 +750,8 @@ def pad(array, pad_width, mode="constant", **kwargs):
         for axis, width_pair in zip(axes, pad_width):
             if array.shape[axis] == 0 and any(width_pair):
                 raise ValueError(
-                    "can't extend empty axis {} using modes other than "
-                    "'constant' or 'empty'".format(axis)
+                    f"can't extend empty axis {axis} using modes other than "
+                    "'constant' or 'empty'"
                 )
         # passed, don't need to do anything more as _pad_simple already
         # returned the correct result
diff --git a/python/cucim/src/cucim/skimage/_vendored/signaltools.py b/python/cucim/src/cucim/skimage/_vendored/signaltools.py
index 4ea5e85cb..89af6d4a8 100644
--- a/python/cucim/src/cucim/skimage/_vendored/signaltools.py
+++ b/python/cucim/src/cucim/skimage/_vendored/signaltools.py
@@ -6,6 +6,7 @@
 does not restrict the choice of fftconvolve to only 1D arrays.
 """
 
+import math
 import timeit
 import warnings
 
@@ -13,11 +14,10 @@
 import numpy as np
 from cupyx.scipy.ndimage import rank_filter, uniform_filter
 
-from cucim import _misc
 from cucim.skimage._vendored import _signaltools_core as _st_core
 from cucim.skimage._vendored._ndimage_util import _fix_sequence_arg
 
-_prod = _misc.prod
+_prod = math.prod
 
 
 def convolve(in1, in2, mode="full", method="auto"):
@@ -212,7 +212,7 @@ def _conv_ops(x_shape, h_shape, mode):
     else:
         raise ValueError(
             "Acceptable mode flags are 'valid',"
-            " 'same', or 'full', not mode={}".format(mode)
+            f" 'same', or 'full', not mode={mode}"
         )
 
     s1, s2 = x_shape, h_shape
@@ -722,7 +722,7 @@ def medfilt(volume, kernel_size=None):
             "kernel_size exceeds volume extent: " "volume will be zero-padded"
         )
 
-    size = np.prod(kernel_size)
+    size = _prod(kernel_size)
     return rank_filter(
         volume, size // 2, size=kernel_size, output=float, mode="constant"
     )
diff --git a/python/cucim/src/cucim/skimage/_vendored/time.py b/python/cucim/src/cucim/skimage/_vendored/time.py
index a4b7bd57c..5ec1156ce 100644
--- a/python/cucim/src/cucim/skimage/_vendored/time.py
+++ b/python/cucim/src/cucim/skimage/_vendored/time.py
@@ -11,7 +11,7 @@
 import numpy
 
 
-class _PerfCaseResult(object):
+class _PerfCaseResult:
     def __init__(self, name, ts, devices):
         assert ts.ndim == 2
         assert ts.shape[0] == len(devices) + 1
@@ -34,7 +34,7 @@ def _to_str_per_item(device_name, t):
         assert t.size > 0
         t_us = t * 1e6
 
-        s = "    {}:{:9.03f} us".format(device_name, t_us.mean())
+        s = f"    {device_name}:{t_us.mean():9.03f} us"
         if t.size > 1:
             s += "   +/-{:6.03f} (min:{:9.03f} / max:{:9.03f}) us".format(
                 t_us.std(), t_us.min(), t_us.max()
@@ -46,7 +46,7 @@ def to_str(self, show_gpu=False):
         if show_gpu:
             for i, d in enumerate(self._devices):
                 results.append(
-                    self._to_str_per_item("GPU-{}".format(d), self._ts[1 + i])
+                    self._to_str_per_item(f"GPU-{d}", self._ts[1 + i])
                 )
         return "{:<20s}:{}".format(self.name, " ".join(results))
 
diff --git a/python/cucim/src/cucim/skimage/color/__init__.pyi b/python/cucim/src/cucim/skimage/color/__init__.pyi
new file mode 100644
index 000000000..74a72acec
--- /dev/null
+++ b/python/cucim/src/cucim/skimage/color/__init__.pyi
@@ -0,0 +1,133 @@
+# Explicitly setting `__all__` is necessary for type inference engines
+# to know which symbols are exported. See
+# https://peps.python.org/pep-0484/#stub-files
+
+__all__ = [
+    "convert_colorspace",
+    "xyz_tristimulus_values",
+    "rgba2rgb",
+    "rgb2hsv",
+    "hsv2rgb",
+    "rgb2xyz",
+    "xyz2rgb",
+    "rgb2rgbcie",
+    "rgbcie2rgb",
+    "rgb2gray",
+    "gray2rgb",
+    "gray2rgba",
+    "xyz2lab",
+    "lab2xyz",
+    "lab2rgb",
+    "rgb2lab",
+    "xyz2luv",
+    "luv2xyz",
+    "luv2rgb",
+    "rgb2luv",
+    "rgb2hed",
+    "hed2rgb",
+    "lab2lch",
+    "lch2lab",
+    "rgb2yuv",
+    "yuv2rgb",
+    "rgb2yiq",
+    "yiq2rgb",
+    "rgb2ypbpr",
+    "ypbpr2rgb",
+    "rgb2ycbcr",
+    "ycbcr2rgb",
+    "rgb2ydbdr",
+    "ydbdr2rgb",
+    "separate_stains",
+    "combine_stains",
+    "rgb_from_hed",
+    "hed_from_rgb",
+    "rgb_from_hdx",
+    "hdx_from_rgb",
+    "rgb_from_fgx",
+    "fgx_from_rgb",
+    "rgb_from_bex",
+    "bex_from_rgb",
+    "rgb_from_rbd",
+    "rbd_from_rgb",
+    "rgb_from_gdx",
+    "gdx_from_rgb",
+    "rgb_from_hax",
+    "hax_from_rgb",
+    "rgb_from_bro",
+    "bro_from_rgb",
+    "rgb_from_bpx",
+    "bpx_from_rgb",
+    "rgb_from_ahx",
+    "ahx_from_rgb",
+    "rgb_from_hpx",
+    "hpx_from_rgb",
+    "color_dict",
+    "label2rgb",
+    "deltaE_cie76",
+    "deltaE_ciede94",
+    "deltaE_ciede2000",  # TODO: fix accuracy
+    "deltaE_cmc",
+]
+
+from .colorconv import (
+    ahx_from_rgb,
+    bex_from_rgb,
+    bpx_from_rgb,
+    bro_from_rgb,
+    combine_stains,
+    convert_colorspace,
+    fgx_from_rgb,
+    gdx_from_rgb,
+    gray2rgb,
+    gray2rgba,
+    hax_from_rgb,
+    hdx_from_rgb,
+    hed2rgb,
+    hed_from_rgb,
+    hpx_from_rgb,
+    hsv2rgb,
+    lab2lch,
+    lab2rgb,
+    lab2xyz,
+    lch2lab,
+    luv2rgb,
+    luv2xyz,
+    rbd_from_rgb,
+    rgb2gray,
+    rgb2hed,
+    rgb2hsv,
+    rgb2lab,
+    rgb2luv,
+    rgb2rgbcie,
+    rgb2xyz,
+    rgb2ycbcr,
+    rgb2ydbdr,
+    rgb2yiq,
+    rgb2ypbpr,
+    rgb2yuv,
+    rgb_from_ahx,
+    rgb_from_bex,
+    rgb_from_bpx,
+    rgb_from_bro,
+    rgb_from_fgx,
+    rgb_from_gdx,
+    rgb_from_hax,
+    rgb_from_hdx,
+    rgb_from_hed,
+    rgb_from_hpx,
+    rgb_from_rbd,
+    rgba2rgb,
+    rgbcie2rgb,
+    separate_stains,
+    xyz2lab,
+    xyz2luv,
+    xyz2rgb,
+    xyz_tristimulus_values,
+    ycbcr2rgb,
+    ydbdr2rgb,
+    yiq2rgb,
+    ypbpr2rgb,
+    yuv2rgb,
+)
+from .colorlabel import color_dict, label2rgb
+from .delta_e import deltaE_cie76, deltaE_ciede94, deltaE_ciede2000, deltaE_cmc
diff --git a/python/cucim/src/cucim/skimage/color/colorconv.py b/python/cucim/src/cucim/skimage/color/colorconv.py
index 89eeffabb..b7d33fc10 100644
--- a/python/cucim/src/cucim/skimage/color/colorconv.py
+++ b/python/cucim/src/cucim/skimage/color/colorconv.py
@@ -58,7 +58,6 @@
 from .._shared.utils import (
     _supported_float_type,
     channel_as_last_axis,
-    deprecate_func,
     identity,
 )
 from ..util import dtype, dtype_limits
@@ -80,7 +79,7 @@ def convert_colorspace(arr, fromspace, tospace, *, channel_axis=-1):
 
     Parameters
     ----------
-    arr : (..., 3, ...) array_like
+    arr : (..., C=3, ...) array_like
         The image to convert. By default, the final dimension denotes
         channels.
     fromspace : str
@@ -93,7 +92,7 @@ def convert_colorspace(arr, fromspace, tospace, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The converted image. Same dimensions as input.
 
     Raises
@@ -206,7 +205,7 @@ def rgba2rgb(rgba, background=(1, 1, 1), *, channel_axis=-1):
 
     Parameters
     ----------
-    rgba : (..., 4, ...) array_like
+    rgba : (..., C=4, ...) array_like
         The image in RGBA format. By default, the final dimension denotes
         channels.
     background : array_like
@@ -218,7 +217,7 @@ def rgba2rgb(rgba, background=(1, 1, 1), *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
@@ -326,7 +325,7 @@ def rgb2hsv(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -335,13 +334,13 @@ def rgb2hsv(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in HSV format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -699,43 +698,6 @@ def xyz_tristimulus_values(*, illuminant, observer, dtype=None):
         )
 
 
-@deprecate_func(
-    hint="Use `skimage.color.xyz_tristimulus_values` instead.",
-    deprecated_version="23.08",
-    removed_version="24.06",
-)
-def get_xyz_coords(illuminant, observer, dtype=float):
-    """Get the XYZ coordinates of the given illuminant and observer [1]_.
-
-    Parameters
-    ----------
-    illuminant : {"A", "B", "C", "D50", "D55", "D65", "D75", "E"}, optional
-        The name of the illuminant (the function is NOT case sensitive).
-    observer : {"2", "10", "R"}, optional
-        One of: 2-degree observer, 10-degree observer, or 'R' observer as in
-        R function grDevices::convertColor.
-    dtype: dtype, optional
-        Output data type.
-
-    Returns
-    -------
-    out : array
-        Array with 3 elements containing the XYZ coordinates of the given
-        illuminant.
-
-    Raises
-    ------
-    ValueError
-        If either the illuminant or the observer angle are not supported or
-        unknown.
-
-    References
-    ----------
-    .. [1] https://en.wikipedia.org/wiki/Standard_illuminant
-    """
-    return xyz_tristimulus_values(illuminant=illuminant, observer=observer)
-
-
 # Haematoxylin-Eosin-DAB colorspace
 # From original Ruifrok's paper: A. C. Ruifrok and D. A. Johnston,
 # "Quantification of histochemical staining by color deconvolution,"
@@ -845,12 +807,12 @@ def _convert(matrix, arr, pre='', post='', name='_convert'):
     ----------
     matrix : array_like
         The 3x3 matrix to use.
-    arr : (..., 3) array_like
+    arr : (..., C=3) array_like
         The input array. Final dimension denotes channels.
 
     Returns
     -------
-    out : (..., 3) ndarray
+    out : (..., C=3) ndarray
         The converted array. Same dimensions as input.
     """
     arr = _prepare_colorarray(arr)
@@ -881,7 +843,7 @@ def xyz2rgb(xyz, *, channel_axis=-1):
 
     Parameters
     ----------
-    xyz : (..., 3, ...) array_like
+    xyz : (..., C=3, ...) array_like
         The image in XYZ format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -890,13 +852,13 @@ def xyz2rgb(xyz, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `xyz` is not at least 2-D with shape (..., 3, ...).
+        If `xyz` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -941,7 +903,7 @@ def rgb2xyz(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -950,13 +912,13 @@ def rgb2xyz(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in XYZ format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -999,7 +961,7 @@ def rgb2rgbcie(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -1008,13 +970,13 @@ def rgb2rgbcie(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB CIE format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     References
     ----------
@@ -1036,7 +998,7 @@ def rgbcie2rgb(rgbcie, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgbcie : (..., 3, ...) array_like
+    rgbcie : (..., C=3, ...) array_like
         The image in RGB CIE format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -1045,13 +1007,13 @@ def rgbcie2rgb(rgbcie, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgbcie` is not at least 2-D with shape (..., 3, ...).
+        If `rgbcie` is not at least 2-D with shape (..., C=3, ...).
 
     References
     ----------
@@ -1085,7 +1047,7 @@ def rgb2gray(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
 
@@ -1098,7 +1060,7 @@ def rgb2gray(rgb, *, channel_axis=-1):
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -1129,7 +1091,7 @@ def rgb2gray(rgb, *, channel_axis=-1):
     return gray
 
 
-def gray2rgba(image, alpha=None, *, channel_axis=-1):
+def gray2rgba(image, alpha=None, *, channel_axis=-1, check_alpha=True):
     """Create a RGBA representation of a gray-level image.
 
     Parameters
@@ -1143,6 +1105,10 @@ def gray2rgba(image, alpha=None, *, channel_axis=-1):
     channel_axis : int, optional
         This parameter indicates which axis of the output array will correspond
         to channels.
+    check_alpha : bool, optional
+        Checking for unsafe casting of alpha adds overhead, so can be disabled
+        on request. Note: This kwarg is not present in scikit-image (it always
+        checks the alpha array).
 
     Returns
     -------
@@ -1151,20 +1117,36 @@ def gray2rgba(image, alpha=None, *, channel_axis=-1):
         image shape.
     """
 
-    alpha_min, alpha_max = dtype_limits(image, clip_negative=False)
-
     if alpha is None:
-        alpha = alpha_max
-
-    if not cp.can_cast(alpha, image.dtype):
-        warn("alpha can't be safely cast to image dtype {}"
-             .format(image.dtype.name), stacklevel=2)
+        _, alpha = dtype_limits(image, clip_negative=False)
 
     if np.isscalar(alpha):
-        alpha = cp.full(image.shape, alpha, dtype=image.dtype)
-    elif alpha.shape != image.shape:
-        raise ValueError("alpha.shape must match image.shape")
-    rgba = np.stack((image,) * 3 + (alpha,), axis=channel_axis)
+        if check_alpha:
+            # do not use np.can_cast here for NumPy 2.0 compatibility
+            with np.errstate(over="ignore", under="ignore"):
+                alpha_cast = np.asarray(alpha).astype(image.dtype)
+            if alpha_cast != alpha:
+                warn(
+                    'alpha cannot be safely cast to image dtype '
+                    f'{image.dtype.name}',
+                    stacklevel=2
+                )
+        alpha_arr = cp.full(image.shape, alpha, dtype=image.dtype)
+    else:
+        alpha_arr = cp.asarray(alpha).astype(image.dtype, copy=False)
+        if check_alpha and not cp.array_equal(alpha, alpha_arr):
+            warn(
+                'alpha cannot be safely cast to image dtype '
+                f'{image.dtype.name}',
+                stacklevel=2
+            )
+        if alpha_arr.shape != image.shape:
+            try:
+                alpha_arr = cp.broadcast_to(alpha_arr, image.shape)
+            except ValueError as e:
+                raise ValueError("alpha.shape must match image.shape") from e
+
+    rgba = np.stack((image,) * 3 + (alpha_arr,), axis=channel_axis)
     return rgba
 
 
@@ -1181,7 +1163,7 @@ def gray2rgb(image, *, channel_axis=-1):
 
     Returns
     -------
-    rgb : (..., 3, ...) ndarray
+    rgb : (..., C=3, ...) ndarray
         RGB image. A new dimension of length 3 is added to input image.
 
     Notes
@@ -1231,7 +1213,7 @@ def xyz2lab(xyz, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Parameters
     ----------
-    xyz : (..., 3, ...) array_like
+    xyz : (..., C=3, ...) array_like
         The image in XYZ format. By default, the final dimension denotes
         channels.
     illuminant : {"A", "B", "C", "D50", "D55", "D65", "D75", "E"}, optional
@@ -1245,13 +1227,13 @@ def xyz2lab(xyz, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in CIE-LAB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `xyz` is not at least 2-D with shape (..., 3, ...).
+        If `xyz` is not at least 2-D with shape (..., C=3, ...).
     ValueError
         If either the illuminant or the observer angle is unsupported or
         unknown.
@@ -1335,7 +1317,7 @@ def lab2xyz(lab, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Parameters
     ----------
-    lab : (..., 3, ...) array_like
+    lab : (..., C=3, ...) array_like
         The input image in CIE-LAB color space.
         Unless `channel_axis` is set, the final dimension denotes the CIE-LAB
         channels.
@@ -1351,13 +1333,13 @@ def lab2xyz(lab, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in XYZ color space, of same shape as input.
 
     Raises
     ------
     ValueError
-        If `lab` is not at least 2-D with shape (..., 3, ...).
+        If `lab` is not at least 2-D with shape (..., C=3, ...).
     ValueError
         If either the illuminant or the observer angle are not supported or
         unknown.
@@ -1399,7 +1381,7 @@ def _lab2xyz(lab, illuminant, observer, channel_axis):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in XYZ format. Same dimensions as input.
     n_invalid : int
         Number of invalid pixels in the Z channel after conversion.
@@ -1430,7 +1412,7 @@ def rgb2lab(rgb, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     illuminant : {"A", "B", "C", "D50", "D55", "D65", "D75", "E"}, optional
@@ -1443,13 +1425,13 @@ def rgb2lab(rgb, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in Lab format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -1475,7 +1457,7 @@ def lab2rgb(lab, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Parameters
     ----------
-    lab : (..., 3, ...) array_like
+    lab : (..., C=3, ...) array_like
         The input image in CIE-LAB color space.
         Unless `channel_axis` is set, the final dimension denotes the CIE-LAB
         channels.
@@ -1491,13 +1473,13 @@ def lab2rgb(lab, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in sRGB color space, of same shape as input.
 
     Raises
     ------
     ValueError
-        If `lab` is not at least 2-D with shape (..., 3, ...).
+        If `lab` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -1582,7 +1564,7 @@ def xyz2luv(xyz, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Parameters
     ----------
-    xyz : (..., 3, ...) array_like
+    xyz : (..., C=3, ...) array_like
         The image in XYZ format. By default, the final dimension denotes
         channels.
     illuminant : {"A", "B", "C", "D50", "D55", "D65", "D75", "E"}, optional
@@ -1595,13 +1577,13 @@ def xyz2luv(xyz, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in CIE-Luv format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `xyz` is not at least 2-D with shape (..., 3, ...).
+        If `xyz` is not at least 2-D with shape (..., C=3, ...).
     ValueError
         If either the illuminant or the observer angle are not supported or
         unknown.
@@ -1688,7 +1670,7 @@ def luv2xyz(luv, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Parameters
     ----------
-    luv : (..., 3, ...) array_like
+    luv : (..., C=3, ...) array_like
         The image in CIE-Luv format. By default, the final dimension denotes
         channels.
     illuminant : {"A", "B", "C", "D50", "D55", "D65", "D75", "E"}, optional
@@ -1701,13 +1683,13 @@ def luv2xyz(luv, illuminant="D65", observer="2", *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in XYZ format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `luv` is not at least 2-D with shape (..., 3, ...).
+        If `luv` is not at least 2-D with shape (..., C=3, ...).
     ValueError
         If either the illuminant or the observer angle are not supported or
         unknown.
@@ -1741,7 +1723,7 @@ def rgb2luv(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -1750,13 +1732,13 @@ def rgb2luv(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in CIE Luv format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -1776,19 +1758,19 @@ def luv2rgb(luv, *, channel_axis=-1):
 
     Parameters
     ----------
-    luv : (..., 3, ...) array_like
+    luv : (..., C=3, ...) array_like
         The image in CIE Luv format. By default, the final dimension denotes
         channels.
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `luv` is not at least 2-D with shape (..., 3, ...).
+        If `luv` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -1803,7 +1785,7 @@ def rgb2hed(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -1812,13 +1794,13 @@ def rgb2hed(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in HED format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     References
     ----------
@@ -1844,7 +1826,7 @@ def hed2rgb(hed, *, channel_axis=-1):
 
     Parameters
     ----------
-    hed : (..., 3, ...) array_like
+    hed : (..., C=3, ...) array_like
         The image in the HED color space. By default, the final dimension
         denotes channels.
     channel_axis : int, optional
@@ -1853,13 +1835,13 @@ def hed2rgb(hed, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `hed` is not at least 2-D with shape (..., 3, ...).
+        If `hed` is not at least 2-D with shape (..., C=3, ...).
 
     References
     ----------
@@ -1906,7 +1888,7 @@ def separate_stains(rgb, conv_matrix, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     conv_matrix: ndarray
@@ -1917,13 +1899,13 @@ def separate_stains(rgb, conv_matrix, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in stain color space. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -2016,7 +1998,7 @@ def combine_stains(stains, conv_matrix, *, channel_axis=-1):
 
     Parameters
     ----------
-    stains : (..., 3, ...) array_like
+    stains : (..., C=3, ...) array_like
         The image in stain color space. By default, the final dimension denotes
         channels.
     conv_matrix: ndarray
@@ -2027,13 +2009,13 @@ def combine_stains(stains, conv_matrix, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `stains` is not at least 2-D with shape (..., 3, ...).
+        If `stains` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -2115,7 +2097,7 @@ def lab2lch(lab, *, channel_axis=-1):
 
     Parameters
     ----------
-    lab : (..., 3, ...) array_like
+    lab : (..., C=3, ...) array_like
         The N-D image in CIE-LAB format. The last (``N+1``-th) dimension must
         have at least 3 elements, corresponding to the ``L``, ``a``, and ``b``
         color channels. Subsequent elements are copied.
@@ -2125,7 +2107,7 @@ def lab2lch(lab, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in LCH format, in a N-D array with same shape as input `lab`.
 
     Raises
@@ -2190,7 +2172,7 @@ def lch2lab(lch, *, channel_axis=-1):
 
     Parameters
     ----------
-    lch : (..., 3, ...) array_like
+    lch : (..., C=3, ...) array_like
         The N-D image in CIE-LCH format. The last (``N+1``-th) dimension must
         have at least 3 elements, corresponding to the ``L``, ``a``, and ``b``
         color channels.  Subsequent elements are copied.
@@ -2200,7 +2182,7 @@ def lch2lab(lch, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in LAB format, with same shape as input `lch`.
 
     Raises
@@ -2251,7 +2233,7 @@ def rgb2yuv(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2260,13 +2242,13 @@ def rgb2yuv(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in YUV format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -2286,7 +2268,7 @@ def rgb2yiq(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2295,13 +2277,13 @@ def rgb2yiq(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in YIQ format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
     """
     return _convert(yiq_from_rgb, rgb, name='rgb2yiq')
 
@@ -2312,7 +2294,7 @@ def rgb2ypbpr(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2321,13 +2303,13 @@ def rgb2ypbpr(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in YPbPr format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     References
     ----------
@@ -2342,7 +2324,7 @@ def rgb2ycbcr(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2351,13 +2333,13 @@ def rgb2ycbcr(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in YCbCr format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -2383,7 +2365,7 @@ def rgb2ydbdr(rgb, *, channel_axis=-1):
 
     Parameters
     ----------
-    rgb : (..., 3, ...) array_like
+    rgb : (..., C=3, ...) array_like
         The image in RGB format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2392,13 +2374,13 @@ def rgb2ydbdr(rgb, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in YDbDr format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `rgb` is not at least 2-D with shape (..., 3, ...).
+        If `rgb` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -2418,19 +2400,19 @@ def yuv2rgb(yuv, *, channel_axis=-1):
 
     Parameters
     ----------
-    yuv : (..., 3, ...) array_like
+    yuv : (..., C=3, ...) array_like
         The image in YUV format. By default, the final dimension denotes
         channels.
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `yuv` is not at least 2-D with shape (..., 3, ...).
+        If `yuv` is not at least 2-D with shape (..., C=3, ...).
 
     References
     ----------
@@ -2445,7 +2427,7 @@ def yiq2rgb(yiq, *, channel_axis=-1):
 
     Parameters
     ----------
-    yiq : (..., 3, ...) array_like
+    yiq : (..., C=3, ...) array_like
         The image in YIQ format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2454,13 +2436,13 @@ def yiq2rgb(yiq, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `yiq` is not at least 2-D with shape (..., 3, ...).
+        If `yiq` is not at least 2-D with shape (..., C=3, ...).
     """
     return _convert(rgb_from_yiq, yiq, name='yiq2rgb')
 
@@ -2471,7 +2453,7 @@ def ypbpr2rgb(ypbpr, *, channel_axis=-1):
 
     Parameters
     ----------
-    ypbpr : (..., 3, ...) array_like
+    ypbpr : (..., C=3, ...) array_like
         The image in YPbPr format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2480,13 +2462,13 @@ def ypbpr2rgb(ypbpr, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3) ndarray
+    out : (..., C=3) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `ypbpr` is not at least 2-D with shape (..., 3).
+        If `ypbpr` is not at least 2-D with shape (..., C=3).
 
     References
     ----------
@@ -2501,7 +2483,7 @@ def ycbcr2rgb(ycbcr, *, channel_axis=-1):
 
     Parameters
     ----------
-    ycbcr : (..., 3, ...) array_like
+    ycbcr : (..., C=3, ...) array_like
         The image in YCbCr format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2510,13 +2492,13 @@ def ycbcr2rgb(ycbcr, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `ycbcr` is not at least 2-D with shape (..., 3, ...).
+        If `ycbcr` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
@@ -2543,7 +2525,7 @@ def ydbdr2rgb(ydbdr, *, channel_axis=-1):
 
     Parameters
     ----------
-    ydbdr : (..., 3, ...) array_like
+    ydbdr : (..., C=3, ...) array_like
         The image in YDbDr format. By default, the final dimension denotes
         channels.
     channel_axis : int, optional
@@ -2552,13 +2534,13 @@ def ydbdr2rgb(ydbdr, *, channel_axis=-1):
 
     Returns
     -------
-    out : (..., 3, ...) ndarray
+    out : (..., C=3, ...) ndarray
         The image in RGB format. Same dimensions as input.
 
     Raises
     ------
     ValueError
-        If `ydbdr` is not at least 2-D with shape (..., 3, ...).
+        If `ydbdr` is not at least 2-D with shape (..., C=3, ...).
 
     Notes
     -----
diff --git a/python/cucim/src/cucim/skimage/color/colorlabel.py b/python/cucim/src/cucim/skimage/color/colorlabel.py
index 2ac4cf85a..e0eb64e7f 100644
--- a/python/cucim/src/cucim/skimage/color/colorlabel.py
+++ b/python/cucim/src/cucim/skimage/color/colorlabel.py
@@ -39,7 +39,8 @@ def _rgb_vector(color):
     Parameters
     ----------
     color : str or array
-        Color name in `color_dict` or RGB float values between [0, 1].
+        Color name in `cucim.skimage.color.color_dict` or RGB float values
+        between [0, 1].
     """
     if isinstance(color, str):
         color = color_dict[color]
@@ -60,6 +61,9 @@ def _match_label_with_color(label, colors, bg_label, bg_color):
 
     # map labels to their ranks among all labels from small to large
     unique_labels, mapped_labels = cp.unique(label, return_inverse=True)
+    # output of unique with return_inverse=True is no longer flat in NumPy 2.0
+    # guarding against a potential corresponding future change for CuPy here
+    mapped_labels = mapped_labels.reshape(-1)
 
     # get rank of bg_label
     bg_label_rank_list = mapped_labels[label.ravel() == bg_label]
@@ -114,8 +118,8 @@ def label2rgb(
         `bg_color` is `None`, and `kind` is `overlay`,
         background is not painted by any colors.
     bg_color : str or array, optional
-        Background color. Must be a name in `color_dict` or RGB float values
-        between [0, 1].
+        Background color. Must be a name in `cucim.skimage.color.color_dict` or
+        RGB float values between [0, 1].
     image_alpha : float [0, 1], optional
         Opacity of the image.
     kind : string, one of {'overlay', 'avg'}
@@ -188,8 +192,8 @@ def _label2rgb_overlay(
         Label that's treated as the background. If `bg_label` is specified and
         `bg_color` is `None`, background is not painted by any colors.
     bg_color : str or array, optional
-        Background color. Must be a name in `color_dict` or RGB float values
-        between [0, 1].
+        Background color. Must be a name in `cucim.skimage.color.color_dict` or
+        RGB float values between [0, 1].
     image_alpha : float [0, 1], optional
         Opacity of the image.
     saturation : float [0, 1], optional
diff --git a/python/cucim/src/cucim/skimage/data/_binary_blobs.py b/python/cucim/src/cucim/skimage/data/_binary_blobs.py
index ad2451eeb..4659c798e 100644
--- a/python/cucim/src/cucim/skimage/data/_binary_blobs.py
+++ b/python/cucim/src/cucim/skimage/data/_binary_blobs.py
@@ -1,14 +1,20 @@
 import cupy as cp
 
 from .._shared.filters import gaussian
-from .._shared.utils import deprecate_kwarg
+from .._shared.utils import DEPRECATED, deprecate_parameter
 
 
-@deprecate_kwarg(
-    {"seed": "rng"}, deprecated_version="23.12.00", removed_version="24.12.00"
+@deprecate_parameter(
+    "seed", new_name="rng", start_version="23.12.00", stop_version="24.12.00"
 )
 def binary_blobs(
-    length=512, blob_size_fraction=0.1, n_dim=2, volume_fraction=0.5, rng=None
+    length=512,
+    blob_size_fraction=0.1,
+    n_dim=2,
+    volume_fraction=0.5,
+    rng=None,
+    *,
+    seed=DEPRECATED,
 ):
     """
     Generate synthetic binary image with several rounded blob-like objects.
@@ -57,7 +63,6 @@ def binary_blobs(
     >>> # Blobs cover a smaller volume fraction of the image
     >>> blobs = data.binary_blobs(length=256, volume_fraction=0.3)
     """  # noqa: E501
-
     rs = cp.random.default_rng(rng)
     shape = tuple([length] * n_dim)
     mask = cp.zeros(shape)
diff --git a/python/cucim/src/cucim/skimage/exposure/__init__.py b/python/cucim/src/cucim/skimage/exposure/__init__.py
index 5af690875..62d86f776 100644
--- a/python/cucim/src/cucim/skimage/exposure/__init__.py
+++ b/python/cucim/src/cucim/skimage/exposure/__init__.py
@@ -1,25 +1,3 @@
-from ._adapthist import equalize_adapthist
-from .exposure import (
-    adjust_gamma,
-    adjust_log,
-    adjust_sigmoid,
-    cumulative_distribution,
-    equalize_hist,
-    histogram,
-    is_low_contrast,
-    rescale_intensity,
-)
-from .histogram_matching import match_histograms
+import lazy_loader as lazy
 
-__all__ = [
-    "histogram",
-    "equalize_hist",
-    "equalize_adapthist",
-    "rescale_intensity",
-    "cumulative_distribution",
-    "adjust_gamma",
-    "adjust_sigmoid",
-    "adjust_log",
-    "is_low_contrast",
-    "match_histograms",
-]
+__getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
diff --git a/python/cucim/src/cucim/skimage/exposure/__init__.pyi b/python/cucim/src/cucim/skimage/exposure/__init__.pyi
new file mode 100644
index 000000000..72b33665f
--- /dev/null
+++ b/python/cucim/src/cucim/skimage/exposure/__init__.pyi
@@ -0,0 +1,29 @@
+# Explicitly setting `__all__` is necessary for type inference engines
+# to know which symbols are exported. See
+# https://peps.python.org/pep-0484/#stub-files
+
+__all__ = [
+    "histogram",
+    "equalize_hist",
+    "equalize_adapthist",
+    "rescale_intensity",
+    "cumulative_distribution",
+    "adjust_gamma",
+    "adjust_sigmoid",
+    "adjust_log",
+    "is_low_contrast",
+    "match_histograms",
+]
+
+from ._adapthist import equalize_adapthist
+from .exposure import (
+    adjust_gamma,
+    adjust_log,
+    adjust_sigmoid,
+    cumulative_distribution,
+    equalize_hist,
+    histogram,
+    is_low_contrast,
+    rescale_intensity,
+)
+from .histogram_matching import match_histograms
diff --git a/python/cucim/src/cucim/skimage/exposure/_adapthist.py b/python/cucim/src/cucim/skimage/exposure/_adapthist.py
index bb449517a..81c3a77e7 100644
--- a/python/cucim/src/cucim/skimage/exposure/_adapthist.py
+++ b/python/cucim/src/cucim/skimage/exposure/_adapthist.py
@@ -15,8 +15,6 @@
 import cupy as cp
 import numpy as np
 
-# TODO: replace _misc.prod with math.prod once minimum Python >= 3.88
-from cucim import _misc
 from cucim.skimage.exposure.exposure import rescale_intensity
 
 from .._shared.utils import _supported_float_type
@@ -37,7 +35,7 @@ def equalize_adapthist(image, kernel_size=None, clip_limit=0.01, nbins=256):
 
     Parameters
     ----------
-    image : (N1, ...,NN[, C]) ndarray
+    image : (M[, ...][, C]) ndarray
         Input image.
     kernel_size : int or array_like, optional
         Defines the shape of contextual regions used in the algorithm. If
@@ -53,7 +51,7 @@ def equalize_adapthist(image, kernel_size=None, clip_limit=0.01, nbins=256):
 
     Returns
     -------
-    out : (N1, ...,NN[, C]) ndarray
+    out : (M[, ...][, C]) ndarray
         Equalized image with float64 dtype.
 
     See Also
@@ -103,7 +101,7 @@ def _clahe(image, kernel_size, clip_limit, nbins):
 
     Parameters
     ----------
-    image : (N1,...,NN) ndarray
+    image : (M[, ...]) ndarray
         Input image.
     kernel_size : int or N-tuple of int
         Defines the shape of contextual regions used in the algorithm.
@@ -115,7 +113,7 @@ def _clahe(image, kernel_size, clip_limit, nbins):
 
     Returns
     -------
-    out : (N1,...,NN) ndarray
+    out : (M[, ...]) ndarray
         Equalized image.
 
     The number of "effective" graylevels in the output image is set by `nbins`;
@@ -164,10 +162,10 @@ def _clahe(image, kernel_size, clip_limit, nbins):
     hist_blocks = image[tuple(hist_slices)].reshape(hist_blocks_shape)
     hist_blocks = hist_blocks.transpose(hist_blocks_axis_order)
     hist_block_assembled_shape = hist_blocks.shape
-    hist_blocks = hist_blocks.reshape((_misc.prod(ns_hist), -1))
+    hist_blocks = hist_blocks.reshape((math.prod(ns_hist), -1))
 
     # Calculate actual clip limit
-    kernel_elements = _misc.prod(kernel_size)
+    kernel_elements = math.prod(kernel_size)
     if clip_limit > 0.0:
         clim = int(max(clip_limit * kernel_elements, 1))
     else:
@@ -212,7 +210,7 @@ def _clahe(image, kernel_size, clip_limit, nbins):
     blocks = blocks.transpose(blocks_axis_order)
     blocks_flattened_shape = blocks.shape
     blocks = blocks.reshape(
-        (_misc.prod(ns_proc), _misc.prod(blocks.shape[ndim:]))
+        (math.prod(ns_proc), math.prod(blocks.shape[ndim:]))
     )
 
     # calculate interpolation coefficients
@@ -229,7 +227,7 @@ def _clahe(image, kernel_size, clip_limit, nbins):
         edge_maps = map_array[
             tuple(slice(e, e + n) for e, n in zip(edge, ns_proc))
         ]
-        edge_maps = edge_maps.reshape((_misc.prod(ns_proc), -1))
+        edge_maps = edge_maps.reshape((math.prod(ns_proc), -1))
 
         # apply map
         edge_mapped = cp.take_along_axis(edge_maps, blocks, axis=-1)
diff --git a/python/cucim/src/cucim/skimage/exposure/exposure.py b/python/cucim/src/cucim/skimage/exposure/exposure.py
index 0d521cf66..2daa33cbe 100644
--- a/python/cucim/src/cucim/skimage/exposure/exposure.py
+++ b/python/cucim/src/cucim/skimage/exposure/exposure.py
@@ -370,7 +370,7 @@ def cumulative_distribution(image, nbins=256):
     >>> image = img_as_float(cp.array(data.camera()))
     >>> hi = exposure.histogram(image)
     >>> cdf = exposure.cumulative_distribution(image)
-    >>> cp.alltrue(cdf[0] == cp.cumsum(hi[0])/float(image.size))
+    >>> cp.all(cdf[0] == cp.cumsum(hi[0])/float(image.size))
     array(True)
     """
     hist, bin_centers = histogram(image, nbins)
diff --git a/python/cucim/src/cucim/skimage/feature/_basic_features.py b/python/cucim/src/cucim/skimage/feature/_basic_features.py
index c6e45b966..16eeb9bff 100644
--- a/python/cucim/src/cucim/skimage/feature/_basic_features.py
+++ b/python/cucim/src/cucim/skimage/feature/_basic_features.py
@@ -26,7 +26,7 @@ def _singlescale_basic_features_singlechannel(
     img, sigma, intensity=True, edges=True, texture=True
 ):
     results = ()
-    gaussian_filtered = filters.gaussian(img, sigma, preserve_range=False)
+    gaussian_filtered = filters.gaussian(img, sigma=sigma, preserve_range=False)
     if intensity:
         results += (gaussian_filtered,)
     if edges:
diff --git a/python/cucim/src/cucim/skimage/feature/_hessian_det_appx.py b/python/cucim/src/cucim/skimage/feature/_hessian_det_appx.py
index b93d61fc2..85e26df52 100644
--- a/python/cucim/src/cucim/skimage/feature/_hessian_det_appx.py
+++ b/python/cucim/src/cucim/skimage/feature/_hessian_det_appx.py
@@ -58,7 +58,7 @@ def _get_hessian_det_appx_kernel(dtype, large_int) -> cp.RawModule:
         os.path.normpath(os.path.dirname(__file__)), "cuda"
     )
     cu_file = os.path.join(kernel_directory, "_hessian_det_appx.cu")
-    with open(cu_file, "rt") as f:
+    with open(cu_file) as f:
         _code = f.read()
 
     return cp.RawModule(
diff --git a/python/cucim/src/cucim/skimage/feature/blob.py b/python/cucim/src/cucim/skimage/feature/blob.py
index 6b47f8777..c8ab35e09 100644
--- a/python/cucim/src/cucim/skimage/feature/blob.py
+++ b/python/cucim/src/cucim/skimage/feature/blob.py
@@ -68,7 +68,7 @@ def _get_prune_blob_rawmodule(dtype, large_int) -> cp.RawModule:
     kernel_directory = os.path.join(
         os.path.normpath(os.path.dirname(__file__)), "cuda"
     )
-    with open(os.path.join(kernel_directory, "blob.cu"), "rt") as f:
+    with open(os.path.join(kernel_directory, "blob.cu")) as f:
         _code = f.read()
 
     return cp.RawModule(
@@ -316,9 +316,9 @@ def blob_dog(
     # to obtain an approximation of the scale invariant Laplacian of the
     # Gaussian operator
     dog_image_cube = cp.empty(image.shape + (k,), dtype=float_dtype)
-    gaussian_previous = gaussian(image, sigma_list[0], mode="reflect")
+    gaussian_previous = gaussian(image, sigma=sigma_list[0], mode="reflect")
     for i, s in enumerate(sigma_list[1:]):
-        gaussian_current = gaussian(image, s, mode="reflect")
+        gaussian_current = gaussian(image, sigma=s, mode="reflect")
         dog_image_cube[..., i] = gaussian_previous - gaussian_current
         gaussian_previous = gaussian_current
 
diff --git a/python/cucim/src/cucim/skimage/feature/corner.py b/python/cucim/src/cucim/skimage/feature/corner.py
index a58120bde..e35d648f8 100644
--- a/python/cucim/src/cucim/skimage/feature/corner.py
+++ b/python/cucim/src/cucim/skimage/feature/corner.py
@@ -4,7 +4,7 @@
 
 import cupy as cp
 import numpy as np
-from scipy import spatial  # TODO: use cuSpatial if cKDTree becomes available
+from scipy import spatial  # TODO: use cuSpatial if KDTree becomes available
 
 import cucim.skimage._vendored.ndimage as ndi
 from cucim.skimage.util import img_as_float
@@ -139,7 +139,11 @@ def structure_tensor(image, sigma=1, mode="constant", cval=0, order="rc"):
     # structure tensor
     A_elems = [
         gaussian(
-            der0 * der1, sigma, mode=mode, cval=cval, channel_axis=channel_axis
+            der0 * der1,
+            sigma=sigma,
+            mode=mode,
+            cval=cval,
+            channel_axis=channel_axis,
         )
         for der0, der1 in combinations_with_replacement(derivatives, 2)
     ]
@@ -1370,7 +1374,7 @@ def corner_peaks(
         coords = cp.asnumpy(coords)
 
         # Use KDtree to find the peaks that are too close to each other
-        tree = spatial.cKDTree(coords)
+        tree = spatial.KDTree(coords)
 
         rejected_peaks_indices = set()
         for idx, point in enumerate(coords):
diff --git a/python/cucim/src/cucim/skimage/feature/template.py b/python/cucim/src/cucim/skimage/feature/template.py
index 3cad08c90..9a7876042 100644
--- a/python/cucim/src/cucim/skimage/feature/template.py
+++ b/python/cucim/src/cucim/skimage/feature/template.py
@@ -1,6 +1,6 @@
-import cupy as cp
+import math
 
-from cucim import _misc
+import cupy as cp
 
 from .._shared.utils import _supported_float_type, check_nD
 from .._vendored import pad
@@ -168,7 +168,7 @@ def match_template(
 
     # perform mean and sum in float64 for accuracy
     template_mean = template.mean(dtype=cp.float64)
-    template_volume = _misc.prod(template.shape)
+    template_volume = math.prod(template.shape)
     template_ssd = template - template_mean
     template_ssd *= template_ssd
     template_ssd = cp.sum(template_ssd, dtype=cp.float64)
diff --git a/python/cucim/src/cucim/skimage/feature/util.py b/python/cucim/src/cucim/skimage/feature/util.py
index ef2ca1da7..298f422d0 100644
--- a/python/cucim/src/cucim/skimage/feature/util.py
+++ b/python/cucim/src/cucim/skimage/feature/util.py
@@ -25,7 +25,7 @@ def _mask_border_keypoints(image_shape, keypoints, distance):
 
     Parameters
     ----------
-    image_shape : (2, ) array_like
+    image_shape : (2,) array_like
         Shape of the image as ``(rows, cols)``.
     keypoints : (N, 2) array
         Keypoint coordinates as ``(rows, cols)``.
@@ -34,7 +34,7 @@ def _mask_border_keypoints(image_shape, keypoints, distance):
 
     Returns
     -------
-    mask : (N, ) bool array
+    mask : (N,) bool array
         Mask indicating if pixels are within the image (``True``) or in the
         border region of the image (``False``).
 
diff --git a/python/cucim/src/cucim/skimage/filters/_gaussian.py b/python/cucim/src/cucim/skimage/filters/_gaussian.py
index 0b23cce91..0f9ffb5be 100644
--- a/python/cucim/src/cucim/skimage/filters/_gaussian.py
+++ b/python/cucim/src/cucim/skimage/filters/_gaussian.py
@@ -150,7 +150,7 @@ def difference_of_gaussians(
 
     out = gaussian(
         image,
-        low_sigma,
+        sigma=low_sigma,
         mode=mode,
         cval=cval,
         channel_axis=channel_axis,
@@ -160,7 +160,7 @@ def difference_of_gaussians(
 
     out -= gaussian(
         image,
-        high_sigma,
+        sigma=high_sigma,
         mode=mode,
         cval=cval,
         channel_axis=channel_axis,
diff --git a/python/cucim/src/cucim/skimage/filters/_median.py b/python/cucim/src/cucim/skimage/filters/_median.py
index 20948dc0b..23d7b2ac0 100644
--- a/python/cucim/src/cucim/skimage/filters/_median.py
+++ b/python/cucim/src/cucim/skimage/filters/_median.py
@@ -1,3 +1,4 @@
+import math
 from warnings import warn
 
 import cupy as cp
@@ -5,24 +6,9 @@
 
 import cucim.skimage._vendored.ndimage as ndi
 
-from .._shared.utils import deprecate_kwarg
 from ._median_hist import KernelResourceError, _can_use_histogram, _median_hist
 
-try:
-    from math import prod
-except ImportError:
-    from functools import reduce
-    from operator import mul
 
-    def prod(x):
-        return reduce(mul, x)
-
-
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 def median(
     image,
     footprint=None,
@@ -131,11 +117,12 @@ def median(
             warn(
                 "Change 'behavior' to 'ndimage' if you want to use the "
                 "parameters 'mode' or 'cval'. They will be discarded "
-                "otherwise."
+                "otherwise.",
+                stacklevel=2,
             )
         raise NotImplementedError("rank behavior not currently implemented")
         # TODO: implement median rank filter
-        # return generic.median(image, selem=selem, out=out)
+        # return generic.median(image, footprint=footprint, out=out)
 
     if footprint is None:
         footprint_shape = (3,) * image.ndim
@@ -168,7 +155,7 @@ def median(
     use_histogram = can_use_histogram
     if algorithm == "auto":
         # prefer sorting-based algorithm if footprint shape is small
-        use_histogram = use_histogram and prod(footprint_shape) > 150
+        use_histogram = use_histogram and math.prod(footprint_shape) > 150
 
     if use_histogram:
         try:
diff --git a/python/cucim/src/cucim/skimage/filters/_median_hist.py b/python/cucim/src/cucim/skimage/filters/_median_hist.py
index e0fe08e81..403215b71 100644
--- a/python/cucim/src/cucim/skimage/filters/_median_hist.py
+++ b/python/cucim/src/cucim/skimage/filters/_median_hist.py
@@ -9,11 +9,6 @@
 from .._shared.utils import _to_np_mode
 from .._vendored import pad
 
-if hasattr(math, "prod"):
-    prod = math.prod
-else:
-    prod = np.prod
-
 
 def _dtype_to_CUDA_int_type(dtype):
     cpp_int_types = {
@@ -34,7 +29,7 @@ def _dtype_to_CUDA_int_type(dtype):
 
 def _get_hist_dtype(footprint_shape):
     """Determine C++ type and cupy.dtype to use for the histogram."""
-    max_possible_count = prod(footprint_shape)
+    max_possible_count = math.prod(footprint_shape)
 
     if max_possible_count < 128:
         dtype = cp.int8
@@ -194,7 +189,7 @@ def _get_median_rawkernel(
     )
 
     kernel_directory = os.path.join(os.path.dirname(__file__), "cuda")
-    with open(os.path.join(kernel_directory, "histogram_median.cu"), "rt") as f:
+    with open(os.path.join(kernel_directory, "histogram_median.cu")) as f:
         rank_filter_kernel = "\n".join(f.readlines())
 
     return cp.RawKernel(
@@ -458,11 +453,11 @@ def _median_hist(
 
     if footprint is None:
         footprint_shape = (3,) * image.ndim
-        med_pos = prod(footprint_shape) // 2
+        med_pos = math.prod(footprint_shape) // 2
     elif isinstance(footprint, tuple):
         footprint_shape = footprint
         footprint = None
-        med_pos = prod(footprint_shape) // 2
+        med_pos = math.prod(footprint_shape) // 2
     else:
         footprint_shape = footprint.shape
         med_pos = footprint.size // 2
diff --git a/python/cucim/src/cucim/skimage/filters/_separable_filtering.py b/python/cucim/src/cucim/skimage/filters/_separable_filtering.py
index 107d303e6..31effe313 100644
--- a/python/cucim/src/cucim/skimage/filters/_separable_filtering.py
+++ b/python/cucim/src/cucim/skimage/filters/_separable_filtering.py
@@ -3,7 +3,7 @@
 import cupy as cp
 
 from cucim.skimage._vendored import _ndimage_util as util
-from cucim.skimage._vendored._internal import _normalize_axis_index, prod
+from cucim.skimage._vendored._internal import _normalize_axis_index
 from cucim.skimage._vendored._ndimage_filters_core import (
     _ndimage_CAST_FUNCTION,
     _ndimage_includes,
@@ -83,7 +83,7 @@ def _get_smem_shape(
             shape = (bz, by, (patch_per_block + 2 * halo_size) * bx)
     else:
         raise NotImplementedError("TODO")
-    nbytes = cp.dtype(image_dtype).itemsize * prod(shape)
+    nbytes = cp.dtype(image_dtype).itemsize * math.prod(shape)
     return shape, nbytes
 
 
diff --git a/python/cucim/src/cucim/skimage/filters/_sparse.py b/python/cucim/src/cucim/skimage/filters/_sparse.py
index a1d674629..9e4d7b213 100644
--- a/python/cucim/src/cucim/skimage/filters/_sparse.py
+++ b/python/cucim/src/cucim/skimage/filters/_sparse.py
@@ -96,11 +96,11 @@ def correlate_sparse(image, kernel, mode="reflect"):
 
     Parameters
     ----------
-    image : ndarray, dtype float, shape (M, N,[ ...,] P)
+    image : ndarray, dtype float, shape (M, N[, ...], P)
         The input array. If mode is 'valid', this array should already be
         padded, as a margin of the same shape as kernel will be stripped
         off.
-    kernel : ndarray, dtype float shape (Q, R,[ ...,] S)
+    kernel : ndarray, dtype float shape (Q, R[, ...], S)
         The kernel to be correlated. Must have the same number of
         dimensions as `padded_array`. For high performance, it should
         be sparse (few nonzero entries).
@@ -112,9 +112,9 @@ def correlate_sparse(image, kernel, mode="reflect"):
 
     Returns
     -------
-    result : array of float, shape (M, N,[ ...,] P)
+    result : array of float, shape (M, N[, ...], P)
         The result of cross-correlating `image` with `kernel`. If mode
-        'valid' is used, the resulting shape is (M-Q+1, N-R+1,[ ...,] P-S+1).
+        'valid' is used, the resulting shape is (M-Q+1, N-R+1[, ...], P-S+1).
     """
 
     float_dtype = _supported_float_type(image.dtype)
diff --git a/python/cucim/src/cucim/skimage/filters/_unsharp_mask.py b/python/cucim/src/cucim/skimage/filters/_unsharp_mask.py
index 1be8e833d..1bb5cf4bd 100644
--- a/python/cucim/src/cucim/skimage/filters/_unsharp_mask.py
+++ b/python/cucim/src/cucim/skimage/filters/_unsharp_mask.py
@@ -27,7 +27,7 @@ def unsharp_mask(
 
     Parameters
     ----------
-    image : [P, ..., ]M[, N][, C] ndarray
+    image : (M[, ...][, C]) ndarray
         Input image.
     radius : scalar or sequence of scalars, optional
         If a scalar is given, then its value is used for all dimensions.
@@ -49,7 +49,7 @@ def unsharp_mask(
 
     Returns
     -------
-    output : [P, ..., ]M[, N][, C] ndarray of float
+    output : (M[, ...][, C]) ndarray of float
         Image with unsharp mask applied.
 
     Notes
diff --git a/python/cucim/src/cucim/skimage/filters/lpi_filter.py b/python/cucim/src/cucim/skimage/filters/lpi_filter.py
index a1bd20f7d..a4cb8202c 100644
--- a/python/cucim/src/cucim/skimage/filters/lpi_filter.py
+++ b/python/cucim/src/cucim/skimage/filters/lpi_filter.py
@@ -233,9 +233,9 @@ def wiener(
 
     Parameters
     ----------
-    data : (M,N) ndarray
+    data : (M, N) ndarray
         Input data.
-    K : float or (M,N) ndarray
+    K : float or (M, N) ndarray
         Ratio between power spectrum of noise and undegraded
         image.
     impulse_response : callable `f(r, c, **filter_params)`
diff --git a/python/cucim/src/cucim/skimage/filters/ridges.py b/python/cucim/src/cucim/skimage/filters/ridges.py
index 34bd008d3..139de12be 100644
--- a/python/cucim/src/cucim/skimage/filters/ridges.py
+++ b/python/cucim/src/cucim/skimage/filters/ridges.py
@@ -63,7 +63,7 @@ def meijering(
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (N, M[, ...]) ndarray
         Array with input image data.
     sigmas : iterable of floats, optional
         Sigmas used as scales of filter
@@ -157,7 +157,7 @@ def sato(
 
     Parameters
     ----------
-    image : (N, M[, P]) ndarray
+    image : (M, N[, P]) ndarray
         Array with input image data.
     sigmas : iterable of floats, optional
         Sigmas used as scales of filter.
@@ -172,7 +172,7 @@ def sato(
 
     Returns
     -------
-    out : (N, M[, P]) ndarray
+    out : (M, N[, P]) ndarray
         Filtered image (maximum of pixels across all scales).
 
     See also
@@ -340,7 +340,7 @@ def frangi(
 
     Parameters
     ----------
-    image : (N, M[, P]) ndarray
+    image : (M, N[, P]) ndarray
         Array with input image data.
     sigmas : iterable of floats, optional
         Sigmas used as scales of filter, i.e.,
@@ -370,7 +370,7 @@ def frangi(
 
     Returns
     -------
-    out : (N, M[, P]) ndarray
+    out : (M, N[, P]) ndarray
         Filtered image (maximum of pixels across all scales).
 
     Notes
@@ -496,7 +496,7 @@ def hessian(
 
     Parameters
     ----------
-    image : (N, M[, P]) ndarray
+    image : (M, N[, P]) ndarray
         Array with input image data.
     sigmas : iterable of floats, optional
         Sigmas used as scales of filter, i.e.,
@@ -522,7 +522,7 @@ def hessian(
 
     Returns
     -------
-    out : (N, M[, P]) ndarray
+    out : (M, N[, P]) ndarray
         Filtered image (maximum of pixels across all scales).
 
     Notes
diff --git a/python/cucim/src/cucim/skimage/filters/thresholding.py b/python/cucim/src/cucim/skimage/filters/thresholding.py
index 76127e52a..34337ef89 100644
--- a/python/cucim/src/cucim/skimage/filters/thresholding.py
+++ b/python/cucim/src/cucim/skimage/filters/thresholding.py
@@ -15,10 +15,9 @@
 )
 
 import cucim.skimage._vendored.ndimage as ndi
-from cucim import _misc
 
 from .._shared.filters import gaussian
-from .._shared.utils import _supported_float_type, deprecate_kwarg, warn
+from .._shared.utils import _supported_float_type, warn
 from .._shared.version_requirements import require
 from .._vendored import pad
 from ..exposure import histogram
@@ -48,7 +47,7 @@ def _try_all(image, methods=None, figsize=None, num_cols=2, verbose=True):
 
     Parameters
     ----------
-    image : (N, M) ndarray
+    image : (M, N) ndarray
         Input image.
     methods : dict, optional
         Names and associated functions.
@@ -118,7 +117,7 @@ def try_all_threshold(image, figsize=(8, 5), verbose=True):
 
     Parameters
     ----------
-    image : (N, M) ndarray
+    image : (M, N) ndarray
         Input image.
     figsize : tuple, optional
         Figure size (in inches).
@@ -197,7 +196,7 @@ def threshold_local(
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
     block_size : int or sequence of int
         Odd size of pixel neighborhood which is used to calculate the
@@ -212,7 +211,7 @@ def threshold_local(
         * 'mean': apply arithmetic mean filter
         * 'median': apply median rank filter
 
-        By default the 'gaussian' method is used.
+        By default, the 'gaussian' method is used.
     offset : float, optional
         Constant subtracted from weighted mean of neighborhood to calculate
         the local threshold value. Default offset is 0.
@@ -230,7 +229,7 @@ def threshold_local(
 
     Returns
     -------
-    threshold : (N, M[, ..., P]) ndarray
+    threshold : (M, N[, ...]) ndarray
         Threshold image. All pixels in the input image higher than the
         corresponding pixel in the threshold image are considered foreground.
 
@@ -271,7 +270,7 @@ def threshold_local(
             sigma = tuple([(b - 1) / 6.0 for b in block_size])
         else:
             sigma = param
-        gaussian(image, sigma, output=thresh_image, mode=mode, cval=cval)
+        gaussian(image, sigma=sigma, out=thresh_image, mode=mode, cval=cval)
     elif method == "mean":
         ndi.uniform_filter(
             image, block_size, output=thresh_image, mode=mode, cval=cval
@@ -354,7 +353,7 @@ def threshold_otsu(image=None, nbins=256, *, hist=None):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray, optional
+    image : (M, N[, ...]) ndarray, optional
         Grayscale input image.
     nbins : int, optional
         Number of bins used to calculate histogram. This value is ignored for
@@ -417,7 +416,7 @@ def threshold_yen(image=None, nbins=256, *, hist=None):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
     nbins : int, optional
         Number of bins used to calculate histogram. This value is ignored for
@@ -489,7 +488,7 @@ def threshold_isodata(image=None, nbins=256, return_all=False, *, hist=None):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
     nbins : int, optional
         Number of bins used to calculate histogram. This value is ignored for
@@ -612,7 +611,7 @@ def threshold_li(
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
 
     tolerance : float, optional
@@ -775,11 +774,6 @@ def threshold_li(
     return threshold
 
 
-@deprecate_kwarg(
-    {"max_iter": "max_num_iter"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 def threshold_minimum(image=None, nbins=256, max_num_iter=10000, *, hist=None):
     """Return threshold value based on minimum method.
 
@@ -792,7 +786,7 @@ def threshold_minimum(image=None, nbins=256, max_num_iter=10000, *, hist=None):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray, optional
+    image : (M, N[, ...]) ndarray, optional
         Grayscale input image.
     nbins : int, optional
         Number of bins used to calculate histogram. This value is ignored for
@@ -848,7 +842,7 @@ def threshold_mean(image):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
 
     Returns
@@ -879,7 +873,7 @@ def threshold_triangle(image, nbins=256):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
     nbins : int, optional
         Number of bins used to calculate histogram. This value is ignored for
@@ -983,7 +977,7 @@ def _mean_std(image, w):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
     w : int, or iterable of int
         Window size specified as a single odd integer (3, 5, 7, …),
@@ -1028,7 +1022,7 @@ def _mean_std(image, w):
         for indices in kernel_indices
     ]
 
-    total_window_size = _misc.prod(w)
+    total_window_size = math.prod(w)
     kernel_shape = tuple(_w + 1 for _w in w)
     m = _correlate_sparse(integral, kernel_shape, kernel_indices, kernel_values)
     m = m.astype(float_dtype, copy=False)
@@ -1060,7 +1054,7 @@ def threshold_niblack(image, window_size=15, k=0.2):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
     window_size : int, or iterable of int, optional
         Window size specified as a single odd integer (3, 5, 7, …),
@@ -1071,7 +1065,7 @@ def threshold_niblack(image, window_size=15, k=0.2):
 
     Returns
     -------
-    threshold : (N, M) ndarray
+    threshold : (M, N) ndarray
         Threshold mask. All pixels with an intensity higher than
         this value are assumed to be foreground.
 
@@ -1125,7 +1119,7 @@ def threshold_sauvola(image, window_size=15, k=0.2, r=None):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (M, N[, ...]) ndarray
         Grayscale input image.
     window_size : int, or iterable of int, optional
         Window size specified as a single odd integer (3, 5, 7, …),
@@ -1139,7 +1133,7 @@ def threshold_sauvola(image, window_size=15, k=0.2, r=None):
 
     Returns
     -------
-    threshold : (N, M) ndarray
+    threshold : (M, N) ndarray
         Threshold mask. All pixels with an intensity higher than
         this value are assumed to be foreground.
 
@@ -1236,7 +1230,7 @@ def threshold_multiotsu(image=None, classes=3, nbins=256, *, hist=None):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray, optional
+    image : (M, N[, ...]) ndarray, optional
         Grayscale input image.
     classes : int, optional
         Number of classes to be thresholded, i.e. the number of resulting
diff --git a/python/cucim/src/cucim/skimage/measure/_label.py b/python/cucim/src/cucim/skimage/measure/_label.py
index 50bff6241..7a922c20e 100644
--- a/python/cucim/src/cucim/skimage/measure/_label.py
+++ b/python/cucim/src/cucim/skimage/measure/_label.py
@@ -57,8 +57,8 @@ def label(label_image, background=None, return_num=False, connectivity=None):
 
     See Also
     --------
-    regionprops
-    regionprops_table
+    cucim.skimage.measure.regionprops
+    cucim.skimage.measure.regionprops_table
 
     References
     ----------
diff --git a/python/cucim/src/cucim/skimage/measure/_label_kernels.py b/python/cucim/src/cucim/skimage/measure/_label_kernels.py
index c93638230..f5a2ab404 100644
--- a/python/cucim/src/cucim/skimage/measure/_label_kernels.py
+++ b/python/cucim/src/cucim/skimage/measure/_label_kernels.py
@@ -76,7 +76,7 @@ def _kernel_connect(greyscale_mode=False, int_t="int"):
     # Note: atomicCAS is implemented for int, unsigned short, unsigned int, and
     # unsigned long long
 
-    code = """
+    code = f"""
         if (y[i] < 0) continue;
         for (int dr = 0; dr < ndirs; dr++) {{
             {int_t} j = i;
@@ -112,9 +112,7 @@ def _kernel_connect(greyscale_mode=False, int_t="int"):
                 }}
             }}
         }}
-        """.format(
-        x_condition=x_condition, int_t=int_t
-    )
+        """
 
     return cupy.ElementwiseKernel(
         in_params,
diff --git a/python/cucim/src/cucim/skimage/measure/_moments.py b/python/cucim/src/cucim/skimage/measure/_moments.py
index cdd87ae91..2c52b227f 100644
--- a/python/cucim/src/cucim/skimage/measure/_moments.py
+++ b/python/cucim/src/cucim/skimage/measure/_moments.py
@@ -14,7 +14,7 @@ def moments_coords(coords, order=3):
      * Area as: ``M[0, 0]``.
      * Centroid as: {``M[1, 0] / M[0, 0]``, ``M[0, 1] / M[0, 0]``}.
 
-    Note that raw moments are neither translation, scale nor rotation
+    Note that raw moments are neither translation, scale, nor rotation
     invariant.
 
     Parameters
@@ -174,11 +174,11 @@ def moments(image, order=3, *, spacing=None):
 
     Parameters
     ----------
-    image : nD double or uint8 array
+    image : (N[, ...]) double or uint8 array
         Rasterized shape as image.
     order : int, optional
         Maximum order of moments. Default is 3.
-    spacing: tuple of float, shape (ndim, )
+    spacing: tuple of float, shape (ndim,)
         The pixel spacing along each axis of the image.
 
     Returns
@@ -238,14 +238,14 @@ def moments_central(image, center=None, order=3, *, spacing=None, **kwargs):
 
     Parameters
     ----------
-    image : nD double or uint8 array
+    image : (N[, ...]) double or uint8 array
         Rasterized shape as image.
     center : tuple of float, optional
         Coordinates of the image centroid. This will be computed if it
         is not provided.
     order : int, optional
         The maximum order of moments computed.
-    spacing: tuple of float, shape (ndim, )
+    spacing: tuple of float, shape (ndim,)
         The pixel spacing along each axis of the image.
 
     Returns
@@ -283,9 +283,9 @@ def moments_central(image, center=None, order=3, *, spacing=None, **kwargs):
         #       The centroid will be obtained from the raw moments.
         moments_raw = moments(image, order=order, spacing=spacing)
         return moments_raw_to_central(moments_raw)
-    if spacing is None:
-        spacing = np.ones(image.ndim)
     float_dtype = _supported_float_type(image.dtype)
+    if spacing is None:
+        spacing = np.ones(image.ndim, dtype=float_dtype)
     calc = image.astype(float_dtype, copy=False)
     powers = cp.arange(order + 1, dtype=float_dtype)
     _delta = cp.arange(max(image.shape), dtype=float_dtype)[:, cp.newaxis]
@@ -363,15 +363,17 @@ def moments_normalized(mu, order=3, spacing=None):
 
     Parameters
     ----------
-    mu : (M,[ ...,] M) array
+    mu : (M[, ...], M) array
         Central image moments, where M must be greater than or equal
         to ``order``.
     order : int, optional
         Maximum order of moments. Default is 3.
+    spacing: tuple of float, shape (ndim,)
+        The pixel spacing along each axis of the image.
 
     Returns
     -------
-    nu : (``order + 1``,[ ...,] ``order + 1``) array
+    nu : (``order + 1``[, ...], ``order + 1``) array
         Normalized central image moments.
 
     Notes
@@ -491,7 +493,7 @@ def centroid(image, *, spacing=None):
     ----------
     image : array
         The input image.
-    spacing: tuple of float, shape (ndim, )
+    spacing: tuple of float, shape (ndim,)
         The pixel spacing along each axis of the image.
 
     Returns
@@ -583,7 +585,7 @@ def inertia_tensor(image, mu=None, *, spacing=None):
         (for example, `skimage.measure.regionprops`), then it is more
         efficient to pre-compute them and pass them to the inertia tensor
         call.
-    spacing : tuple of float, optional
+    spacing : tuple of float, shape (ndim,), optional
         The pixel spacing along each axis of the image.
 
     Returns
@@ -657,7 +659,7 @@ def inertia_tensor_eigvals(image, mu=None, T=None, *, spacing=None):
     T : array, shape ``(image.ndim, image.ndim)``
         The pre-computed inertia tensor. If ``T`` is given, ``mu`` and
         ``image`` are ignored.
-    spacing : tuple of float, optional
+    spacing : tuple of float, shape (ndim,), optional
         The pixel spacing along each axis of the image.
 
     Returns
diff --git a/python/cucim/src/cucim/skimage/measure/_polygon.py b/python/cucim/src/cucim/skimage/measure/_polygon.py
index f1bf8c9d3..e3deabd2a 100644
--- a/python/cucim/src/cucim/skimage/measure/_polygon.py
+++ b/python/cucim/src/cucim/skimage/measure/_polygon.py
@@ -13,7 +13,7 @@ def approximate_polygon(coords, tolerance):
 
     Parameters
     ----------
-    coords : (N, 2) array
+    coords : (K, 2) array
         Coordinate array.
     tolerance : float
         Maximum distance from original points of polygon to approximated
@@ -22,7 +22,7 @@ def approximate_polygon(coords, tolerance):
 
     Returns
     -------
-    coords : (M, 2) array
+    coords : (L, 2) array
         Approximated polygonal chain where M <= N.
 
     References
@@ -114,7 +114,7 @@ def subdivide_polygon(coords, degree=2, preserve_ends=False):
 
     Parameters
     ----------
-    coords : (N, 2) array
+    coords : (K, 2) array
         Coordinate array.
     degree : {1, 2, 3, 4, 5, 6, 7}, optional
         Degree of B-Spline. Default is 2.
@@ -124,7 +124,7 @@ def subdivide_polygon(coords, degree=2, preserve_ends=False):
 
     Returns
     -------
-    coords : (M, 2) array
+    coords : (L, 2) array
         Subdivided coordinate array.
 
     References
diff --git a/python/cucim/src/cucim/skimage/measure/_regionprops.py b/python/cucim/src/cucim/skimage/measure/_regionprops.py
index 6246798ee..c0b41c691 100644
--- a/python/cucim/src/cucim/skimage/measure/_regionprops.py
+++ b/python/cucim/src/cucim/skimage/measure/_regionprops.py
@@ -64,6 +64,7 @@
     "mean_intensity": "intensity_mean",
     "MinIntensity": "intensity_min",
     "min_intensity": "intensity_min",
+    "std_intensity": "intensity_std",
     "MinorAxisLength": "axis_minor_length",
     "minor_axis_length": "axis_minor_length",
     "Moments": "moments",
@@ -119,6 +120,7 @@
     "intensity_max": float,
     "intensity_mean": float,
     "intensity_min": float,
+    "intensity_std": float,
     "label": int,
     "moments": float,
     "moments_central": float,
@@ -145,6 +147,7 @@
     "intensity_max",
     "intensity_mean",
     "intensity_min",
+    "intensity_std",
     "moments_weighted",
     "moments_weighted_central",
     "centroid_weighted",
@@ -229,7 +232,10 @@ def wrapper(obj):
         cache = obj._cache
         prop = f.__name__
 
-        if not ((prop in cache) and obj._cache_active):
+        if not obj._cache_active:
+            return f(obj)
+
+        if prop not in cache:
             cache[prop] = f(obj)
 
         return cache[prop]
@@ -588,6 +594,11 @@ def intensity_min(self):
         vals = self.image_intensity[self.image]
         return cp.min(vals, axis=0).astype(cp.float64, copy=False)
 
+    @property
+    def intensity_std(self):
+        vals = self.image_intensity[self.image]
+        return cp.std(vals, axis=0)
+
     @property
     def axis_major_length(self):
         if self._ndim == 2:
@@ -817,7 +828,7 @@ def _props_to_dict(regions, properties=("label", "bbox"), separator="-"):
 
     Parameters
     ----------
-    regions : (N,) list
+    regions : (K,) list
         List of RegionProperties objects as returned by :func:`regionprops`.
     properties : tuple or list of str, optional
         Properties that will be included in the resulting dictionary
@@ -986,13 +997,12 @@ def regionprops_table(
 
     Parameters
     ----------
-    label_image : (N, M[, P]) ndarray
+    label_image : (M, N[, P]) ndarray
         Labeled input image. Labels with value 0 are ignored.
     intensity_image : (M, N[, P][, C]) ndarray, optional
         Intensity (i.e., input) image with same size as labeled image, plus
-        optionally an extra dimension for multichannel data. Currently,
-        this extra channel dimension, if present, must be the last axis.
-        Default is None.
+        optionally an extra dimension for multichannel data. The channel
+        dimension, if present, must be the last axis. Default is None.
 
         .. versionchanged:: 0.18.0
             The ability to provide an extra dimension for channels was added.
@@ -1025,7 +1035,7 @@ def regionprops_table(
         issued. A property computation function must take a region mask as its
         first argument. If the property requires an intensity image, it must
         accept the intensity image as the second argument.
-    spacing: tuple of float, shape (ndim, )
+    spacing: tuple of float, shape (ndim,)
         The pixel spacing along each axis of the image.
 
     Returns
@@ -1190,7 +1200,7 @@ def regionprops(
         issued. A property computation function must take a region mask as its
         first argument. If the property requires an intensity image, it must
         accept the intensity image as the second argument.
-    spacing: tuple of float, shape (ndim, )
+    spacing: tuple of float, shape (ndim,)
         The pixel spacing along each axis of the image.
 
     Returns
@@ -1235,9 +1245,9 @@ def regionprops(
     **centroid_weighted_local** : array
         Centroid coordinate tuple ``(row, col)``, relative to region bounding
         box, weighted with intensity image.
-    **coords_scaled** : (N, 2) ndarray
+    **coords_scaled** : (K, 2) ndarray
         Coordinate list ``(row, col)``of the region scaled by ``spacing``.
-    **coords** : (N, 2) ndarray
+    **coords** : (K, 2) ndarray
         Coordinate list ``(row, col)`` of the region.
     **eccentricity** : float
         Eccentricity of the ellipse that has the same second-moments as the
@@ -1278,6 +1288,8 @@ def regionprops(
         Value with the mean intensity in the region.
     **intensity_min** : float
         Value with the least intensity in the region.
+    **intensity_std** : float
+        Standard deviation of the intensity in the region.
     **label** : int
         The label in the labeled input image.
     **moments** : (3, 3) ndarray
diff --git a/python/cucim/src/cucim/skimage/measure/_regionprops_utils.py b/python/cucim/src/cucim/skimage/measure/_regionprops_utils.py
index d1c36b390..e8c828948 100644
--- a/python/cucim/src/cucim/skimage/measure/_regionprops_utils.py
+++ b/python/cucim/src/cucim/skimage/measure/_regionprops_utils.py
@@ -71,9 +71,8 @@ def euler_number(image, connectivity=None):
 
     Parameters
     ----------
-    image: (N, M) ndarray or (N, M, D) ndarray.
-        2D or 3D images.
-        If image is not binary, all values strictly greater than zero
+    image: (M, N[, P]) 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
@@ -198,8 +197,8 @@ def perimeter(image, neighborhood=4):
 
     Parameters
     ----------
-    image : (N, M) ndarray
-        2D binary image.
+    image : (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
@@ -269,8 +268,8 @@ def perimeter_crofton(image, directions=4):
 
     Parameters
     ----------
-    image : (N, M) ndarray
-        2D image. If image is not binary, all values strictly greater than zero
+    image : (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
diff --git a/python/cucim/src/cucim/skimage/measure/block.py b/python/cucim/src/cucim/skimage/measure/block.py
index 4c3c8dc8c..6b158ad19 100644
--- a/python/cucim/src/cucim/skimage/measure/block.py
+++ b/python/cucim/src/cucim/skimage/measure/block.py
@@ -12,7 +12,7 @@ def block_reduce(image, block_size=2, func=cp.sum, cval=0, func_kwargs=None):
 
     Parameters
     ----------
-    image : ndarray
+    image : (M[, ...]) ndarray
         N-dimensional input image.
     block_size : array_like or int
         Array containing down-sampling integer factor along each axis.
diff --git a/python/cucim/src/cucim/skimage/measure/entropy.py b/python/cucim/src/cucim/skimage/measure/entropy.py
index 8abcf9056..99bb254d4 100644
--- a/python/cucim/src/cucim/skimage/measure/entropy.py
+++ b/python/cucim/src/cucim/skimage/measure/entropy.py
@@ -10,7 +10,7 @@ def shannon_entropy(image, base=2):
 
     Parameters
     ----------
-    image : (N, M) ndarray
+    image : (M, N) ndarray
         Grayscale input image.
     base : float, optional
         The logarithmic base to use.
diff --git a/python/cucim/src/cucim/skimage/measure/profile.py b/python/cucim/src/cucim/skimage/measure/profile.py
index 7ccc1e2ae..3e7187040 100644
--- a/python/cucim/src/cucim/skimage/measure/profile.py
+++ b/python/cucim/src/cucim/skimage/measure/profile.py
@@ -27,9 +27,9 @@ def profile_line(
         The image, either grayscale (2D array) or multichannel
         (3D array, where the final axis contains the channel
         information).
-    src : array_like, shape (2, )
+    src : array_like, shape (2,)
         The coordinates of the start point of the scan line.
-    dst : array_like, shape (2, )
+    dst : array_like, shape (2,)
         The coordinates of the end point of the scan
         line. The destination point is *included* in the profile, in
         contrast to standard numpy indexing.
diff --git a/python/cucim/src/cucim/skimage/metrics/__init__.py b/python/cucim/src/cucim/skimage/metrics/__init__.py
index 571d7790a..62d86f776 100644
--- a/python/cucim/src/cucim/skimage/metrics/__init__.py
+++ b/python/cucim/src/cucim/skimage/metrics/__init__.py
@@ -1,21 +1,3 @@
-from ._adapted_rand_error import adapted_rand_error
-from ._contingency_table import contingency_table
-from ._structural_similarity import structural_similarity
-from ._variation_of_information import variation_of_information
-from .simple_metrics import (
-    mean_squared_error,
-    normalized_mutual_information,
-    normalized_root_mse,
-    peak_signal_noise_ratio,
-)
+import lazy_loader as lazy
 
-__all__ = [
-    "adapted_rand_error",
-    "variation_of_information",
-    "contingency_table",
-    "mean_squared_error",
-    "normalized_mutual_information",
-    "normalized_root_mse",
-    "peak_signal_noise_ratio",
-    "structural_similarity",
-]
+__getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
diff --git a/python/cucim/src/cucim/skimage/metrics/__init__.pyi b/python/cucim/src/cucim/skimage/metrics/__init__.pyi
new file mode 100644
index 000000000..54cca52ba
--- /dev/null
+++ b/python/cucim/src/cucim/skimage/metrics/__init__.pyi
@@ -0,0 +1,25 @@
+# Explicitly setting `__all__` is necessary for type inference engines
+# to know which symbols are exported. See
+# https://peps.python.org/pep-0484/#stub-files
+
+__all__ = [
+    "adapted_rand_error",
+    "variation_of_information",
+    "contingency_table",
+    "mean_squared_error",
+    "normalized_mutual_information",
+    "normalized_root_mse",
+    "peak_signal_noise_ratio",
+    "structural_similarity",
+]
+
+from ._adapted_rand_error import adapted_rand_error
+from ._contingency_table import contingency_table
+from ._structural_similarity import structural_similarity
+from ._variation_of_information import variation_of_information
+from .simple_metrics import (
+    mean_squared_error,
+    normalized_mutual_information,
+    normalized_root_mse,
+    peak_signal_noise_ratio,
+)
diff --git a/python/cucim/src/cucim/skimage/metrics/_structural_similarity.py b/python/cucim/src/cucim/skimage/metrics/_structural_similarity.py
index 188a216d1..04ad88ab2 100644
--- a/python/cucim/src/cucim/skimage/metrics/_structural_similarity.py
+++ b/python/cucim/src/cucim/skimage/metrics/_structural_similarity.py
@@ -86,10 +86,10 @@ def structural_similarity(
     gradient : bool, optional
         If True, also return the gradient with respect to im2.
     data_range : float, optional
-        The data range of the input image (distance between minimum and
-        maximum possible values). By default, this is estimated from the image
+        The data range of the input image (difference between maximum and
+        minimum possible values). By default, this is estimated from the image
         data type. This estimate may be wrong for floating-point image data.
-        Therefore it is recommended to always pass this value explicitly
+        Therefore it is recommended to always pass this scalar value explicitly
         (see note below).
     channel_axis : int or None, optional
         If None, the image is assumed to be a grayscale (single channel) image.
@@ -130,7 +130,7 @@ def structural_similarity(
     estimate yields a result double the value of the desired range, as the
     `dtype_range` in `skimage.util.dtype.py` has defined intervals from -1 to
     +1. This yields an estimate of 2, instead of 1, which is most often
-    required when working with image data (as negative light intentsities are
+    required when working with image data (as negative light intensities are
     nonsensical). In case of working with YCbCr-like color data, note that
     these ranges are different per channel (Cb and Cr have double the range
     of Y), so one cannot calculate a channel-averaged SSIM with a single call
diff --git a/python/cucim/src/cucim/skimage/morphology/_skeletonize.py b/python/cucim/src/cucim/skimage/morphology/_skeletonize.py
index 819e26bdf..4abe6c1c7 100644
--- a/python/cucim/src/cucim/skimage/morphology/_skeletonize.py
+++ b/python/cucim/src/cucim/skimage/morphology/_skeletonize.py
@@ -5,8 +5,9 @@
 
 import cucim.skimage._vendored.ndimage as ndi
 from cucim.core.operations.morphology import distance_transform_edt
+from cucim.skimage._shared.utils import DEPRECATED, deprecate_parameter
 
-from .._shared.utils import check_nD, deprecate_kwarg
+from .._shared.utils import check_nD
 from ._medial_axis_lookup import (
     cornerness_table as _medial_axis_cornerness_table,
     lookup_table as _medial_axis_lookup_table,
@@ -48,11 +49,6 @@
 # fmt: on
 
 
-@deprecate_kwarg(
-    {"max_iter": "max_num_iter"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 def thin(image, max_num_iter=None):
     """
     Perform morphological thinning of a binary image.
@@ -60,7 +56,9 @@ def thin(image, max_num_iter=None):
     Parameters
     ----------
     image : binary (M, N) ndarray
-        The image to be thinned.
+        The image to thin. If this input isn't already a binary image,
+        it gets converted into one: In this case, zero values are considered
+        background (False), nonzero values are considered foreground (True).
     max_num_iter : int, number of iterations, optional
         Regardless of the value of this parameter, the thinned image
         is returned immediately if an iteration produces no change.
@@ -98,10 +96,10 @@ def thin(image, max_num_iter=None):
 
     Examples
     --------
-    >>> square = np.zeros((7, 7), dtype=np.uint8)
+    >>> square = np.zeros((7, 7), dtype=bool)
     >>> square[1:-1, 2:-2] = 1
     >>> square[0, 1] =  1
-    >>> square
+    >>> square.view(cp.uint8)
     array([[0, 1, 0, 0, 0, 0, 0],
            [0, 0, 1, 1, 1, 0, 0],
            [0, 0, 1, 1, 1, 0, 0],
@@ -110,7 +108,7 @@ def thin(image, max_num_iter=None):
            [0, 0, 1, 1, 1, 0, 0],
            [0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
     >>> skel = thin(square)
-    >>> skel.astype(np.uint8)
+    >>> skel.view(np.uint8)
     array([[0, 1, 0, 0, 0, 0, 0],
            [0, 0, 1, 0, 0, 0, 0],
            [0, 0, 0, 1, 0, 0, 0],
@@ -123,11 +121,11 @@ def thin(image, max_num_iter=None):
     check_nD(image, 2)
 
     # convert image to uint8 with values in {0, 1}
-    skel = cp.asarray(image, dtype=bool).astype(cp.uint8)
+    skel = cp.asarray(image, dtype=bool).view(cp.uint8)
 
     # neighborhood mask
     mask = cp.asarray(
-        [[8, 4, 2], [16, 0, 1], [32, 64, 128]], dtype=cp.uint8  # noqa  # noqa
+        [[8, 4, 2], [16, 0, 1], [32, 64, 128]], dtype=cp.uint8  # noqa
     )
 
     G123_LUT = cp.asarray(_G123_LUT)
@@ -170,21 +168,21 @@ def _get_tiebreaker(n, seed):
     return tiebreaker
 
 
-@deprecate_kwarg(
-    {"random_state": "rng"},
-    deprecated_version="23.08",
-    removed_version="24.06",
-)
-@deprecate_kwarg(
-    {"seed": "rng"}, deprecated_version="23.12", removed_version="24.12"
+@deprecate_parameter(
+    "seed", new_name="rng", start_version="23.12", stop_version="24.12"
 )
-def medial_axis(image, mask=None, return_distance=False, *, rng=None):
+def medial_axis(
+    image, mask=None, return_distance=False, *, seed=DEPRECATED, rng=None
+):
     """Compute the medial axis transform of a binary image.
 
     Parameters
     ----------
     image : binary ndarray, shape (M, N)
-        The image of the shape to be skeletonized.
+        The image of the shape to skeletonize. If this input isn't already a
+        binary image, it gets converted into one: In this case, zero values are
+        considered background (False), nonzero values are considered
+        foreground (True).
     mask : binary ndarray, shape (M, N), optional
         If a mask is given, only those elements in `image` with a true
         value in `mask` are used for computing the medial axis.
@@ -212,7 +210,7 @@ def medial_axis(image, mask=None, return_distance=False, *, rng=None):
 
     See Also
     --------
-    skeletonize
+    skeletonize, thin
 
     Notes
     -----
@@ -239,9 +237,9 @@ def medial_axis(image, mask=None, return_distance=False, *, rng=None):
 
     Examples
     --------
-    >>> square = np.zeros((7, 7), dtype=np.uint8)
+    >>> square = np.zeros((7, 7), dtype=bool)
     >>> square[1:-1, 2:-2] = 1
-    >>> square
+    >>> square.view(cp.uint8)
     array([[0, 0, 0, 0, 0, 0, 0],
            [0, 0, 1, 1, 1, 0, 0],
            [0, 0, 1, 1, 1, 0, 0],
@@ -249,7 +247,7 @@ def medial_axis(image, mask=None, return_distance=False, *, rng=None):
            [0, 0, 1, 1, 1, 0, 0],
            [0, 0, 1, 1, 1, 0, 0],
            [0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
-    >>> medial_axis(square).astype(np.uint8)
+    >>> medial_axis(square).view(cp.uint8)
     array([[0, 0, 0, 0, 0, 0, 0],
            [0, 0, 1, 0, 1, 0, 0],
            [0, 0, 0, 1, 0, 0, 0],
diff --git a/python/cucim/src/cucim/skimage/morphology/binary.py b/python/cucim/src/cucim/skimage/morphology/binary.py
index 39c916ca2..665704e4e 100644
--- a/python/cucim/src/cucim/skimage/morphology/binary.py
+++ b/python/cucim/src/cucim/skimage/morphology/binary.py
@@ -1,18 +1,16 @@
 """
 Binary morphological operations
 """
-import functools
 
 import cupy as cp
 
 import cucim.skimage._vendored.ndimage as ndi
 
-from .._shared.utils import deprecate_kwarg
-from .footprints import _footprint_is_sequence
+from .footprints import _footprint_is_sequence, pad_footprint
 from .misc import default_footprint
 
 
-def _iterate_binary_func(binary_func, image, footprint, out):
+def _iterate_binary_func(binary_func, image, footprint, out, border_value):
     """Helper to call `binary_func` for each footprint in a sequence.
 
     binary_func is a binary morphology function that accepts "structure",
@@ -25,7 +23,12 @@ def _iterate_binary_func(binary_func, image, footprint, out):
     #     `iterations > 1` is added.
     fp, num_iter = footprint[0]
     binary_func(
-        image, structure=fp, output=out, iterations=num_iter, brute_force=True
+        image,
+        structure=fp,
+        output=out,
+        iterations=num_iter,
+        border_value=border_value,
+        brute_force=True,
     )
     for fp, num_iter in footprint[1:]:
         # Note: out.copy() because the computation cannot be in-place!
@@ -35,6 +38,7 @@ def _iterate_binary_func(binary_func, image, footprint, out):
             structure=fp,
             output=out,
             iterations=num_iter,
+            border_value=border_value,
             brute_force=True,
         )
     return out
@@ -44,12 +48,7 @@ def _iterate_binary_func(binary_func, image, footprint, out):
 # default with the same dimension as the input image and size 3 along each
 # axis.
 @default_footprint
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
-def binary_erosion(image, footprint=None, out=None):
+def binary_erosion(image, footprint=None, out=None, *, mode="ignore"):
     """Return fast binary morphological erosion of an image.
 
     This function returns the same result as grayscale erosion but performs
@@ -71,6 +70,15 @@ def binary_erosion(image, footprint=None, out=None):
     out : ndarray of bool, optional
         The array to store the result of the morphology. If None is
         passed, a new array will be allocated.
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'max', 'min', 'ignore'.
+        If 'max' or 'ignore', pixels outside the image domain are assumed
+        to be `True`, which causes them to not influence the result.
+        Default is 'ignore'.
+
+        .. versionadded:: 24.06
+            `mode` was added in 24.06.
 
     Returns
     -------
@@ -87,30 +95,37 @@ def binary_erosion(image, footprint=None, out=None):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
 
+    For even-sized footprints, :func:`skimage.morphology.erosion` and
+    this function produce an output that differs: one is shifted by one pixel
+    compared to the other.
     """
     if out is None:
         out = cp.empty(image.shape, dtype=bool)
 
-    if _footprint_is_sequence(footprint):
-        binary_func = functools.partial(ndi.binary_erosion, border_value=True)
-        return _iterate_binary_func(binary_func, image, footprint, out)
+    if mode not in {"max", "min", "ignore"}:
+        raise ValueError(f"unsupported mode, got {mode!r}")
+    border_value = False if mode == "min" else True
+
+    footprint = pad_footprint(footprint, pad_end=True)
+
+    if not _footprint_is_sequence(footprint):
+        footprint = [(footprint, 1)]
 
-    ndi.binary_erosion(
-        image, structure=footprint, output=out, border_value=True
+    out = _iterate_binary_func(
+        binary_func=ndi.binary_erosion,
+        image=image,
+        footprint=footprint,
+        out=out,
+        border_value=border_value,
     )
     return out
 
 
 @default_footprint
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
-def binary_dilation(image, footprint=None, out=None):
+def binary_dilation(image, footprint=None, out=None, *, mode="ignore"):
     """Return fast binary morphological dilation of an image.
 
     This function returns the same result as grayscale dilation but performs
@@ -132,6 +147,15 @@ def binary_dilation(image, footprint=None, out=None):
     out : ndarray of bool, optional
         The array to store the result of the morphology. If None is
         passed, a new array will be allocated.
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'max', 'min', 'ignore'.
+        If 'min' or 'ignore', pixels outside the image domain are assumed
+        to be `False`, which causes them to not influence the result.
+        Default is 'ignore'.
+
+        .. versionadded:: 24.06
+            `mode` was added in 24.06.
 
     Returns
     -------
@@ -148,27 +172,37 @@ def binary_dilation(image, footprint=None, out=None):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
 
+    For non-symmetric footprints, :func:`skimage.morphology.binary_dilation`
+    and :func:`skimage.morphology.dilation` produce an output that differs:
+    `binary_dilation` mirrors the footprint, whereas `dilation` does not.
     """
     if out is None:
         out = cp.empty(image.shape, dtype=bool)
 
-    if _footprint_is_sequence(footprint):
-        return _iterate_binary_func(ndi.binary_dilation, image, footprint, out)
+    if mode not in {"max", "min", "ignore"}:
+        raise ValueError(f"unsupported mode, got {mode!r}")
+    border_value = True if mode == "max" else False
+
+    footprint = pad_footprint(footprint, pad_end=True)
+
+    if not _footprint_is_sequence(footprint):
+        footprint = [(footprint, 1)]
 
-    ndi.binary_dilation(image, structure=footprint, output=out)
+    out = _iterate_binary_func(
+        binary_func=ndi.binary_dilation,
+        image=image,
+        footprint=footprint,
+        out=out,
+        border_value=border_value,
+    )
     return out
 
 
 @default_footprint
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
-def binary_opening(image, footprint=None, out=None):
+def binary_opening(image, footprint=None, out=None, *, mode="ignore"):
     """Return fast binary morphological opening of an image.
 
     This function returns the same result as grayscale opening but performs
@@ -191,6 +225,15 @@ def binary_opening(image, footprint=None, out=None):
     out : ndarray of bool, optional
         The array to store the result of the morphology. If None
         is passed, a new array will be allocated.
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'max', 'min', 'ignore'.
+        If 'ignore', pixels outside the image domain are assumed to be `True`
+        for the erosion and `False` for the dilation, which causes them to not
+        influence the result. Default is 'ignore'.
+
+        .. versionadded:: 24.06
+            `mode` was added in 24.06
 
     Returns
     -------
@@ -206,22 +249,16 @@ def binary_opening(image, footprint=None, out=None):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
-
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
     """
-    eroded = binary_erosion(image, footprint)
-    out = binary_dilation(eroded, footprint, out=out)
+    tmp = binary_erosion(image, footprint, mode=mode)
+    out = binary_dilation(tmp, footprint, out=out, mode=mode)
     return out
 
 
 @default_footprint
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
-def binary_closing(image, footprint=None, out=None):
+def binary_closing(image, footprint=None, out=None, *, mode="ignore"):
     """Return fast binary morphological closing of an image.
 
     This function returns the same result as grayscale closing but performs
@@ -244,6 +281,15 @@ def binary_closing(image, footprint=None, out=None):
     out : ndarray of bool, optional
         The array to store the result of the morphology. If None,
         is passed, a new array will be allocated.
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'max', 'min', 'ignore'.
+        If 'ignore', pixels outside the image domain are assumed to be `True`
+        for the erosion and `False` for the dilation, which causes them to not
+        influence the result. Default is 'ignore'.
+
+        .. versionadded:: 24.06
+            `mode` was added in 24.06.
 
     Returns
     -------
@@ -259,10 +305,9 @@ def binary_closing(image, footprint=None, out=None):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
-
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
     """
-    dilated = binary_dilation(image, footprint)
-    out = binary_erosion(dilated, footprint, out=out)
+    tmp = binary_dilation(image, footprint, mode=mode)
+    out = binary_erosion(tmp, footprint, out=out, mode=mode)
     return out
diff --git a/python/cucim/src/cucim/skimage/morphology/footprints.py b/python/cucim/src/cucim/skimage/morphology/footprints.py
index cae094a04..c19574475 100644
--- a/python/cucim/src/cucim/skimage/morphology/footprints.py
+++ b/python/cucim/src/cucim/skimage/morphology/footprints.py
@@ -100,13 +100,13 @@ def footprint_from_sequence(footprints):
     footprints : tuple of 2-tuples
         A sequence of footprint tuples where the first element of each tuple
         is an array corresponding to a footprint and the second element is the
-        number of times it is to be applied. Currently all footprints should
+        number of times it is to be applied. Currently, all footprints should
         have odd size.
 
     Returns
     -------
     footprint : ndarray
-        An single array equivalent to applying the sequence of `footprints`.
+        An single array equivalent to applying the sequence of ``footprints``.
     """
 
     # Create a single pixel image of sufficient size and apply binary dilation.
@@ -137,10 +137,10 @@ def square(width, dtype=None, *, decomposition=None):
     decomposition : {None, 'separable', 'sequence'}, optional
         If None, a single array is returned. For 'sequence', a tuple of smaller
         footprints is returned. Applying this series of smaller footprints will
-        given an identical result to a single, larger footprint, but often with
+        give an identical result to a single, larger footprint, but often with
         better computational performance. See Notes for more details.
         With 'separable', this function uses separable 1D footprints for each
-        axis. Whether 'seqeunce' or 'separable' is computationally faster may
+        axis. Whether 'sequence' or 'separable' is computationally faster may
         be architecture-dependent.
 
     Returns
@@ -1054,3 +1054,95 @@ def star(a, dtype=cp.uint8):
     footprint[footprint > 0] = 1
 
     return cp.array(footprint.astype(dtype, copy=False))
+
+
+def mirror_footprint(footprint):
+    """Mirror each dimension in the footprint.
+
+    Parameters
+    ----------
+    footprint : ndarray or tuple
+        The input footprint or sequence of footprints
+
+    Returns
+    -------
+    inverted : ndarray or tuple
+        The footprint, mirrored along each dimension.
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from cucim.skimage.morphology.footprints import mirror_footprint
+    >>> footprint = cp.array([[0, 0, 0],
+    ...                       [0, 1, 1],
+    ...                       [0, 1, 1]], cp.uint8)
+    >>> mirror_footprint(footprint)
+    array([[1, 1, 0],
+           [1, 1, 0],
+           [0, 0, 0]], dtype=uint8)
+
+    """
+    if _footprint_is_sequence(footprint):
+        return tuple((mirror_footprint(fp), n) for fp, n in footprint)
+    elif isinstance(footprint, tuple):
+        return footprint
+    return footprint[(slice(None, None, -1),) * footprint.ndim]
+
+
+def pad_footprint(footprint, *, pad_end=True):
+    """Pad the footprint to an odd size along each dimension.
+
+    Parameters
+    ----------
+    footprint : ndarray or tuple
+        The input footprint or sequence of footprints
+    pad_end : bool, optional
+        If ``True``, pads at the end of each dimension (right side), otherwise
+        pads on the front (left side).
+
+    Returns
+    -------
+    padded : ndarray or tuple
+        The footprint, padded to an odd size along each dimension.
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> from cucim.skimage.morphology.footprints import pad_footprint
+    >>> footprint = cp.array([[0, 0],
+    ...                       [1, 1],
+    ...                       [1, 1]], cp.uint8)
+    >>> pad_footprint(footprint)
+    array([[0, 0, 0],
+           [1, 1, 0],
+           [1, 1, 0]], dtype=uint8)
+
+    """
+    if _footprint_is_sequence(footprint):
+        return tuple(
+            (pad_footprint(fp, pad_end=pad_end), n) for fp, n in footprint
+        )
+
+    # Unlike skimage, CuPy needs to support tuple to avoid the overhead of
+    # allocating a tiny footprint array on the GPU when possible.
+    if isinstance(footprint, tuple):
+        if all(s % 2 for s in footprint):
+            # can use tuple directly if all dimensions have odd length
+            return footprint
+        # Use a padded array instead of the tuple of any sizes were even
+        padded_shape = tuple(s if s % 2 else s + 1 for s in footprint)
+        padded = np.zeros(padded_shape, dtype=cp.uint8)
+        if pad_end:
+            padded[tuple(slice(0, s) for s in footprint)] = 1
+        else:
+            padded[tuple(slice(-s, None) for s in footprint)] = 1
+        return cp.asarray(padded)
+
+    # return as is if already odd size on all axes
+    if all(s % 2 for s in footprint.shape):
+        return footprint
+    padding = [
+        ((0, 1) if pad_end else (1, 0)) if sz % 2 == 0 else (0, 0)
+        for sz in footprint.shape
+    ]
+    return cp.pad(footprint, padding)
diff --git a/python/cucim/src/cucim/skimage/morphology/gray.py b/python/cucim/src/cucim/skimage/morphology/gray.py
index 9f7eea0a3..afc2e8cd9 100644
--- a/python/cucim/src/cucim/skimage/morphology/gray.py
+++ b/python/cucim/src/cucim/skimage/morphology/gray.py
@@ -1,16 +1,18 @@
 """
 Grayscale morphological operations
 """
-import functools
+import warnings
 
 import cupy as cp
 
 import cucim.skimage._vendored.ndimage as ndi
 
-from .._shared.utils import deprecate_kwarg
-from .._vendored import pad
-from ..util import crop
-from .footprints import _footprint_is_sequence, _shape_from_sequence
+from .._shared.utils import DEPRECATED
+from .footprints import (
+    _footprint_is_sequence,
+    mirror_footprint,
+    pad_footprint,
+)
 from .misc import default_footprint
 
 __all__ = [
@@ -23,32 +25,36 @@
 ]
 
 
-def _iterate_gray_func(gray_func, image, footprints, out):
+def _iterate_gray_func(gray_func, image, footprints, out, mode, cval):
     """Helper to call `binary_func` for each footprint in a sequence.
 
-    binary_func is a binary morphology function that accepts "structure",
+    `gray_func` is a binary morphology function that accepts "structure",
     "output" and "iterations" keyword arguments
-    (e.g. `scipy.ndimage.binary_erosion`).
+    (e.g. `scipy.ndimage.gray_erosion`).
     """
     fp, num_iter = footprints[0]
     tuple_fp = isinstance(fp, tuple)
     if tuple_fp:
-        gray_func(image, size=fp, output=out)
+        gray_func(image, size=fp, output=out, mode=mode, cval=cval)
     else:
-        gray_func(image, footprint=fp, output=out)
+        gray_func(image, footprint=fp, output=out, mode=mode, cval=cval)
     for _ in range(1, num_iter):
         if tuple_fp:
-            gray_func(out.copy(), size=fp, output=out)
+            gray_func(out.copy(), size=fp, output=out, mode=mode, cval=cval)
         else:
-            gray_func(out.copy(), footprint=fp, output=out)
+            gray_func(
+                out.copy(), footprint=fp, output=out, mode=mode, cval=cval
+            )
     for fp, num_iter in footprints[1:]:
         tuple_fp = isinstance(fp, tuple)
         # Note: out.copy() because the computation cannot be in-place!
         for _ in range(num_iter):
             if tuple_fp:
-                gray_func(out.copy(), size=fp, output=out)
+                gray_func(out.copy(), size=fp, output=out, mode=mode, cval=cval)
             else:
-                gray_func(out.copy(), footprint=fp, output=out)
+                gray_func(
+                    out.copy(), footprint=fp, output=out, mode=mode, cval=cval
+                )
     return out
 
 
@@ -97,105 +103,72 @@ def _shift_footprint(footprint, shift_x, shift_y):
     return footprint
 
 
-def _invert_footprint(footprint):
-    """Change the order of the values in `footprint`.
-
-    This is a patch for the *weird* footprint inversion in
-    `ndi.grey_morphology` [1]_.
+def _shift_footprints(footprint, shift_x, shift_y):
+    """Shifts the footprints, whether it's a single array or a sequence.
 
-    Parameters
-    ----------
-    footprint : array
-        The input footprint.
-
-    Returns
-    -------
-    inverted : array, same shape and type as `footprint`
-        The footprint, in opposite order.
-
-    Examples
-    --------
-    >>> footprint = cp.asarray([[0, 0, 0], [0, 1, 1], [0, 1, 1]], cp.uint8)
-    >>> _invert_footprint(footprint)
-    array([[1, 1, 0],
-           [1, 1, 0],
-           [0, 0, 0]], dtype=uint8)
-
-    References
-    ----------
-    .. [1] https://github.com/scipy/scipy/blob/ec20ababa400e39ac3ffc9148c01ef86d5349332/scipy/ndimage/morphology.py#L1285
-    """  # noqa: E501
-    if isinstance(footprint, tuple):
-        # fully populated rectangle is symmetric
+    See `_shift_footprint`, which is called for each array in the sequence.
+    """
+    if shift_x is DEPRECATED and shift_y is DEPRECATED:
         return footprint
-    inverted = footprint[(slice(None, None, -1),) * footprint.ndim]
-    return inverted
 
+    warning_msg = (
+        "The parameters `shift_x` and `shift_y` are deprecated since v0.23 and "
+        "will be removed in v0.26. Use `pad_footprint` or modify the footprint"
+        "manually instead."
+    )
+    warnings.warn(warning_msg, FutureWarning, stacklevel=4)
 
-def pad_for_eccentric_footprints(func):
-    """Pad input images for certain morphological operations.
-
-    Parameters
-    ----------
-    func : callable
-        A morphological function, either opening or closing, that
-        supports eccentric footprints. Its parameters must
-        include at least `image`, `footprint`, and `out`.
+    if _footprint_is_sequence(footprint):
+        return tuple(
+            (_shift_footprint(fp, shift_x, shift_y), n) for fp, n in footprint
+        )
+    return _shift_footprint(footprint, shift_x, shift_y)
 
-    Returns
-    -------
-    func_out : callable
-        The same function, but correctly padding the input image before
-        applying the input function.
 
-    See Also
-    --------
-    opening, closing.
-    """
-
-    @functools.wraps(func)
-    def func_out(image, footprint, out=None, *args, **kwargs):
-        pad_widths = []
-        padding = False
-        if out is None:
-            out = cp.empty_like(image)
-        if _footprint_is_sequence(footprint):
-            # Note: in practice none of our built-in footprint sequences will
-            #       require padding (all are symmetric and have odd sizes)
-            footprint_shape = _shape_from_sequence(footprint)
-        elif isinstance(footprint, tuple):
-            footprint_shape = footprint
-        else:
-            footprint_shape = footprint.shape
-        for axis_len in footprint_shape:
-            if axis_len % 2 == 0:
-                axis_pad_width = axis_len - 1
-                padding = True
-            else:
-                axis_pad_width = 0
-            pad_widths.append((axis_pad_width,) * 2)
-        if padding:
-            image = pad(image, pad_widths, mode="edge")
-            out_temp = cp.empty_like(image)
+def _min_max_to_constant_mode(dtype, mode, cval):
+    """Replace 'max' and 'min' with appropriate 'cval' and 'constant' mode."""
+    if mode == "max":
+        mode = "constant"
+        if cp.issubdtype(dtype, bool):
+            cval = True
+        elif cp.issubdtype(dtype, cp.integer):
+            cval = cp.iinfo(dtype).max
         else:
-            out_temp = out
-        out_temp = func(image, footprint, out=out_temp, *args, **kwargs)
-        if padding:
-            out[:] = crop(out_temp, pad_widths)
+            cval = cp.inf
+    elif mode == "min":
+        mode = "constant"
+        if cp.issubdtype(dtype, bool):
+            cval = False
+        elif cp.issubdtype(dtype, cp.integer):
+            cval = cp.iinfo(dtype).min
         else:
-            out = out_temp
-        return out
+            cval = -cp.inf
+    return mode, cval
 
-    return func_out
+
+_SUPPORTED_MODES = {
+    "reflect",
+    "constant",
+    "nearest",
+    "mirror",
+    "wrap",
+    "max",
+    "min",
+    "ignore",
+}
 
 
 @default_footprint
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
-def erosion(image, footprint=None, out=None, shift_x=False, shift_y=False):
+def erosion(
+    image,
+    footprint=None,
+    out=None,
+    shift_x=DEPRECATED,
+    shift_y=DEPRECATED,
+    *,
+    mode="reflect",
+    cval=0.0,
+):
     """Return grayscale morphological erosion of an image.
 
     Morphological erosion sets a pixel at (i,j) to the minimum over all pixels
@@ -214,10 +187,25 @@ def erosion(image, footprint=None, out=None, shift_x=False, shift_y=False):
     out : cupy.ndarray, optional
         The array to store the result of the morphology. If None is
         passed, a new array will be allocated.
-    shift_x, shift_y : bool, optional
-        shift footprint about center point. This only affects
-        eccentric footprints (i.e. footprint with even numbered
-        sides).
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
+        'max', 'min', or 'ignore'.
+        If 'max' or 'ignore', pixels outside the image domain are assumed
+        to be the maximum for the image's dtype, which causes them to not
+        influence the result. Default is 'reflect'.
+    cval : scalar, optional
+        Value to fill past edges of input if `mode` is 'constant'. Default
+        is 0.0.
+
+        .. versionadded:: 24.06
+            `mode` and `cval` were added in 24.06.
+
+    Other Parameters
+    ----------------
+    shift_x, shift_y : DEPRECATED
+
+        .. deprecated:: 24.06
 
     Returns
     -------
@@ -227,7 +215,7 @@ def erosion(image, footprint=None, out=None, shift_x=False, shift_y=False):
     Notes
     -----
     For ``uint8`` (and ``uint16`` up to a certain bit-depth) data, the
-    lower algorithm complexity makes the `skimage.filters.rank.minimum`
+    lower algorithm complexity makes the :func:`skimage.filters.rank.minimum`
     function more efficient for larger images and footprints.
 
     The footprint can also be a provided as a sequence of 2-tuples where the
@@ -237,8 +225,12 @@ def erosion(image, footprint=None, out=None, shift_x=False, shift_y=False):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
+
+    For even-sized footprints, :func:`skimage.morphology.binary_erosion` and
+    this function produce an output that differs: one is shifted by one pixel
+    compared to the other.
 
     Examples
     --------
@@ -261,36 +253,54 @@ def erosion(image, footprint=None, out=None, shift_x=False, shift_y=False):
     if out is None:
         out = cp.empty_like(image)
 
-    if _footprint_is_sequence(footprint):
-        footprints = tuple(
-            (_shift_footprint(fp, shift_x, shift_y), n) for fp, n in footprint
-        )
-        return _iterate_gray_func(ndi.grey_erosion, image, footprints, out)
-
-    if isinstance(footprint, tuple):
-        if len(footprint) != image.ndim:
-            raise ValueError(
-                "footprint.ndim={len(footprint)}, image.ndim={image.ndim}"
-            )
-        if image.ndim == 2 and any(s % 2 == 0 for s in footprint):
-            # only odd-shaped footprints are properly handled for tuples
-            footprint = cp.ones(footprint, dtype=bool)
-        else:
-            ndi.grey_erosion(image, size=footprint, output=out)
-            return out
-
-    footprint = _shift_footprint(footprint, shift_x, shift_y)
-    ndi.grey_erosion(image, footprint=footprint, output=out)
+    if mode not in _SUPPORTED_MODES:
+        raise ValueError(f"unsupported mode, got {mode!r}")
+    if mode == "ignore":
+        mode = "max"
+    mode, cval = _min_max_to_constant_mode(image.dtype, mode, cval)
+
+    is_footprint_sequence = _footprint_is_sequence(footprint)
+    footprint = _shift_footprints(footprint, shift_x, shift_y)
+
+    # if isinstance(footprint, tuple) and not is_footprint_sequence:
+    #     if len(footprint) != image.ndim:
+    #         raise ValueError(
+    #             "footprint.ndim={len(footprint)}, image.ndim={image.ndim}"
+    #         )
+    #     if image.ndim == 2 and any(s % 2 == 0 for s in footprint):
+    #         # only odd-shaped footprints are properly handled for tuples
+    #         footprint = cp.ones(footprint, dtype=bool)
+    #     else:
+    #         ndi.grey_erosion(image, size=footprint, output=out)
+    #         return out
+
+    footprint = pad_footprint(footprint, pad_end=False)
+
+    if not is_footprint_sequence:
+        footprint = [(footprint, 1)]
+
+    out = _iterate_gray_func(
+        gray_func=ndi.grey_erosion,
+        image=image,
+        footprints=footprint,
+        out=out,
+        mode=mode,
+        cval=cval,
+    )
     return out
 
 
 @default_footprint
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
-def dilation(image, footprint=None, out=None, shift_x=False, shift_y=False):
+def dilation(
+    image,
+    footprint=None,
+    out=None,
+    shift_x=DEPRECATED,
+    shift_y=DEPRECATED,
+    *,
+    mode="reflect",
+    cval=0.0,
+):
     """Return grayscale morphological dilation of an image.
 
     Morphological dilation sets the value of a pixel to the maximum over all
@@ -310,10 +320,25 @@ def dilation(image, footprint=None, out=None, shift_x=False, shift_y=False):
     out : cupy.ndarray, optional
         The array to store the result of the morphology. If None is
         passed, a new array will be allocated.
-    shift_x, shift_y : bool, optional
-        Shift footprint about center point. This only affects 2D
-        eccentric footprints (i.e., footprints with even-numbered
-        sides).
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
+        'max', 'min', or 'ignore'.
+        If 'max' or 'ignore', pixels outside the image domain are assumed
+        to be the maximum for the image's dtype, which causes them to not
+        influence the result. Default is 'reflect'.
+    cval : scalar, optional
+        Value to fill past edges of input if `mode` is 'constant'. Default
+        is 0.0.
+
+        .. versionadded:: 24.06
+            `mode` and `cval` were added in 24.06.
+
+    Other Parameters
+    ----------------
+    shift_x, shift_y : DEPRECATED
+
+        .. deprecated:: 24.06
 
     Returns
     -------
@@ -322,9 +347,9 @@ def dilation(image, footprint=None, out=None, shift_x=False, shift_y=False):
 
     Notes
     -----
-    For `uint8` (and `uint16` up to a certain bit-depth) data, the lower
-    algorithm complexity makes the `skimage.filters.rank.maximum` function more
-    efficient for larger images and footprints.
+    For ``uint8`` (and ``uint16`` up to a certain bit-depth) data, the lower
+    algorithm complexity makes the :func:`skimage.filters.rank.maximum` function
+    more efficient for larger images and footprints.
 
     The footprint can also be a provided as a sequence of 2-tuples where the
     first element of each 2-tuple is a footprint ndarray and the second element
@@ -333,8 +358,12 @@ def dilation(image, footprint=None, out=None, shift_x=False, shift_y=False):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
+
+    For non-symmetric footprints, :func:`skimage.morphology.binary_dilation`
+    and :func:`skimage.morphology.dilation` produce an output that differs:
+    `binary_dilation` mirrors the footprint, whereas `dilation` does not.
 
     Examples
     --------
@@ -357,45 +386,47 @@ def dilation(image, footprint=None, out=None, shift_x=False, shift_y=False):
     if out is None:
         out = cp.empty_like(image)
 
-    if _footprint_is_sequence(footprint):
-        # shift and invert (see comment below) each footprint
-        footprints = tuple(
-            (_invert_footprint(_shift_footprint(fp, shift_x, shift_y)), n)
-            for fp, n in footprint
-        )
-        return _iterate_gray_func(ndi.grey_dilation, image, footprints, out)
-
-    if isinstance(footprint, tuple):
-        if len(footprint) != image.ndim:
-            raise ValueError(
-                "footprint.ndim={len(footprint)}, image.ndim={image.ndim}"
-            )
-        if image.ndim == 2 and any(s % 2 == 0 for s in footprint):
-            # only odd-shaped footprints are properly handled for tuples
-            footprint = cp.ones(footprint, dtype=bool)
-        else:
-            ndi.grey_dilation(image, size=footprint, output=out)
-            return out
-
-    footprint = _shift_footprint(footprint, shift_x, shift_y)
-    # Inside ndi.grey_dilation, the footprint is inverted,
-    # e.g. `footprint = footprint[::-1, ::-1]` for 2D [1]_, for reasons unknown
-    # to this author (@jni). To "patch" this behaviour, we invert our own
-    # footprint before passing it to `ndi.grey_dilation`.
-    # [1] https://github.com/scipy/scipy/blob/ec20ababa400e39ac3ffc9148c01ef86d5349332/scipy/ndimage/morphology.py#L1285  # noqa
-    footprint = _invert_footprint(footprint)
-    ndi.grey_dilation(image, footprint=footprint, output=out)
+    if mode not in _SUPPORTED_MODES:
+        raise ValueError(f"unsupported mode, got {mode!r}")
+    if mode == "ignore":
+        mode = "min"
+    mode, cval = _min_max_to_constant_mode(image.dtype, mode, cval)
+
+    is_footprint_sequence = _footprint_is_sequence(footprint)
+    footprint = _shift_footprints(footprint, shift_x, shift_y)
+
+    # if isinstance(footprint, tuple) and not is_footprint_sequence:
+    #     if len(footprint) != image.ndim:
+    #         raise ValueError(
+    #             "footprint.ndim={len(footprint)}, image.ndim={image.ndim}"
+    #         )
+    #     if image.ndim == 2 and any(s % 2 == 0 for s in footprint):
+    #         # only odd-shaped footprints are properly handled for tuples
+    #         footprint = cp.ones(footprint, dtype=bool)
+    #     else:
+    #         ndi.grey_dilation(image, size=footprint, output=out)
+    #         return out
+
+    footprint = pad_footprint(footprint, pad_end=False)
+    # Note that `ndi.grey_dilation` mirrors the footprint and this
+    # additional inversion should be removed in skimage2, see gh-6676.
+    footprint = mirror_footprint(footprint)
+    if not is_footprint_sequence:
+        footprint = [(footprint, 1)]
+
+    out = _iterate_gray_func(
+        gray_func=ndi.grey_dilation,
+        image=image,
+        footprints=footprint,
+        out=out,
+        mode=mode,
+        cval=cval,
+    )
     return out
 
 
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 @default_footprint
-@pad_for_eccentric_footprints
-def opening(image, footprint=None, out=None):
+def opening(image, footprint=None, out=None, *, mode="reflect", cval=0.0):
     """Return grayscale morphological opening of an image.
 
     The morphological opening of an image is defined as an erosion followed by
@@ -415,6 +446,19 @@ def opening(image, footprint=None, out=None):
     out : cupy.ndarray, optional
         The array to store the result of the morphology. If None
         is passed, a new array will be allocated.
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
+        'max', 'min', or 'ignore'.
+        If 'max' or 'ignore', pixels outside the image domain are assumed
+        to be the maximum for the image's dtype, which causes them to not
+        influence the result. Default is 'reflect'.
+    cval : scalar, optional
+        Value to fill past edges of input if `mode` is 'constant'. Default
+        is 0.0.
+
+        .. versionadded:: 24.06
+            `mode` and `cval` were added in 24.06.
 
     Returns
     -------
@@ -430,8 +474,8 @@ def opening(image, footprint=None, out=None):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
 
     Examples
     --------
@@ -451,20 +495,20 @@ def opening(image, footprint=None, out=None):
            [0, 0, 0, 0, 0]], dtype=uint8)
 
     """
-    eroded = erosion(image, footprint)
-    # note: shift_x, shift_y do nothing if footprint side length is odd
-    out = dilation(eroded, footprint, out=out, shift_x=True, shift_y=True)
+    footprint = pad_footprint(footprint, pad_end=False)
+    eroded = erosion(image, footprint, mode=mode, cval=cval)
+    out = dilation(
+        eroded,
+        footprint=mirror_footprint(footprint),
+        out=out,
+        mode=mode,
+        cval=cval,
+    )
     return out
 
 
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 @default_footprint
-@pad_for_eccentric_footprints
-def closing(image, footprint=None, out=None):
+def closing(image, footprint=None, out=None, *, mode="reflect", cval=0.0):
     """Return grayscale morphological closing of an image.
 
     The morphological closing of an image is defined as a dilation followed by
@@ -484,6 +528,19 @@ def closing(image, footprint=None, out=None):
     out : cupy.ndarray, optional
         The array to store the result of the morphology. If None,
         a new array will be allocated.
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
+        'max', 'min', or 'ignore'.
+        If 'max' or 'ignore', pixels outside the image domain are assumed
+        to be the maximum for the image's dtype, which causes them to not
+        influence the result. Default is 'reflect'.
+    cval : scalar, optional
+        Value to fill past edges of input if `mode` is 'constant'. Default
+        is 0.0.
+
+        .. versionadded:: 24.06
+            `mode` and `cval` were added in 24.06.
 
     Returns
     -------
@@ -499,8 +556,8 @@ def closing(image, footprint=None, out=None):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
 
     Examples
     --------
@@ -520,9 +577,15 @@ def closing(image, footprint=None, out=None):
            [0, 0, 0, 0, 0]], dtype=uint8)
 
     """
-    dilated = dilation(image, footprint)
-    # note: shift_x, shift_y do nothing if footprint side length is odd
-    out = erosion(dilated, footprint, out=out, shift_x=True, shift_y=True)
+    footprint = pad_footprint(footprint, pad_end=False)
+    dilated = dilation(image, footprint, mode=mode, cval=cval)
+    out = erosion(
+        dilated,
+        footprint=mirror_footprint(footprint),
+        out=out,
+        mode=mode,
+        cval=cval,
+    )
     return out
 
 
@@ -544,12 +607,7 @@ def _white_tophat_seqence(image, footprints, out):
 
 
 @default_footprint
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
-def white_tophat(image, footprint=None, out=None):
+def white_tophat(image, footprint=None, out=None, *, mode="reflect", cval=0.0):
     """Return white top hat of an image.
 
     The white top hat of an image is defined as the image minus its
@@ -568,6 +626,19 @@ def white_tophat(image, footprint=None, out=None):
     out : cupy.ndarray, optional
         The array to store the result of the morphology. If None
         is passed, a new array will be allocated.
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
+        'max', 'min', or 'ignore'.
+        If 'max' or 'ignore', pixels outside the image domain are assumed
+        to be the maximum for the image's dtype, which causes them to not
+        influence the result. Default is 'reflect'.
+    cval : scalar, optional
+        Value to fill past edges of input if `mode` is 'constant'. Default
+        is 0.0.
+
+        .. versionadded:: 24.06
+            `mode` and `cval` were added in 24.06.
 
     Returns
     -------
@@ -583,8 +654,8 @@ def white_tophat(image, footprint=None, out=None):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
 
     See Also
     --------
@@ -613,45 +684,24 @@ def white_tophat(image, footprint=None, out=None):
 
     """
     if out is image:
-        opened = opening(image, footprint)
-        if cp.issubdtype(opened.dtype, cp.bool_):
+        # We need a temporary image
+        opened = opening(image, footprint, mode=mode, cval=cval)
+        if cp.issubdtype(opened.dtype, bool):
             cp.logical_xor(out, opened, out=out)
         else:
             out -= opened
         return out
-    elif out is None:
-        out = cp.empty_like(image)
-    # work-around for NumPy deprecation warning for arithmetic
-    # operations on bool arrays
-    if isinstance(image, cp.ndarray) and image.dtype == bool:
-        image_ = image.view(dtype=cp.uint8)
-    else:
-        image_ = image
-    if isinstance(out, cp.ndarray) and out.dtype == bool:
-        out_ = out.view(dtype=cp.uint8)
+    # Else write intermediate result into output image
+    out = opening(image, footprint, mode=mode, cval=cval)
+    if cp.issubdtype(out.dtype, bool):
+        cp.logical_xor(image, out, out=out)
     else:
-        out_ = out
-    if _footprint_is_sequence(footprint):
-        return _white_tophat_seqence(image_, footprint, out_)
-    elif isinstance(footprint, tuple):
-        if len(footprint) != image.ndim:
-            raise ValueError(
-                "footprint.ndim={len(footprint)}, image.ndim={image.ndim}"
-            )
-        ndi.white_tophat(image, size=footprint, output=out)
-        return out
-
-    out_ = ndi.white_tophat(image_, footprint=footprint, output=out_)
+        cp.subtract(image, out, out=out)
     return out
 
 
 @default_footprint
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
-def black_tophat(image, footprint=None, out=None):
+def black_tophat(image, footprint=None, out=None, *, mode="reflect", cval=0.0):
     """Return black top hat of an image.
 
     The black top hat of an image is defined as its morphological closing minus
@@ -671,6 +721,19 @@ def black_tophat(image, footprint=None, out=None):
     out : cupy.ndarray, optional
         The array to store the result of the morphology. If None
         is passed, a new array will be allocated.
+    mode : str, optional
+        The `mode` parameter determines how the array borders are handled.
+        Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
+        'max', 'min', or 'ignore'.
+        If 'max' or 'ignore', pixels outside the image domain are assumed
+        to be the maximum for the image's dtype, which causes them to not
+        influence the result. Default is 'reflect'.
+    cval : scalar, optional
+        Value to fill past edges of input if `mode` is 'constant'. Default
+        is 0.0.
+
+        .. versionadded:: 24.06
+            `mode` and `cval` were added in 24.06.
 
     Returns
     -------
@@ -686,8 +749,8 @@ def black_tophat(image, footprint=None, out=None):
     would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
     effect that is the same as ``footprint=cp.ones((9, 9))``, but with lower
     computational cost. Most of the builtin footprints such as
-    ``skimage.morphology.disk`` provide an option to automatically generate a
-    footprint sequence of this type.
+    :func:`skimage.morphology.disk` provide an option to automatically generate
+    a footprint sequence of this type.
 
     See Also
     --------
@@ -716,12 +779,17 @@ def black_tophat(image, footprint=None, out=None):
 
     """
     if out is image:
-        original = image.copy()
-    else:
-        original = image
-    out = closing(image, footprint, out=out)
+        # We need a temporary image
+        closed = closing(image, footprint, mode=mode, cval=cval)
+        if cp.issubdtype(closed.dtype, bool):
+            cp.logical_xor(closed, out, out=out)
+        else:
+            cp.subtract(closed, out, out=out)
+        return out
+
+    out = closing(image, footprint, out=out, mode=mode, cval=cval)
     if cp.issubdtype(out.dtype, bool):
-        cp.logical_xor(out, original, out=out)
+        cp.logical_xor(out, image, out=out)
     else:
-        out -= original
+        out -= image
     return out
diff --git a/python/cucim/src/cucim/skimage/morphology/grayreconstruct.py b/python/cucim/src/cucim/skimage/morphology/grayreconstruct.py
index 7e0ba77ea..e94dbcbec 100644
--- a/python/cucim/src/cucim/skimage/morphology/grayreconstruct.py
+++ b/python/cucim/src/cucim/skimage/morphology/grayreconstruct.py
@@ -14,16 +14,9 @@
 import skimage
 from packaging.version import Version
 
-from .._shared.utils import deprecate_kwarg
-
 old_reconstruction_pyx = Version(skimage.__version__) < Version("0.20.0")
 
 
-@deprecate_kwarg(
-    kwarg_mapping={"selem": "footprint"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 def reconstruction(seed, mask, method="dilation", footprint=None, offset=None):
     """Perform a morphological reconstruction of an image.
 
diff --git a/python/cucim/src/cucim/skimage/morphology/grey.py b/python/cucim/src/cucim/skimage/morphology/grey.py
deleted file mode 100644
index bb892ec3c..000000000
--- a/python/cucim/src/cucim/skimage/morphology/grey.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import warnings
-
-from .gray import (  # noqa
-    black_tophat,
-    closing,
-    dilation,
-    erosion,
-    opening,
-    white_tophat,
-)
-
-__all__ = [
-    "erosion",
-    "dilation",
-    "opening",
-    "closing",
-    "white_tophat",
-    "black_tophat",
-]
-
-
-warnings.warn(
-    "Importing from cucim.skimage.morphology.grey is deprecated. "
-    "Please import from cucim.skimage.morphology instead.",
-    FutureWarning,
-    stacklevel=2,
-)
diff --git a/python/cucim/src/cucim/skimage/morphology/greyreconstruct.py b/python/cucim/src/cucim/skimage/morphology/greyreconstruct.py
deleted file mode 100644
index 1f78b4e6a..000000000
--- a/python/cucim/src/cucim/skimage/morphology/greyreconstruct.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import warnings
-
-from .grayreconstruct import reconstruction  # noqa
-
-warnings.warn(
-    "Importing from cucim.skimage.morphology.greyreconstruct is deprecated. "
-    "Please import from cucim.skimage.morphology instead.",
-    FutureWarning,
-    stacklevel=2,
-)
diff --git a/python/cucim/src/cucim/skimage/registration/_masked_phase_cross_correlation.py b/python/cucim/src/cucim/skimage/registration/_masked_phase_cross_correlation.py
index eb803879c..5f7910a29 100644
--- a/python/cucim/src/cucim/skimage/registration/_masked_phase_cross_correlation.py
+++ b/python/cucim/src/cucim/skimage/registration/_masked_phase_cross_correlation.py
@@ -56,8 +56,7 @@ def _masked_phase_cross_correlation(
     -------
     shifts : ndarray
         Shift vector (in pixels) required to register ``moving_image``
-        with ``reference_image``. Axis ordering is consistent with
-        numpy (e.g. Z, Y, X)
+        with ``reference_image``. Axis ordering is consistent with numpy.
 
     References
     ----------
diff --git a/python/cucim/src/cucim/skimage/registration/_optical_flow.py b/python/cucim/src/cucim/skimage/registration/_optical_flow.py
index 3feb28f7a..6d115ac20 100644
--- a/python/cucim/src/cucim/skimage/registration/_optical_flow.py
+++ b/python/cucim/src/cucim/skimage/registration/_optical_flow.py
@@ -1,4 +1,3 @@
-# coding: utf-8
 """TV-L1 optical flow algorithm implementation.
 
 """
@@ -12,7 +11,7 @@
 from .._shared._gradient import gradient
 from .._shared.utils import _supported_float_type
 from ..transform import warp
-from ._optical_flow_utils import coarse_to_fine, get_warp_points
+from ._optical_flow_utils import _coarse_to_fine, _get_warp_points
 
 
 def _tvl1(
@@ -31,9 +30,9 @@ def _tvl1(
     Parameters
     ----------
     reference_image : ndarray, shape (M, N[, P[, ...]])
-        The first gray scale image of the sequence.
+        The first grayscale image of the sequence.
     moving_image : ndarray, shape (M, N[, P[, ...]])
-        The second gray scale image of the sequence.
+        The second grayscale image of the sequence.
     flow0 : ndarray, shape (image0.ndim, M, N[, P[, ...]])
         Initialization for the vector field.
     attachment : float
@@ -56,7 +55,7 @@ def _tvl1(
 
     Returns
     -------
-    flow : ndarray, shape ((image0.ndim, M, N[, P[, ...]])
+    flow : ndarray, shape (image0.ndim, M, N[, P[, ...]])
         The estimated optical flow components for each axis.
 
     """
@@ -97,7 +96,7 @@ def _tvl1(
             )
 
         image1_warp = warp(
-            moving_image, get_warp_points(grid, flow_current), mode="edge"
+            moving_image, _get_warp_points(grid, flow_current), mode="edge"
         )
         # output_as_array=True stacks the gradients along the first axis
         grad = gradient(image1_warp, output_as_array=True)
@@ -181,14 +180,14 @@ def optical_flow_tvl1(
     Parameters
     ----------
     reference_image : ndarray, shape (M, N[, P[, ...]])
-        The first gray scale image of the sequence.
+        The first grayscale image of the sequence.
     moving_image : ndarray, shape (M, N[, P[, ...]])
-        The second gray scale image of the sequence.
+        The second grayscale image of the sequence.
     attachment : float, optional
         Attachment parameter (:math:`\lambda` in [1]_). The smaller
         this parameter is, the smoother the returned result will be.
     tightness : float, optional
-        Tightness parameter (:math:`\tau` in [1]_). It should have
+        Tightness parameter (:math:`\theta` in [1]_). It should have
         a small value in order to maintain attachment and
         regularization parts in correspondence.
     num_warp : int, optional
@@ -210,7 +209,7 @@ def optical_flow_tvl1(
 
     Returns
     -------
-    flow : ndarray, shape ((image0.ndim, M, N[, P[, ...]])
+    flow : ndarray, shape (image0.ndim, M, N[, P[, ...]])
         The estimated optical flow components for each axis.
 
     Notes
@@ -260,7 +259,7 @@ def optical_flow_tvl1(
         msg = f"dtype={dtype} is not supported. Try 'float32' or 'float64.'"
         raise ValueError(msg)
 
-    return coarse_to_fine(reference_image, moving_image, solver, dtype=dtype)
+    return _coarse_to_fine(reference_image, moving_image, solver, dtype=dtype)
 
 
 def _ilk(
@@ -271,9 +270,9 @@ def _ilk(
     Parameters
     ----------
     reference_image : ndarray, shape (M, N[, P[, ...]])
-        The first gray scale image of the sequence.
+        The first grayscale image of the sequence.
     moving_image : ndarray, shape (M, N[, P[, ...]])
-        The second gray scale image of the sequence.
+        The second grayscale image of the sequence.
     flow0 : ndarray, shape (reference_image.ndim, M, N[, P[, ...]])
         Initialization for the vector field.
     radius : int
@@ -289,7 +288,7 @@ def _ilk(
 
     Returns
     -------
-    flow : ndarray, shape ((reference_image.ndim, M, N[, P[, ...]])
+    flow : ndarray, shape (reference_image.ndim, M, N[, P[, ...]])
         The estimated optical flow components for each axis.
 
     """
@@ -325,7 +324,7 @@ def _ilk(
             flow = ndi.median_filter(flow, (1,) + ndim * (3,))
 
         moving_image_warp = warp(
-            moving_image, get_warp_points(grid, flow), mode="edge"
+            moving_image, _get_warp_points(grid, flow), mode="edge"
         )
         # output_as_array=True stacks the gradients along the first axis
         grad = gradient(moving_image_warp, output_as_array=True)
@@ -371,9 +370,9 @@ def optical_flow_ilk(
     Parameters
     ----------
     reference_image : ndarray, shape (M, N[, P[, ...]])
-        The first gray scale image of the sequence.
+        The first grayscale image of the sequence.
     moving_image : ndarray, shape (M, N[, P[, ...]])
-        The second gray scale image of the sequence.
+        The second grayscale image of the sequence.
     radius : int, optional
         Radius of the window considered around each pixel.
     num_warp : int, optional
@@ -393,7 +392,7 @@ def optical_flow_ilk(
 
     Returns
     -------
-    flow : ndarray, shape ((reference_image.ndim, M, N[, P[, ...]])
+    flow : ndarray, shape (reference_image.ndim, M, N[, P[, ...]])
         The estimated optical flow components for each axis.
 
     Notes
@@ -438,4 +437,4 @@ def optical_flow_ilk(
         msg = f"dtype={dtype} is not supported. Try 'float32' or 'float64.'"
         raise ValueError(msg)
 
-    return coarse_to_fine(reference_image, moving_image, solver, dtype=dtype)
+    return _coarse_to_fine(reference_image, moving_image, solver, dtype=dtype)
diff --git a/python/cucim/src/cucim/skimage/registration/_optical_flow_utils.py b/python/cucim/src/cucim/skimage/registration/_optical_flow_utils.py
index 2f21d91ea..9d0477d7e 100644
--- a/python/cucim/src/cucim/skimage/registration/_optical_flow_utils.py
+++ b/python/cucim/src/cucim/skimage/registration/_optical_flow_utils.py
@@ -10,7 +10,7 @@
 from cucim.skimage.util.dtype import _convert
 
 
-def get_warp_points(grid, flow):
+def _get_warp_points(grid, flow):
     """Compute warp point coordinates.
 
     Parameters
@@ -33,7 +33,7 @@ def get_warp_points(grid, flow):
     return out
 
 
-def resize_flow(flow, shape):
+def _resize_flow(flow, shape):
     """Rescale the values of the vector field (u, v) to the desired shape.
 
     The values of the output vector field are scaled to the new
@@ -66,7 +66,7 @@ def resize_flow(flow, shape):
     return rflow
 
 
-def get_pyramid(I, downscale=2.0, nlevel=10, min_size=16):  # noqa
+def _get_pyramid(I, downscale=2.0, nlevel=10, min_size=16):  # noqa
     """Construct image pyramid.
 
     Parameters
@@ -100,7 +100,7 @@ def get_pyramid(I, downscale=2.0, nlevel=10, min_size=16):  # noqa
     return pyramid[::-1]
 
 
-def coarse_to_fine(
+def _coarse_to_fine(
     I0, I1, solver, downscale=2, nlevel=10, min_size=16, dtype=np.float32
 ):
     """Generic coarse to fine solver.
@@ -139,8 +139,8 @@ def coarse_to_fine(
 
     pyramid = list(
         zip(
-            get_pyramid(_convert(I0, dtype), downscale, nlevel, min_size),
-            get_pyramid(_convert(I1, dtype), downscale, nlevel, min_size),
+            _get_pyramid(_convert(I0, dtype), downscale, nlevel, min_size),
+            _get_pyramid(_convert(I1, dtype), downscale, nlevel, min_size),
         )
     )
 
@@ -150,6 +150,6 @@ def coarse_to_fine(
     flow = solver(pyramid[0][0], pyramid[0][1], flow)
 
     for J0, J1 in pyramid[1:]:
-        flow = solver(J0, J1, resize_flow(flow, J0.shape))
+        flow = solver(J0, J1, _resize_flow(flow, J0.shape))
 
     return flow
diff --git a/python/cucim/src/cucim/skimage/registration/_phase_cross_correlation.py b/python/cucim/src/cucim/skimage/registration/_phase_cross_correlation.py
index ba5b8935e..e6be5acf6 100644
--- a/python/cucim/src/cucim/skimage/registration/_phase_cross_correlation.py
+++ b/python/cucim/src/cucim/skimage/registration/_phase_cross_correlation.py
@@ -5,13 +5,13 @@
 
 import itertools
 import math
+import warnings
 
 import cupy as cp
 import cupyx.scipy.ndimage as ndi
 import numpy as np
 
 from .._shared.fft import fftmodule as fft
-from .._shared.utils import remove_arg
 from ._masked_phase_cross_correlation import _masked_phase_cross_correlation
 
 
@@ -116,9 +116,20 @@ def _compute_error(cross_correlation_max, src_amp, target_amp):
     target_amp : float
         The normalized average image intensity of the target image
     """
-    error = 1.0 - cross_correlation_max * cross_correlation_max.conj() / (
-        src_amp * target_amp
-    )
+    amp = src_amp * target_amp
+    if amp == 0:
+        warnings.warn(
+            "Could not determine RMS error between images with the normalized "
+            f"average intensities {src_amp!r} and {target_amp!r}. Either the "
+            "reference or moving image may be empty.",
+            UserWarning,
+            stacklevel=3,
+        )
+
+    with np.errstate(invalid="ignore"):
+        error = 1.0 - cross_correlation_max * cross_correlation_max.conj() / (
+            amp
+        )
 
     return cp.sqrt(cp.abs(error))
 
@@ -176,6 +187,14 @@ def _disambiguate_shift(reference_image, moving_image, shift):
         if corr > max_corr:
             max_corr = corr
             max_slice = test_slice
+    if max_slice is None:
+        warnings.warn(
+            "Could not determine real-space shift for periodic shift "
+            f"{shift!r} as requested by `disambiguate=True` (disambiguation "
+            "is degenerate).",
+            stacklevel=3,
+        )
+        return shift
     real_shift_acc = []
     for sl, pos_shift, neg_shift in zip(
         max_slice, positive_shift, negative_shift
@@ -188,7 +207,6 @@ def _disambiguate_shift(reference_image, moving_image, shift):
     return real_shift
 
 
-@remove_arg("return_error", changed_version="24.06")
 def phase_cross_correlation(
     reference_image,
     moving_image,
@@ -196,7 +214,6 @@ def phase_cross_correlation(
     upsample_factor=1,
     space="real",
     disambiguate=False,
-    return_error=True,
     reference_mask=None,
     moving_mask=None,
     overlap_ratio=0.3,
@@ -307,13 +324,6 @@ def phase_cross_correlation(
            Pattern Recognition, pp. 2918-2925 (2010).
            :DOI:`10.1109/CVPR.2010.5540032`
     """
-    if not return_error:
-        raise ValueError(
-            "return_error must be True (or 'always'), False is no longer "
-            "supported as of cuCIM 24.02 and the `return_error` kwarg will be "
-            "removed in cuCIM 24.06."
-        )
-
     if (reference_mask is not None) or (moving_mask is not None):
         shift = _masked_phase_cross_correlation(
             reference_image,
diff --git a/python/cucim/src/cucim/skimage/restoration/_denoise.py b/python/cucim/src/cucim/skimage/restoration/_denoise.py
index f09829783..97d442424 100644
--- a/python/cucim/src/cucim/skimage/restoration/_denoise.py
+++ b/python/cucim/src/cucim/skimage/restoration/_denoise.py
@@ -91,11 +91,6 @@ def _denoise_tv_chambolle_nd(image, weight=0.1, eps=2.0e-4, max_num_iter=200):
     return out
 
 
-@utils.deprecate_kwarg(
-    {"n_iter_max": "max_num_iter"},
-    removed_version="23.02.00",
-    deprecated_version="22.06.00",
-)
 def denoise_tv_chambolle(
     image, weight=0.1, eps=2.0e-4, max_num_iter=200, *, channel_axis=None
 ):
diff --git a/python/cucim/src/cucim/skimage/restoration/deconvolution.py b/python/cucim/src/cucim/skimage/restoration/deconvolution.py
index 554232d62..41572359b 100644
--- a/python/cucim/src/cucim/skimage/restoration/deconvolution.py
+++ b/python/cucim/src/cucim/skimage/restoration/deconvolution.py
@@ -4,7 +4,11 @@
 import cupy as cp
 import numpy as np
 
-from .._shared.utils import _supported_float_type, deprecate_kwarg
+from .._shared.utils import (
+    DEPRECATED,
+    _supported_float_type,
+    deprecate_parameter,
+)
 from . import uft
 
 __keywords__ = "restoration, image, deconvolution"
@@ -147,15 +151,17 @@ def wiener(image, psf, balance, reg=None, is_real=True, clip=True):
     return deconv
 
 
-@deprecate_kwarg(
-    {"random_state": "rng"},
-    removed_version="24.12.00",
-    deprecated_version="23.08.00",
+@deprecate_parameter(
+    "random_state",
+    new_name="rng",
+    start_version="23.08.00",
+    stop_version="24.12.00",
 )
-@deprecate_kwarg(
-    {"seed": "rng"},
-    removed_version="24.12.00",
-    deprecated_version="23.12.00",
+@deprecate_parameter(
+    "seed",
+    new_name="rng",
+    start_version="23.08.00",
+    stop_version="24.12.00",
 )
 def unsupervised_wiener(
     image,
@@ -166,6 +172,8 @@ def unsupervised_wiener(
     clip=True,
     *,
     rng=None,
+    random_state=DEPRECATED,
+    seed=DEPRECATED,
 ):
     """Unsupervised Wiener-Hunt deconvolution.
 
diff --git a/python/cucim/src/cucim/skimage/restoration/j_invariant.py b/python/cucim/src/cucim/skimage/restoration/j_invariant.py
index 10ec14d2f..3b8c7bb15 100644
--- a/python/cucim/src/cucim/skimage/restoration/j_invariant.py
+++ b/python/cucim/src/cucim/skimage/restoration/j_invariant.py
@@ -96,7 +96,7 @@ def denoise_invariant(
 
     Parameters
     ----------
-    image : ndarray ([M[, N[, ...P]][, C]) of ints, uints or floats
+    image : ndarray (M[, N[, ...]][, C]) of ints, uints or floats
         Input data to be denoised. `image` can be of any numeric type,
         but it is cast into a ndarray of floats (using `img_as_float`) for the
         computation of the denoised image.
diff --git a/python/cucim/src/cucim/skimage/restoration/uft.py b/python/cucim/src/cucim/skimage/restoration/uft.py
index 813d500f0..d2d53404a 100644
--- a/python/cucim/src/cucim/skimage/restoration/uft.py
+++ b/python/cucim/src/cucim/skimage/restoration/uft.py
@@ -74,8 +74,10 @@ def uifftn(inarray, dim=None):
 
     Returns
     -------
-    outarray : ndarray (same shape than inarray)
-        The unitary inverse N-D Fourier transform of ``inarray``.
+    outarray : ndarray
+        The unitary inverse nD Fourier transform of ``inarray``. Has the same
+        shape as ``inarray``.
+
 
     Examples
     --------
@@ -101,7 +103,7 @@ def urfftn(inarray, dim=None):
 
     Parameters
     ----------
-    inarray : ndarray, shape (M, N, ..., P)
+    inarray : ndarray, shape (M[, ...], P)
         The array to transform.
     dim : int, optional
         The last axis along which to compute the transform. All
@@ -109,7 +111,7 @@ def urfftn(inarray, dim=None):
 
     Returns
     -------
-    outarray : ndarray, shape (M, N, ..., P / 2 + 1)
+    outarray : ndarray, shape (M[, ...], P / 2 + 1)
         The unitary N-D real Fourier transform of ``inarray``.
 
     Notes
@@ -254,12 +256,12 @@ def urfft2(inarray):
 
     Parameters
     ----------
-    inarray : ndarray, shape (M, N, ..., P)
+    inarray : ndarray, shape (M[, ...], P)
         The array to transform.
 
     Returns
     -------
-    outarray : ndarray, shape (M, N, ..., 2 * (P - 1))
+    outarray : ndarray, shape (M[, ...], 2 * (P - 1))
         The unitary 2-D real Fourier transform of ``inarray``.
 
     See Also
@@ -289,7 +291,7 @@ def uirfft2(inarray, shape=None):
 
     Parameters
     ----------
-    inarray : ndarray, shape (M, N, ..., P)
+    inarray : ndarray, shape (M[, ...], P)
         The array to transform.
     shape : tuple of int, optional
         The shape of the output. The shape of ``rfft`` is ambiguous in
@@ -298,7 +300,7 @@ def uirfft2(inarray, shape=None):
 
     Returns
     -------
-    outarray : ndarray, shape (M, N, ..., 2 * (P - 1))
+    outarray : ndarray, shape (M[, ...], 2 * (P - 1))
         The unitary 2-D inverse real Fourier transform of ``inarray``.
 
     See Also
diff --git a/python/cucim/src/cucim/skimage/segmentation/_chan_vese.py b/python/cucim/src/cucim/skimage/segmentation/_chan_vese.py
index 81c393794..789518cb1 100644
--- a/python/cucim/src/cucim/skimage/segmentation/_chan_vese.py
+++ b/python/cucim/src/cucim/skimage/segmentation/_chan_vese.py
@@ -4,7 +4,7 @@
 
 from cucim.core.operations.morphology import distance_transform_edt
 
-from .._shared.utils import _supported_float_type, deprecate_kwarg
+from .._shared.utils import _supported_float_type
 from .._vendored import pad
 
 
@@ -272,11 +272,6 @@ def _cv_init_level_set(init_level_set, image_shape, dtype=cp.float64):
     return res.astype(dtype, copy=False)
 
 
-@deprecate_kwarg(
-    {"max_iter": "max_num_iter"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 def chan_vese(
     image,
     mu=0.25,
diff --git a/python/cucim/src/cucim/skimage/segmentation/_expand_labels.py b/python/cucim/src/cucim/skimage/segmentation/_expand_labels.py
index c888f6ffe..ba0f8a410 100644
--- a/python/cucim/src/cucim/skimage/segmentation/_expand_labels.py
+++ b/python/cucim/src/cucim/skimage/segmentation/_expand_labels.py
@@ -3,18 +3,19 @@
 from cucim.core.operations.morphology import distance_transform_edt
 
 
-def expand_labels(label_image, distance=1):
+def expand_labels(label_image, distance=1, spacing=1):
     """Expand labels in label image by ``distance`` pixels without overlapping.
 
     Given a label image, ``expand_labels`` grows label regions (connected
-    components) outwards by up to ``distance`` pixels without overflowing into
-    neighboring regions. More specifically, each background pixel that is
-    within Euclidean distance of <= ``distance`` pixels of a connected
-    component is assigned the label of that connected component.
-
-    Where multiple connected components are within ``distance`` pixels of a
-    background pixel, the label value of the closest connected component will
-    be assigned (see Notes for the case of multiple labels at equal distance).
+    outwards by up to ``distance`` units without overflowing into neighboring
+    regions. More specifically, each background pixel that is within Euclidean
+    distance of <= ``distance`` pixels of a connected component is assigned the
+    label of that connected component. The `spacing` parameter can be used to
+    specify the spacing rate of the distance transform used to calculate the
+    Euclidean distance for anisotropic images where multiple connected
+    components are within ``distance`` pixels of a background pixel, the label
+    value of the closest connected component will be assigned (see Notes for
+    the case of multiple labels at equal distance).
 
     Parameters
     ----------
@@ -23,6 +24,10 @@ def expand_labels(label_image, distance=1):
     distance : float
         Euclidean distance in pixels by which to grow the labels. Default is
         one.
+    spacing : float, or sequence of float, optional
+        Spacing of elements along each dimension. If a sequence, must be of length
+        equal to the input rank; if a single number, this is used for all axes. If
+        not specified, a grid spacing of unity is implied.
 
     Returns
     -------
@@ -79,10 +84,14 @@ def expand_labels(label_image, distance=1):
     array([[2, 1, 1, 0],
            [2, 2, 0, 0],
            [2, 3, 3, 0]])
+    >>> expand_labels(labels2d, 1, spacing=[1, 0.5])
+    array([[1, 1, 1, 1],
+           [2, 2, 2, 0],
+           [3, 3, 3, 3]])
     """  # noqa: E501
 
     distances, nearest_label_coords = distance_transform_edt(
-        label_image == 0, return_indices=True
+        label_image == 0, sampling=spacing, return_indices=True
     )
     labels_out = cp.zeros_like(label_image)
     dilate_mask = distances <= distance
diff --git a/python/cucim/src/cucim/skimage/segmentation/morphsnakes.py b/python/cucim/src/cucim/skimage/segmentation/morphsnakes.py
index c974f0ee0..e189e5a46 100644
--- a/python/cucim/src/cucim/skimage/segmentation/morphsnakes.py
+++ b/python/cucim/src/cucim/skimage/segmentation/morphsnakes.py
@@ -9,7 +9,7 @@
 from cucim import _misc
 
 from .._shared._gradient import gradient
-from .._shared.utils import check_nD, deprecate_kwarg
+from .._shared.utils import check_nD
 
 __all__ = [
     "morphological_chan_vese",
@@ -252,11 +252,6 @@ def _fused_variance_kernel(
     return aux_lt0, aux_gt0
 
 
-@deprecate_kwarg(
-    {"iterations": "num_iter"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 def morphological_chan_vese(
     image,
     num_iter,
@@ -377,11 +372,6 @@ def morphological_chan_vese(
     return u
 
 
-@deprecate_kwarg(
-    {"iterations": "num_iter"},
-    removed_version="23.02.00",
-    deprecated_version="22.02.00",
-)
 def morphological_geodesic_active_contour(
     gimage,
     num_iter,
@@ -404,12 +394,12 @@ def morphological_geodesic_active_contour(
         original image. Instead, this is usually a preprocessed version of the
         original image that enhances and highlights the borders (or other
         structures) of the object to segment.
-        `morphological_geodesic_active_contour` will try to stop the contour
-        evolution in areas where `gimage` is small. See
-        `morphsnakes.inverse_gaussian_gradient` as an example function to
+        :func:`morphological_geodesic_active_contour` will try to stop the
+        contour evolution in areas where `gimage` is small. See
+        :func:`inverse_gaussian_gradient` as an example function to
         perform this preprocessing. Note that the quality of
-        `morphological_geodesic_active_contour` might greatly depend on this
-        preprocessing.
+        :func:`morphological_geodesic_active_contour` might greatly depend on
+        this preprocessing.
     num_iter : uint
         Number of num_iter to run.
     init_level_set : str, (M, N) array, or (L, M, N) array
@@ -425,7 +415,7 @@ def morphological_geodesic_active_contour(
         segmentations.
     threshold : float, optional
         Areas of the image with a value smaller than this threshold will be
-        considered borders. The evolution of the contour will stop in this
+        considered borders. The evolution of the contour will stop in these
         areas.
     balloon : float, optional
         Balloon force to guide the contour in non-informative areas of the
diff --git a/python/cucim/src/cucim/skimage/segmentation/random_walker_segmentation.py b/python/cucim/src/cucim/skimage/segmentation/random_walker_segmentation.py
index 717920c32..cc3ff3e65 100644
--- a/python/cucim/src/cucim/skimage/segmentation/random_walker_segmentation.py
+++ b/python/cucim/src/cucim/skimage/segmentation/random_walker_segmentation.py
@@ -15,6 +15,7 @@
 import cucim.skimage._vendored.ndimage as ndi
 
 from .._shared import utils
+from .._shared.compat import SCIPY_CG_TOL_PARAM_NAME
 from ..util import img_as_float
 
 # TODO: Implemented multigrid solver option, 'cg_mg'
@@ -202,8 +203,16 @@ def _solve_linear_system(lap_sparse, B, tol, mode):
             # ml = ruge_stuben_solver(lap_sparse)
             # M = ml.aspreconditioner(cycle='V')
             # maxiter = 30
+        rtol = {SCIPY_CG_TOL_PARAM_NAME: tol}
         cg_out = [
-            cg(lap_sparse, B[:, i].toarray(), tol=tol, M=M, maxiter=maxiter)
+            cg(
+                lap_sparse,
+                B[:, i].toarray(),
+                **rtol,
+                atol=0,
+                M=M,
+                maxiter=maxiter,
+            )
             for i in range(B.shape[1])
         ]
         if any([info > 0 for _, info in cg_out]):
@@ -302,13 +311,13 @@ def random_walker(
 
     Parameters
     ----------
-    data : array_like
+    data : (M, N[, P][, C]) ndarray
         Image to be segmented in phases. Gray-level `data` can be two- or
         three-dimensional; multichannel data can be three- or four-
         dimensional with `channel_axis` specifying the dimension containing
         channels. Data spacing is assumed isotropic unless the `spacing`
         keyword argument is used.
-    labels : array of ints, of same shape as `data` without channels dimension
+    labels : (M, N[, P]) array of ints
         Array of seed markers labeled with different positive integers
         for different phases. Zero-labeled pixels are unlabeled pixels.
         Negative labels correspond to inactive pixels that are not taken
@@ -447,8 +456,8 @@ def random_walker(
     # Parse input data
     if mode not in ("cg_mg", "cg", "bf", "cg_j", None):
         raise ValueError(
-            "{mode} is not a valid mode. Valid modes are 'cg_mg',"
-            " 'cg', 'cg_j', 'bf' and None".format(mode=mode)
+            f"{mode} is not a valid mode. Valid modes are 'cg_mg',"
+            " 'cg', 'cg_j', 'bf' and None"
         )
 
     if data.dtype.kind == "f":
diff --git a/python/cucim/src/cucim/skimage/transform/_geometric.py b/python/cucim/src/cucim/skimage/transform/_geometric.py
index 8f3758b37..25347db26 100644
--- a/python/cucim/src/cucim/skimage/transform/_geometric.py
+++ b/python/cucim/src/cucim/skimage/transform/_geometric.py
@@ -7,6 +7,7 @@
 import numpy as np
 from scipy import spatial
 
+from .._shared.compat import NP_COPY_IF_NEEDED
 from .._shared.utils import get_bound_method_class, safe_as_int
 
 _sin, _cos = math.sin, math.cos
@@ -233,7 +234,7 @@ def residuals(self, src, dst):
 
         Returns
         -------
-        residuals : (N, ) ndarray
+        residuals : (N,) ndarray
             Residual for coordinate.
 
         """
@@ -273,6 +274,57 @@ class FundamentalMatrixTransform(GeometricTransform):
     params : (3, 3) ndarray
         Fundamental matrix.
 
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import cucim.skimage as ski
+    >>> tform_matrix = ski.transform.FundamentalMatrixTransform()
+
+    Define source and destination points:
+
+    >>> src = np.array([1.839035, 1.924743,
+    ...                 0.543582, 0.375221,
+    ...                 0.473240, 0.142522,
+    ...                 0.964910, 0.598376,
+    ...                 0.102388, 0.140092,
+    ...                15.994343, 9.622164,
+    ...                 0.285901, 0.430055,
+    ...                 0.091150, 0.254594]).reshape(-1, 2)
+    >>> dst = np.array([1.002114, 1.129644,
+    ...                 1.521742, 1.846002,
+    ...                 1.084332, 0.275134,
+    ...                 0.293328, 0.588992,
+    ...                 0.839509, 0.087290,
+    ...                 1.779735, 1.116857,
+    ...                 0.878616, 0.602447,
+    ...                 0.642616, 1.028681]).reshape(-1, 2)
+
+    Estimate the transformation matrix:
+
+    >>> tform_matrix.estimate(src, dst)
+    True
+    >>> tform_matrix.params
+    array([[-0.21785884,  0.41928191, -0.03430748],
+           [-0.07179414,  0.04516432,  0.02160726],
+           [ 0.24806211, -0.42947814,  0.02210191]])
+
+    Compute the Sampson distance:
+
+    >>> tform_matrix.residuals(src, dst)
+    array([0.0053886 , 0.00526101, 0.08689701, 0.01850534, 0.09418259,
+           0.00185967, 0.06160489, 0.02655136])
+
+    Apply inverse transformation:
+
+    >>> tform_matrix.inverse(dst)
+    array([[-0.0513591 ,  0.04170974,  0.01213043],
+           [-0.21599496,  0.29193419,  0.00978184],
+           [-0.0079222 ,  0.03758889, -0.00915389],
+           [ 0.14187184, -0.27988959,  0.02476507],
+           [ 0.05890075, -0.07354481, -0.00481342],
+           [-0.21985267,  0.36717464, -0.01482408],
+           [ 0.01339569, -0.03388123,  0.00497605],
+           [ 0.03420927, -0.1135812 ,  0.02228236]])
     """
 
     # CuPy Backend: if matrix is None cannot infer array module from it
@@ -435,7 +487,7 @@ def residuals(self, src, dst):
 
         Returns
         -------
-        residuals : (N, ) ndarray
+        residuals : (N,) ndarray
             Sampson distance.
 
         """
@@ -661,7 +713,8 @@ def _inv_matrix(self):
     def _apply_mat(self, coords, matrix):
         xp = cp.get_array_module(coords)
         ndim = matrix.shape[0] - 1
-        coords = xp.array(coords, copy=False, ndmin=2)
+        copy = NP_COPY_IF_NEEDED if xp == np else False
+        coords = xp.array(coords, copy=copy, ndmin=2)
 
         src = xp.concatenate([coords, xp.ones((coords.shape[0], 1))], axis=1)
         dst = src @ matrix.T
@@ -887,14 +940,14 @@ def __repr__(self):
         paramstr = self.__nice__()
         classname = self.__class__.__name__
         classstr = classname
-        return "<{}({}) at {}>".format(classstr, paramstr, hex(id(self)))
+        return f"<{classstr}({paramstr}) at {hex(id(self))}>"
 
     def __str__(self):
         """Add standard str formatting around a __nice__ string"""
         paramstr = self.__nice__()
         classname = self.__class__.__name__
         classstr = classname
-        return "<{}({})>".format(classstr, paramstr)
+        return f"<{classstr}({paramstr})>"
 
     @property
     def dimensionality(self):
@@ -1357,7 +1410,7 @@ class EuclideanTransform(ProjectiveTransform):
 
     where the homogeneous transformation matrix is::
 
-        [[a0  b0  a1]
+        [[a0 -b0  a1]
          [b0  a0  b1]
          [0   0    1]]
 
diff --git a/python/cucim/src/cucim/skimage/transform/_warps.py b/python/cucim/src/cucim/skimage/transform/_warps.py
index 88f6f0346..e407820b7 100644
--- a/python/cucim/src/cucim/skimage/transform/_warps.py
+++ b/python/cucim/src/cucim/skimage/transform/_warps.py
@@ -259,11 +259,11 @@ def rescale(
 
     Parameters
     ----------
-    image : ndarray
+    image : (M, N[, ...][, C]) ndarray
         Input image.
     scale : {float, tuple of floats}
-        Scale factors. Separate scale factors can be defined as
-        `(rows, cols[, ...][, dim])`.
+        Scale factors for spatial dimensions. Separate scale factors can be
+        defined as (m, n[, ...]).
 
     Returns
     -------
@@ -614,8 +614,8 @@ def downscale_local_mean(image, factors, cval=0, clip=True):
 
     Parameters
     ----------
-    image : ndarray
-        N-dimensional input image.
+    image : (M[, ...]) ndarray
+        Input image.
     factors : array_like
         Array containing down-sampling integer factor along each axis.
     cval : float, optional
@@ -715,7 +715,7 @@ def swirl(
         the range 0-5. See `skimage.transform.warp` for detail.
     mode : {'constant', 'edge', 'symmetric', 'reflect', 'wrap'}, optional
         Points outside the boundaries of the input are filled according
-        to the given mode, with 'constant' used as the default. Modes match
+        to the given mode, with 'reflect' used as the default. Modes match
         the behaviour of `numpy.pad`.
     cval : float, optional
         Used in conjunction with mode 'constant', the value outside
@@ -1157,24 +1157,24 @@ def _linear_polar_mapping(output_coords, k_angle, k_radius, center):
 
     Parameters
     ----------
-    output_coords : ndarray
-        `(M, 2)` array of `(col, row)` coordinates in the output image
+    output_coords : (M, 2) ndarray
+        Array of `(col, row)` coordinates in the output image.
     k_angle : float
         Scaling factor that relates the intended number of rows in the output
-        image to angle: ``k_angle = nrows / (2 * np.pi)``
+        image to angle: ``k_angle = nrows / (2 * np.pi)``.
     k_radius : float
         Scaling factor that relates the radius of the circle bounding the
         area to be transformed to the intended number of columns in the output
-        image: ``k_radius = ncols / radius``
+        image: ``k_radius = ncols / radius``.
     center : tuple (row, col)
         Coordinates that represent the center of the circle that bounds the
         area to be transformed in an input image.
 
     Returns
     -------
-    coords : ndarray
-        `(M, 2)` array of `(col, row)` coordinates in the input image that
-        correspond to the `output_coords` given as input.
+    coords : (M, 2) ndarray
+        Array of `(col, row)` coordinates in the input image that correspond to
+        the `output_coords` given as input.
     """
     angle = output_coords[:, 1] / k_angle
     rr = ((output_coords[:, 0] / k_radius) * cp.sin(angle)) + center[0]
@@ -1188,24 +1188,24 @@ def _log_polar_mapping(output_coords, k_angle, k_radius, center):
 
     Parameters
     ----------
-    output_coords : ndarray
-        `(M, 2)` array of `(col, row)` coordinates in the output image
+    output_coords : (M, 2) ndarray
+        Array of `(col, row)` coordinates in the output image.
     k_angle : float
         Scaling factor that relates the intended number of rows in the output
-        image to angle: ``k_angle = nrows / (2 * np.pi)``
+        image to angle: ``k_angle = nrows / (2 * np.pi)``.
     k_radius : float
         Scaling factor that relates the radius of the circle bounding the
         area to be transformed to the intended number of columns in the output
-        image: ``k_radius = width / math.log(radius)``
-    center : tuple (row, col)
-        Coordinates that represent the center of the circle that bounds the
-        area to be transformed in an input image.
+        image: ``k_radius = width / np.log(radius)``.
+    center : 2-tuple
+        `(row, col)` coordinates that represent the center of the circle that
+        bounds the area to be transformed in an input image.
 
     Returns
     -------
-    coords : ndarray
-        `(M, 2)` array of `(col, row)` coordinates in the input image that
-        correspond to the `output_coords` given as input.
+    coords : ndarray, shape (M, 2)
+        Array of `(col, row)` coordinates in the input image that correspond to
+        the `output_coords` given as input.
     """
     angle = output_coords[:, 1] / k_angle
     rr = ((cp.exp(output_coords[:, 0] / k_radius)) * cp.sin(angle)) + center[0]
@@ -1229,14 +1229,13 @@ def warp_polar(
 
     Parameters
     ----------
-    image : ndarray
-        Input image. Only 2-D arrays are accepted by default. 3-D arrays are
-        accepted if a `channel_axis` is specified.
-    center : tuple (row, col), optional
-        Point in image that represents the center of the transformation (i.e.,
-        the origin in cartesian space). Values can be of type `float`.
-        If no value is given, the center is assumed to be the center point
-        of the image.
+    image : (M, N[, C]) ndarray
+        Input image. For multichannel images `channel_axis` has to be specified.
+    center : 2-tuple, optional
+        `(row, col)` coordinates of the point in `image` that represents the
+        center of the transformation (i.e., the origin in Cartesian space).
+        Values can be of type `float`. If no value is given, the center is
+        assumed to be the center point of `image`.
     radius : float, optional
         Radius of the circle that bounds the area to be transformed.
     output_shape : tuple (row, col), optional
@@ -1396,7 +1395,7 @@ def resize_local_mean(
     ----------
     image : ndarray
         Input image. If this is a multichannel image, the axis corresponding
-        to channels should be specified using `channel_axis`
+        to channels should be specified using `channel_axis`.
     output_shape : tuple or ndarray
         Size of the generated output image. When `channel_axis` is not None,
         the `channel_axis` should either be omitted from `output_shape` or the
diff --git a/python/cucim/src/cucim/skimage/transform/integral.py b/python/cucim/src/cucim/skimage/transform/integral.py
index dccfcb204..1c457631c 100644
--- a/python/cucim/src/cucim/skimage/transform/integral.py
+++ b/python/cucim/src/cucim/skimage/transform/integral.py
@@ -67,6 +67,9 @@ def integrate(ii, start, end):
     S : scalar or ndarray
         Integral (sum) over the given window(s).
 
+    See Also
+    --------
+    integral_image : Create an integral image / summed area table.
 
     Examples
     --------
diff --git a/python/cucim/src/cucim/skimage/transform/pyramids.py b/python/cucim/src/cucim/skimage/transform/pyramids.py
index 88c512691..9a1313fa2 100644
--- a/python/cucim/src/cucim/skimage/transform/pyramids.py
+++ b/python/cucim/src/cucim/skimage/transform/pyramids.py
@@ -21,7 +21,7 @@ def _smooth(image, sigma, mode, cval, channel_axis):
     gaussian(
         image,
         sigma,
-        output=smoothed,
+        out=smoothed,
         mode=mode,
         cval=cval,
         channel_axis=channel_axis,
diff --git a/python/cucim/src/cucim/skimage/util/_map_array.py b/python/cucim/src/cucim/skimage/util/_map_array.py
index c5421dc00..2fe073020 100644
--- a/python/cucim/src/cucim/skimage/util/_map_array.py
+++ b/python/cucim/src/cucim/skimage/util/_map_array.py
@@ -29,11 +29,11 @@ def map_array(input_arr, input_vals, output_vals, out=None):
 
     Parameters
     ----------
-    input_arr : array of int, shape (M[, N][, P][, ...])
+    input_arr : array of int, shape (M[, ...])
         The input label image.
-    input_vals : array of int, shape (N,)
+    input_vals : array of int, shape (K,)
         The values to map from.
-    output_vals : array, shape (N,)
+    output_vals : array, shape (K,)
         The values to map to.
     out: array, same shape as `input_arr`
         The output array. Will be created if not provided. It should
@@ -43,6 +43,23 @@ def map_array(input_arr, input_vals, output_vals, out=None):
     -------
     out : array, same shape as `input_arr`
         The array of mapped values.
+
+    Notes
+    -----
+    If `input_arr` contains values that aren't covered by `input_vals`, they
+    are set to 0.
+
+    Examples
+    --------
+    >>> import cupy as cp
+    >>> import cucim.skimage as ski
+    >>> ski.util.map_array(
+    ...    input_arr=cp.array([[0, 2, 2, 0], [3, 4, 5, 0]]),
+    ...    input_vals=cp.array([1, 2, 3, 4, 6]),
+    ...    output_vals=cp.array([6, 7, 8, 9, 10]),
+    ... )
+    array([[0, 7, 7, 0],
+           [8, 9, 0, 0]])
     """
 
     if not cp.issubdtype(input_arr.dtype, cp.integer):
@@ -121,9 +138,9 @@ class ArrayMap:
 
     Parameters
     ----------
-    in_values : array of int, shape (N,)
+    in_values : array of int, shape (K,)
         The source values from which to map.
-    out_values : array, shape (N,)
+    out_values : array, shape (K,)
         The destination values from which to map.
     """
 
diff --git a/python/cucim/src/cucim/skimage/util/dtype.py b/python/cucim/src/cucim/skimage/util/dtype.py
index 2ba6dc7bb..21b37944a 100644
--- a/python/cucim/src/cucim/skimage/util/dtype.py
+++ b/python/cucim/src/cucim/skimage/util/dtype.py
@@ -18,28 +18,30 @@
     "dtype_limits",
 ]
 
-# For integers Numpy uses `_integer_types` basis internally, and builds a leaky
-# `cupy.XintYY` abstraction on top of it. This leads to situations when, for
-# example, there are two cupy.Xint64 dtypes with the same attributes but
-# different object references. In order to avoid any potential issues,
-# we use the basis dtypes here. For more information, see:
-# - https://github.com/scikit-image/scikit-image/issues/3043
-# For convenience, for these dtypes we indicate also the possible bit depths
-# (some of them are platform specific). For the details, see:
-# http://www.unix.org/whitepapers/64bit.html
+# Some of these may or may not be aliases depending on architecture & platform
 _integer_types = (
-    cp.byte,
-    cp.ubyte,  # 8 bits
-    cp.short,
-    cp.ushort,  # 16 bits
-    cp.intc,
-    cp.uintc,  # 16 or 32 or 64 bits
+    np.int8,
+    np.byte,
+    np.int16,
+    np.short,
+    np.int32,
+    np.int64,
+    np.longlong,
+    np.int_,
+    np.intp,
+    np.intc,
     int,
-    cp.int_,
-    cp.uint,  # 32 or 64 bits
-    cp.longlong,
-    cp.ulonglong,
-)  # 64 bits
+    np.uint8,
+    np.ubyte,
+    np.uint16,
+    np.ushort,
+    np.uint32,
+    np.uint64,
+    np.ulonglong,
+    np.uint,
+    np.uintp,
+    np.uintc,
+)
 _integer_ranges = {
     t: (cp.iinfo(t).min, cp.iinfo(t).max) for t in _integer_types
 }
@@ -279,7 +281,7 @@ def _convert(image, dtype, force_copy=False, uniform=False):
     #   is a subclass of that type (e.g. `cp.floating` will allow
     #   `float32` and `float64` arrays through)
 
-    if cp.issubdtype(dtype_in, np.core.numerictypes.obj2sctype(dtype)):
+    if cp.issubdtype(dtype_in, dtype):
         if force_copy:
             image = image.copy()
         return image
diff --git a/python/cucim/src/cucim/skimage/util/lookfor.py b/python/cucim/src/cucim/skimage/util/lookfor.py
index bfebe734b..b3baf1d18 100644
--- a/python/cucim/src/cucim/skimage/util/lookfor.py
+++ b/python/cucim/src/cucim/skimage/util/lookfor.py
@@ -1,8 +1,17 @@
 import sys
 
-import numpy as np
+from cucim.skimage._shared.utils import deprecate_func
 
+try:
+    from numpy import lookfor as np_lookfor
+except ImportError:
+    np_lookfor = None
 
+
+@deprecate_func(
+    deprecated_version="24.06",
+    removed_version="24.12",
+)
 def lookfor(what):
     """Do a keyword search on scikit-image docstrings.
 
@@ -11,21 +20,21 @@ def lookfor(what):
     what : str
         Words to look for.
 
+    Notes
+    -----
+    This untested search function is not currently working as expected will be
+    removed as it is unneeded.
+
     Examples
     --------
     >>> import cucim.skimage
     >>> cucim.skimage.lookfor('median')  # doctest: +SKIP
     Search results for 'median'
     ---------------------------
-    cucim.skimage.filters.median
-        Return local median of an image.
-    cucim.skimage.measure.block_reduce
-        Downsample image by applying function `func` to local blocks.
-    cucim.skimage.filters.threshold_local
-        Compute a threshold mask image based on local pixel neighborhood.
-    cucim.skimage.registration.optical_flow_ilk
-        Coarse to fine optical flow estimator.
-    cucim.skimage.registration.optical_flow_tvl1
-        Coarse to fine optical flow estimator.
+    Nothing found.
     """
-    return np.lookfor(what, sys.modules[__name__.split(".")[0]])
+    if np_lookfor is None:
+        raise RuntimeError(
+            "lookfor unavailable (numpy.lookfor was removed in numpy 2.0)"
+        )
+    return np_lookfor(what, sys.modules[__name__.split(".")[0]])
diff --git a/python/cucim/src/cucim/skimage/util/noise.py b/python/cucim/src/cucim/skimage/util/noise.py
index 45ac1169b..fb7d9dc28 100644
--- a/python/cucim/src/cucim/skimage/util/noise.py
+++ b/python/cucim/src/cucim/skimage/util/noise.py
@@ -1,6 +1,6 @@
 import cupy as cp
 
-from .._shared.utils import deprecate_kwarg
+from .._shared.utils import DEPRECATED, deprecate_parameter
 from .dtype import img_as_float
 
 __all__ = ["random_noise"]
@@ -54,10 +54,12 @@ def _bernoulli(p, shape, *, rng):
     return rng.random(shape) <= p
 
 
-@deprecate_kwarg(
-    {"seed": "rng"}, deprecated_version="23.12", removed_version="24.12"
+@deprecate_parameter(
+    "seed", new_name="rng", start_version="23.12", stop_version="24.12"
 )
-def random_noise(image, mode="gaussian", rng=None, clip=True, **kwargs):
+def random_noise(
+    image, mode="gaussian", rng=None, clip=True, *, seed=DEPRECATED, **kwargs
+):
     """
     Function to add random noise of various types to a floating-point image.
 
diff --git a/python/cucim/src/cucim/skimage/util/shape.py b/python/cucim/src/cucim/skimage/util/shape.py
index 377e65799..00ce0fed9 100644
--- a/python/cucim/src/cucim/skimage/util/shape.py
+++ b/python/cucim/src/cucim/skimage/util/shape.py
@@ -14,8 +14,8 @@ def view_as_blocks(arr_in, block_shape):
 
     Parameters
     ----------
-    arr_in : ndarray
-        N-d input array.
+    arr_in : ndarray, shape (M[, ...])
+        Input array.
     block_shape : tuple
         The shape of the block. Each dimension must divide evenly into the
         corresponding dimensions of `arr_in`.
@@ -120,8 +120,8 @@ def view_as_windows(arr_in, window_shape, step=1):
 
     Parameters
     ----------
-    arr_in : ndarray
-        N-d input array.
+    arr_in : ndarray, shape (M[, ...])
+        Input array.
     window_shape : integer or tuple of length arr_in.ndim
         Defines the shape of the elementary n-dimensional orthotope
         (better know as hyperrectangle [1]_) of the rolling window view.


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 9048da8d8..4724fea3e 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -16,7 +16,7 @@ on:
 jobs:
   conda-python-tests:
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/conda-python-tests.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/conda-python-tests.yaml@branch-24.06
     with:
       build_type: nightly
       branch: ${{ inputs.branch }}
@@ -24,7 +24,7 @@ jobs:
       sha: ${{ inputs.sha }}
   wheel-tests:
     secrets: inherit
-    uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@branch-24.04
+    uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@branch-24.06
     with:
       build_type: nightly
       branch: ${{ inputs.branch }}
diff --git a/ci/test_wheel.sh b/ci/test_wheel.sh
index 5a4361401..6b0f9a10b 100755
--- a/ci/test_wheel.sh
+++ b/ci/test_wheel.sh
@@ -11,27 +11,21 @@ python -m pip install $(echo ./dist/cucim*.whl)[test]
 
 CUDA_MAJOR_VERSION=${RAPIDS_CUDA_VERSION:0:2}
 
-# Run smoke tests for aarch64 pull requests
-if [[ "$(arch)" == "aarch64" && ${RAPIDS_BUILD_TYPE} == "pull-request" ]]; then
-    python ./ci/wheel_smoke_test.py
-else
-
-    DEBIAN_FRONTEND=noninteractive apt update
-    DEBIAN_FRONTEND=noninteractive apt install -y --no-install-recommends libopenslide0
+DEBIAN_FRONTEND=noninteractive apt update
+DEBIAN_FRONTEND=noninteractive apt install -y --no-install-recommends libopenslide0
 
-    if [[ ${CUDA_MAJOR_VERSION} == "11" ]]; then
-        # Omit I/O-related tests in ./python/cucim/tests due to known CUDA bug
-        # with dynamic loading of libcufile.
-        python -m pytest \
-          --junitxml="${RAPIDS_TESTS_DIR}/junit-cucim.xml" \
-          --numprocesses=8 \
-          --dist=worksteal \
-          ./python/cucim/src/
-    else
-        python -m pytest \
-          --junitxml="${RAPIDS_TESTS_DIR}/junit-cucim.xml" \
-          --numprocesses=8 \
-          --dist=worksteal \
-          ./python/cucim
-    fi
+if [[ ${CUDA_MAJOR_VERSION} == "11" ]]; then
+    # Omit I/O-related tests in ./python/cucim/tests due to known CUDA bug
+    # with dynamic loading of libcufile.
+    python -m pytest \
+      --junitxml="${RAPIDS_TESTS_DIR}/junit-cucim.xml" \
+      --numprocesses=8 \
+      --dist=worksteal \
+      ./python/cucim/src/
+else
+    python -m pytest \
+      --junitxml="${RAPIDS_TESTS_DIR}/junit-cucim.xml" \
+      --numprocesses=8 \
+      --dist=worksteal \
+      ./python/cucim
 fi
diff --git a/ci/wheel_smoke_test.py b/ci/wheel_smoke_test.py
deleted file mode 100644
index 5311c6a96..000000000
--- a/ci/wheel_smoke_test.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import cucim
-import cucim.skimage
-import cupy as cp
-
-if __name__ == "__main__":
-    # verify that all top-level modules are available
-    assert cucim.is_available("clara")
-    assert cucim.is_available("core")
-    assert cucim.is_available("skimage")
-
-    # generate a synthetic image and apply a filter
-    img = cucim.skimage.data.binary_blobs(length=512, n_dim=2)
-    assert isinstance(img, cp.ndarray)
-    assert img.dtype.kind == "b"
-    assert img.shape == (512, 512)
-
-    eroded = cucim.skimage.morphology.binary_erosion(
-        img, cp.ones((3, 3), dtype=bool)
-    )
diff --git a/python/cucim/src/cucim/skimage/_shared/testing.py b/python/cucim/src/cucim/skimage/_shared/testing.py
index f73bf94bf..911d9cda1 100644
--- a/python/cucim/src/cucim/skimage/_shared/testing.py
+++ b/python/cucim/src/cucim/skimage/_shared/testing.py
@@ -1,3 +1,5 @@
+import inspect
+
 import pytest
 from numpy.testing import (  # noqa
     TestCase,
@@ -40,3 +42,52 @@ def fetch(data_filename):
             pytest.skip(f"Unable to download {data_filename}")
     else:
         pytest.skip("skimage _fetch utility not found")
+
+
+def assert_stacklevel(warnings, *, offset=-1):
+    """Assert correct stacklevel of captured warnings.
+
+    When cucim.skimage raises warnings, the stacklevel should ideally be set
+    so that the origin of the warnings will point to the public function
+    that was called by the user and not necessarily the very place where the
+    warnings were emitted (which may be inside of some internal function).
+    This utility function helps with checking that
+    the stacklevel was set correctly on warnings captured by `pytest.warns`.
+
+    Parameters
+    ----------
+    warnings : collections.abc.Iterable[warning.WarningMessage]
+        Warnings that were captured by `pytest.warns`.
+    offset : int, optional
+        Offset from the line this function is called to the line were the
+        warning is supposed to originate from. For multiline calls, the
+        first line is relevant. Defaults to -1 which corresponds to the line
+        right above the one where this function is called.
+
+    Raises
+    ------
+    AssertionError
+        If a warning in `warnings` does not match the expected line number or
+        file name.
+
+    Examples
+    --------
+    >>> def test_something():
+    ...     with pytest.warns(UserWarning, match="some message") as record:
+    ...         something_raising_a_warning()
+    ...     assert_stacklevel(record)
+    ...
+    >>> def test_another_thing():
+    ...     with pytest.warns(UserWarning, match="some message") as record:
+    ...         iam_raising_many_warnings(
+    ...             "A long argument that forces the call to wrap."
+    ...         )
+    ...     assert_stacklevel(record, offset=-3)
+    """
+    frame = inspect.stack()[1].frame  # 0 is current frame, 1 is outer frame
+    line_number = frame.f_lineno + offset
+    filename = frame.f_code.co_filename
+    expected = f"{filename}:{line_number}"
+    for warning in warnings:
+        actual = f"{warning.filename}:{warning.lineno}"
+        assert actual == expected, f"{actual} != {expected}"
diff --git a/python/cucim/src/cucim/skimage/_shared/tests/test_coord.py b/python/cucim/src/cucim/skimage/_shared/tests/test_coord.py
new file mode 100644
index 000000000..2a8353789
--- /dev/null
+++ b/python/cucim/src/cucim/skimage/_shared/tests/test_coord.py
@@ -0,0 +1,98 @@
+import time
+
+import cupy as cp
+import numpy as np
+import pytest
+from scipy.spatial.distance import minkowski, pdist
+
+from cucim.skimage._shared.coord import ensure_spacing
+
+
+@pytest.mark.parametrize("p", [1, 2, np.inf])
+@pytest.mark.parametrize("size", [30, 50, None])
+def test_ensure_spacing_trivial(p, size):
+    # --- Empty input
+    assert ensure_spacing(cp.asarray([]), p_norm=p).size == 0
+
+    # --- A unique point
+    coord = cp.random.randn(1, 2)
+    assert cp.array_equal(
+        coord, ensure_spacing(coord, p_norm=p, min_split_size=size)
+    )
+
+    # --- Verified spacing
+    coord = cp.random.randn(100, 2)
+
+    # --- 0 spacing
+    assert cp.array_equal(
+        coord, ensure_spacing(coord, spacing=0, p_norm=p, min_split_size=size)
+    )
+
+    # Spacing is chosen to be half the minimum distance
+    coord_cpu = cp.asnumpy(coord)
+    spacing = cp.asarray(pdist(coord_cpu, metric=minkowski, p=p).min() * 0.5)
+
+    out = ensure_spacing(coord, spacing=spacing, p_norm=p, min_split_size=size)
+
+    assert cp.array_equal(coord, out)
+
+
+@pytest.mark.parametrize("ndim", [1, 2, 3, 4, 5])
+@pytest.mark.parametrize("size", [2, 10, None])
+def test_ensure_spacing_nD(ndim, size):
+    coord = cp.ones((5, ndim))
+
+    expected = cp.ones((1, ndim))
+
+    assert cp.array_equal(ensure_spacing(coord, min_split_size=size), expected)
+
+
+@pytest.mark.parametrize("p", [1, 2, np.inf])
+@pytest.mark.parametrize("size", [50, 100, None])
+def test_ensure_spacing_batch_processing(p, size):
+    coord_cpu = np.random.randn(100, 2)
+
+    # --- Consider the average distance btween the point as spacing
+    spacing = cp.asarray(np.median(pdist(coord_cpu, metric=minkowski, p=p)))
+    coord = cp.asarray(coord_cpu)
+
+    expected = ensure_spacing(coord, spacing=spacing, p_norm=p)
+
+    cp.testing.assert_array_equal(
+        ensure_spacing(coord, spacing=spacing, p_norm=p, min_split_size=size),
+        expected,
+    )
+
+
+def test_max_batch_size():
+    """Small batches are slow, large batches -> large allocations -> also slow.
+
+    https://github.com/scikit-image/scikit-image/pull/6035#discussion_r751518691
+    """
+    coords = cp.random.randint(low=0, high=1848, size=(40000, 2))
+    tstart = time.time()
+    ensure_spacing(coords, spacing=100, min_split_size=50, max_split_size=2000)
+    dur1 = time.time() - tstart
+
+    tstart = time.time()
+    ensure_spacing(coords, spacing=100, min_split_size=50, max_split_size=20000)
+    dur2 = time.time() - tstart
+
+    # Originally checked dur1 < dur2 to assert that the default batch size was
+    # faster than a much larger batch size. However, on rare occasion a CI test
+    # case would fail with dur1 ~5% larger than dur2. To be more robust to
+    # variable load or differences across architectures, we relax this here.
+    assert dur1 < 1.33 * dur2
+
+
+@pytest.mark.parametrize("p", [1, 2, np.inf])
+@pytest.mark.parametrize("size", [30, 50, None])
+def test_ensure_spacing_p_norm(p, size):
+    coord_cpu = np.random.randn(100, 2)
+
+    # --- Consider the average distance btween the point as spacing
+    spacing = cp.asarray(np.median(pdist(coord_cpu, metric=minkowski, p=p)))
+    coord = cp.asarray(coord_cpu)
+    out = ensure_spacing(coord, spacing=spacing, p_norm=p, min_split_size=size)
+
+    assert pdist(cp.asnumpy(out), metric=minkowski, p=p).min() > spacing
diff --git a/python/cucim/src/cucim/skimage/_shared/tests/test_utils.py b/python/cucim/src/cucim/skimage/_shared/tests/test_utils.py
index a2ae1729c..8ae5ad9bd 100644
--- a/python/cucim/src/cucim/skimage/_shared/tests/test_utils.py
+++ b/python/cucim/src/cucim/skimage/_shared/tests/test_utils.py
@@ -5,13 +5,15 @@
 import numpy as np
 import pytest
 
+from cucim.skimage._shared import testing
 from cucim.skimage._shared.utils import (
+    DEPRECATED,
     _supported_float_type,
     _validate_interpolation_order,
     change_default_value,
     channel_as_last_axis,
     check_nD,
-    deprecate_kwarg,
+    deprecate_parameter,
 )
 
 complex_dtypes = [np.complex64, np.complex128]
@@ -73,70 +75,6 @@ def bar(arg0, arg1=0, arg2=1):
     assert len(recorded) == 0
 
 
-def test_deprecate_kwarg():
-    @deprecate_kwarg({"old_arg1": "new_arg1"}, "22.02.00")
-    def foo(arg0, new_arg1=1, arg2=None):
-        """Expected docstring"""
-        return arg0, new_arg1, arg2
-
-    @deprecate_kwarg(
-        {"old_arg1": "new_arg1"},
-        deprecated_version="22.02.00",
-        warning_msg="Custom warning message",
-    )
-    def bar(arg0, new_arg1=1, arg2=None):
-        """Expected docstring"""
-        return arg0, new_arg1, arg2
-
-    # Assert that the DeprecationWarning is raised when the deprecated
-    # argument name is used and that the reasult is valid
-    with pytest.warns(FutureWarning) as record:
-        assert foo(0, old_arg1=1) == (0, 1, None)
-        assert bar(0, old_arg1=1) == (0, 1, None)
-
-    msg = (
-        "`old_arg1` is a deprecated argument name "
-        "for `foo`. Please use `new_arg1` instead."
-    )
-    assert str(record[0].message) == msg
-    assert str(record[1].message) == "Custom warning message"
-
-    # Assert that nothing happens when the function is called with the
-    # new API
-    with warnings.catch_warnings(record=True) as recorded:
-        # No kwargs
-        assert foo(0) == (0, 1, None)
-        assert foo(0, 2) == (0, 2, None)
-        assert foo(0, 1, 2) == (0, 1, 2)
-        # Kwargs without deprecated argument
-        assert foo(0, new_arg1=1, arg2=2) == (0, 1, 2)
-        assert foo(0, new_arg1=2) == (0, 2, None)
-        assert foo(0, arg2=2) == (0, 1, 2)
-        assert foo(0, 1, arg2=2) == (0, 1, 2)
-        # Function name and doc is preserved
-        assert foo.__name__ == "foo"
-        if sys.flags.optimize < 2:
-            # if PYTHONOPTIMIZE is set to 2, docstrings are stripped
-            if not have_numpydoc:
-                assert foo.__doc__ == """Expected docstring"""
-            else:
-                assert (
-                    foo.__doc__
-                    == """Expected docstring
-
-
-    Other Parameters
-    ----------------
-    old_arg1 : DEPRECATED
-        Deprecated in favor of `new_arg1`.
-
-        .. deprecated:: 22.02.00
-"""
-                )
-
-    assert len(recorded) == 0
-
-
 def test_check_nD():
     z = np.random.random(200**2).reshape((200, 200))
     x = z[10:30, 30:10]
@@ -258,3 +196,339 @@ def test_decorated_channel_axis_shape(channel_axis):
         assert size is None
     else:
         assert size == x.shape[channel_axis]
+
+
+@deprecate_parameter("old1", start_version="0.10", stop_version="0.12")
+@deprecate_parameter("old0", start_version="0.10", stop_version="0.12")
+def _func_deprecated_params(arg0, old0=DEPRECATED, old1=DEPRECATED, arg1=None):
+    """Expected docstring.
+
+    Parameters
+    ----------
+    arg0 : int
+        First unchanged parameter.
+    arg1 : int, optional
+        Second unchanged parameter.
+    """
+    return arg0, old0, old1, arg1
+
+
+@deprecate_parameter(
+    "old1", new_name="new0", start_version="0.10", stop_version="0.12"
+)
+@deprecate_parameter(
+    "old0", new_name="new1", start_version="0.10", stop_version="0.12"
+)
+def _func_replace_params(
+    arg0, old0=DEPRECATED, old1=DEPRECATED, new0=None, new1=None, arg1=None
+):
+    """Expected docstring.
+
+    Parameters
+    ----------
+    arg0 : int
+        First unchanged parameter.
+    new0 : int, optional
+        First new parameter.
+
+        .. versionadded:: 0.10
+    new1 : int, optional
+        Second new parameter.
+
+        .. versionadded:: 0.10
+    arg1 : int, optional
+        Second unchanged parameter.
+    """
+    return arg0, old0, old1, new0, new1, arg1
+
+
+class Test_deprecate_parameter:
+    @pytest.mark.skipif(not have_numpydoc, reason="requires numpydoc")
+    def test_docstring_removed_param(self):
+        # function name and doc are preserved
+        assert _func_deprecated_params.__name__ == "_func_deprecated_params"
+        if sys.flags.optimize < 2:
+            # if PYTHONOPTIMIZE is set to 2, docstrings are stripped
+            assert (
+                _func_deprecated_params.__doc__
+                == """Expected docstring.
+
+
+    Parameters
+    ----------
+    arg0 : int
+        First unchanged parameter.
+    arg1 : int, optional
+        Second unchanged parameter.
+
+    Other Parameters
+    ----------------
+    old0 : DEPRECATED
+        `old0` is deprecated.
+
+        .. deprecated:: 0.10
+    old1 : DEPRECATED
+        `old1` is deprecated.
+
+        .. deprecated:: 0.10
+"""
+            )
+
+    @pytest.mark.skipif(not have_numpydoc, reason="requires numpydoc")
+    def test_docstring_replaced_param(self):
+        assert _func_replace_params.__name__ == "_func_replace_params"
+        if sys.flags.optimize < 2:
+            # if PYTHONOPTIMIZE is set to 2, docstrings are stripped
+            assert (
+                _func_replace_params.__doc__
+                == """Expected docstring.
+
+
+    Parameters
+    ----------
+    arg0 : int
+        First unchanged parameter.
+    new0 : int, optional
+        First new parameter.
+
+        .. versionadded:: 0.10
+    new1 : int, optional
+        Second new parameter.
+
+        .. versionadded:: 0.10
+    arg1 : int, optional
+        Second unchanged parameter.
+
+    Other Parameters
+    ----------------
+    old0 : DEPRECATED
+        Deprecated in favor of `new1`.
+
+        .. deprecated:: 0.10
+    old1 : DEPRECATED
+        Deprecated in favor of `new0`.
+
+        .. deprecated:: 0.10
+"""
+            )
+
+    def test_warning_removed_param(self):
+        match = (
+            r".*`old[01]` is deprecated since version 0\.10 and will be "
+            r"removed in 0\.12.* see the documentation of "
+            r".*_func_deprecated_params`."
+        )
+        with pytest.warns(FutureWarning, match=match):
+            assert _func_deprecated_params(1, 2) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                None,
+            )
+        with pytest.warns(FutureWarning, match=match):
+            assert _func_deprecated_params(1, 2, 3) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                None,
+            )
+        with pytest.warns(FutureWarning, match=match):
+            assert _func_deprecated_params(1, old0=2) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                None,
+            )
+        with pytest.warns(FutureWarning, match=match):
+            assert _func_deprecated_params(1, old1=2) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                None,
+            )
+
+        with warnings.catch_warnings(record=True) as record:
+            assert _func_deprecated_params(1, arg1=3) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                3,
+            )
+        assert len(record) == 0
+
+    def test_warning_replaced_param(self):
+        match = (
+            r".*`old[0,1]` is deprecated since version 0\.10 and will be "
+            r"removed in 0\.12.* see the documentation of "
+            r".*_func_replace_params`."
+        )
+
+        with pytest.warns(FutureWarning, match=match):
+            assert _func_replace_params(1, 2) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                None,
+                2,
+                None,
+            )
+
+        with pytest.warns(FutureWarning, match=match) as records:
+            assert _func_replace_params(1, 2, 3) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                3,
+                2,
+                None,
+            )
+        assert len(records) == 2
+        assert "`old1` is deprecated" in records[0].message.args[0]
+        assert "`old0` is deprecated" in records[1].message.args[0]
+
+        with pytest.warns(FutureWarning, match=match):
+            assert _func_replace_params(1, old0=2) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                None,
+                2,
+                None,
+            )
+
+        with pytest.warns(FutureWarning, match=match):
+            assert _func_replace_params(1, old1=3) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                3,
+                None,
+                None,
+            )
+
+        # Otherwise, no warnings are emitted!
+        with warnings.catch_warnings(record=True) as record:
+            assert _func_replace_params(1, new0=2, new1=3) == (
+                1,
+                DEPRECATED,
+                DEPRECATED,
+                2,
+                3,
+                None,
+            )
+        assert len(record) == 0
+
+    def test_missing_DEPRECATED(self):
+        decorate = deprecate_parameter(
+            "old", start_version="0.10", stop_version="0.12", stacklevel=2
+        )
+
+        def foo(arg0, old=None):
+            return arg0, old
+
+        with pytest.raises(RuntimeError, match="Expected .* <DEPRECATED>"):
+            decorate(foo)
+
+        def bar(arg0, old=DEPRECATED):
+            return arg0
+
+        assert decorate(bar)(1) == 1
+
+    def test_new_keyword_only(self):
+        @deprecate_parameter(
+            "old",
+            new_name="new",
+            start_version="0.19",
+            stop_version="0.21",
+        )
+        def foo(arg0, old=DEPRECATED, *, new=1, arg3=None):
+            """Expected docstring"""
+            return arg0, new, arg3
+
+        # Assert that nothing happens when the function is called with the
+        # new API
+        with warnings.catch_warnings(record=True) as recorded:
+            # No kwargs
+            assert foo(0) == (0, 1, None)
+            # Kwargs without deprecated argument
+            assert foo(0, new=1, arg3=2) == (0, 1, 2)
+            assert foo(0, new=2) == (0, 2, None)
+            assert foo(0, arg3=2) == (0, 1, 2)
+        assert len(recorded) == 0
+
+    def test_conflicting_old_and_new(self):
+        match = r".*`old[0,1]` is deprecated"
+        with pytest.warns(FutureWarning, match=match):
+            with pytest.raises(ValueError, match=".* avoid conflicting values"):
+                _func_replace_params(1, old0=2, new1=2)
+
+        with pytest.warns(FutureWarning, match=match):
+            with pytest.raises(ValueError, match=".* avoid conflicting values"):
+                _func_replace_params(1, old1=2, new0=2)
+
+        with pytest.warns(FutureWarning, match=match):
+            with pytest.raises(ValueError, match=".* avoid conflicting values"):
+                _func_replace_params(1, old0=1, old1=1, new0=1, new1=1)
+
+    def test_wrong_call_signature(self):
+        """Check that normal errors for faulty calls are unchanged."""
+        with pytest.raises(
+            TypeError, match=r".* required positional argument\: 'arg0'"
+        ):
+            _func_replace_params()
+
+        with pytest.warns(FutureWarning, match=r".*`old[0,1]` is deprecated"):
+            with pytest.raises(
+                TypeError, match=".* multiple values for argument 'old0'"
+            ):
+                _func_deprecated_params(1, 2, old0=2)
+
+    def test_wrong_param_name(self):
+        with pytest.raises(ValueError, match="'old' is not in list"):
+
+            @deprecate_parameter(
+                "old", start_version="0.10", stop_version="0.12"
+            )
+            def foo(arg0):
+                pass
+
+        with pytest.raises(ValueError, match="'new' is not in list"):
+
+            @deprecate_parameter(
+                "old", new_name="new", start_version="0.10", stop_version="0.12"
+            )
+            def bar(arg0, old, arg1):
+                pass
+
+    def test_warning_location(self):
+        with pytest.warns(FutureWarning) as records:
+            _func_deprecated_params(1, old0=2, old1=2)
+            testing.assert_stacklevel(records)
+        assert len(records) == 2
+
+    def test_stacklevel(self):
+        @deprecate_parameter(
+            "old",
+            start_version="0.19",
+            stop_version="0.21",
+        )
+        def foo(arg0, old=DEPRECATED):
+            pass
+
+        with pytest.raises(RuntimeError, match="Set stacklevel manually"):
+            foo(0, 1)
+
+        @deprecate_parameter(
+            "old",
+            start_version="0.19",
+            stop_version="0.21",
+            stacklevel=2,
+        )
+        def bar(arg0, old=DEPRECATED):
+            pass
+
+        with pytest.warns(
+            FutureWarning, match="`old` is deprecated"
+        ) as records:
+            bar(0, 1)
+            testing.assert_stacklevel(records)
diff --git a/python/cucim/src/cucim/skimage/color/tests/test_adapt_rgb.py b/python/cucim/src/cucim/skimage/color/tests/test_adapt_rgb.py
index 9096db9d0..292a04635 100644
--- a/python/cucim/src/cucim/skimage/color/tests/test_adapt_rgb.py
+++ b/python/cucim/src/cucim/skimage/color/tests/test_adapt_rgb.py
@@ -23,7 +23,7 @@ def edges_each(image):
 
 @adapt_rgb(each_channel)
 def smooth_each(image, sigma):
-    return filters.gaussian(image, sigma)
+    return filters.gaussian(image, sigma=sigma)
 
 
 @adapt_rgb(each_channel)
@@ -40,7 +40,7 @@ def edges_hsv(image):
 
 @adapt_rgb(hsv_value)
 def smooth_hsv(image, sigma):
-    return filters.gaussian(image, sigma)
+    return filters.gaussian(image, sigma=sigma)
 
 
 @adapt_rgb(hsv_value)
diff --git a/python/cucim/src/cucim/skimage/color/tests/test_colorconv.py b/python/cucim/src/cucim/skimage/color/tests/test_colorconv.py
index 704e874df..a0ff889ee 100644
--- a/python/cucim/src/cucim/skimage/color/tests/test_colorconv.py
+++ b/python/cucim/src/cucim/skimage/color/tests/test_colorconv.py
@@ -23,6 +23,7 @@
 from skimage import data
 
 from cucim.skimage._shared._warnings import expected_warnings
+from cucim.skimage._shared.testing import assert_stacklevel
 from cucim.skimage._shared.utils import _supported_float_type, slice_at_axis
 from cucim.skimage.color import (
     combine_stains,
@@ -741,8 +742,9 @@ def test_lab_full_gamut(self):
         )
         for value in [0, 10, 20]:
             lab[:, :, 0] = value
-            with pytest.warns(UserWarning, match=regex):
+            with pytest.warns(UserWarning, match=regex) as record:
                 lab2xyz(lab)
+            assert_stacklevel(record)
 
     @pytest.mark.parametrize("channel_axis", [0, 1, -1, -2])
     def test_lab_lch_roundtrip(self, channel_axis):
@@ -894,6 +896,7 @@ def test_warning_stacklevel(self, func):
         )
         with pytest.warns(UserWarning, match=regex) as messages:
             func(lab=cp.array([[[0, 0, 300.0]]]))
+        assert_stacklevel(messages)
         assert len(messages) == 1
         assert messages[0].filename == __file__, "warning points at wrong file"
 
@@ -1006,8 +1009,8 @@ def test_gray2rgba_alpha():
 
     # Warning about alpha cast
     alpha = 0.5
-    with expected_warnings(["alpha can't be safely cast to image dtype"]):
-        rgba = gray2rgba(img_u8, alpha)
+    with expected_warnings(["alpha cannot be safely cast to image dtype"]):
+        rgba = gray2rgba(img_u8, alpha, check_alpha=True)
         assert_array_equal(rgba[..., :3], gray2rgb(img_u8))
 
     # Invalid shape
@@ -1019,6 +1022,24 @@ def test_gray2rgba_alpha():
     assert expected_err_msg == str(err.value)
 
 
+@pytest.mark.parametrize(
+    "alpha,dtype",
+    [
+        (-1, cp.uint8),
+        (300, cp.int8),
+        (0.5, int),
+        (0.5, cp.uint8),
+        (cp.finfo(cp.float64).max, cp.float32),
+    ],
+)
+def test_gray2rgba_alpha_fail_cast(alpha, dtype):
+    image = cp.ones((5, 5), dtype=dtype)
+    with pytest.warns(UserWarning, match="alpha cannot be safely cast"):
+        gray2rgba(image, alpha=alpha, check_alpha=True)
+
+    gray2rgba(image, alpha=alpha, check_alpha=False)
+
+
 @pytest.mark.parametrize("func", [rgb2gray, gray2rgb, gray2rgba])
 @pytest.mark.parametrize(
     "shape", ([(3,), (2, 3), (4, 5, 3), (5, 4, 5, 3), (4, 5, 4, 5, 3)])
diff --git a/python/cucim/src/cucim/skimage/feature/tests/test_blob.py b/python/cucim/src/cucim/skimage/feature/tests/test_blob.py
index 8136775f9..83655ad18 100644
--- a/python/cucim/src/cucim/skimage/feature/tests/test_blob.py
+++ b/python/cucim/src/cucim/skimage/feature/tests/test_blob.py
@@ -30,7 +30,7 @@ def test_blob_dog(dtype, threshold_type):
         threshold = 2.0
         if img.dtype.kind != "f":
             # account for internal scaling to [0, 1] by img_as_float
-            threshold /= img.ptp()
+            threshold /= cp.ptp(img)
         threshold_rel = None
     elif threshold_type == "relative":
         threshold = None
@@ -211,7 +211,7 @@ def test_blob_log(dtype, threshold_type):
         threshold = 1
         if img.dtype.kind != "f":
             # account for internal scaling to [0, 1] by img_as_float
-            threshold /= img.ptp()
+            threshold /= cp.ptp(img)
         threshold_rel = None
     elif threshold_type == "relative":
         threshold = None
@@ -386,7 +386,7 @@ def test_blob_doh(dtype, threshold_type):
         threshold = 0.05
         if img.dtype.kind == "f":
             # account for lack of internal scaling to [0, 1] by img_as_float
-            ptp = img.ptp()
+            ptp = cp.ptp(img)
             threshold *= ptp**2
         threshold_rel = None
     elif threshold_type == "relative":
diff --git a/python/cucim/src/cucim/skimage/filters/tests/test_gaussian.py b/python/cucim/src/cucim/skimage/filters/tests/test_gaussian.py
index ae3349f43..c3d7a60bd 100644
--- a/python/cucim/src/cucim/skimage/filters/tests/test_gaussian.py
+++ b/python/cucim/src/cucim/skimage/filters/tests/test_gaussian.py
@@ -2,6 +2,7 @@
 import numpy as np
 import pytest
 
+from cucim.skimage._shared.testing import assert_stacklevel
 from cucim.skimage.filters._gaussian import difference_of_gaussians, gaussian
 
 
@@ -90,7 +91,7 @@ def test_preserve_range():
     )
 
     img = cp.array([[10.0, -10.0], [-4, 3]], dtype=np.float32)
-    gaussian(img, 1)
+    gaussian(img, sigma=1)
 
 
 def test_1d_ok():
@@ -113,18 +114,16 @@ def test_4d_ok():
 @pytest.mark.parametrize("dtype", [cp.float32, cp.float64])
 def test_preserve_output(dtype):
     image = cp.arange(9, dtype=dtype).reshape((3, 3))
-    output = cp.zeros_like(image, dtype=dtype)
-    gaussian_image = gaussian(
-        image, sigma=1, output=output, preserve_range=True
-    )
-    assert gaussian_image is output
+    out = cp.zeros_like(image, dtype=dtype)
+    gaussian_image = gaussian(image, sigma=1, out=out, preserve_range=True)
+    assert gaussian_image is out
 
 
 def test_output_error():
     image = cp.arange(9, dtype=cp.float32).reshape((3, 3))
-    output = cp.zeros_like(image, dtype=cp.uint8)
-    with pytest.raises(ValueError):
-        gaussian(image, sigma=1, output=output, preserve_range=True)
+    out = cp.zeros_like(image, dtype=cp.uint8)
+    with pytest.raises(ValueError, match="dtype of `out` must be floating"):
+        gaussian(image, sigma=1, out=out, preserve_range=True)
 
 
 @pytest.mark.parametrize("s", [1, (2, 3)])
@@ -136,8 +135,12 @@ def test_difference_of_gaussians(s, s2, channel_axis):
         n_channels = 5
         image = np.stack((image,) * n_channels, channel_axis)
     image = cp.asarray(image)
-    im1 = gaussian(image, s, preserve_range=True, channel_axis=channel_axis)
-    im2 = gaussian(image, s2, preserve_range=True, channel_axis=channel_axis)
+    im1 = gaussian(
+        image, sigma=s, preserve_range=True, channel_axis=channel_axis
+    )
+    im2 = gaussian(
+        image, sigma=s2, preserve_range=True, channel_axis=channel_axis
+    )
     dog = im1 - im2
     dog2 = difference_of_gaussians(image, s, s2, channel_axis=channel_axis)
     assert cp.allclose(dog, dog2)
@@ -146,9 +149,9 @@ def test_difference_of_gaussians(s, s2, channel_axis):
 @pytest.mark.parametrize("s", [1, (1, 2)])
 def test_auto_sigma2(s):
     image = cp.random.rand(10, 10)
-    im1 = gaussian(image, s)
+    im1 = gaussian(image, sigma=s)
     s2 = 1.6 * np.array(s)
-    im2 = gaussian(image, s2)
+    im2 = gaussian(image, sigma=s2)
     dog = im1 - im2
     dog2 = difference_of_gaussians(image, s, s2)
     assert cp.allclose(dog, dog2)
@@ -172,6 +175,16 @@ def test_dog_invalid_sigma2():
         difference_of_gaussians(image, (1, 5), (2, 4))
 
 
+def test_deprecated_gaussian_output():
+    image = cp.array([0, 1, 0], dtype=float)
+    desired = cp.array([0.24197145, 0.39894347, 0.24197145])
+    with pytest.warns(FutureWarning, match="Parameter `output` is") as record:
+        gaussian(image, output=image)
+    assert_stacklevel(record)
+    assert len(record) == 1
+    cp.testing.assert_array_almost_equal(desired, image)
+
+
 @pytest.mark.parametrize(
     "dtype", [cp.uint8, cp.float16, cp.float32, cp.float64]
 )
diff --git a/python/cucim/src/cucim/skimage/filters/tests/test_median.py b/python/cucim/src/cucim/skimage/filters/tests/test_median.py
index e104640c1..d714c0276 100644
--- a/python/cucim/src/cucim/skimage/filters/tests/test_median.py
+++ b/python/cucim/src/cucim/skimage/filters/tests/test_median.py
@@ -1,3 +1,5 @@
+import math
+
 import cupy as cp
 import pytest
 from cupy.testing import assert_allclose
@@ -8,15 +10,6 @@
 from cucim.skimage._shared.testing import expected_warnings
 from cucim.skimage.filters import median
 
-try:
-    from math import prod
-except ImportError:
-    from functools import reduce
-    from operator import mul
-
-    def prod(x):
-        return reduce(mul, x)
-
 
 @pytest.fixture
 def image():
@@ -55,11 +48,6 @@ def test_median_warning(image, mode, cval, behavior, warning_type):
         median(image, mode=mode, behavior=behavior)
 
 
-def test_selem_kwarg_deprecation(image):
-    with expected_warnings(["`selem` is a deprecated argument name"]):
-        median(image, selem=None)
-
-
 # TODO: update if rank.median implemented
 @pytest.mark.parametrize(
     "behavior, func",
@@ -138,7 +126,7 @@ def test_median_hist_dtypes(
         img = rng.integers(-128, 384, shape, dtype=int).astype(cp.int16)
 
     # 150 is the value used to auto-select between sorting vs. histogram
-    small_kernel = prod(footprint_shape) < 150
+    small_kernel = math.prod(footprint_shape) < 150
     if algorithm_kwargs and (
         algorithm == "sorting" or (algorithm == "auto" and small_kernel)
     ):
diff --git a/python/cucim/src/cucim/skimage/filters/tests/test_thresholding.py b/python/cucim/src/cucim/skimage/filters/tests/test_thresholding.py
index a454c9584..3825dcb7e 100644
--- a/python/cucim/src/cucim/skimage/filters/tests/test_thresholding.py
+++ b/python/cucim/src/cucim/skimage/filters/tests/test_thresholding.py
@@ -604,13 +604,6 @@ def test_threshold_minimum_histogram():
     assert_array_equal(threshold, 85)
 
 
-def test_threshold_minimum_deprecated_max_iter_kwarg():
-    camera = util.img_as_ubyte(camerad)
-    hist = histogram(camera.ravel(), 256, source_range="image")
-    with expected_warnings(["`max_iter` is a deprecated argument"]):
-        threshold_minimum(hist=hist, max_iter=5000)
-
-
 def test_threshold_minimum_counts():
     camera = util.img_as_ubyte(camerad)
     counts, bin_centers = histogram(camera.ravel(), 256, source_range="image")
diff --git a/python/cucim/src/cucim/skimage/measure/tests/test_regionprops.py b/python/cucim/src/cucim/skimage/measure/tests/test_regionprops.py
index 1ce3b841f..8829a59ff 100644
--- a/python/cucim/src/cucim/skimage/measure/tests/test_regionprops.py
+++ b/python/cucim/src/cucim/skimage/measure/tests/test_regionprops.py
@@ -1064,6 +1064,16 @@ def test_cache():
     assert cp.any(f0 != f1)
 
 
+def test_disabled_cache_is_empty():
+    SAMPLE_mod = SAMPLE.copy()
+    region = regionprops(SAMPLE_mod, cache=False)[0]
+    # Access one property to trigger cache
+    _ = region.image_filled
+
+    # Cache should be empty
+    assert region._cache == dict()
+
+
 def test_docstrings_and_props():
     def foo():
         """foo"""
@@ -1242,8 +1252,8 @@ def test_column_dtypes_correct():
 
 
 def test_all_documented_items_in_col_dtypes():
-    numpydoc = pytest.importorskip("numpydoc")
-    docstring = numpydoc.docscrape.FunctionDoc(regionprops)
+    numpydoc_docscrape = pytest.importorskip("numpydoc.docscrape")
+    docstring = numpydoc_docscrape.FunctionDoc(regionprops)
     notes_lines = docstring["Notes"]
     property_lines = filter(lambda line: line.startswith("**"), notes_lines)
     pattern = r"\*\*(?P<property_name>[a-z_]+)\*\*.*"
diff --git a/python/cucim/src/cucim/skimage/morphology/tests/test_binary.py b/python/cucim/src/cucim/skimage/morphology/tests/test_binary.py
index a851d542d..aee555cab 100755
--- a/python/cucim/src/cucim/skimage/morphology/tests/test_binary.py
+++ b/python/cucim/src/cucim/skimage/morphology/tests/test_binary.py
@@ -6,7 +6,6 @@
 from skimage import data
 
 from cucim.skimage import color, morphology
-from cucim.skimage._shared._warnings import expected_warnings
 from cucim.skimage.util import img_as_bool
 
 img = color.rgb2gray(cp.array(data.astronaut()))
@@ -20,15 +19,6 @@ def test_non_square_image():
     testing.assert_array_equal(binary_res, gray_res)
 
 
-@pytest.mark.parametrize(
-    "function",
-    ["binary_erosion", "binary_dilation", "binary_closing", "binary_opening"],
-)
-def test_selem_kwarg_deprecation(function):
-    with expected_warnings(["`selem` is a deprecated argument name"]):
-        getattr(morphology, function)(bw_img, selem=morphology.square(3))
-
-
 def test_binary_erosion():
     footprint = morphology.square(3)
     binary_res = morphology.binary_erosion(bw_img, footprint)
@@ -50,6 +40,17 @@ def test_binary_closing():
     testing.assert_array_equal(binary_res, gray_res)
 
 
+def test_binary_closing_extensive():
+    footprint = cp.array([[0, 0, 1], [0, 1, 1], [1, 1, 1]])
+
+    result_default = morphology.binary_closing(bw_img, footprint=footprint)
+    assert cp.all(result_default >= bw_img)
+
+    # mode="min" is expected to be not extensive
+    result_min = morphology.binary_closing(img, footprint=footprint, mode="min")
+    assert not cp.all(result_min >= bw_img)
+
+
 def test_binary_opening():
     footprint = morphology.square(3)
     binary_res = morphology.binary_opening(bw_img, footprint)
@@ -57,6 +58,19 @@ def test_binary_opening():
     testing.assert_array_equal(binary_res, gray_res)
 
 
+def test_binary_opening_anti_extensive():
+    footprint = cp.array([[0, 0, 1], [0, 1, 1], [1, 1, 1]])
+
+    result_default = morphology.binary_opening(bw_img, footprint=footprint)
+    assert cp.all(result_default <= bw_img)
+
+    # mode="max" is expected to be not extensive
+    result_max = morphology.binary_opening(
+        bw_img, footprint=footprint, mode="max"
+    )
+    assert not cp.all(result_max <= bw_img)
+
+
 def _get_decomp_test_data(function, ndim=2):
     if function == "binary_erosion":
         img = cp.ones((17,) * ndim, dtype=cp.uint8)
@@ -223,6 +237,21 @@ def test_out_argument():
 ]
 
 
+@pytest.mark.parametrize("func", binary_functions)
+@pytest.mark.parametrize("mode", ["max", "min", "ignore"])
+def test_supported_mode(func, mode):
+    img = cp.ones((10, 10), dtype=bool)
+    func(img, mode=mode)
+
+
+@pytest.mark.parametrize("func", binary_functions)
+@pytest.mark.parametrize("mode", ["reflect", 3, None])
+def test_unsupported_mode(func, mode):
+    img = cp.ones((10, 10))
+    with pytest.raises(ValueError, match="unsupported mode"):
+        func(img, mode=mode)
+
+
 @pytest.mark.parametrize("function", binary_functions)
 def test_default_footprint(function):
     footprint = morphology.diamond(radius=1)
diff --git a/python/cucim/src/cucim/skimage/morphology/tests/test_gray.py b/python/cucim/src/cucim/skimage/morphology/tests/test_gray.py
index 0b2754df3..4a986472f 100755
--- a/python/cucim/src/cucim/skimage/morphology/tests/test_gray.py
+++ b/python/cucim/src/cucim/skimage/morphology/tests/test_gray.py
@@ -1,13 +1,16 @@
 import cupy as cp
 import numpy as np
 import pytest
+import skimage
 from cupy import testing
 from cupyx.scipy import ndimage as ndi
-from skimage import data
+from packaging.version import Version
+from skimage import data, morphology as morphology_cpu
 
 from cucim.skimage import color, morphology, transform
 from cucim.skimage._shared._warnings import expected_warnings
-from cucim.skimage._shared.testing import fetch
+from cucim.skimage._shared.testing import assert_stacklevel
+from cucim.skimage.morphology import gray
 from cucim.skimage.util import img_as_ubyte, img_as_uint
 
 
@@ -27,124 +30,164 @@ def cell3d_image():
     )
 
 
+gray_morphology_funcs = (
+    "erosion",
+    "dilation",
+    "opening",
+    "closing",
+    "white_tophat",
+    "black_tophat",
+)
+gray_modes = sorted(gray._SUPPORTED_MODES)
+
+
 class TestMorphology:
-    # These expected outputs were generated with skimage v0.12.1
-    # using:
-    #
-    #   from skimage.morphology.tests.test_gray import TestMorphology
-    #   import numpy as np
-    #   output = TestMorphology()._build_expected_output()
-    #   np.savez_compressed('gray_morph_output.npz', **output)
-
-    def _build_expected_output(self):
-        funcs = (
-            morphology.erosion,
-            morphology.dilation,
-            morphology.opening,
-            morphology.closing,
-            morphology.white_tophat,
-            morphology.black_tophat,
-        )
-        footprints_2D = (
-            morphology.square,
-            morphology.diamond,
-            morphology.disk,
-            morphology.star,
-        )
+    @pytest.mark.parametrize(
+        "footprint, footprint_kwargs",
+        (
+            (morphology.square, dict(decomposition=None)),
+            (morphology.diamond, dict(decomposition=None)),
+            (morphology.disk, dict(decomposition=None)),
+            (morphology.star, {}),
+        ),
+    )
+    @pytest.mark.parametrize("func", gray_morphology_funcs)
+    @pytest.mark.parametrize("n", range(1, 5))
+    def test_gray_morphology(self, footprint, footprint_kwargs, func, n):
+        if (n % 2 == 0) and (Version(skimage.__version__) < Version("0.23.0")):
+            # even sized footprint bug fixes require recent skimage
+            pytest.skip("direct CPU comparison requires skimage >= 0.23.0")
 
         image = img_as_ubyte(
             transform.downscale_local_mean(
                 color.rgb2gray(cp.array(data.coffee())), (20, 20)
             )
         )
+        gpu_fp = footprint(n, **footprint_kwargs)
+        if isinstance(gpu_fp, tuple):
+            # skimage doesn't support a tuple footprint
+            cpu_fp = np.ones(gpu_fp, dtype=np.uint8)
+        else:
+            cpu_fp = cp.asnumpy(gpu_fp)
+        cpu_func = getattr(morphology_cpu, func)
+        cpu_result = cpu_func(cp.asnumpy(image), cpu_fp)
+
+        gpu_func = getattr(morphology, func)
+        gpu_result = gpu_func(image, gpu_fp)
+
+        cp.testing.assert_array_equal(cpu_result, gpu_result)
+
+    def test_gray_closing_extensive(self):
+        img = cp.array(data.coins())
+        footprint = cp.array([[0, 0, 1], [0, 1, 1], [1, 1, 1]])
 
-        output = {}
-        for n in range(1, 4):
-            for footprint in footprints_2D:
-                for func in funcs:
-                    key = "{0}_{1}_{2}".format(
-                        footprint.__name__, n, func.__name__
-                    )
-                    output[key] = func(image, footprint(n))
+        # Default mode="reflect" is not extensive for backwards-compatibility
+        result_default = morphology.closing(img, footprint=footprint)
+        assert not cp.all(result_default >= img)
 
-        return output
+        result = morphology.closing(img, footprint=footprint, mode="ignore")
+        assert cp.all(result >= img)
 
-    def test_gray_morphology(self):
-        expected = dict(np.load(fetch("data/gray_morph_output.npz")))
-        calculated = self._build_expected_output()
-        for k, v in calculated.items():
-            cp.testing.assert_array_equal(cp.asarray(expected[k]), v)
+    def test_gray_opening_anti_extensive(self):
+        img = cp.array(data.coins())
+        footprint = cp.array([[0, 0, 1], [0, 1, 1], [1, 1, 1]])
+
+        # Default mode="reflect" is not extensive for backwards-compatibility
+        result_default = morphology.opening(img, footprint=footprint)
+        assert not cp.all(result_default <= img)
+
+        result_ignore = morphology.opening(
+            img, footprint=footprint, mode="ignore"
+        )
+        assert cp.all(result_ignore <= img)
+
+    @pytest.mark.parametrize("func", gray_morphology_funcs)
+    @pytest.mark.parametrize("mode", gray_modes)
+    def test_supported_mode(self, func, mode):
+        img = cp.ones((10, 10))
+        func = getattr(morphology, func)
+        func(img, mode=mode)
+
+    @pytest.mark.parametrize("func", gray_morphology_funcs)
+    @pytest.mark.parametrize("mode", ["", "symmetric", 3, None])
+    def test_unsupported_mode(self, func, mode):
+        img = cp.ones((10, 10))
+        with pytest.raises(ValueError, match="unsupported mode"):
+            func = getattr(morphology, func)
+            func(img, mode=mode)
+
+
+eccentric_footprint_args = [
+    (morphology.square, (2,)),
+    (morphology.rectangle, (2, 2)),
+    (morphology.rectangle, (2, 1)),
+    (morphology.rectangle, (1, 2)),
+]
+eccentric_params = ("footprint_func, args", eccentric_footprint_args)
 
 
 class TestEccentricStructuringElements:
     def setup_method(self):
-        self.black_pixel = 255 * cp.ones((4, 4), dtype=cp.uint8)
-        self.black_pixel[1, 1] = 0
+        self.black_pixel = 255 * cp.ones((6, 6), dtype=cp.uint8)
+        self.black_pixel[2, 2] = 0
         self.white_pixel = 255 - self.black_pixel
-        self.footprints = [
-            morphology.square(2),
-            morphology.rectangle(2, 2),
-            morphology.rectangle(2, 1),
-            morphology.rectangle(1, 2),
-        ]
-
-    def test_dilate_erode_symmetry(self):
-        for s in self.footprints:
-            c = morphology.erosion(self.black_pixel, s)
-            d = morphology.dilation(self.white_pixel, s)
-            assert cp.all(c == (255 - d))
-
-    def test_open_black_pixel(self):
-        for s in self.footprints:
-            gray_open = morphology.opening(self.black_pixel, s)
-            assert cp.all(gray_open == self.black_pixel)
-
-    def test_close_white_pixel(self):
-        for s in self.footprints:
-            gray_close = morphology.closing(self.white_pixel, s)
-            assert cp.all(gray_close == self.white_pixel)
-
-    def test_open_white_pixel(self):
-        for s in self.footprints:
-            assert cp.all(morphology.opening(self.white_pixel, s) == 0)
-
-    def test_close_black_pixel(self):
-        for s in self.footprints:
-            assert cp.all(morphology.closing(self.black_pixel, s) == 255)
-
-    def test_white_tophat_white_pixel(self):
-        for s in self.footprints:
-            tophat = morphology.white_tophat(self.white_pixel, s)
-            cp.testing.assert_array_equal(tophat, self.white_pixel)
-
-    def test_black_tophat_black_pixel(self):
-        for s in self.footprints:
-            tophat = morphology.black_tophat(self.black_pixel, s)
-            cp.testing.assert_array_equal(tophat, 255 - self.black_pixel)
-
-    def test_white_tophat_black_pixel(self):
-        for s in self.footprints:
-            tophat = morphology.white_tophat(self.black_pixel, s)
-            assert cp.all(tophat == 0)
-
-    def test_black_tophat_white_pixel(self):
-        for s in self.footprints:
-            tophat = morphology.black_tophat(self.white_pixel, s)
-            assert cp.all(tophat == 0)
-
-
-gray_functions = [
-    morphology.erosion,
-    morphology.dilation,
-    morphology.opening,
-    morphology.closing,
-    morphology.white_tophat,
-    morphology.black_tophat,
-]
-
 
-@pytest.mark.parametrize("function", gray_functions)
-def test_default_footprint(function):
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_dilate_erode_symmetry(self, footprint_func, args):
+        s = footprint_func(*args)
+        c = morphology.erosion(self.black_pixel, s)
+        d = morphology.dilation(self.white_pixel, s)
+        assert cp.all(c == (255 - d))
+
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_open_black_pixel(self, footprint_func, args):
+        s = footprint_func(*args)
+        gray_open = morphology.opening(self.black_pixel, s)
+        assert cp.all(gray_open == self.black_pixel)
+
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_close_white_pixel(self, footprint_func, args):
+        s = footprint_func(*args)
+        gray_close = morphology.closing(self.white_pixel, s)
+        assert cp.all(gray_close == self.white_pixel)
+
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_open_white_pixel(self, footprint_func, args):
+        s = footprint_func(*args)
+        assert cp.all(morphology.opening(self.white_pixel, s) == 0)
+
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_close_black_pixel(self, footprint_func, args):
+        s = footprint_func(*args)
+        assert cp.all(morphology.closing(self.black_pixel, s) == 255)
+
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_white_tophat_white_pixel(self, footprint_func, args):
+        s = footprint_func(*args)
+        tophat = morphology.white_tophat(self.white_pixel, s)
+        cp.testing.assert_array_equal(tophat, self.white_pixel)
+
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_black_tophat_black_pixel(self, footprint_func, args):
+        s = footprint_func(*args)
+        tophat = morphology.black_tophat(self.black_pixel, s)
+        cp.testing.assert_array_equal(tophat, self.white_pixel)
+
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_white_tophat_black_pixel(self, footprint_func, args):
+        s = footprint_func(*args)
+        tophat = morphology.white_tophat(self.black_pixel, s)
+        assert cp.all(tophat == 0)
+
+    @pytest.mark.parametrize(*eccentric_params)
+    def test_black_tophat_white_pixel(self, footprint_func, args):
+        s = footprint_func(*args)
+        tophat = morphology.black_tophat(self.white_pixel, s)
+        assert cp.all(tophat == 0)
+
+
+@pytest.mark.parametrize("func_name", gray_morphology_funcs)
+def test_default_footprint(func_name):
     footprint = morphology.diamond(radius=1)
     # fmt: off
     image = cp.array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
@@ -161,6 +204,7 @@ def test_default_footprint(function):
                       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], cp.uint8)
     # fmt: on
+    function = getattr(morphology, func_name)
     im_expected = function(image, footprint)
     im_test = function(image)
     cp.testing.assert_array_equal(im_expected, im_test)
@@ -327,28 +371,6 @@ def test_1d_erosion():
     cp.testing.assert_array_equal(eroded, expected)
 
 
-def test_deprecated_import():
-    msg = "Importing from cucim.skimage.morphology.grey is deprecated."
-    with expected_warnings([msg + r"|\A\Z"]):
-        from cucim.skimage.morphology.grey import erosion  # noqa
-
-
-@pytest.mark.parametrize(
-    "function",
-    [
-        "erosion",
-        "dilation",
-        "closing",
-        "opening",
-        "white_tophat",
-        "black_tophat",
-    ],
-)
-def test_selem_kwarg_deprecation(function):
-    with expected_warnings(["`selem` is a deprecated argument name"]):
-        getattr(morphology, function)(cp.zeros((4, 4)), selem=cp.ones((3, 3)))
-
-
 @pytest.mark.parametrize(
     "function",
     [
@@ -538,3 +560,16 @@ def test_tuple_as_footprint(function, ndim, odd_only):
     expected = func(img, footprint=footprint_ndarray)
     out = func(img, footprint=footprint_shape)
     testing.assert_array_equal(expected, out)
+
+
+@pytest.mark.parametrize("func", [morphology.erosion, morphology.dilation])
+@pytest.mark.parametrize("name", ["shift_x", "shift_y"])
+@pytest.mark.parametrize("value", [True, False, None])
+def test_deprecated_shift(func, name, value):
+    img = cp.ones(10)
+    func(img)  # Shouldn't warn
+
+    regex = "`shift_x` and `shift_y` are deprecated"
+    with pytest.warns(FutureWarning, match=regex) as record:
+        func(img, **{name: value})
+    assert_stacklevel(record)
diff --git a/python/cucim/src/cucim/skimage/morphology/tests/test_skeletonize.py b/python/cucim/src/cucim/skimage/morphology/tests/test_skeletonize.py
index 42b9926dd..03c012e60 100644
--- a/python/cucim/src/cucim/skimage/morphology/tests/test_skeletonize.py
+++ b/python/cucim/src/cucim/skimage/morphology/tests/test_skeletonize.py
@@ -5,7 +5,6 @@
 from skimage import data
 from skimage.morphology import thin as thin_cpu
 
-from cucim.skimage._shared._warnings import expected_warnings
 from cucim.skimage.morphology import medial_axis, thin
 
 
@@ -16,22 +15,25 @@ def input_image(self):
         ii = cp.array(
             [
                 [0, 0, 0, 0, 0, 0, 0],
-                [0, 1, 1, 1, 1, 1, 0],
+                [0, 1, 2, 3, 4, 5, 0],
                 [0, 1, 0, 1, 1, 1, 0],
                 [0, 1, 1, 1, 1, 1, 0],
-                [0, 1, 1, 1, 1, 1, 0],
+                [0, 6, 1, 1, 1, 1, 0],
                 [0, 1, 1, 1, 1, 1, 0],
                 [0, 0, 0, 0, 0, 0, 0],
             ],
-            dtype=cp.uint8,
+            dtype=float,
         )
         return ii
 
     def test_zeros(self):
-        assert cp.all(thin(cp.zeros((10, 10))) == 0)
+        image = cp.zeros((10, 10), dtype=bool)
+        assert cp.all(thin(image) == 0)
 
-    def test_iter_1(self):
-        result = thin(self.input_image, 1).astype(cp.uint8)
+    @pytest.mark.parametrize("dtype", [bool, float, int])
+    def test_iter_1(self, dtype):
+        image = self.input_image.astype(dtype)
+        result = thin(image, 1).astype(bool)
         expected = cp.array(
             [
                 [0, 0, 0, 0, 0, 0, 0],
@@ -42,18 +44,14 @@ def test_iter_1(self):
                 [0, 0, 0, 0, 0, 0, 0],
                 [0, 0, 0, 0, 0, 0, 0],
             ],
-            dtype=cp.uint8,
+            dtype=bool,
         )
         assert_array_equal(result, expected)
 
-    def test_max_iter_kwarg_deprecation(self):
-        result1 = thin(self.input_image, max_num_iter=1).astype(cp.uint8)
-        with expected_warnings(["`max_iter` is a deprecated argument name"]):
-            result2 = thin(self.input_image, max_iter=1).astype(cp.uint8)
-        assert_array_equal(result1, result2)
-
-    def test_noiter(self):
-        result = thin(self.input_image).astype(cp.uint8)
+    @pytest.mark.parametrize("dtype", [bool, float, int])
+    def test_noiter(self, dtype):
+        image = self.input_image.astype(dtype)
+        result = thin(image).astype(bool)
         expected = cp.array(
             [
                 [0, 0, 0, 0, 0, 0, 0],
@@ -64,12 +62,12 @@ def test_noiter(self):
                 [0, 0, 0, 0, 0, 0, 0],
                 [0, 0, 0, 0, 0, 0, 0],
             ],
-            dtype=cp.uint8,
+            dtype=bool,
         )
         assert_array_equal(result, expected)
 
     def test_baddim(self):
-        for ii in [cp.zeros((3)), cp.zeros((3, 3, 3))]:
+        for ii in [cp.zeros(3, dtype=bool), cp.zeros((3, 3, 3), dtype=bool)]:
             with pytest.raises(ValueError):
                 thin(ii)
 
@@ -94,12 +92,12 @@ def test_00_01_zeros_masked(self):
         result = medial_axis(cp.zeros((10, 10), bool), cp.zeros((10, 10), bool))
         assert not cp.any(result)
 
-    def _test_vertical_line(self, **kwargs):
+    def _test_vertical_line(self, dtype, **kwargs):
         """Test a thick vertical line, issue #3861"""
-        img = cp.zeros((9, 9))
+        img = cp.zeros((9, 9), dtype=dtype)
         img[:, 2] = 1
-        img[:, 3] = 1
-        img[:, 4] = 1
+        img[:, 3] = 2
+        img[:, 4] = 3
 
         expected = cp.full(img.shape, False)
         expected[:, 3] = True
@@ -107,31 +105,27 @@ def _test_vertical_line(self, **kwargs):
         result = medial_axis(img, **kwargs)
         assert_array_equal(result, expected)
 
-    def test_vertical_line(self):
+    @pytest.mark.parametrize("dtype", [bool, float, int])
+    def test_vertical_line(self, dtype):
         """Test a thick vertical line, issue #3861"""
-        self._test_vertical_line()
+        self._test_vertical_line(dtype=dtype)
 
     def test_rng_numpy(self):
         # NumPy Generator allowed
-        self._test_vertical_line(rng=np.random.default_rng())
+        self._test_vertical_line(dtype=bool, rng=np.random.default_rng())
 
     def test_rng_cupy(self):
         # CuPy Generator not currently supported
         with pytest.raises(ValueError):
-            self._test_vertical_line(rng=cp.random.default_rng())
+            self._test_vertical_line(dtype=bool, rng=cp.random.default_rng())
 
     def test_rng_int(self):
-        self._test_vertical_line(rng=15)
+        self._test_vertical_line(dtype=bool, rng=15)
 
     def test_vertical_line_seed(self):
         """seed was deprecated (now use rng)"""
         with pytest.warns(FutureWarning):
-            self._test_vertical_line(seed=15)
-
-    def test_vertical_line_random_state(self):
-        """random_state was deprecated (now use rng)"""
-        with pytest.warns(FutureWarning):
-            self._test_vertical_line(random_state=15)
+            self._test_vertical_line(dtype=bool, seed=15)
 
     def test_01_01_rectangle(self):
         """Test skeletonize on a rectangle"""
diff --git a/python/cucim/src/cucim/skimage/registration/tests/test_masked_phase_cross_correlation.py b/python/cucim/src/cucim/skimage/registration/tests/test_masked_phase_cross_correlation.py
index 79c0c8255..b1458b5c8 100644
--- a/python/cucim/src/cucim/skimage/registration/tests/test_masked_phase_cross_correlation.py
+++ b/python/cucim/src/cucim/skimage/registration/tests/test_masked_phase_cross_correlation.py
@@ -140,17 +140,13 @@ def test_masked_registration_padfield_data():
     for xi, yi in shifts:
         fixed_image = cp.array(
             imread(
-                fetch(
-                    "registration/tests/data/OriginalX{:d}Y{:d}.png"
-                    "".format(xi, yi)
-                )
+                fetch(f"registration/tests/data/OriginalX{xi:d}Y{yi:d}.png" "")
             )
         )
         moving_image = cp.array(
             imread(
                 fetch(
-                    "registration/tests/data/TransformedX{:d}Y{:d}.png"
-                    "".format(xi, yi)
+                    f"registration/tests/data/TransformedX{xi:d}Y{yi:d}.png" ""
                 )
             )
         )
@@ -270,7 +266,7 @@ def test_cross_correlate_masked_side_effects():
     # CuPy Backed: had to refactor (cannot set write=False)
     # for arr in (arr1, arr2, m1, m2):
     #    arr.setflags(write=False)
-    arr1c, arr2c, m1c, m2c = [a.copy() for a in (arr1, arr2, m1, m2)]
+    arr1c, arr2c, m1c, m2c = (a.copy() for a in (arr1, arr2, m1, m2))
 
     cross_correlate_masked(arr1, arr2, m1, m2)
 
diff --git a/python/cucim/src/cucim/skimage/registration/tests/test_phase_cross_correlation.py b/python/cucim/src/cucim/skimage/registration/tests/test_phase_cross_correlation.py
index 7a853fb86..082c853cf 100644
--- a/python/cucim/src/cucim/skimage/registration/tests/test_phase_cross_correlation.py
+++ b/python/cucim/src/cucim/skimage/registration/tests/test_phase_cross_correlation.py
@@ -10,6 +10,7 @@
 from cucim.skimage import img_as_float
 from cucim.skimage._shared._warnings import expected_warnings
 from cucim.skimage._shared.fft import fftmodule as fft
+from cucim.skimage._shared.testing import assert_stacklevel
 from cucim.skimage.data import binary_blobs
 from cucim.skimage.registration._phase_cross_correlation import (
     _upsampled_dft,
@@ -220,6 +221,27 @@ def test_disambiguate_zero_shift():
         image,
         image,
         disambiguate=True,
-        return_error="always",
     )
     assert computed_shift == (0, 0)
+
+
+@pytest.mark.parametrize("null_images", [(1, 0), (0, 1), (0, 0)])
+def test_disambiguate_empty_image(null_images):
+    """When the image is empty, disambiguation becomes degenerate."""
+    image = cp.array(camera())
+    with pytest.warns(UserWarning) as records:
+        shift, error, phasediff = phase_cross_correlation(
+            image * null_images[0], image * null_images[1], disambiguate=True
+        )
+        assert_stacklevel(records, offset=-3)
+    cp.testing.assert_array_equal(shift, cp.array([0.0, 0.0]))
+    assert cp.isnan(error)
+    assert phasediff == 0.0
+
+    # Check warnings
+    assert len(records) == 2
+    assert "Could not determine real-space shift" in records[0].message.args[0]
+    assert (
+        "Could not determine RMS error between images"
+        in records[1].message.args[0]
+    )
diff --git a/python/cucim/src/cucim/skimage/restoration/tests/test_restoration.py b/python/cucim/src/cucim/skimage/restoration/tests/test_restoration.py
index 94a5c308f..8608e5374 100644
--- a/python/cucim/src/cucim/skimage/restoration/tests/test_restoration.py
+++ b/python/cucim/src/cucim/skimage/restoration/tests/test_restoration.py
@@ -5,7 +5,7 @@
 from scipy import signal
 
 from cucim.skimage import restoration
-from cucim.skimage._shared.testing import expected_warnings, fetch
+from cucim.skimage._shared.testing import fetch
 from cucim.skimage._shared.utils import _supported_float_type
 from cucim.skimage.color import rgb2gray
 from cucim.skimage.restoration import uft
@@ -120,43 +120,6 @@ def test_unsupervised_wiener(dtype):
     # cp.testing.assert_allclose(cp.real(deconvolved), np.load(path), rtol=1e-3)
 
 
-def test_unsupervised_wiener_deprecated_user_param():
-    psf = np.ones((5, 5), dtype=float) / 25
-    data = signal.convolve2d(cp.asnumpy(test_img), psf, "same")
-    data = cp.array(data)
-    psf = cp.array(psf)
-    otf = uft.ir2tf(psf, data.shape, is_real=False)
-    _, laplacian = uft.laplacian(2, data.shape)
-    with expected_warnings(
-        [
-            "`max_iter` is a deprecated key",
-            "`min_iter` is a deprecated key",
-            "`random_state` is a deprecated argument name",
-        ]
-    ):
-        restoration.unsupervised_wiener(
-            data,
-            otf,
-            reg=laplacian,
-            is_real=False,
-            user_params={"max_iter": 200, "min_iter": 30},
-            random_state=5,
-        )
-
-    with expected_warnings(
-        [
-            "`seed` is a deprecated argument name",
-        ]
-    ):
-        restoration.unsupervised_wiener(
-            data,
-            otf,
-            reg=laplacian,
-            is_real=False,
-            seed=5,
-        )
-
-
 def test_image_shape():
     """Test that shape of output image in deconvolution is same as input.
 
diff --git a/python/cucim/src/cucim/skimage/segmentation/tests/test_chan_vese.py b/python/cucim/src/cucim/skimage/segmentation/tests/test_chan_vese.py
index d14da55b6..ec1f61f06 100644
--- a/python/cucim/src/cucim/skimage/segmentation/tests/test_chan_vese.py
+++ b/python/cucim/src/cucim/skimage/segmentation/tests/test_chan_vese.py
@@ -2,7 +2,6 @@
 import pytest
 from cupy.testing import assert_array_equal
 
-from cucim.skimage._shared._warnings import expected_warnings
 from cucim.skimage._shared.utils import _supported_float_type
 from cucim.skimage.segmentation import chan_vese
 
@@ -88,12 +87,6 @@ def test_chan_vese_gap_closing():
     assert_array_equal(result, ref)
 
 
-def test_chan_vese_max_iter_deprecation():
-    img = cp.zeros((20, 20))
-    with expected_warnings(["`max_iter` is a deprecated argument"]):
-        chan_vese(img, max_iter=10)
-
-
 def test_chan_vese_incorrect_level_set():
     img = cp.zeros((10, 10))
     ls = cp.zeros((10, 9))
diff --git a/python/cucim/src/cucim/skimage/segmentation/tests/test_expand_labels.py b/python/cucim/src/cucim/skimage/segmentation/tests/test_expand_labels.py
index 4955db8c0..984582f0a 100644
--- a/python/cucim/src/cucim/skimage/segmentation/tests/test_expand_labels.py
+++ b/python/cucim/src/cucim/skimage/segmentation/tests/test_expand_labels.py
@@ -153,26 +153,36 @@
     ]
 )
 
+SAMPLE3D_EXPAND_SPACING = cp.array(
+    [
+        [[0, 3, 0, 0], [3, 3, 3, 0], [0, 3, 0, 0], [0, 0, 0, 0]],
+        [[0, 3, 3, 0], [3, 3, 3, 3], [0, 3, 3, 0], [0, 0, 0, 0]],
+        [[0, 3, 0, 0], [3, 3, 3, 0], [0, 3, 5, 0], [0, 5, 5, 5]],
+        [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 5, 0], [0, 5, 5, 5]],
+    ]
+)
+
 SAMPLE_EDGECASE_BEHAVIOUR = cp.array([[0, 1, 0, 0], [2, 0, 0, 0], [0, 3, 0, 0]])
 
 
 @pytest.mark.parametrize(
-    "input_array, expected_output, expand_distance",
+    "input_array, expected_output, expand_distance, spacing",
     [
-        (SAMPLE1D, SAMPLE1D_EXPANDED_3, 3),
-        (SAMPLE2D, SAMPLE2D_EXPANDED_3, 3),
-        (SAMPLE2D, SAMPLE2D_EXPANDED_1_5, 1.5),
-        (EDGECASE1D, EDGECASE1D_EXPANDED_3, 3),
-        (EDGECASE2D, EDGECASE2D_EXPANDED_4, 4),
-        (SAMPLE3D, SAMPLE3D_EXPANDED_2, 2),
+        (SAMPLE1D, SAMPLE1D_EXPANDED_3, 3, 1),
+        (SAMPLE2D, SAMPLE2D_EXPANDED_3, 3, 1),
+        (SAMPLE2D, SAMPLE2D_EXPANDED_1_5, 1.5, 1),
+        (EDGECASE1D, EDGECASE1D_EXPANDED_3, 3, 1),
+        (EDGECASE2D, EDGECASE2D_EXPANDED_4, 4, 1),
+        (SAMPLE3D, SAMPLE3D_EXPANDED_2, 2, 1),
+        (SAMPLE3D, SAMPLE3D_EXPAND_SPACING, 1, [2, 1, 1]),
     ],
 )
-def test_expand_labels(input_array, expected_output, expand_distance):
+def test_expand_labels(input_array, expected_output, expand_distance, spacing):
     if input_array.ndim == 1:
         with pytest.raises(NotImplementedError):
-            expand_labels(input_array, expand_distance)
+            expand_labels(input_array, expand_distance, spacing)
     else:
-        expanded = expand_labels(input_array, expand_distance)
+        expanded = expand_labels(input_array, expand_distance, spacing)
         assert_array_equal(expanded, expected_output)
 
 
diff --git a/python/cucim/src/cucim/skimage/segmentation/tests/test_morphsnakes.py b/python/cucim/src/cucim/skimage/segmentation/tests/test_morphsnakes.py
index fa64820aa..4dcb90c36 100644
--- a/python/cucim/src/cucim/skimage/segmentation/tests/test_morphsnakes.py
+++ b/python/cucim/src/cucim/skimage/segmentation/tests/test_morphsnakes.py
@@ -2,7 +2,6 @@
 import pytest
 from cupy.testing import assert_array_equal
 
-from cucim.skimage._shared.testing import expected_warnings
 from cucim.skimage.segmentation import (
     disk_level_set,
     inverse_gaussian_gradient,
@@ -66,23 +65,6 @@ def test_morphsnakes_black():
     assert acwe_ls.dtype == gac_ls.dtype == gac_ls2.dtype == cp.int8
 
 
-def test_morphsnakes_iterations_kwarg_deprecation():
-    img = cp.zeros((11, 11))
-    ls = disk_level_set(img.shape, center=(5, 5), radius=3)
-
-    ref_zeros = cp.zeros(img.shape, dtype=cp.int8)
-
-    with expected_warnings(["`iterations` is a deprecated argument"]):
-        acwe_ls = morphological_chan_vese(img, iterations=6, init_level_set=ls)
-    assert_array_equal(acwe_ls, ref_zeros)
-
-    with expected_warnings(["`iterations` is a deprecated argument"]):
-        gac_ls = morphological_geodesic_active_contour(
-            img, iterations=6, init_level_set=ls
-        )
-    assert_array_equal(gac_ls, ref_zeros)
-
-
 def test_morphsnakes_simple_shape_chan_vese():
     img = gaussian_blob()
     ls1 = disk_level_set(img.shape, center=(5, 5), radius=3)
diff --git a/python/cucim/src/cucim/skimage/transform/tests/test_pyramids.py b/python/cucim/src/cucim/skimage/transform/tests/test_pyramids.py
index da022aeb3..1586539c7 100644
--- a/python/cucim/src/cucim/skimage/transform/tests/test_pyramids.py
+++ b/python/cucim/src/cucim/skimage/transform/tests/test_pyramids.py
@@ -1,4 +1,5 @@
 import math
+import warnings
 
 import cupy as cp
 import pytest
@@ -208,7 +209,16 @@ def test_check_factor():
     "pyramid_func", [pyramids.pyramid_gaussian, pyramids.pyramid_laplacian]
 )
 def test_pyramid_dtype_support(pyramid_func, dtype):
-    img = cp.ones((32, 8), dtype=dtype)
+    with warnings.catch_warnings():
+        # Ignore arch specific warning on arm64, armhf, ppc64el, riscv64, s390x
+        # https://github.com/scikit-image/scikit-image/issues/7391
+        warnings.filterwarnings(
+            action="ignore",
+            category=RuntimeWarning,
+            message="invalid value encountered in cast",
+        )
+        img = cp.random.randn(32, 8).astype(dtype)
+
     pyramid = pyramid_func(img)
 
     float_dtype = _supported_float_type(dtype)
diff --git a/python/cucim/src/cucim/skimage/transform/tests/test_warps.py b/python/cucim/src/cucim/skimage/transform/tests/test_warps.py
index 403cc12ea..643663a5c 100644
--- a/python/cucim/src/cucim/skimage/transform/tests/test_warps.py
+++ b/python/cucim/src/cucim/skimage/transform/tests/test_warps.py
@@ -881,7 +881,7 @@ def test_linear_warp_polar(dtype):
     assert warped.dtype == _supported_float_type(dtype)
     profile = warped.mean(axis=0)
     peaks = cp.asnumpy(peak_local_max(profile))
-    assert np.alltrue([peak in radii for peak in peaks])
+    assert np.all([peak in radii for peak in peaks])
 
 
 @pytest.mark.parametrize("dtype", [cp.float16, cp.float32, cp.float64])
@@ -906,7 +906,7 @@ def test_log_warp_polar(dtype):
     peaks_coord = peak_local_max(profile)
     peaks_coord.sort(axis=0)
     gaps = cp.asnumpy(peaks_coord[1:] - peaks_coord[:-1])
-    assert np.alltrue([x >= 38 and x <= 40 for x in gaps])
+    assert np.all([x >= 38 and x <= 40 for x in gaps])
 
 
 def test_invalid_scaling_polar():
diff --git a/python/cucim/src/cucim/skimage/util/tests/test_dtype.py b/python/cucim/src/cucim/skimage/util/tests/test_dtype.py
index 1f3d5d8ef..63958594c 100644
--- a/python/cucim/src/cucim/skimage/util/tests/test_dtype.py
+++ b/python/cucim/src/cucim/skimage/util/tests/test_dtype.py
@@ -61,7 +61,7 @@ def test_range(dtype, f_and_dt):
         imin = 0
 
     _verify_range(
-        "From %s to %s" % (cp.dtype(dtype), cp.dtype(dt)),
+        f"From {cp.dtype(dtype)} to {cp.dtype(dt)}",
         y,
         omin,
         omax,
@@ -96,7 +96,7 @@ def test_range_extra_dtypes(dtype_in, dt):
 
     omin, omax = dtype_range_extra[dt]
     _verify_range(
-        "From %s to %s" % (cp.dtype(dtype_in), cp.dtype(dt)),
+        f"From {cp.dtype(dtype_in)} to {cp.dtype(dt)}",
         y,
         omin,
         omax,
diff --git a/python/cucim/src/localtest.py b/python/cucim/src/localtest.py
index 452e6bc47..f5794dd43 100644
--- a/python/cucim/src/localtest.py
+++ b/python/cucim/src/localtest.py
@@ -79,7 +79,7 @@ def __enter__(self):
     def __exit__(self, exc_type, exc, exc_tb):
         if not self.end:
             self.elapsed_time()
-        print("{} : {}".format(self.message, self.end - self.start))
+        print(f"{self.message} : {self.end - self.start}")
 
 
 num_threads = os.cpu_count()
@@ -142,9 +142,7 @@ def load_tile_cucim(slide, start_loc, tile_size):
         cucim_time = timer.elapsed_time()
         cucim_tot_time += cucim_time
     print(
-        "  Performance gain (OpenSlide/cuCIM): {}".format(
-            openslide_time / cucim_time
-        )
+        f"  Performance gain (OpenSlide/cuCIM): {openslide_time / cucim_time}"
     )
 
 print("Total time (OpenSlide):", openslide_tot_time)
diff --git a/python/cucim/tests/performance/clara/test_read_region_memory_usage.py b/python/cucim/tests/performance/clara/test_read_region_memory_usage.py
index 12aacab1a..8f259b910 100644
--- a/python/cucim/tests/performance/clara/test_read_region_memory_usage.py
+++ b/python/cucim/tests/performance/clara/test_read_region_memory_usage.py
@@ -72,6 +72,7 @@ def test_read_region_cpu_memleak(testimg_tiff_stripe_4096x4096_256):
 
 
 def test_read_random_region_cpu_memleak(testimg_tiff_stripe_4096x4096_256):
+    import gc
     import os
     import random
 
@@ -81,10 +82,12 @@ def test_read_random_region_cpu_memleak(testimg_tiff_stripe_4096x4096_256):
 
     img = open_image_cucim(testimg_tiff_stripe_4096x4096_256)
 
-    iteration = 1000
+    iteration = 10000
     mem_usage_history = [process.memory_info().rss] * iteration
     level_count = img.resolutions["level_count"]
 
+    memory_increment_count = 0
+
     for i in range(iteration):
         location = (
             random.randrange(-2048, 4096 + 2048),
@@ -92,12 +95,26 @@ def test_read_random_region_cpu_memleak(testimg_tiff_stripe_4096x4096_256):
         )
         level = random.randrange(0, level_count)
         _ = img.read_region(location, (256, 256), level)
+        if i == 0 or i == iteration - 1:
+            gc.collect()
         mem_usage_history[i] = process.memory_info().rss
+        if i > 0:
+            if mem_usage_history[i] - mem_usage_history[i - 1] > 0:
+                memory_increment_count += 1
+                print(
+                    f"mem increase (iteration: {i:3d}): "
+                    f"{mem_usage_history[i] - mem_usage_history[i - 1]:4d} "
+                    "bytes"
+                )
 
     print(mem_usage_history)
 
-    # Memory usage difference should be smaller than (iteration) * 100 bytes
-    assert mem_usage_history[-1] - mem_usage_history[1] < iteration * 100
+    # The expected memory usage difference should be smaller than
+    # <iteration> * 256 * 3 bytes
+    # (one line of pixels in the tile image is 256 * 3 bytes)
+    assert mem_usage_history[-1] - mem_usage_history[1] < iteration * 256 * 3
+    # The memory usage increment should be less than 1% of the iteration count.
+    assert memory_increment_count < iteration * 0.01
 
 
 def test_tiff_iterator(testimg_tiff_stripe_4096x4096_256):
diff --git a/python/cucim/tests/util/gen_image.py b/python/cucim/tests/util/gen_image.py
index 9334c691c..e2100771b 100644
--- a/python/cucim/tests/util/gen_image.py
+++ b/python/cucim/tests/util/gen_image.py
@@ -67,9 +67,7 @@ def gen(self):
 
             generator_obj = GENERATOR_MAP.get(kind)
             if generator_obj is None:
-                raise RuntimeError(
-                    "There is no generator for '{}'".format(kind)
-                )
+                raise RuntimeError(f"There is no generator for '{kind}'")
 
             image_data = generator_obj.get_image(
                 pattern=pattern, image_size=image_size
diff --git a/python/cucim/tests/util/gen_tiff.py b/python/cucim/tests/util/gen_tiff.py
index f3ee8231b..1a7296cf5 100644
--- a/python/cucim/tests/util/gen_tiff.py
+++ b/python/cucim/tests/util/gen_tiff.py
@@ -20,7 +20,7 @@
 import numpy as np
 from tifffile import TiffWriter
 
-COMPRESSION_MAP = {"jpeg": ("jpeg", 95), "deflate": "deflate", "raw": None}
+COMPRESSION_MAP = {"jpeg": "jpeg", "deflate": "deflate", "raw": None}
 
 
 class TiffGenerator:
@@ -57,8 +57,11 @@ def save_image(
             raise RuntimeError("'image_data' is neithor list or numpy.ndarray")
 
         compression = COMPRESSION_MAP.get(compression)
+        compressionargs = None
         if not compression:
-            compression = ("jpeg", 95)
+            compression = "jpeg"
+        if compression == "jpeg":
+            compressionargs = {"level": 95}
 
         # save as tif
         tiff_file_name = str(
@@ -84,6 +87,7 @@ def save_image(
                     photometric="RGB",
                     planarconfig="CONTIG",
                     compression=compression,  # requires imagecodecs
+                    compressionargs=compressionargs,
                     subfiletype=1 if level else 0,
                     resolution=level_resolution,
                     resolutionunit=resolutionunit,