Repository: Project-MONAI/MONAI

Issue:
A utility function to generate sampling points
**Is your feature request related to a problem? Please describe.**
Currently there are a few functions in monai.transforms to help sampling image patches:

1. `map_classes_to_indices` to identify all feasible sampling indices
2. `generate_label_classes_crop_centers` to subsample and generate spatial coordinates
3. `RandCropByLabelClasses` combines 1, 2 and output image patches


**Describe the solution you'd like**

Would be great to have a helper function to run step 1 and 2 as a single command. The function should output sampling coordinates instead of the final patch

**Describe alternatives you've considered**

Write the logic with multiple commands: https://gist.github.com/wyli/50c65e686beaf418a32c9cc131f0bdc7

Same idea applies to the other sampling transforms such as RandCropByPosNegLabel


Discussion:
I wrote this helper function with the expectation that the output would be the coordinates of the centers. I hope it meets your requirements.


```
def combineMCtIandGLCCC(
    label: NdarrayOrTensor,
    spatial_size: Sequence[int] | int,
    num_samples: int,
    label_spatial_shape: Sequence[int],
    indices: Sequence[NdarrayOrTensor],
    num_classes: int | None = None,
    image: NdarrayOrTensor | None = None,
    image_threshold: float = 0.0,
    max_samples_per_class: int | None = None,
    ratios: list[float | int] | None = None,
    rand_state: np.random.RandomState | None = None,
    allow_smaller: bool = False,
    warn: bool = True,
) -> list[NdarrayOrTensor]:
    """
    Combine "map_classes_to_indices" and "generate_pos_neg_label_crop_centers" functions, return crop center coordinates.
     Args:
        spatial_size: spatial size of the ROIs to be sampled.
        num_samples: total sample centers to be generated.
        pos_ratio: ratio of total locations generated that have center being foreground.
        label_spatial_shape: spatial shape of the original label data to unravel selected centers.
        fg_indices: pre-computed foreground indices in 1 dimension.
        bg_indices: pre-computed background indices in 1 dimension.
        rand_state: numpy randomState object to align with other modules.
        allow_smaller: if `False`, an exception will be raised if the image is smaller than
            the requested ROI in any dimension. If `True`, any smaller dimensions will be set to
            match the cropped size (i.e., no cropping in that dimension).
        label: use the label data to get the indices of every class.
        num_classes: number of classes for argmax label, not necessary for One-Hot label.
        image: if image is not None, only return the indices of every class that are within the valid
            region of the image (``image > image_threshold``).
        image_threshold: if enabled `image`, use ``image > image_threshold`` to
            determine the valid image content area and select class indices only in this area.
        max_samples_per_class: maximum length of indices in each class to reduce memory consumption.
            Default is None, no subsampling.    
        
        Raises:
        ValueError: When the proposed roi is larger than the image.
        ValueError: When the foreground and background indices lengths are 0.
    """

    indices_ = indices if indices is None else indices
    if indices_ is None:
        if label is None:
            raise ValueError("label must not be None.")
        indices_ = map_classes_to_indices(
            label, num_classes, image, image_threshold, max_samples_per_class
        )
    _shape = None
    if label is not None:
        _shape = label.peek_pending_shape() if isinstance(label, MetaTensor) else label.shape[1:]
    elif image is not None:
        _shape = image.peek_pending_shape() if isinstance(image, MetaTensor) else image.shape[1:]
    if _shape is None:
        raise ValueError("label or image must be provided to infer the output spatial shape.")
    centers = generate_label_classes_crop_centers(
        spatial_size, num_samples, _shape, indices_, ratios, rand_state, allow_smaller, warn
    )
    return centers
```
    
@Yu0610 can you start a PR ?

Resolving patch:
diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py
--- a/monai/transforms/__init__.py
+++ b/monai/transforms/__init__.py
@@ -671,6 +671,7 @@
     in_bounds,
     is_empty,
     is_positive,
+    map_and_generate_sampling_centers,
     map_binary_to_indices,
     map_classes_to_indices,
     map_spatial_axes,
diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py
--- a/monai/transforms/utils.py
+++ b/monai/transforms/utils.py
@@ -108,6 +108,7 @@
     "in_bounds",
     "is_empty",
     "is_positive",
+    "map_and_generate_sampling_centers",
     "map_binary_to_indices",
     "map_classes_to_indices",
     "map_spatial_axes",
@@ -368,6 +369,70 @@ def check_non_lazy_pending_ops(
         warnings.warn(msg)
 
 
+def map_and_generate_sampling_centers(
+    label: NdarrayOrTensor,
+    spatial_size: Sequence[int] | int,
+    num_samples: int,
+    label_spatial_shape: Sequence[int] | None = None,
+    num_classes: int | None = None,
+    image: NdarrayOrTensor | None = None,
+    image_threshold: float = 0.0,
+    max_samples_per_class: int | None = None,
+    ratios: list[float | int] | None = None,
+    rand_state: np.random.RandomState | None = None,
+    allow_smaller: bool = False,
+    warn: bool = True,
+) -> tuple[tuple]:
+    """
+    Combine "map_classes_to_indices" and "generate_label_classes_crop_centers" functions, return crop center coordinates.
+    This calls `map_classes_to_indices` to get indices from `label`, gets the shape from `label_spatial_shape`
+    is given otherwise from the labels, calls `generate_label_classes_crop_centers`, and returns its results.
+
+    Args:
+        label: use the label data to get the indices of every class.
+        spatial_size: spatial size of the ROIs to be sampled.
+        num_samples: total sample centers to be generated.
+        label_spatial_shape: spatial shape of the original label data to unravel selected centers.
+        indices: sequence of pre-computed foreground indices of every class in 1 dimension.
+        num_classes: number of classes for argmax label, not necessary for One-Hot label.
+        image: if image is not None, only return the indices of every class that are within the valid
+            region of the image (``image > image_threshold``).
+        image_threshold: if enabled `image`, use ``image > image_threshold`` to
+            determine the valid image content area and select class indices only in this area.
+        max_samples_per_class: maximum length of indices in each class to reduce memory consumption.
+            Default is None, no subsampling.
+        ratios: ratios of every class in the label to generate crop centers, including background class.
+            if None, every class will have the same ratio to generate crop centers.
+        rand_state: numpy randomState object to align with other modules.
+        allow_smaller: if `False`, an exception will be raised if the image is smaller than
+            the requested ROI in any dimension. If `True`, any smaller dimensions will be set to
+            match the cropped size (i.e., no cropping in that dimension).
+        warn: if `True` prints a warning if a class is not present in the label.
+    Returns:
+        Tuple of crop centres
+    """
+    if label is None:
+        raise ValueError("label must not be None.")
+    indices = map_classes_to_indices(label, num_classes, image, image_threshold, max_samples_per_class)
+
+    if label_spatial_shape is not None:
+        _shape = label_spatial_shape
+    elif isinstance(label, monai.data.MetaTensor):
+        _shape = label.peek_pending_shape()
+    else:
+        _shape = label.shape[1:]
+
+    if _shape is None:
+        raise ValueError(
+            "label_spatial_shape or label with a known shape must be provided to infer the output spatial shape."
+        )
+    centers = generate_label_classes_crop_centers(
+        spatial_size, num_samples, _shape, indices, ratios, rand_state, allow_smaller, warn
+    )
+
+    return ensure_tuple(centers)
+
+
 def map_binary_to_indices(
     label: NdarrayOrTensor, image: NdarrayOrTensor | None = None, image_threshold: float = 0.0
 ) -> tuple[NdarrayOrTensor, NdarrayOrTensor]:


Test patch:
diff --git a/tests/test_map_and_generate_sampling_centers.py b/tests/test_map_and_generate_sampling_centers.py
new file mode 100644
--- /dev/null
+++ b/tests/test_map_and_generate_sampling_centers.py
@@ -0,0 +1,87 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import unittest
+from copy import deepcopy
+
+import numpy as np
+from parameterized import parameterized
+
+from monai.transforms import map_and_generate_sampling_centers
+from monai.utils.misc import set_determinism
+from tests.utils import TEST_NDARRAYS, assert_allclose
+
+TEST_CASE_1 = [
+    # test Argmax data
+    {
+        "label": (np.array([[[0, 1, 2], [2, 0, 1], [1, 2, 0]]])),
+        "spatial_size": [2, 2, 2],
+        "num_samples": 2,
+        "label_spatial_shape": [3, 3, 3],
+        "num_classes": 3,
+        "image": None,
+        "ratios": [0, 1, 2],
+        "image_threshold": 0.0,
+    },
+    tuple,
+    2,
+    3,
+]
+
+TEST_CASE_2 = [
+    {
+        "label": (
+            np.array(
+                [
+                    [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
+                    [[0, 1, 0], [0, 0, 1], [1, 0, 0]],
+                    [[0, 0, 1], [1, 0, 0], [0, 1, 0]],
+                ]
+            )
+        ),
+        "spatial_size": [2, 2, 2],
+        "num_samples": 1,
+        "ratios": None,
+        "label_spatial_shape": [3, 3, 3],
+        "image": None,
+        "image_threshold": 0.0,
+    },
+    tuple,
+    1,
+    3,
+]
+
+
+class TestMapAndGenerateSamplingCenters(unittest.TestCase):
+
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2])
+    def test_map_and_generate_sampling_centers(self, input_data, expected_type, expected_count, expected_shape):
+        results = []
+        for p in TEST_NDARRAYS + (None,):
+            input_data = deepcopy(input_data)
+            if p is not None:
+                input_data["label"] = p(input_data["label"])
+            set_determinism(0)
+            result = map_and_generate_sampling_centers(**input_data)
+            self.assertIsInstance(result, expected_type)
+            self.assertEqual(len(result), expected_count)
+            self.assertEqual(len(result[0]), expected_shape)
+            # check for consistency between numpy, torch and torch.cuda
+            results.append(result)
+            if len(results) > 1:
+                for x, y in zip(result[0], result[-1]):
+                    assert_allclose(x, y, type_test=False)
+
+
+if __name__ == "__main__":
+    unittest.main()