hypline 0.2.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.
hypline/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import app
2
+
3
+
4
+ def main():
5
+ app()
hypline/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ import multiprocessing as mp
2
+ from pathlib import Path
3
+
4
+ import typer
5
+ from rich import print
6
+ from typing_extensions import Annotated
7
+
8
+ from .regression import ConfoundRegression
9
+ from .utils import DillProcess
10
+
11
+ app = typer.Typer()
12
+
13
+
14
+ @app.command()
15
+ def clean(
16
+ fmriprep_dir: Annotated[
17
+ str,
18
+ typer.Argument(
19
+ help="Directory containing fMRIPrep outputs",
20
+ show_default=False,
21
+ ),
22
+ ],
23
+ config_file: Annotated[
24
+ str,
25
+ typer.Argument(
26
+ help="Configuration file",
27
+ show_default=False,
28
+ ),
29
+ ],
30
+ model_name: Annotated[
31
+ str,
32
+ typer.Argument(
33
+ help="Confound regression model to run (defined in config)",
34
+ show_default=False,
35
+ ),
36
+ ],
37
+ output_dir: Annotated[
38
+ str | None,
39
+ typer.Option(
40
+ help="Directory to store cleaned BOLD data",
41
+ show_default="<fmriprep_dir>_cleaned",
42
+ ),
43
+ ] = None,
44
+ custom_confounds_dir: Annotated[
45
+ str | None,
46
+ typer.Option(help="Directory containing custom confounds"),
47
+ ] = None,
48
+ subject_ids_input: Annotated[
49
+ str,
50
+ typer.Option(
51
+ "--subject-ids",
52
+ help="Target subject IDs (comma-separated, with no spaces)",
53
+ show_default="all",
54
+ ),
55
+ ] = "*",
56
+ session_name: Annotated[
57
+ str,
58
+ typer.Option(
59
+ help="Target session name",
60
+ show_default="all",
61
+ ),
62
+ ] = "*",
63
+ task_name: Annotated[
64
+ str,
65
+ typer.Option(
66
+ help="Target task name",
67
+ show_default="all",
68
+ ),
69
+ ] = "*",
70
+ data_space_name: Annotated[
71
+ str,
72
+ typer.Option(help="Target BOLD data space"),
73
+ ] = "MNI152NLin2009cAsym",
74
+ n_processes: Annotated[
75
+ int,
76
+ typer.Option(help="Number of processes to run in parallel"),
77
+ ] = 1,
78
+ ):
79
+ """
80
+ Clean BOLD data in the given fMRIPrep outputs.
81
+ """
82
+ if subject_ids_input == "*":
83
+ subject_ids = [
84
+ path.name[4:] for path in Path(fmriprep_dir).glob("sub-*") if path.is_dir()
85
+ ]
86
+ else:
87
+ subject_ids = subject_ids_input.split(",")
88
+
89
+ def clean_bold(subject_ids: list[str]):
90
+ reg = ConfoundRegression(
91
+ config_file=config_file,
92
+ fmriprep_dir=fmriprep_dir,
93
+ output_dir=output_dir,
94
+ custom_confounds_dir=custom_confounds_dir,
95
+ )
96
+
97
+ reg.clean_bold(
98
+ model_name=model_name,
99
+ subject_ids=subject_ids,
100
+ session_name=session_name,
101
+ task_name=task_name,
102
+ data_space_name=data_space_name,
103
+ )
104
+
105
+ if n_processes < 2:
106
+ clean_bold(subject_ids)
107
+ else:
108
+ if mp.current_process().name == "MainProcess":
109
+ max_processes = mp.cpu_count()
110
+ if n_processes > max_processes:
111
+ n_processes = max_processes
112
+ print(
113
+ "[bold yellow]Warning:[/bold yellow] "
114
+ "Requested processes exceed available CPU "
115
+ "cores. Resetting to match available cores."
116
+ )
117
+
118
+ processes: list[DillProcess] = []
119
+ for i in range(n_processes):
120
+ p = DillProcess(target=clean_bold, args=(subject_ids[i::n_processes],))
121
+ processes.append(p)
122
+ p.start()
123
+ for p in processes:
124
+ p.join()
125
+
126
+
127
+ @app.callback()
128
+ def callback():
129
+ """
130
+ An opinionated framework/toolbox for conducting
131
+ data cleaning and analysis in hyperscanning studies
132
+ involving dyadic conversations.
133
+ """
hypline/enums.py ADDED
@@ -0,0 +1,23 @@
1
+ from enum import Enum
2
+
3
+
4
+ class CompCorMethod(Enum):
5
+ ANATOMICAL = "aCompCor"
6
+ TEMPORAL = "tCompCor"
7
+ MEAN = "Mean"
8
+
9
+
10
+ class CompCorTissue(Enum):
11
+ CSF = "CSF"
12
+ WM = "WM"
13
+ COMBINED = "combined"
14
+
15
+
16
+ class SurfaceSpace(Enum):
17
+ FS_AVERAGE_5 = "fsaverage5"
18
+ FS_AVERAGE_6 = "fsaverage6"
19
+
20
+
21
+ class VolumeSpace(Enum):
22
+ MNI_152_NLIN_6_ASYM = "MNI152NLin6Asym"
23
+ MNI_152_NLIN_2009_C_ASYM = "MNI152NLin2009cAsym"
hypline/regression.py ADDED
@@ -0,0 +1,433 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+ from types import MappingProxyType
7
+
8
+ import nibabel as nib
9
+ import numpy as np
10
+ import polars as pl
11
+ import yaml
12
+ from natsort import natsorted
13
+ from nibabel.gifti import GiftiDataArray, GiftiImage
14
+ from nibabel.nifti1 import Nifti1Image
15
+ from nilearn import image as nimg
16
+ from nilearn import signal
17
+ from pydantic import TypeAdapter
18
+ from rich import print
19
+ from rich.progress import track
20
+
21
+ from .enums import CompCorMethod, CompCorTissue, SurfaceSpace, VolumeSpace
22
+ from .schemas import CompCorOptions, Config, ConfoundMetadata, ModelSpec
23
+
24
+
25
+ class ConfoundRegression:
26
+ # Read-only mapping between a data space name and its enum variant
27
+ DATA_SPACES = MappingProxyType(
28
+ {space.value: space for space in list(VolumeSpace) + list(SurfaceSpace)}
29
+ )
30
+
31
+ def __init__(
32
+ self,
33
+ config_file: str,
34
+ fmriprep_dir: str,
35
+ output_dir: str | None = None,
36
+ custom_confounds_dir: str | None = None,
37
+ ):
38
+ # Parse and validate config data
39
+ config_filepath = Path(config_file)
40
+ if config_filepath.exists() is False:
41
+ raise FileNotFoundError(f"Path does not exist: {config_file}")
42
+ self._config = Config.model_validate(
43
+ yaml.safe_load(config_filepath.read_text())
44
+ )
45
+
46
+ # Set the directory path to fMRIPrep data
47
+ self._fmriprep_dir = Path(fmriprep_dir)
48
+ if self._fmriprep_dir.exists() is False:
49
+ raise FileNotFoundError(f"Path does not exist: {fmriprep_dir}")
50
+
51
+ # Set the directory path to store cleaned outputs
52
+ self._output_dir = (
53
+ Path(output_dir)
54
+ if output_dir
55
+ else self._fmriprep_dir.with_name(self._fmriprep_dir.name + "_cleaned")
56
+ )
57
+ self._output_dir.mkdir(parents=True, exist_ok=True)
58
+
59
+ # Set the directory path to custom confounds
60
+ self._custom_confounds_dir = None
61
+ if custom_confounds_dir:
62
+ self._custom_confounds_dir = Path(custom_confounds_dir)
63
+ if self._custom_confounds_dir.exists() is False:
64
+ raise FileNotFoundError(f"Path does not exist: {custom_confounds_dir}")
65
+
66
+ # Create logging-related attributes
67
+ self._log_dir = self._output_dir / "logs"
68
+ self._log_dir.mkdir(parents=True, exist_ok=True)
69
+ self._logger = logging.getLogger(__name__)
70
+ self._logger.setLevel(logging.INFO)
71
+ self._formatter = logging.Formatter(
72
+ fmt="%(asctime)s - %(levelname)s - %(message)s",
73
+ datefmt="%Y-%m-%d %H:%M:%S",
74
+ )
75
+
76
+ @property
77
+ def config(self) -> Config:
78
+ return deepcopy(self._config)
79
+
80
+ @property
81
+ def fmriprep_dir(self) -> Path:
82
+ return deepcopy(self._fmriprep_dir)
83
+
84
+ @property
85
+ def output_dir(self) -> Path:
86
+ return deepcopy(self._output_dir)
87
+
88
+ @property
89
+ def custom_confounds_dir(self) -> Path | None:
90
+ return deepcopy(self._custom_confounds_dir)
91
+
92
+ def clean_bold(
93
+ self,
94
+ model_name: str,
95
+ subject_ids: list[str],
96
+ session_name: str = "*",
97
+ task_name: str = "*",
98
+ data_space_name: str = "MNI152NLin2009cAsym",
99
+ ):
100
+ # Mapping between a data space type and the corresponding method
101
+ CLEAN_BOLD = {
102
+ VolumeSpace: self._clean_bold_in_volume_space,
103
+ SurfaceSpace: self._clean_bold_in_surface_space,
104
+ }
105
+
106
+ model_spec = self._config.model_specs.get(model_name)
107
+ if model_spec is None:
108
+ raise ValueError(f"Undefined model: {model_name}")
109
+
110
+ data_space = self.DATA_SPACES.get(data_space_name)
111
+ data_space_type = type(data_space)
112
+ if data_space_type not in CLEAN_BOLD:
113
+ raise ValueError(f"Unsupported data space: {data_space_name}")
114
+
115
+ for sub_id in track(subject_ids, description="Processing..."):
116
+ file_handler = logging.FileHandler(self._log_dir / f"sub-{sub_id}.log")
117
+ file_handler.setFormatter(self._formatter)
118
+ self._logger.addHandler(file_handler)
119
+
120
+ bold_pattern = self._compose_glob_pattern_for_bold(
121
+ subject_id=sub_id,
122
+ session_name=session_name,
123
+ task_name=task_name,
124
+ data_space=data_space,
125
+ )
126
+ bold_filepaths = self._fmriprep_dir.glob(bold_pattern)
127
+
128
+ for filepath in bold_filepaths:
129
+ self._logger.info("Cleaning starting: %s", filepath.name)
130
+ try:
131
+ CLEAN_BOLD[data_space_type](filepath, model_spec)
132
+ self._logger.info("Cleaning complete: %s", filepath.name)
133
+ except Exception as e:
134
+ self._logger.error(e)
135
+ print("[red]Processing failed:[/red]", filepath.name)
136
+
137
+ self._logger.removeHandler(file_handler) # Reset for next iteration
138
+
139
+ def _clean_bold_in_volume_space(self, filepath: Path, model_spec: ModelSpec):
140
+ # Read raw BOLD data
141
+ bold = nimg.load_img(filepath) # Shape of (x, y, z, TRs)
142
+ assert isinstance(bold, Nifti1Image)
143
+
144
+ # Extract TR value (assumed constant in a given run)
145
+ p = filepath.parent / (filepath.name.split(".")[0] + ".json")
146
+ with open(p, "r") as f:
147
+ data = json.load(f)
148
+ assert isinstance(data, dict)
149
+ repetition_time = data.get("RepetitionTime") # In seconds
150
+ if repetition_time is None:
151
+ raise ValueError(f"TR metadata is missing: {p.name}")
152
+ TR = float(repetition_time)
153
+
154
+ # Load confounds for the requested model
155
+ confounds_df = self._load_confounds(filepath, model_spec)
156
+ confounds = confounds_df.to_numpy() # Shape of (TRs, confounds)
157
+ if confounds.shape[0] != bold.shape[-1]:
158
+ raise ValueError(
159
+ f"Unequal number of TRs between BOLD and confounds data: {filepath.name}"
160
+ )
161
+
162
+ # Perform confound regression
163
+ cleaned_bold = nimg.clean_img(
164
+ bold,
165
+ confounds=confounds,
166
+ detrend=True,
167
+ t_r=TR,
168
+ ensure_finite=True,
169
+ standardize="zscore_sample",
170
+ standardize_confounds=True,
171
+ )
172
+ assert isinstance(cleaned_bold, Nifti1Image)
173
+
174
+ # Store cleaned BOLD data
175
+ entities = filepath.name.split("_")
176
+ if entities[-2].startswith("desc-"):
177
+ entities[-2] = "desc-clean"
178
+ else:
179
+ entities.insert(-1, "desc-clean")
180
+ new_filename = "_".join(entities)
181
+ intermediate_dir = filepath.relative_to(self._fmriprep_dir).parent
182
+ new_filepath = self._output_dir / intermediate_dir / new_filename
183
+ new_filepath.parent.mkdir(parents=True, exist_ok=True)
184
+ nib.save(cleaned_bold, new_filepath)
185
+
186
+ def _clean_bold_in_surface_space(self, filepath: Path, model_spec: ModelSpec):
187
+ # Read raw BOLD data
188
+ img = nib.load(filepath)
189
+ assert isinstance(img, GiftiImage)
190
+ bold = img.agg_data()
191
+ assert isinstance(bold, np.ndarray)
192
+ bold = bold.T # Shape of (TRs, voxels)
193
+
194
+ # Extract TR value (assumed constant in a given run)
195
+ repetition_time = img.darrays[0].meta.get("TimeStep") # In milliseconds
196
+ if repetition_time is None:
197
+ raise ValueError(f"TR metadata is missing: {filepath.name}")
198
+ TR = float(repetition_time) / 1000 # Convert to seconds
199
+
200
+ # Load confounds for the requested model
201
+ confounds_df = self._load_confounds(filepath, model_spec)
202
+ confounds = confounds_df.to_numpy() # Shape of (TRs, confounds)
203
+ if confounds.shape[0] != bold.shape[0]:
204
+ raise ValueError(
205
+ f"Unequal number of TRs between BOLD and confounds data: {filepath.name}"
206
+ )
207
+
208
+ # Perform confound regression
209
+ cleaned_bold = signal.clean(
210
+ bold,
211
+ confounds=confounds,
212
+ detrend=True,
213
+ t_r=TR,
214
+ ensure_finite=True,
215
+ standardize="zscore_sample",
216
+ standardize_confounds=True,
217
+ )
218
+
219
+ # Store cleaned BOLD data
220
+ new_img = GiftiImage(
221
+ darrays=[
222
+ GiftiDataArray(data=row, intent="NIFTI_INTENT_TIME_SERIES")
223
+ for row in cleaned_bold
224
+ ],
225
+ header=img.header,
226
+ extra=img.extra,
227
+ )
228
+ entities = filepath.name.split("_")
229
+ if entities[-2].startswith("desc-"):
230
+ entities[-2] = "desc-clean"
231
+ else:
232
+ entities.insert(-1, "desc-clean")
233
+ new_filename = "_".join(entities)
234
+ intermediate_dir = filepath.relative_to(self._fmriprep_dir).parent
235
+ new_filepath = self._output_dir / intermediate_dir / new_filename
236
+ new_filepath.parent.mkdir(parents=True, exist_ok=True)
237
+ nib.save(new_img, new_filepath)
238
+
239
+ def _load_confounds(
240
+ self, bold_filepath: Path, model_spec: ModelSpec
241
+ ) -> pl.DataFrame:
242
+ # Extract file name up to the run number segment
243
+ match = re.search(r"^(.*?run-\d+)", bold_filepath.name)
244
+ if match is None:
245
+ raise ValueError(f"Run number is missing: {bold_filepath.name}")
246
+ identifier = match.group(1) # Includes subject/session/task/run info
247
+
248
+ # Load standard confounds for the requested model
249
+ files = bold_filepath.parent.glob(f"{identifier}*desc-confounds*timeseries.tsv")
250
+ confounds_filepath = next(files, None)
251
+ if confounds_filepath is None:
252
+ raise FileNotFoundError(f"Confounds not found for: {identifier}")
253
+ confounds_df = (
254
+ pl.read_csv(confounds_filepath, separator="\t")
255
+ .fill_nan(None) # For interpolation
256
+ .fill_null(strategy="backward") # Assume missing data in the beginning only
257
+ )
258
+ confounds_meta = TypeAdapter(dict[str, ConfoundMetadata]).validate_json(
259
+ confounds_filepath.with_suffix(".json").read_text()
260
+ )
261
+ confounds_df = self._extract_confounds(confounds_df, confounds_meta, model_spec)
262
+
263
+ # Load custom confounds for the requested model
264
+ if model_spec.custom_confounds:
265
+ files = self._custom_confounds_dir.glob(
266
+ f"**/{identifier}*desc-customConfounds*timeseries.tsv"
267
+ )
268
+ custom_confounds_filepath = next(files, None)
269
+ if custom_confounds_filepath is None:
270
+ raise FileNotFoundError(f"Custom confounds not found for: {identifier}")
271
+ custom_confounds_df = pl.read_csv(
272
+ custom_confounds_filepath,
273
+ separator="\t",
274
+ columns=model_spec.custom_confounds,
275
+ )
276
+ if custom_confounds_df.fill_nan(None).null_count().pipe(sum).item() > 0:
277
+ raise ValueError(
278
+ f"Missing / NaN values in custom confounds data: {identifier}"
279
+ )
280
+ if custom_confounds_df.height != confounds_df.height:
281
+ raise ValueError(
282
+ f"Unequal number of rows (TRs) between standard and custom confounds data: {identifier}"
283
+ )
284
+ confounds_df = pl.concat(
285
+ [confounds_df, custom_confounds_df], how="horizontal"
286
+ )
287
+
288
+ return confounds_df
289
+
290
+ def _extract_confounds(
291
+ self,
292
+ confounds_df: pl.DataFrame,
293
+ confounds_meta: dict[str, ConfoundMetadata],
294
+ model_spec: ModelSpec,
295
+ ) -> pl.DataFrame:
296
+ """
297
+ Extract confounds (including CompCor ones).
298
+
299
+ Notes
300
+ -----
301
+ Adapted from https://github.com/snastase/narratives/blob/master/code/extract_confounds.py.
302
+ """
303
+ # Pop out confound groups of variable number
304
+ groups = set(model_spec.confounds).intersection({"cosine", "motion_outlier"})
305
+
306
+ # Grab the requested (non-group) confounds
307
+ confounds = confounds_df[[c for c in model_spec.confounds if c not in groups]]
308
+
309
+ # Grab confound groups if requested
310
+ if groups:
311
+ group_cols = [
312
+ col
313
+ for col in confounds_df.columns
314
+ if any(group in col for group in groups)
315
+ ]
316
+ confounds = pl.concat(
317
+ [confounds, confounds_df[group_cols]], how="horizontal"
318
+ )
319
+
320
+ # Grab CompCor confounds if requested
321
+ compcors = [c for c in CompCorMethod if c.value in model_spec.model_fields_set]
322
+ if compcors:
323
+ comps_selected: list[str] = []
324
+ for compcor in compcors:
325
+ for options in getattr(model_spec, compcor.value):
326
+ assert isinstance(options, CompCorOptions)
327
+ comps_selected.extend(
328
+ self._select_comps(
329
+ confounds_meta,
330
+ compcor,
331
+ n_comps=options.n_comps,
332
+ tissue=options.tissue,
333
+ )
334
+ )
335
+ confounds = pl.concat(
336
+ [confounds, confounds_df[comps_selected]], how="horizontal"
337
+ )
338
+
339
+ return confounds
340
+
341
+ def _select_comps(
342
+ self,
343
+ confounds_meta: dict[str, ConfoundMetadata],
344
+ method: CompCorMethod,
345
+ n_comps: int | float,
346
+ tissue: CompCorTissue | None,
347
+ ) -> list[str]:
348
+ """
349
+ Select relevant CompCor components.
350
+
351
+ Notes
352
+ -----
353
+ Adapted from https://github.com/snastase/narratives/blob/master/code/extract_confounds.py.
354
+ """
355
+ # Check that we sensible number of components
356
+ assert n_comps > 0
357
+
358
+ # Ignore tissue if specified for tCompCor
359
+ if method == CompCorMethod.TEMPORAL and tissue:
360
+ self._logger.warning(
361
+ "tCompCor is not restricted to a tissue mask - ignoring tissue specification (%s)",
362
+ tissue.value,
363
+ )
364
+ tissue = None
365
+
366
+ # Get CompCor metadata for relevant method
367
+ compcor_meta = {
368
+ k: v
369
+ for k, v in confounds_meta.items()
370
+ if v.Method == method and v.Retained is True
371
+ }
372
+
373
+ # If aCompCor, filter metadata for tissue mask
374
+ if method == CompCorMethod.ANATOMICAL:
375
+ compcor_meta = {k: v for k, v in compcor_meta.items() if v.Mask == tissue}
376
+
377
+ # Make sure metadata components are sorted properly
378
+ comps_sorted = natsorted(compcor_meta)
379
+ for i, comp in enumerate(comps_sorted):
380
+ if comp != comps_sorted[-1]:
381
+ comp_next = comps_sorted[i + 1]
382
+ assert (
383
+ compcor_meta[comp].SingularValue
384
+ > compcor_meta[comp_next].SingularValue
385
+ )
386
+
387
+ # Either get top n components
388
+ if n_comps >= 1.0:
389
+ n_comps = int(n_comps)
390
+ if len(comps_sorted) >= n_comps:
391
+ comps_selected = comps_sorted[:n_comps]
392
+ else:
393
+ comps_selected = comps_sorted
394
+ self._logger.warning(
395
+ "Only %d %s components available (%d requested)",
396
+ len(comps_sorted),
397
+ method.value,
398
+ n_comps,
399
+ )
400
+
401
+ # Or components necessary to capture n proportion of variance
402
+ else:
403
+ comps_selected = []
404
+ for comp in comps_sorted:
405
+ comps_selected.append(comp)
406
+ if compcor_meta[comp].CumulativeVarianceExplained > n_comps:
407
+ break
408
+
409
+ # Check we didn't end up with degenerate 0 components
410
+ assert len(comps_selected) > 0
411
+
412
+ return comps_selected
413
+
414
+ @staticmethod
415
+ def _compose_glob_pattern_for_bold(
416
+ subject_id: str,
417
+ session_name: str,
418
+ task_name: str,
419
+ data_space: VolumeSpace | SurfaceSpace,
420
+ ) -> str:
421
+ SUFFIX_MAP = {VolumeSpace: "bold.nii.gz", SurfaceSpace: "bold.func.gii"}
422
+
423
+ subject = f"sub-{subject_id}"
424
+ session = "" if session_name == "*" else f"ses-{session_name}"
425
+ task = "" if task_name == "*" else f"task-{task_name}"
426
+ space = f"space-{data_space.value}"
427
+ suffix = SUFFIX_MAP[type(data_space)]
428
+
429
+ filepath_pattern = "*".join(
430
+ filter(None, [subject, session, task, space, suffix])
431
+ )
432
+
433
+ return f"{subject}/**/{filepath_pattern}"
hypline/schemas.py ADDED
@@ -0,0 +1,28 @@
1
+ from pydantic import BaseModel, PositiveFloat, PositiveInt
2
+
3
+ from .enums import CompCorMethod, CompCorTissue
4
+
5
+
6
+ class CompCorOptions(BaseModel):
7
+ n_comps: PositiveInt | PositiveFloat = 5
8
+ tissue: CompCorTissue | None = None
9
+
10
+
11
+ class ConfoundMetadata(BaseModel):
12
+ Method: CompCorMethod
13
+ Retained: bool | None = None
14
+ Mask: CompCorTissue | None = None
15
+ SingularValue: float | None = None
16
+ VarianceExplained: float | None = None
17
+ CumulativeVarianceExplained: float | None = None
18
+
19
+
20
+ class ModelSpec(BaseModel):
21
+ confounds: list[str]
22
+ custom_confounds: list[str] | None = None
23
+ aCompCor: list[CompCorOptions] | None = None
24
+ tCompCor: list[CompCorOptions] | None = None
25
+
26
+
27
+ class Config(BaseModel):
28
+ model_specs: dict[str, ModelSpec]
hypline/utils.py ADDED
@@ -0,0 +1,23 @@
1
+ from multiprocessing import Process
2
+
3
+ import dill
4
+
5
+
6
+ class DillProcess(Process):
7
+ """
8
+ Extend the `Process` class to support serialization
9
+ of closures and local functions.
10
+
11
+ Notes
12
+ -----
13
+ Adapted from https://stackoverflow.com/a/72776044.
14
+ """
15
+
16
+ def __init__(self, *args, **kwargs):
17
+ super().__init__(*args, **kwargs)
18
+ self._target = dill.dumps(self._target)
19
+
20
+ def run(self):
21
+ if self._target:
22
+ self._target = dill.loads(self._target)
23
+ self._target(*self._args, **self._kwargs)
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: hypline
3
+ Version: 0.2.0
4
+ Summary: Data Cleaning and Analysis Pipeline for Hyperscanning Research
5
+ Project-URL: Repository, https://github.com/princeton-ddss/hypline
6
+ Project-URL: Documentation, https://princeton-ddss.github.io/hypline/
7
+ Author-email: Sangyoon Park <datumvitae@gmail.com>
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: dill>=0.3.9
11
+ Requires-Dist: natsort>=8.4.0
12
+ Requires-Dist: nibabel>=5.3.2
13
+ Requires-Dist: nilearn>=0.11.1
14
+ Requires-Dist: numpy>=2.2.4
15
+ Requires-Dist: polars>=1.26.0
16
+ Requires-Dist: pydantic>=2.10.6
17
+ Requires-Dist: pyyaml>=6.0.2
18
+ Requires-Dist: rich>=14.0.0
19
+ Requires-Dist: typer>=0.15.2
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Hypline
23
+
24
+ Hypline is a Python package that provides a CLI tool for cleaning and analyzing data from hyperscanning studies involving dyadic conversations.
25
+
26
+ ## Installation
27
+
28
+ Hypline can be installed using `pip`:
29
+
30
+ ```bash
31
+ pip install hypline
32
+ ```
33
+
34
+ It can also be installed using other package managers such as [`uv`](https://docs.astral.sh/uv/) and [`poetry`](https://python-poetry.org/docs/).
35
+
36
+ ## Quick Start
37
+
38
+ Once the package is installed, `hypline` command will be available, like so:
39
+
40
+ ```bash
41
+ hypline --help
42
+ ```
43
+
44
+ Running the above will display an overview of the tool, including supported subcommands.
45
+
46
+ For instance, `clean` is a subcommand for performing confound regression to clean BOLD outputs from [fMRIPrep](https://fmriprep.org/en/stable/index.html), and its details can be viewed by running:
47
+
48
+ ```bash
49
+ hypline clean --help
50
+ ```
51
+
52
+ ## What Next
53
+
54
+ If you want to learn more about Hypline, please check out the official project [documentation](https://princeton-ddss.github.io/hypline/).
@@ -0,0 +1,11 @@
1
+ hypline/__init__.py,sha256=SU_ipGTn_2PkwuEufOyMdpyCU2BmU-cDpQZZeMpzF3E,45
2
+ hypline/cli.py,sha256=_o9ZdubSaGriO1UwaJFQkZAxyof9ki22nYH5aBh_v1Q,3638
3
+ hypline/enums.py,sha256=dznKn542WjYtWloHYg6wOgEaA0oM0AkmPHb0GdBycsQ,424
4
+ hypline/regression.py,sha256=jKFMIoxMcYWTo0xNO3rkskHBnBv0HzUM-XMilDs1CgM,16385
5
+ hypline/schemas.py,sha256=GqBR5ZFkk551dk6Dxf5JO8CMQvNTA6W2aost2lXwRJA,761
6
+ hypline/utils.py,sha256=A1ULLv9AGxwe03JBriQz-_Uf7_iRgcjAUeJPeNKnQx8,547
7
+ hypline-0.2.0.dist-info/METADATA,sha256=f5tYpp_got6hXfbp3MMDsjJqM98bKIiwcY3Is6s7XWY,1681
8
+ hypline-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ hypline-0.2.0.dist-info/entry_points.txt,sha256=6v2pTaC6l1ZUnp3ASMrlgDpRzADy9lUgbbClDIb6e_U,41
10
+ hypline-0.2.0.dist-info/licenses/LICENSE,sha256=tQFZEx9unYshmR4ql0X5AMdM4B4seUu6fsiN0kX6FZw,1079
11
+ hypline-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hypline = hypline:main
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2025 The Trustees of Princeton University
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.