fmri-qctoolkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ from .data.afni_pipeline import AFNIPipeline
2
+ from .data.fmriprep_pipeline import FMRIPrepPipeline
3
+ from .dashboard.afni_app import AFNIDashboard
4
+ from .dashboard.fmriprep_app import FMRIPrepDashboard
5
+
6
+ __all__ = [
7
+ 'AFNIPipeline',
8
+ 'FMRIPrepPipeline',
9
+ 'AFNIDashboard',
10
+ 'FMRIPrepDashboard'
11
+ ]
fMRI_QCtoolkit/cli.py ADDED
@@ -0,0 +1,240 @@
1
+ import os
2
+ import typer
3
+ from pathlib import Path
4
+ import subprocess
5
+
6
+ from .data.fmriprep_pipeline import FMRIPrepPipeline
7
+ from .data.afni_pipeline import AFNIPipeline
8
+ from .dashboard.fmriprep_app import FMRIPrepDashboard
9
+ from .dashboard.afni_app import AFNIDashboard
10
+ from .dashboard.fmriprep_rating_app import FMRIPrepRatingApp
11
+ from .utils.access import is_loopback
12
+ from .utils.port_utils import find_free_port
13
+ from .utils.subject_utils import parse_subjects_input,validate_fmriprep_subjects,validate_afni_subjects
14
+
15
+ app = typer.Typer(help="MRI Quality Control Dashboard")
16
+
17
+ prep_app = typer.Typer(help="Data preparation")
18
+ dashboard_app = typer.Typer(help="Dashboard visualization")
19
+ rating_app = typer.Typer(help="Quality rating interface")
20
+
21
+ app.add_typer(prep_app, name="prep")
22
+ app.add_typer(dashboard_app, name="dashboard")
23
+ app.add_typer(rating_app, name="rating")
24
+
25
+ # -------------------
26
+ # Helper functions
27
+ # -------------------
28
+ def validate_dir_exists(path: Path, name: str):
29
+ if not path.is_dir():
30
+ typer.echo(f"{name} not found or not a directory: {path}", err=True)
31
+ raise typer.Exit(code=1)
32
+
33
+ def validate_file_exists(path: Path, name: str):
34
+ if not path.is_file():
35
+ typer.echo(f"{name} not found or not a file: {path}", err=True)
36
+ raise typer.Exit(code=1)
37
+
38
+ def check_bind_host(host: str, debug: bool):
39
+ if is_loopback(host):
40
+ return
41
+ if debug:
42
+ typer.echo(f"Refusing --debug on non-loopback host {host}: debug mode must not be reachable from the network.", err=True)
43
+ raise typer.Exit(code=1)
44
+ typer.echo(f"Warning: host {host} is reachable from other machines; only the session token protects it.", err=True)
45
+
46
+ def ensure_output_dir(path: Path):
47
+ try:
48
+ path.mkdir(parents=True, exist_ok=True)
49
+ except Exception as e:
50
+ typer.echo(f"Cannot create output directory {path}: {e}", err=True)
51
+ raise typer.Exit(code=1)
52
+
53
+ def launch_dashboard(pipeline_cls, dashboard_cls, data_file: Path, lollipop_file: Path,
54
+ task: str, host: str, debug: bool):
55
+ validate_file_exists(data_file, "Data file")
56
+ validate_file_exists(lollipop_file, "Lollipop file")
57
+ check_bind_host(host, debug)
58
+
59
+ # Auto-detect available port
60
+ try:
61
+ port = find_free_port(start=5000, end=5500, delay=0.2, host=host)
62
+ typer.echo(f"Found available port: {port}")
63
+ except RuntimeError as e:
64
+ typer.echo(f"Port allocation failed: {e}", err=True)
65
+ raise typer.Exit(code=1)
66
+
67
+ pipeline = pipeline_cls.from_saved_data(
68
+ data_file=data_file,
69
+ lollipop_file=lollipop_file,
70
+ task=task,
71
+ )
72
+ dashboard = dashboard_cls(pipeline, task=task)
73
+ dashboard.run(host=host, port=port, debug=debug)
74
+
75
+ # ----------------------------
76
+ # Preparation Commands
77
+ # ----------------------------
78
+ @prep_app.command("afni")
79
+ def prep_afni(
80
+ input_dir: Path = typer.Option(..., help="Input directory"),
81
+ output_dir: Path = typer.Option(..., help="Output directory"),
82
+ task: str = typer.Option(..., help="Task name. The program will automatically detect paths containing this field within the input directory."),
83
+ prefix: str = typer.Option(..., help="Subject prefix, eg: sub-90=sub-(prefix)+90(ID)")
84
+ ):
85
+ validate_dir_exists(input_dir, "Input directory")
86
+ ensure_output_dir(output_dir)
87
+
88
+ typer.echo("Running AFNI data preparation...")
89
+ pipeline = AFNIPipeline(
90
+ input_dir=input_dir,
91
+ task=task,
92
+ prefix=prefix,
93
+ output_dir=output_dir
94
+ )
95
+ try:
96
+ pipeline.process()
97
+ except (FileNotFoundError, ValueError) as e:
98
+ typer.echo(f"Error: {e}", err=True)
99
+ raise typer.Exit(code=1)
100
+
101
+ typer.echo("Data preparation complete!")
102
+ typer.echo(f"Output files saved to: {output_dir}")
103
+ typer.echo(f"To launch dashboard:\n fmriqc dashboard afni --data-file {output_dir}/df_final.csv --lollipop-file {output_dir}/lollipop_chart_data.csv --task {task}")
104
+
105
+
106
+ @prep_app.command("fmriprep")
107
+ def prep_fmriprep(
108
+ bold_file: Path = typer.Option(..., help="Path to group_bold.tsv"),
109
+ rating_dir: Path = typer.Option(..., help="Directory with rating CSVs"),
110
+ task: str = typer.Option(..., help="Task name, will be used to filter group.tsv"),
111
+ output_dir: Path = typer.Option(..., help="Output directory")
112
+ ):
113
+ validate_file_exists(bold_file, "BOLD file")
114
+ validate_dir_exists(rating_dir, "Rating directory")
115
+ ensure_output_dir(output_dir)
116
+
117
+ typer.echo("Running fMRIPrep data preparation...")
118
+ pipeline = FMRIPrepPipeline(
119
+ bold_file=bold_file,
120
+ rating_dir=rating_dir,
121
+ task=task,
122
+ output_dir=output_dir,
123
+ )
124
+ pipeline.process()
125
+
126
+ typer.echo("Data preparation complete!")
127
+ typer.echo(f"Output files saved to: {output_dir}")
128
+ typer.echo(f"To launch dashboard:\n fmriqc dashboard fmriprep --data-file {output_dir}/df_final.csv --lollipop-file {output_dir}/lollipop_chart_data.csv --task {task}")
129
+
130
+ # ----------------------------
131
+ # Dashboard Commands
132
+ # ----------------------------
133
+ @dashboard_app.command("afni")
134
+ def dashboard_afni(
135
+ data_file: Path = typer.Option(..., help="Pre-processed data file"),
136
+ lollipop_file: Path = typer.Option(..., help="Lollipop chart file"),
137
+ task: str = typer.Option(..., help="Task name"),
138
+ host: str = typer.Option("127.0.0.1", help="Host address"),
139
+ debug: bool = typer.Option(False, help="Run in debug mode")
140
+ ):
141
+ launch_dashboard(AFNIPipeline, AFNIDashboard, data_file, lollipop_file, task, host, debug)
142
+
143
+
144
+ @dashboard_app.command("fmriprep")
145
+ def dashboard_fmriprep(
146
+ data_file: Path = typer.Option(..., help="Pre-processed data file"),
147
+ lollipop_file: Path = typer.Option(..., help="Lollipop chart file"),
148
+ task: str = typer.Option(..., help="Task name"),
149
+ host: str = typer.Option("127.0.0.1", help="Host address"),
150
+ debug: bool = typer.Option(False, help="Run in debug mode")
151
+ ):
152
+ launch_dashboard(FMRIPrepPipeline, FMRIPrepDashboard, data_file, lollipop_file, task, host, debug)
153
+
154
+ # ----------------------------
155
+ # Rating Commands
156
+ # ----------------------------
157
+ @rating_app.command("fmriprep")
158
+ def rating_fmriprep(
159
+ input_dir: Path = typer.Option(..., help="Directory containing fMRIPrep HTML reports"),
160
+ output_dir: Path = typer.Option(..., help="Directory to save rating CSV files"),
161
+ subjects: str = typer.Option(..., help="Subjects to rate (comma/space separated or path to text file)"),
162
+ no_browser: bool = typer.Option(False, help="Only print the links; do not open browser tabs"),
163
+ ):
164
+ validate_dir_exists(input_dir, "Input directory")
165
+ ensure_output_dir(output_dir)
166
+
167
+ subject_list = parse_subjects_input(subjects)
168
+ if not subject_list:
169
+ typer.echo("No subjects found to rate", err=True)
170
+ raise typer.Exit(code=1)
171
+
172
+ found_files, missing_subjects = validate_fmriprep_subjects(input_dir, subject_list)
173
+
174
+ if missing_subjects:
175
+ typer.echo(f"Warning: HTML files not found for subjects: {', '.join(map(str, missing_subjects))}")
176
+ if not found_files:
177
+ typer.echo("No HTML files found for any subjects", err=True)
178
+ raise typer.Exit(code=1)
179
+
180
+ subject_list = [s for s in subject_list if s not in missing_subjects]
181
+ typer.echo(f"Proceeding with {len(subject_list)} subjects: {', '.join(subject_list[:5])}{'...' if len(subject_list) > 5 else ''}")
182
+
183
+ typer.echo("Starting fMRIPrep rating interface...")
184
+ typer.echo("Rate each subject's quality control report")
185
+ typer.echo("Ratings will be saved automatically as JSON and CSV files")
186
+ typer.echo("If the program cannot detect the session and run number, both will default to 1.")
187
+ typer.echo("A link is printed for each subject" if no_browser else "Browser tabs will open for each subject")
188
+
189
+ rating_app_instance = FMRIPrepRatingApp(
190
+ data_dir=input_dir,
191
+ output_dir=output_dir,
192
+ subjects=subject_list
193
+ )
194
+
195
+ try:
196
+ rating_app_instance.run_app_and_open_browsers(open_browser=not no_browser)
197
+ except KeyboardInterrupt:
198
+ typer.echo("\n Rating session ended")
199
+ except Exception as e:
200
+ typer.echo(f"Error running rating app: {e}", err=True)
201
+ raise typer.Exit(code=1)
202
+
203
+
204
+ @rating_app.command("afni")
205
+ def rating_afni(
206
+ input_dir: Path = typer.Option(..., help="AFNI derivatives' root directory"),
207
+ subjects: str = typer.Option(..., help="Subjects to rate (comma/space separated or path to text file)"),
208
+ task: str = typer.Option(..., help="Task name. The program will automatically detect paths containing this field within the input directory."),
209
+ prefix: str = typer.Option(..., help="Prefix for AFNI subjects, e.g., 'sub-'"),
210
+ afni_path: str = typer.Option("", help="Path to AFNI binaries (added to $PATH if given; otherwise open_apqc.py must already be on $PATH)"),
211
+ ):
212
+ validate_dir_exists(input_dir, "Input directory")
213
+
214
+ subject_list = parse_subjects_input(subjects)
215
+ if not subject_list:
216
+ typer.echo("No subjects found to rate", err=True)
217
+ raise typer.Exit(code=1)
218
+
219
+ html_files, missing_subjects = validate_afni_subjects(input_dir, subject_list, prefix=prefix, task=task)
220
+
221
+ if missing_subjects:
222
+ typer.echo(f"Warning: HTML files not found for subjects: {', '.join(map(str, missing_subjects))}")
223
+ if not html_files:
224
+ typer.echo("No AFNI QC HTML files found", err=True)
225
+ raise typer.Exit(code=1)
226
+
227
+ typer.echo(f"Found {len(html_files)} AFNI QC files")
228
+
229
+ env = os.environ.copy()
230
+ if afni_path:
231
+ env["PATH"] = afni_path + os.pathsep + env.get("PATH", "")
232
+ try:
233
+ subprocess.run(["open_apqc.py", "-infiles", *map(str, html_files)], env=env, check=False)
234
+ except FileNotFoundError:
235
+ typer.echo("open_apqc.py not found; add AFNI to $PATH or pass --afni-path", err=True)
236
+ raise typer.Exit(code=1)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ app()
@@ -0,0 +1,13 @@
1
+ """
2
+ Dashboard modules for MRI QC visualization
3
+ """
4
+
5
+ from .base_app import BaseDashboard
6
+ from .afni_app import AFNIDashboard
7
+ from .fmriprep_app import FMRIPrepDashboard
8
+
9
+ __all__ = [
10
+ 'BaseDashboard',
11
+ 'AFNIDashboard',
12
+ 'FMRIPrepDashboard'
13
+ ]
@@ -0,0 +1,28 @@
1
+ """
2
+ AFNI-specific dashboard implementation
3
+ """
4
+
5
+ from .base_app import BaseDashboard
6
+ from ..utils.status import assign_afni_status
7
+ from ..utils.filter_components_utils import generate_filter_components
8
+
9
+ class AFNIDashboard(BaseDashboard):
10
+ """Dashboard for AFNI QC data."""
11
+
12
+ def _get_config_filename(self):
13
+ """Return the config filename for AFNI."""
14
+ return 'afni_config.json'
15
+
16
+ def assign_status(self, value, variable_name):
17
+ """Assign status using AFNI-specific rules."""
18
+ return assign_afni_status(value, variable_name)
19
+
20
+ def get_variable_labels(self):
21
+ """Get AFNI-specific variable labels."""
22
+ return (self.config["variable_labels"]["quantitative"],
23
+ self.config["variable_labels"]["qualitative"])
24
+
25
+ def get_filter_components(self):
26
+ """Generate AFNI-specific filter components."""
27
+ df = self.processor.get_data()
28
+ return generate_filter_components(df, self.processor, self.config, include_fraction_sliders=True)
@@ -0,0 +1,108 @@
1
+ {
2
+ "variable_labels": {
3
+ "quantitative": {
4
+ "cens_frac": "cens fraction",
5
+ "cens_mot": "cens motion",
6
+ "cens_displace": "cens displace",
7
+ "DF_frac": "DF fraction",
8
+ "flip_guess": "flip guess",
9
+ "TSNR": "TSNR",
10
+ "GCOR": "GCOR"
11
+ },
12
+ "qualitative": {
13
+ "mot_rating": "motion",
14
+ "radcor_rating": "correlation",
15
+ "regr_rating": "regression",
16
+ "va2t_rating": "anat to template",
17
+ "ve2a_rating": "EPI to anat",
18
+ "vorig_rating": "vorig",
19
+ "vstat_rating": "vstat",
20
+ "warns_rating": "warnings",
21
+ "qsumm_rating": "quantitative",
22
+ "FINAL_rating": "final"
23
+ }
24
+ },
25
+ "quantitative_configs": [
26
+ {
27
+ "variable": "cens_frac",
28
+ "label": "Censor Fraction",
29
+ "min_value": 0,
30
+ "max_value_offset": null,
31
+ "default_range": [0, 0.15],
32
+ "step": 0.01
33
+ },
34
+ {
35
+ "variable": "cens_mot",
36
+ "label": "Average Censored Motion",
37
+ "min_value": 0,
38
+ "max_value_offset": null,
39
+ "default_range": [0, 0.1],
40
+ "step": 0.01
41
+ },
42
+ {
43
+ "variable": "cens_displace",
44
+ "label": "Max Censored Displacement",
45
+ "min_value": 0,
46
+ "max_value_offset": 20,
47
+ "default_range": [0, 6],
48
+ "step": 0.01
49
+ },
50
+ {
51
+ "variable": "TSNR",
52
+ "label": "TSNR Average",
53
+ "min_value": null,
54
+ "max_value_offset": null,
55
+ "default_range": null,
56
+ "step": 1
57
+ },
58
+ {
59
+ "variable": "DF_frac",
60
+ "label": "Final DF Fraction",
61
+ "min_value": 0,
62
+ "max_value_offset": null,
63
+ "default_range": [0.7, 1],
64
+ "step": 0.1
65
+ },
66
+ {
67
+ "variable": "flip_guess",
68
+ "label": "Flip Guess",
69
+ "min_value": 0,
70
+ "max_value_offset": null,
71
+ "default_range": [0, 1],
72
+ "step": 1
73
+ },
74
+ {
75
+ "variable": "GCOR",
76
+ "label": "GCOR",
77
+ "min_value": 0,
78
+ "max_value_offset": null,
79
+ "default_range": [0, 0.15],
80
+ "step": 0.01
81
+ }
82
+ ],
83
+ "frac_trs_defaults": {
84
+ "range": [0, 0.2],
85
+ "step": 0.01,
86
+ "marks": {
87
+ "0": "0",
88
+ "0.2": "0.2",
89
+ "1": "1"
90
+ }
91
+ },
92
+ "qualitative_options": [
93
+ {"label": "Good", "value": "good"},
94
+ {"label": "Other", "value": "other"},
95
+ {"label": "Bad", "value": "bad"},
96
+ {"label": "NA", "value": "NA"}
97
+ ],
98
+ "qualitative_defaults": ["good", "other", "bad", "NA"],
99
+ "heatmap_quantitative_vars": ["cens_frac", "cens_mot", "cens_displace", "TSNR", "DF_frac", "flip_guess", "GCOR"],
100
+ "_comments": {
101
+ "quantitative_configs": "min_value: null means data_min - 0.1 or 1, max_value_offset: null means data_max + 0.1 or 1, default_range: null means [min, max]",
102
+ "variable_labels": "Display names for variables in the dashboard",
103
+ "frac_trs_defaults": "Default settings for fraction TRs censored sliders",
104
+ "qualitative_options": "Options available in qualitative variable dropdowns",
105
+ "qualitative_defaults": "Default selected values for qualitative dropdowns",
106
+ "heatmap_quantitative_vars": "Variables to include in quantitative heatmap (in display order)"
107
+ }
108
+ }