Skip to content

Metrics API

This module contains core functions for dose analysis, DVH computation, quality metrics, and plan comparison.

DVH Module

dvh

Dose-Volume Histogram (DVH) computation and analysis.

This module provides functions for computing DVHs and extracting DVH-based metrics such as volume at dose (VX) and dose at volume (DX).

Classes

Functions

compute_dvh
compute_dvh(dose: Dose, structure: Structure, max_dose: Optional[float] = None, step_size: float = 0.1) -> Tuple[np.ndarray, np.ndarray]

Compute dose-volume histogram for a structure.

A DVH shows the percentage of structure volume that receives at least a given dose level.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to compute DVH for

required
max_dose Optional[float]

Maximum dose for histogram bins (auto-detect if None)

None
step_size float

Bin width in Gy

0.1

Returns:

Type Description
ndarray

Tuple of (dose_bins, volume_percentages)

ndarray
  • dose_bins: Array of dose levels (Gy)
Tuple[ndarray, ndarray]
  • volume_percentages: Percentage of volume receiving >= each dose (0-100)

Examples:

>>> from dosemetrics.dose import Dose
>>> from dosemetrics.metrics import dvh
>>> 
>>> dose = Dose.from_dicom("rtdose.dcm")
>>> ptv = structures.get_structure("PTV")
>>> 
>>> dose_bins, volumes = dvh.compute_dvh(dose, ptv)
>>> 
>>> # Plot DVH
>>> import matplotlib.pyplot as plt
>>> plt.plot(dose_bins, volumes)
>>> plt.xlabel("Dose (Gy)")
>>> plt.ylabel("Volume (%)")
Source code in src/dosemetrics/metrics/dvh.py
def compute_dvh(
    dose: Dose,
    structure: Structure,
    max_dose: Optional[float] = None,
    step_size: float = 0.1,
) -> Tuple[np.ndarray, np.ndarray]:
    """
    Compute dose-volume histogram for a structure.

    A DVH shows the percentage of structure volume that receives at least
    a given dose level.

    Args:
        dose: Dose distribution object
        structure: Structure to compute DVH for
        max_dose: Maximum dose for histogram bins (auto-detect if None)
        step_size: Bin width in Gy

    Returns:
        Tuple of (dose_bins, volume_percentages)
        - dose_bins: Array of dose levels (Gy)
        - volume_percentages: Percentage of volume receiving >= each dose (0-100)

    Examples:
        >>> from dosemetrics.dose import Dose
        >>> from dosemetrics.metrics import dvh
        >>> 
        >>> dose = Dose.from_dicom("rtdose.dcm")
        >>> ptv = structures.get_structure("PTV")
        >>> 
        >>> dose_bins, volumes = dvh.compute_dvh(dose, ptv)
        >>> 
        >>> # Plot DVH
        >>> import matplotlib.pyplot as plt
        >>> plt.plot(dose_bins, volumes)
        >>> plt.xlabel("Dose (Gy)")
        >>> plt.ylabel("Volume (%)")
    """
    dose_values = dose.get_dose_in_structure(structure)

    if len(dose_values) == 0:
        bins = np.array([0.0])
        volumes = np.array([0.0])
        return bins, volumes

    if max_dose is None:
        max_dose = float(np.max(dose_values))

    bins = np.arange(0, max_dose + step_size, step_size)
    volumes = np.array([
        100.0 * np.sum(dose_values >= dose_bin) / len(dose_values)
        for dose_bin in bins
    ])

    return bins, volumes
compute_volume_at_dose
compute_volume_at_dose(dose: Dose, structure: Structure, dose_threshold: float) -> float

Compute percentage of structure receiving at least the dose threshold.

This computes VX where X is the dose threshold (e.g., V20 = % volume >= 20 Gy).

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required
dose_threshold float

Dose threshold in Gy

required

Returns:

Type Description
float

Percentage of volume (0-100) receiving >= dose_threshold

Examples:

>>> # V20: percentage of lung receiving >= 20 Gy
>>> v20 = compute_volume_at_dose(dose, lung, 20.0)
>>> print(f"V20: {v20:.1f}%")
>>> 
>>> # V5: percentage of heart receiving >= 5 Gy
>>> v5 = compute_volume_at_dose(dose, heart, 5.0)
Source code in src/dosemetrics/metrics/dvh.py
def compute_volume_at_dose(
    dose: Dose,
    structure: Structure,
    dose_threshold: float
) -> float:
    """
    Compute percentage of structure receiving at least the dose threshold.

    This computes VX where X is the dose threshold (e.g., V20 = % volume >= 20 Gy).

    Args:
        dose: Dose distribution object
        structure: Structure to analyze
        dose_threshold: Dose threshold in Gy

    Returns:
        Percentage of volume (0-100) receiving >= dose_threshold

    Examples:
        >>> # V20: percentage of lung receiving >= 20 Gy
        >>> v20 = compute_volume_at_dose(dose, lung, 20.0)
        >>> print(f"V20: {v20:.1f}%")
        >>> 
        >>> # V5: percentage of heart receiving >= 5 Gy
        >>> v5 = compute_volume_at_dose(dose, heart, 5.0)
    """
    dose_values = dose.get_dose_in_structure(structure)

    if len(dose_values) == 0:
        return 0.0

    return float(100.0 * np.sum(dose_values >= dose_threshold) / len(dose_values))
compute_dose_at_volume
compute_dose_at_volume(dose: Dose, structure: Structure, volume_percent: float) -> float

Compute dose received by a given percentage of structure volume.

This computes DX where X is the volume percentage (e.g., D95 = dose to 95% of volume).

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required
volume_percent float

Volume percentage (0-100)

required

Returns:

Type Description
float

Dose in Gy that the specified volume percentage receives

Raises:

Type Description
ValueError

If volume_percent is not in range 0-100

Examples:

>>> # D95: dose covering 95% of PTV
>>> d95 = compute_dose_at_volume(dose, ptv, 95)
>>> print(f"D95: {d95:.2f} Gy")
>>> 
>>> # D_0.1cc for OAR (requires volume in cc conversion)
>>> # For now, use percentile approximation
>>> d_max = compute_dose_at_volume(dose, brainstem, 0.1)
Source code in src/dosemetrics/metrics/dvh.py
def compute_dose_at_volume(
    dose: Dose,
    structure: Structure,
    volume_percent: float
) -> float:
    """
    Compute dose received by a given percentage of structure volume.

    This computes DX where X is the volume percentage (e.g., D95 = dose to 95% of volume).

    Args:
        dose: Dose distribution object
        structure: Structure to analyze
        volume_percent: Volume percentage (0-100)

    Returns:
        Dose in Gy that the specified volume percentage receives

    Raises:
        ValueError: If volume_percent is not in range 0-100

    Examples:
        >>> # D95: dose covering 95% of PTV
        >>> d95 = compute_dose_at_volume(dose, ptv, 95)
        >>> print(f"D95: {d95:.2f} Gy")
        >>> 
        >>> # D_0.1cc for OAR (requires volume in cc conversion)
        >>> # For now, use percentile approximation
        >>> d_max = compute_dose_at_volume(dose, brainstem, 0.1)
    """
    if not 0 <= volume_percent <= 100:
        raise ValueError(f"Volume percent must be 0-100, got {volume_percent}")

    dose_values = dose.get_dose_in_structure(structure)

    if len(dose_values) == 0:
        return 0.0

    # DX means X% of volume receives AT LEAST this dose
    # This is the (100-X)th percentile of dose distribution
    percentile = 100 - volume_percent
    return float(np.percentile(dose_values, percentile))
compute_dose_at_volume_cc
compute_dose_at_volume_cc(dose: Dose, structure: Structure, volume_cc: float) -> float

Compute dose received by a given absolute volume in cc.

This computes D_Xcc (e.g., D_0.1cc = dose to hottest 0.1 cc).

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required
volume_cc float

Absolute volume in cubic centimeters

required

Returns:

Type Description
float

Dose in Gy received by the specified volume

Examples:

>>> # D_0.1cc: dose to hottest 0.1 cc (common OAR metric)
>>> d_0_1cc = compute_dose_at_volume_cc(dose, brainstem, 0.1)
>>> print(f"D_0.1cc: {d_0_1cc:.2f} Gy")
Source code in src/dosemetrics/metrics/dvh.py
def compute_dose_at_volume_cc(
    dose: Dose,
    structure: Structure,
    volume_cc: float
) -> float:
    """
    Compute dose received by a given absolute volume in cc.

    This computes D_Xcc (e.g., D_0.1cc = dose to hottest 0.1 cc).

    Args:
        dose: Dose distribution object
        structure: Structure to analyze
        volume_cc: Absolute volume in cubic centimeters

    Returns:
        Dose in Gy received by the specified volume

    Examples:
        >>> # D_0.1cc: dose to hottest 0.1 cc (common OAR metric)
        >>> d_0_1cc = compute_dose_at_volume_cc(dose, brainstem, 0.1)
        >>> print(f"D_0.1cc: {d_0_1cc:.2f} Gy")
    """
    dose_values = dose.get_dose_in_structure(structure)

    if len(dose_values) == 0:
        return 0.0

    # Convert cc to number of voxels
    voxel_volume_cc = np.prod(structure.spacing) / 1000.0  # mm³ to cc
    num_voxels = int(np.round(volume_cc / voxel_volume_cc))

    if num_voxels >= len(dose_values):
        # Requested volume exceeds structure volume
        return float(np.min(dose_values))

    if num_voxels <= 0:
        return float(np.max(dose_values))

    # Sort dose values in descending order and take the dose at num_voxels
    sorted_doses = np.sort(dose_values)[::-1]
    return float(sorted_doses[num_voxels - 1])
compute_equivalent_uniform_dose
compute_equivalent_uniform_dose(dose: Dose, structure: Structure, a_parameter: float) -> float

Compute Equivalent Uniform Dose (EUD).

EUD = (mean(D_i^a))^(1/a)

The a-parameter depends on tissue type: - a < 0 for tumors (emphasizes cold spots) - a > 0 for normal tissues (emphasizes hot spots)

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required
a_parameter float

Tissue-specific parameter

required

Returns:

Type Description
float

Equivalent uniform dose in Gy

References

Niemierko, Med Phys 1997

Examples:

>>> # For tumor (emphasize underdosage)
>>> eud_tumor = compute_equivalent_uniform_dose(dose, ptv, a_parameter=-10)
>>> 
>>> # For OAR (emphasize overdosage)
>>> eud_oar = compute_equivalent_uniform_dose(dose, brainstem, a_parameter=5)
Source code in src/dosemetrics/metrics/dvh.py
def compute_equivalent_uniform_dose(
    dose: Dose,
    structure: Structure,
    a_parameter: float
) -> float:
    """
    Compute Equivalent Uniform Dose (EUD).

    EUD = (mean(D_i^a))^(1/a)

    The a-parameter depends on tissue type:
    - a < 0 for tumors (emphasizes cold spots)
    - a > 0 for normal tissues (emphasizes hot spots)

    Args:
        dose: Dose distribution object
        structure: Structure to analyze
        a_parameter: Tissue-specific parameter

    Returns:
        Equivalent uniform dose in Gy

    References:
        Niemierko, Med Phys 1997

    Examples:
        >>> # For tumor (emphasize underdosage)
        >>> eud_tumor = compute_equivalent_uniform_dose(dose, ptv, a_parameter=-10)
        >>> 
        >>> # For OAR (emphasize overdosage)
        >>> eud_oar = compute_equivalent_uniform_dose(dose, brainstem, a_parameter=5)
    """
    dose_values = dose.get_dose_in_structure(structure)

    if len(dose_values) == 0:
        return 0.0

    if a_parameter == 0:
        # Limit case: geometric mean
        return float(np.exp(np.mean(np.log(dose_values + 1e-10))))

    powered_doses = np.power(dose_values, a_parameter)
    mean_powered = np.mean(powered_doses)
    eud = np.power(mean_powered, 1.0 / a_parameter)

    return float(eud)
create_dvh_table
create_dvh_table(dose: Dose, structure_set: StructureSet, structure_names: Optional[list] = None, max_dose: Optional[float] = None, step_size: float = 0.1) -> pd.DataFrame

Create DVH table for multiple structures in long format.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure_set StructureSet

StructureSet containing structures

required
structure_names Optional[list]

List of structure names to include (optional)

None
max_dose Optional[float]

Maximum dose for bins

None
step_size float

Dose bin width in Gy

0.1

Returns:

Type Description
DataFrame

DataFrame with columns [Dose, Structure, Volume]

Examples:

>>> dvh_df = create_dvh_table(dose, structures, 
...                           structure_names=["PTV", "Brainstem", "SpinalCord"])
>>> dvh_df.to_csv("dvh_data.csv")
Source code in src/dosemetrics/metrics/dvh.py
def create_dvh_table(
    dose: Dose,
    structure_set: StructureSet,
    structure_names: Optional[list] = None,
    max_dose: Optional[float] = None,
    step_size: float = 0.1
) -> pd.DataFrame:
    """
    Create DVH table for multiple structures in long format.

    Args:
        dose: Dose distribution object
        structure_set: StructureSet containing structures
        structure_names: List of structure names to include (optional)
        max_dose: Maximum dose for bins
        step_size: Dose bin width in Gy

    Returns:
        DataFrame with columns [Dose, Structure, Volume]

    Examples:
        >>> dvh_df = create_dvh_table(dose, structures, 
        ...                           structure_names=["PTV", "Brainstem", "SpinalCord"])
        >>> dvh_df.to_csv("dvh_data.csv")
    """
    if structure_names is None:
        structure_names = structure_set.structure_names

    dvh_data = []

    for name in structure_names:
        try:
            structure = structure_set.get_structure(name)
            dose_bins, volumes = compute_dvh(dose, structure, max_dose, step_size)

            for dose_val, vol_val in zip(dose_bins, volumes):
                dvh_data.append({
                    'Dose': dose_val,
                    'Structure': name,
                    'Volume': vol_val
                })
        except ValueError:
            # Structure not found
            continue

    return pd.DataFrame(dvh_data)
extract_dvh_metrics
extract_dvh_metrics(dose: Dose, structure: Structure, dose_thresholds: Optional[list] = None, volume_percentages: Optional[list] = None) -> Dict[str, float]

Extract common DVH metrics for a structure.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required
dose_thresholds Optional[list]

List of dose levels for VX metrics (Gy)

None
volume_percentages Optional[list]

List of volume percentages for DX metrics

None

Returns:

Type Description
Dict[str, float]

Dictionary with DVH metrics

Examples:

>>> metrics = extract_dvh_metrics(
...     dose, ptv,
...     dose_thresholds=[20, 40, 60],
...     volume_percentages=[2, 50, 95, 98]
... )
>>> print(metrics)
{'V20': 98.5, 'V40': 97.2, 'V60': 95.8, 'D2': 63.5, 'D50': 60.2, ...}
Source code in src/dosemetrics/metrics/dvh.py
def extract_dvh_metrics(
    dose: Dose,
    structure: Structure,
    dose_thresholds: Optional[list] = None,
    volume_percentages: Optional[list] = None
) -> Dict[str, float]:
    """
    Extract common DVH metrics for a structure.

    Args:
        dose: Dose distribution object
        structure: Structure to analyze
        dose_thresholds: List of dose levels for VX metrics (Gy)
        volume_percentages: List of volume percentages for DX metrics

    Returns:
        Dictionary with DVH metrics

    Examples:
        >>> metrics = extract_dvh_metrics(
        ...     dose, ptv,
        ...     dose_thresholds=[20, 40, 60],
        ...     volume_percentages=[2, 50, 95, 98]
        ... )
        >>> print(metrics)
        {'V20': 98.5, 'V40': 97.2, 'V60': 95.8, 'D2': 63.5, 'D50': 60.2, ...}
    """
    metrics = {}

    # Volume at dose metrics (VX)
    if dose_thresholds:
        for threshold in dose_thresholds:
            v_x = compute_volume_at_dose(dose, structure, threshold)
            metrics[f'V{threshold}'] = v_x

    # Dose at volume metrics (DX)
    if volume_percentages:
        for vol_pct in volume_percentages:
            d_x = compute_dose_at_volume(dose, structure, vol_pct)
            metrics[f'D{vol_pct}'] = d_x

    return metrics
compute_dose_statistics
compute_dose_statistics(dose: Dose, structure: Structure) -> Dict[str, float]

Compute comprehensive dose statistics for a structure.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required

Returns:

Type Description
Dict[str, float]

Dictionary with statistics including:

Dict[str, float]
  • mean_dose, max_dose, min_dose, median_dose, std_dose
Dict[str, float]
  • D95, D50, D05, D02, D98 (dose percentiles)

Examples:

>>> from dosemetrics.dose import Dose
>>> from dosemetrics.structure_set import StructureSet
>>> from dosemetrics.metrics import dvh
>>> 
>>> dose = Dose.from_dicom("rtdose.dcm")
>>> structures = StructureSet(...)
>>> ptv = structures.get_structure("PTV")
>>> 
>>> stats = dvh.compute_dose_statistics(dose, ptv)
>>> print(f"Mean dose: {stats['mean_dose']:.2f} Gy")
>>> print(f"D95: {stats['D95']:.2f} Gy")
Source code in src/dosemetrics/metrics/dvh.py
def compute_dose_statistics(dose: Dose, structure: Structure) -> Dict[str, float]:
    """
    Compute comprehensive dose statistics for a structure.

    Args:
        dose: Dose distribution object
        structure: Structure to analyze

    Returns:
        Dictionary with statistics including:
        - mean_dose, max_dose, min_dose, median_dose, std_dose
        - D95, D50, D05, D02, D98 (dose percentiles)

    Examples:
        >>> from dosemetrics.dose import Dose
        >>> from dosemetrics.structure_set import StructureSet
        >>> from dosemetrics.metrics import dvh
        >>> 
        >>> dose = Dose.from_dicom("rtdose.dcm")
        >>> structures = StructureSet(...)
        >>> ptv = structures.get_structure("PTV")
        >>> 
        >>> stats = dvh.compute_dose_statistics(dose, ptv)
        >>> print(f"Mean dose: {stats['mean_dose']:.2f} Gy")
        >>> print(f"D95: {stats['D95']:.2f} Gy")
    """
    dose_values = dose.get_dose_in_structure(structure)

    if len(dose_values) == 0:
        return {
            'mean_dose': 0.0,
            'max_dose': 0.0,
            'min_dose': 0.0,
            'median_dose': 0.0,
            'std_dose': 0.0,
            'D95': 0.0,
            'D50': 0.0,
            'D05': 0.0,
            'D02': 0.0,
            'D98': 0.0,
        }

    return {
        'mean_dose': float(np.mean(dose_values)),
        'max_dose': float(np.max(dose_values)),
        'min_dose': float(np.min(dose_values)),
        'median_dose': float(np.median(dose_values)),
        'std_dose': float(np.std(dose_values)),
        'D95': float(np.percentile(dose_values, 5)),   # 95% receives at least this
        'D50': float(np.percentile(dose_values, 50)),
        'D05': float(np.percentile(dose_values, 95)),  # 5% receives at least this
        'D02': float(np.percentile(dose_values, 98)),  # 2% receives at least this
        'D98': float(np.percentile(dose_values, 2)),   # 98% receives at least this
    }
compute_mean_dose
compute_mean_dose(dose: Dose, structure: Structure) -> float

Compute mean dose in structure.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required

Returns:

Type Description
float

Mean dose in Gy

Source code in src/dosemetrics/metrics/dvh.py
def compute_mean_dose(dose: Dose, structure: Structure) -> float:
    """
    Compute mean dose in structure.

    Args:
        dose: Dose distribution object
        structure: Structure to analyze

    Returns:
        Mean dose in Gy
    """
    dose_values = dose.get_dose_in_structure(structure)
    return float(np.mean(dose_values)) if len(dose_values) > 0 else 0.0
compute_max_dose
compute_max_dose(dose: Dose, structure: Structure) -> float

Compute maximum dose in structure.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required

Returns:

Type Description
float

Maximum dose in Gy

Source code in src/dosemetrics/metrics/dvh.py
def compute_max_dose(dose: Dose, structure: Structure) -> float:
    """
    Compute maximum dose in structure.

    Args:
        dose: Dose distribution object
        structure: Structure to analyze

    Returns:
        Maximum dose in Gy
    """
    dose_values = dose.get_dose_in_structure(structure)
    return float(np.max(dose_values)) if len(dose_values) > 0 else 0.0
compute_min_dose
compute_min_dose(dose: Dose, structure: Structure) -> float

Compute minimum dose in structure.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required

Returns:

Type Description
float

Minimum dose in Gy

Source code in src/dosemetrics/metrics/dvh.py
def compute_min_dose(dose: Dose, structure: Structure) -> float:
    """
    Compute minimum dose in structure.

    Args:
        dose: Dose distribution object
        structure: Structure to analyze

    Returns:
        Minimum dose in Gy
    """
    dose_values = dose.get_dose_in_structure(structure)
    return float(np.min(dose_values)) if len(dose_values) > 0 else 0.0
compute_median_dose
compute_median_dose(dose: Dose, structure: Structure) -> float

Compute median dose in structure.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required

Returns:

Type Description
float

Median dose in Gy

Source code in src/dosemetrics/metrics/dvh.py
def compute_median_dose(dose: Dose, structure: Structure) -> float:
    """
    Compute median dose in structure.

    Args:
        dose: Dose distribution object
        structure: Structure to analyze

    Returns:
        Median dose in Gy
    """
    dose_values = dose.get_dose_in_structure(structure)
    return float(np.median(dose_values)) if len(dose_values) > 0 else 0.0
compute_dose_percentile
compute_dose_percentile(dose: Dose, structure: Structure, percentile: float) -> float

Compute dose percentile (DX).

D95 means 95% of the volume receives at least this dose. This corresponds to the 5th percentile of the dose distribution.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
structure Structure

Structure to analyze

required
percentile float

Volume percentage (0-100). For D95, use percentile=95

required

Returns:

Type Description
float

Dose in Gy that the specified percentage of volume receives

Raises:

Type Description
ValueError

If percentile is not in range 0-100

Examples:

>>> # D95: dose received by 95% of volume
>>> d95 = compute_dose_percentile(dose, ptv, 95)
>>> 
>>> # D50: median dose
>>> d50 = compute_dose_percentile(dose, ptv, 50)
>>> 
>>> # D05: near-maximum dose (hot spot)
>>> d05 = compute_dose_percentile(dose, ptv, 5)
Source code in src/dosemetrics/metrics/dvh.py
def compute_dose_percentile(
    dose: Dose, 
    structure: Structure, 
    percentile: float
) -> float:
    """
    Compute dose percentile (DX).

    D95 means 95% of the volume receives at least this dose.
    This corresponds to the 5th percentile of the dose distribution.

    Args:
        dose: Dose distribution object
        structure: Structure to analyze
        percentile: Volume percentage (0-100). For D95, use percentile=95

    Returns:
        Dose in Gy that the specified percentage of volume receives

    Raises:
        ValueError: If percentile is not in range 0-100

    Examples:
        >>> # D95: dose received by 95% of volume
        >>> d95 = compute_dose_percentile(dose, ptv, 95)
        >>> 
        >>> # D50: median dose
        >>> d50 = compute_dose_percentile(dose, ptv, 50)
        >>> 
        >>> # D05: near-maximum dose (hot spot)
        >>> d05 = compute_dose_percentile(dose, ptv, 5)
    """
    if not 0 <= percentile <= 100:
        raise ValueError(f"Percentile must be 0-100, got {percentile}")

    dose_values = dose.get_dose_in_structure(structure)

    if len(dose_values) == 0:
        return 0.0

    # DX means X% receives AT LEAST this dose
    # This is the (100-X)th percentile of the dose array
    return float(np.percentile(dose_values, 100 - percentile))

Conformity Module

conformity

Conformity indices for target coverage evaluation.

This module provides various conformity indices used to evaluate how well the prescription isodose conforms to the target volume. These metrics are critical for assessing treatment plan quality.

Classes

Functions

compute_conformity_index
compute_conformity_index(dose: Dose, target: Structure, prescription_dose: float) -> float

Compute Conformity Index (CI).

CI = V_target_rx / V_rx

Where: - V_target_rx = volume of target receiving >= prescription dose - V_rx = total volume receiving >= prescription dose

Measures how well the prescription isodose conforms to the target. Ideal value is 1.0. Values < 1.0 indicate dose spillage outside target.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure (PTV, CTV, etc.)

required
prescription_dose float

Prescription dose in Gy

required

Returns:

Type Description
float

Conformity index (dimensionless, typically 0-1)

References

ICRU Report 62 (1999)

Examples:

>>> ci = compute_conformity_index(dose, ptv, prescription_dose=60.0)
>>> print(f"Conformity Index: {ci:.3f}")
Source code in src/dosemetrics/metrics/conformity.py
def compute_conformity_index(
    dose: Dose, 
    target: Structure, 
    prescription_dose: float
) -> float:
    """
    Compute Conformity Index (CI).

    CI = V_target_rx / V_rx

    Where:
    - V_target_rx = volume of target receiving >= prescription dose
    - V_rx = total volume receiving >= prescription dose

    Measures how well the prescription isodose conforms to the target.
    Ideal value is 1.0. Values < 1.0 indicate dose spillage outside target.

    Args:
        dose: Dose distribution object
        target: Target structure (PTV, CTV, etc.)
        prescription_dose: Prescription dose in Gy

    Returns:
        Conformity index (dimensionless, typically 0-1)

    References:
        ICRU Report 62 (1999)

    Examples:
        >>> ci = compute_conformity_index(dose, ptv, prescription_dose=60.0)
        >>> print(f"Conformity Index: {ci:.3f}")
    """
    # Volume of target receiving >= prescription dose
    target_dose_values = dose.get_dose_in_structure(target)
    v_target_rx = np.sum(target_dose_values >= prescription_dose)

    # Total volume receiving >= prescription dose
    v_rx = np.sum(dose.dose_array >= prescription_dose)

    if v_rx == 0:
        return 0.0

    return float(v_target_rx / v_rx)
compute_conformity_number
compute_conformity_number(dose: Dose, target: Structure, prescription_dose: float) -> float

Compute Conformity Number (CN) or Conformation Number.

CN = (V_target_rx / V_target) * (V_target_rx / V_rx)

Combines target coverage and dose spillage into a single metric. Ideal value is 1.0.

The first factor (V_target_rx / V_target) represents target coverage. The second factor (V_target_rx / V_rx) represents conformity.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure

required
prescription_dose float

Prescription dose in Gy

required

Returns:

Type Description
float

Conformity number (0-1)

References

van't Riet et al., Int J Radiat Oncol Biol Phys 1997

Examples:

>>> cn = compute_conformity_number(dose, ptv, prescription_dose=60.0)
>>> print(f"Conformity Number: {cn:.3f}")
Source code in src/dosemetrics/metrics/conformity.py
def compute_conformity_number(
    dose: Dose,
    target: Structure,
    prescription_dose: float
) -> float:
    """
    Compute Conformity Number (CN) or Conformation Number.

    CN = (V_target_rx / V_target) * (V_target_rx / V_rx)

    Combines target coverage and dose spillage into a single metric.
    Ideal value is 1.0.

    The first factor (V_target_rx / V_target) represents target coverage.
    The second factor (V_target_rx / V_rx) represents conformity.

    Args:
        dose: Dose distribution object
        target: Target structure
        prescription_dose: Prescription dose in Gy

    Returns:
        Conformity number (0-1)

    References:
        van't Riet et al., Int J Radiat Oncol Biol Phys 1997

    Examples:
        >>> cn = compute_conformity_number(dose, ptv, prescription_dose=60.0)
        >>> print(f"Conformity Number: {cn:.3f}")
    """
    target_dose_values = dose.get_dose_in_structure(target)

    v_target = len(target_dose_values)
    if v_target == 0:
        return 0.0

    v_target_rx = np.sum(target_dose_values >= prescription_dose)
    v_rx = np.sum(dose.dose_array >= prescription_dose)

    if v_rx == 0:
        return 0.0

    coverage = v_target_rx / v_target
    conformity = v_target_rx / v_rx

    return float(coverage * conformity)
compute_paddick_conformity_index
compute_paddick_conformity_index(dose: Dose, target: Structure, prescription_dose: float) -> float

Compute Paddick Conformity Index (CI_Paddick).

CI_Paddick = (V_target_rx)^2 / (V_target * V_rx)

This index is commonly used for radiosurgery and SBRT plans. Ideal value is 1.0.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure

required
prescription_dose float

Prescription dose in Gy

required

Returns:

Type Description
float

Paddick conformity index (0-1)

References

Paddick, J Neurosurg 2000

Examples:

>>> # Often used for stereotactic radiosurgery
>>> ci_paddick = compute_paddick_conformity_index(dose, gtv, prescription_dose=18.0)
>>> print(f"Paddick CI: {ci_paddick:.3f}")
Source code in src/dosemetrics/metrics/conformity.py
def compute_paddick_conformity_index(
    dose: Dose,
    target: Structure,
    prescription_dose: float
) -> float:
    """
    Compute Paddick Conformity Index (CI_Paddick).

    CI_Paddick = (V_target_rx)^2 / (V_target * V_rx)

    This index is commonly used for radiosurgery and SBRT plans.
    Ideal value is 1.0.

    Args:
        dose: Dose distribution object
        target: Target structure
        prescription_dose: Prescription dose in Gy

    Returns:
        Paddick conformity index (0-1)

    References:
        Paddick, J Neurosurg 2000

    Examples:
        >>> # Often used for stereotactic radiosurgery
        >>> ci_paddick = compute_paddick_conformity_index(dose, gtv, prescription_dose=18.0)
        >>> print(f"Paddick CI: {ci_paddick:.3f}")
    """
    target_dose_values = dose.get_dose_in_structure(target)

    v_target = len(target_dose_values)
    if v_target == 0:
        return 0.0

    v_target_rx = np.sum(target_dose_values >= prescription_dose)
    v_rx = np.sum(dose.dose_array >= prescription_dose)

    if v_rx == 0 or v_target == 0:
        return 0.0

    return float((v_target_rx ** 2) / (v_target * v_rx))
compute_coverage
compute_coverage(dose: Dose, target: Structure, prescription_dose: float) -> float

Compute target coverage.

Coverage = V_target_rx / V_target

Percentage of target volume receiving at least the prescription dose.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure

required
prescription_dose float

Prescription dose in Gy

required

Returns:

Type Description
float

Coverage as fraction (0-1) or percentage if multiplied by 100

Examples:

>>> coverage = compute_coverage(dose, ptv, prescription_dose=60.0)
>>> print(f"Target coverage: {coverage*100:.1f}%")
Source code in src/dosemetrics/metrics/conformity.py
def compute_coverage(
    dose: Dose,
    target: Structure,
    prescription_dose: float
) -> float:
    """
    Compute target coverage.

    Coverage = V_target_rx / V_target

    Percentage of target volume receiving at least the prescription dose.

    Args:
        dose: Dose distribution object
        target: Target structure
        prescription_dose: Prescription dose in Gy

    Returns:
        Coverage as fraction (0-1) or percentage if multiplied by 100

    Examples:
        >>> coverage = compute_coverage(dose, ptv, prescription_dose=60.0)
        >>> print(f"Target coverage: {coverage*100:.1f}%")
    """
    target_dose_values = dose.get_dose_in_structure(target)

    v_target = len(target_dose_values)
    if v_target == 0:
        return 0.0

    v_target_rx = np.sum(target_dose_values >= prescription_dose)

    return float(v_target_rx / v_target)
compute_spillage
compute_spillage(dose: Dose, target: Structure, prescription_dose: float) -> float

Compute dose spillage outside target.

Spillage = (V_rx - V_target_rx) / V_rx

Fraction of prescription isodose volume that is outside the target. Lower values indicate better conformity.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure

required
prescription_dose float

Prescription dose in Gy

required

Returns:

Type Description
float

Spillage as fraction (0-1)

Examples:

>>> spillage = compute_spillage(dose, ptv, prescription_dose=60.0)
>>> print(f"Dose spillage: {spillage*100:.1f}%")
Source code in src/dosemetrics/metrics/conformity.py
def compute_spillage(
    dose: Dose,
    target: Structure,
    prescription_dose: float
) -> float:
    """
    Compute dose spillage outside target.

    Spillage = (V_rx - V_target_rx) / V_rx

    Fraction of prescription isodose volume that is outside the target.
    Lower values indicate better conformity.

    Args:
        dose: Dose distribution object
        target: Target structure
        prescription_dose: Prescription dose in Gy

    Returns:
        Spillage as fraction (0-1)

    Examples:
        >>> spillage = compute_spillage(dose, ptv, prescription_dose=60.0)
        >>> print(f"Dose spillage: {spillage*100:.1f}%")
    """
    target_dose_values = dose.get_dose_in_structure(target)
    v_target_rx = np.sum(target_dose_values >= prescription_dose)
    v_rx = np.sum(dose.dose_array >= prescription_dose)

    if v_rx == 0:
        return 0.0

    return float((v_rx - v_target_rx) / v_rx)

Homogeneity Module

homogeneity

Homogeneity indices for target dose uniformity.

This module provides metrics to assess the uniformity of dose distribution within target volumes. More homogeneous dose distributions are generally preferred for tumor control.

Classes

Functions

compute_homogeneity_index
compute_homogeneity_index(dose: Dose, target: Structure, d2_percentile: float = 2.0, d98_percentile: float = 98.0) -> float

Compute Homogeneity Index (HI).

HI = (D2 - D98) / D50

Where: - D2 = dose received by 2% of volume (near-maximum) - D98 = dose received by 98% of volume (near-minimum) - D50 = median dose

Measures dose uniformity within target. Lower values indicate more homogeneous dose distribution.

Typical acceptable range: 0.05 - 0.20

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure (PTV, CTV, etc.)

required
d2_percentile float

Upper percentile for near-max (typically 2%)

2.0
d98_percentile float

Lower percentile for near-min (typically 98%)

98.0

Returns:

Type Description
float

Homogeneity index (dimensionless)

References

ICRU Report 83 (2010)

Examples:

>>> hi = compute_homogeneity_index(dose, ptv)
>>> print(f"Homogeneity Index: {hi:.3f}")
>>> if hi < 0.15:
...     print("Excellent dose homogeneity")
Source code in src/dosemetrics/metrics/homogeneity.py
def compute_homogeneity_index(
    dose: Dose,
    target: Structure,
    d2_percentile: float = 2.0,
    d98_percentile: float = 98.0
) -> float:
    """
    Compute Homogeneity Index (HI).

    HI = (D2 - D98) / D50

    Where:
    - D2 = dose received by 2% of volume (near-maximum)
    - D98 = dose received by 98% of volume (near-minimum)
    - D50 = median dose

    Measures dose uniformity within target. Lower values indicate more
    homogeneous dose distribution.

    Typical acceptable range: 0.05 - 0.20

    Args:
        dose: Dose distribution object
        target: Target structure (PTV, CTV, etc.)
        d2_percentile: Upper percentile for near-max (typically 2%)
        d98_percentile: Lower percentile for near-min (typically 98%)

    Returns:
        Homogeneity index (dimensionless)

    References:
        ICRU Report 83 (2010)

    Examples:
        >>> hi = compute_homogeneity_index(dose, ptv)
        >>> print(f"Homogeneity Index: {hi:.3f}")
        >>> if hi < 0.15:
        ...     print("Excellent dose homogeneity")
    """
    dose_values = dose.get_dose_in_structure(target)

    if len(dose_values) == 0:
        return 0.0

    # Note: D2 means 2% of volume receives at least this dose
    # This corresponds to 98th percentile of dose array
    d2 = np.percentile(dose_values, 100 - d2_percentile)
    d98 = np.percentile(dose_values, 100 - d98_percentile)
    d50 = np.percentile(dose_values, 50)

    if d50 == 0:
        return float('inf')

    return float((d2 - d98) / d50)
compute_gradient_index
compute_gradient_index(dose: Dose, target: Structure, prescription_dose: float, half_prescription_volume_method: bool = True) -> float

Compute Gradient Index (GI) for dose fall-off outside target.

Two calculation methods: 1. Half-prescription volume: GI = V_50% / V_100% 2. Distance-based: Ratio of volumes at specific distances

Where: - V_100% = volume receiving >= prescription dose - V_50% = volume receiving >= 50% prescription dose

Lower values indicate steeper dose fall-off (better for sparing OARs).

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure

required
prescription_dose float

Prescription dose in Gy

required
half_prescription_volume_method bool

Use V_50%/V_100% method (default True)

True

Returns:

Type Description
float

Gradient index (dimensionless, typically 2-8)

References

Paddick and Lippitz, J Neurosurg 2006

Examples:

>>> gi = compute_gradient_index(dose, ptv, prescription_dose=60.0)
>>> print(f"Gradient Index: {gi:.2f}")
>>> if gi < 3.0:
...     print("Excellent dose fall-off")
Source code in src/dosemetrics/metrics/homogeneity.py
def compute_gradient_index(
    dose: Dose,
    target: Structure,
    prescription_dose: float,
    half_prescription_volume_method: bool = True
) -> float:
    """
    Compute Gradient Index (GI) for dose fall-off outside target.

    Two calculation methods:
    1. Half-prescription volume: GI = V_50% / V_100%
    2. Distance-based: Ratio of volumes at specific distances

    Where:
    - V_100% = volume receiving >= prescription dose
    - V_50% = volume receiving >= 50% prescription dose

    Lower values indicate steeper dose fall-off (better for sparing OARs).

    Args:
        dose: Dose distribution object
        target: Target structure
        prescription_dose: Prescription dose in Gy
        half_prescription_volume_method: Use V_50%/V_100% method (default True)

    Returns:
        Gradient index (dimensionless, typically 2-8)

    References:
        Paddick and Lippitz, J Neurosurg 2006

    Examples:
        >>> gi = compute_gradient_index(dose, ptv, prescription_dose=60.0)
        >>> print(f"Gradient Index: {gi:.2f}")
        >>> if gi < 3.0:
        ...     print("Excellent dose fall-off")
    """
    v_100 = np.sum(dose.dose_array >= prescription_dose)
    v_50 = np.sum(dose.dose_array >= 0.5 * prescription_dose)

    if v_100 == 0:
        return float('inf')

    return float(v_50 / v_100)
compute_dose_homogeneity
compute_dose_homogeneity(dose: Dose, target: Structure) -> float

Compute coefficient of variation (CV) of dose within target.

CV = std_dose / mean_dose

Alternative measure of dose homogeneity. Lower values indicate more uniform dose distribution.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure

required

Returns:

Type Description
float

Coefficient of variation (dimensionless)

Examples:

>>> cv = compute_dose_homogeneity(dose, ptv)
>>> print(f"Dose CV: {cv:.3f}")
Source code in src/dosemetrics/metrics/homogeneity.py
def compute_dose_homogeneity(
    dose: Dose,
    target: Structure
) -> float:
    """
    Compute coefficient of variation (CV) of dose within target.

    CV = std_dose / mean_dose

    Alternative measure of dose homogeneity. Lower values indicate
    more uniform dose distribution.

    Args:
        dose: Dose distribution object
        target: Target structure

    Returns:
        Coefficient of variation (dimensionless)

    Examples:
        >>> cv = compute_dose_homogeneity(dose, ptv)
        >>> print(f"Dose CV: {cv:.3f}")
    """
    dose_values = dose.get_dose_in_structure(target)

    if len(dose_values) == 0:
        return 0.0

    mean = np.mean(dose_values)
    if mean == 0:
        return float('inf')

    std = np.std(dose_values)
    return float(std / mean)
compute_uniformity_index
compute_uniformity_index(dose: Dose, target: Structure) -> float

Compute uniformity index.

UI = 1 - (D_max - D_min) / D_prescription

Values closer to 1.0 indicate better uniformity.

Parameters:

Name Type Description Default
dose Dose

Dose distribution object

required
target Structure

Target structure

required

Returns:

Type Description
float

Uniformity index (0-1)

Note

Requires prescription dose in target metadata or as parameter. Currently uses median dose as approximation.

Examples:

>>> ui = compute_uniformity_index(dose, ptv)
>>> print(f"Uniformity Index: {ui:.3f}")
Source code in src/dosemetrics/metrics/homogeneity.py
def compute_uniformity_index(
    dose: Dose,
    target: Structure
) -> float:
    """
    Compute uniformity index.

    UI = 1 - (D_max - D_min) / D_prescription

    Values closer to 1.0 indicate better uniformity.

    Args:
        dose: Dose distribution object
        target: Target structure

    Returns:
        Uniformity index (0-1)

    Note:
        Requires prescription dose in target metadata or as parameter.
        Currently uses median dose as approximation.

    Examples:
        >>> ui = compute_uniformity_index(dose, ptv)
        >>> print(f"Uniformity Index: {ui:.3f}")
    """
    dose_values = dose.get_dose_in_structure(target)

    if len(dose_values) == 0:
        return 0.0

    d_max = np.max(dose_values)
    d_min = np.min(dose_values)
    d_ref = np.median(dose_values)  # Use median as reference

    if d_ref == 0:
        return 0.0

    return float(1.0 - (d_max - d_min) / d_ref)

Geometric Module

geometric

Geometric similarity and overlap metrics for structure comparison.

This module provides metrics to compare two structure sets, typically used for evaluating auto-segmentation algorithms or inter-observer variability.

Classes

Functions

compute_dice_coefficient
compute_dice_coefficient(structure1: Structure, structure2: Structure) -> float

Compute Dice coefficient (Sørensen-Dice index).

Dice = 2 * |A ∩ B| / (|A| + |B|)

Measures overlap between two structures. Range [0, 1], where 1 is perfect overlap.

Parameters:

Name Type Description Default
structure1 Structure

First structure

required
structure2 Structure

Second structure

required

Returns:

Type Description
float

Dice coefficient (0-1)

References

Dice, Ecology 1945; Sørensen, Biologiske Skrifter 1948

Examples:

>>> auto_ptv = structures_auto.get_structure("PTV")
>>> manual_ptv = structures_manual.get_structure("PTV")
>>> dice = compute_dice_coefficient(auto_ptv, manual_ptv)
>>> print(f"Dice: {dice:.3f}")
Source code in src/dosemetrics/metrics/geometric.py
def compute_dice_coefficient(structure1: Structure, structure2: Structure) -> float:
    """
    Compute Dice coefficient (Sørensen-Dice index).

    Dice = 2 * |A ∩ B| / (|A| + |B|)

    Measures overlap between two structures. Range [0, 1], where 1 is perfect overlap.

    Args:
        structure1: First structure
        structure2: Second structure

    Returns:
        Dice coefficient (0-1)

    References:
        Dice, Ecology 1945; Sørensen, Biologiske Skrifter 1948

    Examples:
        >>> auto_ptv = structures_auto.get_structure("PTV")
        >>> manual_ptv = structures_manual.get_structure("PTV")
        >>> dice = compute_dice_coefficient(auto_ptv, manual_ptv)
        >>> print(f"Dice: {dice:.3f}")
    """
    if structure1.mask is None or structure2.mask is None:
        return 0.0

    intersection = np.logical_and(structure1.mask, structure2.mask)
    sum_volumes = structure1.volume_voxels() + structure2.volume_voxels()

    if sum_volumes == 0:
        return 0.0

    return float(2.0 * np.sum(intersection) / sum_volumes)
compute_jaccard_index
compute_jaccard_index(structure1: Structure, structure2: Structure) -> float

Compute Jaccard index (Intersection over Union, IoU).

Jaccard = |A ∩ B| / |A ∪ B|

Measures overlap between two structures. Range [0, 1], where 1 is perfect overlap. More conservative than Dice coefficient.

Parameters:

Name Type Description Default
structure1 Structure

First structure

required
structure2 Structure

Second structure

required

Returns:

Type Description
float

Jaccard index (0-1)

References

Jaccard, New Phytologist 1912

Examples:

>>> jaccard = compute_jaccard_index(auto_ptv, manual_ptv)
>>> print(f"IoU: {jaccard:.3f}")
Source code in src/dosemetrics/metrics/geometric.py
def compute_jaccard_index(structure1: Structure, structure2: Structure) -> float:
    """
    Compute Jaccard index (Intersection over Union, IoU).

    Jaccard = |A ∩ B| / |A ∪ B|

    Measures overlap between two structures. Range [0, 1], where 1 is perfect overlap.
    More conservative than Dice coefficient.

    Args:
        structure1: First structure
        structure2: Second structure

    Returns:
        Jaccard index (0-1)

    References:
        Jaccard, New Phytologist 1912

    Examples:
        >>> jaccard = compute_jaccard_index(auto_ptv, manual_ptv)
        >>> print(f"IoU: {jaccard:.3f}")
    """
    if structure1.mask is None or structure2.mask is None:
        return 0.0

    intersection = np.logical_and(structure1.mask, structure2.mask)
    union = np.logical_or(structure1.mask, structure2.mask)

    union_sum = np.sum(union)
    if union_sum == 0:
        return 0.0

    return float(np.sum(intersection) / union_sum)
compute_volume_difference
compute_volume_difference(structure1: Structure, structure2: Structure) -> float

Compute absolute volume difference.

Parameters:

Name Type Description Default
structure1 Structure

First structure

required
structure2 Structure

Second structure

required

Returns:

Type Description
float

Absolute volume difference in cubic centimeters

Examples:

>>> vol_diff = compute_volume_difference(auto_ptv, manual_ptv)
>>> print(f"Volume difference: {vol_diff:.2f} cc")
Source code in src/dosemetrics/metrics/geometric.py
def compute_volume_difference(structure1: Structure, structure2: Structure) -> float:
    """
    Compute absolute volume difference.

    Args:
        structure1: First structure
        structure2: Second structure

    Returns:
        Absolute volume difference in cubic centimeters

    Examples:
        >>> vol_diff = compute_volume_difference(auto_ptv, manual_ptv)
        >>> print(f"Volume difference: {vol_diff:.2f} cc")
    """
    return abs(structure1.volume_cc() - structure2.volume_cc())
compute_volume_ratio
compute_volume_ratio(structure1: Structure, structure2: Structure) -> float

Compute volume ratio V1/V2.

Parameters:

Name Type Description Default
structure1 Structure

First structure (numerator)

required
structure2 Structure

Second structure (denominator)

required

Returns:

Type Description
float

Volume ratio (dimensionless)

Examples:

>>> ratio = compute_volume_ratio(auto_ptv, manual_ptv)
>>> print(f"Volume ratio: {ratio:.3f}")
Source code in src/dosemetrics/metrics/geometric.py
def compute_volume_ratio(structure1: Structure, structure2: Structure) -> float:
    """
    Compute volume ratio V1/V2.

    Args:
        structure1: First structure (numerator)
        structure2: Second structure (denominator)

    Returns:
        Volume ratio (dimensionless)

    Examples:
        >>> ratio = compute_volume_ratio(auto_ptv, manual_ptv)
        >>> print(f"Volume ratio: {ratio:.3f}")
    """
    v2 = structure2.volume_cc()
    if v2 == 0:
        return float('inf') if structure1.volume_cc() > 0 else 1.0

    return structure1.volume_cc() / v2
compute_sensitivity
compute_sensitivity(structure1: Structure, structure2: Structure) -> float

Compute sensitivity (recall, true positive rate).

Sensitivity = TP / (TP + FN) = |A ∩ B| / |B|

Measures how much of structure2 is covered by structure1.

Parameters:

Name Type Description Default
structure1 Structure

Predicted/test structure

required
structure2 Structure

Reference/ground truth structure

required

Returns:

Type Description
float

Sensitivity (0-1)

Examples:

>>> sens = compute_sensitivity(auto_structure, manual_structure)
>>> print(f"Sensitivity: {sens:.3f}")
Source code in src/dosemetrics/metrics/geometric.py
def compute_sensitivity(structure1: Structure, structure2: Structure) -> float:
    """
    Compute sensitivity (recall, true positive rate).

    Sensitivity = TP / (TP + FN) = |A ∩ B| / |B|

    Measures how much of structure2 is covered by structure1.

    Args:
        structure1: Predicted/test structure
        structure2: Reference/ground truth structure

    Returns:
        Sensitivity (0-1)

    Examples:
        >>> sens = compute_sensitivity(auto_structure, manual_structure)
        >>> print(f"Sensitivity: {sens:.3f}")
    """
    if structure1.mask is None or structure2.mask is None:
        return 0.0

    intersection = np.logical_and(structure1.mask, structure2.mask)
    v2 = structure2.volume_voxels()

    if v2 == 0:
        return 0.0

    return float(np.sum(intersection) / v2)
compute_specificity
compute_specificity(structure1: Structure, structure2: Structure, background_mask: Optional[ndarray] = None) -> float

Compute specificity (true negative rate).

Specificity = TN / (TN + FP)

Requires definition of background/universe. If not provided, uses the bounding box union of both structures.

Parameters:

Name Type Description Default
structure1 Structure

Predicted/test structure

required
structure2 Structure

Reference/ground truth structure

required
background_mask Optional[ndarray]

Mask defining the universe (optional)

None

Returns:

Type Description
float

Specificity (0-1)

Examples:

>>> spec = compute_specificity(auto_structure, manual_structure)
>>> print(f"Specificity: {spec:.3f}")
Source code in src/dosemetrics/metrics/geometric.py
def compute_specificity(
    structure1: Structure, 
    structure2: Structure,
    background_mask: Optional[np.ndarray] = None
) -> float:
    """
    Compute specificity (true negative rate).

    Specificity = TN / (TN + FP)

    Requires definition of background/universe. If not provided, uses
    the bounding box union of both structures.

    Args:
        structure1: Predicted/test structure
        structure2: Reference/ground truth structure
        background_mask: Mask defining the universe (optional)

    Returns:
        Specificity (0-1)

    Examples:
        >>> spec = compute_specificity(auto_structure, manual_structure)
        >>> print(f"Specificity: {spec:.3f}")
    """
    if structure1.mask is None or structure2.mask is None:
        return 0.0

    # True negatives: voxels outside both structures
    # False positives: in structure1 but not in structure2
    not_s1 = ~structure1.mask
    not_s2 = ~structure2.mask

    true_negatives = np.logical_and(not_s1, not_s2)
    false_positives = np.logical_and(structure1.mask, not_s2)

    denominator = np.sum(true_negatives) + np.sum(false_positives)

    if denominator == 0:
        return 0.0

    return float(np.sum(true_negatives) / denominator)
compute_hausdorff_distance
compute_hausdorff_distance(structure1: Structure, structure2: Structure, percentile: Optional[float] = None) -> float

Compute Hausdorff distance between two structures.

If percentile is specified, computes the percentile Hausdorff distance (e.g., 95th percentile HD95), which is more robust to outliers.

Parameters:

Name Type Description Default
structure1 Structure

First structure

required
structure2 Structure

Second structure

required
percentile Optional[float]

If specified, compute percentile HD (e.g., 95 for HD95)

None

Returns:

Type Description
float

Hausdorff distance in mm

Examples:

>>> hd = compute_hausdorff_distance(auto_structure, manual_structure)
>>> hd95 = compute_hausdorff_distance(auto_structure, manual_structure, percentile=95)
Source code in src/dosemetrics/metrics/geometric.py
def compute_hausdorff_distance(
    structure1: Structure,
    structure2: Structure,
    percentile: Optional[float] = None
) -> float:
    """
    Compute Hausdorff distance between two structures.

    If percentile is specified, computes the percentile Hausdorff distance
    (e.g., 95th percentile HD95), which is more robust to outliers.

    Args:
        structure1: First structure
        structure2: Second structure
        percentile: If specified, compute percentile HD (e.g., 95 for HD95)

    Returns:
        Hausdorff distance in mm

    Examples:
        >>> hd = compute_hausdorff_distance(auto_structure, manual_structure)
        >>> hd95 = compute_hausdorff_distance(auto_structure, manual_structure, percentile=95)
    """
    # Validate percentile
    if percentile is not None:
        if not (0 < percentile <= 100):
            raise ValueError(f"Percentile must be between 0 and 100, got {percentile}")

    if structure1.mask is None or structure2.mask is None:
        return float('inf')

    # Get surface points (boundary voxels)
    # Use binary erosion to get boundary
    eroded1 = ndimage.binary_erosion(structure1.mask)
    eroded2 = ndimage.binary_erosion(structure2.mask)
    surface1 = structure1.mask & ~eroded1
    surface2 = structure2.mask & ~eroded2

    # Get coordinates of surface points
    points1 = np.argwhere(surface1)
    points2 = np.argwhere(surface2)

    if len(points1) == 0 or len(points2) == 0:
        return float('inf')

    # Scale by voxel spacing to get mm
    spacing = np.array(structure1.spacing)
    points1_mm = points1 * spacing
    points2_mm = points2 * spacing

    if percentile is not None:
        # Compute percentile Hausdorff distance
        # Calculate distances from points1 to points2
        from scipy.spatial.distance import cdist
        distances = cdist(points1_mm, points2_mm)

        # For each point in set 1, find min distance to set 2
        min_distances_1_to_2 = np.min(distances, axis=1)
        # For each point in set 2, find min distance to set 1
        min_distances_2_to_1 = np.min(distances, axis=0)

        # Compute percentile
        hd_1_to_2 = np.percentile(min_distances_1_to_2, percentile)
        hd_2_to_1 = np.percentile(min_distances_2_to_1, percentile)

        return float(max(hd_1_to_2, hd_2_to_1))
    else:
        # Standard Hausdorff distance
        hd_1_to_2, _, _ = directed_hausdorff(points1_mm, points2_mm)
        hd_2_to_1, _, _ = directed_hausdorff(points2_mm, points1_mm)

        return float(max(hd_1_to_2, hd_2_to_1))
compute_mean_surface_distance
compute_mean_surface_distance(structure1: Structure, structure2: Structure) -> float

Compute mean surface distance between two structures.

Average of all point-to-surface distances (symmetric).

Parameters:

Name Type Description Default
structure1 Structure

First structure

required
structure2 Structure

Second structure

required

Returns:

Type Description
float

Mean surface distance in mm

Source code in src/dosemetrics/metrics/geometric.py
def compute_mean_surface_distance(
    structure1: Structure,
    structure2: Structure
) -> float:
    """
    Compute mean surface distance between two structures.

    Average of all point-to-surface distances (symmetric).

    Args:
        structure1: First structure
        structure2: Second structure

    Returns:
        Mean surface distance in mm
    """
    if structure1.mask is None or structure2.mask is None:
        return float('inf')

    # Get surface points (boundary voxels)
    eroded1 = ndimage.binary_erosion(structure1.mask)
    eroded2 = ndimage.binary_erosion(structure2.mask)
    surface1 = structure1.mask & ~eroded1
    surface2 = structure2.mask & ~eroded2

    # Get coordinates of surface points
    points1 = np.argwhere(surface1)
    points2 = np.argwhere(surface2)

    if len(points1) == 0 or len(points2) == 0:
        return float('inf')

    # Scale by voxel spacing to get mm
    spacing = np.array(structure1.spacing)
    points1_mm = points1 * spacing
    points2_mm = points2 * spacing

    # Compute pairwise distances
    from scipy.spatial.distance import cdist
    distances = cdist(points1_mm, points2_mm)

    # Mean of minimum distances from each point to other surface
    mean_1_to_2 = np.mean(np.min(distances, axis=1))
    mean_2_to_1 = np.mean(np.min(distances, axis=0))

    # Return symmetric average
    return float((mean_1_to_2 + mean_2_to_1) / 2.0)
compare_structure_sets
compare_structure_sets(structure_set1: StructureSet, structure_set2: StructureSet, structure_names: Optional[list] = None) -> pd.DataFrame

Compute geometric metrics between two structure sets.

Parameters:

Name Type Description Default
structure_set1 StructureSet

First structure set (e.g., auto-segmentation)

required
structure_set2 StructureSet

Second structure set (e.g., manual segmentation)

required
structure_names Optional[list]

List of structure names to compare (optional)

None

Returns:

Type Description
DataFrame

DataFrame with geometric metrics for each structure

Examples:

>>> auto_structures = load_structure_set("auto/")
>>> manual_structures = load_structure_set("manual/")
>>> comparison = compare_structure_sets(auto_structures, manual_structures)
>>> print(comparison)
Source code in src/dosemetrics/metrics/geometric.py
def compare_structure_sets(
    structure_set1: StructureSet,
    structure_set2: StructureSet,
    structure_names: Optional[list] = None
) -> pd.DataFrame:
    """
    Compute geometric metrics between two structure sets.

    Args:
        structure_set1: First structure set (e.g., auto-segmentation)
        structure_set2: Second structure set (e.g., manual segmentation)
        structure_names: List of structure names to compare (optional)

    Returns:
        DataFrame with geometric metrics for each structure

    Examples:
        >>> auto_structures = load_structure_set("auto/")
        >>> manual_structures = load_structure_set("manual/")
        >>> comparison = compare_structure_sets(auto_structures, manual_structures)
        >>> print(comparison)
    """
    if structure_names is None:
        # Use common structures
        names1 = set(structure_set1.structure_names)
        names2 = set(structure_set2.structure_names)
        structure_names = list(names1.intersection(names2))

    results = []

    for name in structure_names:
        try:
            struct1 = structure_set1.get_structure(name)
            struct2 = structure_set2.get_structure(name)

            dice = compute_dice_coefficient(struct1, struct2)
            jaccard = compute_jaccard_index(struct1, struct2)
            vol_diff = compute_volume_difference(struct1, struct2)
            vol_ratio = compute_volume_ratio(struct1, struct2)
            sensitivity = compute_sensitivity(struct1, struct2)

            results.append({
                'Structure': name,
                'Dice': dice,
                'Jaccard': jaccard,
                'Volume_Difference_cc': vol_diff,
                'Volume_Ratio': vol_ratio,
                'Sensitivity': sensitivity,
            })
        except ValueError:
            # Structure not found in one of the sets
            continue

    return pd.DataFrame(results)

Usage Examples

Computing a Basic DVH

from dosemetrics import Dose, Structure
from dosemetrics.metrics.dvh import compute_dvh

dose = Dose.from_nifti("dose.nii.gz")
structure = Structure.from_nifti("ptv.nii.gz", name="PTV")

dvh = compute_dvh(
    dose=dose,
    structure=structure,
    bins=1000
)

Calculating Quality Metrics

from dosemetrics import Dose, Structure
from dosemetrics.metrics.conformity import compute_conformity_index
from dosemetrics.metrics.homogeneity import compute_homogeneity_index
from dosemetrics.metrics.dvh import compute_dose_statistics

# Conformity Index
ci = compute_conformity_index(
    dose=dose,
    target=ptv,
    prescription_dose=60.0
)

# Homogeneity Index
hi = compute_homogeneity_index(
    dose=dose,
    target=ptv
)

# Dose Statistics
stats = compute_dose_statistics(
    dose=dose,
    structure=ptv
)

Comparing Dose Distributions

from dosemetrics import Dose, StructureSet
from dosemetrics.utils.comparison import compare_dose_distributions

# Compare dose distributions across multiple structures
comparison_df = compare_dose_distributions(
    dose1=dose_tps,
    dose2=dose_predicted,
    structure_set=structures,
    structure_names=["PTV", "Heart", "Lung_L"],
    output_file="comparison.pdf"
)

See Also