Source code for medtda.featureextractor

"""
Feature extraction module for MedTDA.

This module provides the FeatureExtractor class for extracting vectorized
TDA features from medical images.
"""

import numpy as np
from typing import Union, Optional, List, Dict, Tuple, Any, Callable
from pathlib import Path
from collections import OrderedDict

from .barcodeextractor import BarcodeExtractor, _UNSET
from . import vectorizers


[docs] class FeatureExtractor: """ Extract vectorized TDA features from medical images. This class handles the complete pipeline: image loading, preprocessing, persistent homology computation, and vectorization of barcodes into fixed-length feature vectors. Parameters ---------- spacing : tuple or None, default=None Target voxel spacing for resampling (3D/4D only). window : tuple or None, default=None Windowing parameters as (center, width). normalize : bool, default=False Whether to apply normalization. normalize_method : {'minmax', 'zscore', 'robust'}, default='minmax' Normalization method (only used if normalize=True). clip_percentiles : tuple of float, optional If provided, clip intensities to percentile range (lower, upper) before normalization. For example, (1, 99) clips to 1st and 99th percentiles. Only applied if normalize=True. background_value : float or None, default=None Value to assign to background pixels when a mask is provided. label : int or None, default=1 For multi-label masks, which label value to consider as foreground. If None, treat mask as binary (any non-zero value is foreground). crop_to_roi : bool, default=True If True and a mask is provided, crop image and mask to the bounding box of the ROI with padding. This reduces memory usage and speeds up persistent homology computation. roi_padding : int, default=1 Number of background pixels to include on all sides of the ROI when crop_to_roi is enabled. filtration_type : {'sublevel', 'superlevel'}, default='sublevel' Type of filtration for persistent homology. construction : {'T', 'V'}, default='T' Type of cubical complex construction: - 'T': T-construction (pixels/voxels as top-cells, 8-neighborhood in 2D) - 'V': V-construction (pixels/voxels as 0-cells, 4-neighborhood in 2D) max_dimension : int, default=-1 Maximum homology dimension to compute. If -1, automatically determined based on image dimensionality. return_barcodes : bool, default=False If True, return computed barcodes along with features. vectorization_method : str or list, default='PersStats' Single method name or list of method names to apply. Options: 'BettiCurve', 'EntropySummary', 'PersStats', 'PersTropicalCoordinates', 'PersLandscape', 'PersImage', 'PersLifespan', 'PersSilhouette' Examples -------- >>> from medtda import FeatureExtractor >>> extractor = FeatureExtractor( ... normalize=True, ... vectorization_method='PersStats' ... ) >>> features = extractor.execute('image.nii.gz', 'mask.nii.gz') >>> print(features.keys()) # e.g., dict_keys(['PersStats_H0_mean', ...]) """
[docs] def __init__( self, spacing: Optional[Tuple[float, ...]] = None, window: Optional[Tuple[float, float]] = None, normalize: bool = False, normalize_method: str = 'minmax', clip_percentiles: Optional[Tuple[float, float]] = None, background_value: Optional[float] = None, label: Optional[int] = 1, crop_to_roi: bool = True, roi_padding: int = 1, filtration_type: str = 'sublevel', construction: str = 'T', max_dimension: int = -1, return_barcodes: bool = False, vectorization_method: Union[str, List[str]] = 'PersStats' ): """Initialize FeatureExtractor with parameters.""" # Initialize BarcodeExtractor for PH computation self.barcode_extractor = BarcodeExtractor( spacing=spacing, window=window, normalize=normalize, normalize_method=normalize_method, clip_percentiles=clip_percentiles, background_value=background_value, label=label, crop_to_roi=crop_to_roi, roi_padding=roi_padding, filtration_type=filtration_type, construction=construction, max_dimension=max_dimension ) # Store vectorization parameters self.return_barcodes = return_barcodes self._vectorization_methods: Dict[str, bool] = {} self._vectorization_params: Dict[str, Dict[str, Any]] = {} # Initialize vectorization methods if isinstance(vectorization_method, str): vectorization_method = [vectorization_method] for method in vectorization_method: self._validate_vectorization_method(method) self._vectorization_methods[method] = True self._vectorization_params[method] = {}
def _validate_vectorization_method(self, method: str) -> None: """Validate that vectorization method exists.""" if method not in vectorizers.VECTORIZATION_METHODS: available = list(set([k for k in vectorizers.VECTORIZATION_METHODS.keys() if k[0].isupper()])) # Get PascalCase names raise ValueError( f"Unknown vectorization method '{method}'. " f"Available methods: {available}" ) # Preprocessing parameter setters (delegate to BarcodeExtractor)
[docs] def set_spacing(self, spacing: Optional[Tuple[float, ...]]) -> None: """Update spacing parameter.""" self.barcode_extractor.set_spacing(spacing)
[docs] def set_window(self, window_center: float, window_width: float) -> None: """Set windowing parameters.""" self.barcode_extractor.set_window(window_center, window_width)
[docs] def set_normalize(self, enabled: bool, method: str = 'minmax') -> None: """Enable/disable normalization and set method.""" self.barcode_extractor.set_normalize(enabled, method)
[docs] def set_clip_percentiles(self, percentiles: Optional[Tuple[float, float]]) -> None: """ Set percentile clipping for outlier removal. Parameters ---------- percentiles : tuple of float or None If provided, should be (lower_percentile, upper_percentile) where 0 <= lower_percentile < upper_percentile <= 100. Values outside these percentiles will be clipped. Set to None to disable clipping. """ self.barcode_extractor.set_clip_percentiles(percentiles)
[docs] def set_background_value(self, value: Optional[float]) -> None: """Set background value for masked regions.""" self.barcode_extractor.set_background_value(value)
[docs] def set_label(self, label: Optional[int]) -> None: """ Set label value to extract from multi-label mask. Parameters ---------- label : int or None Label value to extract. If None, treat mask as binary. """ self.barcode_extractor.set_label(label)
[docs] def set_filtration_type(self, filtration_type: str) -> None: """Set persistent homology filtration type.""" self.barcode_extractor.set_filtration_type(filtration_type)
[docs] def set_construction(self, construction: str) -> None: """Set cubical complex construction type.""" self.barcode_extractor.set_construction(construction)
[docs] def set_max_dimension(self, max_dimension: int) -> None: """Set maximum homology dimension to compute.""" self.barcode_extractor.set_max_dimension(max_dimension)
[docs] def set_return_barcodes(self, return_barcodes: bool) -> None: """ Enable/disable barcode return. Parameters ---------- return_barcodes : bool If True, execute() returns (features, barcodes) tuple. """ self.return_barcodes = return_barcodes
# Vectorization method management
[docs] def set_vectorization_method(self, method: str, **params) -> None: """ Set a single vectorization method with optional parameters. This replaces all currently enabled methods with just this one. Parameters ---------- method : str Vectorization method name. **params Method-specific parameters. """ self._validate_vectorization_method(method) self._vectorization_methods = {method: True} self._vectorization_params = {method: params}
[docs] def set_vectorization_params(self, method: str, **params) -> None: """ Update parameters for a vectorization method. Parameters ---------- method : str Vectorization method name. **params Method-specific parameters to update. """ if method not in self._vectorization_methods: raise ValueError( f"Method '{method}' is not enabled. " f"Enable it first using enable_vectorization_methods()." ) self._vectorization_params[method].update(params)
[docs] def enable_vectorization_methods(self, methods: List[str]) -> None: """ Enable multiple vectorization methods. Parameters ---------- methods : list of str List of method names to enable. """ for method in methods: self._validate_vectorization_method(method) self._vectorization_methods[method] = True if method not in self._vectorization_params: self._vectorization_params[method] = {}
[docs] def enable_all_vectorization_methods(self) -> None: """Enable all available vectorization methods.""" # Get unique PascalCase method names all_methods = [k for k in vectorizers.VECTORIZATION_METHODS.keys() if k[0].isupper()] self.enable_vectorization_methods(all_methods)
[docs] def disable_vectorization_method(self, method: str) -> None: """ Disable a specific vectorization method. Parameters ---------- method : str Method name to disable. """ if method in self._vectorization_methods: del self._vectorization_methods[method] if method in self._vectorization_params: del self._vectorization_params[method]
[docs] def get_enabled_vectorization_methods(self) -> List[str]: """ Get list of currently enabled vectorization methods. Returns ------- list of str Enabled method names. """ return list(self._vectorization_methods.keys())
[docs] def get_vectorization_params(self, method: str) -> Dict: """ Get current parameters for a vectorization method. Parameters ---------- method : str Method name. Returns ------- dict Current parameters for the method. """ if method not in self._vectorization_params: return {} return self._vectorization_params[method].copy()
[docs] def get_settings(self) -> Dict: """ Get current settings for extraction. Returns ------- dict Dictionary containing all current settings. """ barcode_settings = self.barcode_extractor.get_settings() return { **barcode_settings, 'return_barcodes': self.return_barcodes, 'vectorization_methods': self.get_enabled_vectorization_methods(), 'vectorization_params': self._vectorization_params.copy() }
[docs] def execute( self, image: Union[str, Path, np.ndarray], mask: Optional[Union[str, Path, np.ndarray]] = None, label: Optional[int] = _UNSET # type: ignore[assignment] ) -> Union[Dict[str, Any], Tuple[Dict[str, Any], Dict[str, np.ndarray]]]: """ Execute feature extraction pipeline. This method performs: 1. Preprocess image (via BarcodeExtractor) 2. Compute persistent homology (get barcodes) 3. Vectorize barcodes using enabled methods 4. Format features with naming convention Parameters ---------- image : str, Path, or numpy.ndarray Input image. mask : str, Path, numpy.ndarray, or None, default=None Optional mask. label : int or None, optional Label value to extract from mask. If None, uses instance default. Returns ------- features : OrderedDict Feature dictionary with keys following naming convention: - Dict-based methods (e.g., PersStats): '{method}_{dimension}_{feature_name}' - Array-based methods (e.g., BettiCurve): '{method}_{dimension}_f{index}' barcodes : dict (optional) If return_barcodes=True, returns (features, barcodes) tuple. Examples -------- >>> extractor = FeatureExtractor( ... normalize=True, ... vectorization_method=['PersStats', 'BettiCurve'] ... ) >>> features = extractor.execute('image.nii.gz') >>> print(list(features.keys())[:5]) # First 5 feature names """ # Step 1: Extract barcodes using BarcodeExtractor barcodes = self.barcode_extractor.execute(image, mask, label) # Step 2: Vectorize barcodes for each enabled method features = dict() for method_name in self.get_enabled_vectorization_methods(): method_params = self._vectorization_params.get(method_name, {}) method_func: Callable[..., Any] = vectorizers.VECTORIZATION_METHODS[method_name] # type: ignore[assignment,index] # Apply vectorization to each dimension for dim_key, barcode in barcodes.items(): # Get feature vector for this dimension feature_vector = method_func(barcode, **method_params) # Add features with proper naming if isinstance(feature_vector, dict): # Method returns named features (like PersStats) for feat_name, feat_value in feature_vector.items(): full_name = f"{method_name}_{dim_key}_{feat_name}" features[full_name] = feat_value else: # Method returns array - create indexed names for i, feat_value in enumerate(feature_vector): full_name = f"{method_name}_{dim_key}_f{i}" features[full_name] = feat_value # Step 3: Return features (and optionally barcodes) if self.return_barcodes: return features, barcodes else: return features