PySourceviz 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.
pysourceviz/plotting.py
ADDED
|
@@ -0,0 +1,1718 @@
|
|
|
1
|
+
"""Cortical source-estimate plotting for PySourceviz."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from numbers import Integral, Real
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
|
|
18
|
+
import mne
|
|
19
|
+
import numpy as np
|
|
20
|
+
from nibabel.freesurfer.io import read_morph_data
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_import_module = importlib.import_module
|
|
24
|
+
_NILEARN_SULC_RANGE = (0.03, 0.22)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_choice(name: str, value: str, allowed: tuple[str, ...]) -> None:
|
|
28
|
+
if value not in allowed:
|
|
29
|
+
choices = ", ".join(repr(choice) for choice in allowed)
|
|
30
|
+
raise ValueError(f"{name} must be one of {choices}; got {value!r}.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _finite_real(name: str, value: object) -> float:
|
|
34
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
35
|
+
raise TypeError(f"{name} must be a finite real number; got {value!r}.")
|
|
36
|
+
result = float(value)
|
|
37
|
+
if not math.isfinite(result):
|
|
38
|
+
raise ValueError(f"{name} must be finite; got {value!r}.")
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_time_selection(
|
|
43
|
+
time: float | Literal["peak"] | None,
|
|
44
|
+
time_window: tuple[float, float] | None,
|
|
45
|
+
) -> None:
|
|
46
|
+
if time is not None and time_window is not None:
|
|
47
|
+
raise ValueError("time and time_window are mutually exclusive.")
|
|
48
|
+
if time is not None:
|
|
49
|
+
if isinstance(time, str):
|
|
50
|
+
if time != "peak":
|
|
51
|
+
raise ValueError("time must be a finite number, 'peak', or None.")
|
|
52
|
+
else:
|
|
53
|
+
_finite_real("time", time)
|
|
54
|
+
if time_window is not None:
|
|
55
|
+
if not isinstance(time_window, tuple) or len(time_window) != 2:
|
|
56
|
+
raise TypeError("time_window must be a two-item tuple.")
|
|
57
|
+
start = _finite_real("time_window start", time_window[0])
|
|
58
|
+
stop = _finite_real("time_window stop", time_window[1])
|
|
59
|
+
if start > stop:
|
|
60
|
+
raise ValueError("time_window start must not exceed its stop.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_positive_int_pair(
|
|
64
|
+
name: str,
|
|
65
|
+
value: tuple[int, int],
|
|
66
|
+
) -> tuple[int, int]:
|
|
67
|
+
if not isinstance(value, tuple) or len(value) != 2:
|
|
68
|
+
raise TypeError(f"{name} must be a two-item tuple of positive integers.")
|
|
69
|
+
if any(
|
|
70
|
+
isinstance(item, bool) or not isinstance(item, Integral) for item in value
|
|
71
|
+
):
|
|
72
|
+
raise TypeError(f"{name} must contain positive integers.")
|
|
73
|
+
result = (int(value[0]), int(value[1]))
|
|
74
|
+
if any(item <= 0 for item in result):
|
|
75
|
+
raise ValueError(f"{name} values must be positive.")
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_smoothing_steps(
|
|
80
|
+
value: int | Literal["nearest"] | None,
|
|
81
|
+
) -> int | Literal["nearest"] | None:
|
|
82
|
+
if value is None:
|
|
83
|
+
return None
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
if value == "nearest":
|
|
86
|
+
return value
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"smoothing_steps must be None, 'nearest', or a non-negative "
|
|
89
|
+
f"integer; got {value!r}."
|
|
90
|
+
)
|
|
91
|
+
if isinstance(value, bool) or not isinstance(value, Integral):
|
|
92
|
+
raise TypeError(
|
|
93
|
+
"smoothing_steps must be None, 'nearest', or a non-negative "
|
|
94
|
+
f"integer; got {value!r}."
|
|
95
|
+
)
|
|
96
|
+
result = int(value)
|
|
97
|
+
if result < 0:
|
|
98
|
+
raise ValueError("smoothing_steps must be non-negative.")
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_output_and_show(
|
|
103
|
+
output: str | Path | None,
|
|
104
|
+
show: bool | None,
|
|
105
|
+
) -> tuple[Path | None, bool]:
|
|
106
|
+
if output is not None and not isinstance(output, (str, os.PathLike)):
|
|
107
|
+
raise TypeError("output must be a string, path-like object, or None.")
|
|
108
|
+
if show is not None and not isinstance(show, bool):
|
|
109
|
+
raise TypeError("show must be a bool or None.")
|
|
110
|
+
output_path = Path(output) if output is not None else None
|
|
111
|
+
return output_path, (output_path is None if show is None else show)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _validate_phase_one_options(
|
|
115
|
+
*,
|
|
116
|
+
subjects_dir: str | Path,
|
|
117
|
+
backend: str,
|
|
118
|
+
surface: str,
|
|
119
|
+
time: float | str | None,
|
|
120
|
+
time_window: tuple[float, float] | None,
|
|
121
|
+
reduce: str,
|
|
122
|
+
mode: str,
|
|
123
|
+
robust_percentile: float,
|
|
124
|
+
layout: str,
|
|
125
|
+
output: str | Path | None,
|
|
126
|
+
size: tuple[int, int] | None,
|
|
127
|
+
scale: tuple[int, int],
|
|
128
|
+
show: bool | None,
|
|
129
|
+
) -> None:
|
|
130
|
+
if not isinstance(subjects_dir, (str, os.PathLike)):
|
|
131
|
+
raise TypeError("subjects_dir must be a non-empty path-like value.")
|
|
132
|
+
if isinstance(subjects_dir, str) and not subjects_dir.strip():
|
|
133
|
+
raise ValueError("subjects_dir must not be empty.")
|
|
134
|
+
_validate_choice("backend", backend, ("brainspace", "nilearn"))
|
|
135
|
+
_validate_choice("surface", surface, ("inflated", "pial", "white"))
|
|
136
|
+
_validate_choice("layout", layout, ("row", "grid"))
|
|
137
|
+
_validate_choice("mode", mode, ("magnitude", "signed"))
|
|
138
|
+
_validate_choice("reduce", reduce, ("mean", "rms"))
|
|
139
|
+
_validate_time_selection(time, time_window)
|
|
140
|
+
percentile = _finite_real("robust_percentile", robust_percentile)
|
|
141
|
+
if not 0.0 < percentile <= 100.0:
|
|
142
|
+
raise ValueError("robust_percentile must be in the interval (0, 100].")
|
|
143
|
+
if size is not None:
|
|
144
|
+
_validate_positive_int_pair("size", size)
|
|
145
|
+
_validate_positive_int_pair("scale", scale)
|
|
146
|
+
_resolve_output_and_show(output, show)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _classify_stc(stc: Any) -> str:
|
|
150
|
+
kinds = (
|
|
151
|
+
(mne.SourceEstimate, "scalar"),
|
|
152
|
+
(mne.VectorSourceEstimate, "vector"),
|
|
153
|
+
(mne.MixedSourceEstimate, "mixed"),
|
|
154
|
+
(mne.MixedVectorSourceEstimate, "mixed-vector"),
|
|
155
|
+
)
|
|
156
|
+
if isinstance(stc, (mne.VolSourceEstimate, mne.VolVectorSourceEstimate)):
|
|
157
|
+
raise TypeError(
|
|
158
|
+
"PySourceviz v0.1 requires a real cortical surface source "
|
|
159
|
+
"estimate; volume-only STCs are not supported."
|
|
160
|
+
)
|
|
161
|
+
for cls, kind in kinds:
|
|
162
|
+
if isinstance(stc, cls):
|
|
163
|
+
if np.iscomplexobj(stc.data):
|
|
164
|
+
raise TypeError(
|
|
165
|
+
"PySourceviz v0.1 requires real data; complex-valued "
|
|
166
|
+
"STCs are not supported."
|
|
167
|
+
)
|
|
168
|
+
return kind
|
|
169
|
+
raise TypeError(
|
|
170
|
+
"PySourceviz v0.1 requires a real cortical surface SourceEstimate, "
|
|
171
|
+
"VectorSourceEstimate, or mixed equivalent."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _copy_surface_stc(
|
|
176
|
+
stc: mne.SourceEstimate,
|
|
177
|
+
data: np.ndarray,
|
|
178
|
+
*,
|
|
179
|
+
tmin: float | None = None,
|
|
180
|
+
) -> mne.SourceEstimate:
|
|
181
|
+
return mne.SourceEstimate(
|
|
182
|
+
np.array(data, dtype=float, copy=True),
|
|
183
|
+
[
|
|
184
|
+
np.array(vertices, dtype=int, copy=True)
|
|
185
|
+
for vertices in stc.vertices
|
|
186
|
+
],
|
|
187
|
+
tmin=float(stc.tmin if tmin is None else tmin),
|
|
188
|
+
tstep=float(stc.tstep),
|
|
189
|
+
subject=stc.subject,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _normalize_surface_stc(stc: Any, mode: str) -> mne.SourceEstimate:
|
|
194
|
+
kind = _classify_stc(stc)
|
|
195
|
+
working = stc.copy()
|
|
196
|
+
if kind in ("mixed", "mixed-vector"):
|
|
197
|
+
working = working.surface()
|
|
198
|
+
if kind in ("vector", "mixed-vector"):
|
|
199
|
+
working = working.magnitude()
|
|
200
|
+
data = np.asarray(working.data)
|
|
201
|
+
if mode == "magnitude":
|
|
202
|
+
data = np.abs(data)
|
|
203
|
+
return _copy_surface_stc(working, data)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class _TimeSummary:
|
|
208
|
+
stc: mne.SourceEstimate
|
|
209
|
+
selection: str
|
|
210
|
+
selected_time: float | None
|
|
211
|
+
requested_window: tuple[float, float] | None
|
|
212
|
+
sampled_window: tuple[float, float] | None
|
|
213
|
+
reduce: str | None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _summarize_time(
|
|
217
|
+
stc: mne.SourceEstimate,
|
|
218
|
+
time: float | str | None,
|
|
219
|
+
time_window: tuple[float, float] | None,
|
|
220
|
+
reduce: str,
|
|
221
|
+
) -> _TimeSummary:
|
|
222
|
+
times = np.asarray(stc.times, dtype=float)
|
|
223
|
+
if time is None and time_window is None:
|
|
224
|
+
if times.size != 1:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
"STC has multiple time samples; specify time or time_window."
|
|
227
|
+
)
|
|
228
|
+
selected_time = float(times[0])
|
|
229
|
+
one_time = _copy_surface_stc(
|
|
230
|
+
stc,
|
|
231
|
+
stc.data[:, [0]],
|
|
232
|
+
tmin=selected_time,
|
|
233
|
+
)
|
|
234
|
+
return _TimeSummary(
|
|
235
|
+
one_time,
|
|
236
|
+
"single",
|
|
237
|
+
selected_time,
|
|
238
|
+
None,
|
|
239
|
+
None,
|
|
240
|
+
None,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if time == "peak":
|
|
244
|
+
score = np.abs(np.asarray(stc.data, dtype=float))
|
|
245
|
+
finite = np.isfinite(score)
|
|
246
|
+
if not finite.any():
|
|
247
|
+
raise ValueError(
|
|
248
|
+
"time='peak' requires at least one finite source value."
|
|
249
|
+
)
|
|
250
|
+
score = np.where(finite, score, -np.inf)
|
|
251
|
+
index = int(np.unravel_index(np.argmax(score), score.shape)[1])
|
|
252
|
+
selected_time = float(times[index])
|
|
253
|
+
one_time = _copy_surface_stc(
|
|
254
|
+
stc,
|
|
255
|
+
stc.data[:, [index]],
|
|
256
|
+
tmin=selected_time,
|
|
257
|
+
)
|
|
258
|
+
return _TimeSummary(
|
|
259
|
+
one_time,
|
|
260
|
+
"peak",
|
|
261
|
+
selected_time,
|
|
262
|
+
None,
|
|
263
|
+
None,
|
|
264
|
+
None,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if time is not None:
|
|
268
|
+
requested_time = float(time)
|
|
269
|
+
distances = np.abs(times - requested_time)
|
|
270
|
+
tolerance = (
|
|
271
|
+
np.finfo(float).eps
|
|
272
|
+
* max(1.0, abs(requested_time), float(np.max(np.abs(times))))
|
|
273
|
+
* 8
|
|
274
|
+
)
|
|
275
|
+
candidates = np.flatnonzero(
|
|
276
|
+
distances <= float(np.min(distances)) + tolerance
|
|
277
|
+
)
|
|
278
|
+
index = int(candidates[0])
|
|
279
|
+
selected_time = float(times[index])
|
|
280
|
+
one_time = _copy_surface_stc(
|
|
281
|
+
stc,
|
|
282
|
+
stc.data[:, [index]],
|
|
283
|
+
tmin=selected_time,
|
|
284
|
+
)
|
|
285
|
+
return _TimeSummary(
|
|
286
|
+
one_time,
|
|
287
|
+
"time",
|
|
288
|
+
selected_time,
|
|
289
|
+
None,
|
|
290
|
+
None,
|
|
291
|
+
None,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
start, stop = (float(value) for value in time_window)
|
|
295
|
+
if start > stop:
|
|
296
|
+
raise ValueError("time_window start must not exceed its stop.")
|
|
297
|
+
tolerance = (
|
|
298
|
+
np.finfo(float).eps
|
|
299
|
+
* max(1.0, float(np.max(np.abs(times))))
|
|
300
|
+
* 8
|
|
301
|
+
)
|
|
302
|
+
if start < times[0] - tolerance or stop > times[-1] + tolerance:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"time_window {(start, stop)!r} is outside STC coverage "
|
|
305
|
+
f"[{times[0]}, {times[-1]}]."
|
|
306
|
+
)
|
|
307
|
+
mask = (times >= start - tolerance) & (times <= stop + tolerance)
|
|
308
|
+
if not np.any(mask):
|
|
309
|
+
raise ValueError("time_window does not contain an STC sample.")
|
|
310
|
+
selected = np.asarray(stc.data[:, mask], dtype=float)
|
|
311
|
+
if reduce == "mean":
|
|
312
|
+
data = selected.mean(axis=1)
|
|
313
|
+
else:
|
|
314
|
+
data = np.sqrt(np.mean(np.square(selected), axis=1))
|
|
315
|
+
sampled_times = times[mask]
|
|
316
|
+
representative_time = float(sampled_times.mean())
|
|
317
|
+
one_time = _copy_surface_stc(
|
|
318
|
+
stc,
|
|
319
|
+
data[:, np.newaxis],
|
|
320
|
+
tmin=representative_time,
|
|
321
|
+
)
|
|
322
|
+
return _TimeSummary(
|
|
323
|
+
one_time,
|
|
324
|
+
"window",
|
|
325
|
+
None,
|
|
326
|
+
(start, stop),
|
|
327
|
+
(float(sampled_times[0]), float(sampled_times[-1])),
|
|
328
|
+
reduce,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
_PERCENT_THRESHOLD = re.compile(
|
|
333
|
+
r"\s*(?:\d+(?:\.\d*)?|\.\d+)\s*%\s*"
|
|
334
|
+
)
|
|
335
|
+
_ACTIVATION_OPACITY = 0.72
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _finite_sparse_values(
|
|
339
|
+
values: np.ndarray,
|
|
340
|
+
*,
|
|
341
|
+
absolute: bool = False,
|
|
342
|
+
) -> np.ndarray:
|
|
343
|
+
result = np.asarray(values, dtype=float).ravel()
|
|
344
|
+
result = result[np.isfinite(result)]
|
|
345
|
+
if absolute:
|
|
346
|
+
result = np.abs(result)
|
|
347
|
+
if result.size == 0:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
"Display statistics require at least one finite sparse value."
|
|
350
|
+
)
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _parse_threshold(
|
|
355
|
+
threshold: float | str | None,
|
|
356
|
+
sparse_values: np.ndarray,
|
|
357
|
+
) -> float | None:
|
|
358
|
+
if threshold is None:
|
|
359
|
+
return None
|
|
360
|
+
if isinstance(threshold, str):
|
|
361
|
+
if _PERCENT_THRESHOLD.fullmatch(threshold) is None:
|
|
362
|
+
raise ValueError(
|
|
363
|
+
"threshold must be a non-negative number or a percentage "
|
|
364
|
+
"such as '75%'."
|
|
365
|
+
)
|
|
366
|
+
percentile = float(threshold.strip()[:-1])
|
|
367
|
+
if not 0.0 <= percentile <= 100.0:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
"threshold percentile must be in the interval [0, 100]."
|
|
370
|
+
)
|
|
371
|
+
values = _finite_sparse_values(sparse_values, absolute=True)
|
|
372
|
+
return float(np.percentile(values, percentile))
|
|
373
|
+
value = _finite_real("threshold", threshold)
|
|
374
|
+
if value < 0.0:
|
|
375
|
+
raise ValueError("threshold must be non-negative.")
|
|
376
|
+
return value
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass(frozen=True)
|
|
380
|
+
class _ColorScale:
|
|
381
|
+
color_range: tuple[float, float]
|
|
382
|
+
control_points: tuple[float, float, float] | None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _resolve_color_scale(
|
|
386
|
+
clim: str | tuple[float, float] | dict[str, Any],
|
|
387
|
+
robust_percentile: float,
|
|
388
|
+
mode: str,
|
|
389
|
+
threshold: float | None,
|
|
390
|
+
sparse_values: np.ndarray,
|
|
391
|
+
) -> _ColorScale:
|
|
392
|
+
values = _finite_sparse_values(
|
|
393
|
+
sparse_values,
|
|
394
|
+
absolute=(mode == "signed"),
|
|
395
|
+
)
|
|
396
|
+
control_points = None
|
|
397
|
+
if isinstance(clim, str):
|
|
398
|
+
if clim != "robust":
|
|
399
|
+
raise ValueError("clim string value must be 'robust'.")
|
|
400
|
+
vmax = float(np.percentile(values, robust_percentile))
|
|
401
|
+
if mode == "signed":
|
|
402
|
+
bounds = (-vmax, vmax)
|
|
403
|
+
else:
|
|
404
|
+
bounds = (
|
|
405
|
+
0.0 if threshold is None else threshold,
|
|
406
|
+
vmax,
|
|
407
|
+
)
|
|
408
|
+
elif isinstance(clim, dict):
|
|
409
|
+
allowed_keys = {"kind", "lims", "pos_lims"}
|
|
410
|
+
unsupported = set(clim).difference(allowed_keys)
|
|
411
|
+
if unsupported:
|
|
412
|
+
names = ", ".join(sorted(str(key) for key in unsupported))
|
|
413
|
+
raise ValueError(f"clim contains unsupported key(s): {names}.")
|
|
414
|
+
has_lims = "lims" in clim
|
|
415
|
+
has_pos_lims = "pos_lims" in clim
|
|
416
|
+
if has_lims + has_pos_lims != 1:
|
|
417
|
+
raise ValueError(
|
|
418
|
+
"clim must contain exactly one of 'lims' and 'pos_lims'."
|
|
419
|
+
)
|
|
420
|
+
required_key = "pos_lims" if mode == "signed" else "lims"
|
|
421
|
+
if required_key not in clim:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f"{mode} mode requires clim['{required_key}']."
|
|
424
|
+
)
|
|
425
|
+
kind = clim.get("kind", "percent")
|
|
426
|
+
if kind not in ("percent", "value"):
|
|
427
|
+
raise ValueError("clim kind must be 'percent' or 'value'.")
|
|
428
|
+
requested = clim[required_key]
|
|
429
|
+
if (
|
|
430
|
+
not isinstance(requested, (list, tuple, np.ndarray))
|
|
431
|
+
or len(requested) != 3
|
|
432
|
+
):
|
|
433
|
+
raise TypeError(
|
|
434
|
+
f"clim['{required_key}'] must contain three controls."
|
|
435
|
+
)
|
|
436
|
+
controls = np.array(
|
|
437
|
+
[
|
|
438
|
+
_finite_real(f"clim {required_key} control", item)
|
|
439
|
+
for item in requested
|
|
440
|
+
],
|
|
441
|
+
dtype=float,
|
|
442
|
+
)
|
|
443
|
+
if np.any(controls < 0.0):
|
|
444
|
+
raise ValueError("clim controls must be non-negative.")
|
|
445
|
+
if np.any(np.diff(controls) < 0.0):
|
|
446
|
+
raise ValueError(
|
|
447
|
+
"clim controls must be monotonically increasing or equal."
|
|
448
|
+
)
|
|
449
|
+
if kind == "percent":
|
|
450
|
+
if np.any(controls > 100.0):
|
|
451
|
+
raise ValueError(
|
|
452
|
+
"clim percent controls must be in the interval [0, 100]."
|
|
453
|
+
)
|
|
454
|
+
controls = np.percentile(values, controls)
|
|
455
|
+
fmin, fmid, fmax = (float(item) for item in controls)
|
|
456
|
+
if fmax <= fmin:
|
|
457
|
+
raise ValueError(
|
|
458
|
+
"Resolved clim lower and upper controls must be distinct."
|
|
459
|
+
)
|
|
460
|
+
epsilon = 1e-5 * (fmax - fmin)
|
|
461
|
+
if fmid <= fmin:
|
|
462
|
+
fmid = fmin + epsilon
|
|
463
|
+
elif fmid >= fmax:
|
|
464
|
+
fmid = fmax - epsilon
|
|
465
|
+
control_points = (fmin, fmid, fmax)
|
|
466
|
+
bounds = (-fmax, fmax) if mode == "signed" else (fmin, fmax)
|
|
467
|
+
else:
|
|
468
|
+
if not isinstance(clim, tuple) or len(clim) != 2:
|
|
469
|
+
raise TypeError(
|
|
470
|
+
"clim must be 'robust', a two-item tuple, or an MNE-style "
|
|
471
|
+
"dictionary."
|
|
472
|
+
)
|
|
473
|
+
lower = _finite_real("clim lower", clim[0])
|
|
474
|
+
upper = _finite_real("clim upper", clim[1])
|
|
475
|
+
if upper <= lower:
|
|
476
|
+
raise ValueError(
|
|
477
|
+
"clim upper bound must exceed its lower bound."
|
|
478
|
+
)
|
|
479
|
+
if mode == "magnitude" and lower < 0.0:
|
|
480
|
+
raise ValueError(
|
|
481
|
+
"magnitude clim must have a non-negative lower bound."
|
|
482
|
+
)
|
|
483
|
+
if mode == "signed":
|
|
484
|
+
limit = max(abs(lower), abs(upper))
|
|
485
|
+
bounds = (-limit, limit)
|
|
486
|
+
else:
|
|
487
|
+
bounds = (lower, upper)
|
|
488
|
+
if bounds[1] <= bounds[0]:
|
|
489
|
+
raise ValueError(
|
|
490
|
+
"Resolved color range must have strictly increasing bounds."
|
|
491
|
+
)
|
|
492
|
+
if threshold is not None and threshold >= bounds[1]:
|
|
493
|
+
raise ValueError(
|
|
494
|
+
"threshold must be below the color-range upper bound."
|
|
495
|
+
)
|
|
496
|
+
return _ColorScale(
|
|
497
|
+
color_range=(float(bounds[0]), float(bounds[1])),
|
|
498
|
+
control_points=control_points,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _resolve_color_range(
|
|
503
|
+
clim: str | tuple[float, float] | dict[str, Any],
|
|
504
|
+
robust_percentile: float,
|
|
505
|
+
mode: str,
|
|
506
|
+
threshold: float | None,
|
|
507
|
+
sparse_values: np.ndarray,
|
|
508
|
+
) -> tuple[float, float]:
|
|
509
|
+
return _resolve_color_scale(
|
|
510
|
+
clim,
|
|
511
|
+
robust_percentile,
|
|
512
|
+
mode,
|
|
513
|
+
threshold,
|
|
514
|
+
sparse_values,
|
|
515
|
+
).color_range
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _resolve_cmap(cmap: str | None, mode: str) -> str:
|
|
519
|
+
if cmap is not None and not isinstance(cmap, str):
|
|
520
|
+
raise TypeError("cmap must be a string or None.")
|
|
521
|
+
return cmap or ("Reds" if mode == "magnitude" else "RdBu_r")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _build_render_cmap(
|
|
525
|
+
cmap_name: str,
|
|
526
|
+
mode: str,
|
|
527
|
+
color_range: tuple[float, float],
|
|
528
|
+
control_points: tuple[float, float, float] | None,
|
|
529
|
+
) -> str | Any:
|
|
530
|
+
if control_points is None:
|
|
531
|
+
return cmap_name
|
|
532
|
+
from matplotlib import colormaps
|
|
533
|
+
from matplotlib.colors import ListedColormap
|
|
534
|
+
|
|
535
|
+
fmin, fmid, fmax = control_points
|
|
536
|
+
vmin, vmax = color_range
|
|
537
|
+
samples = np.linspace(0.0, 1.0, 256)
|
|
538
|
+
if mode == "magnitude":
|
|
539
|
+
control_locations = np.array(
|
|
540
|
+
[fmin, fmid, fmax], dtype=float
|
|
541
|
+
)
|
|
542
|
+
control_locations = (control_locations - vmin) / (vmax - vmin)
|
|
543
|
+
palette_locations = np.array([0.0, 0.5, 1.0])
|
|
544
|
+
alpha_controls = np.array([0.0, _ACTIVATION_OPACITY, _ACTIVATION_OPACITY])
|
|
545
|
+
else:
|
|
546
|
+
control_locations = np.array(
|
|
547
|
+
[-fmax, -fmid, -fmin, 0.0, fmin, fmid, fmax],
|
|
548
|
+
dtype=float,
|
|
549
|
+
)
|
|
550
|
+
control_locations = (control_locations - vmin) / (vmax - vmin)
|
|
551
|
+
palette_locations = np.array(
|
|
552
|
+
[0.0, 0.25, 0.5, 0.5, 0.5, 0.75, 1.0]
|
|
553
|
+
)
|
|
554
|
+
alpha_controls = np.array(
|
|
555
|
+
[
|
|
556
|
+
_ACTIVATION_OPACITY,
|
|
557
|
+
_ACTIVATION_OPACITY,
|
|
558
|
+
0.0,
|
|
559
|
+
0.0,
|
|
560
|
+
0.0,
|
|
561
|
+
_ACTIVATION_OPACITY,
|
|
562
|
+
_ACTIVATION_OPACITY,
|
|
563
|
+
]
|
|
564
|
+
)
|
|
565
|
+
palette_samples = np.interp(
|
|
566
|
+
samples,
|
|
567
|
+
control_locations,
|
|
568
|
+
palette_locations,
|
|
569
|
+
)
|
|
570
|
+
alpha_samples = np.interp(
|
|
571
|
+
samples,
|
|
572
|
+
control_locations,
|
|
573
|
+
alpha_controls,
|
|
574
|
+
)
|
|
575
|
+
rgba = np.asarray(colormaps[cmap_name](palette_samples), dtype=float)
|
|
576
|
+
rgba[:, 3] = alpha_samples
|
|
577
|
+
return ListedColormap(
|
|
578
|
+
rgba,
|
|
579
|
+
name=f"pysourceviz_{cmap_name}_control",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _expand_to_full_surface(
|
|
584
|
+
one_time_stc: mne.SourceEstimate,
|
|
585
|
+
template: str,
|
|
586
|
+
subjects_dir: str | Path,
|
|
587
|
+
smoothing_steps: int | Literal["nearest"] | None = None,
|
|
588
|
+
) -> mne.SourceEstimate:
|
|
589
|
+
try:
|
|
590
|
+
morph = mne.compute_source_morph(
|
|
591
|
+
one_time_stc,
|
|
592
|
+
subject_from=template,
|
|
593
|
+
subject_to=template,
|
|
594
|
+
subjects_dir=str(subjects_dir),
|
|
595
|
+
spacing=None,
|
|
596
|
+
smooth=smoothing_steps,
|
|
597
|
+
warn=isinstance(smoothing_steps, Integral),
|
|
598
|
+
)
|
|
599
|
+
full_stc = morph.apply(one_time_stc)
|
|
600
|
+
except Exception as error:
|
|
601
|
+
raise RuntimeError(
|
|
602
|
+
f"Could not expand source data on template {template!r}."
|
|
603
|
+
) from error
|
|
604
|
+
if (
|
|
605
|
+
not isinstance(full_stc, mne.SourceEstimate)
|
|
606
|
+
or len(full_stc.vertices) != 2
|
|
607
|
+
):
|
|
608
|
+
raise TypeError(
|
|
609
|
+
"surface morph must return a scalar two-hemisphere "
|
|
610
|
+
"SourceEstimate."
|
|
611
|
+
)
|
|
612
|
+
return full_stc
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _load_cortex_mask(
|
|
616
|
+
subjects_dir: str | Path,
|
|
617
|
+
template: str,
|
|
618
|
+
vertices: list[np.ndarray],
|
|
619
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
620
|
+
label_dir = Path(subjects_dir) / template / "label"
|
|
621
|
+
paths = (
|
|
622
|
+
label_dir / "lh.cortex.label",
|
|
623
|
+
label_dir / "rh.cortex.label",
|
|
624
|
+
)
|
|
625
|
+
missing = [path for path in paths if not path.is_file()]
|
|
626
|
+
if missing:
|
|
627
|
+
warnings.warn(
|
|
628
|
+
"Cortex label file(s) missing; medial-wall masking is disabled "
|
|
629
|
+
"for the affected hemisphere(s): "
|
|
630
|
+
+ ", ".join(str(path) for path in missing),
|
|
631
|
+
RuntimeWarning,
|
|
632
|
+
stacklevel=2,
|
|
633
|
+
)
|
|
634
|
+
masks = []
|
|
635
|
+
for path, hemi_vertices in zip(paths, vertices):
|
|
636
|
+
if path.is_file():
|
|
637
|
+
label = mne.read_label(str(path))
|
|
638
|
+
masks.append(np.isin(hemi_vertices, label.vertices))
|
|
639
|
+
else:
|
|
640
|
+
masks.append(np.ones(len(hemi_vertices), dtype=bool))
|
|
641
|
+
return masks[0], masks[1]
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _mask_dense_activation(
|
|
645
|
+
full_stc: mne.SourceEstimate,
|
|
646
|
+
threshold: float | None,
|
|
647
|
+
cortex_mask: tuple[np.ndarray, np.ndarray],
|
|
648
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
649
|
+
split = len(full_stc.vertices[0])
|
|
650
|
+
values = np.asarray(full_stc.data[:, 0], dtype=float)
|
|
651
|
+
activation = [values[:split].copy(), values[split:].copy()]
|
|
652
|
+
for hemi in range(2):
|
|
653
|
+
mask = np.asarray(cortex_mask[hemi], dtype=bool)
|
|
654
|
+
if mask.shape != activation[hemi].shape:
|
|
655
|
+
raise ValueError(
|
|
656
|
+
"cortex mask length must match full surface vertices."
|
|
657
|
+
)
|
|
658
|
+
if threshold is not None:
|
|
659
|
+
hidden = np.abs(activation[hemi]) < threshold
|
|
660
|
+
activation[hemi][hidden] = np.nan
|
|
661
|
+
activation[hemi][~mask] = np.nan
|
|
662
|
+
return activation[0], activation[1]
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@dataclass(frozen=True)
|
|
666
|
+
class _PreparedSource:
|
|
667
|
+
activation: tuple[np.ndarray, np.ndarray]
|
|
668
|
+
sparse_values: np.ndarray
|
|
669
|
+
template: str
|
|
670
|
+
smoothing_steps: int | Literal["nearest"] | None
|
|
671
|
+
selection: str
|
|
672
|
+
selected_time: float | None
|
|
673
|
+
time_window: tuple[float, float] | None
|
|
674
|
+
sampled_time_window: tuple[float, float] | None
|
|
675
|
+
reduce: str | None
|
|
676
|
+
mode: str
|
|
677
|
+
threshold: float | None
|
|
678
|
+
color_range: tuple[float, float]
|
|
679
|
+
color_control_points: tuple[float, float, float] | None
|
|
680
|
+
cmap: str
|
|
681
|
+
render_cmap: Any
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@dataclass(frozen=True)
|
|
685
|
+
class _HemisphereGeometry:
|
|
686
|
+
coordinates: np.ndarray
|
|
687
|
+
faces: np.ndarray
|
|
688
|
+
sulc: np.ndarray
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@dataclass(frozen=True)
|
|
692
|
+
class _SurfaceGeometry:
|
|
693
|
+
lh: _HemisphereGeometry
|
|
694
|
+
rh: _HemisphereGeometry
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@dataclass(frozen=True)
|
|
698
|
+
class _PanelSpec:
|
|
699
|
+
row: int
|
|
700
|
+
column: int
|
|
701
|
+
hemi: Literal["left", "right"]
|
|
702
|
+
view: Literal["lateral", "medial"]
|
|
703
|
+
label: str
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@dataclass(frozen=True)
|
|
707
|
+
class _LayoutSpec:
|
|
708
|
+
surface_layout: list[list[str]]
|
|
709
|
+
views: list[list[str]]
|
|
710
|
+
label_text: dict[str, list[str]]
|
|
711
|
+
size: tuple[int, int]
|
|
712
|
+
shape: tuple[int, int]
|
|
713
|
+
panels: tuple[_PanelSpec, ...]
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _normalize_sulc(sulc: np.ndarray) -> np.ndarray:
|
|
717
|
+
values = np.asarray(sulc, dtype=float)
|
|
718
|
+
finite = np.isfinite(values)
|
|
719
|
+
if not finite.any():
|
|
720
|
+
raise ValueError(
|
|
721
|
+
"sulc data must contain at least one finite value."
|
|
722
|
+
)
|
|
723
|
+
scale = float(np.percentile(np.abs(values[finite]), 95.0))
|
|
724
|
+
if scale == 0.0:
|
|
725
|
+
return np.zeros(values.shape, dtype=float)
|
|
726
|
+
clean = np.where(finite, values, 0.0)
|
|
727
|
+
return np.clip(clean / scale, -1.0, 1.0)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _readonly_array(array: np.ndarray, dtype: Any) -> np.ndarray:
|
|
731
|
+
result = np.array(array, dtype=dtype, copy=True)
|
|
732
|
+
result.setflags(write=False)
|
|
733
|
+
return result
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _validate_hemisphere_geometry(
|
|
737
|
+
hemi: str,
|
|
738
|
+
coordinates: np.ndarray,
|
|
739
|
+
faces: np.ndarray,
|
|
740
|
+
sulc: np.ndarray,
|
|
741
|
+
) -> _HemisphereGeometry:
|
|
742
|
+
coordinates = np.asarray(coordinates, dtype=float)
|
|
743
|
+
faces = np.asarray(faces)
|
|
744
|
+
sulc = np.asarray(sulc, dtype=float)
|
|
745
|
+
if coordinates.ndim != 2 or coordinates.shape[1] != 3:
|
|
746
|
+
raise ValueError(
|
|
747
|
+
f"{hemi} coordinates must have shape (n_vertices, 3)."
|
|
748
|
+
)
|
|
749
|
+
if not np.isfinite(coordinates).all():
|
|
750
|
+
raise ValueError(f"{hemi} coordinates must be finite.")
|
|
751
|
+
if faces.ndim != 2 or faces.shape[1] != 3:
|
|
752
|
+
raise ValueError(f"{hemi} faces must have shape (n_faces, 3).")
|
|
753
|
+
if not np.issubdtype(faces.dtype, np.integer):
|
|
754
|
+
raise ValueError(f"{hemi} faces must contain integer indices.")
|
|
755
|
+
if sulc.shape != (coordinates.shape[0],):
|
|
756
|
+
raise ValueError(
|
|
757
|
+
f"{hemi} sulc length {sulc.size} does not match surface "
|
|
758
|
+
f"vertex count {coordinates.shape[0]}."
|
|
759
|
+
)
|
|
760
|
+
if faces.size and (
|
|
761
|
+
int(faces.min()) < 0
|
|
762
|
+
or int(faces.max()) >= coordinates.shape[0]
|
|
763
|
+
):
|
|
764
|
+
raise ValueError(
|
|
765
|
+
f"{hemi} faces contain out-of-range vertex indices."
|
|
766
|
+
)
|
|
767
|
+
return _HemisphereGeometry(
|
|
768
|
+
coordinates=_readonly_array(coordinates, float),
|
|
769
|
+
faces=_readonly_array(faces, int),
|
|
770
|
+
sulc=_readonly_array(_normalize_sulc(sulc), float),
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _load_surface_geometry(
|
|
775
|
+
subjects_dir: str | Path,
|
|
776
|
+
template: str,
|
|
777
|
+
surface: str,
|
|
778
|
+
) -> _SurfaceGeometry:
|
|
779
|
+
root = str(Path(subjects_dir).expanduser().resolve())
|
|
780
|
+
return _load_surface_geometry_cached(root, template, surface)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@lru_cache(maxsize=16)
|
|
784
|
+
def _load_surface_geometry_cached(
|
|
785
|
+
subjects_dir: str,
|
|
786
|
+
template: str,
|
|
787
|
+
surface: str,
|
|
788
|
+
) -> _SurfaceGeometry:
|
|
789
|
+
surf_dir = Path(subjects_dir) / template / "surf"
|
|
790
|
+
paths = {
|
|
791
|
+
"lh_surface": surf_dir / f"lh.{surface}",
|
|
792
|
+
"rh_surface": surf_dir / f"rh.{surface}",
|
|
793
|
+
"lh_sulc": surf_dir / "lh.sulc",
|
|
794
|
+
"rh_sulc": surf_dir / "rh.sulc",
|
|
795
|
+
}
|
|
796
|
+
missing = [path for path in paths.values() if not path.is_file()]
|
|
797
|
+
if missing:
|
|
798
|
+
raise FileNotFoundError(
|
|
799
|
+
"Required FreeSurfer surface file(s) missing: "
|
|
800
|
+
+ ", ".join(str(path) for path in missing)
|
|
801
|
+
)
|
|
802
|
+
hemispheres = []
|
|
803
|
+
for hemi in ("lh", "rh"):
|
|
804
|
+
coordinates, faces = mne.read_surface(
|
|
805
|
+
str(paths[f"{hemi}_surface"])
|
|
806
|
+
)
|
|
807
|
+
sulc = read_morph_data(str(paths[f"{hemi}_sulc"]))
|
|
808
|
+
hemispheres.append(
|
|
809
|
+
_validate_hemisphere_geometry(
|
|
810
|
+
hemi,
|
|
811
|
+
coordinates,
|
|
812
|
+
faces,
|
|
813
|
+
sulc,
|
|
814
|
+
)
|
|
815
|
+
)
|
|
816
|
+
return _SurfaceGeometry(
|
|
817
|
+
lh=hemispheres[0],
|
|
818
|
+
rh=hemispheres[1],
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _configure_vtk_backend() -> None:
|
|
823
|
+
if (
|
|
824
|
+
sys.platform == "darwin"
|
|
825
|
+
and "VTK_DEFAULT_RENDER_WINDOW_TYPE" not in os.environ
|
|
826
|
+
):
|
|
827
|
+
os.environ["VTK_DEFAULT_RENDER_WINDOW_TYPE"] = "Cocoa"
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@lru_cache(maxsize=1)
|
|
831
|
+
def _brainspace_api() -> tuple[Any, Any, type]:
|
|
832
|
+
_configure_vtk_backend()
|
|
833
|
+
from brainspace.mesh.mesh_creation import build_polydata
|
|
834
|
+
from brainspace.plotting import plot_surf
|
|
835
|
+
from brainspace.plotting.utils import PTuple
|
|
836
|
+
|
|
837
|
+
return build_polydata, plot_surf, PTuple
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
@lru_cache(maxsize=1)
|
|
841
|
+
def _nilearn_api() -> tuple[Any, Any, Any, Any]:
|
|
842
|
+
"""Import the optional Nilearn/Matplotlib renderer only on demand."""
|
|
843
|
+
try:
|
|
844
|
+
_import_module("nilearn")
|
|
845
|
+
pyplot = _import_module("matplotlib.pyplot")
|
|
846
|
+
figure_class = _import_module("matplotlib.figure").Figure
|
|
847
|
+
agg_canvas = _import_module(
|
|
848
|
+
"matplotlib.backends.backend_agg"
|
|
849
|
+
).FigureCanvasAgg
|
|
850
|
+
nilearn_plotting = _import_module("nilearn.plotting")
|
|
851
|
+
except ImportError as error:
|
|
852
|
+
raise RuntimeError(
|
|
853
|
+
'backend="nilearn" requires the optional Nilearn renderer. '
|
|
854
|
+
'Install it with: python -m pip install "PySourceviz[nilearn]"'
|
|
855
|
+
) from error
|
|
856
|
+
return (
|
|
857
|
+
pyplot,
|
|
858
|
+
nilearn_plotting.plot_surf_stat_map,
|
|
859
|
+
figure_class,
|
|
860
|
+
agg_canvas,
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@lru_cache(maxsize=1)
|
|
865
|
+
def _cortex_cmap() -> Any:
|
|
866
|
+
from matplotlib import colormaps
|
|
867
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
868
|
+
|
|
869
|
+
soft_greys = colormaps["Greys_r"](np.linspace(0.72, 1.0, 256))
|
|
870
|
+
return LinearSegmentedColormap.from_list(
|
|
871
|
+
"pysourceviz_cortex",
|
|
872
|
+
soft_greys,
|
|
873
|
+
N=256,
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _build_brainspace_meshes(
|
|
878
|
+
geometry: _SurfaceGeometry,
|
|
879
|
+
activation: tuple[np.ndarray, np.ndarray],
|
|
880
|
+
) -> dict[str, Any]:
|
|
881
|
+
build_polydata, _, _ = _brainspace_api()
|
|
882
|
+
meshes = {}
|
|
883
|
+
for index, (hemi, hemi_geometry) in enumerate(
|
|
884
|
+
(("lh", geometry.lh), ("rh", geometry.rh))
|
|
885
|
+
):
|
|
886
|
+
values = np.asarray(activation[index], dtype=float)
|
|
887
|
+
n_vertices = hemi_geometry.coordinates.shape[0]
|
|
888
|
+
if values.shape != (n_vertices,):
|
|
889
|
+
raise ValueError(
|
|
890
|
+
f"{hemi} activation length {values.size} does not match "
|
|
891
|
+
f"surface vertex count {n_vertices}."
|
|
892
|
+
)
|
|
893
|
+
mesh = build_polydata(
|
|
894
|
+
hemi_geometry.coordinates,
|
|
895
|
+
hemi_geometry.faces,
|
|
896
|
+
)
|
|
897
|
+
mesh.append_array(
|
|
898
|
+
hemi_geometry.sulc,
|
|
899
|
+
name="sulc",
|
|
900
|
+
at="p",
|
|
901
|
+
)
|
|
902
|
+
mesh.append_array(
|
|
903
|
+
values.copy(),
|
|
904
|
+
name="activation",
|
|
905
|
+
at="p",
|
|
906
|
+
)
|
|
907
|
+
meshes[hemi] = mesh
|
|
908
|
+
return meshes
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _resolve_layout(
|
|
912
|
+
layout: str,
|
|
913
|
+
size: tuple[int, int] | None,
|
|
914
|
+
title: str | None,
|
|
915
|
+
) -> _LayoutSpec:
|
|
916
|
+
# BrainSpace names global camera rotations, not hemisphere-relative
|
|
917
|
+
# anatomical views. The right hemisphere therefore needs the
|
|
918
|
+
# complementary rotation name to match Nilearn's lateral/medial views.
|
|
919
|
+
if layout == "row":
|
|
920
|
+
surface_layout = [["lh", "lh", "rh", "rh"]]
|
|
921
|
+
views = [["lateral", "medial", "lateral", "medial"]]
|
|
922
|
+
label_text = {
|
|
923
|
+
"bottom": [
|
|
924
|
+
"LH lateral",
|
|
925
|
+
"LH medial",
|
|
926
|
+
"RH medial",
|
|
927
|
+
"RH lateral",
|
|
928
|
+
]
|
|
929
|
+
}
|
|
930
|
+
default_size = (1600, 420)
|
|
931
|
+
shape = (1, 4)
|
|
932
|
+
panels = (
|
|
933
|
+
_PanelSpec(0, 0, "left", "lateral", "LH lateral"),
|
|
934
|
+
_PanelSpec(0, 1, "left", "medial", "LH medial"),
|
|
935
|
+
_PanelSpec(0, 2, "right", "medial", "RH medial"),
|
|
936
|
+
_PanelSpec(0, 3, "right", "lateral", "RH lateral"),
|
|
937
|
+
)
|
|
938
|
+
else:
|
|
939
|
+
surface_layout = [["lh", "rh"], ["lh", "rh"]]
|
|
940
|
+
views = [
|
|
941
|
+
["lateral", "medial"],
|
|
942
|
+
["medial", "lateral"],
|
|
943
|
+
]
|
|
944
|
+
label_text = {
|
|
945
|
+
"bottom": ["LH", "RH"],
|
|
946
|
+
"left": ["Lateral", "Medial"],
|
|
947
|
+
}
|
|
948
|
+
default_size = (900, 760)
|
|
949
|
+
shape = (2, 2)
|
|
950
|
+
panels = (
|
|
951
|
+
_PanelSpec(0, 0, "left", "lateral", "LH lateral"),
|
|
952
|
+
_PanelSpec(0, 1, "right", "lateral", "RH lateral"),
|
|
953
|
+
_PanelSpec(1, 0, "left", "medial", "LH medial"),
|
|
954
|
+
_PanelSpec(1, 1, "right", "medial", "RH medial"),
|
|
955
|
+
)
|
|
956
|
+
if title is not None:
|
|
957
|
+
label_text["top"] = [title]
|
|
958
|
+
return _LayoutSpec(
|
|
959
|
+
surface_layout=surface_layout,
|
|
960
|
+
views=views,
|
|
961
|
+
label_text=label_text,
|
|
962
|
+
size=default_size if size is None else size,
|
|
963
|
+
shape=shape,
|
|
964
|
+
panels=panels,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _resolve_background(
|
|
969
|
+
background: str | tuple[float, float, float],
|
|
970
|
+
) -> tuple[float, float, float]:
|
|
971
|
+
from matplotlib.colors import to_rgb
|
|
972
|
+
|
|
973
|
+
try:
|
|
974
|
+
resolved = to_rgb(background)
|
|
975
|
+
except (TypeError, ValueError) as error:
|
|
976
|
+
raise ValueError(
|
|
977
|
+
"background must be a valid color name or RGB triple."
|
|
978
|
+
) from error
|
|
979
|
+
if len(resolved) != 3 or not all(
|
|
980
|
+
math.isfinite(float(value)) for value in resolved
|
|
981
|
+
):
|
|
982
|
+
raise ValueError(
|
|
983
|
+
"background must resolve to three finite RGB values."
|
|
984
|
+
)
|
|
985
|
+
return tuple(float(value) for value in resolved)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _enforce_overlay_ranges(
|
|
989
|
+
plotter: Any,
|
|
990
|
+
color_range: tuple[float, float],
|
|
991
|
+
) -> None:
|
|
992
|
+
view_count = 0
|
|
993
|
+
try:
|
|
994
|
+
renderer_groups = plotter.renderers.values()
|
|
995
|
+
except AttributeError as error:
|
|
996
|
+
raise RuntimeError(
|
|
997
|
+
"BrainSpace plotter does not expose renderer actors."
|
|
998
|
+
) from error
|
|
999
|
+
for renderers in renderer_groups:
|
|
1000
|
+
for renderer in renderers:
|
|
1001
|
+
collection = renderer.actors.VTKObject
|
|
1002
|
+
actor_count = int(collection.GetNumberOfItems())
|
|
1003
|
+
if actor_count == 0:
|
|
1004
|
+
continue
|
|
1005
|
+
if actor_count != 2:
|
|
1006
|
+
raise RuntimeError(
|
|
1007
|
+
"BrainSpace overlay compatibility requires exactly "
|
|
1008
|
+
"two actors in each brain view."
|
|
1009
|
+
)
|
|
1010
|
+
collection.InitTraversal()
|
|
1011
|
+
sulc_actor = collection.GetNextActor()
|
|
1012
|
+
activation_actor = collection.GetNextActor()
|
|
1013
|
+
sulc_lut = sulc_actor.GetMapper().GetLookupTable()
|
|
1014
|
+
activation_lut = (
|
|
1015
|
+
activation_actor.GetMapper().GetLookupTable()
|
|
1016
|
+
)
|
|
1017
|
+
sulc_lut.SetRange(-1.0, 1.0)
|
|
1018
|
+
activation_lut.SetRange(*color_range)
|
|
1019
|
+
view_count += 1
|
|
1020
|
+
if view_count != 4:
|
|
1021
|
+
raise RuntimeError(
|
|
1022
|
+
"BrainSpace overlay compatibility expected four brain-view "
|
|
1023
|
+
f"actor groups; found {view_count}."
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _build_brainspace_plotter(
|
|
1028
|
+
meshes: dict[str, Any],
|
|
1029
|
+
layout: _LayoutSpec,
|
|
1030
|
+
prepared: _PreparedSource,
|
|
1031
|
+
*,
|
|
1032
|
+
background: tuple[float, float, float],
|
|
1033
|
+
colorbar: bool,
|
|
1034
|
+
units: str | None,
|
|
1035
|
+
offscreen: bool,
|
|
1036
|
+
) -> Any:
|
|
1037
|
+
_, plot_surf, PTuple = _brainspace_api()
|
|
1038
|
+
control_points = getattr(prepared, "color_control_points", None)
|
|
1039
|
+
render_cmap = getattr(prepared, "render_cmap", prepared.cmap)
|
|
1040
|
+
activation_opacity = 1.0 if control_points is not None else 0.72
|
|
1041
|
+
text_property = {
|
|
1042
|
+
"fontSize": 14,
|
|
1043
|
+
"bold": False,
|
|
1044
|
+
"color": (0.0, 0.0, 0.0),
|
|
1045
|
+
}
|
|
1046
|
+
kwargs = {
|
|
1047
|
+
"array_name": PTuple("sulc", "activation"),
|
|
1048
|
+
"view": layout.views,
|
|
1049
|
+
"color_bar": "right" if colorbar else None,
|
|
1050
|
+
"color_range": PTuple((-1.0, 1.0), prepared.color_range),
|
|
1051
|
+
"share": "both",
|
|
1052
|
+
"label_text": layout.label_text,
|
|
1053
|
+
"cmap": PTuple(_cortex_cmap(), render_cmap),
|
|
1054
|
+
"nan_color": (0.0, 0.0, 0.0, 0.0),
|
|
1055
|
+
"background": background,
|
|
1056
|
+
"size": layout.size,
|
|
1057
|
+
"return_plotter": True,
|
|
1058
|
+
"suppress_warnings": True,
|
|
1059
|
+
"offscreen": offscreen,
|
|
1060
|
+
"actor__opacity": PTuple(1.0, activation_opacity),
|
|
1061
|
+
"actor__forceOpaque": PTuple(True, False),
|
|
1062
|
+
"actor__ambient": PTuple(0.3, 0.12),
|
|
1063
|
+
"actor__specular": PTuple(0.08, 0.02),
|
|
1064
|
+
"actor__diffuse": PTuple(0.7, 0.92),
|
|
1065
|
+
"text__textScaleMode": "none",
|
|
1066
|
+
"text__textProperty": text_property,
|
|
1067
|
+
"cb__labelTextProperty": text_property,
|
|
1068
|
+
"cb__titleTextProperty": text_property,
|
|
1069
|
+
}
|
|
1070
|
+
if colorbar and units:
|
|
1071
|
+
kwargs["cb__title"] = units
|
|
1072
|
+
plotter = plot_surf(meshes, layout.surface_layout, **kwargs)
|
|
1073
|
+
_enforce_overlay_ranges(plotter, prepared.color_range)
|
|
1074
|
+
return plotter
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _resolve_png_output(
|
|
1078
|
+
output: str | Path | None,
|
|
1079
|
+
) -> Path | None:
|
|
1080
|
+
if output is None:
|
|
1081
|
+
return None
|
|
1082
|
+
path = Path(output)
|
|
1083
|
+
if path.suffix != ".png":
|
|
1084
|
+
raise ValueError("output must use the .png extension.")
|
|
1085
|
+
return path
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _validate_render_options(
|
|
1089
|
+
*,
|
|
1090
|
+
title: str | None,
|
|
1091
|
+
units: str | None,
|
|
1092
|
+
colorbar: bool,
|
|
1093
|
+
transparent: bool,
|
|
1094
|
+
) -> None:
|
|
1095
|
+
for name, value in (("title", title), ("units", units)):
|
|
1096
|
+
if value is not None and not isinstance(value, str):
|
|
1097
|
+
raise TypeError(f"{name} must be a string or None.")
|
|
1098
|
+
if not isinstance(colorbar, bool):
|
|
1099
|
+
raise TypeError("colorbar must be a bool.")
|
|
1100
|
+
if not isinstance(transparent, bool):
|
|
1101
|
+
raise TypeError("transparent must be a bool.")
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _render_brainspace(
|
|
1105
|
+
meshes: dict[str, Any],
|
|
1106
|
+
layout: _LayoutSpec,
|
|
1107
|
+
prepared: _PreparedSource,
|
|
1108
|
+
*,
|
|
1109
|
+
background: tuple[float, float, float],
|
|
1110
|
+
colorbar: bool,
|
|
1111
|
+
units: str | None,
|
|
1112
|
+
output: Path | None,
|
|
1113
|
+
scale: tuple[int, int],
|
|
1114
|
+
transparent: bool,
|
|
1115
|
+
show: bool,
|
|
1116
|
+
) -> Any | None:
|
|
1117
|
+
if output is not None:
|
|
1118
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
1119
|
+
static_plotter = _build_brainspace_plotter(
|
|
1120
|
+
meshes,
|
|
1121
|
+
layout,
|
|
1122
|
+
prepared,
|
|
1123
|
+
background=background,
|
|
1124
|
+
colorbar=colorbar,
|
|
1125
|
+
units=units,
|
|
1126
|
+
offscreen=True,
|
|
1127
|
+
)
|
|
1128
|
+
try:
|
|
1129
|
+
static_plotter.screenshot(
|
|
1130
|
+
str(output),
|
|
1131
|
+
transparent_bg=transparent,
|
|
1132
|
+
scale=scale,
|
|
1133
|
+
)
|
|
1134
|
+
finally:
|
|
1135
|
+
static_plotter.close()
|
|
1136
|
+
if show:
|
|
1137
|
+
interactive_plotter = _build_brainspace_plotter(
|
|
1138
|
+
meshes,
|
|
1139
|
+
layout,
|
|
1140
|
+
prepared,
|
|
1141
|
+
background=background,
|
|
1142
|
+
colorbar=colorbar,
|
|
1143
|
+
units=units,
|
|
1144
|
+
offscreen=False,
|
|
1145
|
+
)
|
|
1146
|
+
interactive_plotter.show(interactive=True)
|
|
1147
|
+
return interactive_plotter
|
|
1148
|
+
if output is None:
|
|
1149
|
+
return _build_brainspace_plotter(
|
|
1150
|
+
meshes,
|
|
1151
|
+
layout,
|
|
1152
|
+
prepared,
|
|
1153
|
+
background=background,
|
|
1154
|
+
colorbar=colorbar,
|
|
1155
|
+
units=units,
|
|
1156
|
+
offscreen=True,
|
|
1157
|
+
)
|
|
1158
|
+
return None
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _nilearn_sulc_map(sulc: np.ndarray) -> np.ndarray:
|
|
1162
|
+
"""Map normalized sulcal depth to a textured white/light-gray cortex."""
|
|
1163
|
+
values = np.asarray(sulc, dtype=float)
|
|
1164
|
+
normalized = (np.clip(values, -1.0, 1.0) + 1.0) / 2.0
|
|
1165
|
+
lower, upper = _NILEARN_SULC_RANGE
|
|
1166
|
+
return lower + normalized * (upper - lower)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _render_nilearn(
|
|
1170
|
+
geometry: _SurfaceGeometry,
|
|
1171
|
+
layout: _LayoutSpec,
|
|
1172
|
+
prepared: _PreparedSource,
|
|
1173
|
+
*,
|
|
1174
|
+
background: tuple[float, float, float],
|
|
1175
|
+
colorbar: bool,
|
|
1176
|
+
units: str | None,
|
|
1177
|
+
output: Path | None,
|
|
1178
|
+
scale: tuple[int, int],
|
|
1179
|
+
transparent: bool,
|
|
1180
|
+
show: bool,
|
|
1181
|
+
) -> Any | None:
|
|
1182
|
+
"""Render prepared vertex data with Nilearn's Matplotlib surface API."""
|
|
1183
|
+
pyplot, plot_surf_stat_map, figure_class, agg_canvas = _nilearn_api()
|
|
1184
|
+
logical_inches = (
|
|
1185
|
+
layout.size[0] / 100.0,
|
|
1186
|
+
layout.size[1] / 100.0,
|
|
1187
|
+
)
|
|
1188
|
+
figure_kwargs = {
|
|
1189
|
+
"figsize": logical_inches,
|
|
1190
|
+
"dpi": 100,
|
|
1191
|
+
"facecolor": background,
|
|
1192
|
+
}
|
|
1193
|
+
if show:
|
|
1194
|
+
figure = pyplot.figure(**figure_kwargs)
|
|
1195
|
+
else:
|
|
1196
|
+
figure = figure_class(**figure_kwargs)
|
|
1197
|
+
agg_canvas(figure)
|
|
1198
|
+
keep_open = False
|
|
1199
|
+
try:
|
|
1200
|
+
figure.patch.set_facecolor(background)
|
|
1201
|
+
title = layout.label_text.get("top", [None])[0]
|
|
1202
|
+
if title is not None:
|
|
1203
|
+
figure.suptitle(title, fontsize=14, y=0.98)
|
|
1204
|
+
|
|
1205
|
+
hemispheres = {
|
|
1206
|
+
"left": (
|
|
1207
|
+
geometry.lh,
|
|
1208
|
+
np.asarray(prepared.activation[0], dtype=float),
|
|
1209
|
+
),
|
|
1210
|
+
"right": (
|
|
1211
|
+
geometry.rh,
|
|
1212
|
+
np.asarray(prepared.activation[1], dtype=float),
|
|
1213
|
+
),
|
|
1214
|
+
}
|
|
1215
|
+
try:
|
|
1216
|
+
supports_darkness = "darkness" in inspect.signature(
|
|
1217
|
+
plot_surf_stat_map
|
|
1218
|
+
).parameters
|
|
1219
|
+
except (TypeError, ValueError):
|
|
1220
|
+
supports_darkness = False
|
|
1221
|
+
control_points = prepared.color_control_points
|
|
1222
|
+
render_cmap = getattr(prepared, "render_cmap", prepared.cmap)
|
|
1223
|
+
rows, columns = layout.shape
|
|
1224
|
+
axes = []
|
|
1225
|
+
for panel in layout.panels:
|
|
1226
|
+
hemi_geometry, activation = hemispheres[panel.hemi]
|
|
1227
|
+
expected = (hemi_geometry.coordinates.shape[0],)
|
|
1228
|
+
if activation.shape != expected:
|
|
1229
|
+
raise ValueError(
|
|
1230
|
+
f"{panel.hemi} activation shape {activation.shape} does "
|
|
1231
|
+
f"not match surface vertex shape {expected}."
|
|
1232
|
+
)
|
|
1233
|
+
index = panel.row * columns + panel.column + 1
|
|
1234
|
+
axis = figure.add_subplot(
|
|
1235
|
+
rows,
|
|
1236
|
+
columns,
|
|
1237
|
+
index,
|
|
1238
|
+
projection="3d",
|
|
1239
|
+
)
|
|
1240
|
+
axis.set_facecolor(background)
|
|
1241
|
+
axes.append(axis)
|
|
1242
|
+
plot_kwargs = {
|
|
1243
|
+
"surf_mesh": [
|
|
1244
|
+
hemi_geometry.coordinates,
|
|
1245
|
+
hemi_geometry.faces,
|
|
1246
|
+
],
|
|
1247
|
+
"stat_map": activation,
|
|
1248
|
+
"bg_map": _nilearn_sulc_map(hemi_geometry.sulc),
|
|
1249
|
+
"hemi": panel.hemi,
|
|
1250
|
+
"view": panel.view,
|
|
1251
|
+
"engine": "matplotlib",
|
|
1252
|
+
"cmap": render_cmap,
|
|
1253
|
+
"colorbar": False,
|
|
1254
|
+
"avg_method": "mean",
|
|
1255
|
+
"threshold": None,
|
|
1256
|
+
"alpha": 1.0,
|
|
1257
|
+
"bg_on_data": control_points is None,
|
|
1258
|
+
"vmin": prepared.color_range[0],
|
|
1259
|
+
"vmax": prepared.color_range[1],
|
|
1260
|
+
"symmetric_cbar": prepared.mode == "signed",
|
|
1261
|
+
"cbar_tick_format": "%.2g",
|
|
1262
|
+
"title": panel.label,
|
|
1263
|
+
"axes": axis,
|
|
1264
|
+
"figure": figure,
|
|
1265
|
+
}
|
|
1266
|
+
if supports_darkness:
|
|
1267
|
+
plot_kwargs["darkness"] = None
|
|
1268
|
+
plot_surf_stat_map(**plot_kwargs)
|
|
1269
|
+
|
|
1270
|
+
if rows == 1:
|
|
1271
|
+
figure.subplots_adjust(
|
|
1272
|
+
left=0.0,
|
|
1273
|
+
right=0.94 if colorbar else 0.99,
|
|
1274
|
+
bottom=0.0,
|
|
1275
|
+
top=0.88 if title else 0.98,
|
|
1276
|
+
wspace=-0.08,
|
|
1277
|
+
)
|
|
1278
|
+
else:
|
|
1279
|
+
figure.subplots_adjust(
|
|
1280
|
+
left=0.0,
|
|
1281
|
+
right=0.92 if colorbar else 0.98,
|
|
1282
|
+
bottom=0.0,
|
|
1283
|
+
top=0.91 if title else 0.98,
|
|
1284
|
+
wspace=-0.12,
|
|
1285
|
+
hspace=-0.16,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
if colorbar:
|
|
1289
|
+
normalizer = pyplot.Normalize(
|
|
1290
|
+
vmin=prepared.color_range[0],
|
|
1291
|
+
vmax=prepared.color_range[1],
|
|
1292
|
+
)
|
|
1293
|
+
mappable = pyplot.cm.ScalarMappable(
|
|
1294
|
+
norm=normalizer,
|
|
1295
|
+
cmap=render_cmap,
|
|
1296
|
+
)
|
|
1297
|
+
mappable.set_array([])
|
|
1298
|
+
colorbar_kwargs: dict[str, Any] = {
|
|
1299
|
+
"ax": axes,
|
|
1300
|
+
"fraction": 0.018,
|
|
1301
|
+
"pad": 0.01,
|
|
1302
|
+
"shrink": 0.65,
|
|
1303
|
+
"aspect": 18,
|
|
1304
|
+
}
|
|
1305
|
+
if control_points is not None:
|
|
1306
|
+
fmin, fmid, fmax = control_points
|
|
1307
|
+
if prepared.mode == "signed":
|
|
1308
|
+
colorbar_kwargs["ticks"] = (
|
|
1309
|
+
-fmax,
|
|
1310
|
+
-fmid,
|
|
1311
|
+
-fmin,
|
|
1312
|
+
0.0,
|
|
1313
|
+
fmin,
|
|
1314
|
+
fmid,
|
|
1315
|
+
fmax,
|
|
1316
|
+
)
|
|
1317
|
+
else:
|
|
1318
|
+
colorbar_kwargs["ticks"] = control_points
|
|
1319
|
+
shared_colorbar = figure.colorbar(mappable, **colorbar_kwargs)
|
|
1320
|
+
if units:
|
|
1321
|
+
shared_colorbar.set_label(units)
|
|
1322
|
+
|
|
1323
|
+
if output is not None:
|
|
1324
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
1325
|
+
original_size = tuple(
|
|
1326
|
+
float(value) for value in figure.get_size_inches()
|
|
1327
|
+
)
|
|
1328
|
+
raster_scale = min(scale)
|
|
1329
|
+
output_dpi = 100 * raster_scale
|
|
1330
|
+
figure.set_size_inches(
|
|
1331
|
+
layout.size[0] * scale[0] / output_dpi,
|
|
1332
|
+
layout.size[1] * scale[1] / output_dpi,
|
|
1333
|
+
forward=False,
|
|
1334
|
+
)
|
|
1335
|
+
try:
|
|
1336
|
+
figure.savefig(
|
|
1337
|
+
output,
|
|
1338
|
+
dpi=output_dpi,
|
|
1339
|
+
facecolor=background,
|
|
1340
|
+
transparent=transparent,
|
|
1341
|
+
)
|
|
1342
|
+
finally:
|
|
1343
|
+
figure.set_size_inches(
|
|
1344
|
+
original_size[0],
|
|
1345
|
+
original_size[1],
|
|
1346
|
+
forward=False,
|
|
1347
|
+
)
|
|
1348
|
+
if show:
|
|
1349
|
+
pyplot.show()
|
|
1350
|
+
keep_open = True
|
|
1351
|
+
return figure
|
|
1352
|
+
if output is None:
|
|
1353
|
+
keep_open = True
|
|
1354
|
+
return figure
|
|
1355
|
+
return None
|
|
1356
|
+
finally:
|
|
1357
|
+
if not keep_open:
|
|
1358
|
+
pyplot.close(figure)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _prepare_source_data(
|
|
1362
|
+
stc: Any,
|
|
1363
|
+
*,
|
|
1364
|
+
subjects_dir: str | Path,
|
|
1365
|
+
template: str,
|
|
1366
|
+
smoothing_steps: int | Literal["nearest"] | None = None,
|
|
1367
|
+
time: float | str | None,
|
|
1368
|
+
time_window: tuple[float, float] | None,
|
|
1369
|
+
reduce: str,
|
|
1370
|
+
mode: str,
|
|
1371
|
+
threshold: float | str | None,
|
|
1372
|
+
clim: str | tuple[float, float] | dict[str, Any],
|
|
1373
|
+
robust_percentile: float,
|
|
1374
|
+
cmap: str | None,
|
|
1375
|
+
) -> _PreparedSource:
|
|
1376
|
+
resolved_smoothing_steps = _validate_smoothing_steps(smoothing_steps)
|
|
1377
|
+
normalized = _normalize_surface_stc(stc, mode)
|
|
1378
|
+
summary = _summarize_time(
|
|
1379
|
+
normalized,
|
|
1380
|
+
time,
|
|
1381
|
+
time_window,
|
|
1382
|
+
reduce,
|
|
1383
|
+
)
|
|
1384
|
+
sparse_values = np.asarray(
|
|
1385
|
+
summary.stc.data[:, 0],
|
|
1386
|
+
dtype=float,
|
|
1387
|
+
).copy()
|
|
1388
|
+
parsed_threshold = _parse_threshold(threshold, sparse_values)
|
|
1389
|
+
color_scale = _resolve_color_scale(
|
|
1390
|
+
clim,
|
|
1391
|
+
robust_percentile,
|
|
1392
|
+
mode,
|
|
1393
|
+
parsed_threshold,
|
|
1394
|
+
sparse_values,
|
|
1395
|
+
)
|
|
1396
|
+
full_stc = _expand_to_full_surface(
|
|
1397
|
+
summary.stc,
|
|
1398
|
+
template,
|
|
1399
|
+
subjects_dir,
|
|
1400
|
+
resolved_smoothing_steps,
|
|
1401
|
+
)
|
|
1402
|
+
cortex_mask = _load_cortex_mask(
|
|
1403
|
+
subjects_dir,
|
|
1404
|
+
template,
|
|
1405
|
+
full_stc.vertices,
|
|
1406
|
+
)
|
|
1407
|
+
activation = _mask_dense_activation(
|
|
1408
|
+
full_stc,
|
|
1409
|
+
parsed_threshold,
|
|
1410
|
+
cortex_mask,
|
|
1411
|
+
)
|
|
1412
|
+
cmap_name = _resolve_cmap(cmap, mode)
|
|
1413
|
+
return _PreparedSource(
|
|
1414
|
+
activation=activation,
|
|
1415
|
+
sparse_values=sparse_values,
|
|
1416
|
+
template=template,
|
|
1417
|
+
smoothing_steps=resolved_smoothing_steps,
|
|
1418
|
+
selection=summary.selection,
|
|
1419
|
+
selected_time=summary.selected_time,
|
|
1420
|
+
time_window=summary.requested_window,
|
|
1421
|
+
sampled_time_window=summary.sampled_window,
|
|
1422
|
+
reduce=summary.reduce,
|
|
1423
|
+
mode=mode,
|
|
1424
|
+
threshold=parsed_threshold,
|
|
1425
|
+
color_range=color_scale.color_range,
|
|
1426
|
+
color_control_points=color_scale.control_points,
|
|
1427
|
+
cmap=cmap_name,
|
|
1428
|
+
render_cmap=_build_render_cmap(
|
|
1429
|
+
cmap_name,
|
|
1430
|
+
mode,
|
|
1431
|
+
color_scale.color_range,
|
|
1432
|
+
color_scale.control_points,
|
|
1433
|
+
),
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _resolve_template(stc: Any, template: str) -> str:
|
|
1438
|
+
if not isinstance(template, str):
|
|
1439
|
+
raise TypeError("template must be a non-empty string.")
|
|
1440
|
+
if not template.strip():
|
|
1441
|
+
raise ValueError("template must not be empty.")
|
|
1442
|
+
subject = stc.subject
|
|
1443
|
+
if template == "native":
|
|
1444
|
+
if not isinstance(subject, str) or not subject.strip():
|
|
1445
|
+
raise ValueError(
|
|
1446
|
+
"template='native' requires stc.subject to identify the "
|
|
1447
|
+
"FreeSurfer subject."
|
|
1448
|
+
)
|
|
1449
|
+
return subject
|
|
1450
|
+
if subject != template:
|
|
1451
|
+
current = subject if subject else "<unknown>"
|
|
1452
|
+
raise ValueError(
|
|
1453
|
+
f"STC subject space {current!r} does not match requested template "
|
|
1454
|
+
f"{template!r}. Morph the STC externally with "
|
|
1455
|
+
"mne.compute_source_morph before calling plot_source()."
|
|
1456
|
+
)
|
|
1457
|
+
return template
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def plot_source(
|
|
1461
|
+
stc: Any,
|
|
1462
|
+
*,
|
|
1463
|
+
subjects_dir: str | Path,
|
|
1464
|
+
backend: Literal["brainspace", "nilearn"] = "brainspace",
|
|
1465
|
+
template: str = "native",
|
|
1466
|
+
surface: Literal["inflated", "pial", "white"] = "inflated",
|
|
1467
|
+
smoothing_steps: int | Literal["nearest"] | None = None,
|
|
1468
|
+
time: float | Literal["peak"] | None = None,
|
|
1469
|
+
time_window: tuple[float, float] | None = None,
|
|
1470
|
+
reduce: Literal["mean", "rms"] = "mean",
|
|
1471
|
+
mode: Literal["magnitude", "signed"] = "magnitude",
|
|
1472
|
+
threshold: float | str | None = None,
|
|
1473
|
+
clim: (
|
|
1474
|
+
Literal["robust"]
|
|
1475
|
+
| tuple[float, float]
|
|
1476
|
+
| dict[str, Any]
|
|
1477
|
+
) = "robust",
|
|
1478
|
+
robust_percentile: float = 98.0,
|
|
1479
|
+
cmap: str | None = None,
|
|
1480
|
+
units: str | None = None,
|
|
1481
|
+
layout: Literal["row", "grid"] = "row",
|
|
1482
|
+
title: str | None = None,
|
|
1483
|
+
colorbar: bool = True,
|
|
1484
|
+
background: str | tuple[float, float, float] = "white",
|
|
1485
|
+
output: str | Path | None = None,
|
|
1486
|
+
size: tuple[int, int] | None = None,
|
|
1487
|
+
scale: tuple[int, int] = (2, 2),
|
|
1488
|
+
transparent: bool = False,
|
|
1489
|
+
show: bool | None = None,
|
|
1490
|
+
) -> dict[str, Any]:
|
|
1491
|
+
"""Plot a cortical MNE source estimate with BrainSpace or Nilearn.
|
|
1492
|
+
|
|
1493
|
+
Numerical processing converts a supported real MNE source estimate into one
|
|
1494
|
+
full-surface activation map without modifying the input. FreeSurfer anatomy
|
|
1495
|
+
supplies the requested mesh, sulcal background, and medial wall mask. The
|
|
1496
|
+
four views use one shared whole-brain activation range and colorbar.
|
|
1497
|
+
|
|
1498
|
+
Parameters
|
|
1499
|
+
----------
|
|
1500
|
+
stc
|
|
1501
|
+
A real MNE surface, vector-surface, mixed, or mixed-vector source
|
|
1502
|
+
estimate. Mixed inputs contribute only their cortical surface part;
|
|
1503
|
+
vector inputs are displayed by vector magnitude.
|
|
1504
|
+
subjects_dir
|
|
1505
|
+
Directory containing the resolved FreeSurfer subject. It must provide
|
|
1506
|
+
``surf/lh.*``, ``surf/rh.*``, and ``surf/lh.sulc``/``rh.sulc``. Cortex
|
|
1507
|
+
labels are used to hide the medial wall when available.
|
|
1508
|
+
backend
|
|
1509
|
+
``"brainspace"`` (default) or the optional ``"nilearn"`` renderer.
|
|
1510
|
+
Both receive the same prepared surface geometry, vertex values,
|
|
1511
|
+
threshold, color controls, and medial-wall mask.
|
|
1512
|
+
template
|
|
1513
|
+
``"native"`` to use ``stc.subject``, or the name of the matching
|
|
1514
|
+
FreeSurfer subject. If anatomical spaces differ, morph the STC externally
|
|
1515
|
+
with :func:`mne.compute_source_morph` before plotting.
|
|
1516
|
+
surface
|
|
1517
|
+
FreeSurfer surface: ``"inflated"``, ``"pial"``, or ``"white"``.
|
|
1518
|
+
smoothing_steps
|
|
1519
|
+
Display interpolation used while expanding sparse source vertices to
|
|
1520
|
+
the full cortical mesh. ``None`` lets MNE choose enough adjacency
|
|
1521
|
+
iterations to cover the surface, ``"nearest"`` preserves the legacy
|
|
1522
|
+
nearest-source assignment, and a non-negative integer requests an
|
|
1523
|
+
exact number of iterations. This does not alter the pre-morph sparse
|
|
1524
|
+
values used for thresholds and color limits.
|
|
1525
|
+
time
|
|
1526
|
+
Time in seconds, ``"peak"``, or ``None``. A numeric request uses the
|
|
1527
|
+
nearest sample and the returned ``selected_time`` records the actual
|
|
1528
|
+
sample. A multi-time STC requires ``time`` or ``time_window``.
|
|
1529
|
+
time_window
|
|
1530
|
+
Inclusive ``(start, stop)`` interval in seconds. Mutually exclusive
|
|
1531
|
+
with ``time``; returned metadata records both requested and sampled
|
|
1532
|
+
bounds.
|
|
1533
|
+
reduce
|
|
1534
|
+
Whole-window reduction: ``"mean"`` or non-negative ``"rms"``.
|
|
1535
|
+
mode
|
|
1536
|
+
``"magnitude"`` takes scalar absolute values or vector magnitude.
|
|
1537
|
+
``"signed"`` preserves real scalar signs and is incompatible with
|
|
1538
|
+
vector inputs and RMS reduction.
|
|
1539
|
+
threshold
|
|
1540
|
+
Non-negative numeric display threshold, percentage string such as
|
|
1541
|
+
``"95%"``, or ``None``. Hidden values become transparent. This is a
|
|
1542
|
+
visualization control, not a test of statistical significance.
|
|
1543
|
+
clim
|
|
1544
|
+
``"robust"``, an explicit ``(lower, upper)`` tuple, or an MNE-shaped
|
|
1545
|
+
dictionary such as ``{"kind": "percent", "lims": [99, 99.9,
|
|
1546
|
+
99.99]}``. The three dictionary controls set the lower, middle, and
|
|
1547
|
+
upper color/opacity points. Signed mode uses ``pos_lims``. Robust
|
|
1548
|
+
limits, percentage controls, and percentage thresholds use combined
|
|
1549
|
+
pre-morph sparse values from both hemispheres, so dense display
|
|
1550
|
+
expansion cannot bias them. These are display controls, not tests of
|
|
1551
|
+
statistical significance.
|
|
1552
|
+
robust_percentile
|
|
1553
|
+
Percentile used as the robust upper magnitude or symmetric signed
|
|
1554
|
+
limit. Must be in ``(0, 100]``.
|
|
1555
|
+
cmap
|
|
1556
|
+
Matplotlib colormap name. Defaults to ``"Reds"`` for magnitude and
|
|
1557
|
+
``"RdBu_r"`` for signed data.
|
|
1558
|
+
units
|
|
1559
|
+
Optional activation colorbar title.
|
|
1560
|
+
layout
|
|
1561
|
+
``"row"`` orders LH lateral, LH medial, RH medial, RH lateral;
|
|
1562
|
+
``"grid"`` places lateral views above medial views.
|
|
1563
|
+
title
|
|
1564
|
+
Optional figure title.
|
|
1565
|
+
colorbar
|
|
1566
|
+
Whether to draw the single shared activation colorbar.
|
|
1567
|
+
background
|
|
1568
|
+
Matplotlib color name or RGB tuple for the render background.
|
|
1569
|
+
output
|
|
1570
|
+
Optional ``.png`` screenshot path. Missing parent directories are
|
|
1571
|
+
created.
|
|
1572
|
+
size
|
|
1573
|
+
Logical figure canvas ``(width, height)``. Layout-specific defaults
|
|
1574
|
+
are used when omitted.
|
|
1575
|
+
scale
|
|
1576
|
+
Positive integer PNG screenshot scale for each pixel axis.
|
|
1577
|
+
transparent
|
|
1578
|
+
Whether a saved PNG has a transparent background. Control-point data
|
|
1579
|
+
transparency is encoded separately in the activation colormap.
|
|
1580
|
+
show
|
|
1581
|
+
Whether to display an interactive plotter. Defaults to ``False`` when
|
|
1582
|
+
``output`` is set and ``True`` otherwise.
|
|
1583
|
+
|
|
1584
|
+
Returns
|
|
1585
|
+
-------
|
|
1586
|
+
dict
|
|
1587
|
+
Resolved source-selection, display, and output metadata. Static-only
|
|
1588
|
+
PNG output returns ``plotter=None``; interactive or explicitly retained
|
|
1589
|
+
scenes return the BrainSpace plotter or Nilearn Matplotlib figure.
|
|
1590
|
+
``selected_time`` is always an actual STC sample rather than the
|
|
1591
|
+
numeric request.
|
|
1592
|
+
|
|
1593
|
+
Raises
|
|
1594
|
+
------
|
|
1595
|
+
TypeError
|
|
1596
|
+
If an argument or source-estimate family has an unsupported type.
|
|
1597
|
+
ValueError
|
|
1598
|
+
If an argument value is invalid or incompatible with another option.
|
|
1599
|
+
FileNotFoundError
|
|
1600
|
+
If a required FreeSurfer surface or sulc file is missing.
|
|
1601
|
+
RuntimeError
|
|
1602
|
+
If MNE cannot expand sparse vertices on the matching subject surface or
|
|
1603
|
+
the selected renderer is unavailable or cannot create the scene.
|
|
1604
|
+
|
|
1605
|
+
Notes
|
|
1606
|
+
-----
|
|
1607
|
+
Sparse-to-dense expansion uses an MNE same-subject morph as display
|
|
1608
|
+
interpolation only. The default automatically fills the cortical mesh;
|
|
1609
|
+
it does not apply Gaussian smoothing or create additional independent
|
|
1610
|
+
measurements. Cross-subject registration and statistical inference remain
|
|
1611
|
+
the caller's responsibility.
|
|
1612
|
+
"""
|
|
1613
|
+
_validate_phase_one_options(
|
|
1614
|
+
subjects_dir=subjects_dir,
|
|
1615
|
+
backend=backend,
|
|
1616
|
+
surface=surface,
|
|
1617
|
+
time=time,
|
|
1618
|
+
time_window=time_window,
|
|
1619
|
+
reduce=reduce,
|
|
1620
|
+
mode=mode,
|
|
1621
|
+
robust_percentile=robust_percentile,
|
|
1622
|
+
layout=layout,
|
|
1623
|
+
output=output,
|
|
1624
|
+
size=size,
|
|
1625
|
+
scale=scale,
|
|
1626
|
+
show=show,
|
|
1627
|
+
)
|
|
1628
|
+
_validate_render_options(
|
|
1629
|
+
title=title,
|
|
1630
|
+
units=units,
|
|
1631
|
+
colorbar=colorbar,
|
|
1632
|
+
transparent=transparent,
|
|
1633
|
+
)
|
|
1634
|
+
resolved_smoothing_steps = _validate_smoothing_steps(smoothing_steps)
|
|
1635
|
+
stc_kind = _classify_stc(stc)
|
|
1636
|
+
if mode == "signed" and stc_kind in ("vector", "mixed-vector"):
|
|
1637
|
+
raise ValueError("mode='signed' is not available for vector STCs.")
|
|
1638
|
+
if mode == "signed" and reduce == "rms":
|
|
1639
|
+
raise ValueError("mode='signed' cannot be combined with reduce='rms'.")
|
|
1640
|
+
resolved_template = _resolve_template(stc, template)
|
|
1641
|
+
output_path, resolved_show = _resolve_output_and_show(output, show)
|
|
1642
|
+
output_path = _resolve_png_output(output_path)
|
|
1643
|
+
resolved_background = _resolve_background(background)
|
|
1644
|
+
resolved_size = (
|
|
1645
|
+
None if size is None else _validate_positive_int_pair("size", size)
|
|
1646
|
+
)
|
|
1647
|
+
resolved_scale = _validate_positive_int_pair("scale", scale)
|
|
1648
|
+
layout_spec = _resolve_layout(layout, resolved_size, title)
|
|
1649
|
+
geometry = _load_surface_geometry(
|
|
1650
|
+
subjects_dir,
|
|
1651
|
+
resolved_template,
|
|
1652
|
+
surface,
|
|
1653
|
+
)
|
|
1654
|
+
prepared = _prepare_source_data(
|
|
1655
|
+
stc,
|
|
1656
|
+
subjects_dir=subjects_dir,
|
|
1657
|
+
template=resolved_template,
|
|
1658
|
+
smoothing_steps=resolved_smoothing_steps,
|
|
1659
|
+
time=time,
|
|
1660
|
+
time_window=time_window,
|
|
1661
|
+
reduce=reduce,
|
|
1662
|
+
mode=mode,
|
|
1663
|
+
threshold=threshold,
|
|
1664
|
+
clim=clim,
|
|
1665
|
+
robust_percentile=robust_percentile,
|
|
1666
|
+
cmap=cmap,
|
|
1667
|
+
)
|
|
1668
|
+
render_kwargs = {
|
|
1669
|
+
"background": resolved_background,
|
|
1670
|
+
"colorbar": colorbar,
|
|
1671
|
+
"units": units,
|
|
1672
|
+
"output": output_path,
|
|
1673
|
+
"scale": resolved_scale,
|
|
1674
|
+
"transparent": transparent,
|
|
1675
|
+
"show": resolved_show,
|
|
1676
|
+
}
|
|
1677
|
+
if backend == "brainspace":
|
|
1678
|
+
meshes = _build_brainspace_meshes(geometry, prepared.activation)
|
|
1679
|
+
plotter = _render_brainspace(
|
|
1680
|
+
meshes,
|
|
1681
|
+
layout_spec,
|
|
1682
|
+
prepared,
|
|
1683
|
+
**render_kwargs,
|
|
1684
|
+
)
|
|
1685
|
+
else:
|
|
1686
|
+
plotter = _render_nilearn(
|
|
1687
|
+
geometry,
|
|
1688
|
+
layout_spec,
|
|
1689
|
+
prepared,
|
|
1690
|
+
**render_kwargs,
|
|
1691
|
+
)
|
|
1692
|
+
return {
|
|
1693
|
+
"backend": backend,
|
|
1694
|
+
"template": prepared.template,
|
|
1695
|
+
"surface": surface,
|
|
1696
|
+
"smoothing_steps": prepared.smoothing_steps,
|
|
1697
|
+
"selection": prepared.selection,
|
|
1698
|
+
"selected_time": prepared.selected_time,
|
|
1699
|
+
"time_window": prepared.time_window,
|
|
1700
|
+
"sampled_time_window": prepared.sampled_time_window,
|
|
1701
|
+
"reduce": prepared.reduce,
|
|
1702
|
+
"mode": prepared.mode,
|
|
1703
|
+
"threshold": prepared.threshold,
|
|
1704
|
+
"color_range": prepared.color_range,
|
|
1705
|
+
"color_control_points": prepared.color_control_points,
|
|
1706
|
+
"cmap": prepared.cmap,
|
|
1707
|
+
"layout": layout,
|
|
1708
|
+
"size": layout_spec.size,
|
|
1709
|
+
"scale": resolved_scale,
|
|
1710
|
+
"background": resolved_background,
|
|
1711
|
+
"transparent": transparent,
|
|
1712
|
+
"show": resolved_show,
|
|
1713
|
+
"colorbar": colorbar,
|
|
1714
|
+
"title": title,
|
|
1715
|
+
"units": units,
|
|
1716
|
+
"output": output_path,
|
|
1717
|
+
"plotter": plotter,
|
|
1718
|
+
}
|