oceanproc 1.1.7__tar.gz → 1.2.0__tar.gz

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.
Files changed (22) hide show
  1. {oceanproc-1.1.7 → oceanproc-1.2.0}/PKG-INFO +2 -1
  2. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/bids_wrapper.py +2 -3
  3. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/group_series.py +96 -14
  4. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/ocean_proc.py +97 -36
  5. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/preprocessing_wrapper.py +86 -32
  6. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/utils.py +1 -1
  7. {oceanproc-1.1.7 → oceanproc-1.2.0}/pyproject.toml +3 -3
  8. oceanproc-1.1.7/oceanproc/firstlevel/Ocean-Proc.code-workspace +0 -8
  9. {oceanproc-1.1.7 → oceanproc-1.2.0}/README.md +0 -0
  10. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/__init__.py +0 -0
  11. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/events_long.py +0 -0
  12. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/firstlevel/__init__.py +0 -0
  13. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/firstlevel/run_glm.py +0 -0
  14. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/firstlevel/tests/__init__.py +0 -0
  15. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/firstlevel/tests/test_run_glm.py +0 -0
  16. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/grouplevel/__init__.py +0 -0
  17. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/grouplevel/computations.py +0 -0
  18. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/grouplevel/design_mat_utils.py +0 -0
  19. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/grouplevel/dscalar_utils.py +0 -0
  20. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/grouplevel/utils.py +0 -0
  21. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/oceanparse.py +0 -0
  22. {oceanproc-1.1.7 → oceanproc-1.2.0}/oceanproc/segmentation_wrapper.py +0 -0
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: oceanproc
3
- Version: 1.1.7
3
+ Version: 1.2.0
4
4
  Summary: MRI preprocessing for WUSTL Ocean labs
5
5
  Author-Email: Ramone Agard <rhagard@wustl.edu>, Joey Scanga <joeyscanga92@gmail.com>
6
6
  Requires-Python: <3.13,>=3.10
7
+ Requires-Dist: sphinx-rtd-theme<3.0.0,>=2.0.0
7
8
  Requires-Dist: flask<4.0.0,>=3.0.3
8
9
  Requires-Dist: asttokens<3.0.0,>=2.4.1
9
10
  Requires-Dist: numpy>=1.26.0
@@ -11,7 +11,7 @@ from textwrap import dedent
11
11
  import xml.etree.ElementTree as et
12
12
  from .utils import exit_program_early, prompt_user_continue, prepare_subprocess_logging, debug_logging, log_linebreak, flags, run_subprocess
13
13
  import logging
14
- from bids import BIDSLayout
14
+ from bids import BIDSLayout, BIDSLayoutIndexer
15
15
  from bisect import bisect
16
16
  module_logger = logging.getLogger("fsspec")
17
17
  module_logger.setLevel(logging.CRITICAL)
@@ -23,7 +23,7 @@ remove_unusable -> relate xml and json with name and aqcuisition time (and serie
23
23
 
24
24
 
25
25
  @debug_logging
26
- def remove_unusable_runs(bids_path:Path, subject:str, session:str):
26
+ def remove_unusable_runs(bids_layout:BIDSLayout, subject:str, session:str):
27
27
  """
28
28
  Will remove unusable scans from list of scans after dcm2bids has run.
29
29
 
@@ -41,7 +41,6 @@ def remove_unusable_runs(bids_path:Path, subject:str, session:str):
41
41
  logger.info("####### Removing the scans marked 'unusable' #######\n")
42
42
 
43
43
  # Try to delete based on "quality" key in sidecar files:
44
- bids_layout = BIDSLayout(bids_path, validate=False)
45
44
  data_files = bids_layout.get(subject=subject, session=session, extension="nii.gz", datatype=".*", regex_search=True)
46
45
  if len(data_files) == 0:
47
46
  exit_program_early("Could not find any data files in the bids directory.")
@@ -8,7 +8,12 @@ from pathlib import Path
8
8
  import xml.etree.ElementTree as et
9
9
  from bids import BIDSLayout
10
10
  import logging
11
+
12
+ from bids.layout import BIDSFile
11
13
  from .utils import exit_program_early, debug_logging, log_linebreak, flags
14
+ import nibabel as nib
15
+ import numpy as np
16
+ import shutil
12
17
 
13
18
  module_logger = logging.getLogger("fsspec")
14
19
  module_logger.setLevel(logging.CRITICAL)
@@ -46,11 +51,33 @@ logger = logging.getLogger(__name__)
46
51
  # return (sorted(localizers), study_id)
47
52
 
48
53
  DEFAULT_LOCALIZER_LABEL = "00"
54
+ EXTRACTED_ACQ_VALUE = "bestVol"
49
55
 
50
56
  def get_empty_group():
51
57
  return {"task":set(), "fmapAP": set(), "fmapPA": set()}
52
58
 
53
59
 
60
+ def get_json_sidecar(bids_file:BIDSFile|str, bids_layout:BIDSLayout) -> BIDSFile:
61
+ if isinstance(bids_file, BIDSFile):
62
+ bids_file = bids_file.path
63
+ sidecar_ents = bids_layout.parse_file_entities(bids_file) | {"extension":".json"}
64
+ if "fmap" in sidecar_ents:
65
+ del sidecar_ents["fmap"]
66
+ sidecar_files = bids_layout.get(**sidecar_ents)
67
+ valid_files = [bf for bf in sidecar_files if set(sidecar_ents.keys()) == set(bids_layout.parse_file_entities(bf).keys())]
68
+ if len(valid_files) != 1:
69
+ exit_program_early(f"Cannot find the associated JSON file for bids file: {bids_file}")
70
+ return valid_files[0]
71
+
72
+
73
+ def reset_intended_for(fmap_file:BIDSFile|str, bids_layout:BIDSLayout):
74
+ sidecar_file = get_json_sidecar(fmap_file, bids_layout)
75
+ jd = sidecar_file.get_dict()
76
+ jd["IntendedFor"] = []
77
+ with open(sidecar_file.path, "w") as out_file:
78
+ json.dump(jd, out_file, indent=4)
79
+
80
+
54
81
  def get_func_from_bids(bids_layout: BIDSLayout,
55
82
  subject:str,
56
83
  session:str,
@@ -78,6 +105,11 @@ def get_fmap_from_bids(bids_layout: BIDSLayout,
78
105
  exit_program_early("Could not find any fieldmap files for this subject and session.")
79
106
 
80
107
  for epi_file in fmap_files:
108
+ reset_intended_for(epi_file, bids_layout)
109
+ file_entities = bids_layout.parse_file_entities(epi_file.path)
110
+ # skip previously extracted acqusitions
111
+ if ("acquisition" in file_entities) and file_entities["acquisition"] == EXTRACTED_ACQ_VALUE:
112
+ continue
81
113
  acq_time = datetime.strptime(epi_file.entities["AcquisitionTime"], "%H:%M:%S.%f")
82
114
  direction = f"fmap{epi_file.entities['direction']}"
83
115
  localizer_block_label = epi_file.entities["localizer_block"] if "localizer_block" in epi_file.entities else DEFAULT_LOCALIZER_LABEL
@@ -154,12 +186,12 @@ def even_fmap_pairing(localizer_group: dict) -> tuple:
154
186
  @debug_logging
155
187
  def map_fmap_to_func(subject:str,
156
188
  session:str,
157
- bids_path:Path,
158
- allow_uneven_fmap_groups: bool = False):
189
+ layout:BIDSLayout,
190
+ allow_uneven_fmap_groups: bool = False,
191
+ extract_best_fmap: bool = False):
159
192
 
160
193
  logger.info("####### Pairing field maps to functional runs #######\n")
161
194
 
162
- layout = BIDSLayout(bids_path, validate=False)
163
195
  groups = dict()
164
196
  get_func_from_bids(bids_layout=layout,
165
197
  subject=subject,
@@ -183,7 +215,7 @@ def map_fmap_to_func(subject:str,
183
215
  if not allow_uneven_fmap_groups:
184
216
  exit_program_early(f"--allow_uneven_fmap_groups not set. (#AP: {len(group['fmapAP'])}, #PA: {len(group['fmapPA'])})")
185
217
  elif len(group["fmapPA"]) == 0 or len(group["fmapAP"]) == 0:
186
- exit_program_early(f"Not enough fieldmaps in one direction (#AP: {len(group['fmapAP'])}, #PA: {len(group['fmapPA'])})")
218
+ exit_program_early(f"Not enough fieldmaps in one direction (AP:\n{group['fmapAP']}, {len(group['fmapAP'])=}\nPA:\n{group['fmapAP']}, {len(group['fmapPA'])=})")
187
219
  fmap_pairs = uneven_fmap_pairing(group)
188
220
  else:
189
221
  fmap_pairs = even_fmap_pairing(group)
@@ -195,27 +227,36 @@ def map_fmap_to_func(subject:str,
195
227
  fmap_times.append(times[0] + (abs(times[1] - times[0]) / 2))
196
228
 
197
229
  # pair task runs with field maps based on closest Acquisition Time
198
- map_pairings = {s:[] for s in fmap_pairs}
230
+ time_pairings = {s:[] for s in fmap_pairs}
199
231
  for (t_file, t_acq_time) in group["task"]:
200
232
  diff = list(map(lambda x: abs(x - t_acq_time), fmap_times))
201
233
  pairing = fmap_pairs[diff.index(min(diff))]
202
- map_pairings[pairing].append(t_file)
234
+ time_pairings[pairing].append(t_file)
235
+
236
+ # gather the sidecar files and extract volumes if requested
237
+ file_pairing = dict()
238
+ for (ap_tup, pa_tup), task_files in time_pairings.items():
239
+ if extract_best_fmap:
240
+ ap_info = extract_best_acqusition(ap_tup[0], layout)
241
+ pa_info = extract_best_acqusition(pa_tup[0], layout)
242
+ else:
243
+ ap_info = (ap_tup[0], get_json_sidecar(ap_tup[0], layout))
244
+ pa_info = (pa_tup[0], get_json_sidecar(pa_tup[0], layout))
245
+ file_pairing[(ap_info, pa_info)] = task_files
203
246
 
204
247
  # add the list of task run files that are paired with each field map in their json files
205
248
  pairing_strs = []
206
- for (ap_tup, pa_tup), task_files in map_pairings.items():
207
- for fmap_file, fmap_type in ((ap_tup[0], "ap"), (pa_tup[0], "pa")):
208
- fmap_json = [f for f in fmap_file.get_associations() if f.entities['extension'] == ".json"]
209
- if len(fmap_json) != 1:
210
- exit_program_early(f"Cannot find the associated JSON file for fmap: {fmap_file.filename}")
211
- fmap_json = fmap_json[0]
249
+ for fmap_info, task_files in file_pairing.items():
250
+ for fmap_file, fmap_json in fmap_info:
212
251
  intended_for = [tf.relpath.split("/", 1)[-1] for tf in task_files]
213
252
  task_series = set([f"{tf.filename}({str(tf.entities['SeriesNumber'])})" for tf in task_files])
214
- jd = fmap_json.get_dict()
253
+ jd = None
254
+ with open(fmap_json.path, "r") as fjson:
255
+ jd = json.load(fjson)
215
256
  jd["IntendedFor"] = intended_for
216
257
  with open(fmap_json.path, "w") as out_file:
217
258
  logger.debug(f"writing field map pairing information to file: {fmap_json.filename}")
218
- out_file.write(json.dumps(jd, indent=4))
259
+ json.dump(jd, out_file, indent=4)
219
260
  pairing_strs.append((f"{fmap_file.filename}:{jd['SeriesNumber']}", ' '.join(task_series)))
220
261
 
221
262
  # report the fieldmap pairings
@@ -253,6 +294,47 @@ def map_fmap_to_func_with_pairing_file(bids_dir_path: Path,
253
294
  json.dump(fmap_dict, f, indent=4)
254
295
 
255
296
 
297
+ def extract_best_acqusition(fmap_file: str|BIDSFile, bids_layout: BIDSLayout) -> tuple[BIDSFile]:
298
+
299
+ if isinstance(fmap_file, BIDSFile):
300
+ fmap_file = fmap_file.path
301
+
302
+ fmap_img = nib.load(fmap_file)
303
+ fmap_data = fmap_img.get_fdata()
304
+ assert len(fmap_data.shape) == 4, f"Fieldmap supplied does not have 4 dimensions : {fmap_file}"
305
+
306
+ # file the volume with the lowest standard deviation
307
+ slice_diffs = np.diff(fmap_data, axis=2)
308
+ vol_stds = np.nanstd(slice_diffs, axis=(0,1,2))
309
+ best_vol = np.argmin(vol_stds)
310
+
311
+ # create a new bids name for the best volume
312
+ new_entities = bids_layout.parse_file_entities(fmap_file) | {"acquisition" : EXTRACTED_ACQ_VALUE}
313
+ best_acq_fmap = bids_layout.build_path(new_entities)
314
+ # save out the new image
315
+ best_acq_fmap_img = fmap_img.__class__(fmap_data[:,:,:,best_vol], affine=fmap_img.affine)
316
+ logger.info(f"saving extracted fieldmap to path: {best_acq_fmap}")
317
+ nib.save(best_acq_fmap_img, best_acq_fmap)
318
+
319
+ # create a new bids name for the sidecar file
320
+ sidecar_entities = new_entities | {"extension": ".json"}
321
+ best_acq_json = bids_layout.build_path(sidecar_entities)
322
+ # find the original sidecar file and copy it
323
+ fmap_json = get_json_sidecar(fmap_file, bids_layout)
324
+ jd = {}
325
+ with open(fmap_json.path, "r") as fjs:
326
+ jd = json.load(fjs)
327
+
328
+ # add metadata and save
329
+ jd["Sources"] = ["bids::"+str(Path(fmap_json.path).relative_to(bids_layout.root))]
330
+ jd["ExtractedVolume"] = int(best_vol + 1)
331
+ with open(best_acq_json, "w") as out_js:
332
+ json.dump(jd, out_js, indent=4)
333
+
334
+ return (BIDSFile(best_acq_fmap), BIDSFile(best_acq_json))
335
+
336
+
337
+
256
338
  if __name__ == "__main__":
257
339
  parser = argparse.ArgumentParser(
258
340
  prog="group_series.py",
@@ -5,13 +5,14 @@ import sys
5
5
  from pathlib import Path
6
6
  import logging
7
7
  import datetime
8
- from .bids_wrapper import dicom_to_bids, remove_unusable_runs, extend_session
9
- from .group_series import map_fmap_to_func, map_fmap_to_func_with_pairing_file
10
- from .preprocessing_wrapper import process_data, adult_defaults, infant_defaults, mount_opts
11
- from .segmentation_wrapper import segmentation_args, segment_anatomical
12
- from .events_long import create_events_and_confounds
13
- from .utils import exit_program_early, prompt_user_continue, default_log_format, add_file_handler, export_args_to_file, flags, debug_logging, log_linebreak, extract_options, make_option
14
- from .oceanparse import OceanParser
8
+ from oceanproc.bids_wrapper import dicom_to_bids, remove_unusable_runs, extend_session
9
+ from oceanproc.group_series import map_fmap_to_func, map_fmap_to_func_with_pairing_file
10
+ from oceanproc.preprocessing_wrapper import process_data, adult_defaults, infant_defaults, mount_opts, insert_dummy_frames
11
+ from oceanproc.segmentation_wrapper import segmentation_args, segment_anatomical
12
+ from oceanproc.events_long import create_events_and_confounds
13
+ from oceanproc.utils import exit_program_early, prompt_user_continue, default_log_format, add_file_handler, export_args_to_file, flags, debug_logging, log_linebreak, extract_options, make_option
14
+ from oceanproc.oceanparse import OceanParser
15
+ from bids import BIDSLayout, BIDSLayoutIndexer
15
16
  import shutil
16
17
  from textwrap import dedent
17
18
 
@@ -40,6 +41,31 @@ def make_work_directory(dir_path:Path, subject:str, session:str) -> Path:
40
41
 
41
42
 
42
43
  def main():
44
+
45
+ def ExistingPath(path):
46
+ p = Path(path)
47
+ if not p.exists():
48
+ raise argparse.ArgumentTypeError(
49
+ f"path string <{path}> does not represent an existing path"
50
+ )
51
+ return p.resolve()
52
+
53
+ def ExistingDir(path):
54
+ p = ExistingPath(path)
55
+ if not p.is_dir():
56
+ raise argparse.ArgumentTypeError(
57
+ f"path string <{path}> does not represent an existing directory"
58
+ )
59
+ return p
60
+
61
+ def ExistingFile(path):
62
+ p = ExistingPath(path)
63
+ if not p.is_file():
64
+ raise argparse.ArgumentTypeError(
65
+ f"path string <{path}> does not represent an existing file"
66
+ )
67
+ return p
68
+
43
69
  parser = OceanParser(
44
70
  prog="oceanproc",
45
71
  description="Ocean Labs adult MRI preprocessing",
@@ -48,7 +74,7 @@ def main():
48
74
  epilog="An arguments file can be accepted with @FILEPATH.\nAny additional arguments not listed in the help message will be passed to the preprocessing subprocess.",
49
75
  allow_abbrev=False
50
76
  )
51
- parser.add_argument("--version", action="version", version='%(prog)s v1.1.2')
77
+ parser.add_argument("--version", action="version", version='%(prog)s v1.1.8')
52
78
  session_args = parser.add_argument_group("Session Specific")
53
79
  config_args = parser.add_argument_group("Configuration Arguments", "These arguments are saved to a file if the '--export_args' option is used")
54
80
  parser.register("type", "Full-Path", lambda p: Path(p).resolve())
@@ -57,13 +83,13 @@ def main():
57
83
  help="The identifier of the subject to preprocess")
58
84
  session_args.add_argument("--session", "-se", required=True,
59
85
  help="The identifier of the session to preprocess")
60
- session_args.add_argument("--source_data", "-sd", type=Path,
86
+ session_args.add_argument("--source_data", "-sd", type=ExistingDir,
61
87
  help="The path to the directory containing the raw DICOM files for this subject and session")
62
88
  session_args.add_argument("--skip_dcm2bids", action="store_true",
63
89
  help="Flag to indicate that dcm2bids does not need to be run for this subject and session")
64
- session_args.add_argument("--usability_file", "-u", type=Path,
90
+ session_args.add_argument("--usability_file", "-u", type=ExistingFile,
65
91
  help="The path to the usability file for this subject and session; this file can be either be an xml or json file. All runs will be used if no file is provided.")
66
- session_args.add_argument("--longitudinal", "-lg", type=Path, nargs="+", action="append",
92
+ session_args.add_argument("--longitudinal", "-lg", type=ExistingPath, nargs="+", action="append",
67
93
  help="Addtional sourcedata and usability pair to include with this BIDs subject and session")
68
94
  session_args.add_argument("--skip_fmap_pairing", action="store_true",
69
95
  help="Flag to indicate that the pairing of fieldmaps to BOLD runs does not need to be performed for this subject and session")
@@ -79,19 +105,19 @@ def main():
79
105
  help="Flag to stop the deletion of the fMRIPrep working directory")
80
106
  session_args.add_argument("--debug_mode", action="store_true",
81
107
  help="Flag to run the program in debug mode for more verbose logging")
82
- session_args.add_argument("--fmap_pairing_file", type=Path,
108
+ session_args.add_argument("--fmap_pairing_file", type=ExistingFile,
83
109
  help="Path to JSON containing info on how to pair fieldmaps to BOLD runs.")
84
110
 
85
- config_args.add_argument("--bids_path", "-b", type=Path, required=True,
111
+ config_args.add_argument("--bids_path", "-b", type=ExistingDir, required=True,
86
112
  help="The path to the directory containing the raw nifti data for all subjects, in BIDS format")
87
- config_args.add_argument("--derivs_path", "-d", type=Path, required=True,
113
+ config_args.add_argument("--derivs_path", "-d", type=ExistingDir, required=True,
88
114
  help="The path to the BIDS formated derivatives directory for this subject")
89
115
  config_args.add_argument("--derivs_subfolder", "-ds", default=None,
90
116
  help=f"""The subfolder in the derivatives directory where bids style outputs should be stored.
91
117
  The default is {adult_defaults.derivs_subfolder} or {infant_defaults.derivs_subfolder} if the '--infant' flag is set.""")
92
- config_args.add_argument("--bids_config", "-c", type=Path, required=True,
118
+ config_args.add_argument("--bids_config", "-c", type=ExistingFile, required=True,
93
119
  help="The path to the dcm2bids config file to use for this subject and session")
94
- config_args.add_argument("--nordic_config", "-n", type=Path,
120
+ config_args.add_argument("--nordic_config", "-n", type=ExistingFile,
95
121
  help="The path to the second dcm2bids config file to use for this subject and session. This implies that the session contains NORDIC data")
96
122
  config_args.add_argument("--nifti", action=argparse.BooleanOptionalAction,
97
123
  help="Flag to specify that the source directory contains files of type NIFTI (.nii/.jsons) instead of DICOM")
@@ -101,36 +127,43 @@ def main():
101
127
  help="framewise displacement threshold (in mm) to determine outlier framee (Default is 0.9).")
102
128
  config_args.add_argument("--skip_bids_validation", action=argparse.BooleanOptionalAction,
103
129
  help="Specifies skipping BIDS validation (only enabled for fMRIprep step)")
104
- config_args.add_argument("--fs_subjects_dir", "-fs", type=Path,
130
+ config_args.add_argument("--fs_subjects_dir", "-fs", type=ExistingDir,
105
131
  help="The path to the directory that contains previous FreeSurfer outputs/derivatives to use for fMRIPrep. If empty, this is the path where new FreeSurfer outputs will be stored.")
106
132
  config_args.add_argument("--allow_uneven_fmap_groups", action="store_true",
107
133
  help="Flag to allow for automated fieldmap pairing when there's more AP- than PA-encoded fieldmaps, or vice versa (will still error out if at least one of each is not present.)")
134
+ config_args.add_argument("--extract_best_fmap", action="store_true",
135
+ help="Flag to specify that the lowest variance fieldmap volume should be extracted from each fieldmap, and used for distortion correction (opposed to all volumes being used)")
136
+ config_args.add_argument("--dscans_path", type=ExistingDir,
137
+ help="""The path to the directory containing 'dscans' files for inserting dummy scans into bold runs. Must be named exactly as corresponding bold image except with 'dscans' as
138
+ the suffix and '.tsv' as the extension. Contents of the file must be a column, titled 'dummy_scan', with ones and zeros. Ones denote which frames should be dummy scans.""")
108
139
  config_args.add_argument("--precomputed_derivatives", "-pd", type="Full-Path", dest="derivatives", nargs="*",
109
140
  help="A list of paths to any BIDS-style precomputed derivatives that should be used in preprocessing. (Ex. /path/to/bibsnet)")
110
- config_args.add_argument("--work_dir", "-w", type=Path, required=True,
141
+ config_args.add_argument("--work_dir", "-w", type=ExistingDir, required=True,
111
142
  help="The path to the working directory used to store intermediate files")
112
- config_args.add_argument("--fs_license", "-l", type=Path, required=True,
143
+ config_args.add_argument("--fs_license", "-l", type=ExistingFile, required=True,
113
144
  help="The path to the license file for the local installation of FreeSurfer")
114
145
  config_args.add_argument("--image_version", "-iv", default=None,
115
146
  help=f"""The version of fmriprep to use; It is reccomended that an entire study use the same version.
116
147
  The default is {adult_defaults.image_version} or {infant_defaults.image_version} if the '--infant' flag is set.""")
117
148
  config_args.add_argument("--infant", "-I", action=argparse.BooleanOptionalAction,
118
149
  help="Flag to specify that NiBabies should be used instead of fMRIPrep")
119
- config_args.add_argument("--bibsnet_image_path", "-bi", type=Path,
150
+ config_args.add_argument("--preproc_image_path", "-pi", type=ExistingFile,
151
+ help="Path to a fMRIPrep or NiBabies apptainer image to use for preprocessing. If provided, this apptainer image will be used instead of docker. (NiBabies images should still be used with the '--infant' flag)")
152
+ config_args.add_argument("--bibsnet_image_path", "-bi", type=ExistingFile,
120
153
  help="Path to the BIBSnet apptainer image to use for segmentation. If provided, BIBSnet segmentation will be run and the outputs will be used for preprocessing. (Must be used with the '--infant' flag)")
121
- config_args.add_argument("--bibsnet_work", "-bw", type=Path,
154
+ config_args.add_argument("--bibsnet_work", "-bw", type=ExistingDir,
122
155
  help="The path to the working directory used to store intermediate files for BIBSnet")
123
156
  args, unknown_args = parser.parse_known_args()
124
157
 
125
- try:
126
- assert args.derivs_path.is_dir(), "Derivatives directory must exist but it cannot be found"
127
- assert args.bids_path.is_dir(), "Raw Bids directory must exist but it cannot be found"
128
- if not args.skip_dcm2bids:
129
- assert args.source_data.is_dir(), "Source data directory must exist but it cannot be found"
130
- assert args.work_dir.is_dir(), "Work directroy must exist but it cannot be found"
131
- except AssertionError as e:
132
- logger.exception(e)
133
- parser.error(e)
158
+ # try:
159
+ # assert args.derivs_path.is_dir(), "Derivatives directory must exist but it cannot be found"
160
+ # assert args.bids_path.is_dir(), "Raw Bids directory must exist but it cannot be found"
161
+ # if not args.skip_dcm2bids:
162
+ # assert args.source_data.is_dir(), "Source data directory must exist but it cannot be found"
163
+ # assert args.work_dir.is_dir(), "Work directroy must exist but it cannot be found"
164
+ # except AssertionError as e:
165
+ # logger.exception(e)
166
+ # parser.error(e)
134
167
 
135
168
  defaults = infant_defaults if args.infant else adult_defaults
136
169
  for k,v in defaults.__dict__.items():
@@ -146,8 +179,8 @@ def main():
146
179
  elif bibsnet_path not in args.derivatives:
147
180
  args.derivatives.append(bibsnet_path)
148
181
 
149
- if args.bibsnet_work and (not args.bibsnet_work.exists()):
150
- parser.error("BIBSnet working directory must exist but it cannot be found")
182
+ # if args.bibsnet_work and (not args.bibsnet_work.exists()):
183
+ # parser.error("BIBSnet working directory must exist but it cannot be found")
151
184
 
152
185
  if args.longitudinal:
153
186
  for lg_group in args.longitudinal:
@@ -162,6 +195,12 @@ def main():
162
195
  parser.error(f"longitudinal argument cannot have more than 2 elements: {lg_group}")
163
196
 
164
197
  preproc_image = f"{defaults.image_name}:{args.image_version}"
198
+ if args.preproc_image_path:
199
+ # if not args.preproc_image_path.exists():
200
+ # parser.error(f"Cannot find the preprocessing apptainer image at the path: {args.preproc_image_path}")
201
+ flags.apptainer = True
202
+ preproc_image = args.preproc_image_path
203
+
165
204
  preproc_derivs_path = args.derivs_path / args.derivs_subfolder
166
205
 
167
206
  log_dir = preproc_derivs_path / f"sub-{args.subject}/log"
@@ -180,7 +219,7 @@ def main():
180
219
  try:
181
220
  assert args.export_args.parent.exists() and args.export_args.suffix, "Argument export path must be a file path in a directory that exists"
182
221
  logger.info(f"####### Exporting Configuration Arguments to: '{args.export_args}' #######")
183
- export_args_to_file(args, config_arguments, args.export_args, extra_args=unknown_args)
222
+ export_args_to_file(args, config_args, args.export_args, extra_args=unknown_args)
184
223
  except Exception as e:
185
224
  logger.exception(e)
186
225
  exit_program_early(e)
@@ -226,10 +265,18 @@ def main():
226
265
  session=args.session,
227
266
  tmp_dir=bids_path,
228
267
  bids_dir=args.bids_path)
268
+
269
+ # create a bids layout object
270
+ ignore_list = ["tmp_dcm2bids"]
271
+ if (bids_path / ".bidsignore").is_file():
272
+ with open(bids_path / ".bidsignore", "r") as f:
273
+ ignore_list.extend(f.readlines())
274
+ ignore_list = list(set(ignore_list))
275
+ bids_layout = BIDSLayout(args.bids_path, validate=False, indexer=BIDSLayoutIndexer(ignore=ignore_list, validate=False))
229
276
 
230
277
  ##### Remove the scans marked as 'unusable' #####
231
278
  remove_unusable_runs(
232
- bids_path=args.bids_path,
279
+ bids_layout=bids_layout,
233
280
  subject=args.subject,
234
281
  session=args.session
235
282
  )
@@ -243,12 +290,15 @@ def main():
243
290
  args.fmap_pairing_file
244
291
  )
245
292
  else:
293
+ # update the bids layout
294
+ bids_layout = BIDSLayout(args.bids_path, validate=False, indexer=BIDSLayoutIndexer(ignore=ignore_list, validate=False))
246
295
  map_fmap_to_func(
247
296
  subject=args.subject,
248
297
  session=args.session,
249
- bids_path=args.bids_path,
298
+ layout=bids_layout,
250
299
  # xml_path=xml_data_path,
251
- allow_uneven_fmap_groups=args.allow_uneven_fmap_groups
300
+ allow_uneven_fmap_groups=args.allow_uneven_fmap_groups,
301
+ extract_best_fmap=args.extract_best_fmap
252
302
  )
253
303
 
254
304
  ##### Run BIBSnet #####
@@ -272,6 +322,17 @@ def main():
272
322
  if not bibsnet_path.exists():
273
323
  exit_program_early(f"Cannot find the outputs for BIBSnet at the path {bibsnet_path}")
274
324
 
325
+
326
+ ##### Insert dummy scans in requested #####
327
+ if args.dscans_path:
328
+ insert_dummy_frames(
329
+ subject=args.subject,
330
+ session=args.session,
331
+ dscan_dir=args.dscans_path,
332
+ bids_layout=bids_layout
333
+ )
334
+
335
+
275
336
  ##### Run fMRIPrep or NiBabies #####
276
337
  all_opts = dict(args._get_kwargs())
277
338
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from pathlib import Path
4
4
  import logging
5
+ from bids import BIDSLayout
5
6
  from .utils import exit_program_early, make_option, prepare_subprocess_logging, flags, debug_logging, log_linebreak, run_subprocess
6
7
  import shutil
7
8
  from subprocess import Popen, PIPE
@@ -9,6 +10,7 @@ from matplotlib import pyplot as plt
9
10
  from bs4 import BeautifulSoup as bsoup
10
11
  import pandas as pd
11
12
  import numpy as np
13
+ import nibabel as nib
12
14
  import json
13
15
  import os
14
16
  import copy
@@ -29,14 +31,6 @@ infant_defaults = SimpleNamespace(
29
31
  derivs_subfolder="nibabies",
30
32
  )
31
33
 
32
- # Good arguments to have if not specified
33
- # { key : (val, can_have_multiple_values)}
34
- preproc_kwargs = {
35
- "cifti-output":("91k", False),
36
- "output-spaces": ("fsLR", True),
37
- "output-spaces": ("MNI152NLin2009cAsym", True)
38
- }
39
-
40
34
  # Preprocessing arguments that need to have their paths binded when running docker
41
35
  mount_opts = {"fs_subjects_dir",
42
36
  "derivatives"}
@@ -83,6 +77,15 @@ def run_preprocessing(subject:str,
83
77
  elif not derivs_path.exists():
84
78
  exit_program_early(f"Derivatives path {derivs_path} does not exist.")
85
79
 
80
+ if flags.apptainer:
81
+ cmd_prelude = "apptainer run --nv --cleanenv --no-mount cwd"
82
+ mount_flag = "-B"
83
+ else:
84
+ uid = Popen(["id", "-u"], stdout=PIPE).stdout.read().decode("utf-8").strip()
85
+ gid = Popen(["id", "-g"], stdout=PIPE).stdout.read().decode("utf-8").strip()
86
+ cmd_prelude = f"docker run --rm -i -u {uid}:{gid}"
87
+ mount_flag = "-v"
88
+
86
89
  additional_mount_paths = []
87
90
  additional_mount_args = []
88
91
  mount_dex = 1
@@ -90,29 +93,26 @@ def run_preprocessing(subject:str,
90
93
  arg_vals = [k]
91
94
  if isinstance(v, list):
92
95
  for sub_v in v:
93
- additional_mount_paths.append(f"-v {sub_v.resolve()}:/deriv{mount_dex}")
96
+ additional_mount_paths.append(f"{mount_flag} {sub_v.resolve()}:/deriv{mount_dex}")
94
97
  arg_vals.append(f"/deriv{mount_dex}")
95
98
  mount_dex += 1
96
99
  else:
97
- additional_mount_paths.append(f"-v {v.resolve()}:/deriv{mount_dex}")
100
+ additional_mount_paths.append(f"{mount_flag} {v.resolve()}:/deriv{mount_dex}")
98
101
  arg_vals.append(f"/deriv{mount_dex}")
99
102
  mount_dex += 1
100
103
  additional_mount_args.append(" ".join(arg_vals))
101
104
  additional_mount_paths = " ".join(additional_mount_paths)
102
105
  additional_mount_args = " ".join(additional_mount_args)
103
106
 
104
- uid = Popen(["id", "-u"], stdout=PIPE).stdout.read().decode("utf-8").strip()
105
- gid = Popen(["id", "-g"], stdout=PIPE).stdout.read().decode("utf-8").strip()
106
-
107
107
  license_mount = "/opt/freesurfer/license.txt"
108
108
  bids_mount = "/data"
109
109
  derivs_mount = "/out"
110
110
  work_mount = "/work"
111
- preproc_command = f"""docker run --rm -i -u {uid}:{gid}
112
- -v {license_file.resolve()}:{license_mount}:ro
113
- -v {bids_path.resolve()}:{bids_mount}:ro
114
- -v {derivs_path.resolve()}:{derivs_mount}
115
- -v {work_path.resolve()}:{work_mount}
111
+ preproc_command = f"""{cmd_prelude}
112
+ {mount_flag} {license_file.resolve()}:{license_mount}:ro
113
+ {mount_flag} {bids_path.resolve()}:{bids_mount}:ro
114
+ {mount_flag} {derivs_path.resolve()}:{derivs_mount}
115
+ {mount_flag} {work_path.resolve()}:{work_mount}
116
116
  {additional_mount_paths}
117
117
  {image_name} {bids_mount} {derivs_mount} participant
118
118
  --participant-label {subject}
@@ -240,6 +240,74 @@ def add_fd_plot_to_report(subject:str,
240
240
  f.write(soup.prettify())
241
241
 
242
242
 
243
+ def insert_dummy_frames(subject:str,
244
+ session:str,
245
+ dscan_dir:Path,
246
+ bids_layout:BIDSLayout):
247
+
248
+ dscans_suffix = "dscans"
249
+ dscans_path_pattern = "sub-{subject}[/ses-{session}]/{datatype<func|meg|beh>|func}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_recording-{recording}]_{suffix<"+dscans_suffix+">}{extension<.tsv|.json>|.tsv}"
250
+
251
+ log_linebreak()
252
+ logger.info(f"####### Inserting Dummy Scans #######\n")
253
+
254
+ # find all bold files
255
+ func_files = bids_layout.get(subject=subject, session=session, suffix="bold", datatype="func", extension="nii.gz")
256
+ for bfile in func_files:
257
+ if not Path(bfile.path).exists:
258
+ continue
259
+ # Find the dscans file for bold run
260
+ dscans_entities = bids_layout.parse_file_entities(bfile.path) | {"suffix":dscans_suffix, "extension":".tsv"}
261
+ dscans_bids_path = bids_layout.build_path(dscans_entities, path_patterns=[dscans_path_pattern], validate=False)
262
+ dscans_file = sorted(dscan_dir.glob(Path(dscans_bids_path).name))
263
+ if len(dscans_file) > 1:
264
+ exit_program_early(f"Found more than one dscans file for bold run {bfile} --> {dscans_file}")
265
+ elif len(dscans_file) == 0:
266
+ logger.info(f"Did not find any dscans files for bold run: {bfile}, file will remain unmodified.")
267
+ continue
268
+ dscans_file = dscans_file[0]
269
+ logger.info(f"found dscans file <{dscans_file.name}> for bold run <{bfile.filename}>")
270
+
271
+ dscans_list = pd.read_csv(dscans_file, sep="\t").loc[:, "dummy_scan"].to_numpy().astype(np.int32)
272
+ bold_img = nib.load(bfile.path)
273
+ num_bold_frames = bold_img.shape[3]
274
+ num_dummy_scans = np.sum(dscans_list > 0)
275
+ num_non_dummy = np.sum(dscans_list == 0)
276
+
277
+ # validate length
278
+ if len(dscans_list) == num_bold_frames:
279
+ logger.info(f" dsans file and bold image have the same length. Dummy scans may have already been inserted or length of dscans file is incorrect. Bold file will remain unmodified.")
280
+ continue
281
+ elif num_bold_frames != num_non_dummy:
282
+ exit_program_early(f"Number of non-dummy scans and length of bold data do not match. # non-dummy scans: {num_non_dummy}, # bold frames: {num_bold_frames}")
283
+
284
+ logger.info(f" inserting {num_dummy_scans} dummy scans into the bold run")
285
+ # create a new bold series with dummy scans inserted
286
+ bold_data = bold_img.get_fdata()
287
+ frame_list = []
288
+ dscans_index = 0
289
+ for f in range(num_bold_frames):
290
+ copy_dex = f-1 if f > 0 else f
291
+ while dscans_list[dscans_index] != 0:
292
+ frame_list.append(bold_data[:,:,:,copy_dex])
293
+ dscans_index += 1
294
+ frame_list.append(bold_data[:,:,:,f])
295
+ dscans_index += 1
296
+ # check if end of run needs to be extended
297
+ if (f == num_bold_frames-1) and (dscans_index < len(dscans_list)):
298
+ while dscans_index < len(dscans_list):
299
+ frame_list.append(bold_data[:,:,:,f])
300
+ dscans_index += 1
301
+
302
+ if len(frame_list) != len(dscans_list):
303
+ exit_program_early(f"Something went wrong when creating dummy scans, lengths do not match. bold frames: {len(frame_list)}, dscan length: {len(dscans_list)}")
304
+
305
+ # save new image
306
+ extended_bold_data = np.stack(frame_list, axis=3)
307
+ extended_bold_img = bold_img.__class__(extended_bold_data, header=bold_img.header, affine=bold_img.affine)
308
+ logger.info(f" saving extended bold image")
309
+ nib.save(extended_bold_img, bfile.path)
310
+
243
311
 
244
312
  @debug_logging
245
313
  def process_data(subject:str,
@@ -273,20 +341,6 @@ def process_data(subject:str,
273
341
  :type remove_work_folder: str
274
342
  :param **kwargs: any arguments to be passed to the preprocessing subprocess
275
343
  """
276
-
277
- # Add in some recommended options
278
- for key, (val, multi) in preproc_kwargs.items():
279
- if (key in kwargs) and multi:
280
- if isinstance(kwargs[key], list):
281
- if val not in kwargs[key]:
282
- kwargs[key].append(val)
283
- else:
284
- if val != kwargs[key]:
285
- kwargs[key] = [kwargs[key], val]
286
- elif key not in kwargs:
287
- kwargs[key] = val
288
-
289
-
290
344
  option_chain = " ".join([make_option(v, key=k, delimeter=" ", convert_underscore=True) for k,v in kwargs.items()])
291
345
  preproc_title = "NiBabies" if flags.infant else "fMRIPrep"
292
346
 
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
16
16
 
17
17
  default_log_format = "%(levelname)s:%(asctime)s:%(module)s: %(message)s"
18
18
 
19
- flags = SimpleNamespace(debug=False, longitudinal=False, infant=False)
19
+ flags = SimpleNamespace(debug=False, longitudinal=False, infant=False, apptainer=False)
20
20
 
21
21
  bids_entities = ["sub", "ses", "sample", "task", "tracksys",
22
22
  "acq", "ce", "trc", "stain", "rec", "dir",
@@ -22,6 +22,7 @@ authors = [
22
22
  ]
23
23
  requires-python = "<3.13,>=3.10"
24
24
  dependencies = [
25
+ "sphinx-rtd-theme>=2.0.0,<3.0.0",
25
26
  "flask<4.0.0,>=3.0.3",
26
27
  "asttokens<3.0.0,>=2.4.1",
27
28
  "numpy>=1.26.0",
@@ -33,13 +34,13 @@ dependencies = [
33
34
  "pybids>=0.16.3",
34
35
  ]
35
36
  name = "oceanproc"
36
- version = "1.1.7"
37
+ version = "1.2.0"
37
38
  description = "MRI preprocessing for WUSTL Ocean labs"
38
39
  readme = "README.md"
39
40
 
40
41
  [project.scripts]
41
42
  oceanproc = "oceanproc.ocean_proc:main"
42
- oceanfla = "oceanproc.firstlevel.run_glm:main"
43
+ oceanfla_legacy = "oceanproc.firstlevel.run_glm:main"
43
44
 
44
45
  [dependency-groups]
45
46
  dev = [
@@ -47,5 +48,4 @@ dev = [
47
48
  "sphinx<8.0.0,>=7.3.7",
48
49
  "twine<6.0.0,>=5.1.0",
49
50
  "ipython<9.0.0,>=8.29.0",
50
- "sphinx-rtd-theme>=2.0.0,<3.0.0",
51
51
  ]
@@ -1,8 +0,0 @@
1
- {
2
- "folders": [
3
- {
4
- "path": "../.."
5
- }
6
- ],
7
- "settings": {}
8
- }
File without changes