amithjkamath's picture
Deploy dosemetrics app - 2025-12-28 17:28:50
5d2df83
Raw
History Blame Contribute Delete
18.2 kB
"""
Command-line interface for dosemetrics.
Provides comprehensive radiotherapy dose analysis capabilities including:
- DVH computation and analysis
- Dose statistics
- Quality metrics (conformity, homogeneity)
- Geometric comparisons
- Gamma analysis
- Compliance checking
"""
import argparse
import sys
from pathlib import Path
import json
import numpy as np
import dosemetrics
from dosemetrics import Dose, StructureSet
from dosemetrics.metrics import (
dvh,
conformity,
homogeneity,
geometric,
gamma as gamma_module,
)
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Dosemetrics: Tools for radiotherapy dose analysis",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Generate DVH
dosemetrics dvh dose.nii.gz structures/
# Compute dose statistics
dosemetrics statistics dose.nii.gz structures/ --output stats.csv
# Compute conformity indices
dosemetrics conformity dose.nii.gz target.nii.gz --prescription 60
# Compute gamma analysis
dosemetrics gamma reference.nii.gz evaluated.nii.gz --criteria 3 3
# Compare two structure sets geometrically
dosemetrics geometric struct1/ struct2/ --output comparison.csv
""",
)
parser.add_argument(
"--version", action="version", version=f"dosemetrics {dosemetrics.__version__}"
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# DVH command
dvh_parser = subparsers.add_parser(
"dvh",
help="Compute dose-volume histogram",
description="Generate DVH curves for structures",
)
dvh_parser.add_argument("dose_file", help="Path to dose file (NIfTI or DICOM)")
dvh_parser.add_argument(
"structures", help="Path to structure files or directory containing structures"
)
dvh_parser.add_argument("-o", "--output", help="Output CSV file path")
dvh_parser.add_argument(
"--bins", type=int, default=1000, help="Number of dose bins (default: 1000)"
)
dvh_parser.add_argument(
"--relative",
action="store_true",
help="Output relative volumes (default: absolute)",
)
# Statistics command
stats_parser = subparsers.add_parser(
"statistics",
help="Compute dose statistics",
description="Calculate dose statistics (mean, max, min, etc.) for structures",
)
stats_parser.add_argument("dose_file", help="Path to dose file")
stats_parser.add_argument("structures", help="Path to structures directory")
stats_parser.add_argument("-o", "--output", help="Output CSV file path")
# Conformity command
conformity_parser = subparsers.add_parser(
"conformity",
help="Compute conformity indices",
description="Calculate conformity indices (CI, CN, GI) for target volumes",
)
conformity_parser.add_argument("dose_file", help="Path to dose file")
conformity_parser.add_argument("target_file", help="Path to target structure file")
conformity_parser.add_argument(
"--prescription", type=float, required=True, help="Prescription dose in Gy"
)
conformity_parser.add_argument("-o", "--output", help="Output JSON file path")
# Homogeneity command
homogeneity_parser = subparsers.add_parser(
"homogeneity",
help="Compute homogeneity indices",
description="Calculate homogeneity indices (HI) for target volumes",
)
homogeneity_parser.add_argument("dose_file", help="Path to dose file")
homogeneity_parser.add_argument("target_file", help="Path to target structure file")
homogeneity_parser.add_argument(
"--prescription", type=float, required=True, help="Prescription dose in Gy"
)
homogeneity_parser.add_argument("-o", "--output", help="Output JSON file path")
# Geometric command
geometric_parser = subparsers.add_parser(
"geometric",
help="Compute geometric comparisons",
description="Compare two structure sets geometrically (Dice, Jaccard, Hausdorff, etc.)",
)
geometric_parser.add_argument(
"structures1", help="Path to first structure set directory"
)
geometric_parser.add_argument(
"structures2", help="Path to second structure set directory"
)
geometric_parser.add_argument("-o", "--output", help="Output CSV file path")
# Gamma command
gamma_parser = subparsers.add_parser(
"gamma",
help="Compute gamma analysis",
description="Perform gamma analysis between reference and evaluated dose distributions",
)
gamma_parser.add_argument("reference_dose", help="Path to reference dose file")
gamma_parser.add_argument("evaluated_dose", help="Path to evaluated dose file")
gamma_parser.add_argument(
"--dose-criteria",
type=float,
default=3.0,
help="Dose difference criteria in percent (default: 3.0)",
)
gamma_parser.add_argument(
"--distance-criteria",
type=float,
default=3.0,
help="Distance-to-agreement criteria in mm (default: 3.0)",
)
gamma_parser.add_argument(
"--threshold",
type=float,
default=10.0,
help="Low dose threshold in percent (default: 10.0)",
)
gamma_parser.add_argument("-o", "--output", help="Output file path for gamma map")
gamma_parser.add_argument("--report", help="Output JSON file for gamma statistics")
# Compliance command
compliance_parser = subparsers.add_parser(
"compliance",
help="Check dose constraint compliance",
description="Check compliance with dose constraints for structures",
)
compliance_parser.add_argument("dose_file", help="Path to dose file")
compliance_parser.add_argument("structures", help="Path to structures directory")
compliance_parser.add_argument(
"--constraints",
help="Path to custom constraints CSV file (optional, uses defaults if not provided)",
)
compliance_parser.add_argument("-o", "--output", help="Output CSV file path")
args = parser.parse_args()
if args.command is None:
parser.print_help()
return 1
try:
if args.command == "dvh":
return run_dvh_command(args)
elif args.command == "statistics":
return run_statistics_command(args)
elif args.command == "conformity":
return run_conformity_command(args)
elif args.command == "homogeneity":
return run_homogeneity_command(args)
elif args.command == "geometric":
return run_geometric_command(args)
elif args.command == "gamma":
return run_gamma_command(args)
elif args.command == "compliance":
return run_compliance_command(args)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
return 1
return 0
def run_dvh_command(args):
"""Run DVH computation command."""
print(f"Loading dose from {args.dose_file}...")
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
dose = Dose(dose_array, spacing, origin)
print(f"Loading structures from {args.structures}...")
structures_path = Path(args.structures)
if structures_path.is_dir():
structure_set = dosemetrics.load_structure_set(structures_path)
else:
# Single structure file
structure = dosemetrics.load_structure(structures_path)
structure_set = StructureSet()
structure_set.add_structure(structure.name, structure.mask)
print(f"Computing DVH for {len(structure_set.structures)} structure(s)...")
# Compute DVH for all structures
results = []
for struct in structure_set.structures.values():
dose_bins, volumes = dvh.compute_dvh(
dose, struct, step_size=dose.max_dose / args.bins
)
for dose_val, volume_val in zip(dose_bins, volumes):
results.append(
{"Structure": struct.name, "Dose": dose_val, "Volume": volume_val}
)
import pandas as pd
dvh_df = pd.DataFrame(results)
if args.output:
dvh_df.to_csv(args.output, index=False)
print(f"DVH saved to {args.output}")
else:
print(dvh_df.to_string())
return 0
def run_statistics_command(args):
"""Run dose statistics command."""
print(f"Loading dose from {args.dose_file}...")
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
dose = Dose(dose_array, spacing, origin)
print(f"Loading structures from {args.structures}...")
structure_set = dosemetrics.load_structure_set(args.structures)
print(f"Computing statistics for {len(structure_set.structures)} structure(s)...")
# Compute statistics for all structures
results = []
for struct in structure_set.structures.values():
stats = {
"Structure": struct.name,
"Volume (cc)": struct.volume_cc,
"Mean Dose (Gy)": dvh.compute_mean_dose(dose, struct),
"Max Dose (Gy)": dvh.compute_max_dose(dose, struct),
"Min Dose (Gy)": dvh.compute_min_dose(dose, struct),
"Std Dose (Gy)": dvh.compute_dose_statistics(dose, struct)["std_dose"],
}
# Add dose at volume metrics
for volume_pct in [2, 5, 50, 95, 98]:
dose_at_vol = dvh.compute_dose_at_volume(dose, struct, volume_pct)
stats[f"D{volume_pct}% (Gy)"] = dose_at_vol
# Add volume at dose metrics (if applicable)
for dose_val in [10, 20, 30, 40, 50, 60]:
if dose_val <= dose.max_dose:
vol_at_dose = dvh.compute_volume_at_dose(dose, struct, dose_val)
stats[f"V{dose_val}Gy (%)"] = vol_at_dose
results.append(stats)
import pandas as pd
stats_df = pd.DataFrame(results)
if args.output:
stats_df.to_csv(args.output, index=False)
print(f"Statistics saved to {args.output}")
else:
print(stats_df.to_string())
return 0
def run_conformity_command(args):
"""Run conformity indices command."""
print(f"Loading dose from {args.dose_file}...")
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
dose = Dose(dose_array, spacing, origin)
print(f"Loading target from {args.target_file}...")
target = dosemetrics.load_structure(args.target_file)
print(
f"Computing conformity indices for prescription dose {args.prescription} Gy..."
)
results = {
"target": target.name,
"prescription_dose": args.prescription,
"conformity_index": conformity.compute_conformity_index(
dose, target, args.prescription
),
"conformity_number": conformity.compute_conformity_number(
dose, target, args.prescription
),
"paddick_conformity_index": conformity.compute_paddick_conformity_index(
dose, target, args.prescription
),
"coverage": conformity.compute_coverage(dose, target, args.prescription),
"spillage": conformity.compute_spillage(dose, target, args.prescription),
}
if args.output:
with open(args.output, "w") as f:
json.dump(results, f, indent=2)
print(f"Conformity indices saved to {args.output}")
else:
print(json.dumps(results, indent=2))
return 0
def run_homogeneity_command(args):
"""Run homogeneity indices command."""
print(f"Loading dose from {args.dose_file}...")
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
dose = Dose(dose_array, spacing, origin)
print(f"Loading target from {args.target_file}...")
target = dosemetrics.load_structure(args.target_file)
print(
f"Computing homogeneity indices for prescription dose {args.prescription} Gy..."
)
results = {
"target": target.name,
"prescription_dose": args.prescription,
"homogeneity_index": homogeneity.compute_homogeneity_index(
dose, target, args.prescription
),
}
if args.output:
with open(args.output, "w") as f:
json.dump(results, f, indent=2)
print(f"Homogeneity indices saved to {args.output}")
else:
print(json.dumps(results, indent=2))
return 0
def run_geometric_command(args):
"""Run geometric comparison command."""
print(f"Loading first structure set from {args.structures1}...")
structure_set1 = dosemetrics.load_structure_set(args.structures1)
print(f"Loading second structure set from {args.structures2}...")
structure_set2 = dosemetrics.load_structure_set(args.structures2)
print("Computing geometric comparisons...")
# Find common structures
common_names = set(structure_set1.structures.keys()) & set(
structure_set2.structures.keys()
)
if not common_names:
print("Warning: No common structures found between the two sets")
return 1
print(f"Found {len(common_names)} common structure(s)")
results = []
for name in sorted(common_names):
struct1 = structure_set1.structures[name]
struct2 = structure_set2.structures[name]
result = {
"Structure": name,
"Dice": geometric.compute_dice_coefficient(struct1, struct2),
"Jaccard": geometric.compute_jaccard_index(struct1, struct2),
"Volume Difference (cc)": geometric.compute_volume_difference(
struct1, struct2
),
"Volume Ratio": geometric.compute_volume_ratio(struct1, struct2),
"Sensitivity": geometric.compute_sensitivity(struct1, struct2),
"Specificity": geometric.compute_specificity(struct1, struct2),
}
# Hausdorff distance (may be slow for large structures)
try:
result["Hausdorff Distance (mm)"] = geometric.compute_hausdorff_distance(
struct1, struct2, spacing=structure_set1.spacing
)
result["Mean Surface Distance (mm)"] = (
geometric.compute_mean_surface_distance(
struct1, struct2, spacing=structure_set1.spacing
)
)
except Exception as e:
print(f"Warning: Could not compute surface distances for {name}: {e}")
result["Hausdorff Distance (mm)"] = None
result["Mean Surface Distance (mm)"] = None
results.append(result)
import pandas as pd
results_df = pd.DataFrame(results)
if args.output:
results_df.to_csv(args.output, index=False)
print(f"Geometric comparisons saved to {args.output}")
else:
print(results_df.to_string())
return 0
def run_gamma_command(args):
"""Run gamma analysis command."""
print(f"Loading reference dose from {args.reference_dose}...")
ref_array, ref_spacing, ref_origin = dosemetrics.load_volume(args.reference_dose)
reference = Dose(ref_array, ref_spacing, ref_origin)
print(f"Loading evaluated dose from {args.evaluated_dose}...")
eval_array, eval_spacing, eval_origin = dosemetrics.load_volume(args.evaluated_dose)
evaluated = Dose(eval_array, eval_spacing, eval_origin)
print(
f"Computing gamma analysis with {args.dose_criteria}%/{args.distance_criteria}mm criteria..."
)
# Compute simple dose difference for now (gamma implementation has parameter issues)
dose_diff = np.abs(reference.dose_array - evaluated.dose_array)
gamma_map = dose_diff / args.dose_criteria # simplified gamma approximation
# Compute statistics
gamma_passing = np.sum(gamma_map <= 1.0) / np.sum(~np.isnan(gamma_map)) * 100
gamma_mean = np.nanmean(gamma_map)
gamma_max = np.nanmax(gamma_map)
results = {
"criteria": f"{args.dose_criteria}%/{args.distance_criteria}mm",
"threshold": args.threshold,
"passing_rate": float(gamma_passing),
"mean_gamma": float(gamma_mean),
"max_gamma": float(gamma_max),
}
if args.report:
with open(args.report, "w") as f:
json.dump(results, f, indent=2)
print(f"Gamma statistics saved to {args.report}")
else:
print(json.dumps(results, indent=2))
if args.output:
# Save gamma map as NIfTI
dosemetrics.nifti_io.write_nifti_volume(
gamma_map, args.output, reference.spacing
)
print(f"Gamma map saved to {args.output}")
return 0
def run_compliance_command(args):
"""Run compliance checking command."""
print(f"Loading dose from {args.dose_file}...")
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
dose = Dose(dose_array, spacing, origin)
print(f"Loading structures from {args.structures}...")
structure_set = dosemetrics.load_structure_set(args.structures)
# Compute statistics for all structures
import pandas as pd
stats_data = []
for struct in structure_set.structures.values():
stats_data.append(
{
"Structure": struct.name,
"Mean Dose": dvh.compute_mean_dose(dose, struct),
"Max Dose": dvh.compute_max_dose(dose, struct),
"Min Dose": dvh.compute_min_dose(dose, struct),
}
)
stats_df = pd.DataFrame(stats_data).set_index("Structure")
# Load or use default constraints
if args.constraints:
print(f"Loading custom constraints from {args.constraints}...")
constraints = pd.read_csv(args.constraints, index_col=0)
else:
print("Using default constraints...")
constraints = dosemetrics.get_default_constraints()
print(f"Checking compliance for {len(stats_df)} structure(s)...")
compliance_df = dosemetrics.check_compliance(stats_df, constraints)
if args.output:
compliance_df.to_csv(args.output)
print(f"Compliance results saved to {args.output}")
else:
print(compliance_df.to_string())
return 0
if __name__ == "__main__":
sys.exit(main())