Skip to content

Segmentation API

obia.segmentation.segment

obia.segmentation.segment

Segments

Segments class for handling and processing image segments.

Attributes:

Name Type Description
_segments

Integer object labels (also available as labels).

segments

Public representation of segments data.

method

Method used for segmentation.

params

Additional parameters for segmentation.

Methods:

Name Description
__init__

Initializes the Segments object with segments data, a method, and additional parameters.

to_segmented_image

Converts a given PIL Image to a segmented image with boundaries overlaid.

write_segments

Writes the segments data to a file.

Source code in obia/segmentation/segment.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class Segments:
    """
    Segments class for handling and processing image segments.

    Attributes:
        _segments: Integer object labels (also available as ``labels``).
        segments: Public representation of segments data.
        method: Method used for segmentation.
        params: Additional parameters for segmentation.

    Methods:
        __init__(_segments, segments, method, **kwargs):
            Initializes the Segments object with segments data, a method, and additional parameters.

        to_segmented_image(image):
            Converts a given PIL Image to a segmented image with boundaries overlaid.

        write_segments(file_path):
            Writes the segments data to a file.
    """
    _segments = None
    segments = None
    method = None

    def __init__(self, _segments, segments, method, **kwargs):
        self._segments = _segments
        self.labels = _segments
        self.segments = segments
        self.method = method
        self.params = dict(kwargs)

    def to_segmented_image(self, image):
        """
        :param image: An input image expected to be of type PIL Image.
        :return: A new image with the boundaries marked on the segments.
        """
        if not isinstance(image, PILImage):
            raise TypeError('Input must be a PIL Image')
        img = np.array(image)
        boundaries = mark_boundaries(img, self._segments)
        boundaries_int = boundaries * 255

        masked_img = boundaries_int.copy()
        return fromarray(masked_img.astype(np.uint8))

    def write_segments(self, file_path):
        """
        :param file_path: The path to the file where segments will be written.
        :return: None
        """
        self.segments.to_file(file_path)

to_segmented_image(image)

:param image: An input image expected to be of type PIL Image. :return: A new image with the boundaries marked on the segments.

Source code in obia/segmentation/segment.py
41
42
43
44
45
46
47
48
49
50
51
52
53
def to_segmented_image(self, image):
    """
    :param image: An input image expected to be of type PIL Image.
    :return: A new image with the boundaries marked on the segments.
    """
    if not isinstance(image, PILImage):
        raise TypeError('Input must be a PIL Image')
    img = np.array(image)
    boundaries = mark_boundaries(img, self._segments)
    boundaries_int = boundaries * 255

    masked_img = boundaries_int.copy()
    return fromarray(masked_img.astype(np.uint8))

write_segments(file_path)

:param file_path: The path to the file where segments will be written. :return: None

Source code in obia/segmentation/segment.py
55
56
57
58
59
60
def write_segments(self, file_path):
    """
    :param file_path: The path to the file where segments will be written.
    :return: None
    """
    self.segments.to_file(file_path)

segment(image, segmentation_bands=None, statistics_bands=None, method='slic', calc_mean=True, calc_variance=True, calc_skewness=True, calc_kurtosis=True, calc_contrast=True, calc_dissimilarity=True, calc_homogeneity=True, calc_ASM=True, calc_energy=True, calc_correlation=True, feature_options=None, **kwargs)

:param image: Input image for segmentation and feature extraction. :param segmentation_bands: Bands to be used for segmentation. Default is None. :param statistics_bands: Bands to be used for statistical calculations. Default is None. :param method: Scikit-image region method from SEGMENTATION_METHODS. Default is "slic". :param calc_mean: Flag to calculate mean of segments. Default is True. :param calc_variance: Flag to calculate variance of segments. Default is True. :param calc_skewness: Flag to calculate skewness of segments. Default is True. :param calc_kurtosis: Flag to calculate kurtosis of segments. Default is True. :param calc_contrast: Flag to calculate contrast of segments. Default is True. :param calc_dissimilarity: Flag to calculate dissimilarity of segments. Default is True. :param calc_homogeneity: Flag to calculate homogeneity of segments. Default is True. :param calc_ASM: Flag to calculate angular second moment of segments. Default is True. :param calc_energy: Flag to calculate energy of segments. Default is True. :param calc_correlation: Flag to calculate correlation of segments. Default is True. :param feature_options: Optional dictionary of create_objects options, including spectral_features, percentiles, indices, shape_features, and texture_config. These override legacy feature flags. :param kwargs: Segmentation parameters; see create_labels for supported methods and inputs. :return: Segments object containing the segmented image and statistics.

Source code in obia/segmentation/segment.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def segment(image, segmentation_bands=None, statistics_bands=None,
            method="slic", calc_mean=True, calc_variance=True,
            calc_skewness=True, calc_kurtosis=True, calc_contrast=True,
            calc_dissimilarity=True, calc_homogeneity=True, calc_ASM=True,
            calc_energy=True, calc_correlation=True, feature_options=None, **kwargs):
    """
    :param image: Input image for segmentation and feature extraction.
    :param segmentation_bands: Bands to be used for segmentation. Default is None.
    :param statistics_bands: Bands to be used for statistical calculations. Default is None.
    :param method: Scikit-image region method from ``SEGMENTATION_METHODS``. Default is "slic".
    :param calc_mean: Flag to calculate mean of segments. Default is True.
    :param calc_variance: Flag to calculate variance of segments. Default is True.
    :param calc_skewness: Flag to calculate skewness of segments. Default is True.
    :param calc_kurtosis: Flag to calculate kurtosis of segments. Default is True.
    :param calc_contrast: Flag to calculate contrast of segments. Default is True.
    :param calc_dissimilarity: Flag to calculate dissimilarity of segments. Default is True.
    :param calc_homogeneity: Flag to calculate homogeneity of segments. Default is True.
    :param calc_ASM: Flag to calculate angular second moment of segments. Default is True.
    :param calc_energy: Flag to calculate energy of segments. Default is True.
    :param calc_correlation: Flag to calculate correlation of segments. Default is True.
    :param feature_options: Optional dictionary of create_objects options, including spectral_features,
        percentiles, indices, shape_features, and texture_config. These override legacy feature flags.
    :param kwargs: Segmentation parameters; see ``create_labels`` for supported methods and inputs.
    :return: Segments object containing the segmented image and statistics.
    """
    labels = create_labels(image, segmentation_bands=segmentation_bands, method=method, **kwargs)
    segments_gdf = labels_to_segments(labels, image)
    options = dict(spectral_bands=statistics_bands, calc_mean=calc_mean,
                   calc_variance=calc_variance, calc_skewness=calc_skewness, calc_kurtosis=calc_kurtosis,
                   calc_contrast=calc_contrast, calc_dissimilarity=calc_dissimilarity,
                   calc_homogeneity=calc_homogeneity, calc_ASM=calc_ASM, calc_energy=calc_energy,
                   calc_correlation=calc_correlation, mask=kwargs.get("mask"))
    if feature_options is not None:
        options.update(feature_options)
    objects_gdf = create_objects(segments_gdf, image, **options)

    return Segments(labels, objects_gdf, method, **kwargs)

obia.segmentation.segment_boundaries

obia.segmentation.segment_boundaries

Create georeferenced objects with scikit-image segmentation algorithms.

create_labels(image, segmentation_bands=None, method='slic', *, mask=None, normalize=True, **kwargs)

Segment a raster into a two-dimensional integer object-label image.

Parameters:

Name Type Description Default
image Image

OBIA Image with data shaped (height, width, bands).

required
segmentation_bands Sequence[int] | None

Zero-based bands to use; defaults to all bands. Watershed, Chan-Vese, morphological snakes, and flood require one band.

None
method str

One of SEGMENTATION_METHODS; uses scikit-image's function names.

'slic'
mask ndarray | None

Boolean (height, width) array of included pixels. SLIC, watershed, and random walker respect it during segmentation. Other methods run on the prepared image and their results are clipped to the mask.

None
normalize bool

Scale each selected band to [0, 1] using included pixels. Set False for physical-unit flood tolerances or preprocessed data.

True
**kwargs Any

Parameters for the chosen scikit-image function. Random walker requires labels, flood requires seed_point, active contour requires a closed snake, and morphological snakes require num_iter. MorphGAC uses an inverse Gaussian gradient by default; gradient_kwargs configures it, or gimage supplies a precomputed (height, width) edge-stopping image. Diagnostic output flags (return_tree, extended_output, return_full_prob) are not supported because this function returns object labels.

{}

Returns:

Type Description
ndarray

np.ndarray: An int32 (height, width) array. Each connected object has a unique positive ID; zero denotes background, watershed lines, or excluded pixels. Native algorithm/marker IDs are renumbered to match polygon segment_id values. Binary methods return foreground objects only. The source image and seed arrays are not modified.

Source code in obia/segmentation/segment_boundaries.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def create_labels(image: Image, segmentation_bands: Sequence[int] | None = None,
                  method: str = "slic", *, mask: np.ndarray | None = None,
                  normalize: bool = True, **kwargs: Any) -> np.ndarray:
    """Segment a raster into a two-dimensional integer object-label image.

    Args:
        image: OBIA Image with data shaped (height, width, bands).
        segmentation_bands: Zero-based bands to use; defaults to all bands.
            Watershed, Chan-Vese, morphological snakes, and flood require one band.
        method: One of ``SEGMENTATION_METHODS``; uses scikit-image's function names.
        mask: Boolean (height, width) array of included pixels. SLIC, watershed,
            and random walker respect it during segmentation. Other methods run
            on the prepared image and their results are clipped to the mask.
        normalize: Scale each selected band to [0, 1] using included pixels.
            Set False for physical-unit flood tolerances or preprocessed data.
        **kwargs: Parameters for the chosen scikit-image function. Random walker
            requires ``labels``, flood requires ``seed_point``, active contour
            requires a closed ``snake``, and morphological snakes require
            ``num_iter``. MorphGAC uses an inverse Gaussian gradient by default;
            ``gradient_kwargs`` configures it, or ``gimage`` supplies a precomputed
            (height, width) edge-stopping image. Diagnostic output flags
            (``return_tree``, ``extended_output``, ``return_full_prob``) are not
            supported because this function returns object labels.

    Returns:
        np.ndarray: An int32 (height, width) array. Each connected object has a unique
            positive ID; zero denotes background, watershed lines, or excluded
            pixels. Native algorithm/marker IDs are renumbered to match polygon
            ``segment_id`` values. Binary methods return foreground objects only.
            The source image and seed arrays are not modified.
    """
    if method not in SEGMENTATION_METHODS:
        raise ValueError(f"Unknown segmentation method {method!r}. Choose from: {', '.join(SEGMENTATION_METHODS)}.")
    params = dict(kwargs)
    for option in ("return_tree", "extended_output", "return_full_prob"):
        if params.get(option, False):
            raise ValueError(f"{option}=True does not return object labels; use scikit-image directly for diagnostic outputs.")

    if method == "random_walker":
        if "labels" not in params:
            raise ValueError("random_walker requires labels: positive seeds, 0 for unlabelled pixels, and negative inactive pixels.")
        params["labels"] = _check_markers(params["labels"], image.img_data.shape[:2], "labels")
        active = params["labels"] >= 0
        if mask is not None:
            mask = np.asarray(mask, dtype=bool)
            if mask.shape != active.shape:
                raise ValueError("mask must have shape (height, width).")
            active &= mask
        mask = active

    data, valid = _prepare_image(image, segmentation_bands, mask, normalize)
    if method in _SINGLE_BAND_METHODS and data.shape[2] != 1:
        raise ValueError(f"{method} requires one band; pass segmentation_bands=[band_index].")
    if not valid.any():
        return np.zeros(valid.shape, dtype=np.int32)
    if method in _CHANNEL_METHODS:
        source = _channel_image(data, method, params)
    elif method == "active_contour" and data.shape[2] > 1:
        source = data
    else:
        source = data[:, :, 0]

    if method == "slic" and mask is not None:
        params["mask"] = valid
    elif method == "watershed":
        params["mask"] = valid
        if params.get("markers") is not None and not np.isscalar(params["markers"]):
            params["markers"] = _check_markers(params["markers"], valid.shape, "markers")
    elif method == "random_walker":
        markers = params["labels"]
        markers[~valid] = -1
        if not np.any(markers > 0):
            raise ValueError("random_walker requires at least one positive seed inside the mask.")
        params["labels"] = markers
    elif method == "active_contour":
        if "snake" not in params:
            raise ValueError("active_contour requires snake: an (N, 2) array of (row, column) pixel coordinates.")
        snake = np.array(params["snake"], dtype=float, copy=True)
        if snake.ndim != 2 or snake.shape[1] != 2 or len(snake) < 3 or not np.isfinite(snake).all():
            raise ValueError("snake must contain at least three finite (row, column) coordinates.")
        if params.get("boundary_condition", "periodic") != "periodic":
            raise ValueError("Object polygons require boundary_condition='periodic'; use scikit-image directly for open snakes.")
        params["snake"] = snake
    elif method == "flood":
        if "seed_point" not in params:
            raise ValueError("flood requires seed_point=(row, column) in pixel coordinates.")
        seed = np.asarray(params["seed_point"])
        if (seed.shape != (2,) or not np.issubdtype(seed.dtype, np.integer)
                or any(v < 0 or v >= size for v, size in zip(seed, valid.shape))):
            raise ValueError("seed_point must contain two integer coordinates inside the image.")
        if not valid[tuple(seed)]:
            raise ValueError("seed_point must be inside the mask.")
    elif method == "morphological_geodesic_active_contour":
        gimage = params.pop("gimage", None)
        gradient_kwargs = params.pop("gradient_kwargs", {})
        if gimage is None:
            source = segmentation.inverse_gaussian_gradient(source, **gradient_kwargs)
        else:
            if gradient_kwargs:
                raise ValueError("Use either gimage or gradient_kwargs, not both.")
            source = np.array(gimage, dtype=float, copy=True)
            if source.shape != valid.shape or not np.isfinite(source).all():
                raise ValueError("gimage must be a finite (height, width) edge-stopping image.")
    if method.startswith("morphological_") and "num_iter" not in params:
        raise ValueError(f"{method} requires num_iter.")

    raw = getattr(segmentation, method)(source, **params)
    if method == "active_contour":
        raw = polygon2mask(valid.shape, raw)
    raw = np.asarray(raw)
    if raw.shape != valid.shape:
        raise ValueError(f"{method} did not return a two-dimensional segmentation.")
    included = valid.copy()
    if method in _BINARY_METHODS or method in {"watershed", "random_walker"}:
        included &= raw > 0
    # Each disconnected piece becomes its own object. Native zero labels from
    # SLIC/quickshift/Felzenszwalb are valid, unlike binary background.
    return label(np.where(included, raw, -1), background=-1, connectivity=1).astype(np.int32)

create_segments(image, segmentation_bands=None, method='slic', **kwargs)

Return georeferenced segment polygons for any SEGMENTATION_METHODS entry.

Accepts the same parameters as :func:create_labels, including mask and normalize. Zero/background pixels are omitted. The source raster is not modified; use :func:create_labels when only the label image is needed.

Source code in obia/segmentation/segment_boundaries.py
236
237
238
239
240
241
242
243
244
def create_segments(image, segmentation_bands=None, method="slic", **kwargs):
    """Return georeferenced segment polygons for any ``SEGMENTATION_METHODS`` entry.

    Accepts the same parameters as :func:`create_labels`, including ``mask`` and
    ``normalize``. Zero/background pixels are omitted. The source raster is not
    modified; use :func:`create_labels` when only the label image is needed.
    """
    labels = create_labels(image, segmentation_bands, method, **kwargs)
    return labels_to_segments(labels, image)

labels_to_segments(labels, image)

Polygonize positive object labels using the raster's transform and CRS.

labels must be a 2D integer array matching the raster, with background 0. Labels from :func:create_labels give one polygon per unique segment_id. Custom labels with disconnected regions can produce multiple rows for an ID.

Source code in obia/segmentation/segment_boundaries.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def labels_to_segments(labels, image):
    """Polygonize positive object labels using the raster's transform and CRS.

    ``labels`` must be a 2D integer array matching the raster, with background 0.
    Labels from :func:`create_labels` give one polygon per unique ``segment_id``.
    Custom labels with disconnected regions can produce multiple rows for an ID.
    """
    labels = np.asarray(labels)
    if labels.shape != image.img_data.shape[:2] or not np.issubdtype(labels.dtype, np.integer):
        raise ValueError("labels must be an integer (height, width) array matching the raster.")
    if labels.min() < 0 or labels.max() > np.iinfo(np.int32).max:
        raise ValueError("labels must be nonnegative int32 object IDs, with background 0.")
    records = [
        {"segment_id": int(value), "geometry": shape(geometry)}
        for geometry, value in shapes(labels.astype(np.int32), mask=labels > 0,
                                      transform=image.transform)
    ]
    return GeoDataFrame(records, columns=["segment_id", "geometry"], crs=image.crs)

normalize_band(band)

Return a floating-point band scaled to [0, 1]; constant bands become zero.

Source code in obia/segmentation/segment_boundaries.py
36
37
38
39
40
41
42
43
44
def normalize_band(band):
    """Return a floating-point band scaled to [0, 1]; constant bands become zero."""
    values = np.asarray(band, dtype=float)
    if not np.isfinite(values).all():
        raise ValueError("Segmentation bands must contain finite values inside the mask.")
    low, high = values.min(), values.max()
    if high == low:
        return np.zeros_like(values)
    return (values - low) / (high - low)

obia.segmentation.segment_statistics

obia.segmentation.segment_statistics

Compatibility entry points for raster feature extraction.

New workflows can use :func:obia.features.extract_features directly. The existing functions keep their boolean flags and legacy output column names.

calculate_spectral_stats(image, statistics_bands, calc_mean=True, calc_variance=True, calc_min=True, calc_max=True, calc_skewness=True, calc_kurtosis=True, *, features=None, percentiles=(), mask=None)

Summarize a band-first (bands, height, width) array without modification.

Legacy flags select the original statistics; features overrides them with names from SPECTRAL_FEATURES. percentiles adds linear percentiles. Nonfinite and masked samples are excluded. A spatial mask defines the object footprint and denominator for valid_fraction.

Source code in obia/segmentation/segment_statistics.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def calculate_spectral_stats(
    image, statistics_bands, calc_mean=True, calc_variance=True, calc_min=True, calc_max=True,
    calc_skewness=True, calc_kurtosis=True, *, features=None, percentiles=(), mask=None,
):
    """Summarize a band-first (bands, height, width) array without modification.

    Legacy flags select the original statistics; ``features`` overrides them
    with names from ``SPECTRAL_FEATURES``. ``percentiles`` adds linear percentiles.
    Nonfinite and masked samples are excluded. A spatial ``mask`` defines the
    object footprint and denominator for ``valid_fraction``.
    """
    data = np.ma.asarray(image, dtype=float).filled(np.nan)
    if data.ndim != 3:
        raise ValueError("image must have shape (bands, height, width).")
    bands = band_indices(statistics_bands, data.shape[0])
    if features is None:
        features = _spectral_selection(calc_mean, calc_variance, calc_min, calc_max, calc_skewness, calc_kurtosis)
    features, percentiles = validate_statistics(features, percentiles)
    inside = np.ones(data.shape[1:], dtype=bool) if mask is None else np.asarray(mask, dtype=bool)
    if inside.shape != data.shape[1:]:
        raise ValueError("mask must have shape (height, width).")
    result = {}
    for band in bands:
        stats = summarize(data[band][inside], int(inside.sum()), features, percentiles)
        result.update({f"b{band}_{name}": value for name, value in stats.items()})
    return result

calculate_textural_stats(image, textural_bands, calc_contrast=True, calc_dissimilarity=True, calc_homogeneity=True, calc_ASM=True, calc_energy=True, calc_correlation=True, *, texture_config=None, mask=None, channel_axis=0)

Calculate texture from a band-first array (or explicit channel_axis=-1).

Only pixel pairs with both endpoints inside mask and finite are counted. Without texture_config, legacy bN_contrast etc. columns use distance 2, four directions, 256 gray levels, and the valid band range of this array. An explicit TextureConfig enables additional features and scale columns. Use extract_features to fit quantization ranges once across all objects.

Source code in obia/segmentation/segment_statistics.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def calculate_textural_stats(
    image, textural_bands, calc_contrast=True, calc_dissimilarity=True,
    calc_homogeneity=True, calc_ASM=True, calc_energy=True, calc_correlation=True,
    *, texture_config=None, mask=None, channel_axis=0,
):
    """Calculate texture from a band-first array (or explicit channel_axis=-1).

    Only pixel pairs with both endpoints inside ``mask`` and finite are counted.
    Without ``texture_config``, legacy bN_contrast etc. columns use distance 2,
    four directions, 256 gray levels, and the valid band range of this array.
    An explicit ``TextureConfig`` enables additional features and scale columns.
    Use ``extract_features`` to fit quantization ranges once across all objects.
    """
    data = np.ma.asarray(image, dtype=float).filled(np.nan)
    if data.ndim != 3 or channel_axis not in (0, -1, 2):
        raise ValueError("image must be 3D; channel_axis must be 0 or -1 (2).")
    data = np.moveaxis(data, channel_axis, 0)
    bands = band_indices(textural_bands, data.shape[0])
    inside = np.ones(data.shape[1:], dtype=bool) if mask is None else np.asarray(mask, dtype=bool)
    if inside.shape != data.shape[1:]:
        raise ValueError("mask must have shape (height, width).")
    legacy = texture_config is None
    if legacy:
        features = _texture_selection(calc_contrast, calc_dissimilarity, calc_homogeneity,
                                      calc_ASM, calc_energy, calc_correlation)
        texture_config = TextureConfig(features=features, distances=(2,), levels=256)
    elif not isinstance(texture_config, TextureConfig):
        raise TypeError("texture_config must be a TextureConfig.")
    result = {}
    for band in bands:
        valid = inside & np.isfinite(data[band])
        values = data[band][valid]
        bounds = texture_config.value_range or ((float(values.min()), float(values.max()))
                                               if values.size else (0.0, 0.0))
        stats = measure_texture(data[band], valid, texture_config, bounds)
        for name, value in stats.items():
            if legacy:
                name = name.removeprefix("glcm_").removesuffix("_d2")
            result[f"b{band}_{name}"] = value
    return result

create_objects(segments, image, spectral_bands=None, textural_bands=None, calculate_spectral=True, calculate_textural=True, calc_mean=True, calc_variance=True, calc_min=True, calc_max=True, calc_skewness=True, calc_kurtosis=True, calc_contrast=True, calc_dissimilarity=True, calc_homogeneity=True, calc_ASM=True, calc_energy=True, calc_correlation=True, *, spectral_features=None, percentiles=(), indices=None, shape_features=(), shape_units='map', texture_config=None, mask=None)

Enrich segment polygons with selected raster features.

Existing flags and bN_mean/bN_contrast-style columns are retained. New spectral_features, percentiles, indices, shape_features, shape_units, texture_config and mask options correspond to extract_features. All bands are numeric zero-based indices into the loaded image. calculate_spectral=False disables band/index distributions; calculate_textural=False disables texture. Shape can be requested alone.

Source code in obia/segmentation/segment_statistics.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def create_objects(
    segments, image, spectral_bands=None, textural_bands=None,
    calculate_spectral=True, calculate_textural=True,
    calc_mean=True, calc_variance=True, calc_min=True, calc_max=True, calc_skewness=True, calc_kurtosis=True,
    calc_contrast=True, calc_dissimilarity=True, calc_homogeneity=True, calc_ASM=True, calc_energy=True,
    calc_correlation=True, *, spectral_features=None, percentiles=(), indices=None,
    shape_features=(), shape_units="map", texture_config=None, mask=None,
):
    """Enrich segment polygons with selected raster features.

    Existing flags and bN_mean/bN_contrast-style columns are retained. New
    ``spectral_features``, ``percentiles``, ``indices``, ``shape_features``,
    ``shape_units``, ``texture_config`` and ``mask`` options correspond to
    ``extract_features``. All bands are numeric zero-based indices into the
    loaded image. ``calculate_spectral=False`` disables band/index distributions;
    ``calculate_textural=False`` disables texture. Shape can be requested alone.
    """
    features = spectral_features
    if features is None:
        features = _spectral_selection(calc_mean, calc_variance, calc_min, calc_max, calc_skewness, calc_kurtosis)
    if not calculate_spectral:
        features, percentiles = (), ()
    legacy_texture = texture_config is None
    if calculate_textural:
        if legacy_texture:
            requested = _texture_selection(calc_contrast, calc_dissimilarity, calc_homogeneity,
                                           calc_ASM, calc_energy, calc_correlation)
            texture_config = TextureConfig(features=requested, distances=(2,), levels=256)
        elif not isinstance(texture_config, TextureConfig):
            raise TypeError("texture_config must be a TextureConfig.")
        if texture_config.bands is None:
            texture_config = replace(texture_config, bands=band_indices(textural_bands, image.img_data.shape[2]))
    else:
        texture_config = None
    result = extract_features(
        segments, image, bands=spectral_bands, spectral=features, percentiles=percentiles,
        indices=indices, shape=shape_features, shape_units=shape_units, texture=texture_config, mask=mask,
    )
    if legacy_texture and texture_config is not None:
        rename = {f"b{b}_glcm_{name}_d2": f"b{b}_{name}"
                  for b in texture_config.bands for name in texture_config.features}
        # Re-extraction updates legacy columns, preserving unrelated attributes.
        result = result.drop(columns=[name for name in rename.values() if name in result], errors="ignore")
        result = result.rename(columns=rename)
    return result