pineuro 1.0.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.
- pineuro/__init__.py +284 -0
- pineuro/atlas_registry.py +49 -0
- pineuro/backup.py +210 -0
- pineuro/collection_qc.py +377 -0
- pineuro/config.py +90 -0
- pineuro/display.py +130 -0
- pineuro/feedback.py +4177 -0
- pineuro/gui/__init__.py +94 -0
- pineuro/gui/app.py +702 -0
- pineuro/gui/components/__init__.py +142 -0
- pineuro/gui/components/activation_plot.py +377 -0
- pineuro/gui/components/base_dialog.py +60 -0
- pineuro/gui/components/collection_progress.py +304 -0
- pineuro/gui/components/csv_poller.py +272 -0
- pineuro/gui/components/disk_space_bar.py +154 -0
- pineuro/gui/components/feedback_summary.py +486 -0
- pineuro/gui/components/manual_masks.py +98 -0
- pineuro/gui/components/mask_selection_dialog.py +1759 -0
- pineuro/gui/components/pipeline_runner.py +2817 -0
- pineuro/gui/components/qc_plot_viewer.py +224 -0
- pineuro/gui/components/qc_plots.py +492 -0
- pineuro/gui/components/qc_viewer.py +346 -0
- pineuro/gui/components/scanner_connection_dialog.py +225 -0
- pineuro/gui/components/session_selection_dialog.py +345 -0
- pineuro/gui/components/task_mirror.py +113 -0
- pineuro/gui/components/unified_attempt_dialog.py +337 -0
- pineuro/gui/config_manager.py +500 -0
- pineuro/gui/config_snapshot.py +206 -0
- pineuro/gui/dispatch.py +103 -0
- pineuro/gui/models.py +272 -0
- pineuro/gui/screens/__init__.py +6 -0
- pineuro/gui/screens/_feedback_tab.py +1198 -0
- pineuro/gui/screens/_localizer_tab.py +1081 -0
- pineuro/gui/screens/participant_workflow.py +8253 -0
- pineuro/gui/screens/study_editor.py +2200 -0
- pineuro/gui/styles.py +406 -0
- pineuro/gui/utils.py +1092 -0
- pineuro/mask_extraction.py +4399 -0
- pineuro/preprocessing.py +1605 -0
- pineuro/py.typed +0 -0
- pineuro/qc.py +4082 -0
- pineuro/scanner_setup.py +466 -0
- pineuro/script_generator.py +401 -0
- pineuro/session.py +2991 -0
- pineuro/sham.py +447 -0
- pineuro/simulator.py +746 -0
- pineuro/storage.py +123 -0
- pineuro/tasks/__init__.py +276 -0
- pineuro/tasks/ball.py +1334 -0
- pineuro/tasks/base.py +661 -0
- pineuro/tasks/designs.py +233 -0
- pineuro/tasks/factory.py +222 -0
- pineuro/tasks/gauge.py +858 -0
- pineuro/tasks/localizer_base.py +255 -0
- pineuro/tasks/protocol.py +102 -0
- pineuro/tasks/rest.py +129 -0
- pineuro/tasks/rit.py +1158 -0
- pineuro/tasks/rit_experience_sampling.py +1408 -0
- pineuro/tasks/rit_triggered_ball.py +1507 -0
- pineuro/tasks/self_other.py +525 -0
- pineuro/tasks/state.py +492 -0
- pineuro/tasks/subprocess_runner.py +438 -0
- pineuro/tasks/task_base.py +1495 -0
- pineuro/tasks/task_registry.py +611 -0
- pineuro/tasks/thermometer.py +1371 -0
- pineuro/tasks/ui_helpers.py +364 -0
- pineuro/tasks/utils.py +222 -0
- pineuro/utils.py +1971 -0
- pineuro-1.0.0.dist-info/METADATA +121 -0
- pineuro-1.0.0.dist-info/RECORD +74 -0
- pineuro-1.0.0.dist-info/WHEEL +5 -0
- pineuro-1.0.0.dist-info/entry_points.txt +2 -0
- pineuro-1.0.0.dist-info/licenses/LICENSE +13 -0
- pineuro-1.0.0.dist-info/top_level.txt +1 -0
pineuro/__init__.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""pineuro - Python Real-Time fMRI Neurofeedback
|
|
2
|
+
|
|
3
|
+
A complete pipeline for real-time fMRI neurofeedback research, from volume acquisition
|
|
4
|
+
through preprocessing, network mask extraction, and moment-to-moment BOLD activation estimation.
|
|
5
|
+
|
|
6
|
+
Main Functions
|
|
7
|
+
--------------
|
|
8
|
+
run_feedback_task : Run a complete real-time feedback task
|
|
9
|
+
preprocessing : DICOM conversion, motion correction, brain extraction
|
|
10
|
+
collect_volumes : Watch for and collect incoming scanner volumes (DICOM or NIfTI)
|
|
11
|
+
mask_extraction_rest : Extract network masks using ICA and Yeo atlas
|
|
12
|
+
mask_extraction_mni : Extract network masks directly from MNI atlas (no localizer needed)
|
|
13
|
+
mask_extraction_task : Extract network masks using GLM contrasts from task localizer
|
|
14
|
+
coregister_reference : Coregister masks to real-time scanner position
|
|
15
|
+
|
|
16
|
+
Task Framework
|
|
17
|
+
--------------
|
|
18
|
+
BaseTask : Abstract base for custom neurofeedback tasks
|
|
19
|
+
BaseLocalizerTask : Abstract base for localizer/stimulus tasks
|
|
20
|
+
BallTask : Built-in dual-network ball game with adaptive difficulty
|
|
21
|
+
ThermometerTask : Built-in single-network thermometer feedback
|
|
22
|
+
create_task : Factory function to create tasks from registry
|
|
23
|
+
NetworkRole : Declare network slot semantics for tasks
|
|
24
|
+
ActivationSmoother : Exponential moving average smoothing
|
|
25
|
+
ActivationNormalizer : Percentile-based normalization
|
|
26
|
+
|
|
27
|
+
Session Orchestration
|
|
28
|
+
--------------------
|
|
29
|
+
SessionConfig : High-level session configuration
|
|
30
|
+
LocalizerConfig / FeedbackRunConfig : Per-localizer and per-feedback-run configuration
|
|
31
|
+
run_full_session : Run complete multi-run session
|
|
32
|
+
setup_session : Initialize session (collection, preprocessing, mask extraction)
|
|
33
|
+
run_single_task : Run one feedback/transfer task via task factory
|
|
34
|
+
TaskState : Persistent state across runs
|
|
35
|
+
|
|
36
|
+
Examples
|
|
37
|
+
--------
|
|
38
|
+
>>> from pineuro import preprocessing, mask_extraction_rest, run_feedback_task
|
|
39
|
+
>>>
|
|
40
|
+
>>> # Step 1: Preprocess resting-state data
|
|
41
|
+
>>> preproc_file, mask_file, ref_file = preprocessing(
|
|
42
|
+
... participant_id='sub-01', session_id='ses-01', task='rest',
|
|
43
|
+
... root_dir='/data/sub-01/ses-01/preprocessing',
|
|
44
|
+
... input_dicoms='/scanner/sub-01/rest',
|
|
45
|
+
... )
|
|
46
|
+
>>>
|
|
47
|
+
>>> # Step 2: Extract participant-specific network masks (ICA + Yeo atlas)
|
|
48
|
+
>>> mask_paths = mask_extraction_rest(
|
|
49
|
+
... participant_id='sub-01', session_id='ses-01',
|
|
50
|
+
... root_dir='/data/sub-01/ses-01/mask_extraction',
|
|
51
|
+
... input_file=preproc_file, mask_file=mask_file, ref_file=ref_file,
|
|
52
|
+
... networks=['ContA', 'DefaultA'], # returns a list of mask Paths
|
|
53
|
+
... )
|
|
54
|
+
>>>
|
|
55
|
+
>>> # Step 3: Run a real-time feedback task (here, simulated offline)
|
|
56
|
+
>>> result = run_feedback_task(
|
|
57
|
+
... mask_paths=mask_paths, subject='sub-01', session='ses-01',
|
|
58
|
+
... task='ball', run=1,
|
|
59
|
+
... output_dir='/data/sub-01/ses-01/feedback',
|
|
60
|
+
... collection_dir='/scanner/incoming',
|
|
61
|
+
... n_baseline=15, n_volumes=200, tr=2.0,
|
|
62
|
+
... simulator_source_dir='/data/sub-01/feedback_dicoms', simulator_speed=10.0,
|
|
63
|
+
... )
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
import importlib as _importlib
|
|
67
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
68
|
+
from importlib.metadata import version as _pkg_version
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
# Single source of truth: the version declared in pyproject.toml and
|
|
72
|
+
# recorded in the installed package metadata.
|
|
73
|
+
__version__ = _pkg_version("pineuro")
|
|
74
|
+
except _PackageNotFoundError: # pragma: no cover - package not installed
|
|
75
|
+
__version__ = "unknown"
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Lazy import system (PEP 562)
|
|
79
|
+
#
|
|
80
|
+
# Heavy neuroimaging libraries (ANTsPy, nilearn, scipy, nibabel) take 8-17s
|
|
81
|
+
# to import on cold start. By deferring submodule imports until first access,
|
|
82
|
+
# the GUI starts instantly and scripts only pay for what they actually use.
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
# Mapping: attribute_name -> (submodule_path, attribute_name_in_submodule)
|
|
86
|
+
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
|
87
|
+
# --- feedback.py (heavy: nibabel, scipy, watchdog, pydicom) ---
|
|
88
|
+
"run_feedback_task": (".feedback", "run_feedback_task"),
|
|
89
|
+
"ConnectivityEstimator": (".feedback", "ConnectivityEstimator"),
|
|
90
|
+
"get_next_run_number": (".feedback", "get_next_run_number"),
|
|
91
|
+
"get_slot_run_info": (".feedback", "get_slot_run_info"),
|
|
92
|
+
# --- preprocessing.py (heavy: nibabel, scipy) ---
|
|
93
|
+
"preprocessing": (".preprocessing", "preprocessing"),
|
|
94
|
+
"collect_volumes": (".preprocessing", "collect_volumes"),
|
|
95
|
+
# --- mask_extraction.py (heavy: nibabel, scipy, pandas) ---
|
|
96
|
+
"mask_extraction_rest": (".mask_extraction", "mask_extraction_rest"),
|
|
97
|
+
"mask_extraction_mni": (".mask_extraction", "mask_extraction_mni"),
|
|
98
|
+
"mask_extraction_task": (".mask_extraction", "mask_extraction_task"),
|
|
99
|
+
"run_mask_extraction_from_config": (".mask_extraction", "run_mask_extraction_from_config"),
|
|
100
|
+
"import_manual_masks": (".mask_extraction", "import_manual_masks"),
|
|
101
|
+
"coregister_reference": (".mask_extraction", "coregister_reference"),
|
|
102
|
+
"create_tissue_masks": (".mask_extraction", "create_tissue_masks"),
|
|
103
|
+
"ATLAS_SOURCES": (".atlas_registry", "ATLAS_SOURCES"),
|
|
104
|
+
"get_atlas_labels": (".mask_extraction", "get_atlas_labels"),
|
|
105
|
+
# --- tasks (lightweight but deferred to avoid loading task modules) ---
|
|
106
|
+
"BaseTask": (".tasks", "BaseTask"),
|
|
107
|
+
"BaseLocalizerTask": (".tasks", "BaseLocalizerTask"),
|
|
108
|
+
"BallTask": (".tasks", "BallTask"),
|
|
109
|
+
"ThermometerTask": (".tasks", "ThermometerTask"),
|
|
110
|
+
"ActivationSmoother": (".tasks", "ActivationSmoother"),
|
|
111
|
+
"ActivationNormalizer": (".tasks", "ActivationNormalizer"),
|
|
112
|
+
"NetworkRole": (".tasks", "NetworkRole"),
|
|
113
|
+
"create_task": (".tasks", "create_task"),
|
|
114
|
+
"create_localizer_task": (".tasks", "create_localizer_task"),
|
|
115
|
+
# --- tasks.state ---
|
|
116
|
+
"TaskState": (".tasks.state", "TaskState"),
|
|
117
|
+
"RunInfo": (".tasks.state", "RunInfo"),
|
|
118
|
+
# --- session.py (lightweight: stdlib only) ---
|
|
119
|
+
"SessionConfig": (".session", "SessionConfig"),
|
|
120
|
+
"PathConfig": (".session", "PathConfig"),
|
|
121
|
+
"LocalizerConfig": (".session", "LocalizerConfig"),
|
|
122
|
+
"LocalizerExtractionConfig": (".session", "LocalizerExtractionConfig"),
|
|
123
|
+
"FeedbackRunConfig": (".session", "FeedbackRunConfig"),
|
|
124
|
+
"QCConfig": (".session", "QCConfig"),
|
|
125
|
+
"GLMConfig": (".session", "GLMConfig"),
|
|
126
|
+
"ConnectivityConfig": (".session", "ConnectivityConfig"),
|
|
127
|
+
"IntermittentConfig": (".session", "IntermittentConfig"),
|
|
128
|
+
"SimulatorConfig": (".session", "SimulatorConfig"),
|
|
129
|
+
"ScannerConfig": (".session", "ScannerConfig"),
|
|
130
|
+
"ReferenceConfig": (".session", "ReferenceConfig"),
|
|
131
|
+
"SessionMetadata": (".session", "SessionMetadata"),
|
|
132
|
+
"ShamConfig": (".session", "ShamConfig"),
|
|
133
|
+
"ShamRunConfig": (".session", "ShamRunConfig"),
|
|
134
|
+
"SimulatorSourceMapping": (".session", "SimulatorSourceMapping"),
|
|
135
|
+
"setup_session": (".session", "setup_session"),
|
|
136
|
+
"run_single_task": (".session", "run_single_task"),
|
|
137
|
+
"run_full_session": (".session", "run_full_session"),
|
|
138
|
+
"run_next_task": (".session", "run_next_task"),
|
|
139
|
+
"reset_task_state": (".session", "reset_task_state"),
|
|
140
|
+
"resolve_localizer_masks": (".session", "resolve_localizer_masks"),
|
|
141
|
+
"detect_existing_sessions": (".session", "detect_existing_sessions"),
|
|
142
|
+
"next_session_id": (".session", "next_session_id"),
|
|
143
|
+
# --- sham.py ---
|
|
144
|
+
"ShamActivationProvider": (".sham", "ShamActivationProvider"),
|
|
145
|
+
"ReplayActivationProvider": (".sham", "ReplayActivationProvider"),
|
|
146
|
+
"load_randomization": (".sham", "load_randomization"),
|
|
147
|
+
"generate_randomization": (".sham", "generate_randomization"),
|
|
148
|
+
"get_condition_for_participant": (".sham", "get_condition_for_participant"),
|
|
149
|
+
"find_feedbacklog_for_replay": (".sham", "find_feedbacklog_for_replay"),
|
|
150
|
+
# --- utils.py (heavy: numpy, nibabel, scipy, templateflow) ---
|
|
151
|
+
"enable_timing": (".utils", "enable_timing"),
|
|
152
|
+
"disable_timing": (".utils", "disable_timing"),
|
|
153
|
+
"get_timing_data": (".utils", "get_timing_data"),
|
|
154
|
+
"save_timing_report": (".utils", "save_timing_report"),
|
|
155
|
+
"Timer": (".utils", "Timer"),
|
|
156
|
+
"check_platform_requirements": (".utils", "check_platform_requirements"),
|
|
157
|
+
"outputs_exist": (".utils", "outputs_exist"),
|
|
158
|
+
"count_volume_files": (".utils", "count_volume_files"),
|
|
159
|
+
"collection_is_complete": (".utils", "collection_is_complete"),
|
|
160
|
+
"ProgressEmitter": (".utils", "ProgressEmitter"),
|
|
161
|
+
"compute_hrf": (".utils", "compute_hrf"),
|
|
162
|
+
"compute_boxcar_task_regressor": (".utils", "compute_boxcar_task_regressor"),
|
|
163
|
+
# --- script_generator.py ---
|
|
164
|
+
"generate_mask_extraction_script": (".script_generator", "generate_mask_extraction_script"),
|
|
165
|
+
"save_script": (".script_generator", "save_script"),
|
|
166
|
+
# --- scanner_setup.py (lightweight: stdlib only) ---
|
|
167
|
+
"test_scanner_connection": (".scanner_setup", "test_scanner_connection"),
|
|
168
|
+
"ConnectionTestResult": (".scanner_setup", "ConnectionTestResult"),
|
|
169
|
+
"ping_scanner": (".scanner_setup", "ping_scanner"),
|
|
170
|
+
"check_watch_directory": (".scanner_setup", "check_watch_directory"),
|
|
171
|
+
"get_setup_instructions": (".scanner_setup", "get_setup_instructions"),
|
|
172
|
+
# --- config module ---
|
|
173
|
+
"config": (".config", ""), # Module import, not attribute
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def __getattr__(name: str):
|
|
178
|
+
if name in _LAZY_IMPORTS:
|
|
179
|
+
module_path, attr_name = _LAZY_IMPORTS[name]
|
|
180
|
+
module = _importlib.import_module(module_path, __name__)
|
|
181
|
+
if not attr_name:
|
|
182
|
+
# Module-level import (e.g., config)
|
|
183
|
+
val = module
|
|
184
|
+
else:
|
|
185
|
+
val = getattr(module, attr_name)
|
|
186
|
+
globals()[name] = val # Cache for subsequent access
|
|
187
|
+
return val
|
|
188
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = [
|
|
192
|
+
# Version
|
|
193
|
+
"__version__",
|
|
194
|
+
# Core functions
|
|
195
|
+
"run_feedback_task",
|
|
196
|
+
"ConnectivityEstimator",
|
|
197
|
+
"get_next_run_number",
|
|
198
|
+
"get_slot_run_info",
|
|
199
|
+
"preprocessing",
|
|
200
|
+
"collect_volumes",
|
|
201
|
+
"mask_extraction_rest",
|
|
202
|
+
"mask_extraction_mni",
|
|
203
|
+
"mask_extraction_task",
|
|
204
|
+
"run_mask_extraction_from_config",
|
|
205
|
+
"import_manual_masks",
|
|
206
|
+
"coregister_reference",
|
|
207
|
+
"ATLAS_SOURCES",
|
|
208
|
+
"get_atlas_labels",
|
|
209
|
+
# Task classes
|
|
210
|
+
"BaseTask",
|
|
211
|
+
"BaseLocalizerTask",
|
|
212
|
+
"BallTask",
|
|
213
|
+
"ThermometerTask",
|
|
214
|
+
"ActivationSmoother",
|
|
215
|
+
"ActivationNormalizer",
|
|
216
|
+
# Task registry & factory
|
|
217
|
+
"NetworkRole",
|
|
218
|
+
"create_task",
|
|
219
|
+
"create_localizer_task",
|
|
220
|
+
# Task regressor computation
|
|
221
|
+
"compute_hrf",
|
|
222
|
+
"compute_boxcar_task_regressor",
|
|
223
|
+
# Task state
|
|
224
|
+
"TaskState",
|
|
225
|
+
"RunInfo",
|
|
226
|
+
# Configuration dataclasses
|
|
227
|
+
"SessionConfig",
|
|
228
|
+
"PathConfig",
|
|
229
|
+
"LocalizerConfig",
|
|
230
|
+
"LocalizerExtractionConfig",
|
|
231
|
+
"FeedbackRunConfig",
|
|
232
|
+
"QCConfig",
|
|
233
|
+
"GLMConfig",
|
|
234
|
+
"ConnectivityConfig",
|
|
235
|
+
"IntermittentConfig",
|
|
236
|
+
"SimulatorConfig",
|
|
237
|
+
"ScannerConfig",
|
|
238
|
+
"ReferenceConfig",
|
|
239
|
+
"SessionMetadata",
|
|
240
|
+
"ShamConfig",
|
|
241
|
+
"ShamRunConfig",
|
|
242
|
+
"SimulatorSourceMapping",
|
|
243
|
+
# Sham functionality
|
|
244
|
+
"ShamActivationProvider",
|
|
245
|
+
"ReplayActivationProvider",
|
|
246
|
+
"load_randomization",
|
|
247
|
+
"generate_randomization",
|
|
248
|
+
"get_condition_for_participant",
|
|
249
|
+
"find_feedbacklog_for_replay",
|
|
250
|
+
# Orchestration functions
|
|
251
|
+
"setup_session",
|
|
252
|
+
"run_single_task",
|
|
253
|
+
"run_full_session",
|
|
254
|
+
"run_next_task",
|
|
255
|
+
"reset_task_state",
|
|
256
|
+
# Multi-session support functions
|
|
257
|
+
"resolve_localizer_masks",
|
|
258
|
+
"detect_existing_sessions",
|
|
259
|
+
"next_session_id",
|
|
260
|
+
# Timing functions
|
|
261
|
+
"enable_timing",
|
|
262
|
+
"disable_timing",
|
|
263
|
+
"get_timing_data",
|
|
264
|
+
"save_timing_report",
|
|
265
|
+
"Timer",
|
|
266
|
+
# Platform checks
|
|
267
|
+
"check_platform_requirements",
|
|
268
|
+
# Utilities
|
|
269
|
+
"outputs_exist",
|
|
270
|
+
"count_volume_files",
|
|
271
|
+
"collection_is_complete",
|
|
272
|
+
"ProgressEmitter",
|
|
273
|
+
# Script generation
|
|
274
|
+
"generate_mask_extraction_script",
|
|
275
|
+
"save_script",
|
|
276
|
+
# Scanner setup
|
|
277
|
+
"test_scanner_connection",
|
|
278
|
+
"ConnectionTestResult",
|
|
279
|
+
"ping_scanner",
|
|
280
|
+
"check_watch_directory",
|
|
281
|
+
"get_setup_instructions",
|
|
282
|
+
# Configuration
|
|
283
|
+
"config",
|
|
284
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Atlas source registry — lightweight metadata for GUI dropdowns.
|
|
2
|
+
|
|
3
|
+
This module is intentionally free of heavy dependencies (no nibabel, scipy,
|
|
4
|
+
nilearn, etc.) so that the GUI can build atlas-related dropdowns without
|
|
5
|
+
triggering multi-second import chains.
|
|
6
|
+
|
|
7
|
+
The actual atlas *fetching* and *label resolution* live in mask_extraction.py,
|
|
8
|
+
which imports ATLAS_SOURCES from here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# ────────────────────────────────────────────────────────────────────
|
|
14
|
+
# Atlas source registry
|
|
15
|
+
# ────────────────────────────────────────────────────────────────────
|
|
16
|
+
ATLAS_SOURCES: dict[str, dict[str, Any]] = {
|
|
17
|
+
# Network atlases (resting-state ICA pathway)
|
|
18
|
+
"yeo_7": {"display_name": "Yeo 7-network", "type": "network", "n_networks": 7},
|
|
19
|
+
"yeo_17": {"display_name": "Yeo 17-network", "type": "network", "n_networks": 17},
|
|
20
|
+
"schaefer_100_7net": {
|
|
21
|
+
"display_name": "Schaefer 100 (7-network)",
|
|
22
|
+
"type": "network",
|
|
23
|
+
"n_networks": 7,
|
|
24
|
+
"n_rois": 100,
|
|
25
|
+
},
|
|
26
|
+
"schaefer_100_17net": {
|
|
27
|
+
"display_name": "Schaefer 100 (17-network)",
|
|
28
|
+
"type": "network",
|
|
29
|
+
"n_networks": 17,
|
|
30
|
+
"n_rois": 100,
|
|
31
|
+
},
|
|
32
|
+
"schaefer_400_7net": {
|
|
33
|
+
"display_name": "Schaefer 400 (7-network)",
|
|
34
|
+
"type": "network",
|
|
35
|
+
"n_networks": 7,
|
|
36
|
+
"n_rois": 400,
|
|
37
|
+
},
|
|
38
|
+
"schaefer_400_17net": {
|
|
39
|
+
"display_name": "Schaefer 400 (17-network)",
|
|
40
|
+
"type": "network",
|
|
41
|
+
"n_networks": 17,
|
|
42
|
+
"n_rois": 400,
|
|
43
|
+
},
|
|
44
|
+
# Anatomical/parcel atlases (cluster thresholding pathway)
|
|
45
|
+
"harvard_oxford": {"display_name": "Harvard-Oxford Cortical", "type": "anatomical"},
|
|
46
|
+
"aal": {"display_name": "AAL (SPM12)", "type": "anatomical"},
|
|
47
|
+
# Whole-brain gray matter (no region selection needed)
|
|
48
|
+
"gray_matter": {"display_name": "Gray Matter (whole-brain)", "type": "tissue"},
|
|
49
|
+
}
|
pineuro/backup.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Session backup: mirror a participant-session folder to a second location.
|
|
2
|
+
|
|
3
|
+
Copies new and changed files from ``<data root>/<participant>/<session>`` to
|
|
4
|
+
``<destination>/<participant>/<session>``. A file is copied when it is missing
|
|
5
|
+
at the destination or its size or modification time differs, through a
|
|
6
|
+
temporary name, so an interrupted copy never leaves a truncated file under the
|
|
7
|
+
real name. Nothing at the destination is ever deleted: files later discarded or
|
|
8
|
+
cleaned up locally stay in the backup. ``tmp/`` (simulator and per-run scratch)
|
|
9
|
+
is never copied, and the raw DICOM copies in ``collection/`` only when the study
|
|
10
|
+
opts in.
|
|
11
|
+
|
|
12
|
+
The destination folder itself must already exist, so a backup to a drive that
|
|
13
|
+
is not connected fails instead of silently filling the local disk under the
|
|
14
|
+
drive's empty mount point. Standard library only, so the GUI can import it at
|
|
15
|
+
top level; the participant workflow runs it between acquisitions.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# The study YAML's top-level ``backup:`` block, with its defaults.
|
|
31
|
+
BACKUP_DEFAULTS: dict[str, Any] = {"enabled": False, "destination": "", "include_dicoms": True}
|
|
32
|
+
|
|
33
|
+
# Never backed up: simulator incoming copies and other per-run scratch.
|
|
34
|
+
_ALWAYS_EXCLUDED = frozenset({"tmp"})
|
|
35
|
+
# Raw DICOM copies; backed up only with ``include_dicoms``.
|
|
36
|
+
_DICOM_DIR = "collection"
|
|
37
|
+
_PARTIAL_SUFFIX = ".pineuro-partial"
|
|
38
|
+
MANIFEST_NAME = ".pineuro_backup.json"
|
|
39
|
+
# Modification times are compared with this tolerance because FAT/exFAT drives
|
|
40
|
+
# (common on external backup disks) store them at 2 s resolution.
|
|
41
|
+
_MTIME_TOLERANCE = 2.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class BackupResult:
|
|
46
|
+
"""Outcome of one :func:`mirror_session` pass."""
|
|
47
|
+
|
|
48
|
+
destination: Path
|
|
49
|
+
copied: int = 0
|
|
50
|
+
skipped: int = 0 # already up to date at the destination
|
|
51
|
+
bytes_copied: int = 0
|
|
52
|
+
errors: list[str] = field(default_factory=list)
|
|
53
|
+
stopped: bool = False # interrupted via stop_event before finishing
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def complete(self) -> bool:
|
|
57
|
+
return not self.errors and not self.stopped
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def backup_destination(session_dir: str | Path, destination: str | Path) -> Path:
|
|
61
|
+
"""Where *session_dir* (``<root>/<participant>/<session>``) is mirrored to."""
|
|
62
|
+
session_dir = Path(session_dir)
|
|
63
|
+
return Path(destination) / session_dir.parent.name / session_dir.name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_backup_config(backup: dict[str, Any], data_root: str | Path) -> str | None:
|
|
67
|
+
"""Return an error message for an unusable ``backup:`` study block, or None."""
|
|
68
|
+
if not backup.get("enabled"):
|
|
69
|
+
return None
|
|
70
|
+
destination = str(backup.get("destination") or "").strip()
|
|
71
|
+
if not destination:
|
|
72
|
+
return "Backup is enabled but no backup folder is set."
|
|
73
|
+
if data_root and _same_path(destination, data_root):
|
|
74
|
+
return "The backup folder cannot be the data directory itself."
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_manifest(session_dir: str | Path, destination: str | Path) -> dict[str, Any] | None:
|
|
79
|
+
"""The record of the last backup pass of *session_dir*, or None if there is none."""
|
|
80
|
+
path = backup_destination(session_dir, destination) / MANIFEST_NAME
|
|
81
|
+
try:
|
|
82
|
+
with open(path, encoding="utf-8") as f:
|
|
83
|
+
data = json.load(f)
|
|
84
|
+
except (OSError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
return data if isinstance(data, dict) else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mirror_session(
|
|
90
|
+
session_dir: str | Path,
|
|
91
|
+
destination: str | Path,
|
|
92
|
+
*,
|
|
93
|
+
include_dicoms: bool = True,
|
|
94
|
+
stop_event: Any = None,
|
|
95
|
+
) -> BackupResult:
|
|
96
|
+
"""Copy new and changed files from *session_dir* to its backup location.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
session_dir
|
|
101
|
+
The participant-session folder, ``<data root>/<participant>/<session>``.
|
|
102
|
+
destination
|
|
103
|
+
Backup folder; must already exist. The session is mirrored to
|
|
104
|
+
``<destination>/<participant>/<session>``.
|
|
105
|
+
include_dicoms
|
|
106
|
+
Also copy the raw DICOM copies in ``collection/``.
|
|
107
|
+
stop_event
|
|
108
|
+
Anything with ``is_set()``; checked between files, so a pass can be
|
|
109
|
+
interrupted (e.g. when an acquisition starts) and resumed later.
|
|
110
|
+
"""
|
|
111
|
+
src_root = Path(session_dir)
|
|
112
|
+
dst_root = backup_destination(src_root, destination)
|
|
113
|
+
result = BackupResult(destination=dst_root)
|
|
114
|
+
|
|
115
|
+
if not Path(destination).is_dir():
|
|
116
|
+
result.errors.append(f"Backup folder {destination} is not available (is the drive connected?)")
|
|
117
|
+
return result
|
|
118
|
+
if _same_path(dst_root, src_root) or _is_within(dst_root, src_root):
|
|
119
|
+
result.errors.append(f"Backup location {dst_root} is inside the session folder; nothing was copied")
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
excluded = set(_ALWAYS_EXCLUDED)
|
|
123
|
+
if not include_dicoms:
|
|
124
|
+
excluded.add(_DICOM_DIR)
|
|
125
|
+
|
|
126
|
+
for src in _iter_files(src_root, excluded):
|
|
127
|
+
if stop_event is not None and stop_event.is_set():
|
|
128
|
+
result.stopped = True
|
|
129
|
+
break
|
|
130
|
+
rel = src.relative_to(src_root)
|
|
131
|
+
dst = dst_root / rel
|
|
132
|
+
try:
|
|
133
|
+
src_stat = src.stat()
|
|
134
|
+
if _up_to_date(src_stat, dst):
|
|
135
|
+
result.skipped += 1
|
|
136
|
+
continue
|
|
137
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
partial = dst.with_name(dst.name + _PARTIAL_SUFFIX)
|
|
139
|
+
shutil.copy2(src, partial)
|
|
140
|
+
os.replace(partial, dst)
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
result.errors.append(f"{rel}: {exc}")
|
|
143
|
+
continue
|
|
144
|
+
result.copied += 1
|
|
145
|
+
result.bytes_copied += src_stat.st_size
|
|
146
|
+
|
|
147
|
+
_write_manifest(src_root, result)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _iter_files(root: Path, excluded_top_level: set[str]) -> Iterator[Path]:
|
|
152
|
+
"""Regular files under *root* (symlinks skipped), minus excluded top-level folders."""
|
|
153
|
+
stack: list[tuple[Path, bool]] = [(root, True)]
|
|
154
|
+
while stack:
|
|
155
|
+
current, is_top = stack.pop()
|
|
156
|
+
try:
|
|
157
|
+
with os.scandir(current) as entries:
|
|
158
|
+
for entry in entries:
|
|
159
|
+
try:
|
|
160
|
+
if entry.is_dir(follow_symlinks=False):
|
|
161
|
+
if not (is_top and entry.name in excluded_top_level):
|
|
162
|
+
stack.append((Path(entry.path), False))
|
|
163
|
+
elif entry.is_file(follow_symlinks=False) and not entry.name.endswith(_PARTIAL_SUFFIX):
|
|
164
|
+
yield Path(entry.path)
|
|
165
|
+
except OSError:
|
|
166
|
+
continue
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
logger.warning(f"Backup: cannot read {current}: {exc}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _up_to_date(src_stat: os.stat_result, dst: Path) -> bool:
|
|
172
|
+
try:
|
|
173
|
+
dst_stat = dst.stat()
|
|
174
|
+
except OSError:
|
|
175
|
+
return False
|
|
176
|
+
return dst_stat.st_size == src_stat.st_size and abs(dst_stat.st_mtime - src_stat.st_mtime) <= _MTIME_TOLERANCE
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _write_manifest(src_root: Path, result: BackupResult) -> None:
|
|
180
|
+
record = {
|
|
181
|
+
"source": str(src_root),
|
|
182
|
+
"finished": datetime.now().isoformat(timespec="seconds"),
|
|
183
|
+
"complete": result.complete,
|
|
184
|
+
"copied": result.copied,
|
|
185
|
+
"skipped": result.skipped,
|
|
186
|
+
"bytes_copied": result.bytes_copied,
|
|
187
|
+
"errors": len(result.errors),
|
|
188
|
+
}
|
|
189
|
+
path = result.destination / MANIFEST_NAME
|
|
190
|
+
try:
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
partial = path.with_name(path.name + _PARTIAL_SUFFIX)
|
|
193
|
+
partial.write_text(json.dumps(record, indent=2), encoding="utf-8")
|
|
194
|
+
os.replace(partial, path)
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
result.errors.append(f"{MANIFEST_NAME}: {exc}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _same_path(a: str | Path, b: str | Path) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
return Path(a).resolve() == Path(b).resolve()
|
|
202
|
+
except OSError:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_within(path: Path, parent: Path) -> bool:
|
|
207
|
+
try:
|
|
208
|
+
return path.resolve().is_relative_to(parent.resolve())
|
|
209
|
+
except (OSError, ValueError):
|
|
210
|
+
return False
|