File size: 18,182 Bytes
873a11f
 
5d2df83
 
 
 
 
 
 
 
873a11f
 
 
 
 
5d2df83
 
873a11f
 
5d2df83
 
 
 
 
 
 
 
873a11f
 
 
 
 
5d2df83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873a11f
 
 
 
 
 
 
 
5d2df83
 
 
 
 
 
 
 
 
 
873a11f
5d2df83
 
 
 
 
 
873a11f
 
5d2df83
 
 
 
 
873a11f
5d2df83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873a11f
 
 
 
 
 
 
5d2df83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873a11f
 
 
 
 
5d2df83
 
 
 
873a11f
5d2df83
 
873a11f
5d2df83
 
 
 
 
 
 
873a11f
5d2df83
873a11f
5d2df83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873a11f
 
 
 
5d2df83
 
 
 
 
873a11f
5d2df83
 
873a11f
5d2df83
873a11f
5d2df83
 
 
 
 
 
 
 
 
 
 
873a11f
5d2df83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873a11f
 
5d2df83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873a11f
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
"""
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())