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/__init__.py +100 -0
- hemispec/__main__.py +5 -0
- hemispec/api.py +637 -0
- hemispec/cli.py +604 -0
- hemispec/compute.py +142 -0
- hemispec/dgn_inference.py +141 -0
- hemispec/dgn_model.py +110 -0
- hemispec/gui.py +1031 -0
- hemispec/hemisphere_classifier.py +425 -0
- hemispec/io.py +101 -0
- hemispec/model_assets.py +206 -0
- hemispec/paths.py +302 -0
- hemispec/plots.py +48 -0
- hemispec/py.typed +0 -0
- hemispec/quickstart.py +115 -0
- hemispec/reports.py +46 -0
- hemispec/resources/preprocess/process_single_subject_GM_v2_reorient.sh +80 -0
- hemispec/roi.py +145 -0
- hemispec/similarity.py +254 -0
- hemispec/workflow.py +496 -0
- hemispec_toolkit-0.1.0.dist-info/METADATA +166 -0
- hemispec_toolkit-0.1.0.dist-info/RECORD +26 -0
- hemispec_toolkit-0.1.0.dist-info/WHEEL +5 -0
- hemispec_toolkit-0.1.0.dist-info/entry_points.txt +3 -0
- hemispec_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- hemispec_toolkit-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|