Skip to content

Image Processing

The voids.image sub-package provides utilities for segmented image processing, connectivity analysis, and pore network extraction used in vug sensitivity studies.


Network Extraction

voids.image.network_extraction

NetworkExtractionResult dataclass

Store outputs of an image-to-network extraction workflow.

Attributes:

Name Type Description
image ndarray

Input phase image used for extraction.

voxel_size float

Physical voxel edge length.

axis_lengths dict[str, float]

Sample lengths by axis.

axis_areas dict[str, float]

Cross-sectional areas normal to each axis.

flow_axis str

Axis used for spanning subnetwork pruning.

network_dict dict[str, object]

Intermediate extracted network mapping before conversion to :class:voids.core.network.Network.

sample SampleGeometry

Sample geometry attached to the network.

provenance Provenance

Extraction provenance metadata.

net_full Network

Full imported network before spanning pruning.

net Network

Axis-spanning subnetwork.

pore_indices ndarray

Indices of retained pores in net_full.

throat_mask ndarray

Mask of retained throats in net_full.

backend str

Extraction backend identifier (currently "porespy").

backend_version str | None

Backend version string when available.

Source code in src/voids/image/network_extraction.py
@dataclass(slots=True)
class NetworkExtractionResult:
    """Store outputs of an image-to-network extraction workflow.

    Attributes
    ----------
    image :
        Input phase image used for extraction.
    voxel_size :
        Physical voxel edge length.
    axis_lengths :
        Sample lengths by axis.
    axis_areas :
        Cross-sectional areas normal to each axis.
    flow_axis :
        Axis used for spanning subnetwork pruning.
    network_dict :
        Intermediate extracted network mapping before conversion to
        :class:`voids.core.network.Network`.
    sample :
        Sample geometry attached to the network.
    provenance :
        Extraction provenance metadata.
    net_full :
        Full imported network before spanning pruning.
    net :
        Axis-spanning subnetwork.
    pore_indices :
        Indices of retained pores in ``net_full``.
    throat_mask :
        Mask of retained throats in ``net_full``.
    backend :
        Extraction backend identifier (currently ``"porespy"``).
    backend_version :
        Backend version string when available.
    """

    image: np.ndarray
    voxel_size: float
    axis_lengths: dict[str, float]
    axis_areas: dict[str, float]
    flow_axis: str
    network_dict: dict[str, object]
    sample: SampleGeometry
    provenance: Provenance
    net_full: Network
    net: Network
    pore_indices: np.ndarray
    throat_mask: np.ndarray
    backend: str
    backend_version: str | None

infer_sample_axes

infer_sample_axes(
    shape, *, voxel_size, axis_names=("x", "y", "z")
)

Infer per-axis counts, lengths, areas, and the longest flow axis.

Parameters:

Name Type Description Default
shape tuple[int, ...]

Image shape in voxel counts.

required
voxel_size float

Edge length of one voxel in the target length unit.

required
axis_names tuple[str, ...]

Axis labels mapped onto the image shape.

('x', 'y', 'z')

Returns:

Type Description
tuple

(axis_counts, axis_lengths, axis_areas, flow_axis).

Source code in src/voids/image/network_extraction.py
def infer_sample_axes(
    shape: tuple[int, ...],
    *,
    voxel_size: float,
    axis_names: tuple[str, ...] = ("x", "y", "z"),
) -> tuple[dict[str, int], dict[str, float], dict[str, float], str]:
    """Infer per-axis counts, lengths, areas, and the longest flow axis.

    Parameters
    ----------
    shape :
        Image shape in voxel counts.
    voxel_size :
        Edge length of one voxel in the target length unit.
    axis_names :
        Axis labels mapped onto the image shape.

    Returns
    -------
    tuple
        ``(axis_counts, axis_lengths, axis_areas, flow_axis)``.
    """

    if voxel_size <= 0:
        raise ValueError("voxel_size must be positive")
    if len(shape) not in {2, 3}:
        raise ValueError("shape must have length 2 or 3")
    if len(axis_names) < len(shape):
        raise ValueError("axis_names must cover every image dimension")

    active_axes = axis_names[: len(shape)]
    axis_counts = {ax: int(n) for ax, n in zip(active_axes, shape)}
    axis_lengths = {ax: count * float(voxel_size) for ax, count in axis_counts.items()}
    axis_areas: dict[str, float] = {}
    for ax in active_axes:
        others = [other for other in active_axes if other != ax]
        area = float(voxel_size) ** max(len(others), 1)
        for other in others:
            area *= axis_counts[other]
        axis_areas[ax] = area
    flow_axis = max(axis_lengths, key=lambda ax: axis_lengths[ax])
    return axis_counts, axis_lengths, axis_areas, flow_axis

extract_spanning_pore_network

extract_spanning_pore_network(
    phases,
    *,
    voxel_size,
    flow_axis=None,
    length_unit="m",
    pressure_unit="Pa",
    extraction_kwargs=None,
    provenance_notes=None,
    strict=True,
    geometry_repairs="imperial_export",
    repair_seed=0,
)

Extract, import, and prune an axis-spanning pore network from an image.

Parameters:

Name Type Description Default
phases ndarray

Binary or integer-labeled phase image where nonzero values are active phases passed to the extraction backend.

required
voxel_size float

Edge length of one voxel in the declared length_unit.

required
flow_axis str | None

Requested spanning axis. When omitted, the longest image axis is used.

None
length_unit str

Units stored in resulting :class:SampleGeometry.

'm'
pressure_unit str

Units stored in resulting :class:SampleGeometry.

'm'
extraction_kwargs dict[str, object] | None

Keyword arguments forwarded to the extraction backend call.

None
provenance_notes dict[str, object] | None

Optional extra provenance metadata attached to the resulting network.

None
strict bool

Forwarded to :func:voids.io.porespy.from_porespy.

True
geometry_repairs str | None

Optional importer preprocessing mode. The default "imperial_export" applies the Imperial College export-style shape-factor repair heuristics during the PoreSpy-to-voids conversion.

'imperial_export'
repair_seed int | None

Seed for any stochastic repair branch when geometry_repairs is not None.

0

Returns:

Type Description
NetworkExtractionResult

Full and pruned networks together with intermediate metadata.

Notes

Current implementation uses PoreSpy's snow2 backend and normalizes accepted return styles into a standard network mapping before import.

Source code in src/voids/image/network_extraction.py
def extract_spanning_pore_network(
    phases: np.ndarray,
    *,
    voxel_size: float,
    flow_axis: str | None = None,
    length_unit: str = "m",
    pressure_unit: str = "Pa",
    extraction_kwargs: dict[str, object] | None = None,
    provenance_notes: dict[str, object] | None = None,
    strict: bool = True,
    geometry_repairs: str | None = "imperial_export",
    repair_seed: int | None = 0,
) -> NetworkExtractionResult:
    """Extract, import, and prune an axis-spanning pore network from an image.

    Parameters
    ----------
    phases :
        Binary or integer-labeled phase image where nonzero values are active
        phases passed to the extraction backend.
    voxel_size :
        Edge length of one voxel in the declared ``length_unit``.
    flow_axis :
        Requested spanning axis. When omitted, the longest image axis is used.
    length_unit, pressure_unit :
        Units stored in resulting :class:`SampleGeometry`.
    extraction_kwargs :
        Keyword arguments forwarded to the extraction backend call.
    provenance_notes :
        Optional extra provenance metadata attached to the resulting network.
    strict :
        Forwarded to :func:`voids.io.porespy.from_porespy`.
    geometry_repairs :
        Optional importer preprocessing mode. The default
        ``"imperial_export"`` applies the Imperial College export-style
        shape-factor repair heuristics during the PoreSpy-to-``voids``
        conversion.
    repair_seed :
        Seed for any stochastic repair branch when ``geometry_repairs`` is not
        ``None``.

    Returns
    -------
    NetworkExtractionResult
        Full and pruned networks together with intermediate metadata.

    Notes
    -----
    Current implementation uses PoreSpy's ``snow2`` backend and normalizes
    accepted return styles into a standard network mapping before import.
    """

    arr = np.asarray(phases, dtype=int)
    if arr.ndim not in {2, 3}:
        raise ValueError("phases must be a 2D or 3D integer image")

    _, axis_lengths, axis_areas, inferred_axis = infer_sample_axes(arr.shape, voxel_size=voxel_size)
    selected_axis = inferred_axis if flow_axis is None else flow_axis
    if selected_axis not in axis_lengths:
        raise ValueError(f"flow_axis '{selected_axis}' is not compatible with shape {arr.shape}")

    network_dict = _snow2_network_dict(arr, snow2_kwargs=dict(extraction_kwargs or {}))
    network_dict = scale_porespy_geometry(network_dict, voxel_size=voxel_size)
    network_dict = ensure_cartesian_boundary_labels(network_dict, axes=(selected_axis,))

    shape_2d_or_3d = tuple(int(n) for n in arr.shape)
    bulk_shape: tuple[int, int, int] = (
        shape_2d_or_3d[0],
        shape_2d_or_3d[1],
        shape_2d_or_3d[2] if arr.ndim == 3 else 1,
    )
    sample = SampleGeometry(
        voxel_size=float(voxel_size),
        bulk_shape_voxels=bulk_shape,
        lengths=axis_lengths,
        cross_sections=axis_areas,
        units={"length": length_unit, "pressure": pressure_unit},
    )
    provenance = Provenance(
        source_kind="image_extraction",
        source_version=getattr(ps, "__version__", None),
        extraction_method="snow2",
        random_seed=repair_seed if geometry_repairs is not None else None,
        user_notes=dict(provenance_notes or {}),
    )
    net_full = from_porespy(
        network_dict,
        sample=sample,
        provenance=provenance,
        strict=strict,
        geometry_repairs=geometry_repairs,
        repair_seed=repair_seed,
    )
    net, pore_indices, throat_mask = spanning_subnetwork(net_full, axis=selected_axis)
    return NetworkExtractionResult(
        image=arr,
        voxel_size=float(voxel_size),
        axis_lengths=axis_lengths,
        axis_areas=axis_areas,
        flow_axis=selected_axis,
        network_dict=network_dict,
        sample=sample,
        provenance=provenance,
        net_full=net_full,
        net=net,
        pore_indices=pore_indices,
        throat_mask=throat_mask,
        backend="porespy",
        backend_version=getattr(ps, "__version__", None),
    )

Segmentation

voids.image.segmentation

VolumeCropResult dataclass

Store cylindrical-support cropping outputs from a grayscale volume.

Attributes:

Name Type Description
raw ndarray

Original grayscale volume as float array.

specimen_mask ndarray

Slice-wise support mask after hole filling.

common_mask ndarray

Per-pixel intersection of support masks over all slices.

crop_bounds_yx tuple[int, int, int, int]

Maximal common rectangle bounds (y0, y1, x0, x1).

cropped ndarray

Cropped grayscale volume containing the common inscribed rectangle.

Source code in src/voids/image/segmentation.py
@dataclass(slots=True)
class VolumeCropResult:
    """Store cylindrical-support cropping outputs from a grayscale volume.

    Attributes
    ----------
    raw :
        Original grayscale volume as float array.
    specimen_mask :
        Slice-wise support mask after hole filling.
    common_mask :
        Per-pixel intersection of support masks over all slices.
    crop_bounds_yx :
        Maximal common rectangle bounds ``(y0, y1, x0, x1)``.
    cropped :
        Cropped grayscale volume containing the common inscribed rectangle.
    """

    raw: np.ndarray
    specimen_mask: np.ndarray
    common_mask: np.ndarray
    crop_bounds_yx: tuple[int, int, int, int]
    cropped: np.ndarray

GrayscaleSegmentationResult dataclass

Store grayscale preprocessing and binary segmentation outputs.

Attributes:

Name Type Description
crop VolumeCropResult

Cylindrical-support crop outputs.

threshold float

Threshold used for binarization.

binary ndarray

Segmented binary volume encoded as void=1, solid=0.

void_phase str

Phase polarity used for thresholding ("dark" or "bright").

threshold_method str

Automatic method used when threshold was not explicitly supplied.

Source code in src/voids/image/segmentation.py
@dataclass(slots=True)
class GrayscaleSegmentationResult:
    """Store grayscale preprocessing and binary segmentation outputs.

    Attributes
    ----------
    crop :
        Cylindrical-support crop outputs.
    threshold :
        Threshold used for binarization.
    binary :
        Segmented binary volume encoded as ``void=1``, ``solid=0``.
    void_phase :
        Phase polarity used for thresholding (``"dark"`` or ``"bright"``).
    threshold_method :
        Automatic method used when threshold was not explicitly supplied.
    """

    crop: VolumeCropResult
    threshold: float
    binary: np.ndarray
    void_phase: str
    threshold_method: str

largest_true_rectangle

largest_true_rectangle(mask2d)

Return maximal-area axis-aligned rectangle fully contained in a mask.

Parameters:

Name Type Description Default
mask2d ndarray

Two-dimensional boolean support mask.

required

Returns:

Type Description
tuple[int, int, int, int]

Rectangle bounds (y0, y1, x0, x1) in NumPy slicing convention.

Raises:

Type Description
ValueError

If mask2d is not 2D or contains no True pixels.

Source code in src/voids/image/segmentation.py
def largest_true_rectangle(mask2d: np.ndarray) -> tuple[int, int, int, int]:
    """Return maximal-area axis-aligned rectangle fully contained in a mask.

    Parameters
    ----------
    mask2d :
        Two-dimensional boolean support mask.

    Returns
    -------
    tuple[int, int, int, int]
        Rectangle bounds ``(y0, y1, x0, x1)`` in NumPy slicing convention.

    Raises
    ------
    ValueError
        If ``mask2d`` is not 2D or contains no ``True`` pixels.
    """

    mask = np.asarray(mask2d, dtype=bool)
    if mask.ndim != 2:
        raise ValueError("mask2d must be a 2D boolean array")

    heights = [0] * mask.shape[1]
    best_area = 0
    best_bounds: tuple[int, int, int, int] | None = None
    for y in range(mask.shape[0]):
        for x in range(mask.shape[1]):
            heights[x] = heights[x] + 1 if mask[y, x] else 0
        stack: list[int] = []
        x = 0
        while x <= mask.shape[1]:
            cur = heights[x] if x < mask.shape[1] else 0
            if not stack or cur >= heights[stack[-1]]:
                stack.append(x)
                x += 1
            else:
                top = stack.pop()
                height = heights[top]
                left = stack[-1] + 1 if stack else 0
                width = x - left
                area = height * width
                if area > best_area:
                    best_area = area
                    best_bounds = (y + 1 - height, y + 1, left, x)
    if best_bounds is None:
        raise ValueError("mask2d does not contain any True pixels")
    return best_bounds

crop_nonzero_cylindrical_volume

crop_nonzero_cylindrical_volume(
    raw,
    *,
    background_value=0.0,
    show_progress=False,
    progress_desc=None,
)

Crop cylindrical specimen support to a common rectangular field of view.

Parameters:

Name Type Description Default
raw ndarray

Raw 3D grayscale volume.

required
background_value float

Voxels strictly above this value are interpreted as specimen support before hole filling.

0.0
show_progress bool

Whether to show progress bars for slice-wise operations.

False
progress_desc str | None

Optional progress description string.

None

Returns:

Type Description
VolumeCropResult

Structured crop result with masks, bounds, and cropped volume.

Source code in src/voids/image/segmentation.py
def crop_nonzero_cylindrical_volume(
    raw: np.ndarray,
    *,
    background_value: float = 0.0,
    show_progress: bool = False,
    progress_desc: str | None = None,
) -> VolumeCropResult:
    """Crop cylindrical specimen support to a common rectangular field of view.

    Parameters
    ----------
    raw :
        Raw 3D grayscale volume.
    background_value :
        Voxels strictly above this value are interpreted as specimen support
        before hole filling.
    show_progress :
        Whether to show progress bars for slice-wise operations.
    progress_desc :
        Optional progress description string.

    Returns
    -------
    VolumeCropResult
        Structured crop result with masks, bounds, and cropped volume.
    """

    arr = np.asarray(raw, dtype=float)
    if arr.ndim != 3:
        raise ValueError("raw must be a 3D grayscale volume")

    specimen_mask = np.zeros_like(arr, dtype=bool)
    iterator = _progress_iter(
        range(arr.shape[0]),
        show_progress=show_progress,
        desc=progress_desc or "Filling support mask slices",
        total=int(arr.shape[0]),
    )
    for i in iterator:
        specimen_mask[i] = ndi.binary_fill_holes(arr[i] > background_value)

    common_mask = specimen_mask.all(axis=0)
    crop_bounds_yx = largest_true_rectangle(common_mask)
    y0, y1, x0, x1 = crop_bounds_yx
    cropped = arr[:, y0:y1, x0:x1]
    return VolumeCropResult(
        raw=arr,
        specimen_mask=specimen_mask,
        common_mask=common_mask,
        crop_bounds_yx=crop_bounds_yx,
        cropped=cropped,
    )

binarize_grayscale_volume

binarize_grayscale_volume(
    cropped,
    *,
    threshold=None,
    method="otsu",
    void_phase="dark",
)

Segment grayscale volume into binary void/solid phases.

Parameters:

Name Type Description Default
cropped ndarray

Cropped 3D grayscale volume.

required
threshold float | None

Explicit threshold; when omitted, an automatic threshold is computed.

None
method str

Automatic threshold method name. Supported values are "otsu", "li", "yen", "isodata", and "triangle".

'otsu'
void_phase str

Which side of threshold corresponds to void: "dark" or "bright".

'dark'

Returns:

Type Description
tuple[ndarray, float]

(binary, threshold_used) where binary is integer encoded as void=1 and solid=0.

Source code in src/voids/image/segmentation.py
def binarize_grayscale_volume(
    cropped: np.ndarray,
    *,
    threshold: float | None = None,
    method: str = "otsu",
    void_phase: str = "dark",
) -> tuple[np.ndarray, float]:
    """Segment grayscale volume into binary void/solid phases.

    Parameters
    ----------
    cropped :
        Cropped 3D grayscale volume.
    threshold :
        Explicit threshold; when omitted, an automatic threshold is computed.
    method :
        Automatic threshold method name. Supported values are ``"otsu"``,
        ``"li"``, ``"yen"``, ``"isodata"``, and ``"triangle"``.
    void_phase :
        Which side of threshold corresponds to void: ``"dark"`` or
        ``"bright"``.

    Returns
    -------
    tuple[numpy.ndarray, float]
        ``(binary, threshold_used)`` where ``binary`` is integer encoded as
        ``void=1`` and ``solid=0``.
    """

    arr = np.asarray(cropped, dtype=float)
    if arr.ndim != 3:
        raise ValueError("cropped must be a 3D grayscale volume")
    if void_phase not in {"dark", "bright"}:
        raise ValueError("void_phase must be either 'dark' or 'bright'")

    if threshold is None:
        if method not in _THRESHOLD_METHODS:
            raise ValueError(f"Unsupported threshold method '{method}'")
        threshold = float(_THRESHOLD_METHODS[method](arr))
    else:
        threshold = float(threshold)

    if void_phase == "dark":
        binary = (arr < threshold).astype(int)
    else:
        binary = (arr > threshold).astype(int)
    return binary, threshold

preprocess_grayscale_cylindrical_volume

preprocess_grayscale_cylindrical_volume(
    raw,
    *,
    background_value=0.0,
    threshold=None,
    threshold_method="otsu",
    void_phase="dark",
    show_progress=False,
    progress_desc=None,
)

Run cylindrical crop and grayscale segmentation in one workflow call.

Parameters:

Name Type Description Default
raw ndarray

Raw 3D grayscale specimen volume.

required
background_value float

Background/support discriminator for cropping.

0.0
threshold float | None

Explicit segmentation threshold.

None
threshold_method str

Method used when threshold is omitted.

'otsu'
void_phase str

Phase polarity selector for thresholding.

'dark'
show_progress bool

Whether to request progress reporting.

False
progress_desc str | None

Optional progress message.

None

Returns:

Type Description
GrayscaleSegmentationResult

Crop metadata plus segmented binary volume.

Source code in src/voids/image/segmentation.py
def preprocess_grayscale_cylindrical_volume(
    raw: np.ndarray,
    *,
    background_value: float = 0.0,
    threshold: float | None = None,
    threshold_method: str = "otsu",
    void_phase: str = "dark",
    show_progress: bool = False,
    progress_desc: str | None = None,
) -> GrayscaleSegmentationResult:
    """Run cylindrical crop and grayscale segmentation in one workflow call.

    Parameters
    ----------
    raw :
        Raw 3D grayscale specimen volume.
    background_value :
        Background/support discriminator for cropping.
    threshold :
        Explicit segmentation threshold.
    threshold_method :
        Method used when ``threshold`` is omitted.
    void_phase :
        Phase polarity selector for thresholding.
    show_progress :
        Whether to request progress reporting.
    progress_desc :
        Optional progress message.

    Returns
    -------
    GrayscaleSegmentationResult
        Crop metadata plus segmented binary volume.
    """

    crop = crop_nonzero_cylindrical_volume(
        raw,
        background_value=background_value,
        show_progress=show_progress,
        progress_desc=progress_desc,
    )
    binary, used_threshold = binarize_grayscale_volume(
        crop.cropped,
        threshold=threshold,
        method=threshold_method,
        void_phase=void_phase,
    )
    return GrayscaleSegmentationResult(
        crop=crop,
        threshold=used_threshold,
        binary=binary,
        void_phase=void_phase,
        threshold_method=threshold_method,
    )

binarize_2d_with_voids

binarize_2d_with_voids(
    gray2d,
    *,
    threshold=None,
    method="otsu",
    void_phase="dark",
)

Segment a 2D grayscale image using the same thresholding policy as 3D.

Parameters:

Name Type Description Default
gray2d ndarray

Two-dimensional grayscale image.

required
threshold float | None

Explicit threshold value.

None
method str

Automatic threshold method when threshold is omitted.

'otsu'
void_phase str

Which side of threshold corresponds to void.

'dark'

Returns:

Type Description
tuple[ndarray, float]

(binary2d, threshold_used) with binary image encoded as integers in {0, 1}.

Source code in src/voids/image/segmentation.py
def binarize_2d_with_voids(
    gray2d: np.ndarray,
    *,
    threshold: float | None = None,
    method: str = "otsu",
    void_phase: str = "dark",
) -> tuple[np.ndarray, float]:
    """Segment a 2D grayscale image using the same thresholding policy as 3D.

    Parameters
    ----------
    gray2d :
        Two-dimensional grayscale image.
    threshold :
        Explicit threshold value.
    method :
        Automatic threshold method when ``threshold`` is omitted.
    void_phase :
        Which side of threshold corresponds to void.

    Returns
    -------
    tuple[numpy.ndarray, float]
        ``(binary2d, threshold_used)`` with binary image encoded as integers
        in ``{0, 1}``.
    """

    gray = np.asarray(gray2d, dtype=float)
    if gray.ndim != 2:
        raise ValueError("gray2d must be a 2D array")
    seg3d, threshold_used = binarize_grayscale_volume(
        gray[None, :, :],
        threshold=threshold,
        method=method,
        void_phase=void_phase,
    )
    return np.asarray(seg3d[0], dtype=int), float(threshold_used)

Image Connectivity

voids.image.connectivity

has_spanning_cluster

has_spanning_cluster(void_mask, axis_index)

Test whether void space percolates from one boundary to the opposite.

Parameters:

Name Type Description Default
void_mask ndarray

Binary image where True denotes void phase and False denotes solid phase. Supported dimensionalities are 2D and 3D.

required
axis_index int

Flow/percolation axis index. For a 3D array (nx, ny, nz), values are typically 0 (x), 1 (y), or 2 (z).

required

Returns:

Type Description
bool

True if at least one connected void component intersects both opposite boundary planes along axis_index.

Raises:

Type Description
ValueError

If void_mask is not 2D/3D or if axis_index is invalid.

Notes

Connectivity is computed via :func:scipy.ndimage.label with its default structuring element (face-connected in 3D, edge-connected in 2D). The criterion is geometric percolation only; it does not assess hydraulic conductance magnitude.

Assumptions and limitations
  • Boundaries are interpreted as the first and last index along the target axis.
  • Periodic boundaries are not considered.
  • Very thin connections may percolate topologically even if they are not representative of realistic transport in a given experiment.
Source code in src/voids/image/connectivity.py
def has_spanning_cluster(void_mask: np.ndarray, axis_index: int) -> bool:
    """Test whether void space percolates from one boundary to the opposite.

    Parameters
    ----------
    void_mask :
        Binary image where ``True`` denotes void phase and ``False`` denotes
        solid phase. Supported dimensionalities are 2D and 3D.
    axis_index :
        Flow/percolation axis index. For a 3D array ``(nx, ny, nz)``, values
        are typically ``0`` (x), ``1`` (y), or ``2`` (z).

    Returns
    -------
    bool
        ``True`` if at least one connected void component intersects both
        opposite boundary planes along ``axis_index``.

    Raises
    ------
    ValueError
        If ``void_mask`` is not 2D/3D or if ``axis_index`` is invalid.

    Notes
    -----
    Connectivity is computed via :func:`scipy.ndimage.label` with its default
    structuring element (face-connected in 3D, edge-connected in 2D). The
    criterion is geometric percolation only; it does not assess hydraulic
    conductance magnitude.

    Assumptions and limitations
    ---------------------------
    - Boundaries are interpreted as the first and last index along the target
      axis.
    - Periodic boundaries are not considered.
    - Very thin connections may percolate topologically even if they are not
      representative of realistic transport in a given experiment.
    """

    mask = np.asarray(void_mask, dtype=bool)
    if mask.ndim not in {2, 3}:
        raise ValueError("void_mask must be 2D or 3D")
    axis = validate_axis_index(axis_index=axis_index, ndim=mask.ndim)

    labels, n_labels = ndi.label(mask)
    if n_labels == 0:
        return False

    inlet_labels = np.unique(np.take(labels, indices=0, axis=axis))
    outlet_labels = np.unique(np.take(labels, indices=-1, axis=axis))
    inlet_labels = inlet_labels[inlet_labels > 0]
    outlet_labels = outlet_labels[outlet_labels > 0]
    return bool(np.intersect1d(inlet_labels, outlet_labels).size > 0)

has_spanning_cluster_2d

has_spanning_cluster_2d(void_mask, axis_index)

2D-specialized wrapper for axis-spanning connectivity checks.

Parameters:

Name Type Description Default
void_mask ndarray

Two-dimensional binary void mask.

required
axis_index int

Axis along which percolation is tested (0 or 1).

required

Returns:

Type Description
bool

True when at least one 2D connected void component spans the selected axis.

Raises:

Type Description
ValueError

If void_mask is not 2D.

Notes

This function exists for notebook/API compatibility and delegates to :func:has_spanning_cluster after enforcing 2D inputs.

Source code in src/voids/image/connectivity.py
def has_spanning_cluster_2d(void_mask: np.ndarray, axis_index: int) -> bool:
    """2D-specialized wrapper for axis-spanning connectivity checks.

    Parameters
    ----------
    void_mask :
        Two-dimensional binary void mask.
    axis_index :
        Axis along which percolation is tested (``0`` or ``1``).

    Returns
    -------
    bool
        ``True`` when at least one 2D connected void component spans the
        selected axis.

    Raises
    ------
    ValueError
        If ``void_mask`` is not 2D.

    Notes
    -----
    This function exists for notebook/API compatibility and delegates to
    :func:`has_spanning_cluster` after enforcing 2D inputs.
    """

    mask = np.asarray(void_mask, dtype=bool)
    if mask.ndim != 2:
        raise ValueError("void_mask must be 2D for has_spanning_cluster_2d")
    return has_spanning_cluster(mask, axis_index=axis_index)