hemispec-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hemispec/api.py ADDED
@@ -0,0 +1,637 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, replace
4
+ from pathlib import Path
5
+ from typing import Callable, Literal
6
+
7
+ import pandas as pd
8
+
9
+ from .compute import compute_ans_rns_arrays, run_compute as _run_compute
10
+ from .dgn_inference import run_dgn_inference_files
11
+ from .dgn_model import load_generator, resolve_device
12
+ from .hemisphere_classifier import run_hemisphere_classifier
13
+ from .model_assets import ensure_default_classifier_models, model_auto_download_enabled
14
+ from .paths import (
15
+ default_classifier_output_path,
16
+ default_input_glob,
17
+ default_trt_output_path,
18
+ is_default_classifier_model_dir,
19
+ resolve_classifier_model_dir,
20
+ resolve_dgn_model_root,
21
+ resolve_glasser_atlas_path,
22
+ resolve_glasser_label_table,
23
+ resolve_preprocess_script,
24
+ resolve_sample_input_dir,
25
+ )
26
+ from .plots import plot_heatmap, plot_within_between_box
27
+ from .reports import save_specificity_result
28
+ from .roi import RoiSummaryConfig, summarize_maps_by_atlas
29
+ from .similarity import (
30
+ DEFAULT_HEMI_SLICES,
31
+ SpecificityResult,
32
+ compute_specificity,
33
+ paired_paths_for_sessions,
34
+ parse_hemi_slices,
35
+ )
36
+
37
+
38
+ PACKAGE_DIR = Path(__file__).resolve().parent
39
+ DEFAULT_PREPROCESS_SCRIPT = resolve_preprocess_script()
40
+ DEFAULT_DGN_CHECKPOINTS = {
41
+ "R_to_L": ("best_netG_R2L.pth", "netG_R2L.pth", "best_netG_L.pth", "netG_L.pth"),
42
+ "L_to_R": ("best_netG_L2R.pth", "netG_L2R.pth", "best_netG_R.pth", "netG_R.pth"),
43
+ }
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class PreprocessingSpec:
48
+ """Document the expected GM preprocessing contract for public callers."""
49
+
50
+ script_path: Path = DEFAULT_PREPROCESS_SCRIPT
51
+ sample_input_dir: Path | None = None
52
+ input_description: str = "T1-weighted NIfTI image"
53
+ output_suffix: str = "_GM_masked.nii.gz"
54
+ reference_image: str = "MNI152_T1_1.5mm_brain.nii.gz"
55
+ gm_threshold: float = 0.15
56
+ requires_fsl: bool = True
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class DGNModelBundle:
61
+ """Metadata for a deployed trained DGN model bundle.
62
+
63
+ This public contract describes runtime inference assets only. Training code
64
+ can be used as methodological reference during development, but it is not a
65
+ product dependency and is intentionally not exposed here.
66
+ """
67
+
68
+ checkpoint: Path
69
+ config: Path | None = None
70
+ output_dir: Path | None = None
71
+ direction: Literal["L_to_R", "R_to_L"] | None = None
72
+ source_hemisphere: Literal["left", "right"] | None = None
73
+ target_hemisphere: Literal["left", "right"] | None = None
74
+ name: str = "DGN"
75
+ version: str | None = None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class DGNInferenceConfig:
80
+ model: DGNModelBundle
81
+ input_glob: str
82
+ out_dir: Path
83
+ device: Literal["auto", "cpu", "cuda"] = "auto"
84
+ batch_size: int = 1
85
+ direction: Literal["both", "L_to_R", "R_to_L"] = "both"
86
+ clip_recon: tuple[float, float] | None = None
87
+ output_suffix: str = "_PRED_LR_full.nii.gz"
88
+ should_cancel: Callable[[], bool] | None = None
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class MetricComputeConfig:
93
+ actual_glob: str
94
+ reconstructed_glob: str
95
+ out_dir: Path
96
+ gm_thresh: float = 0.15
97
+ eps: float = 1e-6
98
+ reconstructed_suffix_to_strip: str = "_PRED_LR_full"
99
+ actual_suffix_to_strip: str = ""
100
+ clip_recon: tuple[float, float] | None = None
101
+ export_voxelwise: bool = True
102
+ save_subject_maps: bool = False
103
+ write_nan_outside: bool = False
104
+ verbose_every: int = 50
105
+ roi_atlas: Path | None = None
106
+ roi_out_csv: Path | None = None
107
+ roi_label_table: Path | None = None
108
+ roi_stat: Literal["mean", "median"] = "mean"
109
+ roi_ignore_zero: bool = True
110
+ should_cancel: Callable[[], bool] | None = None
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class MetricComputeResult:
115
+ n_actual: int
116
+ n_reconstructed: int
117
+ n_pairs: int
118
+ missing_actual: list[str]
119
+ missing_reconstructed: list[str]
120
+ out_dir: Path
121
+ subject_maps_dir: Path | None = None
122
+ roi_csv: Path | None = None
123
+ group_maps_saved: bool = True
124
+
125
+ @classmethod
126
+ def from_legacy_dict(cls, value: dict[str, object]) -> "MetricComputeResult":
127
+ subject_maps_dir = value.get("subject_maps_dir")
128
+ roi_csv = value.get("roi_csv")
129
+ return cls(
130
+ n_actual=int(value["n_actual"]),
131
+ n_reconstructed=int(value["n_predicted"]),
132
+ n_pairs=int(value["n_pairs"]),
133
+ missing_actual=list(value["missing_actual"]),
134
+ missing_reconstructed=list(value["missing_predicted"]),
135
+ out_dir=Path(str(value["out_dir"])),
136
+ subject_maps_dir=Path(str(subject_maps_dir)) if subject_maps_dir else None,
137
+ roi_csv=Path(str(roi_csv)) if roi_csv else None,
138
+ group_maps_saved=bool(value.get("group_maps_saved", True)),
139
+ )
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class PipelineRunConfig:
144
+ """End-to-end trained DGN inference followed by ANS/RNS metric computation."""
145
+
146
+ inference: DGNInferenceConfig
147
+ metrics_out_dir: Path
148
+ gm_thresh: float = 0.15
149
+ eps: float = 1e-6
150
+ reconstructed_suffix_to_strip: str = "_PRED_LR_full"
151
+ actual_suffix_to_strip: str = ""
152
+ save_subject_maps: bool = True
153
+ write_nan_outside: bool = False
154
+ verbose_every: int = 50
155
+ export_voxelwise: bool = True
156
+ roi_atlas: Path | None = None
157
+ roi_out_csv: Path | None = None
158
+ roi_label_table: Path | None = None
159
+ roi_stat: Literal["mean", "median"] = "mean"
160
+ roi_ignore_zero: bool = True
161
+ should_cancel: Callable[[], bool] | None = None
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class PipelineRunResult:
166
+ reconstructed_paths: list[Path]
167
+ metrics: MetricComputeResult
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class ValidationConfig:
172
+ maps_dir: Path
173
+ out_dir: Path
174
+ kinds: tuple[str, ...] = ("ANS", "RNS")
175
+ suffix_template: str = "_{kind}.nii.gz"
176
+ file_regex: str = r"(sub-MSC\d+).*?(run-\d+)"
177
+ session_a: str = "run-01"
178
+ session_b: str = "run-02"
179
+ hemis: tuple[str, ...] = ("L", "R")
180
+ dgn_direction: Literal["auto", "L_to_R", "R_to_L", "bilateral"] | None = None
181
+ hemi_slices: str | None = None
182
+ metric: Literal["pearson", "spearman"] = "pearson"
183
+ mask_type: Literal["rate", "max"] = "rate"
184
+ thr: float = 0.0
185
+ rate_thr: float = 0.3
186
+ mask_mode: Literal["union", "intersect"] = "union"
187
+ symmetrize: bool = True
188
+ write_plots: bool = True
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class ValidationSummaryRow:
193
+ kind: str
194
+ hemi: str
195
+ n_subjects: int
196
+ n_voxels: int
197
+ match_rate: float
198
+ specificity_index: float
199
+ cohen_d: float
200
+ within_mean: float
201
+ between_mean: float
202
+ t_value: float
203
+ p_value: float
204
+
205
+ def as_dict(self) -> dict[str, object]:
206
+ return {
207
+ "kind": self.kind,
208
+ "hemi": self.hemi,
209
+ "n_subjects": self.n_subjects,
210
+ "n_voxels": self.n_voxels,
211
+ "match_rate": self.match_rate,
212
+ "specificity_index": self.specificity_index,
213
+ "cohen_d": self.cohen_d,
214
+ "within_mean": self.within_mean,
215
+ "between_mean": self.between_mean,
216
+ "t_value": self.t_value,
217
+ "p_value": self.p_value,
218
+ }
219
+
220
+
221
+ @dataclass(frozen=True)
222
+ class ValidationRunResult:
223
+ results: list[SpecificityResult] = field(default_factory=list)
224
+ summary_rows: list[ValidationSummaryRow] = field(default_factory=list)
225
+ summary_csv: Path | None = None
226
+ out_dir: Path | None = None
227
+
228
+ def to_dataframe(self) -> pd.DataFrame:
229
+ return pd.DataFrame([row.as_dict() for row in self.summary_rows])
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class HemisphereClassificationConfig:
234
+ """Config for ROI-level hemisphere-classifier validation."""
235
+
236
+ maps_dir: Path
237
+ roi_csv: Path | None = None
238
+ atlas_path: Path | None = None
239
+ label_table: Path | None = None
240
+ classifier_checkpoint: Path | None = None
241
+ classifier_model_dir: Path | None = None
242
+ classifier_mode: Literal["single", "paired_residual"] = "single"
243
+ out_dir: Path | None = None
244
+ kinds: tuple[str, ...] = ("ANS", "RNS")
245
+ suffix_template: str = "_{kind}.nii.gz"
246
+ file_regex: str = r"(?P<subject>.+?)_{kind}\.nii(?:\.gz)?$"
247
+ roi_ignore_zero: bool = True
248
+ device: Literal["auto", "cpu", "cuda"] = "auto"
249
+ batch_size: int = 1
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class HemisphereClassificationResult:
254
+ accuracy: float | None = None
255
+ n_samples: int = 0
256
+ summary_csv: Path | None = None
257
+ predictions_csv: Path | None = None
258
+ out_dir: Path | None = None
259
+ message: str = ""
260
+
261
+
262
+ def get_preprocessing_spec(project_root: str | Path | None = None) -> PreprocessingSpec:
263
+ return PreprocessingSpec(
264
+ script_path=resolve_preprocess_script(project_root),
265
+ sample_input_dir=resolve_sample_input_dir(project_root),
266
+ )
267
+
268
+
269
+ def discover_local_dgn_bundles(root: str | Path | None = None) -> dict[str, DGNModelBundle]:
270
+ root = resolve_dgn_model_root(root)
271
+ specs = {
272
+ "R_to_L": {
273
+ "output_dir": root / "outputs_bi_stable_L",
274
+ "checkpoint_names": DEFAULT_DGN_CHECKPOINTS["R_to_L"],
275
+ "source_hemisphere": "right",
276
+ "target_hemisphere": "left",
277
+ "name": "outputs_bi_stable_L",
278
+ },
279
+ "L_to_R": {
280
+ "output_dir": root / "outputs_bi_stable_R",
281
+ "checkpoint_names": DEFAULT_DGN_CHECKPOINTS["L_to_R"],
282
+ "source_hemisphere": "left",
283
+ "target_hemisphere": "right",
284
+ "name": "outputs_bi_stable_R",
285
+ },
286
+ }
287
+
288
+ bundles: dict[str, DGNModelBundle] = {}
289
+ for direction, spec in specs.items():
290
+ output_dir = Path(spec["output_dir"])
291
+ ckpt_dir = output_dir / "ckpts"
292
+ checkpoint = _first_existing(ckpt_dir / name for name in spec["checkpoint_names"])
293
+ if checkpoint is None:
294
+ continue
295
+ bundles[direction] = DGNModelBundle(
296
+ checkpoint=checkpoint,
297
+ output_dir=output_dir,
298
+ direction=direction, # type: ignore[arg-type]
299
+ source_hemisphere=spec["source_hemisphere"], # type: ignore[arg-type]
300
+ target_hemisphere=spec["target_hemisphere"], # type: ignore[arg-type]
301
+ name=spec["name"],
302
+ )
303
+ return bundles
304
+
305
+
306
+ def run_dgn_inference(config: DGNInferenceConfig) -> list[Path]:
307
+ direction = _resolve_inference_direction(config)
308
+ device = resolve_device(config.device)
309
+ model = load_generator(config.model.checkpoint, device=device)
310
+ outputs = run_dgn_inference_files(
311
+ input_glob=config.input_glob,
312
+ out_dir=config.out_dir,
313
+ model=model,
314
+ direction=direction,
315
+ device=device,
316
+ output_suffix=config.output_suffix,
317
+ clip_recon=config.clip_recon,
318
+ should_cancel=config.should_cancel,
319
+ )
320
+ return [item.output_path for item in outputs]
321
+
322
+
323
+ def compute_metrics(config: MetricComputeConfig) -> MetricComputeResult:
324
+ save_subject_maps = config.save_subject_maps or config.roi_atlas is not None
325
+ result = _run_compute(
326
+ actual_glob=config.actual_glob,
327
+ predicted_glob=config.reconstructed_glob,
328
+ out_dir=config.out_dir,
329
+ gm_thresh=config.gm_thresh,
330
+ eps=config.eps,
331
+ pred_suffix_to_strip=config.reconstructed_suffix_to_strip,
332
+ actual_suffix_to_strip=config.actual_suffix_to_strip,
333
+ clip_recon=config.clip_recon,
334
+ save_subject_maps=save_subject_maps,
335
+ save_group_maps=config.export_voxelwise,
336
+ write_nan_outside=config.write_nan_outside,
337
+ verbose_every=config.verbose_every,
338
+ should_cancel=config.should_cancel,
339
+ )
340
+ subject_maps_dir = result.get("subject_maps_dir")
341
+ if config.roi_atlas is not None:
342
+ if not subject_maps_dir:
343
+ raise RuntimeError("ROI-wise export requires subject-level ANS/RNS maps.")
344
+ roi_csv = config.roi_out_csv or (Path(config.out_dir) / "roi_summary.csv")
345
+ summarize_maps_by_atlas(
346
+ RoiSummaryConfig(
347
+ maps_glob=str(Path(str(subject_maps_dir)) / "*.nii.gz"),
348
+ atlas_path=config.roi_atlas,
349
+ out_csv=roi_csv,
350
+ label_table=config.roi_label_table,
351
+ stat=config.roi_stat,
352
+ ignore_zero=config.roi_ignore_zero,
353
+ )
354
+ )
355
+ result["roi_csv"] = str(roi_csv)
356
+ return MetricComputeResult.from_legacy_dict(result)
357
+
358
+
359
+ def run_pipeline(config: PipelineRunConfig) -> PipelineRunResult:
360
+ inference = config.inference
361
+ if config.should_cancel is not None and inference.should_cancel is None:
362
+ inference = replace(inference, should_cancel=config.should_cancel)
363
+ reconstructed_paths = run_dgn_inference(inference)
364
+ metrics = compute_metrics(
365
+ MetricComputeConfig(
366
+ actual_glob=config.inference.input_glob,
367
+ reconstructed_glob=str(Path(config.inference.out_dir) / f"*{config.inference.output_suffix}"),
368
+ out_dir=config.metrics_out_dir,
369
+ gm_thresh=config.gm_thresh,
370
+ eps=config.eps,
371
+ reconstructed_suffix_to_strip=config.reconstructed_suffix_to_strip,
372
+ actual_suffix_to_strip=config.actual_suffix_to_strip,
373
+ clip_recon=config.inference.clip_recon,
374
+ export_voxelwise=config.export_voxelwise,
375
+ save_subject_maps=config.save_subject_maps,
376
+ write_nan_outside=config.write_nan_outside,
377
+ verbose_every=config.verbose_every,
378
+ roi_atlas=config.roi_atlas,
379
+ roi_out_csv=config.roi_out_csv,
380
+ roi_label_table=config.roi_label_table,
381
+ roi_stat=config.roi_stat,
382
+ roi_ignore_zero=config.roi_ignore_zero,
383
+ should_cancel=config.should_cancel or inference.should_cancel,
384
+ )
385
+ )
386
+ return PipelineRunResult(reconstructed_paths=reconstructed_paths, metrics=metrics)
387
+
388
+
389
+ def validate_specificity(config: ValidationConfig) -> ValidationRunResult:
390
+ out = Path(config.out_dir)
391
+ out.mkdir(parents=True, exist_ok=True)
392
+ hemi_slices = parse_hemi_slices(config.hemi_slices)
393
+ hemis = resolve_validation_hemis(config.hemis, config.dgn_direction, config.maps_dir)
394
+ results: list[SpecificityResult] = []
395
+ rows: list[ValidationSummaryRow] = []
396
+
397
+ for kind in _normalize_labels(config.kinds):
398
+ subjects, scan_a, scan_b = paired_paths_for_sessions(
399
+ config.maps_dir,
400
+ kind=kind,
401
+ session_a=config.session_a,
402
+ session_b=config.session_b,
403
+ suffix_template=config.suffix_template,
404
+ file_regex=config.file_regex,
405
+ )
406
+ if not subjects:
407
+ raise RuntimeError(
408
+ f"No subjects with both {config.session_a} and {config.session_b} for {kind}. "
409
+ "Check maps_dir, file_regex and suffix_template."
410
+ )
411
+
412
+ for hemi in hemis:
413
+ result = compute_specificity(
414
+ scan_a,
415
+ scan_b,
416
+ subjects=subjects,
417
+ kind=kind,
418
+ hemi=hemi,
419
+ metric=config.metric,
420
+ mask_type=config.mask_type,
421
+ thr=config.thr,
422
+ rate_thr=config.rate_thr,
423
+ mask_mode=config.mask_mode,
424
+ hemi_slices=hemi_slices,
425
+ symmetrize=config.symmetrize,
426
+ )
427
+ label = f"{kind}_{hemi}"
428
+ save_specificity_result(result, out, label)
429
+ if config.write_plots:
430
+ plot_heatmap(result.matrix, out / f"heatmap_{label}.png", f"{kind}.{hemi} MR={result.match_rate:.0f}%")
431
+ plot_within_between_box(result.within, result.between, out / f"boxplot_{label}.png", f"{kind}.{hemi}")
432
+
433
+ results.append(result)
434
+ rows.append(
435
+ ValidationSummaryRow(
436
+ kind=kind,
437
+ hemi=hemi,
438
+ n_subjects=len(subjects),
439
+ n_voxels=result.n_voxels,
440
+ match_rate=result.match_rate,
441
+ specificity_index=result.specificity_index,
442
+ cohen_d=result.cohen_d,
443
+ within_mean=float(result.within.mean()),
444
+ between_mean=float(result.between.mean()),
445
+ t_value=result.t_value,
446
+ p_value=result.p_value,
447
+ )
448
+ )
449
+
450
+ summary_csv = out / "validation_summary.csv"
451
+ pd.DataFrame([row.as_dict() for row in rows]).to_csv(summary_csv, index=False)
452
+ return ValidationRunResult(results=results, summary_rows=rows, summary_csv=summary_csv, out_dir=out)
453
+
454
+
455
+ def validate_reliability(config: ValidationConfig) -> ValidationRunResult:
456
+ return validate_specificity(config)
457
+
458
+
459
+ def validate_hemisphere_classification(
460
+ config: HemisphereClassificationConfig,
461
+ ) -> HemisphereClassificationResult:
462
+ """Run trained ROI-level hemisphere classifier validation.
463
+
464
+ The deployed classifier was trained outside the toolkit on noICBM ROI
465
+ features and evaluated on held-out ICBM subjects. This function loads the
466
+ saved sklearn/joblib runtime and applies it to an ROI-wise ANS/RNS CSV.
467
+ Training remains out of HemiSpec runtime scope.
468
+ """
469
+
470
+ if config.roi_csv is None:
471
+ if config.atlas_path is None:
472
+ raise ValueError("Provide roi_csv, or provide atlas_path so ROI features can be generated from maps_dir.")
473
+ out_dir = config.out_dir or (config.maps_dir / "hemisphere_classifier")
474
+ roi_csv = out_dir / "roi_summary_for_classifier.csv"
475
+ summarize_maps_by_atlas(
476
+ RoiSummaryConfig(
477
+ maps_glob=str(config.maps_dir / "*.nii.gz"),
478
+ atlas_path=config.atlas_path,
479
+ out_csv=roi_csv,
480
+ label_table=config.label_table,
481
+ ignore_zero=config.roi_ignore_zero,
482
+ )
483
+ )
484
+ else:
485
+ roi_csv = config.roi_csv
486
+
487
+ model_path = config.classifier_checkpoint or resolve_classifier_model_dir(
488
+ config.classifier_model_dir,
489
+ mode=config.classifier_mode,
490
+ )
491
+ if (
492
+ config.classifier_checkpoint is None
493
+ and not model_path.exists()
494
+ and model_auto_download_enabled()
495
+ and is_default_classifier_model_dir(config.classifier_model_dir, mode=config.classifier_mode)
496
+ ):
497
+ model_path = ensure_default_classifier_models(config.classifier_mode)
498
+ if model_path is None:
499
+ raise ValueError("Provide classifier_checkpoint or classifier_model_dir.")
500
+
501
+ run = run_hemisphere_classifier(
502
+ roi_csv=roi_csv,
503
+ model_path=model_path,
504
+ out_dir=config.out_dir,
505
+ metrics=config.kinds,
506
+ )
507
+ accuracy = float(run.summary["accuracy"].mean()) if "accuracy" in run.summary and not run.summary.empty else None
508
+ return HemisphereClassificationResult(
509
+ accuracy=accuracy,
510
+ n_samples=int(run.summary["n_samples"].sum()) if "n_samples" in run.summary and not run.summary.empty else 0,
511
+ summary_csv=run.summary_csv,
512
+ predictions_csv=run.predictions_csv,
513
+ out_dir=config.out_dir,
514
+ message=run.message,
515
+ )
516
+
517
+
518
+ def _normalize_labels(values: tuple[str, ...]) -> tuple[str, ...]:
519
+ return tuple(value.strip().upper() for value in values if value.strip())
520
+
521
+
522
+ def normalize_dgn_direction(direction: str | None) -> Literal["auto", "L_to_R", "R_to_L", "bilateral"] | None:
523
+ if direction is None:
524
+ return None
525
+ value = direction.strip().replace("-", "_").upper()
526
+ if value in {"", "AUTO"}:
527
+ return "auto"
528
+ if value in {"L_TO_R", "L2R", "LEFT_TO_RIGHT"}:
529
+ return "L_to_R"
530
+ if value in {"R_TO_L", "R2L", "RIGHT_TO_LEFT"}:
531
+ return "R_to_L"
532
+ if value in {"BILATERAL", "BOTH", "L_R", "R_L"}:
533
+ return "bilateral"
534
+ raise ValueError("dgn_direction must be one of: auto, L_to_R, R_to_L, bilateral")
535
+
536
+
537
+ def target_hemisphere_for_direction(direction: str | None) -> str | None:
538
+ normalized = normalize_dgn_direction(direction)
539
+ if normalized == "L_to_R":
540
+ return "R"
541
+ if normalized == "R_to_L":
542
+ return "L"
543
+ return None
544
+
545
+
546
+ def infer_dgn_direction_from_path(path: str | Path | None) -> Literal["L_to_R", "R_to_L"] | None:
547
+ if path is None:
548
+ return None
549
+ text = str(path).replace("-", "_").lower()
550
+ has_l_to_r = "l_to_r" in text or "l2r" in text
551
+ has_r_to_l = "r_to_l" in text or "r2l" in text
552
+ if has_l_to_r and not has_r_to_l:
553
+ return "L_to_R"
554
+ if has_r_to_l and not has_l_to_r:
555
+ return "R_to_L"
556
+ return None
557
+
558
+
559
+ def resolve_validation_hemis(
560
+ hemis: tuple[str, ...] = ("L", "R"),
561
+ dgn_direction: str | None = None,
562
+ maps_dir: str | Path | None = None,
563
+ ) -> tuple[str, ...]:
564
+ labels = _normalize_labels(hemis)
565
+ if not labels:
566
+ labels = ("AUTO",)
567
+
568
+ auto_labels = {"AUTO", "TARGET"}
569
+ if any(label in auto_labels for label in labels):
570
+ if len(labels) > 1:
571
+ raise ValueError("Use a single --hemis auto/target value, or pass explicit hemispheres such as L,R.")
572
+ requested_target = labels[0] == "TARGET"
573
+ normalized_direction = normalize_dgn_direction(dgn_direction)
574
+ inferred_direction = None if normalized_direction not in (None, "auto") else infer_dgn_direction_from_path(maps_dir)
575
+ direction = inferred_direction or (normalized_direction if normalized_direction != "auto" else None)
576
+ target = target_hemisphere_for_direction(direction)
577
+ if target:
578
+ return (target,)
579
+ if requested_target:
580
+ raise ValueError("hemis=target requires --dgn-direction L_to_R/R_to_L or a maps_dir path containing that direction.")
581
+ return ("L", "R")
582
+
583
+ return labels
584
+
585
+
586
+ def _resolve_inference_direction(config: DGNInferenceConfig) -> str:
587
+ if config.direction != "both":
588
+ return config.direction
589
+ if config.model.direction is None:
590
+ raise ValueError("DGNInferenceConfig.direction='both' requires model.direction to be set.")
591
+ return config.model.direction
592
+
593
+
594
+ def _first_existing(paths) -> Path | None:
595
+ for path in paths:
596
+ path = Path(path)
597
+ if path.exists():
598
+ return path
599
+ return None
600
+
601
+
602
+ __all__ = [
603
+ "DEFAULT_HEMI_SLICES",
604
+ "DGNInferenceConfig",
605
+ "DGNModelBundle",
606
+ "HemisphereClassificationConfig",
607
+ "HemisphereClassificationResult",
608
+ "MetricComputeConfig",
609
+ "MetricComputeResult",
610
+ "PipelineRunConfig",
611
+ "PipelineRunResult",
612
+ "PreprocessingSpec",
613
+ "SpecificityResult",
614
+ "ValidationConfig",
615
+ "ValidationRunResult",
616
+ "ValidationSummaryRow",
617
+ "compute_ans_rns_arrays",
618
+ "compute_metrics",
619
+ "default_classifier_output_path",
620
+ "default_input_glob",
621
+ "default_trt_output_path",
622
+ "discover_local_dgn_bundles",
623
+ "get_preprocessing_spec",
624
+ "infer_dgn_direction_from_path",
625
+ "normalize_dgn_direction",
626
+ "resolve_validation_hemis",
627
+ "resolve_classifier_model_dir",
628
+ "resolve_dgn_model_root",
629
+ "resolve_glasser_atlas_path",
630
+ "resolve_glasser_label_table",
631
+ "run_dgn_inference",
632
+ "run_pipeline",
633
+ "target_hemisphere_for_direction",
634
+ "validate_reliability",
635
+ "validate_hemisphere_classification",
636
+ "validate_specificity",
637
+ ]