pyfsviz 0.1.1__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.
pyfsviz/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """pyFSViz package.
2
+
3
+ Python tools for FreeSurfer visualization and QA
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pyfsviz import freesurfer, reports
9
+ from pyfsviz._internal.cli import get_parser, main
10
+ from pyfsviz.freesurfer import (
11
+ FreeSurfer,
12
+ get_freesurfer_colormap,
13
+ )
14
+ from pyfsviz.reports import (
15
+ Template,
16
+ )
17
+
18
+ __all__: list[str] = [
19
+ "FreeSurfer",
20
+ "Template",
21
+ "freesurfer",
22
+ "get_freesurfer_colormap",
23
+ "get_parser",
24
+ "main",
25
+ "reports",
26
+ ]
pyfsviz/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Entry-point module, in case you use `python -m pyfsviz`.
2
+
3
+ Why does this file exist, and why `__main__`? For more info, read:
4
+
5
+ - https://www.python.org/dev/peps/pep-0338/
6
+ - https://docs.python.org/3/using/cmdline.html#cmdoption-m
7
+ """
8
+
9
+ import sys
10
+
11
+ from pyfsviz._internal.cli import main
12
+
13
+ if __name__ == "__main__":
14
+ sys.exit(main(sys.argv[1:]))
File without changes
@@ -0,0 +1,59 @@
1
+ # Why does this file exist, and why not put this in `__main__`?
2
+ #
3
+ # You might be tempted to import things from `__main__` later,
4
+ # but that will cause problems: the code will get executed twice:
5
+ #
6
+ # - When you run `python -m pyfsviz` python will execute
7
+ # `__main__.py` as a script. That means there won't be any
8
+ # `pyfsviz.__main__` in `sys.modules`.
9
+ # - When you import `__main__` it will get executed again (as a module) because
10
+ # there's no `pyfsviz.__main__` in `sys.modules`.
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from typing import Any
17
+
18
+ from pyfsviz._internal import debug
19
+
20
+
21
+ class _DebugInfo(argparse.Action):
22
+ def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
23
+ super().__init__(nargs=nargs, **kwargs)
24
+
25
+ def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
26
+ debug._print_debug_info()
27
+ sys.exit(0)
28
+
29
+
30
+ def get_parser() -> argparse.ArgumentParser:
31
+ """Return the CLI argument parser.
32
+
33
+ Returns
34
+ -------
35
+ An argparse parser.
36
+ """
37
+ parser = argparse.ArgumentParser(prog="pyfsviz")
38
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
39
+ parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
40
+ return parser
41
+
42
+
43
+ def main(args: list[str] | None = None) -> int:
44
+ """Run the main program.
45
+
46
+ This function is executed when you type `pyfsviz` or `python -m pyfsviz`.
47
+
48
+ Parameters
49
+ ----------
50
+ args: Arguments passed from the command line.
51
+
52
+ Returns
53
+ -------
54
+ An exit code.
55
+ """
56
+ parser = get_parser()
57
+ opts = parser.parse_args(args=args)
58
+ print(opts)
59
+ return 0
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from importlib import metadata
8
+
9
+
10
+ @dataclass
11
+ class _Variable:
12
+ """Dataclass describing an environment variable."""
13
+
14
+ name: str
15
+ """Variable name."""
16
+ value: str
17
+ """Variable value."""
18
+
19
+
20
+ @dataclass
21
+ class _Package:
22
+ """Dataclass describing a Python package."""
23
+
24
+ name: str
25
+ """Package name."""
26
+ version: str
27
+ """Package version."""
28
+
29
+
30
+ @dataclass
31
+ class _Environment:
32
+ """Dataclass to store environment information."""
33
+
34
+ interpreter_name: str
35
+ """Python interpreter name."""
36
+ interpreter_version: str
37
+ """Python interpreter version."""
38
+ interpreter_path: str
39
+ """Path to Python executable."""
40
+ platform: str
41
+ """Operating System."""
42
+ packages: list[_Package]
43
+ """Installed packages."""
44
+ variables: list[_Variable]
45
+ """Environment variables."""
46
+
47
+
48
+ def _interpreter_name_version() -> tuple[str, str]:
49
+ if hasattr(sys, "implementation"):
50
+ impl = sys.implementation.version
51
+ version = f"{impl.major}.{impl.minor}.{impl.micro}"
52
+ kind = impl.releaselevel
53
+ if kind != "final":
54
+ version += kind[0] + str(impl.serial)
55
+ return sys.implementation.name, version
56
+ return "", "0.0.0"
57
+
58
+
59
+ def _get_version(dist: str = "pyfsviz") -> str:
60
+ """Get version of the given distribution.
61
+
62
+ Parameters
63
+ ----------
64
+ dist: A distribution name.
65
+
66
+ Returns
67
+ -------
68
+ A version number.
69
+ """
70
+ try:
71
+ return metadata.version(dist)
72
+ except metadata.PackageNotFoundError:
73
+ return "0.0.0"
74
+
75
+
76
+ def _get_debug_info() -> _Environment:
77
+ """Get debug/environment information.
78
+
79
+ Returns
80
+ -------
81
+ Environment information.
82
+ """
83
+ py_name, py_version = _interpreter_name_version()
84
+ packages = ["pyfsviz"]
85
+ variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("PYFSVIZ")]]
86
+ return _Environment(
87
+ interpreter_name=py_name,
88
+ interpreter_version=py_version,
89
+ interpreter_path=sys.executable,
90
+ platform=platform.platform(),
91
+ variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))], # ty: ignore[invalid-argument-type]
92
+ packages=[_Package(pkg, _get_version(pkg)) for pkg in packages],
93
+ )
94
+
95
+
96
+ def _print_debug_info() -> None:
97
+ """Print debug/environment information."""
98
+ info = _get_debug_info()
99
+ print(f"- __System__: {info.platform}")
100
+ print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})")
101
+ print("- __Environment variables__:")
102
+ for var in info.variables:
103
+ print(f" - `{var.name}`: `{var.value}`")
104
+ print("- __Installed packages__:")
105
+ for pkg in info.packages:
106
+ print(f" - `{pkg.name}` v{pkg.version}")
107
+
108
+
109
+ if __name__ == "__main__":
110
+ _print_debug_info()
@@ -0,0 +1,120 @@
1
+ <?xml version="1.0" encoding="utf-8" ?>
2
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
3
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
4
+ <head>
5
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
6
+ <meta name="generator" content="MRIQC" />
7
+ <title>FreeSurfer: Individual Report</title>
8
+ <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
9
+ <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
11
+ <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
12
+
13
+ <style type="text/css">
14
+ body {
15
+ font-family: helvetica;
16
+ padding: 50px 10px 10px;
17
+ }
18
+
19
+ div.warning p.admonition-title, .code .error {
20
+ color: red ;
21
+ font-weight: bold ;
22
+ }
23
+
24
+ span.problematic {
25
+ color: red;
26
+ font-weight: bold ;
27
+ }
28
+
29
+ p.label {
30
+ white-space: nowrap }
31
+
32
+ span.section-subtitle {
33
+ /* font-size relative to parent (h1..h6 element) */
34
+ font-size: 80% }
35
+
36
+ div.embeded-report {
37
+ width: 100%;
38
+ page-break-before:always;
39
+ padding-top: 20px;
40
+ }
41
+
42
+ div.embeded-report svg { width: 100%; }
43
+
44
+ div#accordionOther {
45
+ margin: 0 20px;
46
+ }
47
+
48
+ .add-padding {
49
+ padding-top: 15px;
50
+ }
51
+
52
+ #report-qi2-fitting {
53
+ max-width: 450px;
54
+ }
55
+
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <div class="document">
60
+
61
+ <nav class="navbar fixed-top navbar-expand-lg navbar-light bg-light">
62
+ <div class="collapse navbar-collapse">
63
+ <ul class="navbar-nav">
64
+ <li class="nav-item"><a class="nav-link" href="#summary">Summary</a></li>
65
+ <li class="nav-item"><a class="nav-link" href="#tlrc">Talairach Registration</a></li>
66
+ <li class="nav-item"><a class="nav-link" href="#aseg">Aparc+Aseg Parcellations</a></li>
67
+ <li class="nav-item"><a class="nav-link" href="#surf">Surfaces</a></li>
68
+ </ul>
69
+ </div>
70
+ </nav>
71
+ <noscript>
72
+ <h1 class="text-danger"> The navigation menu uses Javascript. Without it this report might not work as expected </h1>
73
+ </noscript>
74
+
75
+ <h1 class="mt-5 mb-5">FreeSurfer: Individual Report</h1>
76
+
77
+ <div class="card mt-3" style="width: 480pt;">
78
+ <h2 id="summary" class="card-header">Summary</h2>
79
+ <div class="card-body">
80
+ <ul class="simple">
81
+ <li>Date and time: {{ timestamp }}.</li>
82
+ </ul>
83
+ </div>
84
+ </div>
85
+
86
+ <h2 id="tlrc" class="mt-5 mb-2">Talairach Registration</h2>
87
+ {% for tag in tlrc %}
88
+ <div class="card mt-2">
89
+ <div id="report-tlrc" class="card-body">
90
+ {{ tag }}
91
+ </div>
92
+ </div>
93
+ {% endfor %}
94
+
95
+ <h2 id="aseg" class="mt-5 mb-2">Aparc+Aseg Parcellations</h2>
96
+ {% for tag in aseg %}
97
+ <div class="card mt-2">
98
+ <div id="report-aseg" class="card-body">
99
+ {{ tag }}
100
+ </div>
101
+ </div>
102
+ {% endfor %}
103
+
104
+ <h2 id="surf" class="mt-5 mb-2">Surfaces</h2>
105
+ {% for label, tag in surf %}
106
+ <div class="card mt-2">
107
+ <h4 id="surf" class="mt-5 mb-2">{{ label }}</h4>
108
+ <div id="report-{{ label }}" class="card-body">
109
+ {{ tag }}
110
+ </div>
111
+ </div>
112
+ {% endfor %}
113
+
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+
119
+ </body>
120
+ </html>
Binary file
Binary file
pyfsviz/freesurfer.py ADDED
@@ -0,0 +1,616 @@
1
+ """FreeSurfer data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from importlib_resources import files
13
+ from matplotlib import colors
14
+ from matplotlib import pyplot as plt
15
+ from nilearn import plotting
16
+ from nipype.interfaces.freesurfer import MRIConvert
17
+ from nipype.interfaces.fsl import FLIRT
18
+ from nireports.interfaces.reporting.base import SimpleBeforeAfterRPT
19
+
20
+ from pyfsviz.reports import Template
21
+
22
+
23
+ def get_freesurfer_colormap(freesurfer_home: Path | str) -> colors.ListedColormap:
24
+ """Generate matplotlib colormap from FreeSurfer LUT.
25
+
26
+ Code from:
27
+ https://github.com/Deep-MI/qatools-python/blob/freesurfer-module-releases/qatoolspython/createScreenshots.py
28
+
29
+ Parameters
30
+ ----------
31
+ freesurfer_home : path or str representing a path to a directory
32
+ Path corresponding to FREESURFER_HOME env var.
33
+
34
+ Returns
35
+ -------
36
+ colormap : matplotlib.colors.ListedColormap
37
+ A matplotlib compatible FreeSurfer colormap.
38
+
39
+ """
40
+ freesurfer_home = Path(freesurfer_home) if isinstance(freesurfer_home, str) else freesurfer_home
41
+ lut = pd.read_csv(
42
+ freesurfer_home / "FreeSurferColorLUT.txt",
43
+ sep=r"\s+",
44
+ comment="#",
45
+ header=None,
46
+ skipinitialspace=True,
47
+ skip_blank_lines=True,
48
+ )
49
+ lut = np.array(lut)
50
+ lut_tab = np.array(lut[:, (2, 3, 4, 5)] / 255, dtype="float32")
51
+ lut_tab[:, 3] = 1
52
+
53
+ return colors.ListedColormap(lut_tab)
54
+
55
+
56
+ class FreeSurfer:
57
+ """Base class for FreeSurfer data."""
58
+
59
+ def __init__(
60
+ self,
61
+ freesurfer_home: str | None = None,
62
+ subjects_dir: str | None = None,
63
+ log_level: str = "INFO",
64
+ ):
65
+ """Initialize the FreeSurfer data.
66
+
67
+ Parameters
68
+ ----------
69
+ freesurfer_home : str representing a path to a directory
70
+ Path corresponding to FREESURFER_HOME env var.
71
+ subjects_dir : str representing a path to a directory
72
+ Path corresponding to SUBJECTS_DIR env var.
73
+ log_level : str
74
+ Logging level (e.g., "INFO", "DEBUG", "WARNING").
75
+ Default is "INFO".
76
+
77
+ Returns
78
+ -------
79
+ None
80
+
81
+ """
82
+ # Set up logger
83
+ logging.basicConfig(level=getattr(logging, log_level.upper()))
84
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
85
+ """Logger for the FreeSurfer class."""
86
+
87
+ if freesurfer_home is None:
88
+ self.freesurfer_home = Path(os.environ.get("FREESURFER_HOME") or "")
89
+ """Path to the FreeSurfer home directory."""
90
+ else:
91
+ self.freesurfer_home = Path(freesurfer_home)
92
+ """Path to the FreeSurfer home directory."""
93
+ if not self.freesurfer_home.exists():
94
+ raise FileNotFoundError(f"FREESURFER_HOME not found: {self.freesurfer_home}")
95
+ if self.freesurfer_home is None:
96
+ raise ValueError("FREESURFER_HOME must be set")
97
+
98
+ if subjects_dir is None:
99
+ self.subjects_dir = Path(os.environ.get("SUBJECTS_DIR") or "")
100
+ """Path to the subjects directory."""
101
+ else:
102
+ self.subjects_dir = Path(subjects_dir)
103
+ """Path to the subjects directory."""
104
+ if not self.subjects_dir.exists():
105
+ raise FileNotFoundError(f"SUBJECTS_DIR not found: {self.subjects_dir}")
106
+ """Path to the subjects directory."""
107
+ self._mni_nii = files("pyfsviz._internal") / "mni305.cor.nii.gz"
108
+ """Path to the MNI template NIfTI file."""
109
+ self._mni_mgz = files("pyfsviz._internal") / "mni305.cor.mgz"
110
+ """Path to the MNI template MGH file."""
111
+
112
+ def get_colormap(self) -> colors.ListedColormap:
113
+ """Return the colormap for the FreeSurfer data."""
114
+ return get_freesurfer_colormap(self.freesurfer_home)
115
+
116
+ def get_subjects(self) -> list[str]:
117
+ """Return the subjects in the subjects directory."""
118
+ return [
119
+ subject.name
120
+ for subject in self.subjects_dir.iterdir()
121
+ if subject.is_dir() and (subject / "mri" / "transforms" / "talairach.lta").exists()
122
+ ]
123
+
124
+ def check_recon_all(self, subject: str) -> bool:
125
+ """Verify that the subject's FreeSurfer recon finished successfully."""
126
+ recon_file = self.subjects_dir / subject / "scripts" / "recon-all.log"
127
+
128
+ with open(recon_file, encoding="utf-8") as f:
129
+ line = f.readlines()[-1]
130
+ return "finished without error" in line
131
+
132
+ def gen_tlrc_data(self, subject: str, output_dir: str) -> None:
133
+ """Generate inverse talairach data for report generation.
134
+
135
+ Parameters
136
+ ----------
137
+ output_dir : str
138
+ Path for intermediate file output.
139
+
140
+ Examples
141
+ --------
142
+ >>> from pyfsviz.freesurfer import FreeSurfer
143
+ >>> fs_dir = FreeSurfer(
144
+ ... freesurfer_home="/opt/freesurfer",
145
+ ... subjects_dir="/opt/data",
146
+ ... subject="sub-001",
147
+ ... )
148
+ >>> fs_dir.gen_tlrc_data("sub-001", Path("/opt/data/sub-001/mri/transforms"))
149
+ """
150
+ # get inverse transform
151
+ lta_file = self.subjects_dir / subject / "mri" / "transforms" / "talairach.xfm.lta"
152
+ xfm = np.genfromtxt(lta_file, skip_header=5, max_rows=4)
153
+ inverse_xfm = np.linalg.inv(xfm)
154
+ np.savetxt(
155
+ f"{output_dir}/inv.xfm",
156
+ inverse_xfm,
157
+ fmt="%0.8f",
158
+ delimiter=" ",
159
+ newline="\n",
160
+ encoding="utf-8",
161
+ )
162
+
163
+ # convert subject original T1 to nifti (for FSL)
164
+ convert = MRIConvert(
165
+ in_file=self.subjects_dir / subject / "mri" / "orig.mgz",
166
+ out_file=f"{output_dir}/orig.nii.gz",
167
+ out_type="niigz",
168
+ )
169
+ convert.run()
170
+
171
+ # use FSL to convert template file to subject original space
172
+ flirt = FLIRT(
173
+ in_file=self._mni_nii,
174
+ reference=f"{output_dir}/orig.nii.gz",
175
+ out_file=f"{output_dir}/mni2orig.nii.gz",
176
+ in_matrix_file=f"{output_dir}/inv.xfm",
177
+ apply_xfm=True,
178
+ out_matrix_file=f"{output_dir}/out.mat",
179
+ )
180
+ flirt.run()
181
+
182
+ def gen_tlrc_report(
183
+ self,
184
+ subject: str,
185
+ output_dir: str,
186
+ tlrc_dir: str | None = None,
187
+ *,
188
+ gen_data: bool = True,
189
+ ) -> Path:
190
+ """Generate a before and after report of Talairach registration. (Will also run file generation if needed).
191
+
192
+ Parameters
193
+ ----------
194
+ subject : str
195
+ Subject ID.
196
+ output_dir : str
197
+ Path to SVG output.
198
+ gen_data : bool
199
+ Generate inverse Talairach data, by default True
200
+ tlrc_dir : str | None
201
+ Path to output of `gen_tlrc_data`. Default is the subject's mri/transforms directory.
202
+
203
+ Returns
204
+ -------
205
+ Path:
206
+ SVG file generated from the niworkflows SimpleBeforeAfterRPT
207
+
208
+ Examples
209
+ --------
210
+ >>> from pyfsviz.freesurfer import FreeSurfer
211
+ >>> fs_dir = FreeSurfer(
212
+ ... freesurfer_home="/opt/freesurfer",
213
+ ... subjects_dir="/opt/data",
214
+ ... subject="sub-001",
215
+ ... )
216
+ >>> report = fs_dir.gen_tlrc_report(
217
+ ... "sub-001", Path("/opt/data/sub-001/mri/transforms")
218
+ ... )
219
+ """
220
+ if tlrc_dir is None:
221
+ tlrc_dir = f"{self.subjects_dir}/{subject}/mri/transforms"
222
+
223
+ mri_dir = f"{self.subjects_dir}/{subject}/mri"
224
+
225
+ if gen_data:
226
+ self.gen_tlrc_data(subject, tlrc_dir)
227
+
228
+ # use white matter segmentation to compare registrations
229
+ report = SimpleBeforeAfterRPT(
230
+ before=f"{mri_dir}/orig.mgz",
231
+ after=f"{tlrc_dir}/mni2orig.nii.gz",
232
+ wm_seg=f"{mri_dir}/wm.mgz",
233
+ before_label="Subject Orig",
234
+ after_label="Template",
235
+ out_report=f"{output_dir}/tlrc.svg",
236
+ )
237
+ result = report.run()
238
+ return result.outputs.out_report
239
+
240
+ def gen_aparcaseg_plots(self, subject: str, output_dir: str, num_imgs: int = 10) -> list[Path]:
241
+ """Generate parcellation images (aparc & aseg).
242
+
243
+ Parameters
244
+ ----------
245
+ output_dir : str
246
+ Path to output directory.
247
+ num_imgs : int
248
+ Number of images/slices to make.
249
+
250
+ Returns
251
+ -------
252
+ list
253
+ List of SVG image paths
254
+
255
+ Examples
256
+ --------
257
+ >>> from pyfsviz.freesurfer import FreeSurfer
258
+ >>> fs_dir = FreeSurfer(
259
+ ... freesurfer_home="/opt/freesurfer",
260
+ ... subjects_dir="/opt/data",
261
+ ... subject="sub-001",
262
+ ... )
263
+ >>> images = fs_dir.gen_aparcaseg_plots(
264
+ ... "sub-001", Path("/opt/data/sub-001/mri/transforms")
265
+ ... )
266
+ """
267
+ mri_dir = f"{self.subjects_dir}/{subject}/mri"
268
+ cmap = self.get_colormap()
269
+
270
+ # get parcellation and segmentation images
271
+ plotting.plot_roi(
272
+ f"{mri_dir}/aparc+aseg.mgz",
273
+ f"{mri_dir}/T1.mgz",
274
+ cmap=cmap,
275
+ display_mode="mosaic",
276
+ dim=-1,
277
+ cut_coords=num_imgs,
278
+ alpha=0.5,
279
+ output_file=f"{output_dir}/aseg.svg",
280
+ )
281
+ display = plotting.plot_anat(
282
+ f"{mri_dir}/brainmask.mgz",
283
+ display_mode="mosaic",
284
+ cut_coords=num_imgs,
285
+ dim=-1,
286
+ )
287
+ display.add_contours(
288
+ f"{mri_dir}/lh.ribbon.mgz",
289
+ colors="b",
290
+ linewidths=0.5,
291
+ levels=[0.5],
292
+ )
293
+ display.add_contours(
294
+ f"{mri_dir}/rh.ribbon.mgz",
295
+ colors="r",
296
+ linewidths=0.5,
297
+ levels=[0.5],
298
+ )
299
+ display.savefig(f"{output_dir}/aparc.svg")
300
+ display.close()
301
+
302
+ return [Path(f"{output_dir}/aseg.svg"), Path(f"{output_dir}/aparc.svg")]
303
+
304
+ def gen_surf_plots(self, subject: str, output_dir: str) -> list[Path]:
305
+ """Generate pial, inflated, and sulcal images from various viewpoints.
306
+
307
+ Parameters
308
+ ----------
309
+ output_dir : str
310
+ Surface plot output directory.
311
+
312
+ Returns
313
+ -------
314
+ list[Path]:
315
+ List of generated SVG images
316
+
317
+ Examples
318
+ --------
319
+ >>> from pyfsviz.freesurfer import FreeSurfer
320
+ >>> fs_dir = FreeSurfer(
321
+ ... freesurfer_home="/opt/freesurfer",
322
+ ... subjects_dir="/opt/data",
323
+ ... subject="sub-001",
324
+ ... )
325
+ >>> images = fs_dir.gen_surf_plots("sub-001", Path("/opt/data/sub-001/surf"))
326
+ """
327
+ surf_dir = f"{self.subjects_dir}/{subject}/surf"
328
+ label_dir = f"{self.subjects_dir}/{subject}/label"
329
+ cmap = self.get_colormap()
330
+
331
+ hemis = {"lh": "left", "rh": "right"}
332
+ for key, val in hemis.items():
333
+ pial = f"{surf_dir}/{key}.pial"
334
+ inflated = f"{surf_dir}/{key}.inflated"
335
+ sulc = f"{surf_dir}/{key}.sulc"
336
+ white = f"{surf_dir}/{key}.white"
337
+ annot = f"{label_dir}/{key}.aparc.annot"
338
+
339
+ label_files = {pial: "pial", inflated: "infl", white: "white"}
340
+
341
+ for surf, label in label_files.items():
342
+ fig, axs = plt.subplots(2, 3, subplot_kw={"projection": "3d"})
343
+ plotting.plot_surf_roi(
344
+ surf,
345
+ annot,
346
+ hemi=val,
347
+ view="lateral",
348
+ bg_map=sulc,
349
+ bg_on_data=True,
350
+ darkness=1,
351
+ cmap=cmap,
352
+ axes=axs[0, 0],
353
+ figure=fig,
354
+ )
355
+ plotting.plot_surf_roi(
356
+ surf,
357
+ annot,
358
+ hemi=val,
359
+ view="medial",
360
+ bg_map=sulc,
361
+ bg_on_data=True,
362
+ darkness=1,
363
+ cmap=cmap,
364
+ axes=axs[0, 1],
365
+ figure=fig,
366
+ )
367
+ plotting.plot_surf_roi(
368
+ surf,
369
+ annot,
370
+ hemi=val,
371
+ view="dorsal",
372
+ bg_map=sulc,
373
+ bg_on_data=True,
374
+ darkness=1,
375
+ cmap=cmap,
376
+ axes=axs[0, 2],
377
+ figure=fig,
378
+ )
379
+ plotting.plot_surf_roi(
380
+ surf,
381
+ annot,
382
+ hemi=val,
383
+ view="ventral",
384
+ bg_map=sulc,
385
+ bg_on_data=True,
386
+ darkness=1,
387
+ cmap=cmap,
388
+ axes=axs[1, 0],
389
+ figure=fig,
390
+ )
391
+ plotting.plot_surf_roi(
392
+ surf,
393
+ annot,
394
+ hemi=val,
395
+ view="anterior",
396
+ bg_map=sulc,
397
+ bg_on_data=True,
398
+ darkness=1,
399
+ cmap=cmap,
400
+ axes=axs[1, 1],
401
+ figure=fig,
402
+ )
403
+ plotting.plot_surf_roi(
404
+ surf,
405
+ annot,
406
+ hemi=val,
407
+ view="posterior",
408
+ bg_map=sulc,
409
+ bg_on_data=True,
410
+ darkness=1,
411
+ cmap=cmap,
412
+ axes=axs[1, 2],
413
+ figure=fig,
414
+ )
415
+
416
+ plt.savefig(f"{output_dir}/{key}_{label}.svg", dpi=300, format="svg")
417
+ plt.close()
418
+
419
+ return sorted(Path(output_dir).glob("*svg"))
420
+
421
+ def gen_html_report(
422
+ self,
423
+ subject: str,
424
+ output_dir: str,
425
+ img_out: str | None = None,
426
+ template: str | None = None,
427
+ ) -> Path:
428
+ """Generate html report with FreeSurfer images.
429
+
430
+ Parameters
431
+ ----------
432
+ subject : str
433
+ Subject ID.
434
+ output_dir : str
435
+ HTML file name
436
+ img_out : str | None
437
+ Location where SVG images are saved, default is subject's data directory.
438
+ template : str | None
439
+ HTML template to use. Default is local freesurfer.html.
440
+
441
+ Returns
442
+ -------
443
+ Path:
444
+ Path to html file.
445
+
446
+ Examples
447
+ --------
448
+ >>> from pyfsviz.freesurfer import FreeSurfer
449
+ >>> fs_dir = FreeSurfer(
450
+ ... freesurfer_home="/opt/freesurfer",
451
+ ... subjects_dir="/opt/data",
452
+ ... subject="sub-001",
453
+ ... )
454
+ >>> report = fs_dir.gen_html_report(out_name="sub-001.html", output_dir=".")
455
+ """
456
+ if template is None:
457
+ template = files("pyfsviz._internal.html") / "individual.html"
458
+ if img_out is None:
459
+ image_list = list((self.subjects_dir / subject).glob("*/*svg"))
460
+ else:
461
+ image_list = list(Path(img_out).glob("*svg"))
462
+
463
+ tlrc = []
464
+ aseg = []
465
+ surf = []
466
+
467
+ for img in image_list:
468
+ with open(img, encoding="utf-8") as img_file:
469
+ img_data = img_file.read()
470
+
471
+ if "tlrc" in img.name:
472
+ tlrc.append(img_data)
473
+ elif "aseg" in img.name or "aparc" in img.name:
474
+ aseg.append(img_data)
475
+ else:
476
+ labels = {
477
+ "lh_pial": "LH Pial",
478
+ "rh_pial": "RH Pial",
479
+ "lh_infl": "LH Inflated",
480
+ "rh_infl": "RH Inflated",
481
+ "lh_white": "LH White Matter",
482
+ "rh_white": "RH White Matter",
483
+ }
484
+ surf_tuple = (labels[img.stem], img_data)
485
+ surf.append(surf_tuple)
486
+
487
+ _config = {
488
+ "timestamp": datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M"),
489
+ "subject": subject,
490
+ "tlrc": tlrc,
491
+ "aseg": aseg,
492
+ "surf": surf,
493
+ }
494
+
495
+ tpl = Template(str(template))
496
+ tpl.generate_conf(_config, f"{output_dir}/{subject}.html")
497
+
498
+ return Path(output_dir) / f"{subject}.html"
499
+
500
+ def gen_batch_reports(
501
+ self,
502
+ output_dir: str | Path,
503
+ subjects: list[str] | None = None,
504
+ template: str | None = None,
505
+ *,
506
+ gen_images: bool = True,
507
+ skip_failed: bool = True,
508
+ ) -> dict[str, Path | Exception]:
509
+ """Generate HTML reports with images for multiple subjects.
510
+
511
+ This method first generates all required images (TLRC, aparc+aseg, surfaces)
512
+ and then creates HTML reports for each subject.
513
+
514
+ Parameters
515
+ ----------
516
+ output_dir : str or Path
517
+ Directory where HTML reports will be saved.
518
+ subjects : list[str] or None
519
+ List of subject IDs to process. If None, processes all subjects
520
+ in the subjects directory.
521
+ template : str or None
522
+ HTML template to use. Default is local individual.html.
523
+ gen_images : bool
524
+ Generate images for each subject. Default is True.
525
+ skip_failed : bool
526
+ If True, continues processing other subjects if one fails.
527
+ If False, raises exception on first failure.
528
+
529
+ Returns
530
+ -------
531
+ dict[str, Path | Exception]
532
+ Dictionary mapping subject IDs to either the generated HTML file
533
+ path or the exception that occurred during processing.
534
+
535
+ Examples
536
+ --------
537
+ >>> from pyfsviz.freesurfer import FreeSurfer
538
+ >>> fs = FreeSurfer()
539
+ >>> results = fs.gen_batch_reports("reports/", log_level="INFO")
540
+ >>> for subject, result in results.items():
541
+ ... if isinstance(result, Path):
542
+ ... print(f"Generated report for {subject}: {result}")
543
+ ... else:
544
+ ... print(f"Failed to generate report for {subject}: {result}")
545
+ """
546
+ output_dir = Path(output_dir)
547
+ output_dir.mkdir(parents=True, exist_ok=True)
548
+
549
+ if subjects is None:
550
+ subjects = self.get_subjects()
551
+
552
+ self.logger.info(f"Generating reports with images for {len(subjects)} subjects...")
553
+ self.logger.info(f"Output directory: {output_dir}")
554
+
555
+ results: dict[str, Path | Exception] = {}
556
+
557
+ for i, subject in enumerate(subjects, 1):
558
+ self.logger.info(f"[{i}/{len(subjects)}] Processing subject: {subject}")
559
+
560
+ try:
561
+ # Check if recon-all completed successfully
562
+ if not self.check_recon_all(subject):
563
+ self.logger.warning(f"Subject {subject} recon-all did not complete successfully")
564
+
565
+ # Create subject-specific output directory for images
566
+ subject_output_dir = output_dir / subject
567
+ subject_output_dir.mkdir(parents=True, exist_ok=True)
568
+
569
+ # Generate images
570
+ self.logger.info(f" Generating images for {subject}...")
571
+
572
+ if gen_images:
573
+ # Generate TLRC data and report
574
+ tlrc_dir = subject_output_dir / "tlrc"
575
+ tlrc_dir.mkdir(parents=True, exist_ok=True)
576
+ self.gen_tlrc_data(subject, str(tlrc_dir))
577
+ self.gen_tlrc_report(subject, str(tlrc_dir))
578
+
579
+ # Generate aparc+aseg plots
580
+ aparc_dir = subject_output_dir / "aparcaseg"
581
+ aparc_dir.mkdir(parents=True, exist_ok=True)
582
+ self.gen_aparcaseg_plots(subject, str(aparc_dir))
583
+
584
+ # Generate surface plots
585
+ surf_dir = subject_output_dir / "surfaces"
586
+ surf_dir.mkdir(parents=True, exist_ok=True)
587
+ self.gen_surf_plots(subject, str(surf_dir))
588
+
589
+ # Generate HTML report using all generated images
590
+ html_file = self.gen_html_report(
591
+ subject=subject,
592
+ output_dir=str(output_dir),
593
+ img_out=str(subject_output_dir),
594
+ template=template,
595
+ )
596
+
597
+ results[subject] = html_file
598
+
599
+ self.logger.info(f" ✓ Generated report with images: {html_file}")
600
+
601
+ except Exception as e:
602
+ error_msg = f"Failed to generate report with images for {subject}: {e!s}"
603
+ results[subject] = e
604
+
605
+ self.logger.error(f" ✗ {error_msg}") # noqa: TRY400
606
+
607
+ if not skip_failed:
608
+ raise e # noqa: TRY201 # pylint: disable=try-except-raise
609
+
610
+ successful = sum(1 for result in results.values() if isinstance(result, Path))
611
+ failed = len(results) - successful
612
+ self.logger.info("\nBatch report generation with images completed:")
613
+ self.logger.info(f" Successful: {successful}")
614
+ self.logger.info(f" Failed: {failed}")
615
+
616
+ return results
pyfsviz/py.typed ADDED
File without changes
pyfsviz/reports.py ADDED
@@ -0,0 +1,48 @@
1
+ # Copyright 2025 Lezlie España <www.github.com/l-espana>
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ """Utility class for generating a config file from a jinja template.
17
+
18
+ https://github.com/oesteban/endofday/blob/f2e79c625d648ef45b08cc1f11fd0bd84342d604/endofday/core/template.py.
19
+
20
+ Along with other report-related functions.
21
+
22
+ """
23
+
24
+ import jinja2
25
+
26
+
27
+ class Template:
28
+ """Simplified jinja2 template class from oesteban."""
29
+
30
+ def __init__(self, template_str: str) -> None:
31
+ self._template_str = template_str
32
+ self._env = jinja2.Environment(
33
+ loader=jinja2.FileSystemLoader(searchpath="/"),
34
+ trim_blocks=True,
35
+ lstrip_blocks=True,
36
+ autoescape=True,
37
+ )
38
+
39
+ def compile(self, configs: dict) -> str:
40
+ """Generate a string with the replacements."""
41
+ template = self._env.get_template(self._template_str)
42
+ return template.render(configs)
43
+
44
+ def generate_conf(self, configs: dict, path: str) -> None:
45
+ """Save the outcome after replacement on the template to file."""
46
+ output = self.compile(configs)
47
+ with open(path, "w+", encoding="utf-8") as output_file:
48
+ output_file.write(output)
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyfsviz
3
+ Version: 0.1.1
4
+ Summary: Python tools for FreeSurfer visualization and QA
5
+ Author-Email: Lezlie Espana <lespana@mcw.edu>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Documentation
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Topic :: Utilities
21
+ Classifier: Typing :: Typed
22
+ Project-URL: Homepage, https://mcw-meier-lab.github.io/pyFSViz
23
+ Project-URL: Documentation, https://mcw-meier-lab.github.io/pyFSViz
24
+ Project-URL: Changelog, https://mcw-meier-lab.github.io/pyFSViz/changelog
25
+ Project-URL: Repository, https://github.com/mcw-meier-lab/pyFSViz
26
+ Project-URL: Issues, https://github.com/mcw-meier-lab/pyFSViz/issues
27
+ Project-URL: Discussions, https://github.com/mcw-meier-lab/pyFSViz/discussions
28
+ Requires-Python: >=3.10
29
+ Requires-Dist: importlib-resources>=6.5.2
30
+ Requires-Dist: matplotlib>=3.9.4
31
+ Requires-Dist: nilearn>=0.12.1
32
+ Requires-Dist: nipype>=1.10.0
33
+ Requires-Dist: nireports>=25.0.1
34
+ Requires-Dist: numpy>=2.0.2
35
+ Requires-Dist: pandas>=2.3.3
36
+ Requires-Dist: plotly>=6.3.1
37
+ Description-Content-Type: text/markdown
38
+
39
+ # pyFSViz
40
+
41
+ [![ci](https://github.com/mcw-meier-lab/pyFSViz/workflows/ci/badge.svg)](https://github.com/mcw-meier-lab/pyFSViz/actions?query=workflow%3Aci)
42
+ [![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://mcw-meier-lab.github.io/pyFSViz/)
43
+ [![pypi version](https://img.shields.io/pypi/v/pyfsviz.svg)](https://pypi.org/project/pyfsviz/)
44
+
45
+ Python tools for FreeSurfer visualization and QA
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install pyfsviz
51
+ ```
52
+
53
+ With [`uv`](https://docs.astral.sh/uv/):
54
+
55
+ ```bash
56
+ uv tool install pyfsviz
57
+ ```
@@ -0,0 +1,16 @@
1
+ pyfsviz-0.1.1.dist-info/METADATA,sha256=pqEjcd4Wso3eiKAW9IOayReb83p_bXaGWS25WVCPai4,2119
2
+ pyfsviz-0.1.1.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
3
+ pyfsviz-0.1.1.dist-info/entry_points.txt,sha256=cre399MbfrIUVQUwxNKa1ur6ne0s-CkD6_tRL80fKgE,71
4
+ pyfsviz-0.1.1.dist-info/licenses/LICENSE,sha256=Ey0-DGBtdr06UFzAOHz4YEyp8SSCHHkanQy5T2b7Vlc,1070
5
+ pyfsviz/__init__.py,sha256=w7v2Fen3VPhL2hKJ2Mdvhgybcn1REPnHkNL9SJCEZng,482
6
+ pyfsviz/__main__.py,sha256=h_atneBM9iWXrAHRSGS20eaPSCKpWP50_2MBMnXO6wo,349
7
+ pyfsviz/_internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pyfsviz/_internal/cli.py,sha256=Hs-pypt6Mgg2373TLqCrCZH-6Eh3xxDGqWxtJ0hM8_g,1699
9
+ pyfsviz/_internal/debug.py,sha256=FaBMWd86h58Rl05q4wwGkt7XSnZJYIAeUdJt3z4OByQ,2882
10
+ pyfsviz/_internal/html/individual.html,sha256=-QJkMm-0pV_cEUHy3ziTzMohe1Kq-fI9NHfp4bv7QAw,3567
11
+ pyfsviz/_internal/mni305.cor.mgz,sha256=U1i2rb8Y1cQrJysZvc3aGo8osdXOS3GdU56G810XN-k,3285041
12
+ pyfsviz/_internal/mni305.cor.nii.gz,sha256=M9Li-7fXl0RoZyF3Re8HQnsNLrz-hgWC8-4YfHma6Yo,3295839
13
+ pyfsviz/freesurfer.py,sha256=hhGgeEMKJUSJersRKBZPmmbcwbMd7mzXgp9aUmTXQLc,21116
14
+ pyfsviz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ pyfsviz/reports.py,sha256=uJh0mjNCtORWZDrMYarqCgM1Eva-iYhjd2yGPXhKEvs,1703
16
+ pyfsviz-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.5)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ pyfsviz = pyfsviz._internal.cli:main
3
+
4
+ [gui_scripts]
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 mcw-meier-lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.