|
58 | 58 | np_unique, |
59 | 59 | np_unique_withoutzero, |
60 | 60 | np_volume, |
| 61 | + np_voronoi_labels, |
61 | 62 | ) |
62 | 63 | from TPTBox.core.vert_constants import ( |
63 | 64 | AFFINE, |
@@ -1808,6 +1809,59 @@ def dilate_msk_(self, n_pixel: int = 5, labels: LABEL_REFERENCE = None, connecti |
1808 | 1809 | """In-place variant of `dilate_msk`.""" |
1809 | 1810 | return self.dilate_msk(n_pixel=n_pixel, labels=labels, connectivity=connectivity, mask=mask, inplace=True, verbose=verbose,use_crop=use_crop,ignore_direction=ignore_direction) |
1810 | 1811 |
|
| 1812 | + def voronoi_labels(self, labels: LABEL_REFERENCE = None, max_distance: float | None = None, use_mm: bool = True, signed_background: bool = True, tie_to_zero: bool = True, inplace=False, verbose: logging = True) -> Self: |
| 1813 | + """Assigns every voxel the label of the nearest labelled region (a Voronoi partition). |
| 1814 | +
|
| 1815 | + Args: |
| 1816 | + labels (LABEL_REFERENCE, optional): Labels to partition by. Everything else is treated as |
| 1817 | + background. Defaults to None (all labels present in the segmentation). |
| 1818 | + max_distance (float | None, optional): Leave background voxels further than this from any |
| 1819 | + region at 0. In mm when `use_mm` is True, else in voxels. Defaults to None (no limit). |
| 1820 | + use_mm (bool, optional): If True, distances are measured in mm using this image's voxel |
| 1821 | + spacing, so the partition is correct on anisotropic images. If False, voxels are |
| 1822 | + treated as isotropic. Defaults to True. |
| 1823 | + signed_background (bool, optional): If True, voxels filled in from the background get the |
| 1824 | + negated label of their region, so the input mask stays recoverable as `out > 0`. The |
| 1825 | + returned NII then has a signed dtype. Defaults to True. |
| 1826 | + tie_to_zero (bool, optional): If True, voxels equidistant to two different regions are set |
| 1827 | + to 0 instead of being assigned to one of them. Defaults to True. |
| 1828 | + inplace (bool, optional): Whether to modify this NII in place or return a new object. |
| 1829 | + Defaults to False. |
| 1830 | + verbose (bool, optional): Whether to print a message. Defaults to True. |
| 1831 | +
|
| 1832 | + Returns: |
| 1833 | + NII: The partition, signed when `signed_background` is set. |
| 1834 | +
|
| 1835 | + Notes: |
| 1836 | + Runtime is independent of the number of labels (one Euclidean feature transform for the |
| 1837 | + whole volume), unlike `dilate_msk`. |
| 1838 | + Set `signed_background=False, tie_to_zero=False` and a `max_distance` for a plain, |
| 1839 | + unsigned expansion of the labels (`np_expand_labels`). |
| 1840 | +
|
| 1841 | + Examples: |
| 1842 | + >>> nii.voronoi_labels(max_distance=10) # doctest: +SKIP |
| 1843 | + # every voxel within 1 cm of a labelled region takes its label |
| 1844 | + """ |
| 1845 | + assert self.seg |
| 1846 | + log.print("voronoi labels", end="\r", verbose=verbose) |
| 1847 | + out = np_voronoi_labels( |
| 1848 | + self.get_seg_array(), |
| 1849 | + label_ref=labels, |
| 1850 | + max_distance=max_distance, |
| 1851 | + zoom=self.zoom if use_mm else None, |
| 1852 | + signed_background=signed_background, |
| 1853 | + tie_to_zero=tie_to_zero, |
| 1854 | + ) |
| 1855 | + if not signed_background: |
| 1856 | + # keep the image dtype; the negated labels of a signed partition would not fit an unsigned one |
| 1857 | + out = out.astype(self.dtype) |
| 1858 | + log.print("Voronoi partition of", len(np_unique_withoutzero(np.abs(out))), "labels", verbose=verbose) |
| 1859 | + return self.set_array(out, inplace=inplace) |
| 1860 | + |
| 1861 | + def voronoi_labels_(self, labels: LABEL_REFERENCE = None, max_distance: float | None = None, use_mm: bool = True, signed_background: bool = True, tie_to_zero: bool = True, verbose: logging = True) -> Self: |
| 1862 | + """In-place variant of `voronoi_labels`.""" |
| 1863 | + return self.voronoi_labels(labels=labels, max_distance=max_distance, use_mm=use_mm, signed_background=signed_background, tie_to_zero=tie_to_zero, inplace=True, verbose=verbose) |
| 1864 | + |
1811 | 1865 |
|
1812 | 1866 | def fill_holes(self, labels: LABEL_REFERENCE = None, slice_wise_dim: int|str | None = None, verbose:logging=False, inplace=False,use_crop=True) -> Self: # noqa: ARG002 |
1813 | 1867 | """Fills holes in segmentation. |
@@ -2996,6 +3050,126 @@ def relabel_by_position_(self, axis: DIRECTIONS = "I", offset: int = 0, verbose: |
2996 | 3050 | """In-place version of :meth:`relabel_by_position`.""" |
2997 | 3051 | return self.relabel_by_position(axis=axis, offset=offset, inplace=True, verbose=verbose) |
2998 | 3052 |
|
| 3053 | + def _split_axis(self, axis: DIRECTIONS | int) -> tuple[int, bool]: |
| 3054 | + """Resolves ``axis`` to an array axis and whether that direction runs with increasing index.""" |
| 3055 | + if isinstance(axis, int): |
| 3056 | + return axis, True |
| 3057 | + # get_axis falls back to the opposite letter, so recover which way the axis actually runs. |
| 3058 | + return self.get_axis(axis), axis in self.orientation |
| 3059 | + |
| 3060 | + def _split_label_profile(self, labels: LABEL_REFERENCE, ax: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: |
| 3061 | + """Returns the seg array, the mask of ``labels`` and its per-slice voxel count along ``ax``.""" |
| 3062 | + arr = self.get_seg_array() |
| 3063 | + selected = arr > 0 if labels is None else np_isin(arr, labels) |
| 3064 | + counts = selected.sum(axis=tuple(i for i in range(arr.ndim) if i != ax)) |
| 3065 | + return arr, selected, counts |
| 3066 | + |
| 3067 | + def _split_label_at_index(self, arr: np.ndarray, selected: np.ndarray, ax: int, region: slice, offset: int, inplace: bool) -> Self: |
| 3068 | + """Adds ``offset`` to the selected voxels that lie inside ``region`` along axis ``ax``.""" |
| 3069 | + part = np.zeros_like(selected) |
| 3070 | + part[(slice(None),) * ax + (region,)] = True |
| 3071 | + part &= selected |
| 3072 | + if offset != 0 and part.any(): |
| 3073 | + arr = arr.astype(np.promote_types(arr.dtype, np.min_scalar_type(int(arr.max()) + offset)), copy=False) |
| 3074 | + arr[part] += offset |
| 3075 | + return self.set_array(arr, inplace=inplace) |
| 3076 | + |
| 3077 | + def partition_segmentation_by_volume( |
| 3078 | + self, |
| 3079 | + labels: LABEL_REFERENCE = None, |
| 3080 | + axis: DIRECTIONS | int = "P", |
| 3081 | + fraction: float = 0.5, |
| 3082 | + offset: int = 100, |
| 3083 | + inplace: bool = False, |
| 3084 | + ) -> Self: |
| 3085 | + """Splits labels along an axis at the plane that cuts off a given fraction of their volume. |
| 3086 | +
|
| 3087 | + Slices are walked inwards from the ``axis``-most end of the mask, summing voxels until |
| 3088 | + ``fraction`` of the total volume is covered; that leading slab gets ``offset`` added to its |
| 3089 | + label values, the rest is left untouched. With the default ``axis="P"`` and |
| 3090 | + ``fraction=1/3`` the posterior third (by volume, not by extent) is split off. |
| 3091 | +
|
| 3092 | + The direction is resolved against :attr:`orientation`, so no reorientation round-trip is |
| 3093 | + needed. Passing an ``int`` instead selects that array axis, pointing at high indices. |
| 3094 | +
|
| 3095 | + Args: |
| 3096 | + labels (LABEL_REFERENCE, optional): Label(s) to split. Defaults to None (every label). |
| 3097 | + axis (DIRECTIONS | int, optional): Anatomical direction the split-off part lies in, |
| 3098 | + or a raw array axis. Defaults to ``"P"``. |
| 3099 | + fraction (float, optional): Volume fraction that ends up in the split-off part, in |
| 3100 | + ``[0, 1]``. Defaults to 0.5. |
| 3101 | + offset (int, optional): Added to the labels of the split-off part. Defaults to 100. |
| 3102 | + inplace (bool, optional): If True, modifies this NII in place. Defaults to False. |
| 3103 | +
|
| 3104 | + Returns: |
| 3105 | + NII: The segmentation with the split-off part relabeled to ``label + offset``. |
| 3106 | +
|
| 3107 | + Examples: |
| 3108 | + >>> ethmoid.partition_segmentation_by_volume([7, 8], "P", 1 / 3) # doctest: +SKIP |
| 3109 | + # posterior third of labels 7 and 8 becomes 107 and 108 |
| 3110 | + """ |
| 3111 | + assert 0 <= fraction <= 1, f"fraction has to be in [0,1], got {fraction}" |
| 3112 | + ax, forward = self._split_axis(axis) |
| 3113 | + arr, selected, counts = self._split_label_profile(labels, ax) |
| 3114 | + total = int(counts.sum()) |
| 3115 | + if total == 0: |
| 3116 | + return self if inplace else self.copy() |
| 3117 | + n = len(counts) |
| 3118 | + # walk inwards from the axis-most end until the slab holds fraction of the volume |
| 3119 | + order = counts[::-1] if forward else counts |
| 3120 | + target = fraction * total |
| 3121 | + taken = 0 if target <= 0 else min(int(np.searchsorted(np.cumsum(order), target)) + 1, n) |
| 3122 | + region = slice(n - taken, None) if forward else slice(None, taken) |
| 3123 | + return self._split_label_at_index(arr, selected, ax, region, offset, inplace) |
| 3124 | + |
| 3125 | + def partition_segmentation_by_center_of_mass( |
| 3126 | + self, |
| 3127 | + labels: LABEL_REFERENCE = None, |
| 3128 | + axis: DIRECTIONS | int = "L", |
| 3129 | + offset: int = 100, |
| 3130 | + inplace: bool = False, |
| 3131 | + ) -> Self: |
| 3132 | + """Splits labels along an axis at their center of mass. |
| 3133 | +
|
| 3134 | + The center of mass of ``labels`` is projected onto ``axis`` and rounded to the nearest |
| 3135 | + slice; everything from there towards ``axis`` gets ``offset`` added to its label values. |
| 3136 | + With the default ``axis="L"`` this separates the left half of a structure from its right |
| 3137 | + half, and flipping the letter to ``"R"`` splits off the other side. The slice the plane |
| 3138 | + lands on always goes to the ``axis`` side, so the two halves overlap in that one slice |
| 3139 | + when the center of mass sits exactly on a voxel center. |
| 3140 | +
|
| 3141 | + The direction is resolved against :attr:`orientation` and the rounding is done in the |
| 3142 | + anatomical frame, so the split is the same no matter which orientation the image is |
| 3143 | + stored in and no reorientation round-trip is needed. Passing an ``int`` instead selects |
| 3144 | + that array axis, pointing at high indices. |
| 3145 | +
|
| 3146 | + Args: |
| 3147 | + labels (LABEL_REFERENCE, optional): Label(s) to split. Defaults to None (every label). |
| 3148 | + axis (DIRECTIONS | int, optional): Anatomical direction the split-off part lies in, |
| 3149 | + or a raw array axis. Defaults to ``"L"``. |
| 3150 | + offset (int, optional): Added to the labels of the split-off part. Defaults to 100. |
| 3151 | + inplace (bool, optional): If True, modifies this NII in place. Defaults to False. |
| 3152 | +
|
| 3153 | + Returns: |
| 3154 | + NII: The segmentation with the split-off part relabeled to ``label + offset``. |
| 3155 | +
|
| 3156 | + Examples: |
| 3157 | + >>> ethmoid.partition_segmentation_by_center_of_mass([7, 15], "L") # doctest: +SKIP |
| 3158 | + # left half of labels 7 and 15 becomes 107 and 115 |
| 3159 | + """ |
| 3160 | + ax, forward = self._split_axis(axis) |
| 3161 | + arr, selected, counts = self._split_label_profile(labels, ax) |
| 3162 | + total = int(counts.sum()) |
| 3163 | + if total == 0: |
| 3164 | + return self if inplace else self.copy() |
| 3165 | + n = len(counts) |
| 3166 | + com = float((counts * np.arange(n)).sum()) / total |
| 3167 | + # round in the anatomical frame, not the array frame, so the result does not depend on |
| 3168 | + # whether the axis happens to be stored forwards or backwards |
| 3169 | + c = int(np.clip(np.rint(com if forward else n - 1 - com), 0, n)) |
| 3170 | + region = slice(c, None) if forward else slice(None, n - c) |
| 3171 | + return self._split_label_at_index(arr, selected, ax, region, offset, inplace) |
| 3172 | + |
2999 | 3173 | def filter_connected_components_by_bbox_chain( |
3000 | 3174 | self, |
3001 | 3175 | margin_mm: float = 0.0, |
|
0 commit comments