Skip to content

Morphology Features API

pictologics.features.morphology

Morphology Feature Extraction Module

This module provides functions for calculating Morphological (Shape and Size) features from medical images. It implements the Image Biomarker Standardisation Initiative (IBSI) compliant algorithms.

Key Features:

  • Voxel-based: Volume (voxel counting).
  • Mesh-based: Surface Area, Volume (mesh), Compactness, Sphericity.
  • PCA-based: Major/Minor/Least Axis Length, Elongation, Flatness.
  • Convex Hull: Volume, Area, Max 3D Diameter.
  • Bounding Box: Oriented (OMBB) and Axis-Aligned (AABB) Bounding Boxes.
  • Minimum Volume Enclosing Ellipsoid (MVEE): Volume, Area.
  • Intensity-Weighted: Center of Mass Shift, Integrated Intensity.

Optimization:

Uses numba for optimizing the Khachiyan algorithm for MVEE calculation.

Example

Calculate morphology features from a mask:

import numpy as np
from pictologics.loader import Image
from pictologics.features.morphology import calculate_morphology_features

# Create dummy mask
mask_arr = np.zeros((50, 50, 50), dtype=np.uint8)
mask_arr[10:40, 10:40, 10:40] = 1
mask = Image(mask_arr, spacing=(1.0, 1.0, 1.0), origin=(0,0,0))

# Calculate features
features = calculate_morphology_features(mask)
print(features["volume_voxel_counting_YEKZ"])

calculate_morphology_features(mask, image=None, intensity_mask=None, roi_bbox=None)

Calculate morphological features from the ROI mask. Includes both voxel-based and mesh-based features (IBSI compliant).

Parameters:

Name Type Description Default
mask Image

Image object containing the morphological mask. Nonzero values are treated as ROI membership.

required
image Optional[Image]

Optional Image object containing intensity data (required for some features).

None
intensity_mask Optional[Image]

Optional Image object containing the intensity mask (e.g. after outlier filtering). If provided, used for intensity-weighted features (99N0, KLMA). If None, defaults to mask.

None
roi_bbox Optional[tuple[slice, slice, slice]]

Optional precomputed tight bounding box of the mask's nonzero voxels (tuple of slices, as returned by an internal bbox scan). Skips rescanning the full mask volume. If None, computed internally.

None

Returns:

Type Description
dict[str, float]

Dictionary of calculated features.

Example
import numpy as np
from pictologics.loader import Image
from pictologics.features.morphology import calculate_morphology_features

mask_arr = np.zeros((50, 50, 50), dtype=np.uint8)
mask_arr[10:40, 10:40, 10:40] = 1
mask = Image(array=mask_arr, spacing=(1.0, 1.0, 1.0), origin=(0.0, 0.0, 0.0))

features = calculate_morphology_features(mask)
print(round(features["volume_voxel_counting_YEKZ"], 1))
# 27000.0
print(round(features["sphericity_QCFX"], 2))
# 0.82
Source code in pictologics/features/morphology.py
def calculate_morphology_features(
    mask: Image,
    image: Optional[Image] = None,
    intensity_mask: Optional[Image] = None,
    roi_bbox: Optional[tuple[slice, slice, slice]] = None,
) -> dict[str, float]:
    """
    Calculate morphological features from the ROI mask.
    Includes both voxel-based and mesh-based features (IBSI compliant).

    Args:
        mask: Image object containing the morphological mask. Nonzero values
            are treated as ROI membership.
        image: Optional Image object containing intensity data (required for some features).
        intensity_mask: Optional Image object containing the intensity mask (e.g. after outlier filtering).
                        If provided, used for intensity-weighted features (99N0, KLMA).
                        If None, defaults to `mask`.
        roi_bbox: Optional precomputed tight bounding box of the mask's nonzero voxels
                  (tuple of slices, as returned by an internal bbox scan). Skips
                  rescanning the full mask volume. If None, computed internally.

    Returns:
        Dictionary of calculated features.

    Example:
        ```python
        import numpy as np
        from pictologics.loader import Image
        from pictologics.features.morphology import calculate_morphology_features

        mask_arr = np.zeros((50, 50, 50), dtype=np.uint8)
        mask_arr[10:40, 10:40, 10:40] = 1
        mask = Image(array=mask_arr, spacing=(1.0, 1.0, 1.0), origin=(0.0, 0.0, 0.0))

        features = calculate_morphology_features(mask)
        print(round(features["volume_voxel_counting_YEKZ"], 1))
        # 27000.0
        print(round(features["sphericity_QCFX"], 2))
        # 0.82
        ```
    """
    features: dict[str, float] = {}
    i_mask = intensity_mask if intensity_mask is not None else mask

    voxel_volume = np.prod(mask.spacing)

    # Compute the ROI bounding box once; the scans below run on the cropped region
    # instead of the full volume, which dominates runtime for sparse ROIs.
    bbox = roi_bbox if roi_bbox is not None else compute_nonzero_bbox(mask.array)
    if bbox is None:
        # Empty mask: no ROI voxels.
        features["volume_voxel_counting_YEKZ"] = 0.0
        return features

    # 1. Voxel Based Features + mask moments (shared by PCA and intensity
    # morphology features). The moments are computed in cropped index space: the
    # PCA covariance is translation-invariant, and the center-of-mass consumer
    # adds the bbox offset back. The kernel's voxel count doubles as the count
    # for the voxel-counting volume.
    mask_moments = _accumulate_moments_from_mask_numba(mask.array[bbox])
    n_voxels = mask_moments[0]
    features["volume_voxel_counting_YEKZ"] = float(n_voxels * voxel_volume)

    # 2. Mesh Based Features
    mesh_feats, verts, faces = _get_mesh_features(mask, roi_bbox=bbox)
    features.update(mesh_feats)

    if verts is None or faces is None:
        return features

    mesh_volume = features.get("volume_RNU0", 0.0)
    surface_area = features.get("surface_area_C0JK", 0.0)

    # 3. Shape Features
    features.update(_get_shape_features(surface_area, mesh_volume))

    # 4. PCA Based Features
    pca_feats, evals, evecs = _get_pca_features(
        mask, mesh_volume, surface_area, mask_moments=mask_moments
    )
    features.update(pca_feats)

    # 5. Convex Hull Features
    hull_feats, hull = _get_convex_hull_features(verts, mesh_volume, surface_area)
    features.update(hull_feats)

    # 6. Bounding Box Features
    features.update(_get_bounding_box_features(verts, evecs, mesh_volume, surface_area))

    # 7. MVEE Features
    features.update(_get_mvee_features(hull, verts, mesh_volume, surface_area))

    # 8. Intensity Based Features
    if image is not None:
        features.update(
            _get_intensity_morphology_features(
                mask, image, i_mask, mesh_volume, mask_moments=mask_moments, mask_bbox=bbox
            )
        )

    return features