Source code for phenotypic.analysis._cubical_persistence

"""Analysis-only cubical persistence through the optional GUDHI dependency."""

from __future__ import annotations

import importlib
from dataclasses import dataclass
from numbers import Real
from typing import Any, Literal

import numpy as np
from numpy.typing import NDArray


Filtration = Literal["sublevel", "superlevel"]
FloatArray = NDArray[np.float64]
IntArray = NDArray[np.int64]


[docs] @dataclass(frozen=True) class PersistencePairsResult: """Cubical-persistence intervals and their top-cell representatives. Each tuple contains exactly two arrays, indexed by homology dimension ``0`` and ``1``. Regular pairs retain GUDHI's source order and essential pairs are appended. Essential intervals use ``(-1, -1)`` as their death coordinate. Attributes: birth_values: Birth intensities in the input image's coordinates. death_values: Death intensities, including signed infinity for essential intervals. lifetimes: Nonnegative persistence lifetimes. birth_cells: Birth top cells as ``(row, column)`` coordinates. death_cells: Death top cells, or ``(-1, -1)`` for essential intervals. essential_cells: Birth coordinates for essential intervals only. filtration: The selected ``"sublevel"`` or ``"superlevel"`` filtration. Note: The dataclass fields are frozen, but the NumPy arrays remain mutable. """ birth_values: tuple[FloatArray, FloatArray] death_values: tuple[FloatArray, FloatArray] lifetimes: tuple[FloatArray, FloatArray] birth_cells: tuple[IntArray, IntArray] death_cells: tuple[IntArray, IntArray] essential_cells: tuple[IntArray, IntArray] filtration: Filtration
def _validate_image(image: np.ndarray) -> FloatArray: """Return a copied finite two-dimensional real image as ``float64``. Args: image: Candidate numeric image. Returns: Independent two-dimensional ``float64`` image. Raises: ValueError: If the input is not a nonempty, finite, real-valued numeric two-dimensional array. """ try: array = np.asarray(image) except (TypeError, ValueError) as exc: raise ValueError("image must be convertible to a numeric 2-D array") from exc if array.ndim != 2: raise ValueError(f"image must be two-dimensional; got shape {array.shape}") if array.size == 0: raise ValueError("image must have no empty axes") if np.issubdtype(array.dtype, np.bool_): raise ValueError("image must be real-valued numeric data, not boolean") if not np.issubdtype(array.dtype, np.number): raise ValueError(f"image must be numeric; got dtype {array.dtype}") if np.issubdtype(array.dtype, np.complexfloating): raise ValueError("image must be real-valued, not complex") if not np.isfinite(array).all(): raise ValueError("image must contain only finite values") return np.array(array, dtype=np.float64, copy=True) def _validate_filtration(filtration: object) -> Filtration: """Validate and narrow the filtration string.""" if not isinstance(filtration, str): raise ValueError( "filtration must be exactly 'sublevel' or 'superlevel'; " f"got {filtration!r}" ) if filtration == "sublevel": return "sublevel" if filtration == "superlevel": return "superlevel" raise ValueError( "filtration must be exactly 'sublevel' or 'superlevel'; " f"got {filtration!r}" ) def _validate_min_persistence(min_persistence: object) -> float: """Return a finite nonnegative real persistence threshold.""" if isinstance(min_persistence, (bool, np.bool_)) or not isinstance( min_persistence, Real ): raise ValueError("min_persistence must be a real scalar other than bool") value = float(min_persistence) if not np.isfinite(value) or value < 0.0: raise ValueError("min_persistence must be finite and at least zero") return value def _import_gudhi() -> Any: """Import GUDHI lazily with an actionable optional-dependency error.""" try: return importlib.import_module("gudhi") except ImportError as exc: raise ImportError( "cubical_persistence requires the optional GUDHI dependency. " "Install PhenoTypic's topology extra with `uv sync --extra topology`." ) from exc def _coface_coordinates(ids: IntArray, shape: tuple[int, int]) -> IntArray: """Convert Fortran-flat GUDHI top-cell IDs to row/column coordinates.""" if ids.size == 0: return np.empty((0, 2), dtype=np.int64) rows, columns = np.unravel_index(ids, shape, order="F") return np.column_stack((rows, columns)).astype(np.int64, copy=False) def _ids_for_dimension( arrays: list[np.ndarray], dimension: int, columns: int ) -> IntArray: """Return one dimension's pair IDs with a stable empty shape.""" if dimension >= len(arrays): return np.empty((0, columns), dtype=np.int64) return np.asarray(arrays[dimension], dtype=np.int64).reshape((-1, columns)) def _convert_dimension( *, dimension: int, regular: list[np.ndarray], essential: list[np.ndarray], flat_filtration_values: FloatArray, shape: tuple[int, int], filtration: Filtration, ) -> tuple[FloatArray, FloatArray, FloatArray, IntArray, IntArray, IntArray]: """Convert one GUDHI coface group to the frozen public representation.""" regular_ids = _ids_for_dimension(regular, dimension, 2) essential_ids = _ids_for_dimension(essential, dimension, 1).reshape((-1,)) birth_filtration = flat_filtration_values[regular_ids[:, 0]] death_filtration = flat_filtration_values[regular_ids[:, 1]] essential_birth_filtration = flat_filtration_values[essential_ids] if filtration == "sublevel": regular_birth = birth_filtration regular_death = death_filtration essential_birth = essential_birth_filtration essential_death = np.full(essential_ids.size, np.inf, dtype=np.float64) regular_lifetime = regular_death - regular_birth else: regular_birth = -birth_filtration regular_death = -death_filtration essential_birth = -essential_birth_filtration essential_death = np.full(essential_ids.size, -np.inf, dtype=np.float64) regular_lifetime = regular_birth - regular_death birth_values = np.concatenate((regular_birth, essential_birth)).astype( np.float64, copy=False ) death_values = np.concatenate((regular_death, essential_death)).astype( np.float64, copy=False ) lifetimes = np.concatenate( (regular_lifetime, np.full(essential_ids.size, np.inf, dtype=np.float64)) ).astype(np.float64, copy=False) regular_birth_cells = _coface_coordinates(regular_ids[:, 0], shape) regular_death_cells = _coface_coordinates(regular_ids[:, 1], shape) essential_cells = _coface_coordinates(essential_ids, shape) birth_cells = np.concatenate( (regular_birth_cells, essential_cells), axis=0 ).astype(np.int64, copy=False) death_cells = np.concatenate( ( regular_death_cells, np.full((essential_ids.size, 2), -1, dtype=np.int64), ), axis=0, ).astype(np.int64, copy=False) return ( birth_values, death_values, lifetimes, birth_cells, death_cells, essential_cells, )
[docs] def cubical_persistence( image: np.ndarray, *, filtration: Filtration = "superlevel", min_persistence: float = 0.0, ) -> PersistencePairsResult: """Compute beta-0 and beta-1 persistence from image top cells. Pixels are closed, nonperiodic GUDHI top-dimensional cells. Consequently, foreground cells touching at a corner are connected. GUDHI receives the copied image for sublevel persistence and its negation for superlevel persistence. Returned values are always converted back to the original image intensity coordinates. Args: image: Nonempty, finite, real-valued numeric two-dimensional array. filtration: Either ``"sublevel"`` or ``"superlevel"``. Defaults to ``"superlevel"`` for bright structures. min_persistence: Finite nonnegative lifetime threshold. A finite class is retained only when its lifetime is strictly greater than this value. Essential classes are always retained. Returns: Persistence values and top-cell representatives for homology dimensions zero and one. Raises: ValueError: If an input or parameter violates the frozen contract. ImportError: If the optional GUDHI dependency is unavailable for a valid nonempty call. """ source_image = _validate_image(image) selected_filtration = _validate_filtration(filtration) threshold = _validate_min_persistence(min_persistence) gudhi = _import_gudhi() filtration_values = ( source_image if selected_filtration == "sublevel" else -source_image ) complex_ = gudhi.CubicalComplex(top_dimensional_cells=filtration_values) complex_.compute_persistence( homology_coeff_field=11, min_persistence=threshold, ) regular_raw, essential_raw = complex_.cofaces_of_persistence_pairs() regular = list(regular_raw) essential = list(essential_raw) flat_filtration_values = filtration_values.ravel(order="F") dimension_zero = _convert_dimension( dimension=0, regular=regular, essential=essential, flat_filtration_values=flat_filtration_values, shape=source_image.shape, filtration=selected_filtration, ) dimension_one = _convert_dimension( dimension=1, regular=regular, essential=essential, flat_filtration_values=flat_filtration_values, shape=source_image.shape, filtration=selected_filtration, ) return PersistencePairsResult( birth_values=(dimension_zero[0], dimension_one[0]), death_values=(dimension_zero[1], dimension_one[1]), lifetimes=(dimension_zero[2], dimension_one[2]), birth_cells=(dimension_zero[3], dimension_one[3]), death_cells=(dimension_zero[4], dimension_one[4]), essential_cells=(dimension_zero[5], dimension_one[5]), filtration=selected_filtration, )