From a65c12c894f6e2026b02eb673ad0f60e54ed346f Mon Sep 17 00:00:00 2001 From: Martin Norgaard Date: Mon, 17 Nov 2025 10:39:30 +0100 Subject: [PATCH 01/40] ENH: Add session-label option for analyses --- petprep/cli/parser.py | 28 ++++++++++++++++++++++++++-- petprep/config.py | 2 ++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/petprep/cli/parser.py b/petprep/cli/parser.py index 50114f31..488b60df 100644 --- a/petprep/cli/parser.py +++ b/petprep/cli/parser.py @@ -188,8 +188,13 @@ def _bids_filter(value, parser): 'identifier (the sub- prefix can be removed)', ) # Re-enable when option is actually implemented - # g_bids.add_argument('-s', '--session-id', action='store', default='single_session', - # help='Select a specific session to be processed') + g_bids.add_argument( + '--session-label', + nargs='+', + type=lambda label: label.removeprefix('ses-'), + help='A space delimited list of session identifiers or a single ' + 'identifier (the ses- prefix can be removed)', + ) # Re-enable when option is actually implemented # g_bids.add_argument('-r', '--run-id', action='store', default='single_run', # help='Select a specific run to be processed') @@ -749,6 +754,14 @@ def parse_args(args=None, namespace=None): config.execution.log_level = int(max(25 - 5 * opts.verbose_count, logging.DEBUG)) config.from_dict(vars(opts), init=['nipype']) + if config.execution.session_label: + config.execution.bids_filters = config.execution.bids_filters or {} + for modality in ('pet', 'anat'): + config.execution.bids_filters[modality] = { + **config.execution.bids_filters.get(modality, {}), + 'session': config.execution.session_label, + } + pvc_vals = (opts.pvc_tool, opts.pvc_method, opts.pvc_psf) if any(val is not None for val in pvc_vals) and not all(val is not None for val in pvc_vals): parser.error('Options --pvc-tool, --pvc-method and --pvc-psf must be used together.') @@ -908,5 +921,16 @@ def parse_args(args=None, namespace=None): f'One or more participant labels were not found in the BIDS directory: {", ".join(missing_subjects)}.' ) + if config.execution.session_label: + available_sessions = set( + config.execution.layout.get_sessions(subject=list(participant_label) or None) + ) + missing_sessions = set(config.execution.session_label) - available_sessions + if missing_sessions: + parser.error( + 'One or more session labels were not found in the BIDS directory: ' + f"{', '.join(sorted(missing_sessions))}." + ) + config.execution.participant_label = sorted(participant_label) config.workflow.skull_strip_template = config.workflow.skull_strip_template[0] diff --git a/petprep/config.py b/petprep/config.py index c61319b1..2cfc100b 100644 --- a/petprep/config.py +++ b/petprep/config.py @@ -432,6 +432,8 @@ class execution(_Config): """Unique identifier of this particular run.""" participant_label = None """List of participant identifiers that are to be preprocessed.""" + session_label = None + """List of session identifiers that are to be preprocessed.""" task_id = None """Select a particular task from all available in the dataset.""" templateflow_home = _templateflow_home From 923f6e5c5a05e90e3c492a7393d2e51001231ead Mon Sep 17 00:00:00 2001 From: Martin Norgaard Date: Mon, 17 Nov 2025 10:52:51 +0100 Subject: [PATCH 02/40] FIX: update parser to only include PET --- petprep/cli/parser.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/petprep/cli/parser.py b/petprep/cli/parser.py index 488b60df..4598806c 100644 --- a/petprep/cli/parser.py +++ b/petprep/cli/parser.py @@ -756,11 +756,10 @@ def parse_args(args=None, namespace=None): if config.execution.session_label: config.execution.bids_filters = config.execution.bids_filters or {} - for modality in ('pet', 'anat'): - config.execution.bids_filters[modality] = { - **config.execution.bids_filters.get(modality, {}), - 'session': config.execution.session_label, - } + config.execution.bids_filters['pet'] = { + **config.execution.bids_filters.get('pet', {}), + 'session': config.execution.session_label, + } pvc_vals = (opts.pvc_tool, opts.pvc_method, opts.pvc_psf) if any(val is not None for val in pvc_vals) and not all(val is not None for val in pvc_vals): From bfb2a1b5e23df7df25f593fd37cc322f3a8b9c97 Mon Sep 17 00:00:00 2001 From: Martin Norgaard Date: Mon, 17 Nov 2025 10:53:11 +0100 Subject: [PATCH 03/40] FIX: add test for session-label option --- petprep/cli/tests/test_parser.py | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/petprep/cli/tests/test_parser.py b/petprep/cli/tests/test_parser.py index 78d47ebb..86c7bcf6 100644 --- a/petprep/cli/tests/test_parser.py +++ b/petprep/cli/tests/test_parser.py @@ -25,6 +25,8 @@ from argparse import ArgumentError import pytest +import nibabel as nb +import numpy as np from packaging.version import Version from ... import config @@ -225,6 +227,47 @@ def test_derivatives(tmp_path): _reset_config() +def test_session_label_only_filters_pet(tmp_path): + bids = tmp_path / 'bids' + out_dir = tmp_path / 'out' + work_dir = tmp_path / 'work' + bids.mkdir() + (bids / 'dataset_description.json').write_text( + '{"Name": "Test", "BIDSVersion": "1.8.0"}' + ) + + anat_path = bids / 'sub-01' / 'anat' / 'sub-01_T1w.nii.gz' + anat_path.parent.mkdir(parents=True, exist_ok=True) + nb.Nifti1Image(np.zeros((5, 5, 5)), np.eye(4)).to_filename(anat_path) + + pet_path = bids / 'sub-01' / 'ses-blocked' / 'pet' / 'sub-01_ses-blocked_pet.nii.gz' + pet_path.parent.mkdir(parents=True, exist_ok=True) + nb.Nifti1Image(np.zeros((5, 5, 5, 1)), np.eye(4)).to_filename(pet_path) + (pet_path.with_suffix('').with_suffix('.json')).write_text( + '{"FrameTimesStart": [0], "FrameDuration": [1]}' + ) + + try: + parse_args( + args=[ + str(bids), + str(out_dir), + 'participant', + '--session-label', + 'blocked', + '--skip-bids-validation', + '-w', + str(work_dir), + ] + ) + + filters = config.execution.bids_filters + assert filters.get('pet', {}).get('session') == ['blocked'] + assert 'session' not in filters.get('anat', {}) + finally: + _reset_config() + + def test_pvc_argument_handling(tmp_path, minimal_bids): out_dir = tmp_path / 'out' work_dir = tmp_path / 'work' From f447fd21a2809c10b4fa89532643151412137e6b Mon Sep 17 00:00:00 2001 From: Martin Norgaard Date: Mon, 17 Nov 2025 10:56:08 +0100 Subject: [PATCH 04/40] FIX: style --- petprep/cli/tests/test_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/petprep/cli/tests/test_parser.py b/petprep/cli/tests/test_parser.py index 86c7bcf6..9ece4116 100644 --- a/petprep/cli/tests/test_parser.py +++ b/petprep/cli/tests/test_parser.py @@ -24,9 +24,9 @@ from argparse import ArgumentError -import pytest import nibabel as nb import numpy as np +import pytest from packaging.version import Version from ... import config From db25bbd241dbb984917e5844803bd49ac5a6b3f6 Mon Sep 17 00:00:00 2001 From: Martin Norgaard Date: Mon, 17 Nov 2025 10:58:17 +0100 Subject: [PATCH 05/40] FIX: style --- petprep/cli/parser.py | 2 +- petprep/cli/tests/test_parser.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/petprep/cli/parser.py b/petprep/cli/parser.py index 4598806c..7c470800 100644 --- a/petprep/cli/parser.py +++ b/petprep/cli/parser.py @@ -928,7 +928,7 @@ def parse_args(args=None, namespace=None): if missing_sessions: parser.error( 'One or more session labels were not found in the BIDS directory: ' - f"{', '.join(sorted(missing_sessions))}." + f'{", ".join(sorted(missing_sessions))}.' ) config.execution.participant_label = sorted(participant_label) diff --git a/petprep/cli/tests/test_parser.py b/petprep/cli/tests/test_parser.py index 9ece4116..d259f6f8 100644 --- a/petprep/cli/tests/test_parser.py +++ b/petprep/cli/tests/test_parser.py @@ -232,9 +232,7 @@ def test_session_label_only_filters_pet(tmp_path): out_dir = tmp_path / 'out' work_dir = tmp_path / 'work' bids.mkdir() - (bids / 'dataset_description.json').write_text( - '{"Name": "Test", "BIDSVersion": "1.8.0"}' - ) + (bids / 'dataset_description.json').write_text('{"Name": "Test", "BIDSVersion": "1.8.0"}') anat_path = bids / 'sub-01' / 'anat' / 'sub-01_T1w.nii.gz' anat_path.parent.mkdir(parents=True, exist_ok=True) From cf628f48b4d422084aaad2026557d35ae225a879 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 11:39:50 +0100 Subject: [PATCH 06/40] ENH: Add dynamic visualization for motion correction --- petprep/data/reports-spec-pet.yml | 1 + petprep/data/reports-spec.yml | 5 + petprep/interfaces/__init__.py | 2 + petprep/interfaces/motion.py | 154 ++++++++++++++++++++++++++++++ petprep/workflows/pet/base.py | 24 ++++- 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 petprep/interfaces/motion.py diff --git a/petprep/data/reports-spec-pet.yml b/petprep/data/reports-spec-pet.yml index 1ec41495..580e9f40 100644 --- a/petprep/data/reports-spec-pet.yml +++ b/petprep/data/reports-spec-pet.yml @@ -6,6 +6,7 @@ sections: reportlets: - bids: {datatype: figures, desc: summary, suffix: pet} - bids: {datatype: figures, desc: validation, suffix: pet} + - bids: {datatype: figures, desc: hmc, suffix: pet} - bids: {datatype: figures, desc: carpetplot, suffix: pet} - bids: {datatype: figures, desc: confoundcorr, suffix: pet} - bids: {datatype: figures, desc: coreg, suffix: pet} diff --git a/petprep/data/reports-spec.yml b/petprep/data/reports-spec.yml index 8d57713d..8d52d05a 100644 --- a/petprep/data/reports-spec.yml +++ b/petprep/data/reports-spec.yml @@ -127,6 +127,11 @@ sections: static: false subtitle: PET Summary and Carpet Plot + - bids: {datatype: figures, desc: hmc, suffix: pet} + caption: Animated frames before and after PET head motion correction. + static: false + subtitle: Motion correction + - bids: {datatype: figures, desc: confoundcorr, suffix: pet} caption: | Left: Correlation heatmap illustrating relationships among PET-derived confound variables (e.g., motion parameters, global signal). diff --git a/petprep/interfaces/__init__.py b/petprep/interfaces/__init__.py index af99d939..56c9b9ea 100644 --- a/petprep/interfaces/__init__.py +++ b/petprep/interfaces/__init__.py @@ -3,6 +3,7 @@ from niworkflows.interfaces.bids import DerivativesDataSink as _DDSink from .cifti import GeneratePetCifti +from .motion import MotionPlot from .tacs import ExtractRefTAC, ExtractTACs @@ -15,4 +16,5 @@ class DerivativesDataSink(_DDSink): 'GeneratePetCifti', 'ExtractTACs', 'ExtractRefTAC', + 'MotionPlot', ) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py new file mode 100644 index 00000000..010f5926 --- /dev/null +++ b/petprep/interfaces/motion.py @@ -0,0 +1,154 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +"""Reportlets illustrating motion correction.""" + +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory + +import nibabel as nib +import numpy as np +from nipype.interfaces.base import BaseInterfaceInputSpec, File, SimpleInterface, TraitedSpec, traits +from nilearn import image +from nilearn.plotting import plot_epi +from nilearn.plotting.find_cuts import find_xyz_cut_coords +from imageio import v2 as imageio + + +class MotionPlotInputSpec(BaseInterfaceInputSpec): + original_pet = File( + exists=True, + mandatory=True, + desc='Original (uncorrected) PET series in native PET space', + ) + corrected_pet = File( + exists=True, + mandatory=True, + desc=( + 'Motion-corrected PET series derived by applying the estimated motion ' + 'transforms to the original data in native PET space' + ), + ) + duration = traits.Float(1.0, usedefault=True, desc='Frame duration for the GIF (seconds)') + + +class MotionPlotOutputSpec(TraitedSpec): + gif_file = File(exists=True, desc='Animated before/after motion correction GIF') + + +class MotionPlot(SimpleInterface): + """Generate animated visualizations before and after motion correction. + + A single GIF is created using ortho views with consistent cut coordinates + and color scaling derived from the midpoint frame of each series. The + per-frame views of the original and motion-corrected series are concatenated + horizontally, allowing the main PET report to display the animation + directly. + """ + + input_spec = MotionPlotInputSpec + output_spec = MotionPlotOutputSpec + + def _run_interface(self, runtime): + runtime.cwd = Path(runtime.cwd) + + gif_file = runtime.cwd / 'pet_motion_hmc.gif' + gif_file.parent.mkdir(parents=True, exist_ok=True) + + mid_orig, cut_coords_orig, vmin_orig, vmax_orig = self._compute_display_params( + self.inputs.original_pet + ) + _, _, vmin_corr, vmax_corr = self._compute_display_params(self.inputs.corrected_pet) + + gif_file = self._build_animation( + output_path=gif_file, + cut_coords_orig=cut_coords_orig, + cut_coords_corr=cut_coords_orig, + vmin_orig=vmin_orig, + vmax_orig=vmax_orig, + vmin_corr=vmin_corr, + vmax_corr=vmax_corr, + ) + + self._results['gif_file'] = str(gif_file) + + return runtime + + def _compute_display_params(self, in_file: str): + img = nib.load(in_file) + if img.ndim == 3: + mid_img = img + else: + mid_img = image.index_img(in_file, img.shape[-1] // 2) + + data = mid_img.get_fdata().astype(float) + vmax = float(np.percentile(data.flatten(), 99.9)) + vmin = float(np.percentile(data.flatten(), 80)) + cut_coords = find_xyz_cut_coords(mid_img) + + return mid_img, cut_coords, vmin, vmax + + def _build_animation( + self, + *, + output_path: Path, + cut_coords_orig: tuple[float, float, float], + cut_coords_corr: tuple[float, float, float], + vmin_orig: float, + vmax_orig: float, + vmin_corr: float, + vmax_corr: float, + ) -> Path: + orig_img = nib.load(self.inputs.original_pet) + corr_img = nib.load(self.inputs.corrected_pet) + + n_frames = min(orig_img.shape[-1], corr_img.shape[-1]) + + with TemporaryDirectory() as tmpdir: + frames = [] + for idx in range(n_frames): + orig_png = Path(tmpdir) / f'orig_{idx:04d}.png' + corr_png = Path(tmpdir) / f'corr_{idx:04d}.png' + + plot_epi( + image.index_img(self.inputs.original_pet, idx), + colorbar=True, + display_mode='ortho', + title=f'Before motion correction | Frame {idx}', + cut_coords=cut_coords_orig, + vmin=vmin_orig, + vmax=vmax_orig, + output_file=str(orig_png), + ) + plot_epi( + image.index_img(self.inputs.corrected_pet, idx), + colorbar=True, + display_mode='ortho', + title=f'After motion correction | Frame {idx}', + cut_coords=cut_coords_corr, + vmin=vmin_corr, + vmax=vmax_corr, + output_file=str(corr_png), + ) + + orig_arr = np.asarray(imageio.imread(orig_png)) + corr_arr = np.asarray(imageio.imread(corr_png)) + + max_height = max(orig_arr.shape[0], corr_arr.shape[0]) + if orig_arr.shape[0] < max_height: + pad = max_height - orig_arr.shape[0] + orig_arr = np.pad(orig_arr, ((0, pad), (0, 0), (0, 0)), mode='constant', constant_values=255) + if corr_arr.shape[0] < max_height: + pad = max_height - corr_arr.shape[0] + corr_arr = np.pad(corr_arr, ((0, pad), (0, 0), (0, 0)), mode='constant', constant_values=255) + + combined = np.concatenate([orig_arr, corr_arr], axis=1) + frames.append(combined.astype(orig_arr.dtype, copy=False)) + + imageio.mimsave(output_path, frames, duration=self.inputs.duration, loop=0) + + return output_path + + +__all__ = ['MotionPlot'] diff --git a/petprep/workflows/pet/base.py b/petprep/workflows/pet/base.py index 7d58a9eb..95d225b7 100644 --- a/petprep/workflows/pet/base.py +++ b/petprep/workflows/pet/base.py @@ -39,7 +39,7 @@ from niworkflows.utils.connections import listify from ... import config -from ...interfaces import DerivativesDataSink +from ...interfaces import DerivativesDataSink, MotionPlot from ...utils.misc import estimate_pet_mem_usage # PET workflows @@ -275,6 +275,28 @@ def init_pet_wf( ]), ]) # fmt:skip + motion_report = pe.Node(MotionPlot(), name='motion_report', mem_gb=0.1) + ds_motion_report = pe.Node( + DerivativesDataSink( + base_directory=petprep_dir, + desc='hmc', + datatype='figures', + suffix='pet', + ), + name='ds_report_motion', + run_without_submitting=True, + mem_gb=config.DEFAULT_MEMORY_MIN_GB, + ) + ds_motion_report.inputs.source_file = pet_file + + workflow.connect([ + (pet_native_wf, motion_report, [ + ('outputnode.pet_minimal', 'original_pet'), + ('outputnode.pet_native', 'corrected_pet'), + ]), + (motion_report, ds_motion_report, [('gif_file', 'in_file')]), + ]) + petref_out = bool(nonstd_spaces.intersection(('pet', 'run', 'petref'))) petref_out &= config.workflow.level == 'full' From e03d224a8722f6f0c906554aa9de568db41de58d Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 11:56:35 +0100 Subject: [PATCH 07/40] FIX: update to SVG output --- petprep/interfaces/motion.py | 51 +++++++++++++++++++++++++++++------ petprep/workflows/pet/base.py | 2 +- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 010f5926..6603a307 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -4,6 +4,8 @@ from __future__ import annotations +from base64 import b64encode +from io import BytesIO from pathlib import Path from tempfile import TemporaryDirectory @@ -34,7 +36,7 @@ class MotionPlotInputSpec(BaseInterfaceInputSpec): class MotionPlotOutputSpec(TraitedSpec): - gif_file = File(exists=True, desc='Animated before/after motion correction GIF') + svg_file = File(exists=True, desc='Animated before/after motion correction SVG') class MotionPlot(SimpleInterface): @@ -53,16 +55,16 @@ class MotionPlot(SimpleInterface): def _run_interface(self, runtime): runtime.cwd = Path(runtime.cwd) - gif_file = runtime.cwd / 'pet_motion_hmc.gif' - gif_file.parent.mkdir(parents=True, exist_ok=True) + svg_file = runtime.cwd / 'pet_motion_hmc.svg' + svg_file.parent.mkdir(parents=True, exist_ok=True) mid_orig, cut_coords_orig, vmin_orig, vmax_orig = self._compute_display_params( self.inputs.original_pet ) _, _, vmin_corr, vmax_corr = self._compute_display_params(self.inputs.corrected_pet) - gif_file = self._build_animation( - output_path=gif_file, + svg_file = self._build_animation( + output_path=svg_file, cut_coords_orig=cut_coords_orig, cut_coords_corr=cut_coords_orig, vmin_orig=vmin_orig, @@ -71,7 +73,7 @@ def _run_interface(self, runtime): vmax_corr=vmax_corr, ) - self._results['gif_file'] = str(gif_file) + self._results['svg_file'] = str(svg_file) return runtime @@ -103,7 +105,10 @@ def _build_animation( orig_img = nib.load(self.inputs.original_pet) corr_img = nib.load(self.inputs.corrected_pet) - n_frames = min(orig_img.shape[-1], corr_img.shape[-1]) + n_frames = min( + orig_img.shape[-1] if orig_img.ndim > 3 else 1, + corr_img.shape[-1] if corr_img.ndim > 3 else 1, + ) with TemporaryDirectory() as tmpdir: frames = [] @@ -146,7 +151,37 @@ def _build_animation( combined = np.concatenate([orig_arr, corr_arr], axis=1) frames.append(combined.astype(orig_arr.dtype, copy=False)) - imageio.mimsave(output_path, frames, duration=self.inputs.duration, loop=0) + width = int(frames[0].shape[1]) + height = int(frames[0].shape[0]) + total_duration = self.inputs.duration * n_frames + + svg_parts = [ + '', + '') + + for idx, frame in enumerate(frames): + buffer = BytesIO() + imageio.imwrite(buffer, frame, format='PNG') + data_uri = b64encode(buffer.getvalue()).decode('ascii') + svg_parts.append( + f'' + ) + + svg_parts.append('') + + output_path.write_text('\n'.join(svg_parts), encoding='utf-8') return output_path diff --git a/petprep/workflows/pet/base.py b/petprep/workflows/pet/base.py index 95d225b7..9280f0e9 100644 --- a/petprep/workflows/pet/base.py +++ b/petprep/workflows/pet/base.py @@ -294,7 +294,7 @@ def init_pet_wf( ('outputnode.pet_minimal', 'original_pet'), ('outputnode.pet_native', 'corrected_pet'), ]), - (motion_report, ds_motion_report, [('gif_file', 'in_file')]), + (motion_report, ds_motion_report, [('svg_file', 'in_file')]), ]) petref_out = bool(nonstd_spaces.intersection(('pet', 'run', 'petref'))) From e00cd10312fcc28ea872ef634626bc9130df9db5 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 12:53:05 +0100 Subject: [PATCH 08/40] FIX: update display time and looping --- petprep/interfaces/motion.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 6603a307..dffee2c8 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -32,7 +32,11 @@ class MotionPlotInputSpec(BaseInterfaceInputSpec): 'transforms to the original data in native PET space' ), ) - duration = traits.Float(1.0, usedefault=True, desc='Frame duration for the GIF (seconds)') + duration = traits.Float( + 0.5, + usedefault=True, + desc='Frame duration for the animation (seconds); smaller is faster', + ) class MotionPlotOutputSpec(TraitedSpec): @@ -155,16 +159,27 @@ def _build_animation( height = int(frames[0].shape[0]) total_duration = self.inputs.duration * n_frames + frame_pct = 100 / max(n_frames, 1) + visible_pct = max(frame_pct * 0.9, 1.0) + svg_parts = [ '', '') From fb0b6cf6bcdfcdadbde021639a142bdeffc98bf4 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:03:44 +0100 Subject: [PATCH 09/40] FIX: update duration for motion display --- petprep/interfaces/motion.py | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index dffee2c8..4b91c6eb 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -32,11 +32,7 @@ class MotionPlotInputSpec(BaseInterfaceInputSpec): 'transforms to the original data in native PET space' ), ) - duration = traits.Float( - 0.5, - usedefault=True, - desc='Frame duration for the animation (seconds); smaller is faster', - ) + duration = traits.Float(0.4, usedefault=True, desc='Frame duration for the GIF (seconds)') class MotionPlotOutputSpec(TraitedSpec): @@ -159,27 +155,16 @@ def _build_animation( height = int(frames[0].shape[0]) total_duration = self.inputs.duration * n_frames - frame_pct = 100 / max(n_frames, 1) - visible_pct = max(frame_pct * 0.9, 1.0) - svg_parts = [ '', '') From 7d67e884e11869c5fe9e15b8b1b28494e1a3180c Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:11:45 +0100 Subject: [PATCH 10/40] FIX: update duration to 0.25 s --- petprep/interfaces/motion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 4b91c6eb..c4716d84 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -32,7 +32,7 @@ class MotionPlotInputSpec(BaseInterfaceInputSpec): 'transforms to the original data in native PET space' ), ) - duration = traits.Float(0.4, usedefault=True, desc='Frame duration for the GIF (seconds)') + duration = traits.Float(0.25, usedefault=True, desc='Frame duration for the GIF (seconds)') class MotionPlotOutputSpec(TraitedSpec): From 250aacce0c9948212cd85753ef908fb94494493c Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:19:44 +0100 Subject: [PATCH 11/40] FIX: set duration to 0.2 seconds --- petprep/interfaces/motion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index c4716d84..3dcb11b9 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -32,7 +32,7 @@ class MotionPlotInputSpec(BaseInterfaceInputSpec): 'transforms to the original data in native PET space' ), ) - duration = traits.Float(0.25, usedefault=True, desc='Frame duration for the GIF (seconds)') + duration = traits.Float(0.2, usedefault=True, desc='Frame duration for the GIF (seconds)') class MotionPlotOutputSpec(TraitedSpec): From 8bde720cb4306d836cf7ce4cd7c0c0e537ed3619 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:24:30 +0100 Subject: [PATCH 12/40] FIX: update display of frames --- petprep/interfaces/motion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 3dcb11b9..70b5c65c 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -120,7 +120,7 @@ def _build_animation( image.index_img(self.inputs.original_pet, idx), colorbar=True, display_mode='ortho', - title=f'Before motion correction | Frame {idx}', + title=f'Before motion correction | Frame {idx+1}', cut_coords=cut_coords_orig, vmin=vmin_orig, vmax=vmax_orig, @@ -130,7 +130,7 @@ def _build_animation( image.index_img(self.inputs.corrected_pet, idx), colorbar=True, display_mode='ortho', - title=f'After motion correction | Frame {idx}', + title=f'After motion correction | Frame {idx+1}', cut_coords=cut_coords_corr, vmin=vmin_corr, vmax=vmax_corr, From a8dcf4d4cc95894537072e8866d4bb8df545eeff Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:26:42 +0100 Subject: [PATCH 13/40] FIX: add click to restart animation --- petprep/interfaces/motion.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 70b5c65c..3f030a35 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -179,7 +179,26 @@ def _build_animation( f'href="data:image/png;base64,{data_uri}" />' ) - svg_parts.append('') + svg_parts.extend( + [ + '', + '', + ] + ) output_path.write_text('\n'.join(svg_parts), encoding='utf-8') From eb3a99a96e5e7ac54f0987f88876e8a6caba1f6d Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:26:51 +0100 Subject: [PATCH 14/40] FIX: update description --- petprep/data/reports-spec.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/petprep/data/reports-spec.yml b/petprep/data/reports-spec.yml index 8d52d05a..99db5e6d 100644 --- a/petprep/data/reports-spec.yml +++ b/petprep/data/reports-spec.yml @@ -128,7 +128,7 @@ sections: subtitle: PET Summary and Carpet Plot - bids: {datatype: figures, desc: hmc, suffix: pet} - caption: Animated frames before and after PET head motion correction. + caption: Animated frames before and after PET head motion correction (click image to restart). static: false subtitle: Motion correction From afaa4745536b17beb596b88eead79a84ba23e154 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:43:15 +0100 Subject: [PATCH 15/40] FIX: modify playback --- petprep/interfaces/motion.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 3f030a35..076720dc 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -159,7 +159,13 @@ def _build_animation( '', '', From 788c8ba2ab78c10bd7816ba5b19a61b0fb7fb9f3 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:51:24 +0100 Subject: [PATCH 16/40] FIX: update description --- petprep/data/reports-spec.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/petprep/data/reports-spec.yml b/petprep/data/reports-spec.yml index 99db5e6d..ba31269b 100644 --- a/petprep/data/reports-spec.yml +++ b/petprep/data/reports-spec.yml @@ -128,7 +128,7 @@ sections: subtitle: PET Summary and Carpet Plot - bids: {datatype: figures, desc: hmc, suffix: pet} - caption: Animated frames before and after PET head motion correction (click image to restart). + caption: Animated frames before and after PET head motion correction (keep cursor over image to restart). static: false subtitle: Motion correction From 5c8b871a805724b4bf125ce1c76c6566671a811a Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 13:58:50 +0100 Subject: [PATCH 17/40] FIX: style --- petprep/interfaces/motion.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 076720dc..5aa4ddff 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -11,11 +11,17 @@ import nibabel as nib import numpy as np -from nipype.interfaces.base import BaseInterfaceInputSpec, File, SimpleInterface, TraitedSpec, traits +from imageio import v2 as imageio from nilearn import image from nilearn.plotting import plot_epi from nilearn.plotting.find_cuts import find_xyz_cut_coords -from imageio import v2 as imageio +from nipype.interfaces.base import ( + BaseInterfaceInputSpec, + File, + SimpleInterface, + TraitedSpec, + traits +) class MotionPlotInputSpec(BaseInterfaceInputSpec): @@ -193,7 +199,7 @@ def _build_animation( " const frames = svg.querySelectorAll('.frame');", f' const cycleMs = {total_duration * 1000:.0f};', ' let restartTimer = null;', - " const restart = () => {", + ' const restart = () => {', ' frames.forEach((frame) => {', " frame.style.animation = 'none';", ' // Force reflow to restart the CSS animation', From 06c6391079fd691577eec7816cfc15b2bacbaec5 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 14:00:18 +0100 Subject: [PATCH 18/40] FIX: ruff --- petprep/interfaces/motion.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 5aa4ddff..59ee8e42 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -16,11 +16,11 @@ from nilearn.plotting import plot_epi from nilearn.plotting.find_cuts import find_xyz_cut_coords from nipype.interfaces.base import ( - BaseInterfaceInputSpec, - File, - SimpleInterface, - TraitedSpec, - traits + BaseInterfaceInputSpec, + File, + SimpleInterface, + TraitedSpec, + traits, ) From 70709130f30b0d897627683f3edf4de3c47e0ab7 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 14:04:05 +0100 Subject: [PATCH 19/40] FIX: style --- petprep/interfaces/motion.py | 12 ++++++++---- petprep/workflows/pet/base.py | 20 +++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 59ee8e42..3b4068aa 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -126,7 +126,7 @@ def _build_animation( image.index_img(self.inputs.original_pet, idx), colorbar=True, display_mode='ortho', - title=f'Before motion correction | Frame {idx+1}', + title=f'Before motion correction | Frame {idx + 1}', cut_coords=cut_coords_orig, vmin=vmin_orig, vmax=vmax_orig, @@ -136,7 +136,7 @@ def _build_animation( image.index_img(self.inputs.corrected_pet, idx), colorbar=True, display_mode='ortho', - title=f'After motion correction | Frame {idx+1}', + title=f'After motion correction | Frame {idx + 1}', cut_coords=cut_coords_corr, vmin=vmin_corr, vmax=vmax_corr, @@ -149,10 +149,14 @@ def _build_animation( max_height = max(orig_arr.shape[0], corr_arr.shape[0]) if orig_arr.shape[0] < max_height: pad = max_height - orig_arr.shape[0] - orig_arr = np.pad(orig_arr, ((0, pad), (0, 0), (0, 0)), mode='constant', constant_values=255) + orig_arr = np.pad( + orig_arr, ((0, pad), (0, 0), (0, 0)), mode='constant', constant_values=255 + ) if corr_arr.shape[0] < max_height: pad = max_height - corr_arr.shape[0] - corr_arr = np.pad(corr_arr, ((0, pad), (0, 0), (0, 0)), mode='constant', constant_values=255) + corr_arr = np.pad( + corr_arr, ((0, pad), (0, 0), (0, 0)), mode='constant', constant_values=255 + ) combined = np.concatenate([orig_arr, corr_arr], axis=1) frames.append(combined.astype(orig_arr.dtype, copy=False)) diff --git a/petprep/workflows/pet/base.py b/petprep/workflows/pet/base.py index 9280f0e9..fc27d052 100644 --- a/petprep/workflows/pet/base.py +++ b/petprep/workflows/pet/base.py @@ -289,13 +289,19 @@ def init_pet_wf( ) ds_motion_report.inputs.source_file = pet_file - workflow.connect([ - (pet_native_wf, motion_report, [ - ('outputnode.pet_minimal', 'original_pet'), - ('outputnode.pet_native', 'corrected_pet'), - ]), - (motion_report, ds_motion_report, [('svg_file', 'in_file')]), - ]) + workflow.connect( + [ + ( + pet_native_wf, + motion_report, + [ + ('outputnode.pet_minimal', 'original_pet'), + ('outputnode.pet_native', 'corrected_pet'), + ] + ), + (motion_report, ds_motion_report, [('svg_file', 'in_file')]), + ] + ) petref_out = bool(nonstd_spaces.intersection(('pet', 'run', 'petref'))) petref_out &= config.workflow.level == 'full' From 0dc9fc1084bea4fbb02f7059d873717778ac0a71 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 14:05:41 +0100 Subject: [PATCH 20/40] FIX: style --- petprep/interfaces/motion.py | 4 ++-- petprep/workflows/pet/base.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 3b4068aa..137d4071 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -151,12 +151,12 @@ def _build_animation( pad = max_height - orig_arr.shape[0] orig_arr = np.pad( orig_arr, ((0, pad), (0, 0), (0, 0)), mode='constant', constant_values=255 - ) + ) if corr_arr.shape[0] < max_height: pad = max_height - corr_arr.shape[0] corr_arr = np.pad( corr_arr, ((0, pad), (0, 0), (0, 0)), mode='constant', constant_values=255 - ) + ) combined = np.concatenate([orig_arr, corr_arr], axis=1) frames.append(combined.astype(orig_arr.dtype, copy=False)) diff --git a/petprep/workflows/pet/base.py b/petprep/workflows/pet/base.py index fc27d052..483c4e5a 100644 --- a/petprep/workflows/pet/base.py +++ b/petprep/workflows/pet/base.py @@ -297,7 +297,7 @@ def init_pet_wf( [ ('outputnode.pet_minimal', 'original_pet'), ('outputnode.pet_native', 'corrected_pet'), - ] + ], ), (motion_report, ds_motion_report, [('svg_file', 'in_file')]), ] From b934b44dce2b6923057b5e69a9b6653048a5981d Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 16:02:43 +0100 Subject: [PATCH 21/40] FIX: Fix motion report for 3D data --- petprep/workflows/pet/base.py | 58 +++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/petprep/workflows/pet/base.py b/petprep/workflows/pet/base.py index 483c4e5a..9166323c 100644 --- a/petprep/workflows/pet/base.py +++ b/petprep/workflows/pet/base.py @@ -275,33 +275,37 @@ def init_pet_wf( ]), ]) # fmt:skip - motion_report = pe.Node(MotionPlot(), name='motion_report', mem_gb=0.1) - ds_motion_report = pe.Node( - DerivativesDataSink( - base_directory=petprep_dir, - desc='hmc', - datatype='figures', - suffix='pet', - ), - name='ds_report_motion', - run_without_submitting=True, - mem_gb=config.DEFAULT_MEMORY_MIN_GB, - ) - ds_motion_report.inputs.source_file = pet_file - - workflow.connect( - [ - ( - pet_native_wf, - motion_report, - [ - ('outputnode.pet_minimal', 'original_pet'), - ('outputnode.pet_native', 'corrected_pet'), - ], - ), - (motion_report, ds_motion_report, [('svg_file', 'in_file')]), - ] - ) + if nvols > 1: + motion_report = pe.Node(MotionPlot(), name='motion_report', mem_gb=0.1) + ds_motion_report = pe.Node( + DerivativesDataSink( + base_directory=petprep_dir, + desc='hmc', + datatype='figures', + suffix='pet', + name='ds_report_motion', + run_without_submitting=True, + mem_gb=config.DEFAULT_MEMORY_MIN_GB, + ) + ds_motion_report.inputs.source_file = pet_file + + workflow.connect( + [ + ( + pet_native_wf, + motion_report, + [ + ('outputnode.pet_minimal', 'original_pet'), + ('outputnode.pet_native', 'corrected_pet'), + ], + ), + (motion_report, ds_motion_report, [('svg_file', 'in_file')]), + ] + ) + else: + config.loggers.workflow.warning( + f'Motion report will be skipped - series has only {nvols} frame(s)' + ) petref_out = bool(nonstd_spaces.intersection(('pet', 'run', 'petref'))) petref_out &= config.workflow.level == 'full' From 86b5a973060484a9a3cdcbe08c62e79159e5109b Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Mon, 17 Nov 2025 16:08:17 +0100 Subject: [PATCH 22/40] FIX: style --- petprep/workflows/pet/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/petprep/workflows/pet/base.py b/petprep/workflows/pet/base.py index 9166323c..5eb946a9 100644 --- a/petprep/workflows/pet/base.py +++ b/petprep/workflows/pet/base.py @@ -283,7 +283,8 @@ def init_pet_wf( desc='hmc', datatype='figures', suffix='pet', - name='ds_report_motion', + ), + name='ds_report_motion', run_without_submitting=True, mem_gb=config.DEFAULT_MEMORY_MIN_GB, ) From f7837b0a667df89a209cc0afc183cbe2b8cde723 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Tue, 18 Nov 2025 15:46:56 +0100 Subject: [PATCH 23/40] ENH: add test for motion visualization --- petprep/interfaces/tests/test_motion.py | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 petprep/interfaces/tests/test_motion.py diff --git a/petprep/interfaces/tests/test_motion.py b/petprep/interfaces/tests/test_motion.py new file mode 100644 index 00000000..f38e2107 --- /dev/null +++ b/petprep/interfaces/tests/test_motion.py @@ -0,0 +1,53 @@ +import numpy as np +import nibabel as nb +from pathlib import Path + +from petprep.interfaces.motion import MotionPlot + + +def _write_image(path: Path, shape): + data = np.linspace(0, 1, int(np.prod(shape)), dtype=float).reshape(shape) + img = nb.Nifti1Image(data, np.eye(4)) + img.to_filename(path) + return path + + +def test_motion_plot_builds_svg(tmp_path, monkeypatch): + orig_path = _write_image(tmp_path / "orig.nii.gz", (4, 4, 4, 2)) + corr_path = _write_image(tmp_path / "corr.nii.gz", (4, 4, 4, 2)) + + call_count = {"count": 0} + + def fake_plot_epi(img, **kwargs): + height = 10 if call_count["count"] % 2 == 0 else 6 + array = np.ones((height, 8, 3), dtype=np.uint8) * 255 + from imageio import v2 as imageio + + imageio.imwrite(kwargs["output_file"], array) + call_count["count"] += 1 + + monkeypatch.setattr("petprep.interfaces.motion.plot_epi", fake_plot_epi) + + motion = MotionPlot() + motion.inputs.original_pet = str(orig_path) + motion.inputs.corrected_pet = str(corr_path) + motion.inputs.duration = 0.05 + + result = motion.run(cwd=tmp_path) + svg_file = Path(result.outputs.svg_file) + + content = svg_file.read_text() + assert "frame-0" in content + assert "animation-delay: 0.05s" in content + assert call_count["count"] == 4 + + +def test_compute_display_params_handles_single_frame(tmp_path): + img_path = _write_image(tmp_path / "single.nii.gz", (5, 5, 5)) + + motion = MotionPlot() + mid_img, cut_coords, vmin, vmax = motion._compute_display_params(str(img_path)) + + assert mid_img.ndim == 3 + assert len(cut_coords) == 3 + assert vmin <= vmax From 8ce30e9962bf1dc9aa5da91d387e203c01a403e7 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Tue, 18 Nov 2025 15:49:07 +0100 Subject: [PATCH 24/40] FIX: style --- petprep/interfaces/tests/test_motion.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/petprep/interfaces/tests/test_motion.py b/petprep/interfaces/tests/test_motion.py index f38e2107..f45a640d 100644 --- a/petprep/interfaces/tests/test_motion.py +++ b/petprep/interfaces/tests/test_motion.py @@ -13,20 +13,20 @@ def _write_image(path: Path, shape): def test_motion_plot_builds_svg(tmp_path, monkeypatch): - orig_path = _write_image(tmp_path / "orig.nii.gz", (4, 4, 4, 2)) - corr_path = _write_image(tmp_path / "corr.nii.gz", (4, 4, 4, 2)) + orig_path = _write_image(tmp_path / 'orig.nii.gz', (4, 4, 4, 2)) + corr_path = _write_image(tmp_path / 'corr.nii.gz', (4, 4, 4, 2)) - call_count = {"count": 0} + call_count = {'count': 0} def fake_plot_epi(img, **kwargs): - height = 10 if call_count["count"] % 2 == 0 else 6 + height = 10 if call_count['count'] % 2 == 0 else 6 array = np.ones((height, 8, 3), dtype=np.uint8) * 255 from imageio import v2 as imageio - imageio.imwrite(kwargs["output_file"], array) - call_count["count"] += 1 + imageio.imwrite(kwargs['output_file'], array) + call_count['count'] += 1 - monkeypatch.setattr("petprep.interfaces.motion.plot_epi", fake_plot_epi) + monkeypatch.setattr('petprep.interfaces.motion.plot_epi', fake_plot_epi) motion = MotionPlot() motion.inputs.original_pet = str(orig_path) @@ -37,13 +37,13 @@ def fake_plot_epi(img, **kwargs): svg_file = Path(result.outputs.svg_file) content = svg_file.read_text() - assert "frame-0" in content - assert "animation-delay: 0.05s" in content - assert call_count["count"] == 4 + assert 'frame-0' in content + assert 'animation-delay: 0.05s' in content + assert call_count['count'] == 4 def test_compute_display_params_handles_single_frame(tmp_path): - img_path = _write_image(tmp_path / "single.nii.gz", (5, 5, 5)) + img_path = _write_image(tmp_path / 'single.nii.gz', (5, 5, 5)) motion = MotionPlot() mid_img, cut_coords, vmin, vmax = motion._compute_display_params(str(img_path)) From de430d1bc9c821dfc615836ff20aa52c4f541217 Mon Sep 17 00:00:00 2001 From: Martin Norgaard Date: Tue, 18 Nov 2025 15:50:56 +0100 Subject: [PATCH 25/40] Add framewise displacement trace to motion report --- petprep/data/reports-spec.yml | 2 +- petprep/interfaces/motion.py | 118 +++++++++++++++++++++++++++++++++- petprep/workflows/pet/base.py | 8 +++ 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/petprep/data/reports-spec.yml b/petprep/data/reports-spec.yml index ba31269b..a47ab80d 100644 --- a/petprep/data/reports-spec.yml +++ b/petprep/data/reports-spec.yml @@ -128,7 +128,7 @@ sections: subtitle: PET Summary and Carpet Plot - bids: {datatype: figures, desc: hmc, suffix: pet} - caption: Animated frames before and after PET head motion correction (keep cursor over image to restart). + caption: Animated frames before and after PET head motion correction with synchronized framewise displacement trace (keep cursor over image to restart). static: false subtitle: Motion correction diff --git a/petprep/interfaces/motion.py b/petprep/interfaces/motion.py index 137d4071..2c30374e 100644 --- a/petprep/interfaces/motion.py +++ b/petprep/interfaces/motion.py @@ -11,6 +11,7 @@ import nibabel as nib import numpy as np +import pandas as pd from imageio import v2 as imageio from nilearn import image from nilearn.plotting import plot_epi @@ -18,6 +19,7 @@ from nipype.interfaces.base import ( BaseInterfaceInputSpec, File, + isdefined, SimpleInterface, TraitedSpec, traits, @@ -38,6 +40,7 @@ class MotionPlotInputSpec(BaseInterfaceInputSpec): 'transforms to the original data in native PET space' ), ) + fd_file = File(exists=True, desc='Confounds file containing framewise displacement') duration = traits.Float(0.2, usedefault=True, desc='Frame duration for the GIF (seconds)') @@ -69,6 +72,10 @@ def _run_interface(self, runtime): ) _, _, vmin_corr, vmax_corr = self._compute_display_params(self.inputs.corrected_pet) + fd_values = None + if isdefined(self.inputs.fd_file): + fd_values = self._load_framewise_displacement(self.inputs.fd_file) + svg_file = self._build_animation( output_path=svg_file, cut_coords_orig=cut_coords_orig, @@ -77,6 +84,7 @@ def _run_interface(self, runtime): vmax_orig=vmax_orig, vmin_corr=vmin_corr, vmax_corr=vmax_corr, + fd_values=fd_values, ) self._results['svg_file'] = str(svg_file) @@ -97,6 +105,21 @@ def _compute_display_params(self, in_file: str): return mid_img, cut_coords, vmin, vmax + def _load_framewise_displacement(self, fd_file: str) -> np.ndarray: + framewise_disp = pd.read_csv(fd_file, sep='\t') + if 'framewise_displacement' in framewise_disp: + fd_values = framewise_disp['framewise_displacement'] + elif 'FD' in framewise_disp: + fd_values = framewise_disp['FD'] + else: + available = ', '.join(framewise_disp.columns) + raise ValueError( + 'Could not find framewise displacement column in confounds file ' + f'(available columns: {available})' + ) + + return np.asarray(fd_values.fillna(0.0), dtype=float) + def _build_animation( self, *, @@ -107,6 +130,7 @@ def _build_animation( vmax_orig: float, vmin_corr: float, vmax_corr: float, + fd_values: np.ndarray | None, ) -> Path: orig_img = nib.load(self.inputs.original_pet) corr_img = nib.load(self.inputs.corrected_pet) @@ -116,6 +140,10 @@ def _build_animation( corr_img.shape[-1] if corr_img.ndim > 3 else 1, ) + if fd_values is not None: + fd_values = np.asarray(fd_values[:n_frames], dtype=float) + n_frames = min(n_frames, len(fd_values)) + with TemporaryDirectory() as tmpdir: frames = [] for idx in range(n_frames): @@ -162,7 +190,9 @@ def _build_animation( frames.append(combined.astype(orig_arr.dtype, copy=False)) width = int(frames[0].shape[1]) - height = int(frames[0].shape[0]) + frame_height = int(frames[0].shape[0]) + fd_height = 220 if fd_values is not None else 0 + height = frame_height + fd_height total_duration = self.inputs.duration * n_frames svg_parts = [ @@ -176,6 +206,11 @@ def _build_animation( '}' ), '.playing .frame {animation-play-state: running;}', + '.fd-line {fill: none; stroke: #2c7be5; stroke-width: 2;}', + '.fd-axis {stroke: #333; stroke-width: 1;}', + '.fd-point {fill: #2c7be5; stroke: white; stroke-width: 1;}', + '#fd-marker {fill: #d7263d; stroke: white; stroke-width: 2;}', + '#fd-value {font: 14px sans-serif; fill: #1a1a1a;}', '@keyframes framefade {0%, 80% {opacity: 1;} 100% {opacity: 0;}}', ] @@ -191,18 +226,85 @@ def _build_animation( data_uri = b64encode(buffer.getvalue()).decode('ascii') svg_parts.append( f'' ) + if fd_values is not None: + fd_padding = 45 + fd_chart_height = fd_height + fd_x_start = fd_padding + fd_x_end = width - fd_padding + fd_axis_y = frame_height + fd_chart_height - fd_padding + fd_axis_y_top = frame_height + fd_padding + fd_y_range = fd_axis_y - fd_axis_y_top + fd_max = float(np.nanmax(fd_values)) if np.any(fd_values) else 0.0 + if fd_max <= 0: + fd_max = 1.0 + + x_scale = (fd_x_end - fd_x_start) / max(n_frames - 1, 1) + points = [] + point_elems = [] + for idx, value in enumerate(fd_values): + x_coord = fd_x_start + x_scale * idx + y_coord = fd_axis_y - (value / fd_max) * fd_y_range + points.append(f'{x_coord:.2f},{y_coord:.2f}') + point_elems.append( + f'' + ) + + svg_parts.extend( + [ + f'', + f'', + f'', + f'', + *point_elems, + f'', + f'', + f'' + 'Framewise displacement (mm)', + f'' + 'Frames', + '', + ] + ) + svg_parts.extend( [ '